diff --git a/.agent/workspace/2026-02-23T12-00-00_release-readiness-code-review.md b/.agent/workspace/2026-02-23T12-00-00_release-readiness-code-review.md new file mode 100644 index 0000000..babd0b1 --- /dev/null +++ b/.agent/workspace/2026-02-23T12-00-00_release-readiness-code-review.md @@ -0,0 +1,82 @@ +# TimeWarp.Terminal — Release Readiness Code Review + +## Executive Summary +The core API surface is coherent, testable, and well-documented, with strong widget rendering and Unicode/ANSI handling backed by targeted tests. CI/CD automation and package metadata are in place, but the repo still signals “beta” status via versioning and upstream dependencies, and there are a few release-hardening gaps around compatibility, ANSI parsing completeness, and NuGet packaging polish. Addressing the recommendations below will improve confidence for an official release. + +## Scope +Focused review of the primary library surface (`IConsole`, `ITerminal`, `Terminal` static facade, ANSI/Unicode utilities, widgets), test doubles, and release tooling/metadata. The goal is to assess code quality and readiness for an official (non-beta) release. + +## Methodology +- Read core source files in `source/timewarp-terminal/**` +- Reviewed build/pack configuration (`Directory.Build.props`, `Directory.Packages.props`, `source/Directory.Build.props`) +- Reviewed CI/CD pipeline and dev CLI for release flow (`.github/workflows/ci-cd.yml`, `tools/dev-cli/**`) +- Sampled representative tests under `tests/**` +- Searched for TODO/FIXME/Open Questions (none found) + +## Findings + +### Strengths +1. **Clean, testable API design** + - `IConsole`/`ITerminal` separation with explicit covariance on sync methods is implemented consistently and aligns with the documented inheritance pattern. This enables fluent chaining while preserving interface segregation. See `iconsole.cs`, `iterminal.cs`, and explicit interface implementations in `timewarp-terminal.cs` and `test-terminal.cs`. + - The `Terminal` static facade mirrors `System.Console` and allows test substitution via `Terminal.Instance`, which is a pragmatic migration path and simplifies user adoption (`terminal-static.cs`). + +2. **Robust Unicode and ANSI handling** + - `UnicodeWidth` handles CJK, emoji, and grapheme cluster widths with explicit test coverage, which is essential for accurate widget layout (`unicode-width.cs`, `unicode-width-01-basic.cs`). + - `AnsiStringUtils` provides visibility-aware padding and wrapping, preserving ANSI state across wraps (`ansi-string-utils.cs`). Tests exist for emoji table alignment (`table-widget-06-emoji.cs`). + +3. **Widget rendering quality** + - Table, panel, and rule widgets include builder-based configuration, alignment, truncation, and coloring with clear examples and tests (`table-widget.cs`, `panel-widget.cs`, `rule-widget.cs`, `terminal-static-05-widgets.cs`). + +4. **CI/CD and release automation in place** + - The repo has a dedicated dev CLI and CI workflow that perform clean/build/test/sample verification, and a separate release pipeline with version checks and NuGet push (`tools/dev-cli/endpoints/ci-command.cs`, `.github/workflows/ci-cd.yml`). + +### Release Readiness Gaps / Risks +1. **Project version and dependencies still marked beta** + - The package version is `1.0.0-beta.7` (`source/Directory.Build.props`). + - Several internal dependencies are beta versions (`TimeWarp.Builder`, `TimeWarp.Nuru`, `TimeWarp.Amuru`, etc.) in `Directory.Packages.props`. + - This signals instability to consumers and will likely block “official” perception unless explicitly justified. + +2. **Limited target framework compatibility** + - The library targets `net10.0` only (`Directory.Build.props`). If you want broader adoption at release time, consider multi-targeting LTS versions (e.g., `net8.0`) or documenting the strict requirement. + +3. **ANSI parsing scope may be too narrow for general usage** + - `AnsiStringUtils` only strips SGR codes and OSC 8 hyperlinks. Other common ANSI control sequences (cursor moves, erase line, etc.) are not handled. If users emit non-SGR ANSI sequences, width calculations and wrapping may be incorrect (`ansi-string-utils.cs`). + +4. **Truncation drops ANSI styling** + - `Table.TruncateWithEllipsis` strips ANSI codes and returns plain text, losing styling in truncated cells (`table-widget.cs`). This is likely acceptable, but should be documented or adjusted to preserve styling where feasible. + +5. **NuGet package polish** + - The README is included via `None Include` but there is no explicit `PackageReadmeFile` metadata in the project file (`timewarp-terminal.csproj`). NuGet now uses `PackageReadmeFile` to render docs in gallery and clients. Consider adding it alongside other metadata. + +### Code Quality Observations +1. **Defensive I/O handling is thoughtful** + - `TimeWarpTerminal` gracefully handles `IOException` for redirected output; this is user-friendly (`timewarp-terminal.cs`). +2. **Test doubles are mature** + - `TestTerminal` and `TestConsole` cover key scenarios and helper APIs (output capture, key queues, line input) and align with the interface contracts (`test-terminal.cs`, `test-console.cs`). +3. **Build and analyzer posture is strict** + - Warnings as errors and analyzer enforcement are enabled; AOT/trim warnings are suppressed in `Directory.Build.props` (intentional, but a release audit should revisit them). + +## Recommendations +1. **Finalize release versioning** + - Move `source/Directory.Build.props` to a stable version (e.g., `1.0.0`) and ensure the release pipeline/CI validates no `-beta` suffix for official release tags. + - Evaluate upstream dependency stability; either upgrade to stable versions or explicitly document beta dependencies in the README. + +2. **Consider multi-targeting or document strict requirements** + - If you intend broad adoption, multi-target `net8.0`/`net9.0` alongside `net10.0`. If not, clearly document the framework requirement and rationale. + +3. **Expand ANSI parsing or document constraints** + - Either expand `AnsiStringUtils` to recognize additional ANSI control sequences or document that width/line calculations assume SGR + OSC 8 only. + +4. **Preserve or document ANSI styling on truncation** + - If style loss on truncation is acceptable, document it in table API docs. Otherwise, consider retaining ANSI prefix/suffix where possible for truncated content. + +5. **NuGet package metadata polish** + - Add `PackageReadmeFile` (and optionally `PackageIcon`) for a more professional NuGet experience and to avoid support questions. + +## References +- README: `README.md` +- Core interfaces and implementations: `source/timewarp-terminal/iconsole.cs`, `source/timewarp-terminal/iterminal.cs`, `source/timewarp-terminal/timewarp-terminal.cs`, `source/timewarp-terminal/terminal-static.cs` +- Widgets and utilities: `source/timewarp-terminal/widgets/table-widget.cs`, `panel-widget.cs`, `rule-widget.cs`, `ansi-string-utils.cs`, `unicode-width.cs` +- Tests: `tests/terminal-static-05-widgets.cs`, `tests/table-widget-06-emoji.cs`, `tests/unicode-width-01-basic.cs` +- Build & packaging: `Directory.Build.props`, `Directory.Packages.props`, `source/Directory.Build.props`, `source/timewarp-terminal/timewarp-terminal.csproj` +- CI/CD: `.github/workflows/ci-cd.yml`, `tools/dev-cli/endpoints/ci-command.cs` diff --git a/.gitignore b/.gitignore index 20c0dc1..eb6816d 100644 --- a/.gitignore +++ b/.gitignore @@ -417,3 +417,6 @@ FodyWeavers.xsd *.msm *.msp tools/dev-cli/generated/ + +# Dev CLI AOT binary +bin/ diff --git a/kanban/to-do/005-convert-dev-cli-to-runfile-structure.md b/kanban/archived/005-convert-dev-cli-to-runfile-structure.md similarity index 85% rename from kanban/to-do/005-convert-dev-cli-to-runfile-structure.md rename to kanban/archived/005-convert-dev-cli-to-runfile-structure.md index 06376f6..b2a6f7a 100644 --- a/kanban/to-do/005-convert-dev-cli-to-runfile-structure.md +++ b/kanban/archived/005-convert-dev-cli-to-runfile-structure.md @@ -61,3 +61,14 @@ dotnet tools/dev-cli/dev.cs ci --mode pr dotnet runfiles/publish-dev.cs ./dev --help ``` + +## Archived Reason + +**Obsolete** - The dev-cli was already converted to runfile structure before this task was tracked: + +- `tools/dev-cli/dev.cs` exists (runfile entry point) +- `tools/dev-cli/Directory.Build.props` contains configuration +- No `.csproj` file present +- `endpoints/` directory contains command handlers + +The conversion was completed in a previous session but the task file was not updated at that time. diff --git a/kanban/done/011-update-testterminalcontext-to-integrate-with-terminalinstance-property.md b/kanban/done/011-update-testterminalcontext-to-integrate-with-terminalinstance-property.md new file mode 100644 index 0000000..473b1f3 --- /dev/null +++ b/kanban/done/011-update-testterminalcontext-to-integrate-with-terminalinstance-property.md @@ -0,0 +1,107 @@ +# Update TestTerminalContext to integrate with Terminal.Instance property + +## Description + +Update the existing `TestTerminalContext` to provide seamless integration with the new `Terminal.Instance` static property. This enables users to use the ambient test context pattern while also having access to the static `Terminal` class methods in tests. + +## Checklist + +- [x] Add `Terminal` property to `TestTerminalContext` that returns `TestTerminal` +- [x] Modify `TestTerminalContext.Resolve(ITerminal? terminal)` to check `Terminal.Instance` first +- [x] Update `TestTerminalContext` to set `Terminal.Instance = Context.Terminal` on initialization +- [x] Restore previous `Terminal.Instance` on disposal (for proper test isolation) +- [x] Update XML documentation with test usage patterns +- [x] Write integration tests demonstrating the test pattern +- [x] Update existing tests to use the new static API + +## Notes + +### Current Status (2026-03-19) + +**What exists:** +- `TestTerminalContext` class in `source/timewarp-terminal/test-terminal-context.cs` + - Uses `AsyncLocal` for test isolation + - Has `Current` property and `Resolve()` methods + - **NOT integrated with `Terminal.Instance`** +- `Terminal` static class in `source/timewarp-terminal/terminal-static.cs` + - Has `Instance` property that can be set to any `ITerminal` + - **Does NOT check `TestTerminalContext.Current`** + +**Current test pattern (working):** +```csharp +ITerminal original = Terminal.Instance; +using TestTerminal testTerminal = new(); +try +{ + Terminal.Instance = testTerminal; + // test code +} +finally +{ + Terminal.Instance = original; +} +``` + +**Proposed pattern (cleaner):** +```csharp +using TestTerminal terminal = new(); +TestTerminalContext.Current = terminal; +// Terminal.Instance automatically set +// Terminal.WriteLine routes to testTerminal +// Automatic restoration on dispose +``` + +### Why This Task + +The integration would provide: +1. **Cleaner test code** - No manual try/finally blocks +2. **Automatic restoration** - `Terminal.Instance` restored when context disposed +3. **AsyncLocal isolation** - Each async context gets its own terminal +4. **Backward compatibility** - Existing pattern still works + +### Files to Modify + +- `source/timewarp-terminal/test-terminal-context.cs` - Add integration logic +- `tests/*.cs` - Optionally update to use new pattern (low priority) + +## Results + +Successfully integrated `TestTerminalContext` with `Terminal.Instance` for automatic synchronization. + +### Files Changed +- `source/timewarp-terminal/test-terminal-context.cs` - Added Terminal.Instance sync logic +- `source/timewarp-terminal/test-terminal.cs` - Dispose clears context if current +- `tests/test-terminal-context-01-integration.cs` - New integration tests + +### Implementation Details + +**TestTerminalContext.Current setter:** +- When set to non-null: saves previous `Terminal.Instance`, sets new instance +- When set to null: restores previous `Terminal.Instance` + +**TestTerminal.Dispose:** +- Automatically clears `TestTerminalContext.Current` if this is the current terminal +- Triggers restoration of previous `Terminal.Instance` + +**New property:** +- `TestTerminalContext.Terminal` - Direct access to current test terminal (throws if not set) + +### New Test Pattern + +```csharp +using TestTerminal terminal = new(); +TestTerminalContext.Current = terminal; + +// Terminal.Instance automatically set +Terminal.WriteLine("Hello"); // Routes to test terminal + +// Automatic restoration on dispose +``` + +### Verification +- 6 new integration tests pass +- Existing tests continue to pass +- Build succeeds with no warnings + +### Commits +- `feat: integrate TestTerminalContext with Terminal.Instance` (0ecece0) diff --git a/kanban/done/015-update-dev-cli-to-support-self-install-and-bin-prebuilt-binary.md b/kanban/done/015-update-dev-cli-to-support-self-install-and-bin-prebuilt-binary.md new file mode 100644 index 0000000..75d8deb --- /dev/null +++ b/kanban/done/015-update-dev-cli-to-support-self-install-and-bin-prebuilt-binary.md @@ -0,0 +1,39 @@ +# Update dev-cli to support self-install and bin prebuilt binary + +## Description + +Follow the Nuru repo pattern for dev-cli self-install and prebuilt binary support. Currently the dev-cli can only be invoked via `dotnet run --project tools/dev-cli` which is slow and cumbersome. + +## Checklist + +- [x] Fix pre-existing MissingMethodException with TimeWarp.Nuru dependency +- [x] Add `dev self-install` command to publish AOT binary to `bin/` +- [x] Add `bin/` to `.gitignore` +- [x] Set up PATH so `dev test` works from repo root (via .envrc) +- [x] Verify `dev test`, `dev build`, `dev workflow` all work from `bin/dev` + +## Notes + +### Implementation Details + +- `dev self-install` command: `tools/dev-cli/endpoints/self-install.cs` +- AOT binary location: `bin/dev` (7.3 MB) +- Debug symbols: `bin/dev.dbg` (13 MB) +- PATH setup: `.envrc` with `export PATH="$PWD/bin:$PATH"` + +### Verification + +```bash +./bin/dev --help # Works +./bin/dev test # Works +./bin/dev build # Works +./bin/dev workflow # Works +``` + +## Results + +All items completed. The dev-cli now supports: +- AOT compilation via `dev self-install` +- Fast execution from `bin/dev` +- PATH integration via `.envrc` (direnv) +- Proper gitignore for generated binaries diff --git a/kanban/done/018-add-cancelkeypress-event-to-iterminal-for-ctrlc-handling.md b/kanban/done/018-add-cancelkeypress-event-to-iterminal-for-ctrlc-handling.md new file mode 100644 index 0000000..5770e4c --- /dev/null +++ b/kanban/done/018-add-cancelkeypress-event-to-iterminal-for-ctrlc-handling.md @@ -0,0 +1,106 @@ +# Add CancelKeyPress event to ITerminal for Ctrl+C handling + +**GitHub Issue:** https://github.com/TimeWarpEngineering/timewarp-terminal/issues/18 + +## Description + +Add a `CancelKeyPress` event to `ITerminal` interface to allow graceful Ctrl+C handling without using `System.Console` directly. This enables testability and removes banned API usage in consumers like TimeWarp.Nuru's REPL. + +## Problem + +The REPL in TimeWarp.Nuru needs to handle Ctrl+C gracefully. Currently, it must use `Console.CancelKeyPress` directly, which violates the banned API rule and breaks testability. + +```csharp +// Current code in repl-session.cs +Console.CancelKeyPress += OnCancelKeyPress; +Console.CancelKeyPress -= OnCancelKeyPress; +``` + +This triggers RS0030 warnings because `System.Console` is banned in favor of `ITerminal`. + +## Checklist + +- [x] Add `CancelKeyPress` event to `ITerminal` interface +- [x] Implement event in `TimeWarpConsole` (delegate to `Console.CancelKeyPress`) +- [x] Implement event in `TestConsole` (allow test simulation) +- [x] Add unit tests for event behavior +- [x] Update any relevant documentation +- [x] Verify TimeWarp.Nuru REPL can use the new event + +## Notes + +### Proposed Interface Change + +```csharp +public interface ITerminal +{ + // Existing members... + + /// + /// Occurs when the Ctrl+C key combination is pressed. + /// + event ConsoleCancelEventHandler? CancelKeyPress; +} +``` + +### Use Case + +```csharp +// In ReplSession +Terminal.CancelKeyPress += OnCancelKeyPress; + +// Cleanup +Terminal.CancelKeyPress -= OnCancelKeyPress; +``` + +### Benefits + +1. **Testability** - TestConsole can simulate Ctrl+C events +2. **Consistency** - All console interactions go through ITerminal +3. **Removes banned API usage** - REPL code no longer needs to use Console directly + +### Related + +- Discovered while fixing banned API warnings in timewarp-nuru +- Affects `ReplSession` class in timewarp-nuru + +### Files to Modify + +- `source/timewarp-terminal/ITerminal.cs` - Add event to interface +- `source/timewarp-terminal/TimeWarpConsole.cs` - Implement event +- `tests/timewarp-terminal.tests/` - Add tests for event behavior + +## Results + +Successfully added `CancelKeyPress` event to `ITerminal` interface for graceful Ctrl+C handling. + +### Files Changed +- `source/timewarp-terminal/iterminal.cs` - Added `CancelKeyPress` event to interface +- `source/timewarp-terminal/timewarp-terminal.cs` - Implemented event (delegates to `Console.CancelKeyPress`) +- `source/timewarp-terminal/test-terminal.cs` - Implemented event with `SimulateCancelKeyPress()` method for testing +- `tests/cancel-key-press-01-basic.cs` - New test file + +### Implementation Details + +**ITerminal Interface:** +```csharp +event ConsoleCancelEventHandler? CancelKeyPress; +``` + +**TimeWarpTerminal:** +- Delegates directly to `Console.CancelKeyPress` + +**TestTerminal:** +- Uses private handler field for test simulation +- `SimulateCancelKeyPress(ConsoleSpecialKey)` method uses reflection to create `ConsoleCancelEventArgs` (no public constructor) +- Supports testing Ctrl+C, Ctrl+Break, and Cancel property + +### Verification +- All new tests pass +- Existing tests continue to pass +- Build succeeds with no warnings + +### Commits +- `feat: add CancelKeyPress event to ITerminal for Ctrl+C handling` (f6323fd) + +Closes #18 diff --git a/kanban/to-do/011-update-testterminalcontext-to-integrate-with-terminalinstance-property.md b/kanban/to-do/011-update-testterminalcontext-to-integrate-with-terminalinstance-property.md deleted file mode 100644 index d0b88e5..0000000 --- a/kanban/to-do/011-update-testterminalcontext-to-integrate-with-terminalinstance-property.md +++ /dev/null @@ -1,42 +0,0 @@ -# Update TestTerminalContext to integrate with Terminal.Instance property - -## Description - -Update the existing `TestTerminalContext` to provide seamless integration with the new `Terminal.Instance` static property. This enables users to use the ambient test context pattern while also having access to the static `Terminal` class methods in tests. - -## Checklist - -- [ ] Add `Terminal` property to `TestTerminalContext` that returns `TestTerminal` -- [ ] Modify `TestTerminalContext.Resolve(ITerminal? terminal)` to check `Terminal.Instance` first -- [ ] Update `TestTerminalContext` to set `Terminal.Instance = Context.Terminal` on initialization -- [ ] Restore previous `Terminal.Instance` on disposal (for proper test isolation) -- [ ] Update XML documentation with test usage patterns -- [ ] Write integration tests demonstrating the test pattern -- [ ] Update existing tests to use the new static API - -## Notes - -Current test pattern: -```csharp -using TestTerminal terminal = new(); -TestTerminalContext.Current = terminal; -await MyApp.RunAsync(); -Assert.Contains("expected output", terminal.Output); -``` - -New test pattern with Terminal static API: -```csharp -using TestTerminal terminal = new(); -TestTerminalContext.Current = terminal; - -// Terminal static methods now route to test terminal -Terminal.WriteLine("Hello"); -Assert.Contains("Hello", terminal.Output); - -// Or directly use Terminal.Instance -Terminal.Instance = terminal; -Terminal.WriteLine("Direct"); -Assert.Contains("Direct", terminal.Output); -``` - -This task ensures backward compatibility while adding support for the new static API. diff --git a/kanban/to-do/015-update-dev-cli-to-support-self-install-and-bin-prebuilt-binary.md b/kanban/to-do/015-update-dev-cli-to-support-self-install-and-bin-prebuilt-binary.md deleted file mode 100644 index 871640c..0000000 --- a/kanban/to-do/015-update-dev-cli-to-support-self-install-and-bin-prebuilt-binary.md +++ /dev/null @@ -1,18 +0,0 @@ -# Update dev-cli to support self-install and .bin prebuilt binary - -## Description - -Follow the Nuru repo pattern for dev-cli self-install and prebuilt binary support. Currently the dev-cli can only be invoked via `dotnet run --project tools/dev-cli` which is slow and cumbersome. - -## Checklist - -- [ ] Fix pre-existing MissingMethodException with TimeWarp.Nuru dependency -- [ ] Add `dev --self-install` command to publish AOT binary to `.bin/` -- [ ] Add `.bin/` to `.gitignore` -- [ ] Set up PATH so `dev test` works from repo root (via .envrc or similar) -- [ ] Verify `dev test`, `dev build`, `dev ci` all work from `.bin/dev` - -## Notes - -- Reference: `/home/steventcramer/worktrees/github.com/TimeWarpEngineering/timewarp-nuru` dev-cli implementation -- The MissingMethodException is a pre-existing issue: `NuruCoreAppBuilder..ctor(NuruCoreApplicationOptions)` not found — likely a version mismatch with TimeWarp.Nuru NuGet diff --git a/source/timewarp-terminal/iterminal.cs b/source/timewarp-terminal/iterminal.cs index 6470d1d..e20ffbe 100644 --- a/source/timewarp-terminal/iterminal.cs +++ b/source/timewarp-terminal/iterminal.cs @@ -116,4 +116,12 @@ public interface ITerminal : IConsole /// Clears the console buffer and corresponding console window of display information. /// void Clear(); + + /// + /// Occurs when the Ctrl+C key combination is pressed. + /// + /// + /// This event allows graceful handling of Ctrl+C for interactive applications like REPLs. + /// + event ConsoleCancelEventHandler? CancelKeyPress; } diff --git a/source/timewarp-terminal/test-terminal-context.cs b/source/timewarp-terminal/test-terminal-context.cs index dbf947b..dbeed9f 100644 --- a/source/timewarp-terminal/test-terminal-context.cs +++ b/source/timewarp-terminal/test-terminal-context.cs @@ -2,7 +2,7 @@ namespace TimeWarp.Terminal; /// /// Provides an ambient context for that enables zero-configuration testing -/// of CLI applications. +/// of CLI applications with automatic synchronization. /// /// /// @@ -11,6 +11,10 @@ namespace TimeWarp.Terminal; /// running tests in parallel. /// /// +/// Use and for explicit lifecycle control, +/// or for a scoped pattern that restores automatically. +/// +/// /// Resolution order when determining which terminal to use: /// /// (if set) @@ -18,12 +22,17 @@ namespace TimeWarp.Terminal; /// (fallback) /// /// +/// /// +/// Scoped test pattern with automatic TimeWarp.Terminal.Terminal.Instance synchronization: /// /// public static async Task Should_display_greeting() /// { /// using TestTerminal terminal = new(); -/// TestTerminalContext.Current = terminal; +/// using IDisposable scope = TestTerminalContext.Use(terminal); +/// +/// // TimeWarp.Terminal.Terminal.Instance is now set to terminal +/// Terminal.WriteLine("Hello"); // Routes to test terminal /// /// await Program.Main(["greet", "World"]); /// @@ -31,38 +40,117 @@ namespace TimeWarp.Terminal; /// } /// /// -/// public static class TestTerminalContext { private static readonly AsyncLocal Context = new(); + private static readonly AsyncLocal?> SnapshotStack = new(); + + private sealed class ContextSnapshot + { + public required TestTerminal? PreviousContext { get; init; } + public required ITerminal PreviousInstance { get; init; } + } /// - /// Gets or sets the current for the async execution context. + /// Gets the current for the async execution context. /// - /// - /// - /// Setting this property to a non-null value causes all terminal resolution - /// to use the provided instead of the real terminal. - /// - /// - /// The value is scoped to the current async execution context, so parallel tests - /// each have their own isolated value. - /// - /// /// /// The for the current context, or null if not set. /// - public static TestTerminal? Current - { - get => Context.Value; - set => Context.Value = value; - } + public static TestTerminal? Current => Context.Value; /// /// Gets a value indicating whether a is set for the current context. /// public static bool HasValue => Context.Value is not null; + /// + /// Sets the current and synchronizes . + /// + /// The terminal to set as current. + public static void SetCurrent(TestTerminal terminal) + { + ArgumentNullException.ThrowIfNull(terminal); + + Stack stack = GetSnapshotStack(); + stack.Push + ( + new ContextSnapshot + { + PreviousContext = Context.Value, + PreviousInstance = TimeWarp.Terminal.Terminal.Instance + } + ); + + Context.Value = terminal; + TimeWarp.Terminal.Terminal.Instance = terminal; + } + + /// + /// Clears the current context and restores the previous . + /// + public static void ClearCurrent() + { + Stack? stack = SnapshotStack.Value; + if (stack is null || stack.Count == 0) + { + Context.Value = null; + return; + } + + ContextSnapshot snapshot = stack.Pop(); + Context.Value = snapshot.PreviousContext; + TimeWarp.Terminal.Terminal.Instance = snapshot.PreviousInstance; + + if (stack.Count == 0) + SnapshotStack.Value = null; + } + + /// + /// Creates a scoped test terminal context that is restored on dispose. + /// + /// The terminal to set for the scope. + /// An scope that restores the previous context. + public static IDisposable Use(TestTerminal terminal) + { + SetCurrent(terminal); + return new Scope(); + } + + /// + /// Gets the for the current context, or throws if not set. + /// + /// The current . + /// Thrown when no test terminal is set. + public static TestTerminal Terminal + => Context.Value ?? throw new InvalidOperationException("No TestTerminal set in current context. Call TestTerminalContext.SetCurrent or TestTerminalContext.Use first."); + + private static Stack GetSnapshotStack() + { + Stack? stack = SnapshotStack.Value; + if (stack is null) + { + stack = new Stack(); + SnapshotStack.Value = stack; + } + + return stack; + } + + private sealed class Scope : IDisposable + { + private bool Disposed; + + public void Dispose() + { + if (Disposed) + return; + + ClearCurrent(); + Disposed = true; + } + } + /// /// Resolves a terminal using the standard resolution order: /// TestTerminalContext.Current → provided terminal → fallback. diff --git a/source/timewarp-terminal/test-terminal.cs b/source/timewarp-terminal/test-terminal.cs index 2cc38ce..d7f354e 100644 --- a/source/timewarp-terminal/test-terminal.cs +++ b/source/timewarp-terminal/test-terminal.cs @@ -179,7 +179,40 @@ public void SetCursorPosition(int left, int top) /// public bool SupportsHyperlinks { get; set; } + private ConsoleCancelEventHandler? CancelKeyPressHandler; + /// + public event ConsoleCancelEventHandler? CancelKeyPress + { + add => CancelKeyPressHandler += value; + remove => CancelKeyPressHandler -= value; + } + + /// + /// Simulates a Ctrl+C key press for testing. + /// + /// The special key type (default: CtrlC). + /// + /// Uses reflection to create ConsoleCancelEventArgs since it has no public constructor. + /// + public void SimulateCancelKeyPress(ConsoleSpecialKey specialKey = ConsoleSpecialKey.ControlC) + { + if (CancelKeyPressHandler is null) + return; + + // ConsoleCancelEventArgs has no public constructor, so we use reflection + System.Reflection.ConstructorInfo? constructor = typeof(ConsoleCancelEventArgs) + .GetConstructor(System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, [typeof(ConsoleSpecialKey)]); + + if (constructor is not null) + { + ConsoleCancelEventArgs args = (ConsoleCancelEventArgs)constructor.Invoke([specialKey]); + CancelKeyPressHandler.Invoke(this, args); + } + } + + /// + public void Clear() => OutputWriter.WriteLine("[CLEAR]"); @@ -299,11 +332,20 @@ public string[] GetOutputLines() /// /// Disposes the resources used by this instance. /// + /// + /// Disposes the resources used by this instance and clears the test context if this is the current terminal. + /// public void Dispose() { if (Disposed) return; + // Clear context if this is the current terminal (restores previous Terminal.Instance) + if (TestTerminalContext.Current == this) + { + TestTerminalContext.ClearCurrent(); + } + InputReader.Dispose(); OutputWriter.Dispose(); ErrorWriter.Dispose(); diff --git a/source/timewarp-terminal/timewarp-terminal.cs b/source/timewarp-terminal/timewarp-terminal.cs index 7bf020e..de97ce9 100644 --- a/source/timewarp-terminal/timewarp-terminal.cs +++ b/source/timewarp-terminal/timewarp-terminal.cs @@ -173,6 +173,13 @@ private static bool DetectHyperlinkSupport() } /// + /// + public event ConsoleCancelEventHandler? CancelKeyPress + { + add => Console.CancelKeyPress += value; + remove => Console.CancelKeyPress -= value; + } + public void Clear() { try diff --git a/tests/cancel-key-press-01-basic.cs b/tests/cancel-key-press-01-basic.cs new file mode 100644 index 0000000..7632223 --- /dev/null +++ b/tests/cancel-key-press-01-basic.cs @@ -0,0 +1,81 @@ +#!/usr/bin/dotnet -- +#:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj + +// Tests for CancelKeyPress event on ITerminal +#pragma warning disable CA1508 // Avoid dead conditional code + +using TimeWarp.Terminal; + +// Create test terminal +using TestTerminal terminal = new(); + +bool eventRaised = false; +ConsoleSpecialKey receivedSpecialKey = ConsoleSpecialKey.ControlC; +bool receivedCancel = false; + +// Subscribe to CancelKeyPress +terminal.CancelKeyPress += Handler; + +// Simulate Ctrl+C +terminal.SimulateCancelKeyPress(ConsoleSpecialKey.ControlC); + +// Verify event was raised +if (!eventRaised) +{ + Console.WriteLine("❌ FAILED: Event was not raised"); + return; +} + +if (receivedSpecialKey != ConsoleSpecialKey.ControlC) +{ + Console.WriteLine($"❌ FAILED: Expected ControlC, got {receivedSpecialKey}"); + return; +} + +Console.WriteLine("✓ Event raised correctly"); +Console.WriteLine($"✓ SpecialKey: {receivedSpecialKey}"); + +// Test Ctrl+Break +eventRaised = false; +terminal.SimulateCancelKeyPress(ConsoleSpecialKey.ControlBreak); + +if (!eventRaised || receivedSpecialKey != ConsoleSpecialKey.ControlBreak) +{ + Console.WriteLine("❌ FAILED: Ctrl+Break test failed"); + return; +} + +Console.WriteLine("✓ Ctrl+Break works correctly"); + +// Test that Cancel property can be read +eventRaised = false; +terminal.SimulateCancelKeyPress(); + +Console.WriteLine($"✓ Cancel property accessible (initial: {receivedCancel})"); + +// Unsubscribe and verify no event raised +terminal.CancelKeyPress -= Handler; +eventRaised = false; +terminal.SimulateCancelKeyPress(); + +if (eventRaised) +{ + Console.WriteLine("❌ FAILED: Event raised after unsubscribe"); + return; +} + +Console.WriteLine("✓ Unsubscribe works correctly"); + +// Test with no handler (should not throw) +using TestTerminal terminal2 = new(); +terminal2.SimulateCancelKeyPress(); // No handler attached +Console.WriteLine("✓ No exception when no handler attached"); + +Console.WriteLine("\n🧪 All CancelKeyPress tests passed!"); + +void Handler(object? sender, ConsoleCancelEventArgs args) +{ + eventRaised = true; + receivedSpecialKey = args.SpecialKey; + receivedCancel = args.Cancel; +} diff --git a/tests/test-terminal-context-01-integration.cs b/tests/test-terminal-context-01-integration.cs new file mode 100644 index 0000000..951a2c9 --- /dev/null +++ b/tests/test-terminal-context-01-integration.cs @@ -0,0 +1,143 @@ +#!/usr/bin/dotnet -- +#:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj + +// Tests for TestTerminalContext integration with Terminal.Instance +#pragma warning disable CA1849 + +#if !JARIBU_MULTI +return await RunAllTests(); +#endif + +namespace TimeWarp.Terminal.Tests.Core.TestTerminalContextIntegration +{ + +[TestTag("TestTerminalContext")] +public class TestTerminalContextIntegrationTests +{ + [ModuleInitializer] + internal static void Register() => RegisterTests(); + + public static async Task Should_set_terminal_instance_when_context_set() + { + // Arrange + ITerminal original = TimeWarp.Terminal.Terminal.Instance; + using TestTerminal testTerminal = new(); + + // Act + TestTerminalContext.SetCurrent(testTerminal); + + // Assert + TimeWarp.Terminal.Terminal.Instance.ShouldBe(testTerminal); + + // Cleanup + TestTerminalContext.ClearCurrent(); + TimeWarp.Terminal.Terminal.Instance = original; + + await Task.CompletedTask; + } + + public static async Task Should_restore_terminal_instance_when_context_cleared() + { + // Arrange + ITerminal original = TimeWarp.Terminal.Terminal.Instance; + using TestTerminal testTerminal = new(); + TestTerminalContext.SetCurrent(testTerminal); + + // Act + TestTerminalContext.ClearCurrent(); + + // Assert + TimeWarp.Terminal.Terminal.Instance.ShouldBe(original); + + await Task.CompletedTask; + } + + public static async Task Should_restore_terminal_instance_on_dispose() + { + // Arrange + ITerminal original = TimeWarp.Terminal.Terminal.Instance; + + // Act + using (TestTerminal testTerminal = new()) + { + TestTerminalContext.SetCurrent(testTerminal); + TimeWarp.Terminal.Terminal.Instance.ShouldBe(testTerminal); + } + + // Assert - Terminal.Instance should be restored after dispose + TimeWarp.Terminal.Terminal.Instance.ShouldBe(original); + + await Task.CompletedTask; + } + + public static async Task Should_route_static_terminal_calls_to_test_terminal() + { + // Arrange + ITerminal original = TimeWarp.Terminal.Terminal.Instance; + using TestTerminal testTerminal = new(); + TestTerminalContext.SetCurrent(testTerminal); + + // Act + TimeWarp.Terminal.Terminal.WriteLine("Hello from static API"); + + // Assert + testTerminal.OutputContains("Hello from static API").ShouldBeTrue(); + + // Cleanup + TestTerminalContext.ClearCurrent(); + TimeWarp.Terminal.Terminal.Instance = original; + + await Task.CompletedTask; + } + + public static async Task Should_provide_terminal_property() + { + // Arrange + using TestTerminal testTerminal = new(); + TestTerminalContext.SetCurrent(testTerminal); + + // Act + TestTerminal retrieved = TestTerminalContext.Terminal; + + // Assert + retrieved.ShouldBe(testTerminal); + + // Cleanup + TestTerminalContext.ClearCurrent(); + + await Task.CompletedTask; + } + + public static async Task Should_throw_when_terminal_accessed_without_context() + { + // Arrange - ensure no context + TestTerminalContext.ClearCurrent(); + + // Act & Assert + Should.Throw(() => _ = TestTerminalContext.Terminal); + + await Task.CompletedTask; + } + + public static async Task Should_restore_on_use_scope_dispose() + { + // Arrange + ITerminal original = TimeWarp.Terminal.Terminal.Instance; + using TestTerminal testTerminal = new(); + + // Act + using (IDisposable scope = TestTerminalContext.Use(testTerminal)) + { + TimeWarp.Terminal.Terminal.Instance.ShouldBe(testTerminal); + TimeWarp.Terminal.Terminal.WriteLine("Scoped output"); + } + + // Assert + testTerminal.OutputContains("Scoped output").ShouldBeTrue(); + TimeWarp.Terminal.Terminal.Instance.ShouldBe(original); + + await Task.CompletedTask; + } +} + +}