2026-05-08 13:59:14 -04:00
|
|
|
|
using System.IO;
|
|
|
|
|
|
using System.Text.Json;
|
|
|
|
|
|
using System.Windows;
|
|
|
|
|
|
|
|
|
|
|
|
namespace TeamsISO.App.Services;
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Saves / restores the main window's size, position, and state across launches.
|
|
|
|
|
|
/// Stored as JSON at <c>%LOCALAPPDATA%\TeamsISO\window.json</c>. Multi-monitor
|
|
|
|
|
|
/// friendly: a saved position that no longer falls inside any working area is
|
|
|
|
|
|
/// rejected on restore so the window doesn't disappear off-screen when a monitor
|
|
|
|
|
|
/// has been disconnected.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public static class WindowStateStore
|
|
|
|
|
|
{
|
test: services — NotesService, UpdateChecker, PresetApplier, OscBridge, IsoController
Punch-list items 19–25 — covers six of the seven services + the
engine controller. TeamsLauncher fallback chain (item 21) is deferred:
it depends on Process.Start in ways that don't unit-test cleanly
without a process-launch seam that the May 2026 codebase doesn't
have yet.
Service seams added for testability (each marked internal + a
matching InternalsVisibleTo-equivalent grant via the existing
TeamsISO.App.Tests visibility):
* NotesService.DirectoryOverride — redirect %LOCALAPPDATA%\TeamsISO\Notes
* WindowStateStore.PathOverride — redirect window.json
* UpdateChecker.StateDirectoryOverride — redirect both the 24h
cooldown stamp and the no-update-check.flag
* UpdateChecker.TryParseSemVer — visibility bumped to internal
* OscBridge.DispatchAsync — visibility bumped to internal so tests
can drive route dispatch without spinning up the UDP receive loop
New test files (App.Tests):
* Services/NotesServiceTests.cs (6 cases) — header-once, timestamp
format, multi-append, whitespace trim + reject, today-path shape.
* Services/UpdateCheckerTests.cs (7 cases) — TryParseSemVer Theory
across the v?X.Y.Z(.N)(-suffix) inputs the real release stream
produces, semver ordering pin, CheckIfDueAsync short-circuit on
recent stamps (the throttle never fires HTTP — deterministic
offline), LaunchCheckEnabled round-trip via the opt-out flag.
* Services/PresetApplierTests.cs (6 cases) — the four enable/disable
state transitions, case-insensitive display-name join, partial
meeting (preset names participants not present), live participants
unnamed by the preset stay untouched.
* Services/PresetStoreCollection.cs — xUnit collection so any test
class that mutates OperatorPresetStore.PathOverride serializes
with siblings that do the same. OperatorPresetStoreTests now joins
the collection (the class comment claimed it didn't need one
because file paths were per-test-unique — true, but PathOverride
is shared static state, which is why the new PresetApplierTests
was clobbering its result on first run).
* Services/WindowStateStoreTests.cs (6 cases) — JSON round-trip
through the Snapshot record + all the bail paths (no file, too
small, too large, fully off-screen, garbage JSON). Full Window
property write coverage is deferred to branch 11 (needs STA).
* Services/OscBridgeDispatchTests.cs (5 cases) — /teamsiso/refresh-
discovery + unknown-address + /teamsiso/notes + clean bail when
the toggle/preset paths can't reach a dispatcher.
New test cases (Engine.Tests):
* Controller/IsoControllerTests.cs gains three cases —
SetRecording_TogglesEnabledAndStoresDirectory,
AddRecordingMarker_NoOpsCleanly_WhenNoActiveRecorders,
RefreshDiscovery_SetsRefreshFlagOnDiscoveryService.
Tests: 56 → 128 in App.Tests; 103 → 106 in Engine.Tests. Total
green: 234. Build clean (0 warnings, 0 errors).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:06:45 -04:00
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Test-only seam — when set, overrides the default
|
|
|
|
|
|
/// %LOCALAPPDATA%\TeamsISO\window.json path. Lets tests verify
|
|
|
|
|
|
/// the serialization round-trip without polluting the dev's
|
|
|
|
|
|
/// real placement state.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
internal static string? PathOverride { get; set; }
|
|
|
|
|
|
|
|
|
|
|
|
private static string Path => PathOverride ??
|
2026-05-08 13:59:14 -04:00
|
|
|
|
System.IO.Path.Combine(
|
|
|
|
|
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
|
|
|
|
|
"TeamsISO",
|
|
|
|
|
|
"window.json");
|
|
|
|
|
|
|
|
|
|
|
|
public sealed record Snapshot(
|
|
|
|
|
|
double Left,
|
|
|
|
|
|
double Top,
|
|
|
|
|
|
double Width,
|
|
|
|
|
|
double Height,
|
|
|
|
|
|
WindowState State);
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>Save the current window placement.</summary>
|
|
|
|
|
|
public static void Save(Window window)
|
|
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
var snap = new Snapshot(
|
|
|
|
|
|
Left: window.Left,
|
|
|
|
|
|
Top: window.Top,
|
|
|
|
|
|
Width: window.ActualWidth,
|
|
|
|
|
|
Height: window.ActualHeight,
|
|
|
|
|
|
State: window.WindowState == WindowState.Minimized ? WindowState.Normal : window.WindowState);
|
|
|
|
|
|
var dir = System.IO.Path.GetDirectoryName(Path);
|
|
|
|
|
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
|
|
|
|
|
File.WriteAllText(Path, JsonSerializer.Serialize(snap, new JsonSerializerOptions { WriteIndented = true }));
|
|
|
|
|
|
}
|
|
|
|
|
|
catch
|
|
|
|
|
|
{
|
|
|
|
|
|
// Best-effort persistence; never crash on shutdown for a UI nicety.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Apply a previously-saved placement. Clamps onto a visible work area so a
|
|
|
|
|
|
/// monitor change doesn't strand the window off-screen. Returns true if a
|
|
|
|
|
|
/// valid snapshot was applied; false if no file existed or the snapshot was
|
|
|
|
|
|
/// rejected for being entirely outside any visible work area.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
public static bool TryApply(Window window)
|
|
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
|
|
|
|
|
if (!File.Exists(Path)) return false;
|
|
|
|
|
|
var json = File.ReadAllText(Path);
|
|
|
|
|
|
var snap = JsonSerializer.Deserialize<Snapshot>(json);
|
|
|
|
|
|
if (snap is null) return false;
|
|
|
|
|
|
|
|
|
|
|
|
// Sanity-check sizes (don't restore a 0×0 or absurdly large window).
|
|
|
|
|
|
if (snap.Width < 320 || snap.Height < 240) return false;
|
|
|
|
|
|
if (snap.Width > 16000 || snap.Height > 12000) return false;
|
|
|
|
|
|
|
|
|
|
|
|
// Reject if entirely off-screen (any working area on any screen contains
|
|
|
|
|
|
// a corner). System.Windows.Forms gives us per-monitor work areas here;
|
|
|
|
|
|
// we deliberately stick with WPF's SystemParameters which only reports the
|
|
|
|
|
|
// primary, so we use a generous on-screen check rather than refusing
|
|
|
|
|
|
// multi-monitor positions.
|
|
|
|
|
|
if (!IsAnyCornerOnScreen(snap)) return false;
|
|
|
|
|
|
|
|
|
|
|
|
window.WindowStartupLocation = WindowStartupLocation.Manual;
|
|
|
|
|
|
window.Left = snap.Left;
|
|
|
|
|
|
window.Top = snap.Top;
|
|
|
|
|
|
window.Width = snap.Width;
|
|
|
|
|
|
window.Height = snap.Height;
|
|
|
|
|
|
window.WindowState = snap.State;
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
catch
|
|
|
|
|
|
{
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// Approximate "is at least one corner of the saved rect within the virtual
|
|
|
|
|
|
/// screen?" check. Uses SystemParameters.VirtualScreen* which spans every
|
|
|
|
|
|
/// monitor.
|
|
|
|
|
|
/// </summary>
|
|
|
|
|
|
private static bool IsAnyCornerOnScreen(Snapshot snap)
|
|
|
|
|
|
{
|
|
|
|
|
|
var minX = SystemParameters.VirtualScreenLeft;
|
|
|
|
|
|
var minY = SystemParameters.VirtualScreenTop;
|
|
|
|
|
|
var maxX = minX + SystemParameters.VirtualScreenWidth;
|
|
|
|
|
|
var maxY = minY + SystemParameters.VirtualScreenHeight;
|
|
|
|
|
|
|
|
|
|
|
|
var corners = new[]
|
|
|
|
|
|
{
|
|
|
|
|
|
(snap.Left, snap.Top),
|
|
|
|
|
|
(snap.Left + snap.Width, snap.Top),
|
|
|
|
|
|
(snap.Left, snap.Top + snap.Height),
|
|
|
|
|
|
(snap.Left + snap.Width, snap.Top + snap.Height),
|
|
|
|
|
|
};
|
|
|
|
|
|
foreach (var (x, y) in corners)
|
|
|
|
|
|
{
|
|
|
|
|
|
if (x >= minX && x <= maxX && y >= minY && y <= maxY)
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|