diff --git a/src/ClaudeDo.Ui/CLAUDE.md b/src/ClaudeDo.Ui/CLAUDE.md index 014f9995..00291b10 100644 --- a/src/ClaudeDo.Ui/CLAUDE.md +++ b/src/ClaudeDo.Ui/CLAUDE.md @@ -89,7 +89,7 @@ new editor boilerplate there rather than copying it a third time. - **IPrimeScheduleApi** — prime-schedule CRUD. - **UpdateCheckService** — polls releases; `LastCheckStatus`/`LatestVersion`/`CheckNowAsync` feed the shell's update banner. - **InheritanceResolver** — resolves the task → list → global override chain to `(value, source)` for the inherited badges. -- **OperationTiming** — NDJSON sink (`~/.todo-app/logs/operation-timing.ndjson`, one rolled `.1` at 4 MB) behind every hub invoke and bulk DB path; each line carries `pid` because app restarts interleave in one file. Successful calls under 25 ms are dropped, **failed/cancelled ones always land** — a 1 ms cancelled `BindAsync` is churn signal, not noise. `DetailsIsland.BindAsync:` carries the selection trigger (`TasksIslandViewModel.SelectionSource`, set via `SelectFrom` — never assign `SelectedTask` directly), because the first day of data showed ~1000 binds in 3.5 h, 91% cancelled, with an unexplained trigger. +- **OperationTiming** — NDJSON sink (`~/.todo-app/logs/operation-timing.ndjson`, one rolled `.1` at 4 MB) behind every hub invoke and bulk DB path; each line carries `pid` because app restarts interleave in one file. Successful calls under 25 ms are dropped, **failed/cancelled ones always land** — a 1 ms cancelled `BindAsync` is churn signal, not noise. `DetailsIsland.BindAsync:` carries the selection trigger (`TasksIslandViewModel.SelectionSource`, set via `SelectFrom` — never assign `SelectedTask` directly). `Shared` is a settable static singleton **on purpose** — `Ui.Tests`/`Worker.Tests` each carry a `TestSetup` module initializer that redirects it to a temp file before any test runs, because both projects construct real ViewModels (`DetailsIslandViewModel`, `TasksIslandViewModel`) that call `Shared.Record` directly; without the redirect a single test run appends 50-100 lines straight into the live app's log (a day of ~1900 `BindAsync` lines, 91-96% cancelled, turned out to be 13 such test runs plus pre-fix-binary artifacts — real user-driven binds that day: 4). - **RepoScanner**, **InstallArtifactLocator**/**InstallerLocator**/**WorkerLocator**, **ForegroundHelper** (Win32 foreground before launching a terminal), **FocusClearing**. ## Converters diff --git a/src/ClaudeDo.Ui/Services/OperationTiming.cs b/src/ClaudeDo.Ui/Services/OperationTiming.cs index 2a53af7f..5d10a480 100644 --- a/src/ClaudeDo.Ui/Services/OperationTiming.cs +++ b/src/ClaudeDo.Ui/Services/OperationTiming.cs @@ -21,7 +21,12 @@ public sealed class OperationTiming public static string DefaultPath => Path.Combine(Paths.AppDataRoot(), "logs", "operation-timing.ndjson"); - public static OperationTiming Shared { get; } = new(DefaultPath); + // Settable, not just get-only: xUnit constructs real ViewModels (DetailsIslandViewModel, + // TasksIslandViewModel, ...) that call Shared.Record directly, so without a way to redirect + // it every test run appended its churn straight into the live app's NDJSON file at the real + // AppData path — see Ui.Tests' TestSetup module initializer, which points this at a temp file + // for the whole test process before any test runs. + public static OperationTiming Shared { get; set; } = new(DefaultPath); private static readonly int ProcessId = Environment.ProcessId; diff --git a/tests/ClaudeDo.Ui.Tests/TestSetup.cs b/tests/ClaudeDo.Ui.Tests/TestSetup.cs new file mode 100644 index 00000000..79ef3e65 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/TestSetup.cs @@ -0,0 +1,20 @@ +using System.Runtime.CompilerServices; +using ClaudeDo.Ui.Services; + +namespace ClaudeDo.Ui.Tests; + +internal static class TestSetup +{ + // Runs once when this test assembly loads, before any test executes. Without it, every test + // that constructs a real DetailsIslandViewModel/TasksIslandViewModel/WorkerClient and calls a + // method instrumented with OperationTiming.Shared.Record writes straight into the live app's + // ~/.todo-app/logs/operation-timing.ndjson — a single Ui.Tests run added 50-100 cancelled + // "DetailsIsland.BindAsync:?" lines to that file, dwarfing real usage and making the churn + // look like a UI bug. + [ModuleInitializer] + internal static void RedirectOperationTimingAwayFromRealAppData() + { + var path = Path.Combine(Path.GetTempPath(), "claudedo-ui-tests", $"operation-timing-{Environment.ProcessId}.ndjson"); + OperationTiming.Shared = new OperationTiming(path); + } +} diff --git a/tests/ClaudeDo.Worker.Tests/TestSetup.cs b/tests/ClaudeDo.Worker.Tests/TestSetup.cs new file mode 100644 index 00000000..ef2daba8 --- /dev/null +++ b/tests/ClaudeDo.Worker.Tests/TestSetup.cs @@ -0,0 +1,19 @@ +using System.Runtime.CompilerServices; +using ClaudeDo.Ui.Services; + +namespace ClaudeDo.Worker.Tests; + +internal static class TestSetup +{ + // Runs once when this test assembly loads, before any test executes. UiVm/*Tests.cs construct + // real TasksIslandViewModel instances, whose LoadForListAsync/ReorderAsync/ReconcileTickAsync + // call OperationTiming.Shared.Record directly — without a redirect those writes land in the + // live app's ~/.todo-app/logs/operation-timing.ndjson (see ClaudeDo.Ui.Tests' TestSetup for + // the same trap on the Ui.Tests side). + [ModuleInitializer] + internal static void RedirectOperationTimingAwayFromRealAppData() + { + var path = Path.Combine(Path.GetTempPath(), "claudedo-worker-tests", $"operation-timing-{Environment.ProcessId}.ndjson"); + OperationTiming.Shared = new OperationTiming(path); + } +}