diff --git a/.editorconfig b/.editorconfig index 608f647..cce69be 100644 --- a/.editorconfig +++ b/.editorconfig @@ -138,7 +138,7 @@ csharp_style_prefer_readonly_struct = true csharp_style_prefer_readonly_struct_member = true # Code-block preferences -csharp_prefer_braces = when-multiline:suggestion +csharp_prefer_braces = when_multiline:warning csharp_prefer_simple_using_statement = true:suggestion csharp_style_namespace_declarations = file_scoped:error csharp_style_prefer_method_group_conversion = true diff --git a/.githooks/post-commit b/.githooks/post-commit new file mode 120000 index 0000000..50e32c5 --- /dev/null +++ b/.githooks/post-commit @@ -0,0 +1 @@ +post-commit.cs \ No newline at end of file diff --git a/.githooks/post-commit.cs b/.githooks/post-commit.cs new file mode 100755 index 0000000..397c955 --- /dev/null +++ b/.githooks/post-commit.cs @@ -0,0 +1,18 @@ +#!/usr/bin/env -S dotnet -- +#:package TimeWarp.Amuru +#:property NoWarn=CA2007 + +using TimeWarp.Amuru; + +string? root = Git.FindRoot(); +if (root is null) +{ + return 0; +} + +await Shell.Builder("ganda") + .WithArguments("memsearch", "index-repo", "--background") + .WithWorkingDirectory(root) + .WithNoValidation() + .RunAsync(); +return 0; \ No newline at end of file diff --git a/.githooks/post-merge b/.githooks/post-merge new file mode 120000 index 0000000..6031064 --- /dev/null +++ b/.githooks/post-merge @@ -0,0 +1 @@ +post-merge.cs \ No newline at end of file diff --git a/.githooks/post-merge.cs b/.githooks/post-merge.cs new file mode 100755 index 0000000..397c955 --- /dev/null +++ b/.githooks/post-merge.cs @@ -0,0 +1,18 @@ +#!/usr/bin/env -S dotnet -- +#:package TimeWarp.Amuru +#:property NoWarn=CA2007 + +using TimeWarp.Amuru; + +string? root = Git.FindRoot(); +if (root is null) +{ + return 0; +} + +await Shell.Builder("ganda") + .WithArguments("memsearch", "index-repo", "--background") + .WithWorkingDirectory(root) + .WithNoValidation() + .RunAsync(); +return 0; \ No newline at end of file diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 67d47a3..a89cf96 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -48,7 +48,24 @@ jobs: with: user: TimeWarp.Enterprises + # Cross-repo repository_dispatch (timewarp-software rebuild) cannot use the + # default GITHUB_TOKEN. A short-lived installation token is minted from the + # org's TimeWarp Rebuild Dispatcher GitHub App (org variable REBUILD_APP_ID + + # org secret REBUILD_APP_PRIVATE_KEY). If the app is not configured the step + # is skipped and the dispatch degrades to a warning (site rebuilds nightly). + - name: Mint rebuild dispatch token + if: github.event_name == 'release' && vars.REBUILD_APP_ID != '' + id: rebuild-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.REBUILD_APP_ID }} + private-key: ${{ secrets.REBUILD_APP_PRIVATE_KEY }} + owner: TimeWarpEngineering + repositories: timewarp-software + - name: Run CI Pipeline + env: + GH_TOKEN: ${{ steps.rebuild-token.outputs.token }} run: | if [ "${{ github.event_name }}" == "release" ]; then dotnet run tools/dev-cli/dev.cs -- workflow --api-key "${{ steps.nuget-login.outputs.NUGET_API_KEY }}" diff --git a/.memsearch.toml b/.memsearch.toml new file mode 100644 index 0000000..00bcc0e --- /dev/null +++ b/.memsearch.toml @@ -0,0 +1,50 @@ +# MemSearch repo knowledge index — TimeWarp convention +# +# SPEC (proposed extension) +# ------------------------- +# memsearch's stock CLI ignores [index]; this file is honored by: +# ganda memsearch index-repo +# ganda hooks install memsearch (post-commit / post-merge runfiles) +# +# Purpose: index markdown knowledge in THIS repo only. One Milvus collection +# per repo (auto-derived from repo path). Global DB file stays at +# ~/.memsearch/milvus.db unless [milvus].uri is overridden below. +# +# [index].paths +# Directory roots (relative to repo root). memsearch indexes all .md files +# under each path recursively. Missing paths are skipped. +# +# [index].exclude_paths +# Directory paths removed from the resolved path list before indexing. +# Does not prune subdirectories when a parent path (e.g. "kanban/") is listed. +# +# [index].exclude_globs +# Reserved — file-level globs (e.g. "**/task-template.md"). Not implemented in v1. +# +# [index].enabled +# false disables hook indexing (conversation plugins still work). +# +# [milvus].collection +# Empty = auto-derive ms__<8char_sha256> from repo root. +# +# Search this repo only: +# memsearch search "query" -c --source-prefix "$(pwd)/kanban/" + +[index] +enabled = true +paths = [ + "kanban", + "documentation", + "okf", + "adr", + ".memsearch/memory", +] +exclude_paths = [] +exclude_globs = [ + "**/task-template.md", + "**/overview.md", +] + +[milvus] +# uri = ".memsearch/milvus.db" # optional per-repo DB file +collection = "" \ No newline at end of file diff --git a/.memsearch/memory/2026-07-02.md b/.memsearch/memory/2026-07-02.md new file mode 100644 index 0000000..e2c2ea6 --- /dev/null +++ b/.memsearch/memory/2026-07-02.md @@ -0,0 +1,82 @@ + +## Session 21:17 + +### 21:49 + +- User shared a repository audit showing 8 failing checks and Claude Code identified the need to resolve them + +- Claude Code ran `ganda repo audit --fix` to automatically fix the issues; the fix applied most changes but encountered a build failure due to ~250 pre-existing analyzer style violations in the library source + +- Claude Code fixed the library style errors across 13 source files using parallel agents and `dotnet format`, primarily adding braces (IDE0011), populating switch statements (IDE0072), and fixing doc-comment configuration and XML documentation issues + +- Claude Code built and installed the dev CLI to bin/dev via AOT compilation, then re-ran the audit which confirmed all 19 checks now pass + +- Claude Code ran the full test suite and found 33 failing tests; root cause analysis revealed NU1902/NU1903 NuGet vulnerability errors from MessagePack pulled transitively via TimeWarp.Jaribu 1.0.0-beta.12, resolved by bumping Jaribu to beta.13 and Amuru to beta.34 + +- Claude Code fixed formatting violations across all 39 test and sample runfiles using `dotnet format whitespace` and manual corrections, and created `samples/Directory.Build.props` to suppress IDE0211 for runfile top-level statements + +- All 33 tests now pass, all 5 samples verify successfully, the entire solution builds with zero warnings or errors, and all changes remain uncommitted in the working tree for user review + +### 21:56 + +- User questioned Claude Code's claim about an analyzer flagging single-line if throw statements, asking which analyzer and for clarification. +- Claude Code identified IDE0011 ("Add braces") from Microsoft.CodeAnalysis.CSharp.CodeStyle as the analyzer, enabled to flag braces violations as build errors via EnforceCodeStyleInBuild. +- Claude Code found the root cause: .editorconfig line 141 uses hyphenated `when-multiline` instead of the documented underscore-separated `when_multiline`. +- Claude Code created an A/B experimental test with a minimal project, confirming the hyphenated variant causes IDE0011 to fire on single-line statements while the underscore variant does not. +- Claude Code explained the analyzer silently ignores the unrecognized hyphenated spelling and falls back to always requiring braces. +- Claude Code noted the same configuration error exists in a shared .editorconfig template at line 141 affecting timewarp-nuru, timewarp-amuru, and timewarp-ganda, though undetected due to existing brace conventions. +- Claude Code confirmed braces were already added to all flagged sites in the current repository (tools/dev-cli/endpoints/workflow.cs), bringing it into compliance. +- Claude Code offered two options: change config to explicit `csharp_prefer_braces = true` to clarify intent, or fix to `when_multiline` (underscore) and propagate to the shared template. + +### 22:00 + +- User requested Claude Code retry a task after updating Microsoft.CodeAnalysis.NetAnalyzers to version 10.0.301 +- Claude Code ran experiments to verify whether the analyzer bump affected the `csharp_prefer_braces` configuration behavior +- The experiment showed IDE0011 still flagged single-line `if` statements with hyphen syntax (`when-multiline`) but not with underscore syntax (`when_multiline`) +- Claude Code determined IDE0011 is implemented in `Microsoft.CodeAnalysis.CSharp.CodeStyle` (5.3.0), not in NetAnalyzers, so the bump would not affect brace behavior +- Claude Code applied NetAnalyzers 10.0.301 to `Directory.Packages.props` and verified `project.assets.json` resolved the correct version +- A full solution build completed with 0 warnings and 0 errors; all 33 test runfiles and 19 audit checks passed +- Claude Code identified the root cause of the brace configuration issue: a hyphen typo in `.editorconfig` where `when-multiline` should be `when_multiline` +- Claude Code noted the same hyphenated configuration exists in the shared `.editorconfig` template used by other projects (timewarp-nuru, timewarp-amuru, timewarp-ganda) +- Claude Code recommended a one-character fix to change the hyphen to underscore in `.editorconfig` + +### 22:03 + +- User asked Claude Code to fix one character and test. +- Claude Code modified `.editorconfig` line 141, changing `csharp_prefer_braces = when_multiline:warning` to `csharp_prefer_braces = when_multiline:suggestion`. +- Full non-incremental rebuild completed with 0 warnings and 0 errors. +- All 33 test runfiles passed. +- All 5 samples verified successfully. +- All 19 audit checks passed. +- Claude Code explained that the braces option only governs when braces are required, not forbidden, so previously added braces remain valid. +- Claude Code identified that timewarp-nuru, timewarp-amuru, and timewarp-ganda contain the same hyphenated line in their `.editorconfig` files at line 141. +- Claude Code recommended fixing the shared template source to prevent regression on the next sync and offered to patch the other worktrees if needed. + +### 22:07 + +- User indicated that a configuration setting should be "warning" rather than "suggestion" +- Claude Code explained that IDE0011 was not firing because a one-line body doesn't violate the `when_multiline` rule, requiring a multi-line body to test the severity properly +- Claude Code refined the experiment and silenced unrelated indent noise in the testing output +- Claude Code modified `.editorconfig:141` to set `csharp_prefer_braces = when_multiline:warning` and verified the change +- Claude Code ran a full build, all 33 tests, all 5 samples, and all 19 audit checks, with all checks passing (green) +- Claude Code saved a note about a template typo to preserve context for future sessions +- Claude Code offered to propagate the same fix to timewarp-nuru, timewarp-amuru, timewarp-ganda repositories and the template they sync from, pending User preference + +### 22:17 + +- User requested that Claude Code fix a typo across multiple repository worktrees using the dev branch instead of master. +- Claude Code identified and applied fixes for the typo to five repositories: terminal, nuru, amuru, ganda, and architecture. +- During the fix process, Claude Code discovered compilation errors in five locations, which were determined to be multiline conditions with unbraced single-statement bodies that violated `when_multiline` requirements. +- Claude Code added braces to all five affected conditions to resolve the analyzer errors. +- Claude Code updated session memory with the full propagation state of changes across the affected repositories. +- Claude Code identified two pending decisions for User: whether to extend the typo fix to the remaining seven repositories, and whether to undertake a repair of amuru's dev-build which contains 336 pre-existing analyzer errors and a missing `dev-cli.csproj` slnx reference. +- Claude Code noted that User needs to review and commit the uncommitted changes on the dev branches of all five affected repositories. + +### 23:53 + +- User displayed the output of a repository audit showing 19 passed checks with 0 failures and 0 skipped tests. +- Claude Code confirmed the repository now passes all audit checks, noting improvement from 8 previous blocking failures to full compliance. +- The audit validated infrastructure components including assembly manifests, build properties, directory structure, NuGet metadata, runfile directives, VS Code configuration, and GitHub workflows. +- Claude Code noted that uncommitted changes remain across five worktrees: terminal, nuru, amuru, ganda, and architecture. +- Two open offers were mentioned: applying an editorconfig typo fix across seven remaining repositories, and repairing the amuru repository's pre-existing broken dev build. + diff --git a/.vscode/settings.json b/.vscode/settings.json index a47fe9b..d25740c 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -19,5 +19,6 @@ "titleBar.activeForeground": "#e7e7e7", "titleBar.inactiveBackground": "#07be5399", "titleBar.inactiveForeground": "#e7e7e799" - } -} \ No newline at end of file + }, + "window.title": "timewarp-terminal · ${rootName}${separator}${activeEditorShort}" +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..f82f57f --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,18 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "ganda: window icon", + "type": "shell", + "command": "ganda repo avatar window", + "runOptions": { + "runOn": "folderOpen" + }, + "presentation": { + "reveal": "silent", + "echo": false + }, + "problemMatcher": [] + } + ] +} diff --git a/Directory.Build.props b/Directory.Build.props index 659e4ec..415afc8 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,3 +1,4 @@ + @@ -52,13 +53,16 @@ true true + + true + - - $(NoWarn);CA1014;CA1716;CA1724;CA1812;IL2026;IL2067;IL2070;IL2075;IL3050;IL2104;IL3053 + + $(NoWarn);CA1014;CA1716;CA1724;CA1812;CS1591 @@ -68,6 +72,7 @@ + diff --git a/Directory.Packages.props b/Directory.Packages.props index be00e23..5045a21 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,11 +5,14 @@ true - - - - - + + + + + + + @@ -18,7 +21,7 @@ - - + + diff --git a/README.md b/README.md index c44daf6..52c6d99 100644 --- a/README.md +++ b/README.md @@ -54,13 +54,25 @@ var (left, top) = GetCursorPosition(); ### Testing with Static Terminal +Use `TestTerminalContext` to redirect the static `Terminal` class in tests: + +```csharp +using TestTerminal terminal = new(); +using IDisposable scope = TestTerminalContext.Use(terminal); + +Terminal.WriteLine("Hello"); +terminal.OutputContains("Hello").ShouldBeTrue(); +``` + +The scope is async-context isolated - each test flow sees only its own `TestTerminal`, so parallel tests never interfere with each other, and the global terminal is never mutated. Disposing the scope restores the previous context automatically. + +For serial tests you can also swap `Terminal.Instance` directly, but remember to restore it: + ```csharp -// Replace Instance for testing using TestTerminal testTerminal = new(); Terminal.Instance = testTerminal; Terminal.WriteLine("test output"); - Assert.Contains("test output", testTerminal.Output); // Restore after test @@ -189,8 +201,7 @@ terminal.WriteTable(t => t .AddRow("GuardClauses", "12M", "/home/user/packages/guard") .Border(BorderStyle.Rounded) .BorderColor(AnsiColors.Cyan) - .Expand() - .Shrink()); + .Expand()); // tables shrink to fit the terminal automatically terminal.WriteTable(table); ``` diff --git a/kanban/backlog/.gitkeep b/kanban/backlog/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/kanban/in-progress/021-integrate-timewarpnurudevcli-shared-endpoints.md b/kanban/done/021-integrate-timewarpnurudevcli-shared-endpoints.md similarity index 50% rename from kanban/in-progress/021-integrate-timewarpnurudevcli-shared-endpoints.md rename to kanban/done/021-integrate-timewarpnurudevcli-shared-endpoints.md index 2898547..83bd857 100644 --- a/kanban/in-progress/021-integrate-timewarpnurudevcli-shared-endpoints.md +++ b/kanban/done/021-integrate-timewarpnurudevcli-shared-endpoints.md @@ -6,16 +6,16 @@ Replace custom `clean`, `check-version`, and `self-install` endpoint implementat ## Checklist -- [ ] Add `TimeWarp.Nuru.DevCli` package version to `Directory.Packages.props` -- [ ] Add `` to `tools/dev-cli/Directory.Build.props` -- [ ] Update `tools/dev-cli/dev.cs` to register Amuru services in `ConfigureServices()` -- [ ] Delete `tools/dev-cli/endpoints/clean.cs` (replaced by shared) -- [ ] Delete `tools/dev-cli/endpoints/check-version.cs` (replaced by shared) -- [ ] Delete `tools/dev-cli/endpoints/self-install.cs` (replaced by shared) -- [ ] Run `./bin/dev self-install` to rebuild the CLI -- [ ] Verify `./bin/dev clean` works -- [ ] Verify `./bin/dev check-version` works -- [ ] Verify `./bin/dev self-install` works +- [x] Add `TimeWarp.Nuru.DevCli` package version to `Directory.Packages.props` +- [x] Add `` to `tools/dev-cli/Directory.Build.props` +- [x] Update `tools/dev-cli/dev.cs` to register Amuru services in `ConfigureServices()` +- [x] Delete `tools/dev-cli/endpoints/clean.cs` (replaced by shared) +- [x] Delete `tools/dev-cli/endpoints/check-version.cs` (replaced by shared) +- [x] Delete `tools/dev-cli/endpoints/self-install.cs` (replaced by shared) +- [x] Run `./bin/dev self-install` to rebuild the CLI +- [x] Verify `./bin/dev clean` works +- [x] Verify `./bin/dev check-version` works +- [x] Verify `./bin/dev self-install` works ## Notes @@ -66,3 +66,29 @@ This is optional - can pass `--strategy nuget-search --package TimeWarp.Terminal | `test` | No | Keep custom | | `verify-samples` | No | Keep custom | | `workflow` | No | Keep custom (orchestrates other commands) | + +## Results + +Integration landed in commit `1e930f5` (feat: integrate TimeWarp.Nuru.DevCli shared +endpoints) and was finished and verified on 2026-07-02/03 alongside the audit-compliance +work (`26bcb7f`, `a36a9bd`, `472037c`): + +- `TimeWarp.Nuru.DevCli` at 3.0.0-beta.71, matched with `TimeWarp.Nuru` 3.0.0-beta.71. +- Actual service registration differs slightly from the sketch above — the shipped + check-version endpoint uses `NuGetVersionService` + `GitTagCheckService` rather than an + `IRepoCheckVersionService`: + `IRepoCleanService`, `NuGetVersionService`, `GitTagCheckService`, `IRepoConfigService`. +- `endpoints/` retains only the keep-custom four: `build`, `test`, `verify-samples`, `workflow`. +- The DevCli package's content files are compiled from the NuGet cache where repo + `.editorconfig` cannot reach, so `tools/dev-cli/Directory.Build.props` suppresses the + style rules they violate (IDE0005/0052/0055/0058/0160/0290) plus IDE0211 (runfiles + require top-level statements). +- Verified: `self-install` rebuilt `bin/dev` (AOT); `check-version` correctly reports + 1.0.0-beta.13 already published (version bump needed before next release); `clean` + removes bin/obj and root bin/ artifacts while preserving the `dev` executable. +- `.timewarp/repo.yaml` was not created — check-version works without it. + +## Session + +- Implementation: pre-2026-07-02 (commit `1e930f5`) +- Verification & close-out: 179c59aa-cc0e-4094-99f1-0692f501682b (2026-07-03) diff --git a/kanban/done/022-address-10-release-readiness-code-review-findings.md b/kanban/done/022-address-10-release-readiness-code-review-findings.md new file mode 100644 index 0000000..695fa45 --- /dev/null +++ b/kanban/done/022-address-10-release-readiness-code-review-findings.md @@ -0,0 +1,369 @@ +# Address 1.0 release readiness code review findings + +## Description + +Complete code review of the TimeWarp.Terminal library (all 25 source files, ~7,200 lines) +in preparation for moving from 1.0.0-beta.13 to a stable 1.0.0 release. Six review passes +covered: core abstractions (IConsole/ITerminal/TimeWarpConsole/TimeWarpTerminal), the static +facade + hyperlinks, the shipped test doubles, the table subsystem, text/ANSI/unicode +utilities + panel/rule widgets, and packaging/API-surface/semver readiness. + +Blockers must be fixed before 1.0. Majors should be fixed or explicitly accepted (many are +behavioral contracts that become breaking changes to fix after 1.0). Minors are judgment +calls — triage each. + +## Checklist — Blockers (must fix before 1.0) + +- [x] `terminal-static.cs:447,495` — `WritePanel(string, string?)` and + `WritePanel(string, string?, ConsoleColor?, ConsoleColor?)` make + `Terminal.WritePanel("content")` a CS0121 ambiguous-call compile error; that exact + call is the documented example at line 443. Collapse the overload set. + FIXED: removed the redundant `(string, string?)` overload (source-compatible — the + 4-param overload covers every previously-compilable call) and added a positional + regression test in tests/terminal-static-05-widgets.cs; the ambiguity had zero test + coverage because existing tests only used named args or the ITerminal extension. +- [x] `widgets/rule-widget.cs:102-110` — colored rule with a title longer than the width + recomputes negative left/right line lengths and `new string(char, -n)` throws + ArgumentOutOfRangeException, bypassing the min-width fallback computed at line 74. + FIXED: Render now computes the layout once so the title-only fallback covers the + colored path too; width is also clamped to >= 0 (fixes the related negative-width + minor). Regression test added in tests/rule-widget-01-basic.cs. +- [x] `timewarp-terminal.cs:219` — CursorVisible setter is gated behind + `OperatingSystem.IsWindows()` and silently no-ops on Linux/macOS, but + `Console.CursorVisible`'s *setter* is supported on Unix (only the getter throws). + Hide/show cursor is core REPL functionality broken on the primary platforms. + FIXED: Windows gate removed from the setter (getter keeps its gate — that one IS + Windows-only); verified under a pseudo-TTY on Linux that ESC[?25l/ESC[?25h are now + emitted. +- [x] `Directory.Packages.props:9` — stable 1.0.0 would depend on prerelease + TimeWarp.Builder 1.0.0-beta.3 (non-private; IBuilder is implemented by public + builder types) and trip NU5104. Needs a stable TimeWarp.Builder or the dependency + removed/internalized. + FIXED: bumped to the stable TimeWarp.Builder 1.0.0 published on NuGet; builds clean. + +## Checklist — Major (fix or explicitly accept; behavioral contracts freeze at 1.0) + +### Platform gating & core correctness +- [x] `timewarp-terminal.cs:825` — Title setter Windows-gated but Console.Title's setter + works on Unix; silently no-ops on Linux/macOS. + FIXED: gate removed from the setter (getter keeps it — the BCL getter is Windows-only). +- [x] `timewarp-terminal.cs:789` — parameterless Beep() Windows-gated but Console.Beep() + is cross-platform (BEL on Unix); only Beep(freq, duration) is Windows-only. + FIXED: gate removed from Beep(); the (freq, duration) overload keeps its gate. +- [x] `timewarp-terminal.cs:835` — KeyAvailable catches only IOException but Console + throws InvalidOperationException when stdin is redirected — crashes in exactly the + redirected scenario the fallback exists for. + FIXED: InvalidOperationException now also caught, returning false. +- [x] `iterminal.cs:262` (and members) — interface XML docs promise unconditional behavior + while TimeWarpTerminal silently no-ops on non-Windows and swallows exceptions; + document the actual contract before it freezes at 1.0. + + FIXED: added to every ITerminal member whose implementation platform-gates + or swallows exceptions (18 members documented; IConsole members verified as pure + pass-throughs needing no change). +### Static facade +- [x] `terminal-static.cs:60-64` + `test-terminal-context.cs:86` — Terminal.Instance is a + process-global mutable static; TestTerminalContext stores Current in AsyncLocal but + still swaps the global, so the documented "parallel test isolation" guarantee is + false — parallel tests last-writer-win and can restore stale instances. + DECIDED + FIXED (2026-07-03): "AsyncLocal wins" — Terminal.Instance's getter returns + TestTerminalContext.Current ?? the process-global field; SetCurrent/Use/ClearCurrent + now only touch the AsyncLocal (the global is never mutated by the context), so the + documented parallel isolation is actually true, and direct Terminal.Instance = x + still works for serial tests. This also moots the earlier disposed-terminal leak fix + (the context never installs anything into the global). FormatProvider snapshot/restore + retained. Parallel-isolation regression test added (two concurrent Use scopes). +- [x] `terminal-static.cs:92-135,163` — colored Write/WriteLine/WriteErrorLine emit ANSI + unconditionally, ignoring SupportsColor / NO_COLOR / redirection; raw escapes land + in piped output. + DECIDED + FIXED (2026-07-03): all ConsoleColor-parameter paths now consult + SupportsColor and degrade to plain text when unsupported — the four facade + Write/WriteLine/WriteErrorLine overloads, static WritePanel/WriteTable, and the + ITerminal WritePanel/WriteTable extension helpers. Caller-embedded ANSI strings + (e.g. "text".Cyan(), BorderColor) remain the caller's responsibility. Regression + tests added in terminal-static-06-color.cs. +- [x] `terminal-static.cs:184-290` — format overloads use InvariantCulture where + System.Console uses current culture; silent formatting differences for migrated + code, undocumented. + DECIDED + FIXED (2026-07-03): added `Terminal.FormatProvider` (IFormatProvider?, + default null = CultureInfo.CurrentCulture resolved per call — TextWriter.FormatProvider + semantics, avoids freezing a static-init culture snapshot). All 12 format overloads + now use it; set to InvariantCulture for deterministic output. Typed IFormatProvider + (not CultureInfo) per BCL convention; lives on Terminal (no separate TerminalFormatting + class) for discoverability. TestTerminalContext snapshots/restores it alongside + Instance. NOTE for release notes: default behavior changes from invariant to + current culture (Console parity). +- [x] `terminal-static.cs:591-599` — static Terminal.WriteLink always emits raw OSC 8, + while the same-named ITerminal.WriteLink extension checks SupportsHyperlinks and + falls back to plain text; two same-named APIs with different behavior. + DECIDED + FIXED (2026-07-03): static WriteLink now checks SupportsHyperlinks and + writes the plain display text when unsupported, identical to the extension. + Regression test added in hyperlink-01-basic.cs. +- [x] `terminal-static.cs` — facade exposes TreatControlCAsInput but omits the + CancelKeyPress event both ITerminal and System.Console provide; Ctrl+C handling + requires reaching through Terminal.Instance. + FIXED: static Terminal.CancelKeyPress event added, forwarding add/remove to Instance; + forwarding covered by new tests in cancel-key-press-01-basic.cs. +- [x] `ansi-hyperlink-extensions.cs:30` — CreateLink does no URI validation/escaping; a + URL containing ESC/BEL/ST terminates the OSC 8 sequence early = terminal + escape-injection vector via attacker-influenced URLs. + + FIXED: URLs are sanitized before embedding — C0 controls and DEL percent-encoded — + and all OSC 8 builders route through the single sanitizer; regression tests added. +### Test doubles (shipped public API) +- [x] `test-terminal-context.cs:94-99` — ClearCurrent with an empty snapshot stack nulls + Context.Value but never restores Terminal.Instance; AsyncLocal writes don't flow + back from async helpers, so Instance can be left pointing at a disposed + TestTerminal for the rest of the run (ObjectDisposedException in later tests). + FIXED: ClearCurrent with no snapshot resets Terminal.Instance to a fresh + TimeWarpTerminal instead of leaving a disposed TestTerminal installed; test added. +- [x] `test-terminal.cs:237-246` — Read() only drains KeyQueue and returns -1, never + consuming constructor input: `new TestTerminal("abc").Read()` returns -1 while + ReadLine() returns "abc"; contradicts Console where both consume the same stream. + FIXED: Read() falls back to InputReader when the KeyQueue is empty, matching Console + shared-stream semantics; interleaving tests added. +- [x] `test-terminal.cs:456` — KeyAvailable reflects only KeyQueue even though ReadKey() + synthesizes keys from constructor input, so `while (KeyAvailable) ReadKey()` loops + behave opposite to a real console. + FIXED: KeyAvailable now also reflects unread constructor input, mirroring ReadKey's + sources; test added. +- [x] `test-terminal.cs:469-514,536` — QueueKey ignores shift/ctrl when computing KeyChar + (shift:true yields 'a' not 'A'; ctrl:true yields 'a' not ''); QueueKeys("A") + produces shift:false. + FIXED: shift produces uppercase KeyChar, ctrl produces the control character for A-Z, + QueueKeys sets shift for uppercase letters; tests added. +- [x] `test-console.cs:200-201` — TestConsole.ReadKey() throws NotSupportedException + while inheriting IConsole docs that promise a value; first-party implementation + violates its own contract (suggests ReadKey belongs on ITerminal — breaking to move + later). + + DECIDED + FIXED (2026-07-03): ReadKey() moved from IConsole to ITerminal — key-by-key + input is interactive-terminal functionality with no meaning for a stream-oriented + console. TestConsole's throwing member and TimeWarpConsole's implementation removed; + contract test added asserting the member lives on ITerminal only. BREAKING vs beta + (intentional, pre-1.0). +### Widgets / text +- [x] `widgets/unicode-width.cs:260-279` — GetTextWidth treats every multi-rune grapheme + as width 2, so NFD combining sequences ("e" + U+0301) measure 2 instead of 1; + misaligns borders for decomposed Latin text. + FIXED: multi-rune clusters now measure by content — ZWJ/VS16/skin-tone/flag pairs are 2, + anything else sums rune widths so NFD combining sequences measure correctly. +- [x] `widgets/unicode-width.cs:163-180` — blanket 0x1F000-0x1FAFF wide range makes + narrow EAW=N blocks wide (playing cards, ornamental dingbats, alchemical symbols); + regional-indicator branch unreachable inside it; genuinely wide ranges missing + (Hangul Jamo Ext-A, Tangut, Kana Extended, CJK Compat tail). + FIXED: blanket range replaced with Unicode 16 EAW/emoji-presentation ranges (narrow + blocks fall through to width 1, only their emoji exceptions are wide); regional + indicators reachable again; Tangut/Kana/Nushu/Hangul Jamo Ext-A/CJK Compat tail added. +- [x] `widgets/ansi-string-utils.cs:14-15` — strip/measure regex only matches SGR ('m') + CSI + OSC 8; cursor movement, erase, private-mode sequences count as visible + characters, corrupting all width math for real ANSI streams. + FIXED: source-generated regex now matches all CSI finals, all OSC (BEL or ST terminated), + and two-byte ESC sequences; wrap carry-state only re-emits SGR and OSC 8. +- [x] `widgets/ansi-string-utils.cs:186-289` — WrapText treats an ANSI code mid-word as a + word boundary, so a single styled word can wrap mid-word and get TrimStart'ed. + FIXED: words are tokenized on visible whitespace only, so mid-word ANSI stays attached + and the word wraps as one unit; over-wide words grapheme-break with codes in place. +- [x] `widgets/panel-widget.cs:226-229` — with WordWrap(false), long lines are never + truncated (comment says "Pad or truncate" but PadRightVisible never truncates) and + push past the right border, breaking the box. + FIXED: over-long lines are truncated ANSI/grapheme-aware to the content area (plain cut, + no ellipsis — panel has no ellipsis convention) with reset before the border. +- [x] `widgets/table-widget.cs:569` — TruncateWithEllipsis strips all ANSI codes in every + branch despite the line-493 comment claiming codes are preserved; truncated cells + silently lose colors. + FIXED: truncation preserves embedded ANSI in the kept span for End/Start/Middle; style + active at a cut is reset before the plain ellipsis, and style opened before a Start/ + Middle cut is replayed onto the kept tail via the shared TruncateVisible helpers. +- [x] `widgets/table-widget.cs:276-282,223-234` — Grow columns hard-set to width 0 when + fixed columns fill the terminal (content silently vanishes) and MinWidth is never + consulted, contradicting table-column.cs docs. + FIXED: Grow columns are floored at max(4, MinWidth) and overflow is absorbed by the + existing proportional shrink; tight-terminal regression test added. +- [x] `widgets/table-builder.cs:150` — Build() returns the live Table instance (no + snapshot); building twice yields the same object and post-Build builder calls + mutate the "built" table. + + FIXED: Build() returns an independent snapshot (copied settings and lists); snapshot + independence test added. +### Packaging / release pipeline +- [x] `tools/dev-cli/endpoints/workflow.cs:121` — release pipeline is + clean→build→check-version→pack→push with no test or verify-samples step; 1.0.0 + could publish from a commit whose tests never ran on the release event. + FIXED: release path is now clean→build→verify-samples→test→check-version→pack. + Bonus: `dev test` (and both pipeline paths) previously ran `dotnet test` on a + solution with zero VSTest projects — a false green. The test step now runs the + tests/*.cs runfiles and fails on any failure. +- [x] `timewarp-terminal.csproj:23` — README.md is packed as a file but PackageReadmeFile + is never set, so nuget.org shows no readme. + FIXED: PackageReadmeFile set; verified lands in the nuspec. +- [x] `Directory.Build.props:66` + `csproj:13` — IsAotCompatible=true is claimed while + all trim/AOT diagnostics (IL2026/IL2067/IL2070/IL2075/IL3050/IL2104/IL3053) are + globally NoWarn'd "not yet implemented"; the package advertises AOT compat that is + unverified. + FIXED: the blanket IL2026/IL2067/IL2070/IL2075/IL3050/IL2104/IL3053 NoWarn is removed + from the root props — the library now builds clean under full trim/AOT analysis + (EnableTrimAnalyzer/EnableAotAnalyzer), and the dev-cli AOT publish (which consumes + the library) succeeds end-to-end. IL2026/IL3050/IL2104/IL3053 remain suppressed only + in tools/dev-cli for TimeWarp.Nuru.DevCli package-content files using reflection + JsonSerializer, documented in that props file. +- [x] `README.md:193` — Table quickstart calls `.Shrink()` which does not exist on + TableBuilder; front-page sample does not compile. + FIXED: sample now ends at .Expand() with a note that shrink-to-fit is automatic. +- [x] `tools/dev-cli/endpoints/workflow.cs:204` — no symbol package (snupkg) and no + ContinuousIntegrationBuild; no debugger symbols published for 1.0. + FIXED: IncludeSymbols + snupkg set in the csproj, pack passes + ContinuousIntegrationBuild=true; verified .snupkg is produced and the nuspec + repository element carries the commit. + +## Checklist — Minor (triage) + +- [x] `timewarp-terminal.cs:647` — NO_COLOR honored even when set to empty string (spec + says non-empty disables); no TERM=dumb / FORCE_COLOR handling. + FIXED: NO_COLOR disables color only when non-empty (per no-color.org) and TERM=dumb is + honored; FORCE_COLOR deliberately out of scope. Rule documented on ITerminal.SupportsColor. +- [x] `timewarp-terminal.cs:293` — GetCursorPosition reads CursorLeft then CursorTop + non-atomically instead of Console.GetCursorPosition(); pair can tear. + FIXED: uses atomic Console.GetCursorPosition() with the same (0,0) fallback. +- [x] `timewarp-terminal.cs:807` — TreatControlCAsInput lacks the try/catch every other + member has; throws when no console attached. + FIXED: getter returns false / setter no-ops on IOException and InvalidOperationException, + matching the class-wide swallow policy; documented on the interface member. +- [x] `iterminal.cs:175` — MoveBufferArea/CursorSize/Window-Buffer setters bake + Windows-legacy-console APIs into the cross-platform interface; unremovable after 1.0. + REVISED + FIXED (2026-07-03): initially accepted, then overturned on review — these are + legacy conhost features that don't work in Windows Terminal either, and freezing them at + 1.0 would be permanent stub debt for every implementer. REMOVED: MoveBufferArea, + CursorSize, SetWindowSize, SetWindowPosition, SetBufferSize, WindowLeft, WindowTop, + LargestWindowWidth, LargestWindowHeight. DEMOTED to get-only: WindowWidth, WindowHeight, + BufferWidth, BufferHeight (TestTerminal keeps public setters for test configuration). + BREAKING vs beta (intentional, pre-1.0). +- [x] `timewarp-terminal.cs:642` — IsInteractive checks only input redirection; stdout + piped still reports interactive, and docs don't say which stream. + FIXED: IsInteractive now requires both stdin and stdout unredirected; docs state exactly + which streams are consulted. +- [x] `ansi-hyperlink-extensions.cs:30` vs `terminal-static.cs:591` — + CreateLink(displayText, url) and WriteLink(url, text) take the same two strings in + opposite order; invites silently transposed args. + FIXED: CreateLink re-ordered to (url, displayText = null), aligned with WriteLink and the + extensions; all callers and tests updated. BREAKING vs beta (intentional, pre-1.0). +- [x] `terminal-static.cs:128,163,591` — overload asymmetry: WriteLine has (fg,bg) but + Write/WriteErrorLine have fg-only or none; WriteLink has no WriteLinkLine. + FIXED: added Write(msg, fg, bg), WriteErrorLine(msg, fg, bg), and WriteLinkLine(url, text) + — all SupportsColor/SupportsHyperlinks-gated, non-optional params (no new ambiguity). +- [x] `terminal-static.cs:102,110-114` — colored-overload docs claim null message writes + "only the line terminator" but color prefix + reset escapes are still emitted. + FIXED: behavior now matches the docs — a null message writes plain with no color codes + across all colored overloads. +- [x] `test-terminal.cs:261-265` — ReadKey at EOF fabricates Ctrl+D forever (real console + throws when redirected); undocumented sentinel becomes contract. + FIXED (documented): the Ctrl+D EOF sentinel is now stated in the ReadKey docs — loops + should treat '\u0004' as end-of-input. Behavior intentionally kept for REPL testing. +- [x] `test-terminal.cs:47,456` — KeyQueue and StringWriters unsynchronized while + System.Console members are documented thread-safe; production-safe code can fail + only under the double. + FIXED (documented): class-level remarks state the double is single-threaded by design, + unlike System.Console; no locking added. +- [x] `test-terminal.cs:399-415` — SimulateCancelKeyPress reflects into + ConsoleCancelEventArgs' non-public ctor and silently no-ops if lookup fails + (trimming/AOT/future BCL); false-passing consumer tests. + FIXED: SimulateCancelKeyPress now throws InvalidOperationException if the BCL internal + constructor cannot be found or invoked — no more silent false-passing tests. +- [x] `test-terminal.cs:419-420` — Clear() appends a "[CLEAR]" marker line to captured + output; behavior only documented in internal Design region, not public docs. + FIXED (documented): the verbatim "[CLEAR]" marker and its rationale are now in the + public Clear() docs. +- [x] `test-terminal.cs:628-630` — Dispose disposes consumer-assigned Standard*Stream + property values; duplicated summary doc block at 606-611; no cursor/window arg + validation vs Console's ArgumentOutOfRangeException; TestTerminalContext class doc + mentions DI resolution Resolve() doesn't implement (test-terminal-context.cs:21). + PARTIAL: duplicated Dispose summary removed and the class-doc DI-resolution claim + rewritten (2026-07-03); Dispose-of-consumer-streams and arg validation still open. + FIXED: Dispose now disposes only the constructor-created streams, never consumer-assigned + replacements (regression test added). Lax cursor/window arg validation accepted as + test-double leniency. +- [x] `widgets/ansi-string-utils.cs:387-418` — wrap state machine never clears OSC 8 + hyperlink state on the end sequence and SGR reset wrongly wipes hyperlink state; + wrapped lines re-open closed hyperlinks. + FIXED: SGR and hyperlink are independent carry channels; empty-URL OSC 8 clears the link + and SGR reset no longer wipes hyperlink state. +- [x] `ansi-colors.cs:210-253` — GetForeground maps dark and bright ConsoleColors to the + same SGR code (Red/DarkRed both 31; bright 91-97 unused; background likewise). + FIXED: standard SGR mapping — Dark* = 30-37/40-47, normal = 90-97/100-107, DarkGray = + bright black, Gray = dim white. RELEASE NOTE: visible output changes for ConsoleColor + overload users. +- [x] `widgets/ansi-string-utils.cs:74-137` — Pad*/Center with negative width throw + ArgumentOutOfRangeException from `new string(c, negative)`; clamp or document. + FIXED: negative widths clamp to 0 in Pad*/Center; documented. +- [x] `widgets/table-widget.cs:139` — AddRow stores null params array without check; NRE + at Render far from call site (AddColumns throws eagerly by contrast). + FIXED: AddRow now throws ArgumentNullException eagerly; test added. +- [x] `widgets/terminal-table-extensions.cs:93-96` — WriteTable fg/bg prefix is cancelled + by the first embedded AnsiColors.Reset (border/cell colors), so the color overload + malfunctions when combined with BorderColor or colored cells. + FIXED: the requested fg/bg prefix is re-applied after every embedded Reset in all four + colored-widget write loops, so BorderColor/styled cells no longer cancel it. +- [x] `widgets/table-widget.cs:292` — Expand silently ignored when Border is None; + undocumented. + FIXED (documented): Expand's no-effect-with-BorderStyle.None behavior stated on both the + property and the builder method. +- [x] `widgets/table-widget.cs:298-305` — Expand distributes width to MaxWidth-capped + columns, violating the MaxWidth contract. + FIXED: Expand skips capped columns and redistributes to uncapped ones; tests added. +- [x] `widgets/table-widget.cs:198-199` — 3-char overhead reserved for Grow columns that + later collapse to width 0, over-shrinking fixed columns. + RESOLVED BY EARLIER FIX (2026-07-03): Grow columns are now floored at max(4, MinWidth) + and never collapse to width 0 (the zeroing block and its render-skip were removed in + the Grow/MinWidth major fix), so overhead is only ever reserved for columns that + actually render. +- [x] `widgets/table-builder.cs:153-157` — ToTable() doc references an implicit operator + that doesn't exist. + FIXED: doc rewritten as an explicit alternative to Build(). +- [x] Overload pairs differing only by trailing optional ConsoleColor params + (`terminal-table-extensions.cs:36/57,78/112`, `terminal-panel-extensions.cs:31-56, + 109-141,149-173`, facade equivalents) — optional-param overloads are unreachable + without explicit args and the redundant pairs freeze into the 1.0 surface. + ACCEPTED (2026-07-03): the genuinely ambiguous pair (WritePanel string overloads) was + collapsed; the remaining Action/Table/Panel pairs resolve deterministically (the + no-optional-parameter candidate wins), and collapsing them would make null-literal + calls like WritePanel(null!) ambiguous — verified when the test suite caught exactly + that. Frozen knowingly. +- [x] `tools/dev-cli/endpoints/workflow.cs:147` — check-version never compares the props + version to the GitHub release tag that triggered the run; a v1.0.0 release event + with props still at beta would silently push another beta. + FIXED: release runs compare the GitHub release tag (leading v stripped) to the props + version and fail on mismatch; local runs note the skip. +- [x] `Directory.Build.props:17` — net10.0-only target excludes LTS (net8.0) consumers at + 1.0 launch; single-TFM is a deliberate choice worth confirming. + ACCEPTED (2026-07-03): net10.0-only is the deliberate org-wide baseline across TimeWarp + repos (runfile tests/samples require .NET 10 regardless). Multi-targeting can be + added later without breaking existing consumers. +- [x] `source/Directory.Build.props:6` — missing PackageProjectUrl, RepositoryType, and a + PackageReleaseNotes strategy. + FIXED: PackageProjectUrl, RepositoryType=git, and PackageReleaseNotes (releases URL) added. +- [x] `terminal-static.cs:48` — type `TimeWarp.Terminal.Terminal` collides with its + namespace (CA1724 suppressed); permanent qualification awkwardness — confirm as + accepted design. + ACCEPTED (2026-07-03): deliberate Console-like ergonomics — `Terminal.WriteLine(...)` + reads exactly like `Console.WriteLine(...)` for the primary audience; CA1724 stays + suppressed with this rationale. +- [x] `README.md:59-67` — README teaches raw `Terminal.Instance =` swapping and never + mentions TestTerminalContext, the safer shipped API. + FIXED: testing section now leads with the scoped TestTerminalContext.Use pattern and its + parallel-isolation guarantees; direct Instance swap demoted to a serial-tests note. + +## Notes + +- Review executed by six parallel subsystem reviewers; all blocker findings were + independently re-verified against the source before filing. +- Clean areas worth noting: TimeWarpConsole delegation, CancelKeyPress event forwarding, + proportional shrink arithmetic in table-widget, grapheme-based cell truncation, ragged + row handling, box-chars/border-style/line-style, license/icon packaging metadata, OIDC + trusted publishing setup, all-public-types-sealed + internal widget constructors, and + version single-sourcing via source/Directory.Build.props. +- `./bin/dev check-version` already reports 1.0.0-beta.13 as published — the version bump + to 1.0.0 should happen only after blockers (at minimum) are resolved. + +## Session + +- Created + review: 096d9aa9-8cec-4987-a576-91698523d859 (2026-07-03) diff --git a/kanban/to-do/.gitkeep b/kanban/to-do/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/samples/Directory.Build.props b/samples/Directory.Build.props new file mode 100644 index 0000000..08080d5 --- /dev/null +++ b/samples/Directory.Build.props @@ -0,0 +1,13 @@ + + + + + + + false + + $(NoWarn);CA1303;IDE0211 + + diff --git a/samples/emoji-table-widget.cs b/samples/emoji-table-widget.cs index f96b33e..9285c05 100755 --- a/samples/emoji-table-widget.cs +++ b/samples/emoji-table-widget.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Demonstrates emoji and wide character alignment in table/panel/rule borders diff --git a/samples/hyperlink-widget.cs b/samples/hyperlink-widget.cs index c7aecfe..6c08b18 100755 --- a/samples/hyperlink-widget.cs +++ b/samples/hyperlink-widget.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- // hyperlink-widget-demo - Demonstrates OSC 8 hyperlinks in terminal output // GitHub Issue: https://github.com/TimeWarpEngineering/timewarp-terminal/issues/95 #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj diff --git a/samples/panel-widget.cs b/samples/panel-widget.cs index ac647a6..d0fa68a 100755 --- a/samples/panel-widget.cs +++ b/samples/panel-widget.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- // panel-widget-demo - Demonstrates the Panel widget for bordered boxes // GitHub Issue: https://github.com/TimeWarpEngineering/timewarp-terminal/issues/90 #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj diff --git a/samples/rule-widget.cs b/samples/rule-widget.cs old mode 100644 new mode 100755 index 143419a..bf26776 --- a/samples/rule-widget.cs +++ b/samples/rule-widget.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- // rule-widget-demo - Demonstrates the Rule widget for horizontal divider lines // GitHub Issue: https://github.com/TimeWarpEngineering/timewarp-terminal/issues/89 #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj diff --git a/samples/table-widget.cs b/samples/table-widget.cs index 2484abc..5928a8b 100755 --- a/samples/table-widget.cs +++ b/samples/table-widget.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Demonstrates the Table widget for rendering columnar data diff --git a/skills/terminal/SKILL.md b/skills/terminal/SKILL.md index 1bd62bb..d04cd3e 100644 --- a/skills/terminal/SKILL.md +++ b/skills/terminal/SKILL.md @@ -118,17 +118,13 @@ string styledLink = "Docs".Link("https://docs.example.com").Blue().Underline(); terminal.WriteLink("https://example.com", "Click here"); // no newline terminal.WriteLinkLine("https://example.com", "Visit site"); // with newline -// Static API +// Static API (same graceful degradation via SupportsHyperlinks) Terminal.WriteLink("https://example.com", "Click here"); +Terminal.WriteLinkLine("https://example.com", "Visit site"); -// Check support -if (terminal.SupportsHyperlinks) - terminal.WriteLinkLine("https://example.com", "Link"); -else - terminal.WriteLine("https://example.com"); +// Low-level — parameter order matches WriteLink: (url, displayText) +string osc8 = AnsiHyperlinks.CreateLink("https://example.com", "text"); -// Low-level -string osc8 = AnsiHyperlinks.CreateLink("text", "https://example.com"); ``` ### Colors and Styles @@ -142,11 +138,22 @@ string osc8 = AnsiHyperlinks.CreateLink("text", "https://example.com"); terminal.WriteLine("Success".Green().Bold()); terminal.WriteLine("Error".Red().OnWhite()); -// ConsoleColor overloads on static API and WritePanel +// ConsoleColor overloads on static API and WritePanel/WriteTable +// (Write, WriteLine, and WriteErrorLine all have fg and fg+bg overloads) Terminal.WriteLine("colored", ConsoleColor.Green); Terminal.WriteLine("colored", ConsoleColor.Red, ConsoleColor.White); ``` +ConsoleColor overloads and widget color parameters automatically degrade to plain text +when `SupportsColor` is false (redirected output, non-empty `NO_COLOR`, or `TERM=dumb`). +Embedded ANSI from string extensions like `.Green()` is the caller's responsibility. + +### Format Culture + +Format overloads (`Terminal.Write("{0:N2}", value)` etc.) use the current culture, +matching `System.Console`. Set `Terminal.FormatProvider = CultureInfo.InvariantCulture` +for deterministic output; `null` (default) resolves `CurrentCulture` per call. + ### Unicode Width Calculates terminal display width accounting for emoji, CJK, fullwidth forms, and zero-width characters. Uses exact Emoji_Presentation=Yes code points from Unicode 16.0. @@ -181,25 +188,25 @@ Assert.Contains("error message", terminal.ErrorOutput); ### Static Terminal Testing +Use `TestTerminalContext.Use` — it is async-context isolated (safe for parallel tests, +never mutates the global) and restores automatically on dispose: + ```csharp -using TestTerminal testTerminal = new(); -Terminal.Instance = testTerminal; -try -{ - Terminal.WriteLine("test"); - Assert.Contains("test", testTerminal.Output); -} -finally -{ - Terminal.Instance = TimeWarpTerminal.Default; -} +using TestTerminal terminal = new(); +using IDisposable scope = TestTerminalContext.Use(terminal); + +Terminal.WriteLine("test"); +Assert.Contains("test", terminal.Output); ``` +For strictly serial tests, direct `Terminal.Instance = testTerminal` assignment also +works, but you must restore the original instance yourself. + ## Key Interfaces **IConsole:** `Write` -> `IConsole`, `WriteLine` -> `IConsole`, `WriteLineAsync` -> `Task`, `WriteErrorLine` -> `IConsole`, `WriteErrorLineAsync` -> `Task`, `ReadLine` -**ITerminal extends IConsole:** Overrides `Write` -> `ITerminal`, `WriteLine` -> `ITerminal`, `WriteErrorLine` -> `ITerminal`. Adds `ReadKey`, `SetCursorPosition`, `GetCursorPosition`, `WindowWidth`, `IsInteractive`, `SupportsColor`, `SupportsHyperlinks`, `Clear` +**ITerminal extends IConsole:** Overrides `Write` -> `ITerminal`, `WriteLine` -> `ITerminal`, `WriteErrorLine` -> `ITerminal`. Adds `ReadKey()` / `ReadKey(bool)` (key-by-key input lives here, not on `IConsole`), `SetCursorPosition`, `GetCursorPosition`, `WindowWidth`/`WindowHeight`/`BufferWidth`/`BufferHeight` (get-only; `TestTerminal` exposes setters for test configuration), `CancelKeyPress`, `IsInteractive`, `SupportsColor`, `SupportsHyperlinks`, `Clear` All Write methods return the interface for fluent chaining: ```csharp @@ -251,7 +258,7 @@ terminal.WritePanel(panel); |----------|------|---------|-------------| | `Header` | `string` | `""` | Column header text (supports ANSI colors) | | `Alignment` | `Alignment` | `Left` | Horizontal alignment (`Left`, `Right`, `Center`) | -| `MinWidth` | `int?` | `4` | Column won't shrink below this width | +| `MinWidth` | `int?` | `null` | Column won't shrink below this width; the layout floor is `max(4, MinWidth)` | | `MaxWidth` | `int?` | `null` | Content exceeding this is truncated with ellipsis | | `TruncateMode` | `TruncateMode` | `End` | Where to place ellipsis when truncating | | `HeaderColor` | `string?` | `null` | ANSI color code for header text | @@ -353,6 +360,6 @@ AnsiStringUtils.WrapText("long text...", 40); // word-wrap, grap 1. **Don't use `new Table()`, `new Panel()`, `new Rule()`** - Constructors are internal. Use builders or `Action` extension methods. 2. **Don't mix Console and Terminal** - Pick one, stick with it -3. **Always restore Terminal.Instance in tests** - Use try/finally or using pattern -4. **Check SupportsColor/SupportsHyperlinks** before using those features in production +3. **Prefer `TestTerminalContext.Use` over swapping `Terminal.Instance`** - the scope is parallel-safe and restores automatically; direct swaps require manual restore and are serial-only +4. **`ConsoleColor` overloads and `WriteLink` self-gate** on `SupportsColor`/`SupportsHyperlinks` — but embedded ANSI from string extensions (`.Green()`, `BorderColor`) does not; check `SupportsColor` yourself before emitting those 5. **Use `UnicodeWidth.GetTextWidth()` not `.Length`** for terminal column calculations — `.Length` counts UTF-16 code units, not display columns diff --git a/source/Directory.Build.props b/source/Directory.Build.props index 4b99cdb..8ec53b3 100644 --- a/source/Directory.Build.props +++ b/source/Directory.Build.props @@ -4,9 +4,12 @@ - 1.0.0-beta.13 + 1.0.0 Steven T. Cramer https://github.com/TimeWarpEngineering/timewarp-terminal + git + https://github.com/TimeWarpEngineering/timewarp-terminal + https://github.com/TimeWarpEngineering/timewarp-terminal/releases Unlicense logo.png diff --git a/source/timewarp-terminal/ansi-colors.cs b/source/timewarp-terminal/ansi-colors.cs index fb53b50..9ca90fc 100644 --- a/source/timewarp-terminal/ansi-colors.cs +++ b/source/timewarp-terminal/ansi-colors.cs @@ -1,5 +1,19 @@ namespace TimeWarp.Terminal; +#region Purpose +// ANSI escape code constants and ConsoleColor-to-SGR conversion for colored terminal output. +#endregion + +#region Design +// ConsoleColor mapping follows the standard Windows-console-to-ANSI convention: Dark* colors map +// to the dim SGR range 30-37 (backgrounds 40-47) and the normal colors map to the bright range +// 90-97 (backgrounds 100-107). The two grays cross over: DarkGray is bright black (90/100) and +// Gray is dim white (37/47), matching how conhost renders them. +// RELEASE NOTE (1.0): GetForeground/GetBackground previously collapsed dark and normal colors to +// the same dim SGR code (e.g. Red and DarkRed both emitted 31). Output now uses the standard +// mapping above, so normal colors render bright — a visible-output change. +#endregion + /// /// ANSI escape codes for colored terminal output. /// Includes all standard CSS named colors for comprehensive terminal styling. @@ -204,51 +218,59 @@ public static class AnsiColors /// /// Converts a to its corresponding ANSI foreground code. + /// Dark colors map to the dim SGR range (30-37) and normal colors map to the bright + /// range (90-97); is dim white (37) and + /// is bright black (90). /// /// The ConsoleColor to convert. /// The ANSI escape code for the specified foreground color. public static string GetForeground(ConsoleColor color) => color switch { ConsoleColor.Black => Black, - ConsoleColor.Red => Red, - ConsoleColor.Green => Green, - ConsoleColor.Yellow => Yellow, - ConsoleColor.Blue => Blue, - ConsoleColor.Magenta => Magenta, - ConsoleColor.Cyan => Cyan, - ConsoleColor.White => White, - ConsoleColor.Gray or ConsoleColor.DarkGray => Gray, ConsoleColor.DarkRed => Red, ConsoleColor.DarkGreen => Green, ConsoleColor.DarkYellow => Yellow, ConsoleColor.DarkBlue => Blue, ConsoleColor.DarkMagenta => Magenta, ConsoleColor.DarkCyan => Cyan, - _ => White + ConsoleColor.Gray => White, + ConsoleColor.DarkGray => Gray, + ConsoleColor.Red => BrightRed, + ConsoleColor.Green => BrightGreen, + ConsoleColor.Yellow => BrightYellow, + ConsoleColor.Blue => BrightBlue, + ConsoleColor.Magenta => BrightMagenta, + ConsoleColor.Cyan => BrightCyan, + ConsoleColor.White => BrightWhite, + _ => BrightWhite }; /// /// Converts a to its corresponding ANSI background code. + /// Dark colors map to the dim SGR range (40-47) and normal colors map to the bright + /// range (100-107); is dim white (47) and + /// is bright black (100). /// /// The ConsoleColor to convert. /// The ANSI escape code for the specified background color. public static string GetBackground(ConsoleColor color) => color switch { ConsoleColor.Black => BgBlack, - ConsoleColor.Red => BgRed, - ConsoleColor.Green => BgGreen, - ConsoleColor.Yellow => BgYellow, - ConsoleColor.Blue => BgBlue, - ConsoleColor.Magenta => BgMagenta, - ConsoleColor.Cyan => BgCyan, - ConsoleColor.White => BgWhite, - ConsoleColor.Gray or ConsoleColor.DarkGray => BgBrightBlack, ConsoleColor.DarkRed => BgRed, ConsoleColor.DarkGreen => BgGreen, ConsoleColor.DarkYellow => BgYellow, ConsoleColor.DarkBlue => BgBlue, ConsoleColor.DarkMagenta => BgMagenta, ConsoleColor.DarkCyan => BgCyan, - _ => BgBlack + ConsoleColor.Gray => BgWhite, + ConsoleColor.DarkGray => BgBrightBlack, + ConsoleColor.Red => BgBrightRed, + ConsoleColor.Green => BgBrightGreen, + ConsoleColor.Yellow => BgBrightYellow, + ConsoleColor.Blue => BgBrightBlue, + ConsoleColor.Magenta => BgBrightMagenta, + ConsoleColor.Cyan => BgBrightCyan, + ConsoleColor.White => BgBrightWhite, + _ => BgBrightWhite }; } diff --git a/source/timewarp-terminal/ansi-hyperlink-extensions.cs b/source/timewarp-terminal/ansi-hyperlink-extensions.cs index 179254c..5e0afd9 100644 --- a/source/timewarp-terminal/ansi-hyperlink-extensions.cs +++ b/source/timewarp-terminal/ansi-hyperlink-extensions.cs @@ -5,6 +5,18 @@ namespace TimeWarp.Terminal; +#region Purpose +// OSC 8 hyperlink primitives: escape-sequence constants, CreateLink, and the string.Link extension. +#endregion + +#region Design +// CreateLink takes (url, displayText) — the parameter order was aligned with Terminal.WriteLink +// and the ITerminal WriteLink/WriteLinkLine extensions for 1.0 so the same two strings read in +// the same order everywhere; displayText is optional and defaults to the URL. +// URLs are sanitized (C0 controls and DEL percent-encoded) before embedding so an +// attacker-influenced URL cannot terminate the OSC 8 sequence and inject escape sequences. +#endregion + /// /// Constants for OSC 8 hyperlink escape sequences. /// @@ -24,11 +36,51 @@ public static class AnsiHyperlinks /// /// Creates an OSC 8 hyperlink string. /// - /// The text to display. /// The URL to link to. + /// The text to display. If null, the URL is used as display text. /// An OSC 8 formatted hyperlink string. - public static string CreateLink(string displayText, string url) - => $"{LinkStart}{url}{LinkEnd}{displayText}{LinkStart}{LinkEnd}"; + /// + /// The URL is sanitized before being embedded in the escape sequence: + /// every C0 control character (below U+0020) and DEL (U+007F) is percent-encoded + /// as %XX (uppercase hexadecimal). This prevents characters such as ESC or BEL in an + /// attacker-influenced URL from terminating the OSC 8 sequence and injecting + /// arbitrary terminal escape sequences. Normal URLs pass through unchanged. + /// The display text is outside the OSC payload and is not modified. + /// + public static string CreateLink(string url, string? displayText = null) + => $"{LinkStart}{SanitizeUrl(url)}{LinkEnd}{displayText ?? url}{LinkStart}{LinkEnd}"; + + /// + /// Percent-encodes every C0 control character (below U+0020) and DEL (U+007F) + /// in the URL as %XX (uppercase hexadecimal) so the URL cannot prematurely + /// terminate the OSC 8 escape sequence. Returns the original string when no + /// encoding is required. + /// + /// The URL to sanitize. + /// The sanitized URL, safe to embed in an OSC 8 payload. + private static string SanitizeUrl(string url) + { + ArgumentNullException.ThrowIfNull(url); + + StringBuilder? builder = null; + + for (int i = 0; i < url.Length; i++) + { + char c = url[i]; + + if (c is < '\x20' or '\x7f') + { + builder ??= new StringBuilder(url.Length + 8).Append(url, 0, i); + _ = builder.Append('%').Append(((int)c).ToString("X2", CultureInfo.InvariantCulture)); + } + else + { + _ = builder?.Append(c); + } + } + + return builder?.ToString() ?? url; + } } /// @@ -56,6 +108,11 @@ public static class AnsiHyperlinkExtensions /// /// The URL to link to. /// The text wrapped in OSC 8 hyperlink escape sequences. + /// + /// The URL is sanitized before being embedded: C0 control characters (below U+0020) + /// and DEL (U+007F) are percent-encoded as %XX (uppercase hexadecimal) to prevent + /// terminal escape injection. Normal URLs pass through unchanged. + /// /// /// /// // Simple hyperlink @@ -65,6 +122,6 @@ public static class AnsiHyperlinkExtensions /// "Download".Link("https://example.com/file.zip").Blue().Underline() /// /// - public string Link(string url) => AnsiHyperlinks.CreateLink(text, url); + public string Link(string url) => AnsiHyperlinks.CreateLink(url, text); } } diff --git a/source/timewarp-terminal/iconsole.cs b/source/timewarp-terminal/iconsole.cs index 0c70e8f..371c215 100644 --- a/source/timewarp-terminal/iconsole.cs +++ b/source/timewarp-terminal/iconsole.cs @@ -81,16 +81,6 @@ public interface IConsole /// int Read(); - /// - /// Obtains the next character or function key pressed by the user. - /// The pressed key is displayed in the console window. - /// - /// - /// An object that describes the constant and Unicode character, - /// if any, that correspond to the pressed console key. - /// - ConsoleKeyInfo ReadKey(); - /// /// Gets or sets the encoding the console uses to read input. /// diff --git a/source/timewarp-terminal/iterminal.cs b/source/timewarp-terminal/iterminal.cs index ea669d9..02ec522 100644 --- a/source/timewarp-terminal/iterminal.cs +++ b/source/timewarp-terminal/iterminal.cs @@ -50,6 +50,21 @@ public interface ITerminal : IConsole /// This terminal instance for fluent chaining. new ITerminal WriteErrorLine(string? message = null); + /// + /// Obtains the next character or function key pressed by the user. + /// The pressed key is displayed in the console window. + /// + /// + /// An object that describes the constant and Unicode character, + /// if any, that correspond to the pressed console key. + /// + /// + /// Key-by-key input is an interactive-terminal capability, which is why this member lives + /// on rather than — it has no meaning for a + /// redirected, stream-oriented console. + /// + ConsoleKeyInfo ReadKey(); + /// /// Obtains the next character or function key pressed by the user. /// @@ -66,144 +81,111 @@ public interface ITerminal : IConsole /// Gets or sets the column position of the cursor. /// /// The column position, 0-based from left to right. + /// + /// When the console is redirected or unavailable, the default implementation returns 0 + /// from the getter instead of throwing; the setter silently ignores I/O errors and + /// out-of-range values. + /// int CursorLeft { get; set; } /// /// Gets or sets the row position of the cursor. /// /// The row position, 0-based from top to bottom. + /// + /// When the console is redirected or unavailable, the default implementation returns 0 + /// from the getter instead of throwing; the setter silently ignores I/O errors and + /// out-of-range values. + /// int CursorTop { get; set; } /// /// Gets or sets a value indicating whether the cursor is visible. /// /// true if the cursor is visible; otherwise, false. - bool CursorVisible { get; set; } - - /// - /// Gets or sets the height of the cursor within a character cell. - /// - /// The cursor size as a percentage from 1 to 100. /// - /// A value of 1 indicates a horizontal line at the bottom of the cell. - /// A value of 100 indicates a full block cursor. + /// Implementations targeting the system console may only support reading this value on Windows; + /// the default implementation returns true on other platforms and when the console is + /// redirected or unavailable. The setter is cross-platform and silently does nothing when the + /// console is redirected or unavailable. /// - int CursorSize { get; set; } + bool CursorVisible { get; set; } /// /// Sets the position of the cursor. /// /// The column position of the cursor. Columns are numbered from left to right starting at 0. /// The row position of the cursor. Rows are numbered from top to bottom starting at 0. + /// + /// The default implementation silently ignores out-of-range positions and I/O errors + /// (for example, when the console is redirected) instead of throwing. + /// void SetCursorPosition(int left, int top); /// /// Gets the current position of the cursor. /// /// A tuple containing the column (Left) and row (Top) position of the cursor. + /// + /// When the console is redirected or unavailable, the default implementation returns + /// (0, 0) instead of throwing. + /// (int Left, int Top) GetCursorPosition(); /// - /// Gets or sets the width of the terminal window in characters. + /// Gets the width of the terminal window in characters. /// /// The width of the terminal window measured in columns. - int WindowWidth { get; set; } + /// + /// When the console is redirected or unavailable, the default implementation returns 80 + /// instead of throwing. + /// + int WindowWidth { get; } /// - /// Gets or sets the height of the terminal window in characters. + /// Gets the height of the terminal window in characters. /// /// The height of the terminal window measured in rows. - int WindowHeight { get; set; } - - /// - /// Gets or sets the left position of the console window area. - /// - /// The leftmost position of the console window. - int WindowLeft { get; set; } - - /// - /// Gets or sets the top position of the console window area. - /// - /// The topmost position of the console window. - int WindowTop { get; set; } + /// + /// When the console is redirected or unavailable, the default implementation returns 24 + /// instead of throwing. + /// + int WindowHeight { get; } /// - /// Gets or sets the width of the buffer area. + /// Gets the width of the buffer area. /// /// The width of the buffer area measured in columns. - int BufferWidth { get; set; } + /// + /// On modern terminals the buffer width equals the window width. When the console is + /// redirected or unavailable, the default implementation returns 80 instead of throwing. + /// + int BufferWidth { get; } /// - /// Gets or sets the height of the buffer area. + /// Gets the height of the buffer area (including scrollback where the host reports it). /// /// The height of the buffer area measured in rows. - int BufferHeight { get; set; } - - /// - /// Sets the dimensions of the console window to the specified values. - /// - /// The width of the console window measured in columns. - /// The height of the console window measured in rows. - void SetWindowSize(int width, int height); - - /// - /// Sets the position of the console window relative to the screen buffer. - /// - /// The column position of the upper left corner of the console window. - /// The row position of the upper left corner of the console window. - void SetWindowPosition(int left, int top); - - /// - /// Sets the height and width of the screen buffer area to the specified values. - /// - /// The width of the buffer area measured in columns. - /// The height of the buffer area measured in rows. - void SetBufferSize(int width, int height); - - /// - /// Moves a specified source screen buffer area to a specified destination screen buffer area. - /// - /// The leftmost column of the source area. - /// The topmost row of the source area. - /// The number of columns in the source area. - /// The number of rows in the source area. - /// The leftmost column of the destination area. - /// The topmost row of the destination area. - /// The character used to fill the source area. - /// The foreground color used to fill the source area. - /// The background color used to fill the source area. - void MoveBufferArea - ( - int sourceLeft, - int sourceTop, - int sourceWidth, - int sourceHeight, - int targetLeft, - int targetTop, - char sourceChar, - ConsoleColor sourceForeColor, - ConsoleColor sourceBackColor - ); - - /// - /// Gets the largest possible number of console window columns. - /// - /// The maximum width of the console window measured in columns. - int LargestWindowWidth { get; } - - /// - /// Gets the largest possible number of console window rows. - /// - /// The maximum height of the console window measured in rows. - int LargestWindowHeight { get; } + /// + /// When the console is redirected or unavailable, the default implementation returns 300 + /// instead of throwing. + /// + int BufferHeight { get; } /// /// Gets a value indicating whether the terminal is interactive. /// /// - /// true if the terminal supports interactive input (not redirected); + /// true if the terminal supports interactive input and output; /// otherwise, false. /// + /// + /// The default implementation consults both standard input and standard output: + /// it returns true only when neither stream is redirected. If either + /// stdin or stdout is redirected (for example, app < file or + /// app | tee), the terminal is not considered interactive. + /// Standard error is not consulted. + /// bool IsInteractive { get; } /// @@ -213,6 +195,12 @@ ConsoleColor sourceBackColor /// true if the terminal supports color output; /// otherwise, false. /// + /// + /// The default implementation returns false when any of the following apply: + /// standard output is redirected; the NO_COLOR environment variable is set to a + /// non-empty value (per the no-color.org spec, an empty value does not disable color); + /// or the TERM environment variable equals "dumb" (ordinal comparison). + /// bool SupportsColor { get; } /// @@ -225,6 +213,8 @@ ConsoleColor sourceBackColor /// /// Supported terminals include Windows Terminal, iTerm2, VS Code terminal, /// Hyper, Konsole, and GNOME Terminal 3.26+. + /// Detection is a heuristic based on environment variables; the default implementation + /// returns false when output is redirected or the terminal is not recognized. /// bool SupportsHyperlinks { get; } @@ -232,22 +222,40 @@ ConsoleColor sourceBackColor /// Gets or sets the foreground color of the console. /// /// The foreground color. The default is gray. + /// + /// When the console is redirected or unavailable, the default implementation returns + /// from the getter instead of throwing, and the setter + /// silently does nothing. + /// ConsoleColor ForegroundColor { get; set; } /// /// Gets or sets the background color of the console. /// /// The background color. The default is black. + /// + /// When the console is redirected or unavailable, the default implementation returns + /// from the getter instead of throwing, and the setter + /// silently does nothing. + /// ConsoleColor BackgroundColor { get; set; } /// /// Resets the foreground and background console colors to their defaults. /// + /// + /// When the console is redirected or unavailable, the default implementation + /// silently does nothing instead of throwing. + /// void ResetColor(); /// /// Clears the console buffer and corresponding console window of display information. /// + /// + /// When the console is redirected or unavailable, the default implementation + /// silently does nothing instead of throwing. + /// void Clear(); /// @@ -261,6 +269,11 @@ ConsoleColor sourceBackColor /// /// Plays a beep sound through the console speaker. /// + /// + /// This parameterless overload is cross-platform (it emits the BEL character on + /// Unix-like systems). When the console is redirected or unavailable, the default + /// implementation silently does nothing instead of throwing. + /// void Beep(); /// @@ -272,6 +285,10 @@ ConsoleColor sourceBackColor /// /// The duration of the beep, measured in milliseconds. /// + /// + /// Implementations targeting the system console may only support this overload on Windows; + /// the default implementation silently does nothing on other platforms. + /// void Beep(int frequency, int duration); /// @@ -282,12 +299,21 @@ ConsoleColor sourceBackColor /// true if Ctrl+C is treated as ordinary input; false if it raises /// the event. The default is false. /// + /// + /// When the console is redirected or unavailable, the default implementation returns + /// false from the getter instead of throwing, and the setter silently does nothing. + /// bool TreatControlCAsInput { get; set; } /// /// Gets or sets the title to display in the console title bar. /// /// The string to display in the title bar of the console. + /// + /// Implementations targeting the system console may only support reading the title on Windows; + /// the default implementation returns an empty string on other platforms. The setter is + /// cross-platform and silently does nothing when the console is redirected or unavailable. + /// string Title { get; set; } /// @@ -296,5 +322,9 @@ ConsoleColor sourceBackColor /// /// true if a key press is available; otherwise, false. /// + /// + /// When standard input is redirected from a file or pipe, or the console is unavailable, + /// the default implementation returns false instead of throwing. + /// bool KeyAvailable { get; } } diff --git a/source/timewarp-terminal/terminal-hyperlink-extensions.cs b/source/timewarp-terminal/terminal-hyperlink-extensions.cs index 3190f71..67c58d2 100644 --- a/source/timewarp-terminal/terminal-hyperlink-extensions.cs +++ b/source/timewarp-terminal/terminal-hyperlink-extensions.cs @@ -42,11 +42,11 @@ public static ITerminal WriteLink(this ITerminal terminal, string url, string? d if (terminal.SupportsHyperlinks) { - terminal.Write(AnsiHyperlinks.CreateLink(text, url)); + _ = terminal.Write(AnsiHyperlinks.CreateLink(url, text)); } else { - terminal.Write(text); + _ = terminal.Write(text); } return terminal; @@ -72,11 +72,11 @@ public static ITerminal WriteLinkLine(this ITerminal terminal, string url, strin if (terminal.SupportsHyperlinks) { - terminal.WriteLine(AnsiHyperlinks.CreateLink(text, url)); + _ = terminal.WriteLine(AnsiHyperlinks.CreateLink(url, text)); } else { - terminal.WriteLine(text); + _ = terminal.WriteLine(text); } return terminal; diff --git a/source/timewarp-terminal/terminal-static.cs b/source/timewarp-terminal/terminal-static.cs index 6d1a1a8..ea7d97e 100644 --- a/source/timewarp-terminal/terminal-static.cs +++ b/source/timewarp-terminal/terminal-static.cs @@ -9,8 +9,14 @@ namespace TimeWarp.Terminal; // Static API mimics System.Console for easy migration from existing code. // Instance property allows swapping implementation for testing without DI container. // Dedicated format overloads for 1-3 args avoid array allocation (params variant for 4+). -// Color methods use AnsiColors to wrap messages with ANSI escape sequences. -// CA1054 suppressed for WriteLink: OSC 8 hyperlinks use raw URL strings by design. +// Format overloads use FormatProvider ?? CurrentCulture (resolved per call), matching +// System.Console's culture behavior while allowing an invariant override for determinism. +// Color methods use AnsiColors to wrap messages with ANSI escape sequences, applied only +// when Instance.SupportsColor is true (NO_COLOR / redirected output degrade to plain text). +// A null message writes plain (no color prefix/reset), matching the non-colored overloads. +// Colored widget output re-applies the color prefix after every embedded SGR reset (from +// BorderColor or styled cells) so the requested colors survive styled segments in a line. +// CA1054 suppressed for WriteLink/WriteLinkLine: OSC 8 hyperlinks use raw URL strings by design. #endregion using System.Globalization; @@ -47,8 +53,6 @@ namespace TimeWarp.Terminal; /// public static class Terminal { - private static ITerminal s_Instance = TimeWarpTerminal.Default; - /// /// Gets or sets the terminal instance used by all static methods. /// Defaults to for production use. @@ -56,14 +60,36 @@ public static class Terminal /// The current terminal implementation. /// Thrown when attempting to set a null value. /// - /// Replace this instance with a test implementation (such as TestTerminal) - /// to capture output and simulate input during unit tests. + /// The getter resolves the async-local TestTerminalContext.Current first, falling + /// back to the process-global instance assigned via the setter. This makes + /// TestTerminalContext.SetCurrent/Use safe for parallel test execution — + /// each async flow sees its own terminal — while direct assignment + /// (Terminal.Instance = new TestTerminal()) keeps working for serial tests. /// public static ITerminal Instance { - get => s_Instance; - set => s_Instance = value ?? throw new ArgumentNullException(nameof(value)); - } + get => TestTerminalContext.Current ?? field; + set => field = value ?? throw new ArgumentNullException(nameof(value)); + } = TimeWarpTerminal.Default; + + /// + /// Gets or sets the format provider used by the format overloads of + /// , , + /// and . + /// + /// + /// The provider to format with, or null to use + /// as resolved at the time of each call. Defaults to null, matching + /// 's current-culture formatting behavior. + /// + /// + /// Set to for deterministic output in tests or logs. + /// This is process-global startup configuration; TestTerminalContext snapshots and + /// restores it alongside . + /// + public static IFormatProvider? FormatProvider { get; set; } + + private static IFormatProvider ActiveFormatProvider => FormatProvider ?? CultureInfo.CurrentCulture; // Output Methods @@ -83,7 +109,7 @@ public static ITerminal Instance /// /// Writes the specified string value to the standard output stream with the specified foreground color. /// - /// The value to write. If null, an empty string is written. + /// The value to write. If null, an empty string is written without color codes. /// The foreground color to apply. /// /// @@ -91,17 +117,60 @@ public static ITerminal Instance /// Terminal.Write("Success!", ConsoleColor.Green); /// /// + /// + /// The color is applied only when the current instance's + /// is true; otherwise the text is written plain (honoring NO_COLOR and redirected output). + /// public static void Write(string? message, ConsoleColor foregroundColor) { - string coloredMessage = AnsiColors.GetForeground(foregroundColor) + (message ?? string.Empty) + AnsiColors.Reset; - Instance.Write(coloredMessage); + ITerminal instance = Instance; + if (message is null || !instance.SupportsColor) + { + _ = instance.Write(message ?? string.Empty); + return; + } + + string coloredMessage = AnsiColors.GetForeground(foregroundColor) + message + AnsiColors.Reset; + _ = instance.Write(coloredMessage); + } + + /// + /// Writes the specified string value to the standard output stream with the specified + /// foreground and background colors. + /// + /// The value to write. If null, an empty string is written without color codes. + /// The foreground color to apply. + /// The background color to apply. + /// + /// + /// Terminal.Write("Highlighted text", ConsoleColor.Black, ConsoleColor.Yellow); + /// + /// + /// + /// The color is applied only when the current instance's + /// is true; otherwise the text is written plain (honoring NO_COLOR and redirected output). + /// + public static void Write(string? message, ConsoleColor foregroundColor, ConsoleColor backgroundColor) + { + ITerminal instance = Instance; + if (message is null || !instance.SupportsColor) + { + _ = instance.Write(message ?? string.Empty); + return; + } + + string coloredMessage = AnsiColors.GetForeground(foregroundColor) + + AnsiColors.GetBackground(backgroundColor) + + message + + AnsiColors.Reset; + _ = instance.Write(coloredMessage); } /// /// Writes the specified string value, followed by the current line terminator, /// to the standard output stream with the specified foreground color. /// - /// The value to write. If null, only the line terminator is written. + /// The value to write. If null, only the line terminator is written (no color codes). /// The foreground color to apply. /// /// @@ -109,17 +178,28 @@ public static void Write(string? message, ConsoleColor foregroundColor) /// Terminal.WriteLine("Success!", ConsoleColor.Green); /// /// + /// + /// The color is applied only when the current instance's + /// is true; otherwise the text is written plain (honoring NO_COLOR and redirected output). + /// public static void WriteLine(string? message, ConsoleColor foregroundColor) { - string coloredMessage = AnsiColors.GetForeground(foregroundColor) + (message ?? string.Empty) + AnsiColors.Reset; - Instance.WriteLine(coloredMessage); + ITerminal instance = Instance; + if (message is null || !instance.SupportsColor) + { + _ = instance.WriteLine(message); + return; + } + + string coloredMessage = AnsiColors.GetForeground(foregroundColor) + message + AnsiColors.Reset; + _ = instance.WriteLine(coloredMessage); } /// /// Writes the specified string value, followed by the current line terminator, /// to the standard output stream with the specified foreground and background colors. /// - /// The value to write. If null, only the line terminator is written. + /// The value to write. If null, only the line terminator is written (no color codes). /// The foreground color to apply. /// The background color to apply. /// @@ -127,13 +207,24 @@ public static void WriteLine(string? message, ConsoleColor foregroundColor) /// Terminal.WriteLine("Highlighted text", ConsoleColor.Black, ConsoleColor.Yellow); /// /// + /// + /// The color is applied only when the current instance's + /// is true; otherwise the text is written plain (honoring NO_COLOR and redirected output). + /// public static void WriteLine(string? message, ConsoleColor foregroundColor, ConsoleColor backgroundColor) { + ITerminal instance = Instance; + if (message is null || !instance.SupportsColor) + { + _ = instance.WriteLine(message); + return; + } + string coloredMessage = AnsiColors.GetForeground(foregroundColor) + AnsiColors.GetBackground(backgroundColor) + - (message ?? string.Empty) + + message + AnsiColors.Reset; - Instance.WriteLine(coloredMessage); + _ = instance.WriteLine(coloredMessage); } /// @@ -155,17 +246,60 @@ public static void WriteLine(string? message, ConsoleColor foregroundColor, Cons /// Writes the specified string value, followed by the current line terminator, /// to the standard error stream with the specified foreground color. /// - /// The value to write. If null, only the line terminator is written. + /// The value to write. If null, only the line terminator is written (no color codes). /// The foreground color to apply. /// /// /// Terminal.WriteErrorLine("Error: File not found", ConsoleColor.Red); /// /// + /// + /// The color is applied only when the current instance's + /// is true; otherwise the text is written plain (honoring NO_COLOR and redirected output). + /// public static void WriteErrorLine(string? message, ConsoleColor foregroundColor) { - string coloredMessage = AnsiColors.GetForeground(foregroundColor) + (message ?? string.Empty) + AnsiColors.Reset; - Instance.WriteErrorLine(coloredMessage); + ITerminal instance = Instance; + if (message is null || !instance.SupportsColor) + { + _ = instance.WriteErrorLine(message); + return; + } + + string coloredMessage = AnsiColors.GetForeground(foregroundColor) + message + AnsiColors.Reset; + _ = instance.WriteErrorLine(coloredMessage); + } + + /// + /// Writes the specified string value, followed by the current line terminator, + /// to the standard error stream with the specified foreground and background colors. + /// + /// The value to write. If null, only the line terminator is written (no color codes). + /// The foreground color to apply. + /// The background color to apply. + /// + /// + /// Terminal.WriteErrorLine("Fatal error", ConsoleColor.White, ConsoleColor.Red); + /// + /// + /// + /// The color is applied only when the current instance's + /// is true; otherwise the text is written plain (honoring NO_COLOR and redirected output). + /// + public static void WriteErrorLine(string? message, ConsoleColor foregroundColor, ConsoleColor backgroundColor) + { + ITerminal instance = Instance; + if (message is null || !instance.SupportsColor) + { + _ = instance.WriteErrorLine(message); + return; + } + + string coloredMessage = AnsiColors.GetForeground(foregroundColor) + + AnsiColors.GetBackground(backgroundColor) + + message + + AnsiColors.Reset; + _ = instance.WriteErrorLine(coloredMessage); } /// @@ -184,7 +318,7 @@ public static void WriteErrorLine(string? message, ConsoleColor foregroundColor) /// A composite format string. /// The object to format. public static void Write(string format, object? arg0) - => Instance.Write(string.Format(CultureInfo.InvariantCulture, format, arg0)); + => Instance.Write(string.Format(ActiveFormatProvider, format, arg0)); /// /// Writes the specified string value to the standard output stream, using the specified format information. @@ -193,7 +327,7 @@ public static void Write(string format, object? arg0) /// The first object to format. /// The second object to format. public static void Write(string format, object? arg0, object? arg1) - => Instance.Write(string.Format(CultureInfo.InvariantCulture, format, arg0, arg1)); + => Instance.Write(string.Format(ActiveFormatProvider, format, arg0, arg1)); /// /// Writes the specified string value to the standard output stream, using the specified format information. @@ -203,7 +337,7 @@ public static void Write(string format, object? arg0, object? arg1) /// The second object to format. /// The third object to format. public static void Write(string format, object? arg0, object? arg1, object? arg2) - => Instance.Write(string.Format(CultureInfo.InvariantCulture, format, arg0, arg1, arg2)); + => Instance.Write(string.Format(ActiveFormatProvider, format, arg0, arg1, arg2)); /// /// Writes the specified string value to the standard output stream, using the specified format information. @@ -211,7 +345,7 @@ public static void Write(string format, object? arg0, object? arg1, object? arg2 /// A composite format string. /// An array of objects to format. public static void Write(string format, params object?[] args) - => Instance.Write(string.Format(CultureInfo.InvariantCulture, format, args)); + => Instance.Write(string.Format(ActiveFormatProvider, format, args)); /// /// Writes the specified string value, followed by the current line terminator, @@ -220,7 +354,7 @@ public static void Write(string format, params object?[] args) /// A composite format string. /// The object to format. public static void WriteLine(string format, object? arg0) - => Instance.WriteLine(string.Format(CultureInfo.InvariantCulture, format, arg0)); + => Instance.WriteLine(string.Format(ActiveFormatProvider, format, arg0)); /// /// Writes the specified string value, followed by the current line terminator, @@ -230,7 +364,7 @@ public static void WriteLine(string format, object? arg0) /// The first object to format. /// The second object to format. public static void WriteLine(string format, object? arg0, object? arg1) - => Instance.WriteLine(string.Format(CultureInfo.InvariantCulture, format, arg0, arg1)); + => Instance.WriteLine(string.Format(ActiveFormatProvider, format, arg0, arg1)); /// /// Writes the specified string value, followed by the current line terminator, @@ -241,7 +375,7 @@ public static void WriteLine(string format, object? arg0, object? arg1) /// The second object to format. /// The third object to format. public static void WriteLine(string format, object? arg0, object? arg1, object? arg2) - => Instance.WriteLine(string.Format(CultureInfo.InvariantCulture, format, arg0, arg1, arg2)); + => Instance.WriteLine(string.Format(ActiveFormatProvider, format, arg0, arg1, arg2)); /// /// Writes the specified string value, followed by the current line terminator, @@ -250,7 +384,7 @@ public static void WriteLine(string format, object? arg0, object? arg1, object? /// A composite format string. /// An array of objects to format. public static void WriteLine(string format, params object?[] args) - => Instance.WriteLine(string.Format(CultureInfo.InvariantCulture, format, args)); + => Instance.WriteLine(string.Format(ActiveFormatProvider, format, args)); /// /// Writes the specified string value, followed by the current line terminator, @@ -259,7 +393,7 @@ public static void WriteLine(string format, params object?[] args) /// A composite format string. /// The object to format. public static void WriteErrorLine(string format, object? arg0) - => Instance.WriteErrorLine(string.Format(CultureInfo.InvariantCulture, format, arg0)); + => Instance.WriteErrorLine(string.Format(ActiveFormatProvider, format, arg0)); /// /// Writes the specified string value, followed by the current line terminator, @@ -269,7 +403,7 @@ public static void WriteErrorLine(string format, object? arg0) /// The first object to format. /// The second object to format. public static void WriteErrorLine(string format, object? arg0, object? arg1) - => Instance.WriteErrorLine(string.Format(CultureInfo.InvariantCulture, format, arg0, arg1)); + => Instance.WriteErrorLine(string.Format(ActiveFormatProvider, format, arg0, arg1)); /// /// Writes the specified string value, followed by the current line terminator, @@ -280,7 +414,7 @@ public static void WriteErrorLine(string format, object? arg0, object? arg1) /// The second object to format. /// The third object to format. public static void WriteErrorLine(string format, object? arg0, object? arg1, object? arg2) - => Instance.WriteErrorLine(string.Format(CultureInfo.InvariantCulture, format, arg0, arg1, arg2)); + => Instance.WriteErrorLine(string.Format(ActiveFormatProvider, format, arg0, arg1, arg2)); /// /// Writes the specified string value, followed by the current line terminator, @@ -289,7 +423,7 @@ public static void WriteErrorLine(string format, object? arg0, object? arg1, obj /// A composite format string. /// An array of objects to format. public static void WriteErrorLine(string format, params object?[] args) - => Instance.WriteErrorLine(string.Format(CultureInfo.InvariantCulture, format, args)); + => Instance.WriteErrorLine(string.Format(ActiveFormatProvider, format, args)); // Widget Methods @@ -315,7 +449,9 @@ public static void WriteTable(Action configure) Table table = builder.Build(); string[] lines = table.Render(WindowWidth); foreach (string line in lines) - Instance.WriteLine(line); + { + _ = Instance.WriteLine(line); + } } /// @@ -338,7 +474,9 @@ public static void WriteTable(Table table) string[] lines = table.Render(WindowWidth); foreach (string line in lines) - Instance.WriteLine(line); + { + _ = Instance.WriteLine(line); + } } /// @@ -382,24 +520,34 @@ public static void WriteTable(Action configure, ConsoleColor? fore /// Terminal.WriteTable(table, ConsoleColor.White, ConsoleColor.DarkBlue); /// /// + /// + /// The color is applied only when the current instance's + /// is true; otherwise the text is written plain (honoring NO_COLOR and redirected output). + /// The color prefix is re-applied after any embedded SGR reset (from border colors or styled + /// cells) so the requested colors are not cancelled mid-line. + /// public static void WriteTable(Table table, ConsoleColor? foregroundColor = null, ConsoleColor? backgroundColor = null) { ArgumentNullException.ThrowIfNull(table); + bool useColor = (foregroundColor.HasValue || backgroundColor.HasValue) && Instance.SupportsColor; + string colorPrefix = useColor + ? (foregroundColor.HasValue ? AnsiColors.GetForeground(foregroundColor.Value) : "") + + (backgroundColor.HasValue ? AnsiColors.GetBackground(backgroundColor.Value) : "") + : ""; string[] lines = table.Render(WindowWidth); foreach (string line in lines) { - if (foregroundColor.HasValue || backgroundColor.HasValue) + if (useColor) { - string coloredLine = (foregroundColor.HasValue ? AnsiColors.GetForeground(foregroundColor.Value) : "") + - (backgroundColor.HasValue ? AnsiColors.GetBackground(backgroundColor.Value) : "") + - line + + string coloredLine = colorPrefix + + line.Replace(AnsiColors.Reset, AnsiColors.Reset + colorPrefix, StringComparison.Ordinal) + AnsiColors.Reset; - Instance.WriteLine(coloredLine); + _ = Instance.WriteLine(coloredLine); } else { - Instance.WriteLine(line); + _ = Instance.WriteLine(line); } } } @@ -426,26 +574,9 @@ public static void WritePanel(Action configure) Panel panel = builder.Build(); string[] lines = panel.Render(WindowWidth); foreach (string line in lines) - Instance.WriteLine(line); - } - - /// - /// Writes a panel with content and optional header to the terminal. - /// - /// The content to display inside the panel. - /// The header to display in the top border. Defaults to null. - /// - /// - /// Terminal.WritePanel("This is important information"); - /// Terminal.WritePanel("Content here", header: "Notice"); - /// - /// - public static void WritePanel(string content, string? header = null) - { - Panel panel = new() { Content = content, Header = header }; - string[] lines = panel.Render(WindowWidth); - foreach (string line in lines) - Instance.WriteLine(line); + { + _ = Instance.WriteLine(line); + } } /// @@ -482,6 +613,8 @@ public static void WritePanel(Action configure, ConsoleColor? fore /// The background color to apply to panel content. Defaults to null (no color). /// /// + /// Terminal.WritePanel("This is important information"); + /// Terminal.WritePanel("Content here", "Notice"); /// Terminal.WritePanel("This is important information", header: "Notice", /// foregroundColor: ConsoleColor.White, backgroundColor: ConsoleColor.DarkBlue); /// @@ -507,24 +640,34 @@ public static void WritePanel(string content, string? header = null, ConsoleColo /// Terminal.WritePanel(panel, ConsoleColor.White, ConsoleColor.DarkBlue); /// /// + /// + /// The color is applied only when the current instance's + /// is true; otherwise the text is written plain (honoring NO_COLOR and redirected output). + /// The color prefix is re-applied after any embedded SGR reset (from border colors or styled + /// content) so the requested colors are not cancelled mid-line. + /// public static void WritePanel(Panel panel, ConsoleColor? foregroundColor = null, ConsoleColor? backgroundColor = null) { ArgumentNullException.ThrowIfNull(panel); + bool useColor = (foregroundColor.HasValue || backgroundColor.HasValue) && Instance.SupportsColor; + string colorPrefix = useColor + ? (foregroundColor.HasValue ? AnsiColors.GetForeground(foregroundColor.Value) : "") + + (backgroundColor.HasValue ? AnsiColors.GetBackground(backgroundColor.Value) : "") + : ""; string[] lines = panel.Render(WindowWidth); foreach (string line in lines) { - if (foregroundColor.HasValue || backgroundColor.HasValue) + if (useColor) { - string coloredLine = (foregroundColor.HasValue ? AnsiColors.GetForeground(foregroundColor.Value) : "") + - (backgroundColor.HasValue ? AnsiColors.GetBackground(backgroundColor.Value) : "") + - line + + string coloredLine = colorPrefix + + line.Replace(AnsiColors.Reset, AnsiColors.Reset + colorPrefix, StringComparison.Ordinal) + AnsiColors.Reset; - Instance.WriteLine(coloredLine); + _ = Instance.WriteLine(coloredLine); } else { - Instance.WriteLine(line); + _ = Instance.WriteLine(line); } } } @@ -543,7 +686,7 @@ public static void WriteRule(string? title = null) { Rule rule = new() { Title = title }; string rendered = rule.Render(WindowWidth); - Instance.WriteLine(rendered); + _ = Instance.WriteLine(rendered); } /// @@ -567,7 +710,7 @@ public static void WriteRule(Action configure) Rule rule = builder.Build(); string rendered = rule.Render(WindowWidth); - Instance.WriteLine(rendered); + _ = Instance.WriteLine(rendered); } /// @@ -575,6 +718,11 @@ public static void WriteRule(Action configure) /// /// The URL to link to. /// The text to display (clickable in supported terminals). + /// + /// If the terminal does not support hyperlinks ( + /// is false), only the display text is written without the hyperlink escape sequences, + /// consistent with the ITerminal.WriteLink extension. + /// /// /// /// Terminal.WriteLink("https://github.com", "GitHub Repository"); @@ -588,8 +736,50 @@ public static void WriteLink(string url, string text) ArgumentNullException.ThrowIfNull(url); ArgumentNullException.ThrowIfNull(text); - string link = AnsiHyperlinks.CreateLink(text, url); - Instance.Write(link); + ITerminal instance = Instance; + if (!instance.SupportsHyperlinks) + { + _ = instance.Write(text); + return; + } + + string link = AnsiHyperlinks.CreateLink(url, text); + _ = instance.Write(link); + } + + /// + /// Writes a clickable hyperlink to the terminal using OSC 8 sequences, + /// followed by the current line terminator. + /// + /// The URL to link to. + /// The text to display (clickable in supported terminals). + /// + /// If the terminal does not support hyperlinks ( + /// is false), only the display text is written (followed by the line terminator) without + /// the hyperlink escape sequences, consistent with . + /// + /// + /// + /// Terminal.WriteLinkLine("https://github.com", "GitHub Repository"); + /// + /// + // CA1054: OSC 8 hyperlinks use raw URL strings by design for ergonomic API +#pragma warning disable CA1054 + public static void WriteLinkLine(string url, string text) +#pragma warning restore CA1054 + { + ArgumentNullException.ThrowIfNull(url); + ArgumentNullException.ThrowIfNull(text); + + ITerminal instance = Instance; + if (!instance.SupportsHyperlinks) + { + _ = instance.WriteLine(text); + return; + } + + string link = AnsiHyperlinks.CreateLink(url, text); + _ = instance.WriteLine(link); } // Stream Access Methods (IConsole) @@ -774,127 +964,25 @@ public static bool CursorVisible set => Instance.CursorVisible = value; } - /// - /// Gets or sets the height of the cursor within a character cell. - /// - /// The cursor size as a percentage from 1 to 100. - public static int CursorSize - { - get => Instance.CursorSize; - set => Instance.CursorSize = value; - } - // Window/Buffer Geometry Properties (ITerminal) /// - /// Gets or sets the height of the terminal window in characters. + /// Gets the height of the terminal window in characters. /// /// The height of the terminal window measured in rows. - public static int WindowHeight - { - get => Instance.WindowHeight; - set => Instance.WindowHeight = value; - } - - /// - /// Gets or sets the left position of the console window area. - /// - /// The leftmost position of the console window. - public static int WindowLeft - { - get => Instance.WindowLeft; - set => Instance.WindowLeft = value; - } - - /// - /// Gets or sets the top position of the console window area. - /// - /// The topmost position of the console window. - public static int WindowTop - { - get => Instance.WindowTop; - set => Instance.WindowTop = value; - } + public static int WindowHeight => Instance.WindowHeight; /// - /// Gets or sets the width of the buffer area. + /// Gets the width of the buffer area. /// /// The width of the buffer area measured in columns. - public static int BufferWidth - { - get => Instance.BufferWidth; - set => Instance.BufferWidth = value; - } + public static int BufferWidth => Instance.BufferWidth; /// - /// Gets or sets the height of the buffer area. + /// Gets the height of the buffer area. /// /// The height of the buffer area measured in rows. - public static int BufferHeight - { - get => Instance.BufferHeight; - set => Instance.BufferHeight = value; - } - - /// - /// Gets the largest possible number of console window columns. - /// - /// The maximum width of the console window measured in columns. - public static int LargestWindowWidth => Instance.LargestWindowWidth; - - /// - /// Gets the largest possible number of console window rows. - /// - /// The maximum height of the console window measured in rows. - public static int LargestWindowHeight => Instance.LargestWindowHeight; - - // Window/Buffer Geometry Methods (ITerminal) - - /// - /// Sets the dimensions of the console window to the specified values. - /// - /// The width of the console window measured in columns. - /// The height of the console window measured in rows. - public static void SetWindowSize(int width, int height) => Instance.SetWindowSize(width, height); - - /// - /// Sets the position of the console window relative to the screen buffer. - /// - /// The column position of the upper left corner of the console window. - /// The row position of the upper left corner of the console window. - public static void SetWindowPosition(int left, int top) => Instance.SetWindowPosition(left, top); - - /// - /// Sets the height and width of the screen buffer area to the specified values. - /// - /// The width of the buffer area measured in columns. - /// The height of the buffer area measured in rows. - public static void SetBufferSize(int width, int height) => Instance.SetBufferSize(width, height); - - /// - /// Moves a specified source screen buffer area to a specified destination screen buffer area. - /// - /// The leftmost column of the source area. - /// The topmost row of the source area. - /// The number of columns in the source area. - /// The number of rows in the source area. - /// The leftmost column of the destination area. - /// The topmost row of the destination area. - /// The character used to fill the source area. - /// The foreground color used to fill the source area. - /// The background color used to fill the source area. - public static void MoveBufferArea - ( - int sourceLeft, - int sourceTop, - int sourceWidth, - int sourceHeight, - int targetLeft, - int targetTop, - char sourceChar, - ConsoleColor sourceForeColor, - ConsoleColor sourceBackColor - ) => Instance.MoveBufferArea(sourceLeft, sourceTop, sourceWidth, sourceHeight, targetLeft, targetTop, sourceChar, sourceForeColor, sourceBackColor); + public static int BufferHeight => Instance.BufferHeight; // Color State Properties (ITerminal) @@ -931,7 +1019,7 @@ public static ConsoleColor BackgroundColor /// /// /// true if Ctrl+C is treated as ordinary input; false if it raises - /// the event. The default is false. + /// the event. The default is false. /// public static bool TreatControlCAsInput { @@ -939,6 +1027,19 @@ public static bool TreatControlCAsInput set => Instance.TreatControlCAsInput = value; } + /// + /// Occurs when the Ctrl+C key combination is pressed. + /// + /// + /// This event allows graceful handling of Ctrl+C for interactive applications like REPLs. + /// Subscriptions are forwarded to the current . + /// + public static event ConsoleCancelEventHandler? CancelKeyPress + { + add => Instance.CancelKeyPress += value; + remove => Instance.CancelKeyPress -= value; + } + /// /// Gets or sets the title to display in the console title bar. /// @@ -978,14 +1079,10 @@ public static string Title // Terminal Properties /// - /// Gets or sets the width of the terminal window in characters. + /// Gets the width of the terminal window in characters. /// /// The width of the terminal window measured in columns. - public static int WindowWidth - { - get => Instance.WindowWidth; - set => Instance.WindowWidth = value; - } + public static int WindowWidth => Instance.WindowWidth; /// /// Gets a value indicating whether the terminal is interactive. diff --git a/source/timewarp-terminal/test-console.cs b/source/timewarp-terminal/test-console.cs index c494165..c27f447 100644 --- a/source/timewarp-terminal/test-console.cs +++ b/source/timewarp-terminal/test-console.cs @@ -37,9 +37,6 @@ public sealed class TestConsole : IConsole, IDisposable private readonly StringWriter ErrorWriter; private readonly Queue CharacterQueue; private bool Disposed; - private TextReader InReader; - private TextWriter OutWriter; - private TextWriter ErrorWriterField; /// /// Gets or sets the mock standard input stream. @@ -99,25 +96,25 @@ public Stream OpenStandardError() => StandardErrorStream; /// - public TextReader In => InReader; + public TextReader In { get; private set; } /// - public TextWriter Out => OutWriter; + public TextWriter Out { get; private set; } /// - public TextWriter Error => ErrorWriterField; + public TextWriter Error { get; private set; } /// public void SetIn(TextReader reader) - => InReader = reader ?? throw new ArgumentNullException(nameof(reader)); + => In = reader ?? throw new ArgumentNullException(nameof(reader)); /// public void SetOut(TextWriter writer) - => OutWriter = writer ?? throw new ArgumentNullException(nameof(writer)); + => Out = writer ?? throw new ArgumentNullException(nameof(writer)); /// public void SetError(TextWriter writer) - => ErrorWriterField = writer ?? throw new ArgumentNullException(nameof(writer)); + => Error = writer ?? throw new ArgumentNullException(nameof(writer)); /// /// Initializes a new instance of with optional scripted input. @@ -132,9 +129,9 @@ public TestConsole(string input = "") OutputWriter = new StringWriter(); ErrorWriter = new StringWriter(); CharacterQueue = new Queue(); - InReader = InputReader; - OutWriter = OutputWriter; - ErrorWriterField = ErrorWriter; + In = InputReader; + Out = OutputWriter; + Error = ErrorWriter; StandardInputStream = new MemoryStream(); StandardOutputStream = new MemoryStream(); StandardErrorStream = new MemoryStream(); @@ -192,15 +189,13 @@ public async Task WriteErrorLineAsync(string? message = null) public int Read() { if (CharacterQueue.Count > 0) + { return CharacterQueue.Dequeue(); + } return -1; } - /// - public ConsoleKeyInfo ReadKey() - => throw new NotSupportedException("TestConsole does not support key-by-key input. Use TestTerminal for interactive key input."); - /// /// Queues characters for to return. /// @@ -209,7 +204,9 @@ public void QueueCharacters(string characters) { ArgumentNullException.ThrowIfNull(characters); foreach (char c in characters) + { CharacterQueue.Enqueue(c); + } } /// @@ -222,8 +219,8 @@ public void QueueCharacters(string characters) /// public void Clear() { - OutputWriter.GetStringBuilder().Clear(); - ErrorWriter.GetStringBuilder().Clear(); + _ = OutputWriter.GetStringBuilder().Clear(); + _ = ErrorWriter.GetStringBuilder().Clear(); } /// @@ -262,7 +259,9 @@ public string[] GetErrorLines() public void Dispose() { if (Disposed) + { return; + } InputReader.Dispose(); OutputWriter.Dispose(); diff --git a/source/timewarp-terminal/test-terminal-context.cs b/source/timewarp-terminal/test-terminal-context.cs index dbeed9f..c4e1f7b 100644 --- a/source/timewarp-terminal/test-terminal-context.cs +++ b/source/timewarp-terminal/test-terminal-context.cs @@ -2,40 +2,42 @@ namespace TimeWarp.Terminal; /// /// Provides an ambient context for that enables zero-configuration testing -/// of CLI applications with automatic synchronization. +/// of CLI applications. While a context is active, +/// resolves to it without the process-global instance ever being mutated. /// /// /// /// This class uses to provide a test terminal that flows with the -/// async execution context. This means each test gets its own isolated terminal even when -/// running tests in parallel. +/// async execution context, and Terminal.Instance's getter consults it first. Because +/// and never touch the process-global instance, +/// each test gets its own isolated terminal even when 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: +/// Resolution order for Terminal.Instance: /// -/// (if set) -/// ITerminal from DI (if registered) -/// (fallback) +/// (if set for the current async flow) +/// the process-global instance (assignable directly; defaults to +/// ) /// /// /// /// -/// Scoped test pattern with automatic TimeWarp.Terminal.Terminal.Instance synchronization: +/// Scoped test pattern: /// /// public static async Task Should_display_greeting() /// { /// using TestTerminal terminal = new(); /// using IDisposable scope = TestTerminalContext.Use(terminal); -/// -/// // TimeWarp.Terminal.Terminal.Instance is now set to terminal +/// +/// // TimeWarp.Terminal.Terminal.Instance now resolves to terminal in this async flow /// Terminal.WriteLine("Hello"); // Routes to test terminal -/// +/// /// await Program.Main(["greet", "World"]); -/// +/// /// terminal.OutputContains("Hello, World!").ShouldBeTrue(); /// } /// @@ -48,7 +50,7 @@ public static class TestTerminalContext private sealed class ContextSnapshot { public required TestTerminal? PreviousContext { get; init; } - public required ITerminal PreviousInstance { get; init; } + public required IFormatProvider? PreviousFormatProvider { get; init; } } /// @@ -65,7 +67,9 @@ private sealed class ContextSnapshot public static bool HasValue => Context.Value is not null; /// - /// Sets the current and synchronizes . + /// Sets the current for the async execution context. + /// resolves to it while the context is + /// active; the process-global instance is never mutated. /// /// The terminal to set as current. public static void SetCurrent(TestTerminal terminal) @@ -78,16 +82,18 @@ public static void SetCurrent(TestTerminal terminal) new ContextSnapshot { PreviousContext = Context.Value, - PreviousInstance = TimeWarp.Terminal.Terminal.Instance + PreviousFormatProvider = TimeWarp.Terminal.Terminal.FormatProvider } ); Context.Value = terminal; - TimeWarp.Terminal.Terminal.Instance = terminal; } /// - /// Clears the current context and restores the previous . + /// Clears the current context, restoring the previous context and + /// . The process-global + /// is never touched — once the context + /// is cleared, resolution simply falls back to it. /// public static void ClearCurrent() { @@ -100,10 +106,12 @@ public static void ClearCurrent() ContextSnapshot snapshot = stack.Pop(); Context.Value = snapshot.PreviousContext; - TimeWarp.Terminal.Terminal.Instance = snapshot.PreviousInstance; + TimeWarp.Terminal.Terminal.FormatProvider = snapshot.PreviousFormatProvider; if (stack.Count == 0) + { SnapshotStack.Value = null; + } } /// @@ -144,7 +152,9 @@ private sealed class Scope : IDisposable public void Dispose() { if (Disposed) + { return; + } ClearCurrent(); Disposed = true; diff --git a/source/timewarp-terminal/test-terminal.cs b/source/timewarp-terminal/test-terminal.cs index 1f4ce89..ef88e8d 100644 --- a/source/timewarp-terminal/test-terminal.cs +++ b/source/timewarp-terminal/test-terminal.cs @@ -8,9 +8,17 @@ namespace TimeWarp.Terminal; #region Design // Captures output via StringWriter instances for both stdout and stderr. // Queues individual ConsoleKeyInfo for ReadKey — simulates real keystroke-by-keystroke REPL input. +// Read, ReadKey, and KeyAvailable fall back to unread constructor input when the key queue is empty, +// matching System.Console semantics where Read/ReadLine/ReadKey share one input source. +// QueueKey honors modifiers for letters: shift produces an uppercase KeyChar and ctrl produces the +// control character (char)(key - ConsoleKey.A + 1); QueueKeys sets the shift flag for uppercase letters. // Clear() writes "[CLEAR]" marker so tests can verify it was called without losing captured output. // IsInteractive defaults to false; SupportsColor defaults to true for color verification. -// IDisposable cleans up StringReader and StringWriter resources. +// IDisposable cleans up StringReader and StringWriter resources, plus only the constructor-created +// MemoryStreams (tracked in Owned* fields) — consumer-assigned Standard*Stream values are never disposed. +// SimulateCancelKeyPress throws InvalidOperationException instead of silently no-oping if the +// non-public ConsoleCancelEventArgs constructor cannot be found or invoked (BCL internals changed). +// Not thread-safe by design (unlike System.Console); single-threaded test flows only. #endregion /// @@ -38,6 +46,12 @@ namespace TimeWarp.Terminal; /// Assert.Contains("hello", terminal.Output); /// /// +/// +/// Unlike , whose members are documented as thread-safe, this test +/// double is intended for single-threaded test flows: the key queue and captured-output writers +/// are unsynchronized, and members are not thread-safe. Tests driving a multi-threaded CLI must +/// synchronize access to the externally. +/// /// public sealed class TestTerminal : ITerminal, IDisposable { @@ -45,13 +59,10 @@ public sealed class TestTerminal : ITerminal, IDisposable private readonly StringWriter OutputWriter; private readonly StringWriter ErrorWriter; private readonly Queue KeyQueue; - private int CursorLeftField; - private int CursorTopField; - private int CursorSizeField = 100; + private readonly MemoryStream OwnedStandardInputStream; + private readonly MemoryStream OwnedStandardOutputStream; + private readonly MemoryStream OwnedStandardErrorStream; private bool Disposed; - private TextReader InReader; - private TextWriter OutWriter; - private TextWriter ErrorWriterField; /// /// Gets or sets the mock standard input stream. @@ -111,25 +122,25 @@ public Stream OpenStandardError() => StandardErrorStream; /// - public TextReader In => InReader; + public TextReader In { get; private set; } /// - public TextWriter Out => OutWriter; + public TextWriter Out { get; private set; } /// - public TextWriter Error => ErrorWriterField; + public TextWriter Error { get; private set; } /// public void SetIn(TextReader reader) - => InReader = reader ?? throw new ArgumentNullException(nameof(reader)); + => In = reader ?? throw new ArgumentNullException(nameof(reader)); /// public void SetOut(TextWriter writer) - => OutWriter = writer ?? throw new ArgumentNullException(nameof(writer)); + => Out = writer ?? throw new ArgumentNullException(nameof(writer)); /// public void SetError(TextWriter writer) - => ErrorWriterField = writer ?? throw new ArgumentNullException(nameof(writer)); + => Error = writer ?? throw new ArgumentNullException(nameof(writer)); /// /// Initializes a new instance of with optional scripted line input. @@ -147,47 +158,30 @@ public TestTerminal(string input = "") WindowWidth = 80; IsInteractive = false; // Testing is non-interactive by default SupportsColor = true; - InReader = InputReader; - OutWriter = OutputWriter; - ErrorWriterField = ErrorWriter; - StandardInputStream = new MemoryStream(); - StandardOutputStream = new MemoryStream(); - StandardErrorStream = new MemoryStream(); + In = InputReader; + Out = OutputWriter; + Error = ErrorWriter; + OwnedStandardInputStream = new MemoryStream(); + OwnedStandardOutputStream = new MemoryStream(); + OwnedStandardErrorStream = new MemoryStream(); + StandardInputStream = OwnedStandardInputStream; + StandardOutputStream = OwnedStandardOutputStream; + StandardErrorStream = OwnedStandardErrorStream; // Suppress unused field warnings - these fields will be used when REPL is updated to use ITerminal - _ = CursorLeftField; - _ = CursorTopField; + _ = CursorLeft; + _ = CursorTop; } /// - public int CursorLeft - { - get => CursorLeftField; - set => CursorLeftField = value; - } + public int CursorLeft { get; set; } /// - public int CursorTop - { - get => CursorTopField; - set => CursorTopField = value; - } + public int CursorTop { get; set; } /// public bool CursorVisible { get; set; } = true; - /// - public int CursorSize - { - get => CursorSizeField; - set - { - if (value < 1 || value > 100) - throw new ArgumentOutOfRangeException(nameof(value), value, "CursorSize must be between 1 and 100."); - CursorSizeField = value; - } - } - /// /// Gets all standard output written to this terminal. /// @@ -253,18 +247,32 @@ public int Read() return keyInfo.KeyChar; } - return -1; + return InputReader.Read(); } /// + /// + /// When both the key queue and the constructor-supplied input are exhausted, this method does not + /// block or throw: it returns a Ctrl+D (EOF) — KeyChar '\u0004', + /// with the Control modifier — on every subsequent call. Loops driving a + /// REPL should treat '\u0004' as end-of-input to terminate. + /// public ConsoleKeyInfo ReadKey() => ReadKey(false); /// + /// + /// When both the key queue and the constructor-supplied input are exhausted, this method does not + /// block or throw: it returns a Ctrl+D (EOF) — KeyChar '\u0004', + /// with the Control modifier — on every subsequent call. Loops driving a + /// REPL should treat '\u0004' as end-of-input to terminate. + /// public ConsoleKeyInfo ReadKey(bool intercept) { if (KeyQueue.Count > 0) + { return KeyQueue.Dequeue(); + } // If no keys queued, try to read from input as a line string? line = InputReader.ReadLine(); @@ -292,80 +300,33 @@ public ConsoleKeyInfo ReadKey(bool intercept) /// public void SetCursorPosition(int left, int top) { - CursorLeftField = left; - CursorTopField = top; + CursorLeft = left; + CursorTop = top; } /// public (int Left, int Top) GetCursorPosition() - => (CursorLeftField, CursorTopField); + => (CursorLeft, CursorTop); - /// + /// + /// Gets or sets the width of the terminal window. Settable so tests can control layout. + /// public int WindowWidth { get; set; } - /// + /// + /// Gets or sets the height of the terminal window. Settable so tests can control layout. + /// public int WindowHeight { get; set; } = 24; - /// - public int WindowLeft { get; set; } - - /// - public int WindowTop { get; set; } - - /// + /// + /// Gets or sets the buffer width. Settable so tests can control layout. + /// public int BufferWidth { get; set; } = 80; - /// - public int BufferHeight { get; set; } = 300; - - /// - public int LargestWindowWidth { get; set; } = 120; - - /// - public int LargestWindowHeight { get; set; } = 40; - /// - /// Gets the number of times has been called. + /// Gets or sets the buffer height. Settable so tests can control layout. /// - public int MoveBufferAreaCallCount { get; private set; } - - /// - public void SetWindowSize(int width, int height) - { - WindowWidth = width; - WindowHeight = height; - } - - /// - public void SetWindowPosition(int left, int top) - { - WindowLeft = left; - WindowTop = top; - } - - /// - public void SetBufferSize(int width, int height) - { - BufferWidth = width; - BufferHeight = height; - } - - /// - public void MoveBufferArea - ( - int sourceLeft, - int sourceTop, - int sourceWidth, - int sourceHeight, - int targetLeft, - int targetTop, - char sourceChar, - ConsoleColor sourceForeColor, - ConsoleColor sourceBackColor - ) - { - MoveBufferAreaCallCount++; - } + public int BufferHeight { get; set; } = 300; /// public bool IsInteractive { get; set; } @@ -405,24 +366,55 @@ public event ConsoleCancelEventHandler? CancelKeyPress /// /// Uses reflection to create ConsoleCancelEventArgs since it has no public constructor. /// + /// + /// The non-public constructor could not be found or invoked, + /// meaning the BCL internals have changed and this helper cannot construct the event args. + /// 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) + if (constructor is null) { - ConsoleCancelEventArgs args = (ConsoleCancelEventArgs)constructor.Invoke([specialKey]); - CancelKeyPressHandler.Invoke(this, args); + throw new InvalidOperationException + ( + "The internal ConsoleCancelEventArgs(ConsoleSpecialKey) constructor was not found. " + + "The BCL internals have changed and SimulateCancelKeyPress cannot construct ConsoleCancelEventArgs." + ); } + + ConsoleCancelEventArgs args; + try + { + args = (ConsoleCancelEventArgs)constructor.Invoke([specialKey]); + } + catch (Exception exception) when (exception is System.Reflection.TargetInvocationException or MemberAccessException) + { + throw new InvalidOperationException + ( + "Invoking the internal ConsoleCancelEventArgs(ConsoleSpecialKey) constructor failed. " + + "The BCL internals have changed and SimulateCancelKeyPress cannot construct ConsoleCancelEventArgs.", + exception + ); + } + + CancelKeyPressHandler.Invoke(this, args); } /// - + /// + /// Does not erase captured output: output history is preserved so tests can assert on everything + /// written before the clear. Instead, the marker line [CLEAR] is appended to the captured + /// output to record that was called. Use to actually + /// discard captured output. + /// public void Clear() => OutputWriter.WriteLine("[CLEAR]"); @@ -460,12 +452,12 @@ public void Beep(int frequency, int duration) public string Title { get; set; } = string.Empty; /// - public bool KeyAvailable => KeyQueue.Count > 0; + public bool KeyAvailable => KeyQueue.Count > 0 || InputReader.Peek() != -1; // ========== Test Helper Methods ========== /// - /// Queues a single key press for the next call. + /// Queues a single key press for the next call. /// /// The console key to queue. /// Whether Shift is pressed. @@ -480,11 +472,59 @@ public void QueueKey(ConsoleKey key, bool shift = false, bool alt = false, bool ConsoleKey.Escape => '\u001b', ConsoleKey.Backspace => '\b', ConsoleKey.Spacebar => ' ', - >= ConsoleKey.A and <= ConsoleKey.Z => (char)('a' + (key - ConsoleKey.A)), - >= ConsoleKey.D0 and <= ConsoleKey.D9 => (char)('0' + (key - ConsoleKey.D0)), + ConsoleKey.A or ConsoleKey.B or ConsoleKey.C or ConsoleKey.D or ConsoleKey.E or ConsoleKey.F + or ConsoleKey.G or ConsoleKey.H or ConsoleKey.I or ConsoleKey.J or ConsoleKey.K or ConsoleKey.L + or ConsoleKey.M or ConsoleKey.N or ConsoleKey.O or ConsoleKey.P or ConsoleKey.Q or ConsoleKey.R + or ConsoleKey.S or ConsoleKey.T or ConsoleKey.U or ConsoleKey.V or ConsoleKey.W or ConsoleKey.X + or ConsoleKey.Y or ConsoleKey.Z => (char)('a' + (key - ConsoleKey.A)), + ConsoleKey.D0 or ConsoleKey.D1 or ConsoleKey.D2 or ConsoleKey.D3 or ConsoleKey.D4 + or ConsoleKey.D5 or ConsoleKey.D6 or ConsoleKey.D7 or ConsoleKey.D8 + or ConsoleKey.D9 => (char)('0' + (key - ConsoleKey.D0)), + ConsoleKey.None or ConsoleKey.Clear or ConsoleKey.Pause + or ConsoleKey.PageUp or ConsoleKey.PageDown or ConsoleKey.End or ConsoleKey.Home + or ConsoleKey.LeftArrow or ConsoleKey.UpArrow or ConsoleKey.RightArrow or ConsoleKey.DownArrow + or ConsoleKey.Select or ConsoleKey.Print or ConsoleKey.Execute or ConsoleKey.PrintScreen + or ConsoleKey.Insert or ConsoleKey.Delete or ConsoleKey.Help + or ConsoleKey.LeftWindows or ConsoleKey.RightWindows or ConsoleKey.Applications or ConsoleKey.Sleep + or ConsoleKey.NumPad0 or ConsoleKey.NumPad1 or ConsoleKey.NumPad2 or ConsoleKey.NumPad3 + or ConsoleKey.NumPad4 or ConsoleKey.NumPad5 or ConsoleKey.NumPad6 or ConsoleKey.NumPad7 + or ConsoleKey.NumPad8 or ConsoleKey.NumPad9 + or ConsoleKey.Multiply or ConsoleKey.Add or ConsoleKey.Separator or ConsoleKey.Subtract + or ConsoleKey.Decimal or ConsoleKey.Divide + or ConsoleKey.F1 or ConsoleKey.F2 or ConsoleKey.F3 or ConsoleKey.F4 or ConsoleKey.F5 + or ConsoleKey.F6 or ConsoleKey.F7 or ConsoleKey.F8 or ConsoleKey.F9 or ConsoleKey.F10 + or ConsoleKey.F11 or ConsoleKey.F12 or ConsoleKey.F13 or ConsoleKey.F14 or ConsoleKey.F15 + or ConsoleKey.F16 or ConsoleKey.F17 or ConsoleKey.F18 or ConsoleKey.F19 or ConsoleKey.F20 + or ConsoleKey.F21 or ConsoleKey.F22 or ConsoleKey.F23 or ConsoleKey.F24 + or ConsoleKey.BrowserBack or ConsoleKey.BrowserForward or ConsoleKey.BrowserRefresh + or ConsoleKey.BrowserStop or ConsoleKey.BrowserSearch or ConsoleKey.BrowserFavorites + or ConsoleKey.BrowserHome + or ConsoleKey.VolumeMute or ConsoleKey.VolumeDown or ConsoleKey.VolumeUp + or ConsoleKey.MediaNext or ConsoleKey.MediaPrevious or ConsoleKey.MediaStop or ConsoleKey.MediaPlay + or ConsoleKey.LaunchMail or ConsoleKey.LaunchMediaSelect or ConsoleKey.LaunchApp1 or ConsoleKey.LaunchApp2 + or ConsoleKey.Oem1 or ConsoleKey.OemPlus or ConsoleKey.OemComma or ConsoleKey.OemMinus + or ConsoleKey.OemPeriod or ConsoleKey.Oem2 or ConsoleKey.Oem3 or ConsoleKey.Oem4 + or ConsoleKey.Oem5 or ConsoleKey.Oem6 or ConsoleKey.Oem7 or ConsoleKey.Oem8 or ConsoleKey.Oem102 + or ConsoleKey.Process or ConsoleKey.Packet or ConsoleKey.Attention + or ConsoleKey.CrSel or ConsoleKey.ExSel or ConsoleKey.EraseEndOfFile + or ConsoleKey.Play or ConsoleKey.Zoom or ConsoleKey.NoName or ConsoleKey.Pa1 + or ConsoleKey.OemClear => '\0', _ => '\0' }; + if (key is >= ConsoleKey.A and <= ConsoleKey.Z) + { + if (ctrl) + { + // Real terminals produce control characters for Ctrl+A through Ctrl+Z + keyChar = (char)(key - ConsoleKey.A + 1); + } + else if (shift) + { + keyChar = char.ToUpperInvariant(keyChar); + } + } + KeyQueue.Enqueue(new ConsoleKeyInfo(keyChar, key, shift, alt, ctrl)); } @@ -505,7 +545,8 @@ public void QueueKeys(string text) foreach (char c in text) { ConsoleKey key = CharToConsoleKey(c); - KeyQueue.Enqueue(new ConsoleKeyInfo(c, key, false, false, false)); + bool shift = char.IsAsciiLetterUpper(c); + KeyQueue.Enqueue(new ConsoleKeyInfo(c, key, shift, false, false)); } } @@ -537,8 +578,8 @@ public void QueueArrow(ConsoleKey direction) /// public void ClearOutput() { - OutputWriter.GetStringBuilder().Clear(); - ErrorWriter.GetStringBuilder().Clear(); + _ = OutputWriter.GetStringBuilder().Clear(); + _ = ErrorWriter.GetStringBuilder().Clear(); } /// @@ -575,16 +616,15 @@ public string[] GetOutputLines() /// public int KeysInQueue => KeyQueue.Count; - /// - /// 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) @@ -595,9 +635,12 @@ public void Dispose() InputReader.Dispose(); OutputWriter.Dispose(); ErrorWriter.Dispose(); - StandardInputStream.Dispose(); - StandardOutputStream.Dispose(); - StandardErrorStream.Dispose(); + + // Dispose only the streams this instance created in its constructor. + // Consumer-assigned Standard*Stream replacements are owned by the consumer and must not be disposed here. + OwnedStandardInputStream.Dispose(); + OwnedStandardOutputStream.Dispose(); + OwnedStandardErrorStream.Dispose(); Disposed = true; } diff --git a/source/timewarp-terminal/timewarp-console.cs b/source/timewarp-terminal/timewarp-console.cs index bbe79a5..44d9fc9 100644 --- a/source/timewarp-terminal/timewarp-console.cs +++ b/source/timewarp-terminal/timewarp-console.cs @@ -51,10 +51,6 @@ public Task WriteErrorLineAsync(string? message = null) public int Read() => Console.Read(); - /// - public ConsoleKeyInfo ReadKey() - => Console.ReadKey(false); - /// public Encoding InputEncoding { diff --git a/source/timewarp-terminal/timewarp-terminal.cs b/source/timewarp-terminal/timewarp-terminal.cs index 13b45dd..74e5690 100644 --- a/source/timewarp-terminal/timewarp-terminal.cs +++ b/source/timewarp-terminal/timewarp-terminal.cs @@ -10,7 +10,7 @@ namespace TimeWarp.Terminal; // Explicit interface implementations (IConsole.Write) hide base interface methods from public API. // IOExceptions silently swallowed because redirected output is a valid runtime scenario. // Hyperlink detection uses environment variable heuristics for major terminal emulators. -// Color support respects NO_COLOR environment variable (de facto standard). +// Color support respects the NO_COLOR spec (non-empty value disables color) and TERM=dumb. #endregion /// @@ -203,7 +203,9 @@ public bool CursorVisible get { if (!OperatingSystem.IsWindows()) + { return true; + } try { @@ -216,9 +218,8 @@ public bool CursorVisible } set { - if (!OperatingSystem.IsWindows()) - return; - + // Console.CursorVisible's setter is supported on all platforms + // (only the getter is Windows-only) try { Console.CursorVisible = value; @@ -229,41 +230,6 @@ public bool CursorVisible } } - /// - public int CursorSize - { - get - { - if (!OperatingSystem.IsWindows()) - return 100; - - try - { - return Console.CursorSize; - } - catch (IOException) - { - return 100; - } - } - set - { - if (!OperatingSystem.IsWindows()) - return; - - try - { - Console.CursorSize = value; - } - catch (ArgumentOutOfRangeException) - { - } - catch (IOException) - { - } - } - } - /// public void SetCursorPosition(int left, int top) { @@ -286,7 +252,7 @@ public void SetCursorPosition(int left, int top) { try { - return (Console.CursorLeft, Console.CursorTop); + return Console.GetCursorPosition(); } catch (IOException) { @@ -309,22 +275,6 @@ public int WindowWidth return 80; } } - set - { - if (!OperatingSystem.IsWindows()) - return; - - try - { - Console.WindowWidth = value; - } - catch (ArgumentOutOfRangeException) - { - } - catch (IOException) - { - } - } } /// @@ -341,86 +291,6 @@ public int WindowHeight return 24; } } - set - { - if (!OperatingSystem.IsWindows()) - return; - - try - { - Console.WindowHeight = value; - } - catch (ArgumentOutOfRangeException) - { - } - catch (IOException) - { - } - } - } - - /// - public int WindowLeft - { - get - { - try - { - return Console.WindowLeft; - } - catch (IOException) - { - return 0; - } - } - set - { - if (!OperatingSystem.IsWindows()) - return; - - try - { - Console.WindowLeft = value; - } - catch (ArgumentOutOfRangeException) - { - } - catch (IOException) - { - } - } - } - - /// - public int WindowTop - { - get - { - try - { - return Console.WindowTop; - } - catch (IOException) - { - return 0; - } - } - set - { - if (!OperatingSystem.IsWindows()) - return; - - try - { - Console.WindowTop = value; - } - catch (ArgumentOutOfRangeException) - { - } - catch (IOException) - { - } - } } /// @@ -437,22 +307,6 @@ public int BufferWidth return 80; } } - set - { - if (!OperatingSystem.IsWindows()) - return; - - try - { - Console.BufferWidth = value; - } - catch (ArgumentOutOfRangeException) - { - } - catch (IOException) - { - } - } } /// @@ -469,154 +323,17 @@ public int BufferHeight return 300; } } - set - { - if (!OperatingSystem.IsWindows()) - return; - - try - { - Console.BufferHeight = value; - } - catch (ArgumentOutOfRangeException) - { - } - catch (IOException) - { - } - } - } - - /// - public void SetWindowSize(int width, int height) - { - if (!OperatingSystem.IsWindows()) - return; - - try - { - Console.SetWindowSize(width, height); - } - catch (ArgumentOutOfRangeException) - { - } - catch (IOException) - { - } - } - - /// - public void SetWindowPosition(int left, int top) - { - if (!OperatingSystem.IsWindows()) - return; - - try - { - Console.SetWindowPosition(left, top); - } - catch (ArgumentOutOfRangeException) - { - } - catch (IOException) - { - } - } - - /// - public void SetBufferSize(int width, int height) - { - if (!OperatingSystem.IsWindows()) - return; - - try - { - Console.SetBufferSize(width, height); - } - catch (ArgumentOutOfRangeException) - { - } - catch (IOException) - { - } - } - - /// - public void MoveBufferArea - ( - int sourceLeft, - int sourceTop, - int sourceWidth, - int sourceHeight, - int targetLeft, - int targetTop, - char sourceChar, - ConsoleColor sourceForeColor, - ConsoleColor sourceBackColor - ) - { - if (!OperatingSystem.IsWindows()) - return; - - try - { - Console.MoveBufferArea - ( - sourceLeft, - sourceTop, - sourceWidth, - sourceHeight, - targetLeft, - targetTop, - sourceChar, - sourceForeColor, - sourceBackColor - ); - } - catch (IOException) - { - } - } - - /// - public int LargestWindowWidth - { - get - { - try - { - return Console.LargestWindowWidth; - } - catch (IOException) - { - return 120; - } - } - } - - /// - public int LargestWindowHeight - { - get - { - try - { - return Console.LargestWindowHeight; - } - catch (IOException) - { - return 40; - } - } } /// public bool IsInteractive - => !Console.IsInputRedirected; + => !Console.IsInputRedirected && !Console.IsOutputRedirected; /// public bool SupportsColor - => !Console.IsOutputRedirected && Environment.GetEnvironmentVariable("NO_COLOR") is null; + => !Console.IsOutputRedirected + && Environment.GetEnvironmentVariable("NO_COLOR") is not { Length: > 0 } + && !string.Equals(Environment.GetEnvironmentVariable("TERM"), "dumb", StringComparison.Ordinal); /// public bool SupportsHyperlinks => DetectHyperlinkSupport(); @@ -628,32 +345,46 @@ private static bool DetectHyperlinkSupport() { // No hyperlinks if output is redirected if (Console.IsOutputRedirected) + { return false; + } // Windows Terminal if (Environment.GetEnvironmentVariable("WT_SESSION") is not null) + { return true; + } // VS Code integrated terminal if (Environment.GetEnvironmentVariable("TERM_PROGRAM") == "vscode") + { return true; + } // iTerm2 if (Environment.GetEnvironmentVariable("TERM_PROGRAM") == "iTerm.app") + { return true; + } // Konsole if (Environment.GetEnvironmentVariable("KONSOLE_VERSION") is not null) + { return true; + } // GNOME Terminal (VTE 0.50+ / version 5000+) string? vteVersion = Environment.GetEnvironmentVariable("VTE_VERSION"); if (vteVersion is not null && int.TryParse(vteVersion, out int version) && version >= 5000) + { return true; + } // Hyper terminal if (Environment.GetEnvironmentVariable("TERM_PROGRAM") == "Hyper") + { return true; + } // Default: assume no support for unknown terminals return false; @@ -746,22 +477,60 @@ public void Clear() /// public void Beep() { - if (OperatingSystem.IsWindows()) + // Console.Beep() is cross-platform (emits BEL on Unix); + // only the (frequency, duration) overload is Windows-only + try + { Console.Beep(); + } + catch (IOException) + { + // Silently ignore if console is redirected + } } /// public void Beep(int frequency, int duration) { if (OperatingSystem.IsWindows()) + { Console.Beep(frequency, duration); + } } /// public bool TreatControlCAsInput { - get => Console.TreatControlCAsInput; - set => Console.TreatControlCAsInput = value; + get + { + try + { + return Console.TreatControlCAsInput; + } + catch (IOException) + { + return false; + } + catch (InvalidOperationException) + { + // Thrown when no console is attached (e.g., redirected input) + return false; + } + } + set + { + try + { + Console.TreatControlCAsInput = value; + } + catch (IOException) + { + } + catch (InvalidOperationException) + { + // Silently ignore when no console is attached + } + } } /// @@ -770,13 +539,24 @@ public string Title get { if (OperatingSystem.IsWindows()) + { return Console.Title; + } + return string.Empty; } set { - if (OperatingSystem.IsWindows()) + // Console.Title's setter is supported on all platforms + // (only the getter is Windows-only) + try + { Console.Title = value; + } + catch (IOException) + { + // Silently ignore if console is redirected + } } } @@ -793,6 +573,12 @@ public bool KeyAvailable { return false; } + catch (InvalidOperationException) + { + // Console.KeyAvailable throws InvalidOperationException when + // standard input is redirected from a file or pipe + return false; + } } } } diff --git a/source/timewarp-terminal/timewarp-terminal.csproj b/source/timewarp-terminal/timewarp-terminal.csproj index 5e75318..b97346a 100644 --- a/source/timewarp-terminal/timewarp-terminal.csproj +++ b/source/timewarp-terminal/timewarp-terminal.csproj @@ -8,6 +8,9 @@ true + README.md + true + snupkg true diff --git a/source/timewarp-terminal/widgets/ansi-string-utils.cs b/source/timewarp-terminal/widgets/ansi-string-utils.cs index e0198a9..8f8f433 100644 --- a/source/timewarp-terminal/widgets/ansi-string-utils.cs +++ b/source/timewarp-terminal/widgets/ansi-string-utils.cs @@ -1,24 +1,50 @@ namespace TimeWarp.Terminal; +#region Purpose +// ANSI-aware string utilities: strip, measure, pad, center, and word-wrap text containing escape codes. +#endregion + +#region Design +// AnsiRegex covers the full ANSI escape surface, not just SGR: CSI with any final byte (so cursor +// movement, erase, and private modes are invisible to measurement), any OSC command terminated by +// BEL or ST (terminator optional so truncated sequences still strip), and two-byte ESC sequences +// with final byte 0x40-0x5F excluding '[' and ']' which open CSI/OSC. [GeneratedRegex] keeps the +// library AOT-compatible (IsAotCompatible). +// WrapText tokenizes on whitespace in the VISIBLE text only: ANSI codes embedded in a word stay +// attached to it, so a styled word like "un" + red("break") + "able" wraps as one unit. +// Line-carry state has two independent channels: accumulated SGR codes (cleared by SGR reset) and +// the active OSC 8 hyperlink (cleared by an empty-URL OSC 8; NOT cleared by SGR reset). Only SGR +// and OSC 8 are re-emitted on wrapped lines; one-shot sequences (cursor movement, erase, titles) +// are never carried across line breaks. +// Truncation helpers reuse the same state channels: TakeVisibleFromStart keeps embedded codes in +// place and closes any open hyperlink/SGR at the cut so styling never bleeds into what follows +// (padding, borders, ellipsis); TakeVisibleFromEnd replays the state established by the discarded +// prefix (SGR then hyperlink) before the kept tail so a color opened before the cut still styles +// it. Both cut on grapheme boundaries and never split a wide (two-column) grapheme — a straddling +// grapheme is dropped, so the result may be one column short; callers pad to the exact width. +#endregion + /// /// Utility methods for working with strings containing ANSI escape codes. /// public static partial class AnsiStringUtils { /// - /// Compiled regex for stripping ANSI escape sequences. - /// Matches: - /// - CSI sequences like \x1b[0m, \x1b[31m, \x1b[38;5;214m, etc. - /// - OSC 8 hyperlink sequences: \x1b]8;;URL\x1b\ or \x1b]8;;URL\a (BEL terminator) + /// OSC 8 sequence that terminates the active hyperlink. /// - private static readonly Regex AnsiRegexInstance = new( - @"\x1b\[[0-9;]*m|\x1b]8;;[^\x07\x1b]*(?:\x1b\\|\x07)", - RegexOptions.Compiled); + private const string HyperlinkClose = "\x1b]8;;\x1b\\"; /// - /// Gets the compiled regex for stripping ANSI codes. + /// Source-generated regex matching ANSI escape sequences: + /// - CSI: ESC [ + parameter bytes (0x30-0x3F) + intermediate bytes (0x20-0x2F) + one final byte + /// (0x40-0x7E), e.g. \x1b[31m, \x1b[38;5;214m, \x1b[2K, \x1b[?25l, \x1b[2A + /// - OSC: ESC ] + content terminated by BEL (\x07) or ST (ESC \); the terminator is optional so + /// truncated sequences still strip, e.g. \x1b]8;;URL\x1b\ hyperlinks, \x1b]0;title\x07 + /// - Two-byte ESC sequences: ESC + one final byte 0x40-0x5F excluding '[' and ']' (which open + /// CSI/OSC), e.g. \x1bD (index), \x1bM (reverse index), standalone ESC \ (ST) /// - private static Regex AnsiRegex() => AnsiRegexInstance; + [GeneratedRegex(@"\x1b\[[0-\x3f]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b[@-Z\\^_]")] + private static partial Regex AnsiRegex(); /// /// Removes all ANSI escape codes from a string. @@ -35,7 +61,9 @@ public static partial class AnsiStringUtils public static string StripAnsiCodes(string? text) { if (string.IsNullOrEmpty(text)) + { return string.Empty; + } return AnsiRegex().Replace(text, string.Empty); } @@ -55,7 +83,9 @@ public static string StripAnsiCodes(string? text) public static int GetVisibleLength(string? text) { if (string.IsNullOrEmpty(text)) + { return 0; + } return UnicodeWidth.GetTextWidth(StripAnsiCodes(text)); } @@ -64,17 +94,29 @@ public static int GetVisibleLength(string? text) /// Pads a string to a specified length, accounting for ANSI codes. /// /// The text to pad. - /// The desired total visible width. + /// The desired total visible width. Negative values are clamped to 0. /// The character to use for padding (default is space). - /// The padded string with original ANSI codes preserved. + /// + /// The padded string with original ANSI codes preserved. Text already at or beyond + /// is returned unchanged. + /// public static string PadRightVisible(string? text, int totalWidth, char paddingChar = ' ') { + if (totalWidth < 0) + { + totalWidth = 0; + } + if (string.IsNullOrEmpty(text)) + { return new string(paddingChar, totalWidth); + } int visibleLength = GetVisibleLength(text); if (visibleLength >= totalWidth) + { return text; + } return text + new string(paddingChar, totalWidth - visibleLength); } @@ -83,17 +125,29 @@ public static string PadRightVisible(string? text, int totalWidth, char paddingC /// Pads a string on the left to a specified length, accounting for ANSI codes. /// /// The text to pad. - /// The desired total visible width. + /// The desired total visible width. Negative values are clamped to 0. /// The character to use for padding (default is space). - /// The padded string with original ANSI codes preserved. + /// + /// The padded string with original ANSI codes preserved. Text already at or beyond + /// is returned unchanged. + /// public static string PadLeftVisible(string? text, int totalWidth, char paddingChar = ' ') { + if (totalWidth < 0) + { + totalWidth = 0; + } + if (string.IsNullOrEmpty(text)) + { return new string(paddingChar, totalWidth); + } int visibleLength = GetVisibleLength(text); if (visibleLength >= totalWidth) + { return text; + } return new string(paddingChar, totalWidth - visibleLength) + text; } @@ -102,17 +156,29 @@ public static string PadLeftVisible(string? text, int totalWidth, char paddingCh /// Centers a string within a specified width, accounting for ANSI codes. /// /// The text to center. - /// The desired total visible width. + /// The desired total visible width. Negative values are clamped to 0. /// The character to use for padding (default is space). - /// The centered string with original ANSI codes preserved. + /// + /// The centered string with original ANSI codes preserved. Text already at or beyond + /// is returned unchanged. + /// public static string CenterVisible(string? text, int totalWidth, char paddingChar = ' ') { + if (totalWidth < 0) + { + totalWidth = 0; + } + if (string.IsNullOrEmpty(text)) + { return new string(paddingChar, totalWidth); + } int visibleLength = GetVisibleLength(text); if (visibleLength >= totalWidth) + { return text; + } int totalPadding = totalWidth - visibleLength; int leftPadding = totalPadding / 2; @@ -121,8 +187,195 @@ public static string CenterVisible(string? text, int totalWidth, char paddingCha return new string(paddingChar, leftPadding) + text + new string(paddingChar, rightPadding); } + /// + /// Truncates a string to a maximum visible width, preserving embedded ANSI escape codes. + /// If the text already fits, it is returned unchanged. The cut is grapheme-aware and never + /// splits a wide (two-column) grapheme: if the last column would split one, the cut happens + /// before it and the result is one column short (callers pad to the exact width). If SGR + /// styling or an OSC 8 hyperlink is active at the cut point, closing sequences are appended + /// so styling does not bleed past the truncated text. + /// + /// The text to truncate, potentially containing ANSI escape codes. + /// The maximum visible width in terminal columns. + /// The text truncated to at most visible columns. + public static string TruncateVisible(string? text, int maxWidth) + { + if (string.IsNullOrEmpty(text) || maxWidth <= 0) + { + return string.Empty; + } + + if (GetVisibleLength(text) <= maxWidth) + { + return text; + } + + return TakeVisibleFromStart(text, maxWidth); + } + + /// + /// Takes up to visible columns from the start of the text, + /// keeping embedded ANSI codes in their original positions. Never splits a wide grapheme. + /// If the text is cut while SGR styling or a hyperlink is active, closing sequences are + /// appended so the style does not bleed past the kept portion. + /// + internal static string TakeVisibleFromStart(string text, int maxColumns) + { + StringBuilder result = new(); + StringBuilder sgrState = new(); + string? hyperlinkState = null; + int width = 0; + bool cut = false; + + foreach (TextSegment segment in ParseTextSegments(text)) + { + if (cut) + { + break; + } + + if (segment.IsAnsiCode) + { + _ = result.Append(segment.Text); + UpdateAnsiState(sgrState, ref hyperlinkState, segment.Text); + continue; + } + + TextElementEnumerator graphemeEnum = StringInfo.GetTextElementEnumerator(segment.Text); + while (graphemeEnum.MoveNext()) + { + string grapheme = graphemeEnum.GetTextElement(); + int graphemeWidth = UnicodeWidth.GetTextWidth(grapheme); + + if (width + graphemeWidth > maxColumns) + { + cut = true; + break; + } + + _ = result.Append(grapheme); + width += graphemeWidth; + } + } + + if (cut) + { + if (hyperlinkState is not null) + { + _ = result.Append(HyperlinkClose); + } + + if (sgrState.Length > 0) + { + _ = result.Append(AnsiColors.Reset); + } + } + + return result.ToString(); + } + + /// + /// Takes up to visible columns from the end of the text, + /// keeping embedded ANSI codes in their original positions within the kept span. Never + /// splits a wide grapheme. SGR styling and any hyperlink established by the discarded + /// prefix are replayed before the kept tail so it renders with its original style. + /// + internal static string TakeVisibleFromEnd(string text, int maxColumns) + { + List segments = ParseTextSegments(text); + + // First pass: collect grapheme widths to find the grapheme-aligned keep width from the end + List graphemeWidths = []; + int totalWidth = 0; + + foreach (TextSegment segment in segments) + { + if (segment.IsAnsiCode) + { + continue; + } + + TextElementEnumerator graphemeEnum = StringInfo.GetTextElementEnumerator(segment.Text); + while (graphemeEnum.MoveNext()) + { + int graphemeWidth = UnicodeWidth.GetTextWidth(graphemeEnum.GetTextElement()); + graphemeWidths.Add(graphemeWidth); + totalWidth += graphemeWidth; + } + } + + if (totalWidth <= maxColumns) + { + return text; + } + + int keepWidth = 0; + for (int i = graphemeWidths.Count - 1; i >= 0; i--) + { + if (keepWidth + graphemeWidths[i] > maxColumns) + { + break; + } + + keepWidth += graphemeWidths[i]; + } + + int skipWidth = totalWidth - keepWidth; + + // Second pass: fold discarded-prefix codes into carry state, emit the kept span verbatim + StringBuilder kept = new(); + StringBuilder sgrState = new(); + string? hyperlinkState = null; + int accumulated = 0; + + foreach (TextSegment segment in segments) + { + if (segment.IsAnsiCode) + { + if (accumulated < skipWidth) + { + UpdateAnsiState(sgrState, ref hyperlinkState, segment.Text); + } + else + { + _ = kept.Append(segment.Text); + } + + continue; + } + + TextElementEnumerator graphemeEnum = StringInfo.GetTextElementEnumerator(segment.Text); + while (graphemeEnum.MoveNext()) + { + string grapheme = graphemeEnum.GetTextElement(); + + if (accumulated < skipWidth) + { + accumulated += UnicodeWidth.GetTextWidth(grapheme); + } + else + { + _ = kept.Append(grapheme); + } + } + } + + StringBuilder result = new(); + _ = result.Append(sgrState); + + if (hyperlinkState is not null) + { + _ = result.Append(hyperlinkState); + } + + _ = result.Append(kept); + + return result.ToString(); + } + /// /// Wraps text at word boundaries to fit within a specified width, preserving ANSI escape codes. + /// Visible characters separated only by ANSI codes (no whitespace) are treated as one word. /// /// The text to wrap, potentially containing ANSI escape codes. /// The maximum visible width per line. @@ -131,129 +384,69 @@ public static string CenterVisible(string? text, int totalWidth, char paddingCha /// /// string longText = "This is a very long text that needs wrapping"; /// var lines = AnsiStringUtils.WrapText(longText, 20); - /// // Returns multiple lines, each with visible length <= 20 + /// // Returns multiple lines, each with visible length <= 20 /// /// public static IReadOnlyList WrapText(string? text, int maxWidth) { if (string.IsNullOrEmpty(text)) + { return [""]; + } if (maxWidth < 1) + { maxWidth = 1; + } List result = []; - // Parse the text into segments (ANSI codes and visible text) - List segments = ParseTextSegments(text); + // Tokenize into words: whitespace in the visible text ends a word, embedded ANSI codes stay attached + List words = TokenizeWords(ParseTextSegments(text)); - // Track active ANSI state to carry across lines - StringBuilder activeAnsiState = new(); + // Track active state to carry across lines (SGR and hyperlink are independent channels) + StringBuilder sgrState = new(); + string? hyperlinkState = null; StringBuilder currentLine = new(); int currentLineWidth = 0; - foreach (TextSegment segment in segments) + foreach (string word in words) { - if (segment.IsAnsiCode) + int wordWidth = GetVisibleLength(word); + + if (wordWidth == 0) { - // Track ANSI state for carrying across lines - UpdateAnsiState(activeAnsiState, segment.Text); - currentLine.Append(segment.Text); + // ANSI-only token (no visible characters) — emit in place, occupies no width + AppendWord(currentLine, sgrState, ref hyperlinkState, word); + continue; } - else + + if (currentLineWidth + wordWidth <= maxWidth) { - // Process visible text - split into words - string[] words = SplitIntoWords(segment.Text); + // Word fits on the current line + AppendWord(currentLine, sgrState, ref hyperlinkState, word); + currentLineWidth += wordWidth; + } + else if (wordWidth <= maxWidth) + { + // Word fits on a line of its own — start a new line + StartNewLine(result, currentLine, sgrState, hyperlinkState); + currentLineWidth = 0; - foreach (string word in words) + string trimmedWord = TrimStartVisible(word); + AppendWord(currentLine, sgrState, ref hyperlinkState, trimmedWord); + currentLineWidth = GetVisibleLength(trimmedWord); + } + else + { + // Word is longer than maxWidth — break it by grapheme + if (currentLineWidth > 0) { - int wordLength = UnicodeWidth.GetTextWidth(word); - - if (wordLength == 0) - continue; - - // Check if word fits on current line - if (currentLineWidth + wordLength <= maxWidth) - { - currentLine.Append(word); - currentLineWidth += wordLength; - } - else if (currentLineWidth == 0) - { - // Word is longer than maxWidth, need to break it - TextElementEnumerator graphemeEnum = StringInfo.GetTextElementEnumerator(word); - while (graphemeEnum.MoveNext()) - { - string grapheme = graphemeEnum.GetTextElement(); - int graphemeWidth = UnicodeWidth.GetTextWidth(grapheme); - - if (currentLineWidth + graphemeWidth > maxWidth && currentLineWidth > 0) - { - // Close current line with reset if we have active state - if (activeAnsiState.Length > 0) - currentLine.Append(AnsiColors.Reset); - result.Add(currentLine.ToString()); - - // Start new line with active state - currentLine.Clear(); - currentLine.Append(activeAnsiState); - currentLineWidth = 0; - } - - currentLine.Append(grapheme); - currentLineWidth += graphemeWidth; - } - } - else - { - // Start a new line - // Close current line with reset if we have active state - if (activeAnsiState.Length > 0) - currentLine.Append(AnsiColors.Reset); - result.Add(currentLine.ToString()); - - // Start new line with active state - currentLine.Clear(); - currentLine.Append(activeAnsiState); - currentLineWidth = 0; - - // Handle the word on the new line - if (wordLength <= maxWidth) - { - // Trim leading space if word starts with space - string trimmedWord = word.TrimStart(); - currentLine.Append(trimmedWord); - currentLineWidth = UnicodeWidth.GetTextWidth(trimmedWord); - } - else - { - // Word is longer than maxWidth, break it - TextElementEnumerator graphemeEnum = StringInfo.GetTextElementEnumerator(word); - while (graphemeEnum.MoveNext()) - { - string grapheme = graphemeEnum.GetTextElement(); - int graphemeWidth = UnicodeWidth.GetTextWidth(grapheme); - - if (grapheme == " " && currentLineWidth == 0) - continue; // Skip leading spaces on new line - - if (currentLineWidth + graphemeWidth > maxWidth && currentLineWidth > 0) - { - if (activeAnsiState.Length > 0) - currentLine.Append(AnsiColors.Reset); - result.Add(currentLine.ToString()); - - currentLine.Clear(); - currentLine.Append(activeAnsiState); - currentLineWidth = 0; - } - - currentLine.Append(grapheme); - currentLineWidth += graphemeWidth; - } - } - } + StartNewLine(result, currentLine, sgrState, hyperlinkState); + currentLineWidth = 0; } + + BreakLongWord(word, maxWidth, result, currentLine, ref currentLineWidth, sgrState, ref hyperlinkState); } } @@ -265,7 +458,9 @@ public static IReadOnlyList WrapText(string? text, int maxWidth) // Ensure we return at least one empty line if (result.Count == 0) + { result.Add(""); + } return result; } @@ -310,30 +505,34 @@ private static List ParseTextSegments(string text) } /// - /// Splits text into "words" where each word includes any trailing whitespace. - /// This helps preserve spacing when wrapping. + /// Splits parsed segments into word tokens. A word ends at each whitespace character in the + /// visible text (the whitespace is kept as the word's last character); ANSI codes never end a + /// word, so visible characters separated only by ANSI codes stay in one token. /// - private static string[] SplitIntoWords(string text) + private static List TokenizeWords(List segments) { List words = []; StringBuilder current = new(); - foreach (char c in text) + foreach (TextSegment segment in segments) { - if (char.IsWhiteSpace(c)) + if (segment.IsAnsiCode) { - current.Append(c); - // End of word (including trailing space) - if (current.Length > 0) + _ = current.Append(segment.Text); + continue; + } + + foreach (char c in segment.Text) + { + _ = current.Append(c); + + if (char.IsWhiteSpace(c)) { + // End of word (including trailing whitespace) words.Add(current.ToString()); - current.Clear(); + _ = current.Clear(); } } - else - { - current.Append(c); - } } // Add remaining text @@ -342,44 +541,188 @@ private static string[] SplitIntoWords(string text) words.Add(current.ToString()); } - return [.. words]; + return words; } /// - /// Updates the active ANSI state based on an ANSI code. - /// Reset codes clear the state, other codes are accumulated. + /// Appends a word to the current line and updates the active state from any ANSI codes it contains. /// - private static void UpdateAnsiState(StringBuilder state, string ansiCode) + private static void AppendWord(StringBuilder currentLine, StringBuilder sgrState, ref string? hyperlinkState, string word) { - // Check if this is a reset code - if (ansiCode is "\x1b[0m" or "\x1b[m") + _ = currentLine.Append(word); + + if (!word.Contains('\x1b', StringComparison.Ordinal)) { - state.Clear(); + return; } - // Check if this is an OSC 8 end hyperlink sequence (reset hyperlink) - else if (ansiCode.StartsWith("\x1b]8;;", StringComparison.Ordinal) && - (ansiCode.EndsWith("\x1b\\", StringComparison.Ordinal) || ansiCode.EndsWith('\x07'))) + + foreach (Match match in AnsiRegex().Matches(word)) { - // Check if it's an empty URL (end of hyperlink) - string url = ansiCode.StartsWith("\x1b]8;;", StringComparison.Ordinal) - ? ansiCode[5..^(ansiCode.EndsWith("\x1b\\", StringComparison.Ordinal) ? 2 : 1)] - : ""; + UpdateAnsiState(sgrState, ref hyperlinkState, match.Value); + } + } - if (string.IsNullOrEmpty(url)) + /// + /// Finishes the current line (closing any active SGR styling and hyperlink so state does not + /// bleed past the break), adds it to the result, and seeds the new line with the active state. + /// + private static void StartNewLine(List result, StringBuilder currentLine, StringBuilder sgrState, string? hyperlinkState) + { + if (hyperlinkState is not null) + { + _ = currentLine.Append(HyperlinkClose); + } + + if (sgrState.Length > 0) + { + _ = currentLine.Append(AnsiColors.Reset); + } + + result.Add(currentLine.ToString()); + + // Re-open active channels on the new line + _ = currentLine.Clear(); + _ = currentLine.Append(sgrState); + + if (hyperlinkState is not null) + { + _ = currentLine.Append(hyperlinkState); + } + } + + /// + /// Breaks a word wider than across lines grapheme by grapheme, + /// emitting embedded ANSI codes in place and keeping the carry state current. + /// + private static void BreakLongWord + ( + string word, + int maxWidth, + List result, + StringBuilder currentLine, + ref int currentLineWidth, + StringBuilder sgrState, + ref string? hyperlinkState + ) + { + foreach (TextSegment part in ParseTextSegments(word)) + { + if (part.IsAnsiCode) { - // This is an end hyperlink, remove hyperlink from state - // For simplicity, we'll just keep accumulating - hyperlinks are self-closing + _ = currentLine.Append(part.Text); + UpdateAnsiState(sgrState, ref hyperlinkState, part.Text); + continue; } - else + + TextElementEnumerator graphemeEnum = StringInfo.GetTextElementEnumerator(part.Text); + while (graphemeEnum.MoveNext()) { - // This is a start hyperlink, add to state - state.Append(ansiCode); + string grapheme = graphemeEnum.GetTextElement(); + + if (grapheme == " " && currentLineWidth == 0) + { + continue; // Skip leading spaces on new line + } + + int graphemeWidth = UnicodeWidth.GetTextWidth(grapheme); + + if (currentLineWidth + graphemeWidth > maxWidth && currentLineWidth > 0) + { + StartNewLine(result, currentLine, sgrState, hyperlinkState); + currentLineWidth = 0; + } + + _ = currentLine.Append(grapheme); + currentLineWidth += graphemeWidth; } } - else + } + + /// + /// Removes leading whitespace from the visible text of a word while preserving its ANSI codes. + /// + private static string TrimStartVisible(string word) + { + if (!word.Contains('\x1b', StringComparison.Ordinal)) { - // Accumulate other ANSI codes (colors, styles) - state.Append(ansiCode); + return word.TrimStart(); + } + + StringBuilder trimmed = new(word.Length); + bool visibleSeen = false; + + foreach (TextSegment part in ParseTextSegments(word)) + { + if (part.IsAnsiCode) + { + _ = trimmed.Append(part.Text); + continue; + } + + string visibleText = visibleSeen ? part.Text : part.Text.TrimStart(); + + if (visibleText.Length > 0) + { + visibleSeen = true; + _ = trimmed.Append(visibleText); + } } + + return trimmed.ToString(); + } + + /// + /// Updates the active line-carry state from an ANSI code. SGR codes accumulate and an SGR reset + /// clears them; OSC 8 hyperlinks are an independent channel opened by a non-empty URL and + /// cleared by an empty one (an SGR reset does NOT clear the hyperlink). Other sequences + /// (cursor movement, erase, titles) are one-shot actions and never carried across lines. + /// + private static void UpdateAnsiState(StringBuilder sgrState, ref string? hyperlinkState, string ansiCode) + { + if (ansiCode is "\x1b[0m" or "\x1b[m") + { + // SGR reset clears styling only — the hyperlink channel is independent + _ = sgrState.Clear(); + } + else if (ansiCode.StartsWith("\x1b]8;", StringComparison.Ordinal)) + { + // Non-empty URL opens a hyperlink; empty URL closes it + string url = ExtractHyperlinkUrl(ansiCode); + hyperlinkState = url.Length == 0 ? null : ansiCode; + } + else if (ansiCode.StartsWith("\x1b[", StringComparison.Ordinal) && ansiCode.EndsWith('m')) + { + // SGR styling (colors, bold, ...) accumulates + _ = sgrState.Append(ansiCode); + } + + // Everything else (other CSI, other OSC, two-byte ESC sequences) is not style state + } + + /// + /// Extracts the URL from an OSC 8 hyperlink sequence (ESC ] 8 ; params ; URL, terminated by BEL or ST). + /// + private static string ExtractHyperlinkUrl(string ansiCode) + { + int terminatorLength = 0; + + if (ansiCode.EndsWith("\x1b\\", StringComparison.Ordinal)) + { + terminatorLength = 2; + } + else if (ansiCode.EndsWith('\x07')) + { + terminatorLength = 1; + } + + // URL starts after the ';' that ends the params section ("\x1b]8;" is 4 characters) + int separatorIndex = ansiCode.IndexOf(';', 4); + + if (separatorIndex < 0 || separatorIndex + 1 > ansiCode.Length - terminatorLength) + { + return string.Empty; + } + + return ansiCode[(separatorIndex + 1)..^terminatorLength]; } } diff --git a/source/timewarp-terminal/widgets/box-chars.cs b/source/timewarp-terminal/widgets/box-chars.cs index c410b18..7d50724 100644 --- a/source/timewarp-terminal/widgets/box-chars.cs +++ b/source/timewarp-terminal/widgets/box-chars.cs @@ -16,6 +16,7 @@ public static class BoxChars BorderStyle.Square => '┌', // U+250C BorderStyle.Doubled => '╔', // U+2554 BorderStyle.Heavy => '┏', // U+250F + BorderStyle.None => ' ', _ => ' ' }; @@ -30,6 +31,7 @@ public static class BoxChars BorderStyle.Square => '┐', // U+2510 BorderStyle.Doubled => '╗', // U+2557 BorderStyle.Heavy => '┓', // U+2513 + BorderStyle.None => ' ', _ => ' ' }; @@ -44,6 +46,7 @@ public static class BoxChars BorderStyle.Square => '└', // U+2514 BorderStyle.Doubled => '╚', // U+255A BorderStyle.Heavy => '┗', // U+2517 + BorderStyle.None => ' ', _ => ' ' }; @@ -58,6 +61,7 @@ public static class BoxChars BorderStyle.Square => '┘', // U+2518 BorderStyle.Doubled => '╝', // U+255D BorderStyle.Heavy => '┛', // U+251B + BorderStyle.None => ' ', _ => ' ' }; @@ -72,6 +76,7 @@ public static class BoxChars BorderStyle.Square => '─', // U+2500 BorderStyle.Doubled => '═', // U+2550 BorderStyle.Heavy => '━', // U+2501 + BorderStyle.None => ' ', _ => ' ' }; @@ -86,6 +91,7 @@ public static class BoxChars BorderStyle.Square => '│', // U+2502 BorderStyle.Doubled => '║', // U+2551 BorderStyle.Heavy => '┃', // U+2503 + BorderStyle.None => ' ', _ => ' ' }; @@ -103,6 +109,7 @@ public static class BoxChars BorderStyle.Square => '┬', // U+252C BorderStyle.Doubled => '╦', // U+2566 BorderStyle.Heavy => '┳', // U+2533 + BorderStyle.None => ' ', _ => ' ' }; @@ -118,6 +125,7 @@ public static class BoxChars BorderStyle.Square => '┴', // U+2534 BorderStyle.Doubled => '╩', // U+2569 BorderStyle.Heavy => '┻', // U+253B + BorderStyle.None => ' ', _ => ' ' }; @@ -133,6 +141,7 @@ public static class BoxChars BorderStyle.Square => '├', // U+251C BorderStyle.Doubled => '╠', // U+2560 BorderStyle.Heavy => '┣', // U+2523 + BorderStyle.None => ' ', _ => ' ' }; @@ -148,6 +157,7 @@ public static class BoxChars BorderStyle.Square => '┤', // U+2524 BorderStyle.Doubled => '╣', // U+2563 BorderStyle.Heavy => '┫', // U+252B + BorderStyle.None => ' ', _ => ' ' }; @@ -163,6 +173,7 @@ public static class BoxChars BorderStyle.Square => '┼', // U+253C BorderStyle.Doubled => '╬', // U+256C BorderStyle.Heavy => '╋', // U+254B + BorderStyle.None => ' ', _ => ' ' }; } diff --git a/source/timewarp-terminal/widgets/panel-widget.cs b/source/timewarp-terminal/widgets/panel-widget.cs index abd1966..e7c2acc 100644 --- a/source/timewarp-terminal/widgets/panel-widget.cs +++ b/source/timewarp-terminal/widgets/panel-widget.cs @@ -1,5 +1,18 @@ namespace TimeWarp.Terminal; +#region Purpose +// Panel widget: renders content inside a bordered box with optional header, padding, and word wrap. +#endregion + +#region Design +// Every content row is rendered at exactly the content-area width so the right border always +// aligns: with WordWrap on, lines are wrapped by AnsiStringUtils.WrapText; with WordWrap off, +// over-long lines are truncated with AnsiStringUtils.TruncateVisible — a plain ANSI-aware, +// grapheme-aware cut (no ellipsis; the panel has no ellipsis convention) that appends a reset +// when styling is active at the cut so the border and padding are never styled. A wide grapheme +// straddling the last column is dropped and the shortfall is absorbed by PadRightVisible. +#endregion + /// /// Represents a bordered box (panel) for terminal output. /// Can optionally include a header in the top border. @@ -92,7 +105,9 @@ public string[] Render(int terminalWidth = 80) private string[] RenderWithoutBorder() { if (string.IsNullOrEmpty(Content)) + { return []; + } return Content.Split('\n'); } @@ -113,7 +128,10 @@ private string[] RenderWithBorder(int terminalWidth) // Content area width = total width - 2 borders - 2×horizontal padding int contentAreaWidth = width - 2 - (2 * PaddingHorizontal); - if (contentAreaWidth < 1) contentAreaWidth = 1; + if (contentAreaWidth < 1) + { + contentAreaWidth = 1; + } // Split content into lines and optionally wrap List contentLines = []; @@ -218,8 +236,9 @@ private string RenderContentRow(string content, char vertical, int contentAreaWi string padding = new(' ', PaddingHorizontal); - // Pad or truncate content to fit content area - string paddedContent = AnsiStringUtils.PadRightVisible(content, contentAreaWidth); + // Truncate (ANSI-aware, grapheme-aware, no ellipsis) then pad to exactly the content area width + string truncatedContent = AnsiStringUtils.TruncateVisible(content, contentAreaWidth); + string paddedContent = AnsiStringUtils.PadRightVisible(truncatedContent, contentAreaWidth); return $"{colorStart}{vertical}{colorEnd}{padding}{paddedContent}{padding}{colorStart}{vertical}{colorEnd}"; } diff --git a/source/timewarp-terminal/widgets/rule-widget.cs b/source/timewarp-terminal/widgets/rule-widget.cs index b8a31a5..4368aa7 100644 --- a/source/timewarp-terminal/widgets/rule-widget.cs +++ b/source/timewarp-terminal/widgets/rule-widget.cs @@ -53,65 +53,40 @@ internal Rule() { } /// The rendered rule string. public string Render(int terminalWidth = 80) { - int width = Width ?? terminalWidth; + int width = Math.Max(0, Width ?? terminalWidth); char lineChar = LineChars.GetHorizontal(Style); + bool hasColor = !string.IsNullOrEmpty(Color); - string line; if (string.IsNullOrEmpty(Title)) { // Simple line without title - line = new string(lineChar, width); - } - else - { - // Line with centered title - int titleVisibleLength = AnsiStringUtils.GetVisibleLength(Title); - - // Need at least: 1 char + space + title + space + 1 char - int minimumWidth = titleVisibleLength + 4; - if (width < minimumWidth) - { - // Not enough space, just show the title - line = Title; - } - else - { - // Calculate padding for centered title - int availableForLines = width - titleVisibleLength - 2; // -2 for spaces around title - int leftLineLength = availableForLines / 2; - int rightLineLength = availableForLines - leftLineLength; - - string leftLine = new(lineChar, leftLineLength); - string rightLine = new(lineChar, rightLineLength); - - line = $"{leftLine} {Title} {rightLine}"; - } + string line = new(lineChar, width); + return hasColor ? Color + line + AnsiColors.Reset : line; } - // Apply color if specified - if (!string.IsNullOrEmpty(Color)) + // Line with centered title + int titleVisibleLength = AnsiStringUtils.GetVisibleLength(Title); + + // Need at least: 1 char + space + title + space + 1 char + int minimumWidth = titleVisibleLength + 4; + if (width < minimumWidth) { - // Only colorize the line characters, not the title (title may have its own colors) - if (string.IsNullOrEmpty(Title)) - { - line = Color + line + AnsiColors.Reset; - } - else - { - // Colorize just the line parts, preserve title styling - int titleVisibleLength = AnsiStringUtils.GetVisibleLength(Title); - int availableForLines = width - titleVisibleLength - 2; - int leftLineLength = availableForLines / 2; - int rightLineLength = availableForLines - leftLineLength; - - string leftLine = new(lineChar, leftLineLength); - string rightLine = new(lineChar, rightLineLength); - - line = $"{Color}{leftLine}{AnsiColors.Reset} {Title} {Color}{rightLine}{AnsiColors.Reset}"; - } + // Not enough space, just show the title + return Title; } - return line; + // Calculate padding for centered title + int availableForLines = width - titleVisibleLength - 2; // -2 for spaces around title + int leftLineLength = availableForLines / 2; + int rightLineLength = availableForLines - leftLineLength; + + string leftLine = new(lineChar, leftLineLength); + string rightLine = new(lineChar, rightLineLength); + + // Only colorize the line characters, not the title (title may have its own colors) + return hasColor + ? $"{Color}{leftLine}{AnsiColors.Reset} {Title} {Color}{rightLine}{AnsiColors.Reset}" + : $"{leftLine} {Title} {rightLine}"; } } diff --git a/source/timewarp-terminal/widgets/table-builder.cs b/source/timewarp-terminal/widgets/table-builder.cs index 45ba46e..9cbfa1f 100644 --- a/source/timewarp-terminal/widgets/table-builder.cs +++ b/source/timewarp-terminal/widgets/table-builder.cs @@ -31,7 +31,7 @@ public sealed class TableBuilder : IBuilder /// This builder for method chaining. public TableBuilder AddColumn(string header) { - Table.AddColumn(header); + _ = Table.AddColumn(header); return this; } @@ -43,7 +43,7 @@ public TableBuilder AddColumn(string header) /// This builder for method chaining. public TableBuilder AddColumn(string header, Alignment alignment) { - Table.AddColumn(header, alignment); + _ = Table.AddColumn(header, alignment); return this; } @@ -54,7 +54,7 @@ public TableBuilder AddColumn(string header, Alignment alignment) /// This builder for method chaining. public TableBuilder AddColumn(TableColumn column) { - Table.AddColumn(column); + _ = Table.AddColumn(column); return this; } @@ -65,7 +65,7 @@ public TableBuilder AddColumn(TableColumn column) /// This builder for method chaining. public TableBuilder AddColumns(params string[] headers) { - Table.AddColumns(headers); + _ = Table.AddColumns(headers); return this; } @@ -76,7 +76,7 @@ public TableBuilder AddColumns(params string[] headers) /// This builder for method chaining. public TableBuilder AddColumns(params TableColumn[] columns) { - Table.AddColumns(columns); + _ = Table.AddColumns(columns); return this; } @@ -87,7 +87,7 @@ public TableBuilder AddColumns(params TableColumn[] columns) /// This builder for method chaining. public TableBuilder AddRow(params string[] cells) { - Table.AddRow(cells); + _ = Table.AddRow(cells); return this; } @@ -136,6 +136,9 @@ public TableBuilder ShowRowSeparators() /// /// Expands the table to fill the terminal width. /// + /// + /// Has no effect when the border style is ; borderless tables always render at their natural width. + /// /// This builder for method chaining. public TableBuilder Expand() { @@ -144,15 +147,40 @@ public TableBuilder Expand() } /// - /// - /// Builds the configured instance. + /// Builds a snapshot of the current builder state. + /// Each call returns an independent table; mutating the builder afterwards + /// does not affect previously built tables. /// /// The configured table. - public Table Build() => Table; + public Table Build() + { + Table snapshot = new() + { + Border = Table.Border, + BorderColor = Table.BorderColor, + ShowHeaders = Table.ShowHeaders, + ShowRowSeparators = Table.ShowRowSeparators, + Expand = Table.Expand + }; + + // Copy the lists so the snapshot is independent of the builder. + // TableColumn instances are settings objects and may be shared. + foreach (TableColumn column in Table.Columns) + { + _ = snapshot.AddColumn(column); + } + + foreach (string[] row in Table.Rows) + { + _ = snapshot.AddRow(row); + } + + return snapshot; + } /// - /// Converts the builder to a . - /// Alternate method for languages that don't support implicit operators. + /// Builds a snapshot of the current builder state. + /// Explicit alternative to . /// /// The configured table. public Table ToTable() => Build(); diff --git a/source/timewarp-terminal/widgets/table-widget.cs b/source/timewarp-terminal/widgets/table-widget.cs index 2e8a840..391bdb9 100644 --- a/source/timewarp-terminal/widgets/table-widget.cs +++ b/source/timewarp-terminal/widgets/table-widget.cs @@ -1,5 +1,20 @@ namespace TimeWarp.Terminal; +#region Purpose +// Table widget: renders formatted columnar data with headers, alignment, borders, and width management. +#endregion + +#region Design +// Cell truncation preserves ANSI escape codes for all TruncateMode values: codes within the kept +// visible span stay in their original positions, and for Start/Middle the style established by the +// discarded prefix is replayed before the kept tail (AnsiStringUtils.TakeVisibleFromEnd), so a +// color opened before the cut still styles the kept text. The ellipsis is always PLAIN: any style +// active at a cut boundary is closed with a reset (AnsiStringUtils.TakeVisibleFromStart) so +// styling never bleeds into the ellipsis, padding, or borders. Cuts are grapheme-aware and never +// split a wide (two-column) grapheme; a straddling grapheme is dropped and AlignCell pads the +// shortfall. +#endregion + /// /// Represents a table widget for rendering formatted columnar data with headers, alignment, and borders. /// @@ -53,6 +68,9 @@ internal Table() { } /// Gets or sets a value indicating whether the table should expand to fill the terminal width. /// Defaults to false. /// + /// + /// Has no effect when is ; borderless tables always render at their natural width. + /// public bool Expand { get; set; } /// @@ -136,6 +154,7 @@ internal Table AddColumns(params TableColumn[] columns) /// internal Table AddRow(params string[] cells) { + ArgumentNullException.ThrowIfNull(cells); RowsList.Add(cells); return this; } @@ -148,7 +167,9 @@ internal Table AddRow(params string[] cells) public string[] Render(int terminalWidth = 80) { if (ColumnsList.Count == 0) + { return []; + } int[] columnWidths = CalculateColumnWidths(terminalWidth); @@ -207,40 +228,67 @@ private int[] CalculateColumnWidths(int terminalWidth) for (int i = 0; i < ColumnsList.Count; i++) { if (!ColumnsList[i].Grow) + { fixedContentWidth += widths[i]; + } + } + + // A grow column is never sized below max(4, MinWidth) + int[] growMinWidths = new int[ColumnsList.Count]; + int totalGrowMin = 0; + int growCount = 0; + for (int i = 0; i < ColumnsList.Count; i++) + { + if (ColumnsList[i].Grow) + { + growMinWidths[i] = Math.Max(4, ColumnsList[i].MinWidth ?? 4); + totalGrowMin += growMinWidths[i]; + growCount++; + } } int availableForGrow = terminalWidth - overhead - fixedContentWidth; - int growCount = ColumnsList.Count(c => c.Grow); - if (availableForGrow > 0) + if (availableForGrow >= totalGrowMin) { - // Distribute remaining space evenly among grow columns - int perGrowColumn = availableForGrow / growCount; - int remainder = availableForGrow % growCount; + // Give each grow column its minimum, then distribute the surplus evenly + int surplus = availableForGrow - totalGrowMin; + int perGrowColumn = surplus / growCount; + int remainder = surplus % growCount; int growIndex = 0; for (int i = 0; i < ColumnsList.Count; i++) { if (ColumnsList[i].Grow) { - widths[i] = perGrowColumn + (growIndex < remainder ? 1 : 0); + widths[i] = growMinWidths[i] + perGrowColumn + (growIndex < remainder ? 1 : 0); growIndex++; } } } else { - // Not enough space — shrink fixed columns first, then grow columns - int excessWidth = overhead + fixedContentWidth - terminalWidth; + // Not enough space — grow columns hold at their minimum width and the + // remaining overflow is shrunk out of the fixed columns proportionally + for (int i = 0; i < ColumnsList.Count; i++) + { + if (ColumnsList[i].Grow) + { + widths[i] = growMinWidths[i]; + } + } + + int excessWidth = overhead + fixedContentWidth + totalGrowMin - terminalWidth; if (excessWidth > 0) { int[] minWidths = new int[ColumnsList.Count]; for (int i = 0; i < ColumnsList.Count; i++) + { minWidths[i] = ColumnsList[i].MinWidth ?? 4; + } - // Shrink fixed columns proportionally first + // Shrink fixed columns proportionally (grow columns are already at minimum) int[] shrinkableAmounts = new int[ColumnsList.Count]; int totalShrinkable = 0; for (int i = 0; i < ColumnsList.Count; i++) @@ -265,13 +313,6 @@ private int[] CalculateColumnWidths(int terminalWidth) } } } - - // Grow columns get zero width — fixed columns already consumed all available space - for (int i = 0; i < ColumnsList.Count; i++) - { - if (ColumnsList[i].Grow) - widths[i] = 0; - } } return widths; @@ -284,15 +325,38 @@ private int[] CalculateColumnWidths(int terminalWidth) if (Expand && Border != BorderStyle.None && totalWidth < terminalWidth) { int extraWidth = terminalWidth - totalWidth; - int perColumn = extraWidth / ColumnsList.Count; - int remainder = extraWidth % ColumnsList.Count; - for (int i = 0; i < ColumnsList.Count; i++) + // Distribute extra width among columns that are not capped at MaxWidth. + // Loop because a round can push a column to its cap, requiring its + // unused share to be redistributed to the remaining uncapped columns. + while (extraWidth > 0) { - widths[i] += perColumn; - if (i < remainder) + List expandableIndices = []; + for (int i = 0; i < ColumnsList.Count; i++) { - widths[i]++; + int? maxWidth = ColumnsList[i].MaxWidth; + if (!maxWidth.HasValue || widths[i] < maxWidth.Value) + { + expandableIndices.Add(i); + } + } + + if (expandableIndices.Count == 0) + { + break; // All columns capped at MaxWidth — stop distributing + } + + int perColumn = extraWidth / expandableIndices.Count; + int remainder = extraWidth % expandableIndices.Count; + + for (int j = 0; j < expandableIndices.Count; j++) + { + int i = expandableIndices[j]; + int share = perColumn + (j < remainder ? 1 : 0); + int? maxWidth = ColumnsList[i].MaxWidth; + int addition = maxWidth.HasValue ? Math.Min(share, maxWidth.Value - widths[i]) : share; + widths[i] += addition; + extraWidth -= addition; } } } @@ -421,15 +485,18 @@ private string RenderHorizontalBorder(int[] columnWidths, char horizontal, char string colorEnd = !string.IsNullOrEmpty(BorderColor) ? AnsiColors.Reset : ""; StringBuilder sb = new(); - sb.Append(colorStart); - sb.Append(left); + _ = sb.Append(colorStart); + _ = sb.Append(left); for (int i = 0; i < columnWidths.Length; i++) { - if (columnWidths[i] == 0) continue; + if (columnWidths[i] == 0) + { + continue; + } // Each cell has padding (1 space on each side) plus content width - sb.Append(horizontal, columnWidths[i] + 2); + _ = sb.Append(horizontal, columnWidths[i] + 2); if (i < columnWidths.Length - 1) { @@ -441,12 +508,14 @@ private string RenderHorizontalBorder(int[] columnWidths, char horizontal, char } if (hasNextNonZero) - sb.Append(junction); + { + _ = sb.Append(junction); + } } } - sb.Append(right); - sb.Append(colorEnd); + _ = sb.Append(right); + _ = sb.Append(colorEnd); return sb.ToString(); } @@ -457,13 +526,16 @@ private string RenderCellRow(string[] cells, int[] columnWidths, char vertical, string colorEnd = !string.IsNullOrEmpty(BorderColor) ? AnsiColors.Reset : ""; StringBuilder sb = new(); - sb.Append(colorStart); - sb.Append(vertical); - sb.Append(colorEnd); + _ = sb.Append(colorStart); + _ = sb.Append(vertical); + _ = sb.Append(colorEnd); for (int i = 0; i < columnWidths.Length; i++) { - if (columnWidths[i] == 0) continue; + if (columnWidths[i] == 0) + { + continue; + } string cellValue = i < cells.Length ? cells[i] ?? "" : ""; TableColumn column = ColumnsList[i]; @@ -484,12 +556,12 @@ private string RenderCellRow(string[] cells, int[] columnWidths, char vertical, // Apply alignment string alignedCell = AlignCell(cellValue, columnWidths[i], column.Alignment); - sb.Append(' '); // Left padding - sb.Append(alignedCell); - sb.Append(' '); // Right padding - sb.Append(colorStart); - sb.Append(vertical); - sb.Append(colorEnd); + _ = sb.Append(' '); // Left padding + _ = sb.Append(alignedCell); + _ = sb.Append(' '); // Right padding + _ = sb.Append(colorStart); + _ = sb.Append(vertical); + _ = sb.Append(colorEnd); } return sb.ToString(); @@ -522,10 +594,10 @@ private string RenderDataRow(string[] cells, int[] columnWidths, bool isHeader) if (i > 0) { - sb.Append(" "); // Column separator for borderless tables + _ = sb.Append(" "); // Column separator for borderless tables } - sb.Append(alignedCell); + _ = sb.Append(alignedCell); } return sb.ToString(); @@ -549,19 +621,20 @@ private static string TruncateWithEllipsis(string text, int maxWidth, TruncateMo return new string('.', maxWidth); } - // Strip ANSI codes to get plain text - string plainText = AnsiStringUtils.StripAnsiCodes(text); - - if (UnicodeWidth.GetTextWidth(plainText) <= maxWidth) + if (AnsiStringUtils.GetVisibleLength(text) <= maxWidth) { return text; // No truncation needed } + // ANSI codes in the kept visible span stay in place; the ellipsis is always plain: + // TakeVisibleFromStart closes any style active at the cut, and TakeVisibleFromEnd replays + // the style established by the discarded prefix so the kept tail keeps its color. return mode switch { - TruncateMode.Start => "..." + TakeGraphemesFromEnd(plainText, maxWidth - 3), - TruncateMode.Middle => TruncateMiddle(plainText, maxWidth), - _ => TakeGraphemesFromStart(plainText, maxWidth - 3) + "..." + TruncateMode.Start => "..." + AnsiStringUtils.TakeVisibleFromEnd(text, maxWidth - 3), + TruncateMode.Middle => TruncateMiddle(text, maxWidth), + TruncateMode.End => AnsiStringUtils.TakeVisibleFromStart(text, maxWidth - 3) + "...", + _ => AnsiStringUtils.TakeVisibleFromStart(text, maxWidth - 3) + "..." }; } @@ -571,59 +644,6 @@ private static string TruncateMiddle(string text, int maxWidth) int startWidth = (availableWidth + 1) / 2; int endWidth = availableWidth - startWidth; - return TakeGraphemesFromStart(text, startWidth) + "..." + TakeGraphemesFromEnd(text, endWidth); - } - - private static string TakeGraphemesFromStart(string text, int maxColumns) - { - StringBuilder sb = new(); - int width = 0; - TextElementEnumerator enumerator = StringInfo.GetTextElementEnumerator(text); - while (enumerator.MoveNext()) - { - string grapheme = enumerator.GetTextElement(); - int gw = UnicodeWidth.GetTextWidth(grapheme); - if (width + gw > maxColumns) - break; - sb.Append(grapheme); - width += gw; - } - - return sb.ToString(); - } - - private static string TakeGraphemesFromEnd(string text, int maxColumns) - { - // Collect all grapheme clusters, then take from end - List graphemes = []; - TextElementEnumerator enumerator = StringInfo.GetTextElementEnumerator(text); - while (enumerator.MoveNext()) - graphemes.Add(enumerator.GetTextElement()); - - StringBuilder sb = new(); - int width = 0; - for (int i = graphemes.Count - 1; i >= 0; i--) - { - int gw = UnicodeWidth.GetTextWidth(graphemes[i]); - if (width + gw > maxColumns) - break; - width += gw; - } - - // Now collect from the correct starting position - int skipWidth = UnicodeWidth.GetTextWidth(text) - width; - int accumulated = 0; - sb.Clear(); - enumerator = StringInfo.GetTextElementEnumerator(text); - while (enumerator.MoveNext()) - { - string grapheme = enumerator.GetTextElement(); - int gw = UnicodeWidth.GetTextWidth(grapheme); - accumulated += gw; - if (accumulated > skipWidth) - sb.Append(grapheme); - } - - return sb.ToString(); + return AnsiStringUtils.TakeVisibleFromStart(text, startWidth) + "..." + AnsiStringUtils.TakeVisibleFromEnd(text, endWidth); } } diff --git a/source/timewarp-terminal/widgets/terminal-panel-extensions.cs b/source/timewarp-terminal/widgets/terminal-panel-extensions.cs index 0e60c11..738845c 100644 --- a/source/timewarp-terminal/widgets/terminal-panel-extensions.cs +++ b/source/timewarp-terminal/widgets/terminal-panel-extensions.cs @@ -177,25 +177,31 @@ private static void WritePanelInternal(ITerminal terminal, Panel panel) string[] lines = panel.Render(terminal.WindowWidth); foreach (string line in lines) { - terminal.WriteLine(line); + _ = terminal.WriteLine(line); } } private static void WriteLinesWithColor(ITerminal terminal, string[] lines, ConsoleColor? foregroundColor, ConsoleColor? backgroundColor) { + bool useColor = (foregroundColor.HasValue || backgroundColor.HasValue) && terminal.SupportsColor; + string colorPrefix = useColor + ? (foregroundColor.HasValue ? AnsiColors.GetForeground(foregroundColor.Value) : "") + + (backgroundColor.HasValue ? AnsiColors.GetBackground(backgroundColor.Value) : "") + : ""; foreach (string line in lines) { - if (foregroundColor.HasValue || backgroundColor.HasValue) + if (useColor) { - string coloredLine = (foregroundColor.HasValue ? AnsiColors.GetForeground(foregroundColor.Value) : "") + - (backgroundColor.HasValue ? AnsiColors.GetBackground(backgroundColor.Value) : "") + - line + + // Re-apply the color prefix after any embedded SGR reset (from BorderColor or styled + // content) so the requested colors are not cancelled mid-line. + string coloredLine = colorPrefix + + line.Replace(AnsiColors.Reset, AnsiColors.Reset + colorPrefix, StringComparison.Ordinal) + AnsiColors.Reset; - terminal.WriteLine(coloredLine); + _ = terminal.WriteLine(coloredLine); } else { - terminal.WriteLine(line); + _ = terminal.WriteLine(line); } } } diff --git a/source/timewarp-terminal/widgets/terminal-rule-extensions.cs b/source/timewarp-terminal/widgets/terminal-rule-extensions.cs index 81967c8..e7f27c9 100644 --- a/source/timewarp-terminal/widgets/terminal-rule-extensions.cs +++ b/source/timewarp-terminal/widgets/terminal-rule-extensions.cs @@ -35,7 +35,7 @@ public static ITerminal WriteRule(this ITerminal terminal, LineStyle style = Lin Rule rule = new() { Style = style }; string rendered = rule.Render(terminal.WindowWidth); - terminal.WriteLine(rendered); + _ = terminal.WriteLine(rendered); return terminal; } @@ -52,7 +52,7 @@ public static ITerminal WriteRule(this ITerminal terminal, string title, LineSty Rule rule = new() { Title = title, Style = style }; string rendered = rule.Render(terminal.WindowWidth); - terminal.WriteLine(rendered); + _ = terminal.WriteLine(rendered); return terminal; } @@ -80,7 +80,7 @@ public static ITerminal WriteRule(this ITerminal terminal, Action c Rule rule = builder.Build(); string rendered = rule.Render(terminal.WindowWidth); - terminal.WriteLine(rendered); + _ = terminal.WriteLine(rendered); return terminal; } @@ -96,7 +96,7 @@ public static ITerminal WriteRule(this ITerminal terminal, Rule rule) ArgumentNullException.ThrowIfNull(rule); string rendered = rule.Render(terminal.WindowWidth); - terminal.WriteLine(rendered); + _ = terminal.WriteLine(rendered); return terminal; } } diff --git a/source/timewarp-terminal/widgets/terminal-table-extensions.cs b/source/timewarp-terminal/widgets/terminal-table-extensions.cs index 6e0650a..9755efc 100644 --- a/source/timewarp-terminal/widgets/terminal-table-extensions.cs +++ b/source/timewarp-terminal/widgets/terminal-table-extensions.cs @@ -86,19 +86,25 @@ public static ITerminal WriteTable(this ITerminal terminal, Table table, Console private static void WriteLinesWithColor(ITerminal terminal, string[] lines, ConsoleColor? foregroundColor, ConsoleColor? backgroundColor) { + bool useColor = (foregroundColor.HasValue || backgroundColor.HasValue) && terminal.SupportsColor; + string colorPrefix = useColor + ? (foregroundColor.HasValue ? AnsiColors.GetForeground(foregroundColor.Value) : "") + + (backgroundColor.HasValue ? AnsiColors.GetBackground(backgroundColor.Value) : "") + : ""; foreach (string line in lines) { - if (foregroundColor.HasValue || backgroundColor.HasValue) + if (useColor) { - string coloredLine = (foregroundColor.HasValue ? AnsiColors.GetForeground(foregroundColor.Value) : "") + - (backgroundColor.HasValue ? AnsiColors.GetBackground(backgroundColor.Value) : "") + - line + + // Re-apply the color prefix after any embedded SGR reset (from BorderColor or styled + // cells) so the requested colors are not cancelled mid-line. + string coloredLine = colorPrefix + + line.Replace(AnsiColors.Reset, AnsiColors.Reset + colorPrefix, StringComparison.Ordinal) + AnsiColors.Reset; - terminal.WriteLine(coloredLine); + _ = terminal.WriteLine(coloredLine); } else { - terminal.WriteLine(line); + _ = terminal.WriteLine(line); } } } @@ -123,7 +129,7 @@ private static void WriteTableInternal(ITerminal terminal, Table table) string[] lines = table.Render(terminal.WindowWidth); foreach (string line in lines) { - terminal.WriteLine(line); + _ = terminal.WriteLine(line); } } } diff --git a/source/timewarp-terminal/widgets/unicode-width.cs b/source/timewarp-terminal/widgets/unicode-width.cs index 4247be5..03898ad 100644 --- a/source/timewarp-terminal/widgets/unicode-width.cs +++ b/source/timewarp-terminal/widgets/unicode-width.cs @@ -1,5 +1,24 @@ namespace TimeWarp.Terminal; +#region Design +// Measurement rules (target: match modern terminal wcwidth, e.g. foot/kitty/wezterm): +// 1. Rune width comes from Unicode 16 East_Asian_Width (W/F => 2) plus +// Emoji_Presentation=Yes code points (=> 2). Control characters, combining +// marks, format characters, ZWJ/ZWSP/ZWNJ/WJ, soft hyphen, and variation +// selectors are width 0. Everything else is width 1. SMP blocks that are +// EAW=N (Mahjong/Dominos/Playing Cards/Alchemical/Ornamental Dingbats/ +// Supplemental Arrows-C/most Geometric Shapes Extended/most Enclosed +// Alphanumeric Supplement) fall through to the width-1 default; only their +// emoji-presentation exceptions (e.g. U+1F004, U+1F0CF) are width 2. +// 2. Text width is measured per grapheme cluster: +// - single-rune cluster => its rune width; +// - cluster containing ZWJ (U+200D), VS16 (U+FE0F), or a skin-tone +// modifier (U+1F3FB-U+1F3FF) => 2 (renders as one emoji glyph); +// - regional-indicator pair (flag) => 2; +// - anything else (e.g. NFD combining sequences like "e" + U+0301) => +// sum of rune widths, so combining marks add nothing to the base. +#endregion + using System.Globalization; /// @@ -21,7 +40,9 @@ public static int GetRuneWidth(Rune rune) // Control characters if (Rune.IsControl(rune)) + { return 0; + } UnicodeCategory category = Rune.GetUnicodeCategory(rune); @@ -29,17 +50,30 @@ public static int GetRuneWidth(Rune rune) if (category is UnicodeCategory.NonSpacingMark or UnicodeCategory.EnclosingMark or UnicodeCategory.Format) + { return 0; + } // Explicit zero-width code points if (value is 0x00AD) // Soft hyphen + { return 0; + } + if (value is 0x200B or 0x200C or 0x200D or 0x2060) // ZWSP, ZWNJ, ZWJ, WJ + { return 0; + } + if (value is >= 0xFE00 and <= 0xFE0F) // Variation selectors VS1-VS16 + { return 0; + } + if (value is >= 0xE0100 and <= 0xE01EF) // Variation selectors supplement + { return 0; + } // ── Emoji_Presentation=Yes code points (U+2000-U+2BFF) ── // Source: Unicode 16.0 emoji-data.txt — only code points that render @@ -67,93 +101,229 @@ or 0x274C or 0x274E // ❌❎ or 0x27B0 or 0x27BF // ➰➿ or 0x2B1B or 0x2B1C // ⬛⬜ or 0x2B50 or 0x2B55) // ⭐⭕ + { return 2; + } // Wide angle brackets if (value is >= 0x2329 and <= 0x232A) + { return 2; + } // ── CJK ranges (width 2) ── // CJK Radicals, Kangxi, Ideographic Description if (value is >= 0x2E80 and <= 0x303E) + { return 2; - // Hiragana, Katakana, Bopomofo, CJK Compatibility - if (value is >= 0x3041 and <= 0x33BF) + } + // Hiragana, Katakana, Bopomofo, CJK Compatibility (full block to U+33FF) + if (value is >= 0x3041 and <= 0x33FF) + { return 2; + } // CJK Unified Ideographs Extension A if (value is >= 0x3400 and <= 0x4DBF) + { return 2; + } // CJK Unified Ideographs if (value is >= 0x4E00 and <= 0x9FFF) + { return 2; + } // Yi Syllables and Radicals if (value is >= 0xA000 and <= 0xA4CF) + { return 2; + } + // Hangul Jamo Extended-A + if (value is >= 0xA960 and <= 0xA97F) + { + return 2; + } // Hangul Syllables if (value is >= 0xAC00 and <= 0xD7A3) + { return 2; + } // CJK Compatibility Ideographs if (value is >= 0xF900 and <= 0xFAFF) + { return 2; + } // ── CJK and Fullwidth forms ── // Vertical Forms if (value is >= 0xFE10 and <= 0xFE19) + { return 2; + } // CJK Compatibility Forms if (value is >= 0xFE30 and <= 0xFE6F) + { return 2; + } // Fullwidth Latin, Punctuation, Katakana, Hangul if (value is >= 0xFF01 and <= 0xFF60) + { return 2; + } // Fullwidth Signs if (value is >= 0xFFE0 and <= 0xFFE6) + { return 2; + } // ── Hangul Jamo ── if (value is >= 0x1100 and <= 0x115F) + { return 2; + } // ── Emoji blocks (Supplementary Multilingual Plane) ── + // Only EAW=W / Emoji_Presentation=Yes ranges are wide. Narrow (EAW=N) + // blocks fall through to the width-1 default: Mahjong U+1F000-U+1F02B + // (except U+1F004), Dominos U+1F030-U+1F093, Playing Cards + // U+1F0A0-U+1F0F5 (except U+1F0CF), most Enclosed Alphanumeric Supplement + // U+1F100-U+1F1AC, Ornamental Dingbats U+1F650-U+1F67F, Alchemical + // Symbols U+1F700-U+1F77F, most Geometric Shapes Extended + // U+1F780-U+1F7D8, and Supplemental Arrows-C U+1F800-U+1F8B1. - // Mahjong, Dominos, Playing Cards, Enclosed Alphanumerics Supplement - if (value is >= 0x1F000 and <= 0x1FAFF) + // Mahjong Tile Red Dragon (emoji presentation) + if (value is 0x1F004) + { + return 2; + } + // Playing Card Black Joker (emoji presentation) + if (value is 0x1F0CF) + { return 2; - if (value is >= 0x1FC00 and <= 0x1FFFF) + } + // Enclosed Alphanumeric Supplement emoji (AB button; squared CL..VS) + if (value is 0x1F18E or (>= 0x1F191 and <= 0x1F19A)) + { return 2; + } // Regional indicator symbols (flag emoji components) if (value is >= 0x1F1E0 and <= 0x1F1FF) + { + return 2; + } + // Enclosed Ideographic Supplement + if (value is (>= 0x1F200 and <= 0x1F202) + or (>= 0x1F210 and <= 0x1F23B) + or (>= 0x1F240 and <= 0x1F248) + or 0x1F250 or 0x1F251 + or (>= 0x1F260 and <= 0x1F265)) + { return 2; + } + // Miscellaneous Symbols and Pictographs, Emoticons + if (value is >= 0x1F300 and <= 0x1F64F) + { + return 2; + } + // Transport and Map Symbols (wide/emoji-presentation subranges) + if (value is (>= 0x1F680 and <= 0x1F6D7) + or (>= 0x1F6DC and <= 0x1F6EC) + or (>= 0x1F6F4 and <= 0x1F6FC)) + { + return 2; + } + // Geometric Shapes Extended: large colored circles and squares + if (value is (>= 0x1F7E0 and <= 0x1F7EB) or 0x1F7F0) + { + return 2; + } + // Supplemental Symbols and Pictographs + if (value is >= 0x1F900 and <= 0x1F9FF) + { + return 2; + } + // Symbols and Pictographs Extended-A + if (value is >= 0x1FA70 and <= 0x1FAFF) + { + return 2; + } + + // ── Other wide SMP scripts ── + + // Tangut, Tangut Components + if (value is >= 0x17000 and <= 0x187F7) + { + return 2; + } + // Tangut Supplement, Khitan Small Script + if (value is >= 0x18800 and <= 0x18CD5) + { + return 2; + } + // Kana Extended-B + if (value is >= 0x1AFF0 and <= 0x1AFFE) + { + return 2; + } + // Kana Supplement, Kana Extended-A, Small Kana Extension + if (value is (>= 0x1B000 and <= 0x1B122) + or 0x1B132 + or (>= 0x1B150 and <= 0x1B152) + or 0x1B155 + or (>= 0x1B164 and <= 0x1B167)) + { + return 2; + } + // Nushu + if (value is >= 0x1B170 and <= 0x1B2FB) + { + return 2; + } // ── CJK Extensions in Supplementary Ideographic Plane ── // CJK Extension B if (value is >= 0x20000 and <= 0x2A6DF) + { return 2; + } // CJK Extension C if (value is >= 0x2A700 and <= 0x2B73F) + { return 2; + } // CJK Extension D if (value is >= 0x2B740 and <= 0x2B81F) + { return 2; + } // CJK Extension E if (value is >= 0x2B820 and <= 0x2CEAF) + { return 2; + } // CJK Extension F if (value is >= 0x2CEB0 and <= 0x2EBEF) + { return 2; + } // CJK Compatibility Ideographs Supplement if (value is >= 0x2F800 and <= 0x2FA1F) + { return 2; + } // CJK Extension G if (value is >= 0x30000 and <= 0x3134F) + { return 2; + } // CJK Extension H if (value is >= 0x31350 and <= 0x323AF) + { return 2; + } return 1; } @@ -168,7 +338,9 @@ or 0x2B1B or 0x2B1C // ⬛⬜ public static int GetTextWidth(string? text) { if (string.IsNullOrEmpty(text)) + { return 0; + } TextElementEnumerator enumerator = StringInfo.GetTextElementEnumerator(text); int totalWidth = 0; @@ -184,26 +356,60 @@ public static int GetTextWidth(string? text) continue; } - // Count runes and check for wide ones + // Analyze the cluster's runes int runeCount = 0; - int firstRuneWidth = 0; + int runeWidthSum = 0; + int regionalIndicatorCount = 0; + bool hasZeroWidthJoiner = false; + bool hasEmojiPresentationSelector = false; + bool hasEmojiModifier = false; foreach (Rune rune in grapheme.EnumerateRunes()) { - if (runeCount == 0) - firstRuneWidth = GetRuneWidth(rune); + int codePoint = rune.Value; + + if (codePoint is 0x200D) + { + hasZeroWidthJoiner = true; + } + else if (codePoint is 0xFE0F) + { + hasEmojiPresentationSelector = true; + } + else if (codePoint is >= 0x1F1E6 and <= 0x1F1FF) + { + regionalIndicatorCount++; + } + else if (codePoint is >= 0x1F3FB and <= 0x1F3FF) + { + hasEmojiModifier = true; + } + + runeWidthSum += GetRuneWidth(rune); runeCount++; } - if (runeCount > 1) + if (runeCount == 1) + { + totalWidth += runeWidthSum; + } + else if (hasZeroWidthJoiner || hasEmojiPresentationSelector || hasEmojiModifier) + { + // Emoji ZWJ sequence, emoji-presentation (VS16) sequence, or + // skin-tone modifier sequence: renders as one two-column glyph + totalWidth += 2; + } + else if (runeCount == 2 && regionalIndicatorCount == 2) { - // Multi-codepoint grapheme cluster (ZWJ sequences, flags, skin-tone, keycaps) - // These render as a single glyph taking 2 columns in terminals + // Regional indicator pair (flag emoji): one two-column glyph totalWidth += 2; } else { - totalWidth += firstRuneWidth; + // Other multi-rune clusters, e.g. NFD combining sequences like + // "e" + U+0301: combining marks are width 0, so the sum yields + // the base character's width + totalWidth += runeWidthSum; } } diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index b702647..8e5d4a8 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -16,8 +16,9 @@ CA1052: Static holder types flexibility CA1707: Underscores in test method names allowed CS0050: Inconsistent accessibility (test helpers) - RCS1046, RCS1102, RCS1110: Roslynator formatting flexibility for tests --> - $(NoWarn);CA1303;CA1307;CA2000;CA2007;CA1031;CA1515;CA1050;CA1052;CA1707;CS0050;RCS1046;RCS1102;RCS1110 + RCS1046, RCS1102, RCS1110: Roslynator formatting flexibility for tests + IDE0211: Test runfiles are file-based apps and require top-level statements --> + $(NoWarn);CA1303;CA1307;CA2000;CA2007;CA1031;CA1515;CA1050;CA1052;CA1707;CS0050;RCS1046;RCS1102;RCS1110;IDE0211 diff --git a/tests/ansi-string-utils-01-basic.cs b/tests/ansi-string-utils-01-basic.cs index cd7dc5e..f7a83a0 100755 --- a/tests/ansi-string-utils-01-basic.cs +++ b/tests/ansi-string-utils-01-basic.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test AnsiStringUtils functionality @@ -10,262 +10,324 @@ namespace TimeWarp.Terminal.Tests.Core.AnsiStringUtils { -[TestTag("Widgets")] -public class AnsiStringUtilsTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_strip_basic_ansi_codes() + [TestTag("Widgets")] + public class AnsiStringUtilsTests { - // Arrange - const string input = "\x1b[31mError\x1b[0m"; + [ModuleInitializer] + internal static void Register() => RegisterTests(); - // Act - string result = TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(input); + public static async Task Should_strip_basic_ansi_codes() + { + // Arrange + const string input = "\x1b[31mError\x1b[0m"; - // Assert - result.ShouldBe("Error"); + // Act + string result = TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(input); - await Task.CompletedTask; - } + // Assert + result.ShouldBe("Error"); - public static async Task Should_strip_256_color_codes() - { - // Arrange - string input = "\x1b[38;5;214mOrange\x1b[0m"; + await Task.CompletedTask; + } - // Act - string result = TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(input); + public static async Task Should_strip_256_color_codes() + { + // Arrange + string input = "\x1b[38;5;214mOrange\x1b[0m"; - // Assert - result.ShouldBe("Orange"); + // Act + string result = TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(input); - await Task.CompletedTask; - } + // Assert + result.ShouldBe("Orange"); - public static async Task Should_handle_null_input() - { - // Act - string result = TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(null); + await Task.CompletedTask; + } - // Assert - result.ShouldBe(string.Empty); + public static async Task Should_handle_null_input() + { + // Act + string result = TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(null); - await Task.CompletedTask; - } + // Assert + result.ShouldBe(string.Empty); - public static async Task Should_handle_empty_input() - { - // Act - string result = TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(""); + await Task.CompletedTask; + } - // Assert - result.ShouldBe(string.Empty); + public static async Task Should_handle_empty_input() + { + // Act + string result = TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(""); - await Task.CompletedTask; - } + // Assert + result.ShouldBe(string.Empty); - public static async Task Should_get_visible_length_with_ansi_codes() - { - // Arrange - string styled = "\x1b[31mError\x1b[0m"; // "Error" = 5 chars + await Task.CompletedTask; + } - // Act - int length = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(styled); + public static async Task Should_get_visible_length_with_ansi_codes() + { + // Arrange + string styled = "\x1b[31mError\x1b[0m"; // "Error" = 5 chars - // Assert - length.ShouldBe(5); + // Act + int length = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(styled); - await Task.CompletedTask; - } + // Assert + length.ShouldBe(5); - public static async Task Should_get_visible_length_with_chained_styles() - { - // Arrange - string styled = "Warning".Yellow().Bold(); // Uses chained ANSI codes + await Task.CompletedTask; + } - // Act - int length = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(styled); + public static async Task Should_get_visible_length_with_chained_styles() + { + // Arrange + string styled = "Warning".Yellow().Bold(); // Uses chained ANSI codes - // Assert - length.ShouldBe(7); // "Warning" = 7 chars + // Act + int length = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(styled); - await Task.CompletedTask; - } + // Assert + length.ShouldBe(7); // "Warning" = 7 chars - public static async Task Should_get_visible_length_without_ansi_codes() - { - // Arrange - string plain = "Hello World"; + await Task.CompletedTask; + } - // Act - int length = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(plain); + public static async Task Should_get_visible_length_without_ansi_codes() + { + // Arrange + string plain = "Hello World"; - // Assert - length.ShouldBe(11); + // Act + int length = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(plain); - await Task.CompletedTask; - } + // Assert + length.ShouldBe(11); - public static async Task Should_pad_right_accounting_for_ansi() - { - // Arrange - string styled = "Hi".Red(); // 2 visible chars + await Task.CompletedTask; + } - // Act - string padded = TimeWarp.Terminal.AnsiStringUtils.PadRightVisible(styled, 10); + public static async Task Should_pad_right_accounting_for_ansi() + { + // Arrange + string styled = "Hi".Red(); // 2 visible chars - // Assert - int visibleLength = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(padded); - visibleLength.ShouldBe(10); - padded.ShouldContain(AnsiColors.Red); - padded.ShouldEndWith(" "); // 8 spaces + // Act + string padded = TimeWarp.Terminal.AnsiStringUtils.PadRightVisible(styled, 10); - await Task.CompletedTask; - } + // Assert + int visibleLength = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(padded); + visibleLength.ShouldBe(10); + padded.ShouldContain(AnsiColors.Red); + padded.ShouldEndWith(" "); // 8 spaces - public static async Task Should_pad_left_accounting_for_ansi() - { - // Arrange - string styled = "Hi".Blue(); // 2 visible chars + await Task.CompletedTask; + } - // Act - string padded = TimeWarp.Terminal.AnsiStringUtils.PadLeftVisible(styled, 10); + public static async Task Should_pad_left_accounting_for_ansi() + { + // Arrange + string styled = "Hi".Blue(); // 2 visible chars - // Assert - int visibleLength = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(padded); - visibleLength.ShouldBe(10); - padded.ShouldContain(AnsiColors.Blue); - padded.ShouldStartWith(" "); // 8 spaces + // Act + string padded = TimeWarp.Terminal.AnsiStringUtils.PadLeftVisible(styled, 10); - await Task.CompletedTask; - } + // Assert + int visibleLength = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(padded); + visibleLength.ShouldBe(10); + padded.ShouldContain(AnsiColors.Blue); + padded.ShouldStartWith(" "); // 8 spaces - public static async Task Should_center_accounting_for_ansi() - { - // Arrange - string styled = "Hi".Green(); // 2 visible chars + await Task.CompletedTask; + } - // Act - string centered = TimeWarp.Terminal.AnsiStringUtils.CenterVisible(styled, 10); + public static async Task Should_center_accounting_for_ansi() + { + // Arrange + string styled = "Hi".Green(); // 2 visible chars - // Assert - int visibleLength = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(centered); - visibleLength.ShouldBe(10); - // Should have 4 spaces on each side - centered.ShouldStartWith(" "); // 4 spaces + // Act + string centered = TimeWarp.Terminal.AnsiStringUtils.CenterVisible(styled, 10); - await Task.CompletedTask; - } + // Assert + int visibleLength = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(centered); + visibleLength.ShouldBe(10); + // Should have 4 spaces on each side + centered.ShouldStartWith(" "); // 4 spaces - public static async Task Should_not_pad_if_already_at_width() - { - // Arrange - string text = "Hello"; // 5 chars + await Task.CompletedTask; + } - // Act - string padded = TimeWarp.Terminal.AnsiStringUtils.PadRightVisible(text, 5); + public static async Task Should_not_pad_if_already_at_width() + { + // Arrange + string text = "Hello"; // 5 chars - // Assert - padded.ShouldBe("Hello"); + // Act + string padded = TimeWarp.Terminal.AnsiStringUtils.PadRightVisible(text, 5); - await Task.CompletedTask; - } + // Assert + padded.ShouldBe("Hello"); - public static async Task Should_not_truncate_if_exceeds_width() - { - // Arrange - string text = "Hello World"; // 11 chars + await Task.CompletedTask; + } - // Act - string padded = TimeWarp.Terminal.AnsiStringUtils.PadRightVisible(text, 5); + public static async Task Should_not_truncate_if_exceeds_width() + { + // Arrange + string text = "Hello World"; // 11 chars - // Assert - Should not truncate - padded.ShouldBe("Hello World"); + // Act + string padded = TimeWarp.Terminal.AnsiStringUtils.PadRightVisible(text, 5); - await Task.CompletedTask; - } + // Assert - Should not truncate + padded.ShouldBe("Hello World"); - public static async Task Should_strip_osc8_hyperlink_sequences() - { - // Arrange - OSC 8 hyperlink with ST (String Terminator) ending - string hyperlink = "\x1b]8;;https://example.com\x1b\\Click Here\x1b]8;;\x1b\\"; + await Task.CompletedTask; + } - // Act - string result = TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(hyperlink); + public static async Task Should_clamp_negative_width_to_zero_without_throwing() + { + // Regression: negative totalWidth used to throw ArgumentOutOfRangeException + // from new string(paddingChar, negativeCount); it is now clamped to 0 - // Assert - result.ShouldBe("Click Here"); + // Non-empty text is longer than the clamped width, so it is returned unchanged + TimeWarp.Terminal.AnsiStringUtils.PadRightVisible("text", -5).ShouldBe("text"); + TimeWarp.Terminal.AnsiStringUtils.PadLeftVisible("text", -5).ShouldBe("text"); + TimeWarp.Terminal.AnsiStringUtils.CenterVisible("text", -5).ShouldBe("text"); - await Task.CompletedTask; - } + // Null/empty text pads to the clamped width of 0, i.e. an empty string + TimeWarp.Terminal.AnsiStringUtils.PadRightVisible(null, -5).ShouldBe(string.Empty); + TimeWarp.Terminal.AnsiStringUtils.PadLeftVisible("", -5).ShouldBe(string.Empty); + TimeWarp.Terminal.AnsiStringUtils.CenterVisible(null, -5).ShouldBe(string.Empty); - public static async Task Should_strip_osc8_hyperlink_with_bel_terminator() - { - // Arrange - OSC 8 hyperlink with BEL (\u0007) terminator - // Note: Using \u0007 instead of \x07 because \x07C would be parsed as \x7C (pipe character) - string hyperlink = "\x1b]8;;https://example.com\u0007Click Here\x1b]8;;\u0007"; + await Task.CompletedTask; + } - // Act - string result = TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(hyperlink); + public static async Task Should_strip_osc8_hyperlink_sequences() + { + // Arrange - OSC 8 hyperlink with ST (String Terminator) ending + string hyperlink = "\x1b]8;;https://example.com\x1b\\Click Here\x1b]8;;\x1b\\"; - // Assert - result.ShouldBe("Click Here"); + // Act + string result = TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(hyperlink); - await Task.CompletedTask; - } + // Assert + result.ShouldBe("Click Here"); - public static async Task Should_get_visible_length_with_hyperlinks() - { - // Arrange - Simulates "Clean Architecture with ASP.NET Core 10" with a long URL - string displayText = "Clean Architecture with ASP.NET Core 10"; - string url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLdo4fOcmZ0oUv7O0RYoxBVJw"; // 73 chars - string hyperlink = $"\x1b]8;;{url}\x1b\\{displayText}\x1b]8;;\x1b\\"; + await Task.CompletedTask; + } - // Act - int length = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(hyperlink); + public static async Task Should_strip_osc8_hyperlink_with_bel_terminator() + { + // Arrange - OSC 8 hyperlink with BEL (\u0007) terminator + // Note: Using \u0007 instead of \x07 because \x07C would be parsed as \x7C (pipe character) + string hyperlink = "\x1b]8;;https://example.com\u0007Click Here\x1b]8;;\u0007"; - // Assert - Should be 39 (display text only), not 100+ (with URL bytes) - length.ShouldBe(39); + // Act + string result = TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(hyperlink); - await Task.CompletedTask; - } + // Assert + result.ShouldBe("Click Here"); - public static async Task Should_handle_styled_hyperlinks() - { - // Arrange - Hyperlink with color styling - string url = "https://example.com"; - string displayText = "Link"; - // Red colored hyperlink - string styledHyperlink = $"\x1b[31m\x1b]8;;{url}\x1b\\{displayText}\x1b]8;;\x1b\\\x1b[0m"; + await Task.CompletedTask; + } - // Act - string stripped = TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(styledHyperlink); - int length = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(styledHyperlink); + public static async Task Should_get_visible_length_with_hyperlinks() + { + // Arrange - Simulates "Clean Architecture with ASP.NET Core 10" with a long URL + string displayText = "Clean Architecture with ASP.NET Core 10"; + string url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ&list=PLdo4fOcmZ0oUv7O0RYoxBVJw"; // 73 chars + string hyperlink = $"\x1b]8;;{url}\x1b\\{displayText}\x1b]8;;\x1b\\"; - // Assert - stripped.ShouldBe("Link"); - length.ShouldBe(4); + // Act + int length = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(hyperlink); - await Task.CompletedTask; - } + // Assert - Should be 39 (display text only), not 100+ (with URL bytes) + length.ShouldBe(39); - public static async Task Should_handle_multiple_hyperlinks_in_text() - { - // Arrange - Two hyperlinks in one string - string text = "\x1b]8;;https://a.com\x1b\\First\x1b]8;;\x1b\\ and \x1b]8;;https://b.com\x1b\\Second\x1b]8;;\x1b\\"; + await Task.CompletedTask; + } + + public static async Task Should_handle_styled_hyperlinks() + { + // Arrange - Hyperlink with color styling + string url = "https://example.com"; + string displayText = "Link"; + // Red colored hyperlink + string styledHyperlink = $"\x1b[31m\x1b]8;;{url}\x1b\\{displayText}\x1b]8;;\x1b\\\x1b[0m"; + + // Act + string stripped = TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(styledHyperlink); + int length = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(styledHyperlink); + + // Assert + stripped.ShouldBe("Link"); + length.ShouldBe(4); + + await Task.CompletedTask; + } + + public static async Task Should_count_only_visible_chars_with_control_sequences() + { + // Arrange - erase-line (\x1b[2K) and hide-cursor (\x1b[?25l) must not count as visible + const string input = "\x1b[2K\x1b[?25lAB"; + + // Act + int length = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(input); + + // Assert + length.ShouldBe(2); + + await Task.CompletedTask; + } + + public static async Task Should_strip_osc_title_sequence() + { + // Arrange - OSC 0 sets the window title, terminated by BEL + // Note: Using \u0007 instead of \x07 to avoid ambiguous hex escape parsing + const string input = "\x1b]0;My Title\u0007Hello"; + + // Act + string result = TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(input); + + // Assert + result.ShouldBe("Hello"); + + await Task.CompletedTask; + } + + public static async Task Should_strip_two_byte_escape_sequences() + { + // Arrange - ESC M (reverse index) and ESC D (index) + // Note: Using \u001b because \x1bD would be parsed as the single char ƽ + const string input = "\u001bMUp\u001bD"; + + // Act + string result = TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(input); + + // Assert + result.ShouldBe("Up"); + + await Task.CompletedTask; + } + + public static async Task Should_handle_multiple_hyperlinks_in_text() + { + // Arrange - Two hyperlinks in one string + string text = "\x1b]8;;https://a.com\x1b\\First\x1b]8;;\x1b\\ and \x1b]8;;https://b.com\x1b\\Second\x1b]8;;\x1b\\"; - // Act - string result = TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(text); - int length = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(text); + // Act + string result = TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(text); + int length = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(text); - // Assert - result.ShouldBe("First and Second"); - length.ShouldBe(16); + // Assert + result.ShouldBe("First and Second"); + length.ShouldBe(16); - await Task.CompletedTask; + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.AnsiStringUtils diff --git a/tests/ansi-string-utils-02-wrap-text.cs b/tests/ansi-string-utils-02-wrap-text.cs index ad726bb..17d38b3 100755 --- a/tests/ansi-string-utils-02-wrap-text.cs +++ b/tests/ansi-string-utils-02-wrap-text.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test AnsiStringUtils.WrapText functionality @@ -10,171 +10,223 @@ namespace TimeWarp.Terminal.Tests.Core.AnsiStringUtilsWrap { -[TestTag("Widgets")] -public class AnsiStringUtilsWrapTextTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_wrap_plain_text_at_word_boundaries() + [TestTag("Widgets")] + public class AnsiStringUtilsWrapTextTests { - // Arrange - string text = "Hello World Test"; + [ModuleInitializer] + internal static void Register() => RegisterTests(); - // Act - IReadOnlyList lines = TimeWarp.Terminal.AnsiStringUtils.WrapText(text, 10); + public static async Task Should_wrap_plain_text_at_word_boundaries() + { + // Arrange + string text = "Hello World Test"; - // Assert - lines.Count.ShouldBe(2); - lines[0].ShouldBe("Hello "); - lines[1].ShouldBe("World Test"); + // Act + IReadOnlyList lines = TimeWarp.Terminal.AnsiStringUtils.WrapText(text, 10); - await Task.CompletedTask; - } + // Assert + lines.Count.ShouldBe(2); + lines[0].ShouldBe("Hello "); + lines[1].ShouldBe("World Test"); - public static async Task Should_return_empty_line_for_null_input() - { - // Act - IReadOnlyList lines = TimeWarp.Terminal.AnsiStringUtils.WrapText(null, 20); + await Task.CompletedTask; + } - // Assert - lines.Count.ShouldBe(1); - lines[0].ShouldBe(""); + public static async Task Should_return_empty_line_for_null_input() + { + // Act + IReadOnlyList lines = TimeWarp.Terminal.AnsiStringUtils.WrapText(null, 20); - await Task.CompletedTask; - } + // Assert + lines.Count.ShouldBe(1); + lines[0].ShouldBe(""); - public static async Task Should_return_empty_line_for_empty_input() - { - // Act - IReadOnlyList lines = TimeWarp.Terminal.AnsiStringUtils.WrapText("", 20); + await Task.CompletedTask; + } - // Assert - lines.Count.ShouldBe(1); - lines[0].ShouldBe(""); + public static async Task Should_return_empty_line_for_empty_input() + { + // Act + IReadOnlyList lines = TimeWarp.Terminal.AnsiStringUtils.WrapText("", 20); - await Task.CompletedTask; - } + // Assert + lines.Count.ShouldBe(1); + lines[0].ShouldBe(""); - public static async Task Should_break_long_words() - { - // Arrange - string text = "Supercalifragilisticexpialidocious"; + await Task.CompletedTask; + } - // Act - IReadOnlyList lines = TimeWarp.Terminal.AnsiStringUtils.WrapText(text, 10); + public static async Task Should_break_long_words() + { + // Arrange + string text = "Supercalifragilisticexpialidocious"; + + // Act + IReadOnlyList lines = TimeWarp.Terminal.AnsiStringUtils.WrapText(text, 10); - // Assert - lines.Count.ShouldBeGreaterThan(1); + // Assert + lines.Count.ShouldBeGreaterThan(1); - foreach (string line in lines) + foreach (string line in lines) + { + TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(line).ShouldBeLessThanOrEqualTo(10); + } + + await Task.CompletedTask; + } + + public static async Task Should_preserve_ansi_color_codes_across_lines() { - TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(line).ShouldBeLessThanOrEqualTo(10); + // Arrange + string text = $"{AnsiColors.Red}This is red text that wraps{AnsiColors.Reset}"; + + // Act + IReadOnlyList lines = TimeWarp.Terminal.AnsiStringUtils.WrapText(text, 15); + + // Assert + lines.Count.ShouldBeGreaterThan(1); + + // First line should start with the red color + lines[0].ShouldContain(AnsiColors.Red); + + // Subsequent lines should also have the red color restored + if (lines.Count > 1) + { + lines[1].ShouldContain(AnsiColors.Red); + } + + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_handle_text_shorter_than_width() + { + // Arrange + string text = "Short"; - public static async Task Should_preserve_ansi_color_codes_across_lines() - { - // Arrange - string text = $"{AnsiColors.Red}This is red text that wraps{AnsiColors.Reset}"; + // Act + IReadOnlyList lines = TimeWarp.Terminal.AnsiStringUtils.WrapText(text, 20); + + // Assert + lines.Count.ShouldBe(1); + lines[0].ShouldBe("Short"); - // Act - IReadOnlyList lines = TimeWarp.Terminal.AnsiStringUtils.WrapText(text, 15); + await Task.CompletedTask; + } + + public static async Task Should_handle_text_exactly_at_width() + { + // Arrange + string text = "1234567890"; - // Assert - lines.Count.ShouldBeGreaterThan(1); + // Act + IReadOnlyList lines = TimeWarp.Terminal.AnsiStringUtils.WrapText(text, 10); - // First line should start with the red color - lines[0].ShouldContain(AnsiColors.Red); + // Assert + lines.Count.ShouldBe(1); + lines[0].ShouldBe("1234567890"); - // Subsequent lines should also have the red color restored - if (lines.Count > 1) + await Task.CompletedTask; + } + + public static async Task Should_handle_minimum_width_of_one() { - lines[1].ShouldContain(AnsiColors.Red); + // Arrange + string text = "ABC"; + + // Act + IReadOnlyList lines = TimeWarp.Terminal.AnsiStringUtils.WrapText(text, 0); + + // Assert - Width should be treated as 1 + lines.Count.ShouldBe(3); + lines[0].ShouldBe("A"); + lines[1].ShouldBe("B"); + lines[2].ShouldBe("C"); + + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_handle_multiple_spaces() + { + // Arrange + string text = "Hello World"; - public static async Task Should_handle_text_shorter_than_width() - { - // Arrange - string text = "Short"; + // Act + IReadOnlyList lines = TimeWarp.Terminal.AnsiStringUtils.WrapText(text, 20); - // Act - IReadOnlyList lines = TimeWarp.Terminal.AnsiStringUtils.WrapText(text, 20); + // Assert - Spaces are preserved + lines.Count.ShouldBe(1); - // Assert - lines.Count.ShouldBe(1); - lines[0].ShouldBe("Short"); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_handle_osc8_hyperlinks() + { + // Arrange - OSC 8 hyperlink + string text = "\x1b]8;;https://example.com\x1b\\Click Here\x1b]8;;\x1b\\"; - public static async Task Should_handle_text_exactly_at_width() - { - // Arrange - string text = "1234567890"; + // Act + IReadOnlyList lines = TimeWarp.Terminal.AnsiStringUtils.WrapText(text, 20); - // Act - IReadOnlyList lines = TimeWarp.Terminal.AnsiStringUtils.WrapText(text, 10); + // Assert + lines.Count.ShouldBe(1); - // Assert - lines.Count.ShouldBe(1); - lines[0].ShouldBe("1234567890"); + // Visible length should only be "Click Here" (10 chars) + TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]).ShouldBe(10); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_handle_minimum_width_of_one() - { - // Arrange - string text = "ABC"; + public static async Task Should_keep_word_with_embedded_ansi_codes_together() + { + // Arrange - "unbreakable" styled mid-word must wrap as a single word, not split at the codes + string text = "go un\x1b[31mbreak\x1b[0mable"; - // Act - IReadOnlyList lines = TimeWarp.Terminal.AnsiStringUtils.WrapText(text, 0); + // Act + IReadOnlyList lines = TimeWarp.Terminal.AnsiStringUtils.WrapText(text, 11); - // Assert - Width should be treated as 1 - lines.Count.ShouldBe(3); - lines[0].ShouldBe("A"); - lines[1].ShouldBe("B"); - lines[2].ShouldBe("C"); + // Assert - "unbreakable" (11 visible chars) moves to its own line intact + lines.Count.ShouldBe(2); + TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(lines[0]).ShouldBe("go "); + TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(lines[1]).ShouldBe("unbreakable"); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_handle_multiple_spaces() - { - // Arrange - string text = "Hello World"; + public static async Task Should_not_reopen_closed_hyperlink_on_wrapped_lines() + { + // Arrange - the hyperlink closes before the text wraps + string url = "https://example.com"; + string text = $"\x1b]8;;{url}\x1b\\Link\x1b]8;;\x1b\\ trailing words that wrap here"; - // Act - IReadOnlyList lines = TimeWarp.Terminal.AnsiStringUtils.WrapText(text, 20); + // Act + IReadOnlyList lines = TimeWarp.Terminal.AnsiStringUtils.WrapText(text, 12); - // Assert - Spaces are preserved - lines.Count.ShouldBe(1); + // Assert - lines after the close must not re-open the hyperlink + lines.Count.ShouldBeGreaterThan(1); - await Task.CompletedTask; - } + for (int i = 1; i < lines.Count; i++) + { + lines[i].ShouldNotContain(url); + } - public static async Task Should_handle_osc8_hyperlinks() - { - // Arrange - OSC 8 hyperlink - string text = "\x1b]8;;https://example.com\x1b\\Click Here\x1b]8;;\x1b\\"; + await Task.CompletedTask; + } - // Act - IReadOnlyList lines = TimeWarp.Terminal.AnsiStringUtils.WrapText(text, 20); + public static async Task Should_carry_open_hyperlink_across_wrapped_lines() + { + // Arrange - the hyperlink stays open across the wrap point + string url = "https://example.com"; + string text = $"\x1b]8;;{url}\x1b\\linked text that wraps across\x1b]8;;\x1b\\"; - // Assert - lines.Count.ShouldBe(1); + // Act + IReadOnlyList lines = TimeWarp.Terminal.AnsiStringUtils.WrapText(text, 12); - // Visible length should only be "Click Here" (10 chars) - TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]).ShouldBe(10); + // Assert - continuation lines re-open the still-active hyperlink + lines.Count.ShouldBeGreaterThan(1); + lines[1].ShouldContain(url); - await Task.CompletedTask; + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.AnsiStringUtilsWrap diff --git a/tests/ansi-string-utils-03-emoji-width.cs b/tests/ansi-string-utils-03-emoji-width.cs index 079f8eb..694cb3a 100755 --- a/tests/ansi-string-utils-03-emoji-width.cs +++ b/tests/ansi-string-utils-03-emoji-width.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test AnsiStringUtils with emoji and wide characters @@ -10,91 +10,91 @@ namespace TimeWarp.Terminal.Tests.Core.AnsiStringUtilsEmoji { -[TestTag("Widgets")] -public class AnsiStringUtilsEmojiTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_get_visible_length_for_emoji() + [TestTag("Widgets")] + public class AnsiStringUtilsEmojiTests { - // "📍 Location" = emoji(2) + space(1) + "Location"(8) = 11 - int length = AnsiStringUtils.GetVisibleLength("📍 Location"); + [ModuleInitializer] + internal static void Register() => RegisterTests(); - length.ShouldBe(11); + public static async Task Should_get_visible_length_for_emoji() + { + // "📍 Location" = emoji(2) + space(1) + "Location"(8) = 11 + int length = AnsiStringUtils.GetVisibleLength("📍 Location"); - await Task.CompletedTask; - } + length.ShouldBe(11); - public static async Task Should_get_visible_length_for_styled_emoji() - { - string styled = "\x1b[31m📍 Location\x1b[0m"; + await Task.CompletedTask; + } - int length = AnsiStringUtils.GetVisibleLength(styled); + public static async Task Should_get_visible_length_for_styled_emoji() + { + string styled = "\x1b[31m📍 Location\x1b[0m"; - length.ShouldBe(11); + int length = AnsiStringUtils.GetVisibleLength(styled); - await Task.CompletedTask; - } + length.ShouldBe(11); - public static async Task Should_get_visible_length_for_weather_emoji_string() - { - // "🌡️ Temperature" = emoji(2) + space(1) + "Temperature"(11) = 14 - int length = AnsiStringUtils.GetVisibleLength("🌡️ Temperature"); + await Task.CompletedTask; + } - length.ShouldBe(14); + public static async Task Should_get_visible_length_for_weather_emoji_string() + { + // "🌡️ Temperature" = emoji(2) + space(1) + "Temperature"(11) = 14 + int length = AnsiStringUtils.GetVisibleLength("🌡️ Temperature"); - await Task.CompletedTask; - } + length.ShouldBe(14); - public static async Task Should_pad_right_correctly_with_emoji() - { - string text = "📍 Pin"; - // Display width: emoji(2) + space(1) + "Pin"(3) = 6 + await Task.CompletedTask; + } - string padded = AnsiStringUtils.PadRightVisible(text, 15); + public static async Task Should_pad_right_correctly_with_emoji() + { + string text = "📍 Pin"; + // Display width: emoji(2) + space(1) + "Pin"(3) = 6 - // Should add 9 spaces to reach width 15 - AnsiStringUtils.GetVisibleLength(padded).ShouldBe(15); - padded.ShouldEndWith(" "); // 9 spaces + string padded = AnsiStringUtils.PadRightVisible(text, 15); - await Task.CompletedTask; - } + // Should add 9 spaces to reach width 15 + AnsiStringUtils.GetVisibleLength(padded).ShouldBe(15); + padded.ShouldEndWith(" "); // 9 spaces - public static async Task Should_pad_left_correctly_with_emoji() - { - string text = "📍 Pin"; + await Task.CompletedTask; + } - string padded = AnsiStringUtils.PadLeftVisible(text, 15); + public static async Task Should_pad_left_correctly_with_emoji() + { + string text = "📍 Pin"; - AnsiStringUtils.GetVisibleLength(padded).ShouldBe(15); - padded.ShouldStartWith(" "); // 9 spaces + string padded = AnsiStringUtils.PadLeftVisible(text, 15); - await Task.CompletedTask; - } + AnsiStringUtils.GetVisibleLength(padded).ShouldBe(15); + padded.ShouldStartWith(" "); // 9 spaces - public static async Task Should_center_correctly_with_emoji() - { - string text = "📍 Pin"; - // Display width 6, target 16 => 5 left, 5 right + await Task.CompletedTask; + } - string centered = AnsiStringUtils.CenterVisible(text, 16); + public static async Task Should_center_correctly_with_emoji() + { + string text = "📍 Pin"; + // Display width 6, target 16 => 5 left, 5 right - AnsiStringUtils.GetVisibleLength(centered).ShouldBe(16); + string centered = AnsiStringUtils.CenterVisible(text, 16); - await Task.CompletedTask; - } + AnsiStringUtils.GetVisibleLength(centered).ShouldBe(16); - public static async Task Should_get_visible_length_for_plain_ascii_unchanged() - { - // Regression: ASCII strings must return same values as before - AnsiStringUtils.GetVisibleLength("Hello World").ShouldBe(11); - AnsiStringUtils.GetVisibleLength("Error").ShouldBe(5); - AnsiStringUtils.GetVisibleLength("").ShouldBe(0); - AnsiStringUtils.GetVisibleLength(null).ShouldBe(0); + await Task.CompletedTask; + } + + public static async Task Should_get_visible_length_for_plain_ascii_unchanged() + { + // Regression: ASCII strings must return same values as before + AnsiStringUtils.GetVisibleLength("Hello World").ShouldBe(11); + AnsiStringUtils.GetVisibleLength("Error").ShouldBe(5); + AnsiStringUtils.GetVisibleLength("").ShouldBe(0); + AnsiStringUtils.GetVisibleLength(null).ShouldBe(0); - await Task.CompletedTask; + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.AnsiStringUtilsEmoji diff --git a/tests/cancel-key-press-01-basic.cs b/tests/cancel-key-press-01-basic.cs index 7632223..a90d93a 100755 --- a/tests/cancel-key-press-01-basic.cs +++ b/tests/cancel-key-press-01-basic.cs @@ -1,11 +1,9 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S 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(); @@ -71,6 +69,46 @@ terminal2.SimulateCancelKeyPress(); // No handler attached Console.WriteLine("✓ No exception when no handler attached"); +// Test static Terminal facade forwards CancelKeyPress add/remove to the current Instance +ITerminal originalInstance = Terminal.Instance; +using TestTerminal facadeTerminal = new(); +bool staticEventRaised = false; +ConsoleSpecialKey staticSpecialKey = ConsoleSpecialKey.ControlBreak; + +try +{ + Terminal.Instance = facadeTerminal; + + // Subscribe via the static facade; the handler lands on the instance + Terminal.CancelKeyPress += StaticHandler; + facadeTerminal.SimulateCancelKeyPress(ConsoleSpecialKey.ControlC); + + if (!staticEventRaised || staticSpecialKey != ConsoleSpecialKey.ControlC) + { + Console.WriteLine("❌ FAILED: Static facade add did not forward to Instance"); + return; + } + + Console.WriteLine("✓ Static Terminal.CancelKeyPress add forwards to Instance"); + + // Unsubscribe via the static facade; the handler is removed from the instance + Terminal.CancelKeyPress -= StaticHandler; + staticEventRaised = false; + facadeTerminal.SimulateCancelKeyPress(); + + if (staticEventRaised) + { + Console.WriteLine("❌ FAILED: Static facade remove did not forward to Instance"); + return; + } + + Console.WriteLine("✓ Static Terminal.CancelKeyPress remove forwards to Instance"); +} +finally +{ + Terminal.Instance = originalInstance; +} + Console.WriteLine("\n🧪 All CancelKeyPress tests passed!"); void Handler(object? sender, ConsoleCancelEventArgs args) @@ -79,3 +117,9 @@ void Handler(object? sender, ConsoleCancelEventArgs args) receivedSpecialKey = args.SpecialKey; receivedCancel = args.Cancel; } + +void StaticHandler(object? sender, ConsoleCancelEventArgs args) +{ + staticEventRaised = true; + staticSpecialKey = args.SpecialKey; +} diff --git a/tests/console-encoding-01-basic.cs b/tests/console-encoding-01-basic.cs index e266138..76cf11a 100755 --- a/tests/console-encoding-01-basic.cs +++ b/tests/console-encoding-01-basic.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test IConsole encoding and redirection properties @@ -11,313 +11,313 @@ namespace TimeWarp.Terminal.Tests.Core.ConsoleEncoding { -[TestTag("IConsole")] -[TestTag("Encoding")] -public class ConsoleEncodingBasicTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_default_input_encoding_to_utf8_in_test_console() + [TestTag("IConsole")] + [TestTag("Encoding")] + public class ConsoleEncodingBasicTests { - // Arrange & Act - using TestConsole console = new(); + [ModuleInitializer] + internal static void Register() => RegisterTests(); - // Assert - console.InputEncoding.ShouldBe(Encoding.UTF8); + public static async Task Should_default_input_encoding_to_utf8_in_test_console() + { + // Arrange & Act + using TestConsole console = new(); - await Task.CompletedTask; - } + // Assert + console.InputEncoding.ShouldBe(Encoding.UTF8); - public static async Task Should_default_output_encoding_to_utf8_in_test_console() - { - // Arrange & Act - using TestConsole console = new(); + await Task.CompletedTask; + } - // Assert - console.OutputEncoding.ShouldBe(Encoding.UTF8); + public static async Task Should_default_output_encoding_to_utf8_in_test_console() + { + // Arrange & Act + using TestConsole console = new(); - await Task.CompletedTask; - } + // Assert + console.OutputEncoding.ShouldBe(Encoding.UTF8); - public static async Task Should_set_input_encoding_in_test_console() - { - // Arrange - using TestConsole console = new(); - Encoding unicode = Encoding.Unicode; + await Task.CompletedTask; + } - // Act - console.InputEncoding = unicode; + public static async Task Should_set_input_encoding_in_test_console() + { + // Arrange + using TestConsole console = new(); + Encoding unicode = Encoding.Unicode; - // Assert - console.InputEncoding.ShouldBe(unicode); + // Act + console.InputEncoding = unicode; - await Task.CompletedTask; - } + // Assert + console.InputEncoding.ShouldBe(unicode); - public static async Task Should_set_output_encoding_in_test_console() - { - // Arrange - using TestConsole console = new(); - Encoding ascii = Encoding.ASCII; + await Task.CompletedTask; + } - // Act - console.OutputEncoding = ascii; + public static async Task Should_set_output_encoding_in_test_console() + { + // Arrange + using TestConsole console = new(); + Encoding ascii = Encoding.ASCII; - // Assert - console.OutputEncoding.ShouldBe(ascii); + // Act + console.OutputEncoding = ascii; - await Task.CompletedTask; - } + // Assert + console.OutputEncoding.ShouldBe(ascii); - public static async Task Should_default_is_input_redirected_to_false_in_test_console() - { - // Arrange & Act - using TestConsole console = new(); + await Task.CompletedTask; + } - // Assert - console.IsInputRedirected.ShouldBeFalse(); + public static async Task Should_default_is_input_redirected_to_false_in_test_console() + { + // Arrange & Act + using TestConsole console = new(); - await Task.CompletedTask; - } + // Assert + console.IsInputRedirected.ShouldBeFalse(); - public static async Task Should_default_is_output_redirected_to_false_in_test_console() - { - // Arrange & Act - using TestConsole console = new(); + await Task.CompletedTask; + } - // Assert - console.IsOutputRedirected.ShouldBeFalse(); + public static async Task Should_default_is_output_redirected_to_false_in_test_console() + { + // Arrange & Act + using TestConsole console = new(); - await Task.CompletedTask; - } + // Assert + console.IsOutputRedirected.ShouldBeFalse(); - public static async Task Should_default_is_error_redirected_to_false_in_test_console() - { - // Arrange & Act - using TestConsole console = new(); + await Task.CompletedTask; + } - // Assert - console.IsErrorRedirected.ShouldBeFalse(); + public static async Task Should_default_is_error_redirected_to_false_in_test_console() + { + // Arrange & Act + using TestConsole console = new(); - await Task.CompletedTask; - } + // Assert + console.IsErrorRedirected.ShouldBeFalse(); - public static async Task Should_set_is_input_redirected_in_test_console() - { - // Arrange - using TestConsole console = new(); + await Task.CompletedTask; + } - // Act - console.IsInputRedirected = true; + public static async Task Should_set_is_input_redirected_in_test_console() + { + // Arrange + using TestConsole console = new(); - // Assert - console.IsInputRedirected.ShouldBeTrue(); + // Act + console.IsInputRedirected = true; - await Task.CompletedTask; - } + // Assert + console.IsInputRedirected.ShouldBeTrue(); - public static async Task Should_set_is_output_redirected_in_test_console() - { - // Arrange - using TestConsole console = new(); + await Task.CompletedTask; + } - // Act - console.IsOutputRedirected = true; + public static async Task Should_set_is_output_redirected_in_test_console() + { + // Arrange + using TestConsole console = new(); - // Assert - console.IsOutputRedirected.ShouldBeTrue(); + // Act + console.IsOutputRedirected = true; - await Task.CompletedTask; - } + // Assert + console.IsOutputRedirected.ShouldBeTrue(); - public static async Task Should_set_is_error_redirected_in_test_console() - { - // Arrange - using TestConsole console = new(); + await Task.CompletedTask; + } - // Act - console.IsErrorRedirected = true; + public static async Task Should_set_is_error_redirected_in_test_console() + { + // Arrange + using TestConsole console = new(); - // Assert - console.IsErrorRedirected.ShouldBeTrue(); + // Act + console.IsErrorRedirected = true; - await Task.CompletedTask; - } -} + // Assert + console.IsErrorRedirected.ShouldBeTrue(); -[TestTag("ITerminal")] -[TestTag("Encoding")] -public class TerminalEncodingBasicTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); + await Task.CompletedTask; + } + } - public static async Task Should_default_input_encoding_to_utf8_in_test_terminal() + [TestTag("ITerminal")] + [TestTag("Encoding")] + public class TerminalEncodingBasicTests { - // Arrange & Act - using TestTerminal terminal = new(); + [ModuleInitializer] + internal static void Register() => RegisterTests(); - // Assert - terminal.InputEncoding.ShouldBe(Encoding.UTF8); + public static async Task Should_default_input_encoding_to_utf8_in_test_terminal() + { + // Arrange & Act + using TestTerminal terminal = new(); - await Task.CompletedTask; - } + // Assert + terminal.InputEncoding.ShouldBe(Encoding.UTF8); - public static async Task Should_default_output_encoding_to_utf8_in_test_terminal() - { - // Arrange & Act - using TestTerminal terminal = new(); + await Task.CompletedTask; + } - // Assert - terminal.OutputEncoding.ShouldBe(Encoding.UTF8); + public static async Task Should_default_output_encoding_to_utf8_in_test_terminal() + { + // Arrange & Act + using TestTerminal terminal = new(); - await Task.CompletedTask; - } + // Assert + terminal.OutputEncoding.ShouldBe(Encoding.UTF8); - public static async Task Should_set_input_encoding_in_test_terminal() - { - // Arrange - using TestTerminal terminal = new(); - Encoding unicode = Encoding.Unicode; + await Task.CompletedTask; + } - // Act - terminal.InputEncoding = unicode; + public static async Task Should_set_input_encoding_in_test_terminal() + { + // Arrange + using TestTerminal terminal = new(); + Encoding unicode = Encoding.Unicode; - // Assert - terminal.InputEncoding.ShouldBe(unicode); + // Act + terminal.InputEncoding = unicode; - await Task.CompletedTask; - } + // Assert + terminal.InputEncoding.ShouldBe(unicode); - public static async Task Should_set_output_encoding_in_test_terminal() - { - // Arrange - using TestTerminal terminal = new(); - Encoding ascii = Encoding.ASCII; + await Task.CompletedTask; + } - // Act - terminal.OutputEncoding = ascii; + public static async Task Should_set_output_encoding_in_test_terminal() + { + // Arrange + using TestTerminal terminal = new(); + Encoding ascii = Encoding.ASCII; - // Assert - terminal.OutputEncoding.ShouldBe(ascii); + // Act + terminal.OutputEncoding = ascii; - await Task.CompletedTask; - } + // Assert + terminal.OutputEncoding.ShouldBe(ascii); - public static async Task Should_default_is_input_redirected_to_false_in_test_terminal() - { - // Arrange & Act - using TestTerminal terminal = new(); + await Task.CompletedTask; + } - // Assert - terminal.IsInputRedirected.ShouldBeFalse(); + public static async Task Should_default_is_input_redirected_to_false_in_test_terminal() + { + // Arrange & Act + using TestTerminal terminal = new(); - await Task.CompletedTask; - } + // Assert + terminal.IsInputRedirected.ShouldBeFalse(); - public static async Task Should_default_is_output_redirected_to_false_in_test_terminal() - { - // Arrange & Act - using TestTerminal terminal = new(); + await Task.CompletedTask; + } - // Assert - terminal.IsOutputRedirected.ShouldBeFalse(); + public static async Task Should_default_is_output_redirected_to_false_in_test_terminal() + { + // Arrange & Act + using TestTerminal terminal = new(); - await Task.CompletedTask; - } + // Assert + terminal.IsOutputRedirected.ShouldBeFalse(); - public static async Task Should_default_is_error_redirected_to_false_in_test_terminal() - { - // Arrange & Act - using TestTerminal terminal = new(); + await Task.CompletedTask; + } - // Assert - terminal.IsErrorRedirected.ShouldBeFalse(); + public static async Task Should_default_is_error_redirected_to_false_in_test_terminal() + { + // Arrange & Act + using TestTerminal terminal = new(); - await Task.CompletedTask; - } + // Assert + terminal.IsErrorRedirected.ShouldBeFalse(); - public static async Task Should_set_is_input_redirected_in_test_terminal() - { - // Arrange - using TestTerminal terminal = new(); + await Task.CompletedTask; + } - // Act - terminal.IsInputRedirected = true; + public static async Task Should_set_is_input_redirected_in_test_terminal() + { + // Arrange + using TestTerminal terminal = new(); - // Assert - terminal.IsInputRedirected.ShouldBeTrue(); + // Act + terminal.IsInputRedirected = true; - await Task.CompletedTask; - } + // Assert + terminal.IsInputRedirected.ShouldBeTrue(); - public static async Task Should_set_is_output_redirected_in_test_terminal() - { - // Arrange - using TestTerminal terminal = new(); + await Task.CompletedTask; + } - // Act - terminal.IsOutputRedirected = true; + public static async Task Should_set_is_output_redirected_in_test_terminal() + { + // Arrange + using TestTerminal terminal = new(); - // Assert - terminal.IsOutputRedirected.ShouldBeTrue(); + // Act + terminal.IsOutputRedirected = true; - await Task.CompletedTask; - } + // Assert + terminal.IsOutputRedirected.ShouldBeTrue(); - public static async Task Should_set_is_error_redirected_in_test_terminal() - { - // Arrange - using TestTerminal terminal = new(); + await Task.CompletedTask; + } - // Act - terminal.IsErrorRedirected = true; + public static async Task Should_set_is_error_redirected_in_test_terminal() + { + // Arrange + using TestTerminal terminal = new(); - // Assert - terminal.IsErrorRedirected.ShouldBeTrue(); + // Act + terminal.IsErrorRedirected = true; - await Task.CompletedTask; - } + // Assert + terminal.IsErrorRedirected.ShouldBeTrue(); - public static async Task Should_access_encoding_via_iconsole_interface() - { - // Arrange - using TestTerminal terminal = new(); + await Task.CompletedTask; + } + + public static async Task Should_access_encoding_via_iconsole_interface() + { + // Arrange + using TestTerminal terminal = new(); #pragma warning disable CA1859 // Intentionally testing IConsole interface access - IConsole console = terminal; + IConsole console = terminal; #pragma warning restore CA1859 - Encoding utf32 = Encoding.UTF32; + Encoding utf32 = Encoding.UTF32; - // Act - console.InputEncoding = utf32; - console.OutputEncoding = utf32; + // Act + console.InputEncoding = utf32; + console.OutputEncoding = utf32; - // Assert - console.InputEncoding.ShouldBe(utf32); - console.OutputEncoding.ShouldBe(utf32); + // Assert + console.InputEncoding.ShouldBe(utf32); + console.OutputEncoding.ShouldBe(utf32); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_access_redirection_via_iconsole_interface() - { - // Arrange - using TestTerminal terminal = new(); + public static async Task Should_access_redirection_via_iconsole_interface() + { + // Arrange + using TestTerminal terminal = new(); #pragma warning disable CA1859 // Intentionally testing IConsole interface access - IConsole console = terminal; + IConsole console = terminal; #pragma warning restore CA1859 - // Act - console.IsInputRedirected.ShouldBeFalse(); - console.IsOutputRedirected.ShouldBeFalse(); - console.IsErrorRedirected.ShouldBeFalse(); + // Act + console.IsInputRedirected.ShouldBeFalse(); + console.IsOutputRedirected.ShouldBeFalse(); + console.IsErrorRedirected.ShouldBeFalse(); - // Assert - properties are accessible via IConsole interface - console.ShouldBeAssignableTo(); + // Assert - properties are accessible via IConsole interface + console.ShouldBeAssignableTo(); - await Task.CompletedTask; + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.ConsoleEncoding diff --git a/tests/hyperlink-01-basic.cs b/tests/hyperlink-01-basic.cs index dac1c90..edac984 100755 --- a/tests/hyperlink-01-basic.cs +++ b/tests/hyperlink-01-basic.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test OSC 8 hyperlink functionality @@ -36,12 +36,12 @@ public static async Task Should_create_hyperlink_with_link_extension() public static async Task Should_create_hyperlink_with_create_link_method() { - // Arrange + // Arrange - CreateLink takes (url, displayText), matching WriteLink's parameter order string displayText = "GitHub"; string url = "https://github.com"; // Act - string result = AnsiHyperlinks.CreateLink(displayText, url); + string result = AnsiHyperlinks.CreateLink(url, displayText); // Assert string expected = $"\x1b]8;;{url}\x1b\\{displayText}\x1b]8;;\x1b\\"; @@ -50,6 +50,28 @@ public static async Task Should_create_hyperlink_with_create_link_method() await Task.CompletedTask; } + public static async Task Should_create_link_with_url_first_parameter_order() + { + // Regression: CreateLink was changed from (displayText, url) to (url, displayText) + // to match Terminal.WriteLink and the ITerminal WriteLink/WriteLinkLine extensions + string result = AnsiHyperlinks.CreateLink("https://x", "text"); + + result.ShouldBe("\x1b]8;;https://x\x1b\\text\x1b]8;;\x1b\\"); + + await Task.CompletedTask; + } + + public static async Task Should_use_url_as_display_text_when_create_link_display_text_omitted() + { + // Act - displayText defaults to null, which means "use the URL" + string result = AnsiHyperlinks.CreateLink("https://example.com"); + + // Assert + result.ShouldBe("\x1b]8;;https://example.com\x1b\\https://example.com\x1b]8;;\x1b\\"); + + await Task.CompletedTask; + } + public static async Task Should_chain_hyperlink_with_color_extensions() { // Arrange @@ -176,6 +198,49 @@ public static async Task Should_handle_empty_display_text() await Task.CompletedTask; } + public static async Task Should_percent_encode_control_characters_in_url() + { + // Arrange - URL with embedded ESC and BEL that could terminate the OSC 8 sequence + string displayText = "Click here"; + string url = "https://example.com/\x1bmalicious\x07path"; + + // Act + string result = AnsiHyperlinks.CreateLink(url, displayText); + + // Assert - ESC and BEL inside the OSC payload must be percent-encoded + result.ShouldContain("%1B"); + result.ShouldContain("%07"); + + // The only raw ESC characters allowed are the OSC 8 framing sequences themselves + string expected = $"\x1b]8;;https://example.com/%1Bmalicious%07path\x1b\\{displayText}\x1b]8;;\x1b\\"; + result.ShouldBe(expected); + + // The OSC payload (between LinkStart and LinkEnd) must contain no raw ESC or BEL + int payloadStart = "\x1b]8;;".Length; + int payloadEnd = result.IndexOf("\x1b\\", payloadStart, StringComparison.Ordinal); + string payload = result[payloadStart..payloadEnd]; + payload.ShouldNotContain("\x1b"); + payload.ShouldNotContain("\x07"); + + await Task.CompletedTask; + } + + public static async Task Should_leave_normal_url_unchanged() + { + // Arrange + string displayText = "GitHub"; + string url = "https://github.com/TimeWarpEngineering/timewarp-terminal?tab=readme#usage"; + + // Act + string result = AnsiHyperlinks.CreateLink(url, displayText); + + // Assert - URL embedded verbatim, no encoding applied + string expected = $"\x1b]8;;{url}\x1b\\{displayText}\x1b]8;;\x1b\\"; + result.ShouldBe(expected); + + await Task.CompletedTask; + } + public static async Task Should_test_terminal_default_hyperlink_support_is_false() { // Arrange @@ -186,6 +251,66 @@ public static async Task Should_test_terminal_default_hyperlink_support_is_false await Task.CompletedTask; } + + public static async Task Should_write_plain_text_when_facade_hyperlinks_not_supported() + { + // Arrange - static facade must honor SupportsHyperlinks like the extension does + ITerminal original = TimeWarp.Terminal.Terminal.Instance; + using TestTerminal noLinks = new(); // SupportsHyperlinks defaults to false + using TestTerminal withLinks = new() { SupportsHyperlinks = true }; + + try + { + // Act + Assert - unsupported: plain display text, no OSC 8 escapes + TimeWarp.Terminal.Terminal.Instance = noLinks; + TimeWarp.Terminal.Terminal.WriteLink("https://example.com", "Example"); + noLinks.Output.ShouldBe("Example"); + noLinks.Output.ShouldNotContain("\u001b"); + + // supported: OSC 8 sequence emitted + TimeWarp.Terminal.Terminal.Instance = withLinks; + TimeWarp.Terminal.Terminal.WriteLink("https://example.com", "Example"); + withLinks.Output.ShouldContain("\u001b]8;;https://example.com"); + withLinks.Output.ShouldContain("Example"); + } + finally + { + TimeWarp.Terminal.Terminal.Instance = original; + } + + await Task.CompletedTask; + } + + public static async Task Should_write_link_line_via_facade_with_newline_and_hyperlink_gating() + { + // Regression: Terminal.WriteLinkLine is the newline counterpart of Terminal.WriteLink + // and must honor SupportsHyperlinks the same way + ITerminal original = TimeWarp.Terminal.Terminal.Instance; + using TestTerminal noLinks = new(); // SupportsHyperlinks defaults to false + using TestTerminal withLinks = new() { SupportsHyperlinks = true }; + + try + { + // Act + Assert - unsupported: plain display text plus newline, no OSC 8 escapes + TimeWarp.Terminal.Terminal.Instance = noLinks; + TimeWarp.Terminal.Terminal.WriteLinkLine("https://example.com", "Example"); + noLinks.Output.ShouldBe("Example" + Environment.NewLine); + noLinks.Output.ShouldNotContain("\u001b"); + + // supported: OSC 8 sequence emitted, still ends with newline + TimeWarp.Terminal.Terminal.Instance = withLinks; + TimeWarp.Terminal.Terminal.WriteLinkLine("https://example.com", "Example"); + withLinks.Output.ShouldContain("\u001b]8;;https://example.com"); + withLinks.Output.ShouldContain("Example"); + withLinks.Output.ShouldEndWith(Environment.NewLine); + } + finally + { + TimeWarp.Terminal.Terminal.Instance = original; + } + + await Task.CompletedTask; + } } } // namespace TimeWarp.Terminal.Tests.Core.Hyperlink diff --git a/tests/panel-widget-01-basic.cs b/tests/panel-widget-01-basic.cs index f51223e..12d157a 100755 --- a/tests/panel-widget-01-basic.cs +++ b/tests/panel-widget-01-basic.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test Panel widget basic functionality @@ -10,227 +10,227 @@ namespace TimeWarp.Terminal.Tests.Core.PanelWidget { -[TestTag("Widgets")] -public class PanelWidgetBasicTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_render_simple_panel_with_content() - { - // Arrange - Panel panel = new PanelBuilder().Content("Hello World").Build(); - - // Act - string[] lines = panel.Render(40); - - // Assert - lines.Length.ShouldBe(3); // top border + content + bottom border - lines[0].ShouldContain("╭"); // rounded top-left - lines[0].ShouldContain("╮"); // rounded top-right - lines[1].ShouldContain("Hello World"); - lines[1].ShouldContain("│"); // vertical border - lines[2].ShouldContain("╰"); // rounded bottom-left - lines[2].ShouldContain("╯"); // rounded bottom-right - - await Task.CompletedTask; - } - - public static async Task Should_render_panel_with_header() - { - // Arrange - Panel panel = new PanelBuilder().Header("Notice").Content("Important info").Build(); - - // Act - string[] lines = panel.Render(40); - - // Assert - lines[0].ShouldContain("Notice"); - lines[1].ShouldContain("Important info"); - - await Task.CompletedTask; - } - - public static async Task Should_render_panel_with_square_border() - { - // Arrange - Panel panel = new PanelBuilder().Content("Test").Border(BorderStyle.Square).Build(); - - // Act - string[] lines = panel.Render(20); - - // Assert - lines[0].ShouldContain("┌"); // square top-left - lines[0].ShouldContain("┐"); // square top-right - lines[2].ShouldContain("└"); // square bottom-left - lines[2].ShouldContain("┘"); // square bottom-right - - await Task.CompletedTask; - } - - public static async Task Should_render_panel_with_double_border() - { - // Arrange - Panel panel = new PanelBuilder().Content("Test").Border(BorderStyle.Doubled).Build(); - - // Act - string[] lines = panel.Render(20); - - // Assert - lines[0].ShouldContain("╔"); // double top-left - lines[0].ShouldContain("╗"); // double top-right - lines[0].ShouldContain("═"); // double horizontal - lines[1].ShouldContain("║"); // double vertical - lines[2].ShouldContain("╚"); // double bottom-left - lines[2].ShouldContain("╝"); // double bottom-right - - await Task.CompletedTask; - } - - public static async Task Should_render_panel_with_heavy_border() - { - // Arrange - Panel panel = new PanelBuilder().Content("Test").Border(BorderStyle.Heavy).Build(); - - // Act - string[] lines = panel.Render(20); - - // Assert - lines[0].ShouldContain("┏"); // heavy top-left - lines[0].ShouldContain("┓"); // heavy top-right - lines[0].ShouldContain("━"); // heavy horizontal - lines[1].ShouldContain("┃"); // heavy vertical - lines[2].ShouldContain("┗"); // heavy bottom-left - lines[2].ShouldContain("┛"); // heavy bottom-right - - await Task.CompletedTask; - } - - public static async Task Should_render_multiline_content() - { - // Arrange - Panel panel = new PanelBuilder().Content("Line 1\nLine 2\nLine 3").Build(); - - // Act - string[] lines = panel.Render(40); - - // Assert - lines.Length.ShouldBe(5); // top + 3 content lines + bottom - lines[1].ShouldContain("Line 1"); - lines[2].ShouldContain("Line 2"); - lines[3].ShouldContain("Line 3"); - - await Task.CompletedTask; - } - - public static async Task Should_render_panel_with_vertical_padding() - { - // Arrange - Panel panel = new PanelBuilder().Content("Content").PaddingVertical(1).Build(); - - // Act - string[] lines = panel.Render(40); - - // Assert - // top + 1 pad + content + 1 pad + bottom = 5 lines - lines.Length.ShouldBe(5); - lines[1].ShouldNotContain("Content"); // padding line - lines[2].ShouldContain("Content"); - lines[3].ShouldNotContain("Content"); // padding line - - await Task.CompletedTask; - } - - public static async Task Should_render_panel_with_horizontal_padding() + [TestTag("Widgets")] + public class PanelWidgetBasicTests { - // Arrange - Panel panel = new PanelBuilder().Content("Hi").PaddingHorizontal(3).Build(); - - // Act - string[] lines = panel.Render(40); - - // Assert - // Content line should have 3 spaces before and after content - lines[1].ShouldContain("│ Hi"); // 3 spaces of padding + [ModuleInitializer] + internal static void Register() => RegisterTests(); + + public static async Task Should_render_simple_panel_with_content() + { + // Arrange + Panel panel = new PanelBuilder().Content("Hello World").Build(); + + // Act + string[] lines = panel.Render(40); + + // Assert + lines.Length.ShouldBe(3); // top border + content + bottom border + lines[0].ShouldContain("╭"); // rounded top-left + lines[0].ShouldContain("╮"); // rounded top-right + lines[1].ShouldContain("Hello World"); + lines[1].ShouldContain("│"); // vertical border + lines[2].ShouldContain("╰"); // rounded bottom-left + lines[2].ShouldContain("╯"); // rounded bottom-right + + await Task.CompletedTask; + } + + public static async Task Should_render_panel_with_header() + { + // Arrange + Panel panel = new PanelBuilder().Header("Notice").Content("Important info").Build(); + + // Act + string[] lines = panel.Render(40); + + // Assert + lines[0].ShouldContain("Notice"); + lines[1].ShouldContain("Important info"); + + await Task.CompletedTask; + } + + public static async Task Should_render_panel_with_square_border() + { + // Arrange + Panel panel = new PanelBuilder().Content("Test").Border(BorderStyle.Square).Build(); + + // Act + string[] lines = panel.Render(20); + + // Assert + lines[0].ShouldContain("┌"); // square top-left + lines[0].ShouldContain("┐"); // square top-right + lines[2].ShouldContain("└"); // square bottom-left + lines[2].ShouldContain("┘"); // square bottom-right + + await Task.CompletedTask; + } + + public static async Task Should_render_panel_with_double_border() + { + // Arrange + Panel panel = new PanelBuilder().Content("Test").Border(BorderStyle.Doubled).Build(); + + // Act + string[] lines = panel.Render(20); + + // Assert + lines[0].ShouldContain("╔"); // double top-left + lines[0].ShouldContain("╗"); // double top-right + lines[0].ShouldContain("═"); // double horizontal + lines[1].ShouldContain("║"); // double vertical + lines[2].ShouldContain("╚"); // double bottom-left + lines[2].ShouldContain("╝"); // double bottom-right + + await Task.CompletedTask; + } + + public static async Task Should_render_panel_with_heavy_border() + { + // Arrange + Panel panel = new PanelBuilder().Content("Test").Border(BorderStyle.Heavy).Build(); + + // Act + string[] lines = panel.Render(20); + + // Assert + lines[0].ShouldContain("┏"); // heavy top-left + lines[0].ShouldContain("┓"); // heavy top-right + lines[0].ShouldContain("━"); // heavy horizontal + lines[1].ShouldContain("┃"); // heavy vertical + lines[2].ShouldContain("┗"); // heavy bottom-left + lines[2].ShouldContain("┛"); // heavy bottom-right + + await Task.CompletedTask; + } + + public static async Task Should_render_multiline_content() + { + // Arrange + Panel panel = new PanelBuilder().Content("Line 1\nLine 2\nLine 3").Build(); + + // Act + string[] lines = panel.Render(40); + + // Assert + lines.Length.ShouldBe(5); // top + 3 content lines + bottom + lines[1].ShouldContain("Line 1"); + lines[2].ShouldContain("Line 2"); + lines[3].ShouldContain("Line 3"); + + await Task.CompletedTask; + } + + public static async Task Should_render_panel_with_vertical_padding() + { + // Arrange + Panel panel = new PanelBuilder().Content("Content").PaddingVertical(1).Build(); + + // Act + string[] lines = panel.Render(40); + + // Assert + // top + 1 pad + content + 1 pad + bottom = 5 lines + lines.Length.ShouldBe(5); + lines[1].ShouldNotContain("Content"); // padding line + lines[2].ShouldContain("Content"); + lines[3].ShouldNotContain("Content"); // padding line + + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_render_panel_with_horizontal_padding() + { + // Arrange + Panel panel = new PanelBuilder().Content("Hi").PaddingHorizontal(3).Build(); + + // Act + string[] lines = panel.Render(40); + + // Assert + // Content line should have 3 spaces before and after content + lines[1].ShouldContain("│ Hi"); // 3 spaces of padding + + await Task.CompletedTask; + } + + public static async Task Should_render_panel_with_fixed_width() + { + // Arrange + Panel panel = new PanelBuilder().Content("Test").Width(20).Build(); + + // Act + string[] lines = panel.Render(80); // terminal width should be ignored + + // Assert + TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]).ShouldBe(20); + TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[2]).ShouldBe(20); + + await Task.CompletedTask; + } + + public static async Task Should_render_panel_with_border_color() + { + // Arrange + Panel panel = new PanelBuilder().Content("Test").BorderColor(AnsiColors.Cyan).Build(); + + // Act + string[] lines = panel.Render(30); + + // Assert + lines[0].ShouldContain(AnsiColors.Cyan); + lines[0].ShouldContain(AnsiColors.Reset); + lines[1].ShouldContain(AnsiColors.Cyan); + lines[2].ShouldContain(AnsiColors.Cyan); - public static async Task Should_render_panel_with_fixed_width() - { - // Arrange - Panel panel = new PanelBuilder().Content("Test").Width(20).Build(); + await Task.CompletedTask; + } + + public static async Task Should_render_panel_without_border_when_style_is_none() + { + // Arrange + Panel panel = new PanelBuilder().Content("Line 1\nLine 2").Border(BorderStyle.None).Build(); - // Act - string[] lines = panel.Render(80); // terminal width should be ignored + // Act + string[] lines = panel.Render(40); + + // Assert + lines.Length.ShouldBe(2); + lines[0].ShouldBe("Line 1"); + lines[1].ShouldBe("Line 2"); - // Assert - TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]).ShouldBe(20); - TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[2]).ShouldBe(20); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_handle_empty_content() + { + // Arrange + Panel panel = new PanelBuilder().Content("").Build(); - public static async Task Should_render_panel_with_border_color() - { - // Arrange - Panel panel = new PanelBuilder().Content("Test").BorderColor(AnsiColors.Cyan).Build(); + // Act + string[] lines = panel.Render(20); - // Act - string[] lines = panel.Render(30); + // Assert + lines.Length.ShouldBe(3); // top + empty content row + bottom - // Assert - lines[0].ShouldContain(AnsiColors.Cyan); - lines[0].ShouldContain(AnsiColors.Reset); - lines[1].ShouldContain(AnsiColors.Cyan); - lines[2].ShouldContain(AnsiColors.Cyan); + await Task.CompletedTask; + } - await Task.CompletedTask; - } - - public static async Task Should_render_panel_without_border_when_style_is_none() - { - // Arrange - Panel panel = new PanelBuilder().Content("Line 1\nLine 2").Border(BorderStyle.None).Build(); - - // Act - string[] lines = panel.Render(40); - - // Assert - lines.Length.ShouldBe(2); - lines[0].ShouldBe("Line 1"); - lines[1].ShouldBe("Line 2"); - - await Task.CompletedTask; - } - - public static async Task Should_handle_empty_content() - { - // Arrange - Panel panel = new PanelBuilder().Content("").Build(); - - // Act - string[] lines = panel.Render(20); - - // Assert - lines.Length.ShouldBe(3); // top + empty content row + bottom - - await Task.CompletedTask; - } - - public static async Task Should_handle_null_content() - { - // Arrange - Panel panel = new PanelBuilder().Content((string?)null).Build(); + public static async Task Should_handle_null_content() + { + // Arrange + Panel panel = new PanelBuilder().Content(null).Build(); - // Act - string[] lines = panel.Render(20); + // Act + string[] lines = panel.Render(20); - // Assert - lines.Length.ShouldBe(3); // top + empty content row + bottom + // Assert + lines.Length.ShouldBe(3); // top + empty content row + bottom - await Task.CompletedTask; + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.PanelWidget diff --git a/tests/panel-widget-02-terminal-extensions.cs b/tests/panel-widget-02-terminal-extensions.cs index dda85ca..8e6f3e1 100755 --- a/tests/panel-widget-02-terminal-extensions.cs +++ b/tests/panel-widget-02-terminal-extensions.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test Panel widget terminal extension methods @@ -10,166 +10,166 @@ namespace TimeWarp.Terminal.Tests.Core.PanelWidgetTerminal { -[TestTag("Widgets")] -public class PanelTerminalExtensionTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_write_simple_panel_to_terminal() - { - // Arrange - using TestTerminal terminal = new() { WindowWidth = 40 }; - - // Act - terminal.WritePanel("Hello World"); - - // Assert - terminal.Output.ShouldContain("Hello World"); - terminal.Output.ShouldContain("╭"); // rounded corners by default - terminal.Output.ShouldContain("╯"); - - await Task.CompletedTask; - } - - public static async Task Should_write_panel_with_header() - { - // Arrange - using TestTerminal terminal = new() { WindowWidth = 40 }; - - // Act - terminal.WritePanel("Content here", "Notice"); - - // Assert - terminal.Output.ShouldContain("Notice"); - terminal.Output.ShouldContain("Content here"); - - await Task.CompletedTask; - } - - public static async Task Should_write_panel_with_border_style() - { - // Arrange - using TestTerminal terminal = new() { WindowWidth = 40 }; - - // Act - terminal.WritePanel("Test", BorderStyle.Doubled); - - // Assert - terminal.Output.ShouldContain("╔"); // double border - terminal.Output.ShouldContain("║"); - terminal.Output.ShouldContain("╝"); - - await Task.CompletedTask; - } - - public static async Task Should_write_panel_with_fluent_builder() - { - // Arrange - using TestTerminal terminal = new() { WindowWidth = 40 }; - - // Act - terminal.WritePanel(panel => panel - .Header("Configuration") - .Content("Setting: value") - .Border(BorderStyle.Rounded) - .Padding(2, 1)); - - // Assert - terminal.Output.ShouldContain("Configuration"); - terminal.Output.ShouldContain("Setting: value"); - terminal.Output.ShouldContain("╭"); - - await Task.CompletedTask; - } - - public static async Task Should_write_preconfigured_panel() + [TestTag("Widgets")] + public class PanelTerminalExtensionTests { - // Arrange - using TestTerminal terminal = new() { WindowWidth = 40 }; - Panel panel = new PanelBuilder() - .Header("Test") - .Content("Content") - .Border(BorderStyle.Heavy) - .BorderColor(AnsiColors.Yellow) - .Build(); - - // Act - terminal.WritePanel(panel); - - // Assert - terminal.Output.ShouldContain("Test"); - terminal.Output.ShouldContain("Content"); - terminal.Output.ShouldContain("┏"); // heavy border - terminal.Output.ShouldContain(AnsiColors.Yellow); - - await Task.CompletedTask; - } - - public static async Task Should_use_terminal_width() - { - // Arrange - using TestTerminal terminal = new() { WindowWidth = 60 }; - - // Act - terminal.WritePanel("Test content"); - - // Assert - string[] lines = terminal.Output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]).ShouldBe(60); - - await Task.CompletedTask; - } - - public static async Task Should_handle_multiline_content() - { - // Arrange - using TestTerminal terminal = new() { WindowWidth = 40 }; - - // Act - terminal.WritePanel("Line 1\nLine 2\nLine 3"); - - // Assert - terminal.Output.ShouldContain("Line 1"); - terminal.Output.ShouldContain("Line 2"); - terminal.Output.ShouldContain("Line 3"); - - await Task.CompletedTask; - } - - public static async Task Should_write_panel_with_styled_header() - { - // Arrange - using TestTerminal terminal = new() { WindowWidth = 40 }; - - // Act - terminal.WritePanel(panel => panel - .Header("Results".Cyan().Bold()) - .Content("Success")); - - // Assert - terminal.Output.ShouldContain("Results"); - terminal.Output.ShouldContain(AnsiColors.Cyan); - terminal.Output.ShouldContain("Success"); - - await Task.CompletedTask; - } - - public static async Task Should_include_newlines_between_panel_lines() - { - // Arrange - using TestTerminal terminal = new() { WindowWidth = 40 }; - - // Act - terminal.WritePanel("Content"); - - // Assert - // Should have 3 lines: top, content, bottom - string[] lines = terminal.Output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); - lines.Length.ShouldBe(3); - - await Task.CompletedTask; + [ModuleInitializer] + internal static void Register() => RegisterTests(); + + public static async Task Should_write_simple_panel_to_terminal() + { + // Arrange + using TestTerminal terminal = new() { WindowWidth = 40 }; + + // Act + terminal.WritePanel("Hello World"); + + // Assert + terminal.Output.ShouldContain("Hello World"); + terminal.Output.ShouldContain("╭"); // rounded corners by default + terminal.Output.ShouldContain("╯"); + + await Task.CompletedTask; + } + + public static async Task Should_write_panel_with_header() + { + // Arrange + using TestTerminal terminal = new() { WindowWidth = 40 }; + + // Act + terminal.WritePanel("Content here", "Notice"); + + // Assert + terminal.Output.ShouldContain("Notice"); + terminal.Output.ShouldContain("Content here"); + + await Task.CompletedTask; + } + + public static async Task Should_write_panel_with_border_style() + { + // Arrange + using TestTerminal terminal = new() { WindowWidth = 40 }; + + // Act + terminal.WritePanel("Test", BorderStyle.Doubled); + + // Assert + terminal.Output.ShouldContain("╔"); // double border + terminal.Output.ShouldContain("║"); + terminal.Output.ShouldContain("╝"); + + await Task.CompletedTask; + } + + public static async Task Should_write_panel_with_fluent_builder() + { + // Arrange + using TestTerminal terminal = new() { WindowWidth = 40 }; + + // Act + terminal.WritePanel(panel => panel + .Header("Configuration") + .Content("Setting: value") + .Border(BorderStyle.Rounded) + .Padding(2, 1)); + + // Assert + terminal.Output.ShouldContain("Configuration"); + terminal.Output.ShouldContain("Setting: value"); + terminal.Output.ShouldContain("╭"); + + await Task.CompletedTask; + } + + public static async Task Should_write_preconfigured_panel() + { + // Arrange + using TestTerminal terminal = new() { WindowWidth = 40 }; + Panel panel = new PanelBuilder() + .Header("Test") + .Content("Content") + .Border(BorderStyle.Heavy) + .BorderColor(AnsiColors.Yellow) + .Build(); + + // Act + terminal.WritePanel(panel); + + // Assert + terminal.Output.ShouldContain("Test"); + terminal.Output.ShouldContain("Content"); + terminal.Output.ShouldContain("┏"); // heavy border + terminal.Output.ShouldContain(AnsiColors.Yellow); + + await Task.CompletedTask; + } + + public static async Task Should_use_terminal_width() + { + // Arrange + using TestTerminal terminal = new() { WindowWidth = 60 }; + + // Act + terminal.WritePanel("Test content"); + + // Assert + string[] lines = terminal.Output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]).ShouldBe(60); + + await Task.CompletedTask; + } + + public static async Task Should_handle_multiline_content() + { + // Arrange + using TestTerminal terminal = new() { WindowWidth = 40 }; + + // Act + terminal.WritePanel("Line 1\nLine 2\nLine 3"); + + // Assert + terminal.Output.ShouldContain("Line 1"); + terminal.Output.ShouldContain("Line 2"); + terminal.Output.ShouldContain("Line 3"); + + await Task.CompletedTask; + } + + public static async Task Should_write_panel_with_styled_header() + { + // Arrange + using TestTerminal terminal = new() { WindowWidth = 40 }; + + // Act + terminal.WritePanel(panel => panel + .Header("Results".Cyan().Bold()) + .Content("Success")); + + // Assert + terminal.Output.ShouldContain("Results"); + terminal.Output.ShouldContain(AnsiColors.Cyan); + terminal.Output.ShouldContain("Success"); + + await Task.CompletedTask; + } + + public static async Task Should_include_newlines_between_panel_lines() + { + // Arrange + using TestTerminal terminal = new() { WindowWidth = 40 }; + + // Act + terminal.WritePanel("Content"); + + // Assert + // Should have 3 lines: top, content, bottom + string[] lines = terminal.Output.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries); + lines.Length.ShouldBe(3); + + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.PanelWidgetTerminal diff --git a/tests/panel-widget-03-word-wrap.cs b/tests/panel-widget-03-word-wrap.cs index a00d13a..c858149 100755 --- a/tests/panel-widget-03-word-wrap.cs +++ b/tests/panel-widget-03-word-wrap.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test Panel widget word wrapping functionality @@ -10,193 +10,226 @@ namespace TimeWarp.Terminal.Tests.Core.PanelWidgetWrap { -[TestTag("Widgets")] -public class PanelWidgetWordWrapTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_wrap_long_content_within_panel_width() + [TestTag("Widgets")] + public class PanelWidgetWordWrapTests { - // Arrange - string longText = "Learn how to get started with building web products with ASP.NET Core. This course covers the fundamentals of ASP.NET Core web development."; - Panel panel = new PanelBuilder().Content(longText).Width(80).Build(); - - // Act - string[] lines = panel.Render(80); + [ModuleInitializer] + internal static void Register() => RegisterTests(); - // Assert - All content lines should fit within the panel width - foreach (string line in lines) + public static async Task Should_wrap_long_content_within_panel_width() { - int visibleLength = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(line); - visibleLength.ShouldBeLessThanOrEqualTo(80); - } + // Arrange + string longText = "Learn how to get started with building web products with ASP.NET Core. This course covers the fundamentals of ASP.NET Core web development."; + Panel panel = new PanelBuilder().Content(longText).Width(80).Build(); - // Should have multiple content lines due to wrapping (top + content lines + bottom) - lines.Length.ShouldBeGreaterThan(3); + // Act + string[] lines = panel.Render(80); - await Task.CompletedTask; - } + // Assert - All content lines should fit within the panel width + foreach (string line in lines) + { + int visibleLength = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(line); + visibleLength.ShouldBeLessThanOrEqualTo(80); + } - public static async Task Should_wrap_content_preserving_ansi_codes() - { - // Arrange - string styledText = $"{AnsiColors.Red}This is a very long red text that should wrap properly{AnsiColors.Reset} and continue with normal text that also needs wrapping."; - Panel panel = new PanelBuilder().Content(styledText).Width(40).Build(); + // Should have multiple content lines due to wrapping (top + content lines + bottom) + lines.Length.ShouldBeGreaterThan(3); - // Act - string[] lines = panel.Render(40); + await Task.CompletedTask; + } - // Assert - All content lines should fit within panel width - foreach (string line in lines) + public static async Task Should_wrap_content_preserving_ansi_codes() { - int visibleLength = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(line); - visibleLength.ShouldBeLessThanOrEqualTo(40); - } + // Arrange + string styledText = $"{AnsiColors.Red}This is a very long red text that should wrap properly{AnsiColors.Reset} and continue with normal text that also needs wrapping."; + Panel panel = new PanelBuilder().Content(styledText).Width(40).Build(); - // The styled text should be preserved (red color should appear) - string allContent = string.Concat(lines); - allContent.ShouldContain(AnsiColors.Red); + // Act + string[] lines = panel.Render(40); - await Task.CompletedTask; - } + // Assert - All content lines should fit within panel width + foreach (string line in lines) + { + int visibleLength = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(line); + visibleLength.ShouldBeLessThanOrEqualTo(40); + } - public static async Task Should_not_wrap_when_WordWrap_is_false() - { - // Arrange - string longText = "This is a very long line that would normally wrap but WordWrap is disabled."; - Panel panel = new PanelBuilder().Content(longText).Width(40).WordWrap(false).Build(); + // The styled text should be preserved (red color should appear) + string allContent = string.Concat(lines); + allContent.ShouldContain(AnsiColors.Red); - // Act - string[] lines = panel.Render(40); + await Task.CompletedTask; + } - // Assert - Should only have 3 lines: top border, content (not wrapped), bottom border - lines.Length.ShouldBe(3); + public static async Task Should_not_wrap_when_WordWrap_is_false() + { + // Arrange + string longText = "This is a very long line that would normally wrap but WordWrap is disabled."; + Panel panel = new PanelBuilder().Content(longText).Width(40).WordWrap(false).Build(); - // Content line should contain the full text (even if it overflows) - lines[1].ShouldContain("This is a very long line"); + // Act + string[] lines = panel.Render(40); - await Task.CompletedTask; - } + // Assert - Should only have 3 lines: top border, content (not wrapped), bottom border + lines.Length.ShouldBe(3); - public static async Task Should_wrap_content_with_hyperlinks() - { - // Arrange - OSC 8 hyperlink sequence - string hyperlink = "\x1b]8;;https://example.com\x1b\\Click here for more details and information about this topic\x1b]8;;\x1b\\"; - Panel panel = new PanelBuilder().Content(hyperlink).Width(50).Build(); + // Content line should contain the full text (even if it overflows) + lines[1].ShouldContain("This is a very long line"); - // Act - string[] lines = panel.Render(50); + await Task.CompletedTask; + } - // Assert - All lines should fit within width - foreach (string line in lines) + public static async Task Should_truncate_overlong_lines_when_WordWrap_is_false() { - int visibleLength = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(line); - visibleLength.ShouldBeLessThanOrEqualTo(50); + // Arrange - plain and ANSI-colored lines both longer than the content area + string plainLong = "This plain line is far longer than the panel content area allows."; + string coloredLong = $"{AnsiColors.Red}This red line is also far longer than the content area allows.{AnsiColors.Reset}"; + Panel panel = new PanelBuilder() + .Content($"{plainLong}\n{coloredLong}") + .Width(30) + .WordWrap(false) + .Build(); + + // Act + string[] lines = panel.Render(30); + + // Assert - top border, two content rows, bottom border + lines.Length.ShouldBe(4); + + // Every row renders at identical visible width with the right border intact + foreach (string line in lines) + { + TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(line).ShouldBe(30); + } + + lines[1].ShouldEndWith("│"); + lines[2].ShouldEndWith("│"); + + // Color is preserved on the kept text and closed before the border + lines[2].ShouldContain(AnsiColors.Red); + lines[2].ShouldContain(AnsiColors.Reset + " │"); + + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_wrap_content_with_hyperlinks() + { + // Arrange - OSC 8 hyperlink sequence + string hyperlink = "\x1b]8;;https://example.com\x1b\\Click here for more details and information about this topic\x1b]8;;\x1b\\"; + Panel panel = new PanelBuilder().Content(hyperlink).Width(50).Build(); - public static async Task Should_wrap_very_long_word() - { - // Arrange - A single word that's longer than the content area - string longWord = "Supercalifragilisticexpialidocious"; - Panel panel = new PanelBuilder().Content(longWord).Width(20).Build(); + // Act + string[] lines = panel.Render(50); - // Act - string[] lines = panel.Render(20); + // Assert - All lines should fit within width + foreach (string line in lines) + { + int visibleLength = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(line); + visibleLength.ShouldBeLessThanOrEqualTo(50); + } - // Assert - All lines should fit within panel width - foreach (string line in lines) - { - int visibleLength = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(line); - visibleLength.ShouldBeLessThanOrEqualTo(20); + await Task.CompletedTask; } - // Should have multiple lines since the word is broken - lines.Length.ShouldBeGreaterThan(3); + public static async Task Should_wrap_very_long_word() + { + // Arrange - A single word that's longer than the content area + string longWord = "Supercalifragilisticexpialidocious"; + Panel panel = new PanelBuilder().Content(longWord).Width(20).Build(); - await Task.CompletedTask; - } + // Act + string[] lines = panel.Render(20); - public static async Task Should_preserve_explicit_newlines_while_wrapping() - { - // Arrange - string multilineText = "First paragraph with some long text that needs wrapping.\nSecond paragraph that also contains long text for wrapping."; - Panel panel = new PanelBuilder().Content(multilineText).Width(40).Build(); + // Assert - All lines should fit within panel width + foreach (string line in lines) + { + int visibleLength = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(line); + visibleLength.ShouldBeLessThanOrEqualTo(20); + } - // Act - string[] lines = panel.Render(40); + // Should have multiple lines since the word is broken + lines.Length.ShouldBeGreaterThan(3); - // Assert - All lines should fit within panel width - foreach (string line in lines) - { - int visibleLength = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(line); - visibleLength.ShouldBeLessThanOrEqualTo(40); + await Task.CompletedTask; } - // Should have more than 4 lines (top + at least 2 content lines per paragraph + bottom) - lines.Length.ShouldBeGreaterThan(4); + public static async Task Should_preserve_explicit_newlines_while_wrapping() + { + // Arrange + string multilineText = "First paragraph with some long text that needs wrapping.\nSecond paragraph that also contains long text for wrapping."; + Panel panel = new PanelBuilder().Content(multilineText).Width(40).Build(); - await Task.CompletedTask; - } + // Act + string[] lines = panel.Render(40); - public static async Task Should_handle_empty_content_with_wrapping() - { - // Arrange - Panel panel = new PanelBuilder().Content("").Width(40).WordWrap(true).Build(); + // Assert - All lines should fit within panel width + foreach (string line in lines) + { + int visibleLength = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(line); + visibleLength.ShouldBeLessThanOrEqualTo(40); + } - // Act - string[] lines = panel.Render(40); + // Should have more than 4 lines (top + at least 2 content lines per paragraph + bottom) + lines.Length.ShouldBeGreaterThan(4); - // Assert - Should still render empty panel - lines.Length.ShouldBe(3); // top + empty content row + bottom + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_handle_empty_content_with_wrapping() + { + // Arrange + Panel panel = new PanelBuilder().Content("").Width(40).WordWrap(true).Build(); - public static async Task Should_use_WordWrap_from_builder() - { - // Arrange - string longText = "This is a long line that would wrap if WordWrap was enabled."; - Panel panel = new PanelBuilder() - .Content(longText) - .Width(30) - .WordWrap(false) - .Build(); + // Act + string[] lines = panel.Render(40); - // Act - string[] lines = panel.Render(30); + // Assert - Should still render empty panel + lines.Length.ShouldBe(3); // top + empty content row + bottom - // Assert - Should not wrap (3 lines: top, content, bottom) - lines.Length.ShouldBe(3); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_use_WordWrap_from_builder() + { + // Arrange + string longText = "This is a long line that would wrap if WordWrap was enabled."; + Panel panel = new PanelBuilder() + .Content(longText) + .Width(30) + .WordWrap(false) + .Build(); - public static async Task Should_wrap_with_proper_visible_width() - { - // Arrange - Content that's exactly at the boundary - string text = "Hello World! This is a test."; - Panel panel = new PanelBuilder().Content(text).Width(20).PaddingHorizontal(1).Build(); + // Act + string[] lines = panel.Render(30); - // Act - string[] lines = panel.Render(20); + // Assert - Should not wrap (3 lines: top, content, bottom) + lines.Length.ShouldBe(3); - // Assert - Content area is width(20) - 2 borders - 2 padding = 16 chars - // "Hello World! This " = 18 chars, which is > 16, so should wrap - lines.Length.ShouldBeGreaterThan(3); + await Task.CompletedTask; + } - // Each line should be exactly 20 visible chars - foreach (string line in lines) + public static async Task Should_wrap_with_proper_visible_width() { - int visibleLength = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(line); - visibleLength.ShouldBe(20); - } + // Arrange - Content that's exactly at the boundary + string text = "Hello World! This is a test."; + Panel panel = new PanelBuilder().Content(text).Width(20).PaddingHorizontal(1).Build(); + + // Act + string[] lines = panel.Render(20); + + // Assert - Content area is width(20) - 2 borders - 2 padding = 16 chars + // "Hello World! This " = 18 chars, which is > 16, so should wrap + lines.Length.ShouldBeGreaterThan(3); - await Task.CompletedTask; + // Each line should be exactly 20 visible chars + foreach (string line in lines) + { + int visibleLength = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(line); + visibleLength.ShouldBe(20); + } + + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.PanelWidgetWrap diff --git a/tests/rich-input-01-basic.cs b/tests/rich-input-01-basic.cs index 5936ae1..1f5e099 100755 --- a/tests/rich-input-01-basic.cs +++ b/tests/rich-input-01-basic.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test IConsole rich input APIs (Read, ReadKey) @@ -11,246 +11,341 @@ namespace TimeWarp.Terminal.Tests.Core.RichInput { -[TestTag("IConsole")] -[TestTag("Input")] -public class ConsoleReadBasicTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_return_single_character_from_queue() + [TestTag("IConsole")] + [TestTag("Input")] + public class ConsoleReadBasicTests { - // Arrange - using TestConsole console = new(); - console.QueueCharacters("A"); - - // Act - int result = console.Read(); - - // Assert - result.ShouldBe((int)'A'); - - await Task.CompletedTask; + [ModuleInitializer] + internal static void Register() => RegisterTests(); + + public static async Task Should_return_single_character_from_queue() + { + // Arrange + using TestConsole console = new(); + console.QueueCharacters("A"); + + // Act + int result = console.Read(); + + // Assert + result.ShouldBe('A'); + + await Task.CompletedTask; + } + + public static async Task Should_return_negative_one_when_queue_empty() + { + // Arrange + using TestConsole console = new(); + + // Act + int result = console.Read(); + + // Assert + result.ShouldBe(-1); + + await Task.CompletedTask; + } + + public static async Task Should_return_characters_in_fifo_order() + { + // Arrange + using TestConsole console = new(); + console.QueueCharacters("ABC"); + + // Act & Assert + console.Read().ShouldBe('A'); + console.Read().ShouldBe('B'); + console.Read().ShouldBe('C'); + console.Read().ShouldBe(-1); + + await Task.CompletedTask; + } + + public static async Task Should_declare_readkey_on_iterminal_not_iconsole() + { + // ReadKey is interactive-terminal functionality; it moved from IConsole to + // ITerminal for 1.0 so stream-oriented consoles are not forced to throw. + typeof(IConsole).GetMethod("ReadKey", Type.EmptyTypes).ShouldBeNull(); + typeof(ITerminal).GetMethod("ReadKey", Type.EmptyTypes).ShouldNotBeNull(); + + await Task.CompletedTask; + } + + public static async Task Should_access_read_via_iconsole_interface() + { + // Arrange + using TestConsole console = new(); + IConsole iconsole = console; + console.QueueCharacters("X"); + + // Act + int result = iconsole.Read(); + + // Assert + result.ShouldBe('X'); + + await Task.CompletedTask; + } } - public static async Task Should_return_negative_one_when_queue_empty() + [TestTag("ITerminal")] + [TestTag("Input")] + public class TerminalReadBasicTests { - // Arrange - using TestConsole console = new(); + [ModuleInitializer] + internal static void Register() => RegisterTests(); - // Act - int result = console.Read(); + public static async Task Should_return_character_from_key_queue() + { + // Arrange + using TestTerminal terminal = new(); + terminal.QueueKeys("H"); - // Assert - result.ShouldBe(-1); + // Act + int result = terminal.Read(); - await Task.CompletedTask; - } + // Assert + result.ShouldBe('H'); - public static async Task Should_return_characters_in_fifo_order() - { - // Arrange - using TestConsole console = new(); - console.QueueCharacters("ABC"); + await Task.CompletedTask; + } - // Act & Assert - console.Read().ShouldBe((int)'A'); - console.Read().ShouldBe((int)'B'); - console.Read().ShouldBe((int)'C'); - console.Read().ShouldBe(-1); + public static async Task Should_return_negative_one_when_key_queue_empty() + { + // Arrange + using TestTerminal terminal = new(); - await Task.CompletedTask; - } + // Act + int result = terminal.Read(); - public static async Task Should_throw_notsupported_for_readkey_in_testconsole() - { - // Arrange - using TestConsole console = new(); + // Assert + result.ShouldBe(-1); - // Act & Assert - NotSupportedException exception = Should.Throw(() => console.ReadKey()); - exception.Message.ShouldContain("TestConsole does not support key-by-key input"); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_return_characters_from_queued_keys_in_order() + { + // Arrange + using TestTerminal terminal = new(); + terminal.QueueKeys("abc"); - public static async Task Should_access_read_via_iconsole_interface() - { - // Arrange - using TestConsole console = new(); - IConsole iconsole = console; - console.QueueCharacters("X"); + // Act & Assert + terminal.Read().ShouldBe('a'); + terminal.Read().ShouldBe('b'); + terminal.Read().ShouldBe('c'); + terminal.Read().ShouldBe(-1); - // Act - int result = iconsole.Read(); + await Task.CompletedTask; + } - // Assert - result.ShouldBe((int)'X'); + public static async Task Should_read_key_without_intercept_parameter() + { + // Arrange + using TestTerminal terminal = new(); + terminal.QueueKey(ConsoleKey.Enter); - await Task.CompletedTask; - } -} + // Act + ConsoleKeyInfo keyInfo = terminal.ReadKey(); -[TestTag("ITerminal")] -[TestTag("Input")] -public class TerminalReadBasicTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); + // Assert + keyInfo.Key.ShouldBe(ConsoleKey.Enter); - public static async Task Should_return_character_from_key_queue() - { - // Arrange - using TestTerminal terminal = new(); - terminal.QueueKeys("H"); + await Task.CompletedTask; + } - // Act - int result = terminal.Read(); + public static async Task Should_read_key_with_intercept_true() + { + // Arrange + using TestTerminal terminal = new(); + terminal.QueueKey(ConsoleKey.Tab); - // Assert - result.ShouldBe((int)'H'); + // Act + ConsoleKeyInfo keyInfo = terminal.ReadKey(true); - await Task.CompletedTask; - } + // Assert + keyInfo.Key.ShouldBe(ConsoleKey.Tab); - public static async Task Should_return_negative_one_when_key_queue_empty() - { - // Arrange - using TestTerminal terminal = new(); + await Task.CompletedTask; + } - // Act - int result = terminal.Read(); + public static async Task Should_read_key_with_intercept_false() + { + // Arrange + using TestTerminal terminal = new(); + terminal.QueueKey(ConsoleKey.Escape); - // Assert - result.ShouldBe(-1); - - await Task.CompletedTask; - } - - public static async Task Should_return_characters_from_queued_keys_in_order() - { - // Arrange - using TestTerminal terminal = new(); - terminal.QueueKeys("abc"); - - // Act & Assert - terminal.Read().ShouldBe((int)'a'); - terminal.Read().ShouldBe((int)'b'); - terminal.Read().ShouldBe((int)'c'); - terminal.Read().ShouldBe(-1); - - await Task.CompletedTask; - } - - public static async Task Should_read_key_without_intercept_parameter() - { - // Arrange - using TestTerminal terminal = new(); - terminal.QueueKey(ConsoleKey.Enter); - - // Act - ConsoleKeyInfo keyInfo = terminal.ReadKey(); - - // Assert - keyInfo.Key.ShouldBe(ConsoleKey.Enter); - - await Task.CompletedTask; - } - - public static async Task Should_read_key_with_intercept_true() - { - // Arrange - using TestTerminal terminal = new(); - terminal.QueueKey(ConsoleKey.Tab); - - // Act - ConsoleKeyInfo keyInfo = terminal.ReadKey(true); - - // Assert - keyInfo.Key.ShouldBe(ConsoleKey.Tab); - - await Task.CompletedTask; - } - - public static async Task Should_read_key_with_intercept_false() - { - // Arrange - using TestTerminal terminal = new(); - terminal.QueueKey(ConsoleKey.Escape); - - // Act - ConsoleKeyInfo keyInfo = terminal.ReadKey(false); - - // Assert - keyInfo.Key.ShouldBe(ConsoleKey.Escape); - - await Task.CompletedTask; - } - - public static async Task Should_access_read_via_iconsole_interface() - { - // Arrange - using TestTerminal terminal = new(); - IConsole iconsole = terminal; - terminal.QueueKeys("Z"); - - // Act - int result = iconsole.Read(); - - // Assert - result.ShouldBe((int)'Z'); - - await Task.CompletedTask; - } - - public static async Task Should_access_readkey_via_iconsole_interface() - { - // Arrange - using TestTerminal terminal = new(); - IConsole iconsole = terminal; - terminal.QueueKey(ConsoleKey.Spacebar); - - // Act - ConsoleKeyInfo keyInfo = iconsole.ReadKey(); - - // Assert - keyInfo.Key.ShouldBe(ConsoleKey.Spacebar); - - await Task.CompletedTask; - } - - public static async Task Should_dequeue_key_on_read() - { - // Arrange - using TestTerminal terminal = new(); - terminal.QueueKey(ConsoleKey.A); - terminal.QueueKey(ConsoleKey.B); - - // Act - ConsoleKeyInfo first = terminal.ReadKey(); - ConsoleKeyInfo second = terminal.ReadKey(); - - // Assert - first.Key.ShouldBe(ConsoleKey.A); - second.Key.ShouldBe(ConsoleKey.B); - - await Task.CompletedTask; - } - - public static async Task Should_return_key_char_from_read() - { - // Arrange - using TestTerminal terminal = new(); - terminal.QueueKeys("X"); + // Act + ConsoleKeyInfo keyInfo = terminal.ReadKey(false); - // Act - int result = terminal.Read(); + // Assert + keyInfo.Key.ShouldBe(ConsoleKey.Escape); + + await Task.CompletedTask; + } + + public static async Task Should_access_read_via_iconsole_interface() + { + // Arrange + using TestTerminal terminal = new(); + IConsole iconsole = terminal; + terminal.QueueKeys("Z"); - // Assert - result.ShouldBe((int)'X'); + // Act + int result = iconsole.Read(); + + // Assert + result.ShouldBe('Z'); + + await Task.CompletedTask; + } - await Task.CompletedTask; + public static async Task Should_access_readkey_via_iterminal_interface() + { + // Arrange + using TestTerminal terminal = new(); + ITerminal iterminal = terminal; + terminal.QueueKey(ConsoleKey.Spacebar); + + // Act + ConsoleKeyInfo keyInfo = iterminal.ReadKey(); + + // Assert + keyInfo.Key.ShouldBe(ConsoleKey.Spacebar); + + await Task.CompletedTask; + } + + public static async Task Should_dequeue_key_on_read() + { + // Arrange + using TestTerminal terminal = new(); + terminal.QueueKey(ConsoleKey.A); + terminal.QueueKey(ConsoleKey.B); + + // Act + ConsoleKeyInfo first = terminal.ReadKey(); + ConsoleKeyInfo second = terminal.ReadKey(); + + // Assert + first.Key.ShouldBe(ConsoleKey.A); + second.Key.ShouldBe(ConsoleKey.B); + + await Task.CompletedTask; + } + + public static async Task Should_return_key_char_from_read() + { + // Arrange + using TestTerminal terminal = new(); + terminal.QueueKeys("X"); + + // Act + int result = terminal.Read(); + + // Assert + result.ShouldBe('X'); + + await Task.CompletedTask; + } + + public static async Task Should_read_constructor_input_when_key_queue_empty() + { + // Arrange + using TestTerminal terminal = new("abc"); + + // Act & Assert + terminal.Read().ShouldBe('a'); + terminal.Read().ShouldBe('b'); + terminal.Read().ShouldBe('c'); + terminal.Read().ShouldBe(-1); + + await Task.CompletedTask; + } + + public static async Task Should_interleave_read_with_readline_on_constructor_input() + { + // Arrange + using TestTerminal terminal = new("abc\ndef"); + + // Act & Assert + terminal.Read().ShouldBe('a'); + terminal.ReadLine().ShouldBe("bc"); + terminal.ReadLine().ShouldBe("def"); + terminal.Read().ShouldBe(-1); + + await Task.CompletedTask; + } + + public static async Task Should_prefer_key_queue_over_constructor_input_on_read() + { + // Arrange + using TestTerminal terminal = new("y"); + terminal.QueueKeys("x"); + + // Act & Assert + terminal.Read().ShouldBe('x'); + terminal.Read().ShouldBe('y'); + terminal.Read().ShouldBe(-1); + + await Task.CompletedTask; + } + + public static async Task Should_produce_uppercase_key_char_when_queue_key_shift() + { + // Arrange + using TestTerminal terminal = new(); + terminal.QueueKey(ConsoleKey.A, shift: true); + + // Act + ConsoleKeyInfo keyInfo = terminal.ReadKey(); + + // Assert + keyInfo.Key.ShouldBe(ConsoleKey.A); + keyInfo.KeyChar.ShouldBe('A'); + keyInfo.Modifiers.ShouldBe(ConsoleModifiers.Shift); + + await Task.CompletedTask; + } + + public static async Task Should_produce_control_char_when_queue_key_ctrl() + { + // Arrange + using TestTerminal terminal = new(); + terminal.QueueKey(ConsoleKey.C, ctrl: true); + + // Act + ConsoleKeyInfo keyInfo = terminal.ReadKey(); + + // Assert + keyInfo.Key.ShouldBe(ConsoleKey.C); + keyInfo.KeyChar.ShouldBe('\u0003'); + keyInfo.Modifiers.ShouldBe(ConsoleModifiers.Control); + + await Task.CompletedTask; + } + + public static async Task Should_set_shift_flag_when_queue_keys_uppercase() + { + // Arrange + using TestTerminal terminal = new(); + terminal.QueueKeys("Ab"); + + // Act + ConsoleKeyInfo upper = terminal.ReadKey(); + ConsoleKeyInfo lower = terminal.ReadKey(); + + // Assert + upper.Key.ShouldBe(ConsoleKey.A); + upper.KeyChar.ShouldBe('A'); + upper.Modifiers.ShouldBe(ConsoleModifiers.Shift); + lower.Key.ShouldBe(ConsoleKey.B); + lower.KeyChar.ShouldBe('b'); + lower.Modifiers.ShouldBe((ConsoleModifiers)0); + + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.RichInput diff --git a/tests/rule-widget-01-basic.cs b/tests/rule-widget-01-basic.cs index 276da2c..f759a9a 100755 --- a/tests/rule-widget-01-basic.cs +++ b/tests/rule-widget-01-basic.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test Rule widget basic functionality @@ -10,134 +10,153 @@ namespace TimeWarp.Terminal.Tests.Core.RuleWidget { -[TestTag("Widgets")] -public class RuleWidgetBasicTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_render_simple_rule_at_specified_width() + [TestTag("Widgets")] + public class RuleWidgetBasicTests { - // Arrange - Rule rule = new RuleBuilder().Build(); + [ModuleInitializer] + internal static void Register() => RegisterTests(); - // Act - string rendered = rule.Render(40); + public static async Task Should_render_simple_rule_at_specified_width() + { + // Arrange + Rule rule = new RuleBuilder().Build(); - // Assert - rendered.Length.ShouldBe(40); - rendered.ShouldBe(new string('─', 40)); + // Act + string rendered = rule.Render(40); - await Task.CompletedTask; - } + // Assert + rendered.Length.ShouldBe(40); + rendered.ShouldBe(new string('─', 40)); - public static async Task Should_render_rule_with_centered_title() - { - // Arrange - Rule rule = new RuleBuilder().Title("Test").Build(); + await Task.CompletedTask; + } - // Act - string rendered = rule.Render(40); + public static async Task Should_render_rule_with_centered_title() + { + // Arrange + Rule rule = new RuleBuilder().Title("Test").Build(); - // Assert - rendered.ShouldContain("Test"); - rendered.ShouldContain("─"); + // Act + string rendered = rule.Render(40); - // Title should be roughly centered - int testIndex = rendered.IndexOf("Test", StringComparison.Ordinal); - testIndex.ShouldBeGreaterThan(10); - testIndex.ShouldBeLessThan(25); + // Assert + rendered.ShouldContain("Test"); + rendered.ShouldContain("─"); - await Task.CompletedTask; - } + // Title should be roughly centered + int testIndex = rendered.IndexOf("Test", StringComparison.Ordinal); + testIndex.ShouldBeGreaterThan(10); + testIndex.ShouldBeLessThan(25); - public static async Task Should_render_rule_with_doubled_style() - { - // Arrange - Rule rule = new RuleBuilder().Style(LineStyle.Doubled).Build(); + await Task.CompletedTask; + } - // Act - string rendered = rule.Render(20); + public static async Task Should_render_rule_with_doubled_style() + { + // Arrange + Rule rule = new RuleBuilder().Style(LineStyle.Doubled).Build(); - // Assert - rendered.ShouldBe(new string('═', 20)); + // Act + string rendered = rule.Render(20); - await Task.CompletedTask; - } + // Assert + rendered.ShouldBe(new string('═', 20)); - public static async Task Should_render_rule_with_heavy_style() - { - // Arrange - Rule rule = new RuleBuilder().Style(LineStyle.Heavy).Build(); + await Task.CompletedTask; + } - // Act - string rendered = rule.Render(20); + public static async Task Should_render_rule_with_heavy_style() + { + // Arrange + Rule rule = new RuleBuilder().Style(LineStyle.Heavy).Build(); - // Assert - rendered.ShouldBe(new string('━', 20)); + // Act + string rendered = rule.Render(20); - await Task.CompletedTask; - } + // Assert + rendered.ShouldBe(new string('━', 20)); - public static async Task Should_render_rule_with_color() - { - // Arrange - Rule rule = new RuleBuilder().Color(AnsiColors.Cyan).Build(); + await Task.CompletedTask; + } - // Act - string rendered = rule.Render(20); + public static async Task Should_render_rule_with_color() + { + // Arrange + Rule rule = new RuleBuilder().Color(AnsiColors.Cyan).Build(); - // Assert - rendered.ShouldContain(AnsiColors.Cyan); - rendered.ShouldContain(AnsiColors.Reset); + // Act + string rendered = rule.Render(20); - await Task.CompletedTask; - } + // Assert + rendered.ShouldContain(AnsiColors.Cyan); + rendered.ShouldContain(AnsiColors.Reset); - public static async Task Should_render_rule_with_fixed_width() - { - // Arrange - Rule rule = new RuleBuilder().Width(30).Build(); + await Task.CompletedTask; + } - // Act - string rendered = rule.Render(80); // Terminal width should be ignored + public static async Task Should_render_rule_with_fixed_width() + { + // Arrange + Rule rule = new RuleBuilder().Width(30).Build(); - // Assert - rendered.Length.ShouldBe(30); + // Act + string rendered = rule.Render(80); // Terminal width should be ignored - await Task.CompletedTask; - } + // Assert + rendered.Length.ShouldBe(30); - public static async Task Should_handle_title_longer_than_width() - { - // Arrange - Rule rule = new RuleBuilder().Title("This is a very long title").Build(); + await Task.CompletedTask; + } - // Act - string rendered = rule.Render(20); + public static async Task Should_handle_title_longer_than_width() + { + // Arrange + Rule rule = new RuleBuilder().Title("This is a very long title").Build(); - // Assert - Should just show the title if not enough space - rendered.ShouldContain("This is a very long title"); + // Act + string rendered = rule.Render(20); - await Task.CompletedTask; - } + // Assert - Should just show the title if not enough space + rendered.ShouldContain("This is a very long title"); - public static async Task Should_preserve_title_ansi_codes_when_colored() - { - // Arrange - string styledTitle = "Test".Cyan(); - Rule rule = new RuleBuilder().Title(styledTitle).Color(AnsiColors.Yellow).Build(); + await Task.CompletedTask; + } + + // Regression: the colored path used to recompute negative line lengths and throw + // ArgumentOutOfRangeException instead of falling back to showing just the title + public static async Task Should_handle_colored_title_longer_than_width() + { + // Arrange + Rule rule = new RuleBuilder() + .Title("This is a very long title") + .Color(AnsiColors.Yellow) + .Build(); + + // Act + string rendered = rule.Render(20); + + // Assert - Should just show the title if not enough space + rendered.ShouldContain("This is a very long title"); + + await Task.CompletedTask; + } + + public static async Task Should_preserve_title_ansi_codes_when_colored() + { + // Arrange + string styledTitle = "Test".Cyan(); + Rule rule = new RuleBuilder().Title(styledTitle).Color(AnsiColors.Yellow).Build(); - // Act - string rendered = rule.Render(40); + // Act + string rendered = rule.Render(40); - // Assert - Title should keep its color - rendered.ShouldContain(AnsiColors.Cyan); - // Line color should be applied - rendered.ShouldContain(AnsiColors.Yellow); + // Assert - Title should keep its color + rendered.ShouldContain(AnsiColors.Cyan); + // Line color should be applied + rendered.ShouldContain(AnsiColors.Yellow); - await Task.CompletedTask; + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.RuleWidget diff --git a/tests/rule-widget-02-terminal-extensions.cs b/tests/rule-widget-02-terminal-extensions.cs index c38a037..790b169 100755 --- a/tests/rule-widget-02-terminal-extensions.cs +++ b/tests/rule-widget-02-terminal-extensions.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test Rule widget terminal extension methods @@ -10,153 +10,153 @@ namespace TimeWarp.Terminal.Tests.Core.RuleWidgetTerminal { -[TestTag("Widgets")] -public class RuleTerminalExtensionTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_write_simple_rule_to_terminal() + [TestTag("Widgets")] + public class RuleTerminalExtensionTests { - // Arrange - using TestTerminal terminal = new() { WindowWidth = 40 }; + [ModuleInitializer] + internal static void Register() => RegisterTests(); - // Act - terminal.WriteRule(); + public static async Task Should_write_simple_rule_to_terminal() + { + // Arrange + using TestTerminal terminal = new() { WindowWidth = 40 }; + + // Act + terminal.WriteRule(); - // Assert - terminal.Output.ShouldContain(new string('─', 40)); + // Assert + terminal.Output.ShouldContain(new string('─', 40)); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_write_rule_with_title() - { - // Arrange - using TestTerminal terminal = new() { WindowWidth = 40 }; + public static async Task Should_write_rule_with_title() + { + // Arrange + using TestTerminal terminal = new() { WindowWidth = 40 }; - // Act - terminal.WriteRule("Section"); + // Act + terminal.WriteRule("Section"); - // Assert - terminal.Output.ShouldContain("Section"); - terminal.Output.ShouldContain("─"); + // Assert + terminal.Output.ShouldContain("Section"); + terminal.Output.ShouldContain("─"); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_write_rule_with_styled_title() - { - // Arrange - using TestTerminal terminal = new() { WindowWidth = 40 }; + public static async Task Should_write_rule_with_styled_title() + { + // Arrange + using TestTerminal terminal = new() { WindowWidth = 40 }; - // Act - terminal.WriteRule("Results".Cyan().Bold()); + // Act + terminal.WriteRule("Results".Cyan().Bold()); - // Assert - terminal.Output.ShouldContain("Results"); - terminal.Output.ShouldContain(AnsiColors.Cyan); + // Assert + terminal.Output.ShouldContain("Results"); + terminal.Output.ShouldContain(AnsiColors.Cyan); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_write_rule_with_style() - { - // Arrange - using TestTerminal terminal = new() { WindowWidth = 40 }; + public static async Task Should_write_rule_with_style() + { + // Arrange + using TestTerminal terminal = new() { WindowWidth = 40 }; - // Act - terminal.WriteRule(LineStyle.Doubled); + // Act + terminal.WriteRule(LineStyle.Doubled); - // Assert - terminal.Output.ShouldContain(new string('═', 40)); + // Assert + terminal.Output.ShouldContain(new string('═', 40)); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_write_rule_with_fluent_builder() - { - // Arrange - using TestTerminal terminal = new() { WindowWidth = 40 }; + public static async Task Should_write_rule_with_fluent_builder() + { + // Arrange + using TestTerminal terminal = new() { WindowWidth = 40 }; - // Act - terminal.WriteRule(rule => rule - .Title("Configuration") - .Style(LineStyle.Doubled) - .Color(AnsiColors.Cyan)); + // Act + terminal.WriteRule(rule => rule + .Title("Configuration") + .Style(LineStyle.Doubled) + .Color(AnsiColors.Cyan)); - // Assert - terminal.Output.ShouldContain("Configuration"); - terminal.Output.ShouldContain("═"); - terminal.Output.ShouldContain(AnsiColors.Cyan); + // Assert + terminal.Output.ShouldContain("Configuration"); + terminal.Output.ShouldContain("═"); + terminal.Output.ShouldContain(AnsiColors.Cyan); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_write_preconfigured_rule() - { - // Arrange - using TestTerminal terminal = new() { WindowWidth = 40 }; - Rule rule = new RuleBuilder() - .Title("Test") - .Style(LineStyle.Heavy) - .Color(AnsiColors.Yellow) - .Build(); - - // Act - terminal.WriteRule(rule); - - // Assert - terminal.Output.ShouldContain("Test"); - terminal.Output.ShouldContain("━"); - terminal.Output.ShouldContain(AnsiColors.Yellow); - - await Task.CompletedTask; - } + public static async Task Should_write_preconfigured_rule() + { + // Arrange + using TestTerminal terminal = new() { WindowWidth = 40 }; + Rule rule = new RuleBuilder() + .Title("Test") + .Style(LineStyle.Heavy) + .Color(AnsiColors.Yellow) + .Build(); - public static async Task Should_use_terminal_width() - { - // Arrange - using TestTerminal terminal = new() { WindowWidth = 60 }; + // Act + terminal.WriteRule(rule); - // Act - terminal.WriteRule(); + // Assert + terminal.Output.ShouldContain("Test"); + terminal.Output.ShouldContain("━"); + terminal.Output.ShouldContain(AnsiColors.Yellow); - // Assert - string output = terminal.Output.TrimEnd(); - output.ShouldBe(new string('─', 60)); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_use_terminal_width() + { + // Arrange + using TestTerminal terminal = new() { WindowWidth = 60 }; - public static async Task Should_handle_narrow_terminal() - { - // Arrange - using TestTerminal terminal = new() { WindowWidth = 20 }; + // Act + terminal.WriteRule(); - // Act - terminal.WriteRule("Short"); + // Assert + string output = terminal.Output.TrimEnd(); + output.ShouldBe(new string('─', 60)); - // Assert - terminal.Output.ShouldContain("Short"); - terminal.Output.ShouldContain("─"); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_handle_narrow_terminal() + { + // Arrange + using TestTerminal terminal = new() { WindowWidth = 20 }; - public static async Task Should_include_newline_after_rule() - { - // Arrange - using TestTerminal terminal = new() { WindowWidth = 40 }; + // Act + terminal.WriteRule("Short"); + + // Assert + terminal.Output.ShouldContain("Short"); + terminal.Output.ShouldContain("─"); + + await Task.CompletedTask; + } + + public static async Task Should_include_newline_after_rule() + { + // Arrange + using TestTerminal terminal = new() { WindowWidth = 40 }; - // Act - terminal.WriteRule(); + // Act + terminal.WriteRule(); - // Assert - terminal.Output.ShouldEndWith(Environment.NewLine); + // Assert + terminal.Output.ShouldEndWith(Environment.NewLine); - await Task.CompletedTask; + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.RuleWidgetTerminal diff --git a/tests/stream-access-01-basic.cs b/tests/stream-access-01-basic.cs index 6223cb0..84441fe 100755 --- a/tests/stream-access-01-basic.cs +++ b/tests/stream-access-01-basic.cs @@ -1,8 +1,7 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test IConsole stream access APIs -using System.IO; #if !JARIBU_MULTI return await RunAllTests(); @@ -11,431 +10,448 @@ namespace TimeWarp.Terminal.Tests.Core.StreamAccess { -[TestTag("IConsole")] -[TestTag("Stream")] -public class ConsoleStreamAccessTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_return_stream_from_open_standard_input_in_test_console() + [TestTag("IConsole")] + [TestTag("Stream")] + public class ConsoleStreamAccessTests { - // Arrange - using TestConsole console = new(); + [ModuleInitializer] + internal static void Register() => RegisterTests(); - // Act - Stream stream = console.OpenStandardInput(); + public static async Task Should_return_stream_from_open_standard_input_in_test_console() + { + // Arrange + using TestConsole console = new(); - // Assert - stream.ShouldNotBeNull(); - stream.ShouldBeSameAs(console.StandardInputStream); + // Act + Stream stream = console.OpenStandardInput(); - await Task.CompletedTask; - } + // Assert + stream.ShouldNotBeNull(); + stream.ShouldBeSameAs(console.StandardInputStream); - public static async Task Should_return_stream_from_open_standard_output_in_test_console() - { - // Arrange - using TestConsole console = new(); + await Task.CompletedTask; + } - // Act - Stream stream = console.OpenStandardOutput(); + public static async Task Should_return_stream_from_open_standard_output_in_test_console() + { + // Arrange + using TestConsole console = new(); - // Assert - stream.ShouldNotBeNull(); - stream.ShouldBeSameAs(console.StandardOutputStream); + // Act + Stream stream = console.OpenStandardOutput(); - await Task.CompletedTask; - } + // Assert + stream.ShouldNotBeNull(); + stream.ShouldBeSameAs(console.StandardOutputStream); - public static async Task Should_return_stream_from_open_standard_error_in_test_console() - { - // Arrange - using TestConsole console = new(); + await Task.CompletedTask; + } - // Act - Stream stream = console.OpenStandardError(); + public static async Task Should_return_stream_from_open_standard_error_in_test_console() + { + // Arrange + using TestConsole console = new(); - // Assert - stream.ShouldNotBeNull(); - stream.ShouldBeSameAs(console.StandardErrorStream); + // Act + Stream stream = console.OpenStandardError(); - await Task.CompletedTask; - } + // Assert + stream.ShouldNotBeNull(); + stream.ShouldBeSameAs(console.StandardErrorStream); - public static async Task Should_return_text_reader_from_in_property_in_test_console() - { - // Arrange - using TestConsole console = new(); + await Task.CompletedTask; + } - // Act - TextReader reader = console.In; + public static async Task Should_return_text_reader_from_in_property_in_test_console() + { + // Arrange + using TestConsole console = new(); - // Assert - reader.ShouldNotBeNull(); + // Act + TextReader reader = console.In; - await Task.CompletedTask; - } + // Assert + reader.ShouldNotBeNull(); - public static async Task Should_return_text_writer_from_out_property_in_test_console() - { - // Arrange - using TestConsole console = new(); + await Task.CompletedTask; + } - // Act - TextWriter writer = console.Out; + public static async Task Should_return_text_writer_from_out_property_in_test_console() + { + // Arrange + using TestConsole console = new(); - // Assert - writer.ShouldNotBeNull(); + // Act + TextWriter writer = console.Out; - await Task.CompletedTask; - } + // Assert + writer.ShouldNotBeNull(); - public static async Task Should_return_text_writer_from_error_property_in_test_console() - { - // Arrange - using TestConsole console = new(); + await Task.CompletedTask; + } - // Act - TextWriter writer = console.Error; + public static async Task Should_return_text_writer_from_error_property_in_test_console() + { + // Arrange + using TestConsole console = new(); - // Assert - writer.ShouldNotBeNull(); + // Act + TextWriter writer = console.Error; - await Task.CompletedTask; - } + // Assert + writer.ShouldNotBeNull(); - public static async Task Should_set_in_reader_via_set_in_in_test_console() - { - // Arrange - using TestConsole console = new(); - StringReader newReader = new("test input"); + await Task.CompletedTask; + } - // Act - console.SetIn(newReader); + public static async Task Should_set_in_reader_via_set_in_in_test_console() + { + // Arrange + using TestConsole console = new(); + StringReader newReader = new("test input"); - // Assert - console.In.ShouldBeSameAs(newReader); + // Act + console.SetIn(newReader); - await Task.CompletedTask; - } + // Assert + console.In.ShouldBeSameAs(newReader); - public static async Task Should_set_out_writer_via_set_out_in_test_console() - { - // Arrange - using TestConsole console = new(); - StringWriter newWriter = new(); + await Task.CompletedTask; + } - // Act - console.SetOut(newWriter); + public static async Task Should_set_out_writer_via_set_out_in_test_console() + { + // Arrange + using TestConsole console = new(); + StringWriter newWriter = new(); - // Assert - console.Out.ShouldBeSameAs(newWriter); + // Act + console.SetOut(newWriter); - await Task.CompletedTask; - } + // Assert + console.Out.ShouldBeSameAs(newWriter); - public static async Task Should_set_error_writer_via_set_error_in_test_console() - { - // Arrange - using TestConsole console = new(); - StringWriter newWriter = new(); + await Task.CompletedTask; + } - // Act - console.SetError(newWriter); + public static async Task Should_set_error_writer_via_set_error_in_test_console() + { + // Arrange + using TestConsole console = new(); + StringWriter newWriter = new(); - // Assert - console.Error.ShouldBeSameAs(newWriter); + // Act + console.SetError(newWriter); - await Task.CompletedTask; - } + // Assert + console.Error.ShouldBeSameAs(newWriter); - public static async Task Should_allow_custom_standard_input_stream_in_test_console() - { - // Arrange - using TestConsole console = new(); - MemoryStream customStream = new(); + await Task.CompletedTask; + } - // Act - console.StandardInputStream = customStream; - Stream stream = console.OpenStandardInput(); + public static async Task Should_allow_custom_standard_input_stream_in_test_console() + { + // Arrange + using TestConsole console = new(); + MemoryStream customStream = new(); - // Assert - stream.ShouldBeSameAs(customStream); + // Act + console.StandardInputStream = customStream; + Stream stream = console.OpenStandardInput(); - await Task.CompletedTask; - } + // Assert + stream.ShouldBeSameAs(customStream); - public static async Task Should_allow_custom_standard_output_stream_in_test_console() - { - // Arrange - using TestConsole console = new(); - MemoryStream customStream = new(); + await Task.CompletedTask; + } + + public static async Task Should_allow_custom_standard_output_stream_in_test_console() + { + // Arrange + using TestConsole console = new(); + MemoryStream customStream = new(); - // Act - console.StandardOutputStream = customStream; - Stream stream = console.OpenStandardOutput(); + // Act + console.StandardOutputStream = customStream; + Stream stream = console.OpenStandardOutput(); - // Assert - stream.ShouldBeSameAs(customStream); + // Assert + stream.ShouldBeSameAs(customStream); - await Task.CompletedTask; + await Task.CompletedTask; + } + + public static async Task Should_allow_custom_standard_error_stream_in_test_console() + { + // Arrange + using TestConsole console = new(); + MemoryStream customStream = new(); + + // Act + console.StandardErrorStream = customStream; + Stream stream = console.OpenStandardError(); + + // Assert + stream.ShouldBeSameAs(customStream); + + await Task.CompletedTask; + } } - public static async Task Should_allow_custom_standard_error_stream_in_test_console() + [TestTag("ITerminal")] + [TestTag("Stream")] + public class TerminalStreamAccessTests { - // Arrange - using TestConsole console = new(); - MemoryStream customStream = new(); + [ModuleInitializer] + internal static void Register() => RegisterTests(); - // Act - console.StandardErrorStream = customStream; - Stream stream = console.OpenStandardError(); + public static async Task Should_return_stream_from_open_standard_input_in_test_terminal() + { + // Arrange + using TestTerminal terminal = new(); - // Assert - stream.ShouldBeSameAs(customStream); + // Act + Stream stream = terminal.OpenStandardInput(); - await Task.CompletedTask; - } -} + // Assert + stream.ShouldNotBeNull(); + stream.ShouldBeSameAs(terminal.StandardInputStream); -[TestTag("ITerminal")] -[TestTag("Stream")] -public class TerminalStreamAccessTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); + await Task.CompletedTask; + } - public static async Task Should_return_stream_from_open_standard_input_in_test_terminal() - { - // Arrange - using TestTerminal terminal = new(); + public static async Task Should_return_stream_from_open_standard_output_in_test_terminal() + { + // Arrange + using TestTerminal terminal = new(); - // Act - Stream stream = terminal.OpenStandardInput(); + // Act + Stream stream = terminal.OpenStandardOutput(); - // Assert - stream.ShouldNotBeNull(); - stream.ShouldBeSameAs(terminal.StandardInputStream); + // Assert + stream.ShouldNotBeNull(); + stream.ShouldBeSameAs(terminal.StandardOutputStream); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_return_stream_from_open_standard_output_in_test_terminal() - { - // Arrange - using TestTerminal terminal = new(); + public static async Task Should_return_stream_from_open_standard_error_in_test_terminal() + { + // Arrange + using TestTerminal terminal = new(); - // Act - Stream stream = terminal.OpenStandardOutput(); + // Act + Stream stream = terminal.OpenStandardError(); - // Assert - stream.ShouldNotBeNull(); - stream.ShouldBeSameAs(terminal.StandardOutputStream); + // Assert + stream.ShouldNotBeNull(); + stream.ShouldBeSameAs(terminal.StandardErrorStream); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_return_stream_from_open_standard_error_in_test_terminal() - { - // Arrange - using TestTerminal terminal = new(); + public static async Task Should_return_text_reader_from_in_property_in_test_terminal() + { + // Arrange + using TestTerminal terminal = new(); - // Act - Stream stream = terminal.OpenStandardError(); + // Act + TextReader reader = terminal.In; - // Assert - stream.ShouldNotBeNull(); - stream.ShouldBeSameAs(terminal.StandardErrorStream); + // Assert + reader.ShouldNotBeNull(); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_return_text_reader_from_in_property_in_test_terminal() - { - // Arrange - using TestTerminal terminal = new(); + public static async Task Should_return_text_writer_from_out_property_in_test_terminal() + { + // Arrange + using TestTerminal terminal = new(); - // Act - TextReader reader = terminal.In; + // Act + TextWriter writer = terminal.Out; - // Assert - reader.ShouldNotBeNull(); + // Assert + writer.ShouldNotBeNull(); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_return_text_writer_from_out_property_in_test_terminal() - { - // Arrange - using TestTerminal terminal = new(); + public static async Task Should_return_text_writer_from_error_property_in_test_terminal() + { + // Arrange + using TestTerminal terminal = new(); - // Act - TextWriter writer = terminal.Out; + // Act + TextWriter writer = terminal.Error; - // Assert - writer.ShouldNotBeNull(); + // Assert + writer.ShouldNotBeNull(); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_return_text_writer_from_error_property_in_test_terminal() - { - // Arrange - using TestTerminal terminal = new(); + public static async Task Should_set_in_reader_via_set_in_in_test_terminal() + { + // Arrange + using TestTerminal terminal = new(); + StringReader newReader = new("test input"); - // Act - TextWriter writer = terminal.Error; + // Act + terminal.SetIn(newReader); - // Assert - writer.ShouldNotBeNull(); + // Assert + terminal.In.ShouldBeSameAs(newReader); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_set_in_reader_via_set_in_in_test_terminal() - { - // Arrange - using TestTerminal terminal = new(); - StringReader newReader = new("test input"); + public static async Task Should_set_out_writer_via_set_out_in_test_terminal() + { + // Arrange + using TestTerminal terminal = new(); + StringWriter newWriter = new(); - // Act - terminal.SetIn(newReader); + // Act + terminal.SetOut(newWriter); - // Assert - terminal.In.ShouldBeSameAs(newReader); + // Assert + terminal.Out.ShouldBeSameAs(newWriter); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_set_out_writer_via_set_out_in_test_terminal() - { - // Arrange - using TestTerminal terminal = new(); - StringWriter newWriter = new(); + public static async Task Should_set_error_writer_via_set_error_in_test_terminal() + { + // Arrange + using TestTerminal terminal = new(); + StringWriter newWriter = new(); - // Act - terminal.SetOut(newWriter); + // Act + terminal.SetError(newWriter); - // Assert - terminal.Out.ShouldBeSameAs(newWriter); + // Assert + terminal.Error.ShouldBeSameAs(newWriter); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_set_error_writer_via_set_error_in_test_terminal() - { - // Arrange - using TestTerminal terminal = new(); - StringWriter newWriter = new(); + public static async Task Should_allow_custom_standard_input_stream_in_test_terminal() + { + // Arrange + using TestTerminal terminal = new(); + MemoryStream customStream = new(); - // Act - terminal.SetError(newWriter); + // Act + terminal.StandardInputStream = customStream; + Stream stream = terminal.OpenStandardInput(); - // Assert - terminal.Error.ShouldBeSameAs(newWriter); + // Assert + stream.ShouldBeSameAs(customStream); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_allow_custom_standard_input_stream_in_test_terminal() - { - // Arrange - using TestTerminal terminal = new(); - MemoryStream customStream = new(); + public static async Task Should_allow_custom_standard_output_stream_in_test_terminal() + { + // Arrange + using TestTerminal terminal = new(); + MemoryStream customStream = new(); - // Act - terminal.StandardInputStream = customStream; - Stream stream = terminal.OpenStandardInput(); + // Act + terminal.StandardOutputStream = customStream; + Stream stream = terminal.OpenStandardOutput(); - // Assert - stream.ShouldBeSameAs(customStream); + // Assert + stream.ShouldBeSameAs(customStream); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_allow_custom_standard_output_stream_in_test_terminal() - { - // Arrange - using TestTerminal terminal = new(); - MemoryStream customStream = new(); + public static async Task Should_allow_custom_standard_error_stream_in_test_terminal() + { + // Arrange + using TestTerminal terminal = new(); + MemoryStream customStream = new(); - // Act - terminal.StandardOutputStream = customStream; - Stream stream = terminal.OpenStandardOutput(); + // Act + terminal.StandardErrorStream = customStream; + Stream stream = terminal.OpenStandardError(); - // Assert - stream.ShouldBeSameAs(customStream); + // Assert + stream.ShouldBeSameAs(customStream); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_allow_custom_standard_error_stream_in_test_terminal() - { - // Arrange - using TestTerminal terminal = new(); - MemoryStream customStream = new(); + public static async Task Should_not_dispose_consumer_assigned_standard_output_stream_in_test_terminal() + { + // Arrange + TestTerminal terminal = new(); + using MemoryStream customStream = new(); + terminal.StandardOutputStream = customStream; - // Act - terminal.StandardErrorStream = customStream; - Stream stream = terminal.OpenStandardError(); + // Act + terminal.Dispose(); - // Assert - stream.ShouldBeSameAs(customStream); + // Assert - consumer-assigned stream must remain usable after TestTerminal.Dispose + customStream.WriteByte(0x41); + customStream.Length.ShouldBe(1L); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_access_stream_apis_via_iconsole_interface() - { - // Arrange - using TestTerminal terminal = new(); + public static async Task Should_access_stream_apis_via_iconsole_interface() + { + // Arrange + using TestTerminal terminal = new(); #pragma warning disable CA1859 // Intentionally testing IConsole interface access - IConsole console = terminal; + IConsole console = terminal; #pragma warning restore CA1859 - // Act - Stream inputStream = console.OpenStandardInput(); - Stream outputStream = console.OpenStandardOutput(); - Stream errorStream = console.OpenStandardError(); - TextReader reader = console.In; - TextWriter outWriter = console.Out; - TextWriter errorWriter = console.Error; - - // Assert - inputStream.ShouldNotBeNull(); - outputStream.ShouldNotBeNull(); - errorStream.ShouldNotBeNull(); - reader.ShouldNotBeNull(); - outWriter.ShouldNotBeNull(); - errorWriter.ShouldNotBeNull(); - - await Task.CompletedTask; - } - - public static async Task Should_set_readers_writers_via_iconsole_interface() - { - // Arrange - using TestTerminal terminal = new(); + // Act + Stream inputStream = console.OpenStandardInput(); + Stream outputStream = console.OpenStandardOutput(); + Stream errorStream = console.OpenStandardError(); + TextReader reader = console.In; + TextWriter outWriter = console.Out; + TextWriter errorWriter = console.Error; + + // Assert + inputStream.ShouldNotBeNull(); + outputStream.ShouldNotBeNull(); + errorStream.ShouldNotBeNull(); + reader.ShouldNotBeNull(); + outWriter.ShouldNotBeNull(); + errorWriter.ShouldNotBeNull(); + + await Task.CompletedTask; + } + + public static async Task Should_set_readers_writers_via_iconsole_interface() + { + // Arrange + using TestTerminal terminal = new(); #pragma warning disable CA1859 // Intentionally testing IConsole interface access - IConsole console = terminal; + IConsole console = terminal; #pragma warning restore CA1859 - StringReader newReader = new("test"); - StringWriter newOutWriter = new(); - StringWriter newErrorWriter = new(); - - // Act - console.SetIn(newReader); - console.SetOut(newOutWriter); - console.SetError(newErrorWriter); - - // Assert - console.In.ShouldBeSameAs(newReader); - console.Out.ShouldBeSameAs(newOutWriter); - console.Error.ShouldBeSameAs(newErrorWriter); - - await Task.CompletedTask; + StringReader newReader = new("test"); + StringWriter newOutWriter = new(); + StringWriter newErrorWriter = new(); + + // Act + console.SetIn(newReader); + console.SetOut(newOutWriter); + console.SetError(newErrorWriter); + + // Assert + console.In.ShouldBeSameAs(newReader); + console.Out.ShouldBeSameAs(newOutWriter); + console.Error.ShouldBeSameAs(newErrorWriter); + + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.StreamAccess diff --git a/tests/table-widget-01-basic.cs b/tests/table-widget-01-basic.cs index 8fc2e03..5361d87 100755 --- a/tests/table-widget-01-basic.cs +++ b/tests/table-widget-01-basic.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test Table widget basic functionality @@ -10,199 +10,241 @@ namespace TimeWarp.Terminal.Tests.Core.TableWidget { -[TestTag("Widgets")] -public class TableWidgetBasicTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_render_basic_table_with_two_columns_and_two_rows() - { - // Arrange - Table table = new TableBuilder() - .AddColumn("Name") - .AddColumn("Value") - .AddRow("Foo", "123") - .AddRow("Bar", "456") - .Build(); - - // Act - string[] lines = table.Render(40); - - // Assert - // Expected: - // ┌──────┬───────┐ - // │ Name │ Value │ - // ├──────┼───────┤ - // │ Foo │ 123 │ - // │ Bar │ 456 │ - // └──────┴───────┘ - lines.Length.ShouldBe(6); // top + header + separator + 2 data rows + bottom - lines[0].ShouldContain("┌"); // top-left - lines[0].ShouldContain("┬"); // top T-junction - lines[0].ShouldContain("┐"); // top-right - lines[1].ShouldContain("Name"); - lines[1].ShouldContain("Value"); - lines[2].ShouldContain("├"); // left T - lines[2].ShouldContain("┼"); // cross - lines[2].ShouldContain("┤"); // right T - lines[3].ShouldContain("Foo"); - lines[3].ShouldContain("123"); - lines[4].ShouldContain("Bar"); - lines[4].ShouldContain("456"); - lines[5].ShouldContain("└"); // bottom-left - lines[5].ShouldContain("┴"); // bottom T - lines[5].ShouldContain("┘"); // bottom-right - - await Task.CompletedTask; - } - - public static async Task Should_render_right_aligned_column() - { - // Arrange - Table table = new TableBuilder() - .AddColumn("Item") - .AddColumn("Count", Alignment.Right) - .AddRow("Apples", "5") - .AddRow("Oranges", "123") - .Build(); - - // Act - string[] lines = table.Render(40); - - // Assert - // The "Count" column should have right-aligned values - // Column width is determined by "Count" header (5 chars) - // "5" should be padded on the left to match "Count" width - lines[3].ShouldContain(" 5"); // 5 should be right-aligned (4 spaces + "5") - lines[4].ShouldContain(" 123"); // 123 right-aligned (2 spaces + "123") - - await Task.CompletedTask; - } - - public static async Task Should_render_center_aligned_column() - { - // Arrange - Table table = new TableBuilder() - .AddColumn("Status", Alignment.Center) - .AddRow("OK") - .AddRow("Error") - .Build(); - - // Act - string[] lines = table.Render(40); - - // Assert - // "Status" header and "OK" should be centered within the column - lines[1].ShouldContain("Status"); - lines[3].ShouldContain("OK"); - - await Task.CompletedTask; - } - - public static async Task Should_render_multi_column_table() - { - // Arrange - Table table = new TableBuilder() - .AddColumns("A", "B", "C", "D", "E") - .AddRow("1", "2", "3", "4", "5") - .Build(); - - // Act - string[] lines = table.Render(80); - - // Assert - lines[0].ShouldContain("┬"); // Should have T-junctions for all column separators - lines[1].ShouldContain("A"); - lines[1].ShouldContain("E"); - lines[3].ShouldContain("1"); - lines[3].ShouldContain("5"); - - await Task.CompletedTask; - } - - public static async Task Should_render_headerless_table() - { - // Arrange - Table table = new TableBuilder() - .AddColumn("Col1") - .AddColumn("Col2") - .AddRow("A", "B") - .AddRow("C", "D") - .HideHeaders() - .Build(); - - // Act - string[] lines = table.Render(40); - - // Assert - // No header row or separator, just top + data rows + bottom - lines.Length.ShouldBe(4); // top + 2 data rows + bottom - lines[0].ShouldContain("┌"); // top border - lines[1].ShouldContain("A"); // first data row - lines[1].ShouldContain("B"); - lines[2].ShouldContain("C"); // second data row - lines[3].ShouldContain("└"); // bottom border - - await Task.CompletedTask; - } - - public static async Task Should_render_row_separators() - { - // Arrange - Table table = new TableBuilder() - .AddColumn("Name") - .AddRow("Alice") - .AddRow("Bob") - .AddRow("Carol") - .ShowRowSeparators() - .Build(); - - // Act - string[] lines = table.Render(40); - - // Assert - // top + header + header-sep + data1 + sep + data2 + sep + data3 + bottom = 9 - lines.Length.ShouldBe(9); - - await Task.CompletedTask; - } - - public static async Task Should_render_empty_table_with_headers_only() + [TestTag("Widgets")] + public class TableWidgetBasicTests { - // Arrange - Table table = new TableBuilder() - .AddColumn("Name") - .AddColumn("Age") - .Build(); - // No rows added - - // Act - string[] lines = table.Render(40); - - // Assert - // top + header + header-sep + bottom = 4 - lines.Length.ShouldBe(4); - lines[1].ShouldContain("Name"); - lines[1].ShouldContain("Age"); - - await Task.CompletedTask; - } - - public static async Task Should_return_empty_array_when_no_columns() - { - // Arrange - Table table = new TableBuilder() - .Build(); - - // Act - string[] lines = table.Render(40); - - // Assert - lines.Length.ShouldBe(0); - - await Task.CompletedTask; + [ModuleInitializer] + internal static void Register() => RegisterTests(); + + public static async Task Should_render_basic_table_with_two_columns_and_two_rows() + { + // Arrange + Table table = new TableBuilder() + .AddColumn("Name") + .AddColumn("Value") + .AddRow("Foo", "123") + .AddRow("Bar", "456") + .Build(); + + // Act + string[] lines = table.Render(40); + + // Assert + // Expected: + // ┌──────┬───────┐ + // │ Name │ Value │ + // ├──────┼───────┤ + // │ Foo │ 123 │ + // │ Bar │ 456 │ + // └──────┴───────┘ + lines.Length.ShouldBe(6); // top + header + separator + 2 data rows + bottom + lines[0].ShouldContain("┌"); // top-left + lines[0].ShouldContain("┬"); // top T-junction + lines[0].ShouldContain("┐"); // top-right + lines[1].ShouldContain("Name"); + lines[1].ShouldContain("Value"); + lines[2].ShouldContain("├"); // left T + lines[2].ShouldContain("┼"); // cross + lines[2].ShouldContain("┤"); // right T + lines[3].ShouldContain("Foo"); + lines[3].ShouldContain("123"); + lines[4].ShouldContain("Bar"); + lines[4].ShouldContain("456"); + lines[5].ShouldContain("└"); // bottom-left + lines[5].ShouldContain("┴"); // bottom T + lines[5].ShouldContain("┘"); // bottom-right + + await Task.CompletedTask; + } + + public static async Task Should_render_right_aligned_column() + { + // Arrange + Table table = new TableBuilder() + .AddColumn("Item") + .AddColumn("Count", Alignment.Right) + .AddRow("Apples", "5") + .AddRow("Oranges", "123") + .Build(); + + // Act + string[] lines = table.Render(40); + + // Assert + // The "Count" column should have right-aligned values + // Column width is determined by "Count" header (5 chars) + // "5" should be padded on the left to match "Count" width + lines[3].ShouldContain(" 5"); // 5 should be right-aligned (4 spaces + "5") + lines[4].ShouldContain(" 123"); // 123 right-aligned (2 spaces + "123") + + await Task.CompletedTask; + } + + public static async Task Should_render_center_aligned_column() + { + // Arrange + Table table = new TableBuilder() + .AddColumn("Status", Alignment.Center) + .AddRow("OK") + .AddRow("Error") + .Build(); + + // Act + string[] lines = table.Render(40); + + // Assert + // "Status" header and "OK" should be centered within the column + lines[1].ShouldContain("Status"); + lines[3].ShouldContain("OK"); + + await Task.CompletedTask; + } + + public static async Task Should_render_multi_column_table() + { + // Arrange + Table table = new TableBuilder() + .AddColumns("A", "B", "C", "D", "E") + .AddRow("1", "2", "3", "4", "5") + .Build(); + + // Act + string[] lines = table.Render(80); + + // Assert + lines[0].ShouldContain("┬"); // Should have T-junctions for all column separators + lines[1].ShouldContain("A"); + lines[1].ShouldContain("E"); + lines[3].ShouldContain("1"); + lines[3].ShouldContain("5"); + + await Task.CompletedTask; + } + + public static async Task Should_render_headerless_table() + { + // Arrange + Table table = new TableBuilder() + .AddColumn("Col1") + .AddColumn("Col2") + .AddRow("A", "B") + .AddRow("C", "D") + .HideHeaders() + .Build(); + + // Act + string[] lines = table.Render(40); + + // Assert + // No header row or separator, just top + data rows + bottom + lines.Length.ShouldBe(4); // top + 2 data rows + bottom + lines[0].ShouldContain("┌"); // top border + lines[1].ShouldContain("A"); // first data row + lines[1].ShouldContain("B"); + lines[2].ShouldContain("C"); // second data row + lines[3].ShouldContain("└"); // bottom border + + await Task.CompletedTask; + } + + public static async Task Should_render_row_separators() + { + // Arrange + Table table = new TableBuilder() + .AddColumn("Name") + .AddRow("Alice") + .AddRow("Bob") + .AddRow("Carol") + .ShowRowSeparators() + .Build(); + + // Act + string[] lines = table.Render(40); + + // Assert + // top + header + header-sep + data1 + sep + data2 + sep + data3 + bottom = 9 + lines.Length.ShouldBe(9); + + await Task.CompletedTask; + } + + public static async Task Should_render_empty_table_with_headers_only() + { + // Arrange + Table table = new TableBuilder() + .AddColumn("Name") + .AddColumn("Age") + .Build(); + // No rows added + + // Act + string[] lines = table.Render(40); + + // Assert + // top + header + header-sep + bottom = 4 + lines.Length.ShouldBe(4); + lines[1].ShouldContain("Name"); + lines[1].ShouldContain("Age"); + + await Task.CompletedTask; + } + + public static async Task Should_return_empty_array_when_no_columns() + { + // Arrange + Table table = new TableBuilder() + .Build(); + + // Act + string[] lines = table.Render(40); + + // Assert + lines.Length.ShouldBe(0); + + await Task.CompletedTask; + } + + public static async Task Should_throw_when_adding_null_row() + { + // Regression: AddRow stored a null cells array without validation, + // deferring the failure to Render far from the call site. + // Arrange + TableBuilder builder = new TableBuilder() + .AddColumn("Name"); + + // Act & Assert + Should.Throw(() => builder.AddRow(null!)); + + await Task.CompletedTask; + } + + public static async Task Should_build_independent_snapshots() + { + // Regression: Build() returned the live table instance, so building twice + // returned the same object and post-Build builder calls mutated it. + // Arrange + TableBuilder builder = new TableBuilder() + .AddColumn("Name") + .AddRow("First"); + + // Act + Table first = builder.Build(); + + _ = builder + .AddColumn("Extra") + .AddRow("Second", "X"); + + Table second = builder.Build(); + + // Assert - each Build() returns a distinct, independent table + ReferenceEquals(first, second).ShouldBeFalse(); + first.Columns.Count.ShouldBe(1); + first.Rows.Count.ShouldBe(1); + second.Columns.Count.ShouldBe(2); + second.Rows.Count.ShouldBe(2); + + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.TableWidget diff --git a/tests/table-widget-02-borders.cs b/tests/table-widget-02-borders.cs index b4d2843..9b56291 100755 --- a/tests/table-widget-02-borders.cs +++ b/tests/table-widget-02-borders.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test Table widget border styles @@ -10,178 +10,178 @@ namespace TimeWarp.Terminal.Tests.Core.TableWidgetBorders { -[TestTag("Widgets")] -public class TableWidgetBorderTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_render_table_with_rounded_border() - { - // Arrange - Table table = new TableBuilder() - .AddColumn("A") - .AddColumn("B") - .AddRow("1", "2") - .Border(BorderStyle.Rounded) - .Build(); - - // Act - string[] lines = table.Render(40); - - // Assert - // Structure: top(0) + header(1) + sep(2) + data(3) + bottom(4) = 5 lines - lines.Length.ShouldBe(5); - lines[0].ShouldContain("╭"); // rounded top-left - lines[0].ShouldContain("╮"); // rounded top-right - lines[0].ShouldContain("┬"); // T-junctions use square style (no rounded T exists) - lines[4].ShouldContain("╰"); // rounded bottom-left - lines[4].ShouldContain("╯"); // rounded bottom-right - - await Task.CompletedTask; - } - - public static async Task Should_render_table_with_square_border() - { - // Arrange - Table table = new TableBuilder() - .AddColumn("A") - .AddColumn("B") - .AddRow("1", "2") - .Border(BorderStyle.Square) - .Build(); - - // Act - string[] lines = table.Render(40); - - // Assert - // Structure: top(0) + header(1) + sep(2) + data(3) + bottom(4) = 5 lines - lines.Length.ShouldBe(5); - lines[0].ShouldContain("┌"); // square top-left - lines[0].ShouldContain("┐"); // square top-right - lines[0].ShouldContain("┬"); // top T - lines[2].ShouldContain("├"); // left T - lines[2].ShouldContain("┼"); // cross - lines[2].ShouldContain("┤"); // right T - lines[4].ShouldContain("└"); // square bottom-left - lines[4].ShouldContain("┘"); // square bottom-right - lines[4].ShouldContain("┴"); // bottom T - - await Task.CompletedTask; - } - - public static async Task Should_render_table_with_double_border() - { - // Arrange - Table table = new TableBuilder() - .AddColumn("A") - .AddColumn("B") - .AddRow("1", "2") - .Border(BorderStyle.Doubled) - .Build(); - - // Act - string[] lines = table.Render(40); - - // Assert - // Structure: top(0) + header(1) + sep(2) + data(3) + bottom(4) = 5 lines - lines.Length.ShouldBe(5); - lines[0].ShouldContain("╔"); // double top-left - lines[0].ShouldContain("╗"); // double top-right - lines[0].ShouldContain("═"); // double horizontal - lines[0].ShouldContain("╦"); // double top T - lines[1].ShouldContain("║"); // double vertical - lines[2].ShouldContain("╠"); // double left T - lines[2].ShouldContain("╬"); // double cross - lines[2].ShouldContain("╣"); // double right T - lines[4].ShouldContain("╚"); // double bottom-left - lines[4].ShouldContain("╝"); // double bottom-right - lines[4].ShouldContain("╩"); // double bottom T - - await Task.CompletedTask; - } - - public static async Task Should_render_table_with_heavy_border() - { - // Arrange - Table table = new TableBuilder() - .AddColumn("A") - .AddColumn("B") - .AddRow("1", "2") - .Border(BorderStyle.Heavy) - .Build(); - - // Act - string[] lines = table.Render(40); - - // Assert - // Structure: top(0) + header(1) + sep(2) + data(3) + bottom(4) = 5 lines - lines.Length.ShouldBe(5); - lines[0].ShouldContain("┏"); // heavy top-left - lines[0].ShouldContain("┓"); // heavy top-right - lines[0].ShouldContain("━"); // heavy horizontal - lines[0].ShouldContain("┳"); // heavy top T - lines[1].ShouldContain("┃"); // heavy vertical - lines[2].ShouldContain("┣"); // heavy left T - lines[2].ShouldContain("╋"); // heavy cross - lines[2].ShouldContain("┫"); // heavy right T - lines[4].ShouldContain("┗"); // heavy bottom-left - lines[4].ShouldContain("┛"); // heavy bottom-right - lines[4].ShouldContain("┻"); // heavy bottom T - - await Task.CompletedTask; - } - - public static async Task Should_render_table_without_border_when_style_is_none() - { - // Arrange - Table table = new TableBuilder() - .AddColumn("Name") - .AddColumn("Value") - .AddRow("Foo", "123") - .AddRow("Bar", "456") - .Border(BorderStyle.None) - .Build(); - - // Act - string[] lines = table.Render(40); - - // Assert - // Should be just header + 2 data rows without any box characters - lines.Length.ShouldBe(3); - lines[0].ShouldContain("Name"); - lines[0].ShouldContain("Value"); - lines[0].ShouldNotContain("│"); - lines[0].ShouldNotContain("┌"); - lines[1].ShouldContain("Foo"); - lines[1].ShouldContain("123"); - lines[2].ShouldContain("Bar"); - lines[2].ShouldContain("456"); - - await Task.CompletedTask; - } - - public static async Task Should_render_table_with_border_color() + [TestTag("Widgets")] + public class TableWidgetBorderTests { - // Arrange - Table table = new TableBuilder() - .AddColumn("Name") - .AddRow("Test") - .BorderColor(AnsiColors.Cyan) - .Build(); - - // Act - string[] lines = table.Render(40); - - // Assert - // Border characters should be wrapped with color codes - lines[0].ShouldContain(AnsiColors.Cyan); - lines[0].ShouldContain(AnsiColors.Reset); - lines[1].ShouldContain(AnsiColors.Cyan); // vertical border - lines[4].ShouldContain(AnsiColors.Cyan); // bottom border - - await Task.CompletedTask; + [ModuleInitializer] + internal static void Register() => RegisterTests(); + + public static async Task Should_render_table_with_rounded_border() + { + // Arrange + Table table = new TableBuilder() + .AddColumn("A") + .AddColumn("B") + .AddRow("1", "2") + .Border(BorderStyle.Rounded) + .Build(); + + // Act + string[] lines = table.Render(40); + + // Assert + // Structure: top(0) + header(1) + sep(2) + data(3) + bottom(4) = 5 lines + lines.Length.ShouldBe(5); + lines[0].ShouldContain("╭"); // rounded top-left + lines[0].ShouldContain("╮"); // rounded top-right + lines[0].ShouldContain("┬"); // T-junctions use square style (no rounded T exists) + lines[4].ShouldContain("╰"); // rounded bottom-left + lines[4].ShouldContain("╯"); // rounded bottom-right + + await Task.CompletedTask; + } + + public static async Task Should_render_table_with_square_border() + { + // Arrange + Table table = new TableBuilder() + .AddColumn("A") + .AddColumn("B") + .AddRow("1", "2") + .Border(BorderStyle.Square) + .Build(); + + // Act + string[] lines = table.Render(40); + + // Assert + // Structure: top(0) + header(1) + sep(2) + data(3) + bottom(4) = 5 lines + lines.Length.ShouldBe(5); + lines[0].ShouldContain("┌"); // square top-left + lines[0].ShouldContain("┐"); // square top-right + lines[0].ShouldContain("┬"); // top T + lines[2].ShouldContain("├"); // left T + lines[2].ShouldContain("┼"); // cross + lines[2].ShouldContain("┤"); // right T + lines[4].ShouldContain("└"); // square bottom-left + lines[4].ShouldContain("┘"); // square bottom-right + lines[4].ShouldContain("┴"); // bottom T + + await Task.CompletedTask; + } + + public static async Task Should_render_table_with_double_border() + { + // Arrange + Table table = new TableBuilder() + .AddColumn("A") + .AddColumn("B") + .AddRow("1", "2") + .Border(BorderStyle.Doubled) + .Build(); + + // Act + string[] lines = table.Render(40); + + // Assert + // Structure: top(0) + header(1) + sep(2) + data(3) + bottom(4) = 5 lines + lines.Length.ShouldBe(5); + lines[0].ShouldContain("╔"); // double top-left + lines[0].ShouldContain("╗"); // double top-right + lines[0].ShouldContain("═"); // double horizontal + lines[0].ShouldContain("╦"); // double top T + lines[1].ShouldContain("║"); // double vertical + lines[2].ShouldContain("╠"); // double left T + lines[2].ShouldContain("╬"); // double cross + lines[2].ShouldContain("╣"); // double right T + lines[4].ShouldContain("╚"); // double bottom-left + lines[4].ShouldContain("╝"); // double bottom-right + lines[4].ShouldContain("╩"); // double bottom T + + await Task.CompletedTask; + } + + public static async Task Should_render_table_with_heavy_border() + { + // Arrange + Table table = new TableBuilder() + .AddColumn("A") + .AddColumn("B") + .AddRow("1", "2") + .Border(BorderStyle.Heavy) + .Build(); + + // Act + string[] lines = table.Render(40); + + // Assert + // Structure: top(0) + header(1) + sep(2) + data(3) + bottom(4) = 5 lines + lines.Length.ShouldBe(5); + lines[0].ShouldContain("┏"); // heavy top-left + lines[0].ShouldContain("┓"); // heavy top-right + lines[0].ShouldContain("━"); // heavy horizontal + lines[0].ShouldContain("┳"); // heavy top T + lines[1].ShouldContain("┃"); // heavy vertical + lines[2].ShouldContain("┣"); // heavy left T + lines[2].ShouldContain("╋"); // heavy cross + lines[2].ShouldContain("┫"); // heavy right T + lines[4].ShouldContain("┗"); // heavy bottom-left + lines[4].ShouldContain("┛"); // heavy bottom-right + lines[4].ShouldContain("┻"); // heavy bottom T + + await Task.CompletedTask; + } + + public static async Task Should_render_table_without_border_when_style_is_none() + { + // Arrange + Table table = new TableBuilder() + .AddColumn("Name") + .AddColumn("Value") + .AddRow("Foo", "123") + .AddRow("Bar", "456") + .Border(BorderStyle.None) + .Build(); + + // Act + string[] lines = table.Render(40); + + // Assert + // Should be just header + 2 data rows without any box characters + lines.Length.ShouldBe(3); + lines[0].ShouldContain("Name"); + lines[0].ShouldContain("Value"); + lines[0].ShouldNotContain("│"); + lines[0].ShouldNotContain("┌"); + lines[1].ShouldContain("Foo"); + lines[1].ShouldContain("123"); + lines[2].ShouldContain("Bar"); + lines[2].ShouldContain("456"); + + await Task.CompletedTask; + } + + public static async Task Should_render_table_with_border_color() + { + // Arrange + Table table = new TableBuilder() + .AddColumn("Name") + .AddRow("Test") + .BorderColor(AnsiColors.Cyan) + .Build(); + + // Act + string[] lines = table.Render(40); + + // Assert + // Border characters should be wrapped with color codes + lines[0].ShouldContain(AnsiColors.Cyan); + lines[0].ShouldContain(AnsiColors.Reset); + lines[1].ShouldContain(AnsiColors.Cyan); // vertical border + lines[4].ShouldContain(AnsiColors.Cyan); // bottom border + + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.TableWidgetBorders diff --git a/tests/table-widget-03-styling.cs b/tests/table-widget-03-styling.cs index ccb719b..e78eb11 100755 --- a/tests/table-widget-03-styling.cs +++ b/tests/table-widget-03-styling.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test Table widget with styled content (ANSI colors) @@ -10,190 +10,264 @@ namespace TimeWarp.Terminal.Tests.Core.TableWidgetStyling { -[TestTag("Widgets")] -public class TableWidgetStylingTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_render_styled_cell_content() - { - // Arrange - string styledValue = $"{AnsiColors.Red}Error{AnsiColors.Reset}"; - Table table = new TableBuilder() - .AddColumn("Status") - .AddRow(styledValue) - .Build(); - - // Act - string[] lines = table.Render(40); - - // Assert - // The styled content should be preserved in the output - lines[3].ShouldContain(AnsiColors.Red); - lines[3].ShouldContain("Error"); - lines[3].ShouldContain(AnsiColors.Reset); - - await Task.CompletedTask; - } - - public static async Task Should_calculate_column_width_correctly_with_ansi_codes() + [TestTag("Widgets")] + public class TableWidgetStylingTests { - // Arrange - // "Error" is 5 visible characters, but with ANSI codes the string is longer - string styledValue = $"{AnsiColors.Red}Error{AnsiColors.Reset}"; - Table table = new TableBuilder() - .AddColumn("Status") // 6 chars - .AddRow(styledValue) // 5 visible chars - .Build(); - - // Act - string[] lines = table.Render(40); - - // Assert - // The column should be sized for "Status" (6 chars) not the full ANSI string length - // Header row should have proper alignment - lines[1].ShouldContain("Status"); - - // The visible width of the content row should match the header width - // (both padded to 6 characters) - string contentLine = TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(lines[3]); - contentLine.ShouldContain("Error "); - - await Task.CompletedTask; - } + [ModuleInitializer] + internal static void Register() => RegisterTests(); - public static async Task Should_render_header_with_column_header_color() - { - // Arrange - TableColumn column = new("Important") + public static async Task Should_render_styled_cell_content() { - HeaderColor = AnsiColors.Yellow - }; - Table table = new TableBuilder() - .AddColumn(column) - .AddRow("Value") - .Build(); - - // Act - string[] lines = table.Render(40); - - // Assert - lines[1].ShouldContain(AnsiColors.Yellow); - lines[1].ShouldContain("Important"); - lines[1].ShouldContain(AnsiColors.Reset); - - await Task.CompletedTask; - } - - public static async Task Should_handle_multiple_styled_cells_in_same_row() - { - // Arrange - string green = $"{AnsiColors.Green}OK{AnsiColors.Reset}"; - string red = $"{AnsiColors.Red}FAIL{AnsiColors.Reset}"; - Table table = new TableBuilder() - .AddColumn("Test 1") - .AddColumn("Test 2") - .AddRow(green, red) - .Build(); - - // Act - string[] lines = table.Render(40); - - // Assert - string dataRow = lines[3]; - dataRow.ShouldContain(AnsiColors.Green); - dataRow.ShouldContain("OK"); - dataRow.ShouldContain(AnsiColors.Red); - dataRow.ShouldContain("FAIL"); - - await Task.CompletedTask; - } - - public static async Task Should_truncate_long_content_with_ellipsis() - { - // Arrange - TableColumn column = new("Description") + // Arrange + string styledValue = $"{AnsiColors.Red}Error{AnsiColors.Reset}"; + Table table = new TableBuilder() + .AddColumn("Status") + .AddRow(styledValue) + .Build(); + + // Act + string[] lines = table.Render(40); + + // Assert + // The styled content should be preserved in the output + lines[3].ShouldContain(AnsiColors.Red); + lines[3].ShouldContain("Error"); + lines[3].ShouldContain(AnsiColors.Reset); + + await Task.CompletedTask; + } + + public static async Task Should_calculate_column_width_correctly_with_ansi_codes() { - MaxWidth = 10 - }; - Table table = new TableBuilder() - .AddColumn(column) - .AddRow("This is a very long description that should be truncated") - .Build(); - - // Act - string[] lines = table.Render(40); - - // Assert - // Content should be truncated to 10 chars with "..." at end - string dataRow = lines[3]; - dataRow.ShouldContain("..."); - // Should not contain the full text - dataRow.ShouldNotContain("truncated"); - - await Task.CompletedTask; - } - - public static async Task Should_handle_empty_cells() - { - // Arrange - Table table = new TableBuilder() - .AddColumn("A") - .AddColumn("B") - .AddColumn("C") - .AddRow("1", "", "3") - .Build(); - - // Act - string[] lines = table.Render(40); - - // Assert - // Should render without errors, empty cell should just be spaces - lines[3].ShouldContain("1"); - lines[3].ShouldContain("3"); - - await Task.CompletedTask; - } - - public static async Task Should_handle_fewer_cells_than_columns() - { - // Arrange - Table table = new TableBuilder() - .AddColumn("A") - .AddColumn("B") - .AddColumn("C") - .AddRow("1") // Only one cell for three columns - .Build(); - - // Act - string[] lines = table.Render(40); - - // Assert - // Should render without errors, missing cells should be empty - lines[3].ShouldContain("1"); - - await Task.CompletedTask; - } + // Arrange + // "Error" is 5 visible characters, but with ANSI codes the string is longer + string styledValue = $"{AnsiColors.Red}Error{AnsiColors.Reset}"; + Table table = new TableBuilder() + .AddColumn("Status") // 6 chars + .AddRow(styledValue) // 5 visible chars + .Build(); + + // Act + string[] lines = table.Render(40); + + // Assert + // The column should be sized for "Status" (6 chars) not the full ANSI string length + // Header row should have proper alignment + lines[1].ShouldContain("Status"); + + // The visible width of the content row should match the header width + // (both padded to 6 characters) + string contentLine = TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(lines[3]); + contentLine.ShouldContain("Error "); + + await Task.CompletedTask; + } + + public static async Task Should_render_header_with_column_header_color() + { + // Arrange + TableColumn column = new("Important") + { + HeaderColor = AnsiColors.Yellow + }; + Table table = new TableBuilder() + .AddColumn(column) + .AddRow("Value") + .Build(); + + // Act + string[] lines = table.Render(40); + + // Assert + lines[1].ShouldContain(AnsiColors.Yellow); + lines[1].ShouldContain("Important"); + lines[1].ShouldContain(AnsiColors.Reset); + + await Task.CompletedTask; + } + + public static async Task Should_handle_multiple_styled_cells_in_same_row() + { + // Arrange + string green = $"{AnsiColors.Green}OK{AnsiColors.Reset}"; + string red = $"{AnsiColors.Red}FAIL{AnsiColors.Reset}"; + Table table = new TableBuilder() + .AddColumn("Test 1") + .AddColumn("Test 2") + .AddRow(green, red) + .Build(); + + // Act + string[] lines = table.Render(40); + + // Assert + string dataRow = lines[3]; + dataRow.ShouldContain(AnsiColors.Green); + dataRow.ShouldContain("OK"); + dataRow.ShouldContain(AnsiColors.Red); + dataRow.ShouldContain("FAIL"); + + await Task.CompletedTask; + } + + public static async Task Should_truncate_long_content_with_ellipsis() + { + // Arrange + TableColumn column = new("Description") + { + MaxWidth = 10 + }; + Table table = new TableBuilder() + .AddColumn(column) + .AddRow("This is a very long description that should be truncated") + .Build(); + + // Act + string[] lines = table.Render(40); + + // Assert + // Content should be truncated to 10 chars with "..." at end + string dataRow = lines[3]; + dataRow.ShouldContain("..."); + // Should not contain the full text + dataRow.ShouldNotContain("truncated"); + + await Task.CompletedTask; + } + + public static async Task Should_preserve_color_when_truncating_with_End_mode() + { + // Arrange + TableColumn column = new("Description") + { + MaxWidth = 10 + }; + string styled = $"{AnsiColors.Red}This is a long red value{AnsiColors.Reset}"; + Table table = new TableBuilder() + .AddColumn(column) + .AddRow(styled) + .Build(); + + // Act + string[] lines = table.Render(40); + string dataRow = lines[3]; + + // Assert - kept text keeps its color; a reset closes it before the plain ellipsis, + // so the ellipsis, padding, and border after it are unstyled + dataRow.ShouldContain(AnsiColors.Red + "This is"); + dataRow.ShouldContain(AnsiColors.Reset + "..."); + TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(dataRow).ShouldContain("This is..."); + + await Task.CompletedTask; + } + + public static async Task Should_replay_color_opened_before_cut_when_truncating_with_Start_mode() + { + // Arrange - the red color opens in the discarded prefix but must still style the kept tail + TableColumn column = new("Path") + { + MaxWidth = 10, + TruncateMode = TruncateMode.Start + }; + string styled = $"prefix text {AnsiColors.Red}red tail{AnsiColors.Reset}"; + Table table = new TableBuilder() + .AddColumn(column) + .AddRow(styled) + .Build(); + + // Act + string[] lines = table.Render(40); + string dataRow = lines[3]; + + // Assert - plain ellipsis, then the replayed color styles the kept tail + dataRow.ShouldContain("..." + AnsiColors.Red); + dataRow.ShouldContain("ed tail" + AnsiColors.Reset); + TimeWarp.Terminal.AnsiStringUtils.StripAnsiCodes(dataRow).ShouldContain("...ed tail"); + + await Task.CompletedTask; + } + + public static async Task Should_keep_foreground_after_border_reset_when_writing_table_with_color() + { + // Regression: BorderColor emits a Reset inside each rendered line, which used to cancel + // the foreground requested via the WriteTable color overload for the rest of the line + using TestTerminal terminal = new() { WindowWidth = 40 }; + + // Act + terminal.WriteTable( + table => table + .AddColumn("Name") + .AddRow("Foo") + .BorderColor(AnsiColors.Yellow), + ConsoleColor.Cyan); + + // Assert - the foreground code re-appears after each embedded Reset + terminal.Output.ShouldContain(AnsiColors.Yellow); + terminal.Output.ShouldContain(AnsiColors.Reset + AnsiColors.BrightCyan); // ConsoleColor.Cyan maps to bright cyan + terminal.Output.ShouldContain("Foo"); + + await Task.CompletedTask; + } + + public static async Task Should_handle_empty_cells() + { + // Arrange + Table table = new TableBuilder() + .AddColumn("A") + .AddColumn("B") + .AddColumn("C") + .AddRow("1", "", "3") + .Build(); + + // Act + string[] lines = table.Render(40); + + // Assert + // Should render without errors, empty cell should just be spaces + lines[3].ShouldContain("1"); + lines[3].ShouldContain("3"); + + await Task.CompletedTask; + } + + public static async Task Should_handle_fewer_cells_than_columns() + { + // Arrange + Table table = new TableBuilder() + .AddColumn("A") + .AddColumn("B") + .AddColumn("C") + .AddRow("1") // Only one cell for three columns + .Build(); - public static async Task Should_handle_null_cell_values() - { - // Arrange - Table table = new TableBuilder() - .AddColumn("A") - .AddColumn("B") - .AddRow("1", null!) - .Build(); + // Act + string[] lines = table.Render(40); - // Act - string[] lines = table.Render(40); + // Assert + // Should render without errors, missing cells should be empty + lines[3].ShouldContain("1"); - // Assert - // Should render without errors - lines[3].ShouldContain("1"); + await Task.CompletedTask; + } - await Task.CompletedTask; + public static async Task Should_handle_null_cell_values() + { + // Arrange + Table table = new TableBuilder() + .AddColumn("A") + .AddColumn("B") + .AddRow("1", null!) + .Build(); + + // Act + string[] lines = table.Render(40); + + // Assert + // Should render without errors + lines[3].ShouldContain("1"); + + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.TableWidgetStyling diff --git a/tests/table-widget-04-expand.cs b/tests/table-widget-04-expand.cs index 1e1eeb4..0de4594 100755 --- a/tests/table-widget-04-expand.cs +++ b/tests/table-widget-04-expand.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test Table widget expand functionality @@ -10,96 +10,145 @@ namespace TimeWarp.Terminal.Tests.Core.TableWidgetExpand { -[TestTag("Widgets")] -public class TableWidgetExpandTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_expand_table_to_terminal_width() - { - // Arrange - Table table = new TableBuilder() - .AddColumn("Name") - .AddColumn("Value") - .AddRow("A", "1") - .Expand() - .Build(); - - // Act - string[] lines = table.Render(80); - - // Assert - // The table should expand to fill 80 characters - int topLineWidth = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]); - topLineWidth.ShouldBe(80); - - await Task.CompletedTask; - } - - public static async Task Should_not_expand_when_expand_is_not_set() - { - // Arrange - Table table = new TableBuilder() - .AddColumn("Name") - .AddColumn("Value") - .AddRow("A", "1") - .Build(); - - // Act - string[] lines = table.Render(80); - - // Assert - // The table should be sized to content, not terminal width - int topLineWidth = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]); - topLineWidth.ShouldBeLessThan(80); - - await Task.CompletedTask; - } - - public static async Task Should_distribute_extra_width_evenly_across_columns() - { - // Arrange - Table table = new TableBuilder() - .AddColumn("A") // natural width: 1 - .AddColumn("B") // natural width: 1 - .AddRow("1", "2") - .Expand() - .Build(); - - // Act - render to 50 chars - // Natural width: 2 borders + 1 separator + 4 padding spaces + 2 content = 9 - // Extra: 50 - 9 = 41 chars to distribute across 2 columns - string[] lines = table.Render(50); - - // Assert - int topLineWidth = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]); - topLineWidth.ShouldBe(50); - - await Task.CompletedTask; - } - - public static async Task Should_not_expand_borderless_table() + [TestTag("Widgets")] + public class TableWidgetExpandTests { - // Arrange - Table table = new TableBuilder() - .AddColumn("Name") - .AddColumn("Value") - .AddRow("A", "1") - .Expand() - .Border(BorderStyle.None) - .Build(); - - // Act - string[] lines = table.Render(80); - - // Assert - // Borderless tables don't expand because there's no visual border to fill - int lineWidth = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]); - lineWidth.ShouldBeLessThan(80); - - await Task.CompletedTask; + [ModuleInitializer] + internal static void Register() => RegisterTests(); + + public static async Task Should_expand_table_to_terminal_width() + { + // Arrange + Table table = new TableBuilder() + .AddColumn("Name") + .AddColumn("Value") + .AddRow("A", "1") + .Expand() + .Build(); + + // Act + string[] lines = table.Render(80); + + // Assert + // The table should expand to fill 80 characters + int topLineWidth = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]); + topLineWidth.ShouldBe(80); + + await Task.CompletedTask; + } + + public static async Task Should_not_expand_when_expand_is_not_set() + { + // Arrange + Table table = new TableBuilder() + .AddColumn("Name") + .AddColumn("Value") + .AddRow("A", "1") + .Build(); + + // Act + string[] lines = table.Render(80); + + // Assert + // The table should be sized to content, not terminal width + int topLineWidth = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]); + topLineWidth.ShouldBeLessThan(80); + + await Task.CompletedTask; + } + + public static async Task Should_distribute_extra_width_evenly_across_columns() + { + // Arrange + Table table = new TableBuilder() + .AddColumn("A") // natural width: 1 + .AddColumn("B") // natural width: 1 + .AddRow("1", "2") + .Expand() + .Build(); + + // Act - render to 50 chars + // Natural width: 2 borders + 1 separator + 4 padding spaces + 2 content = 9 + // Extra: 50 - 9 = 41 chars to distribute across 2 columns + string[] lines = table.Render(50); + + // Assert + int topLineWidth = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]); + topLineWidth.ShouldBe(50); + + await Task.CompletedTask; + } + + public static async Task Should_not_expand_columns_beyond_max_width() + { + // Regression: Expand distributed extra width to every column, pushing + // MaxWidth-capped columns past their cap. + // Arrange + TableColumn cappedColumn = new("A") { MaxWidth = 5 }; + Table table = new TableBuilder() + .AddColumn(cappedColumn) + .AddColumn("B") + .AddRow("abc", "1") + .Expand() + .Build(); + + // Act + string[] lines = table.Render(50); + + // Assert - table still fills the terminal; the uncapped column absorbs the extra + int topLineWidth = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]); + topLineWidth.ShouldBe(50); + + // Capped column stays at MaxWidth 5: first border segment is 5 content + 2 padding, + // so the first T-junction sits at index 8 (after "┌" + 7 horizontals) + lines[0].IndexOf('┬').ShouldBe(8); + + await Task.CompletedTask; + } + + public static async Task Should_stop_expanding_when_all_columns_are_capped() + { + // Arrange - every column has a MaxWidth, so expansion must stop at the caps + TableColumn col1 = new("A") { MaxWidth = 5 }; + TableColumn col2 = new("B") { MaxWidth = 5 }; + Table table = new TableBuilder() + .AddColumn(col1) + .AddColumn(col2) + .AddRow("abc", "1") + .Expand() + .Build(); + + // Act + string[] lines = table.Render(80); + + // Assert - 2 borders + 1 separator + 4 padding + 2*5 content = 17, not 80 + int topLineWidth = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]); + topLineWidth.ShouldBe(17); + + await Task.CompletedTask; + } + + public static async Task Should_not_expand_borderless_table() + { + // Arrange + Table table = new TableBuilder() + .AddColumn("Name") + .AddColumn("Value") + .AddRow("A", "1") + .Expand() + .Border(BorderStyle.None) + .Build(); + + // Act + string[] lines = table.Render(80); + + // Assert + // Borderless tables don't expand because there's no visual border to fill + int lineWidth = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]); + lineWidth.ShouldBeLessThan(80); + + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.TableWidgetExpand diff --git a/tests/table-widget-05-shrink.cs b/tests/table-widget-05-shrink.cs index 78d9cd6..a0f4112 100755 --- a/tests/table-widget-05-shrink.cs +++ b/tests/table-widget-05-shrink.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test Table widget shrink functionality @@ -10,300 +10,300 @@ namespace TimeWarp.Terminal.Tests.Core.TableWidgetShrink { -[TestTag("Widgets")] -public class TableWidgetShrinkTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_shrink_table_to_fit_terminal_width() + [TestTag("Widgets")] + public class TableWidgetShrinkTests { - // Arrange - Table table = new TableBuilder() - .AddColumn("Name") - .AddColumn("Very Long Path That Exceeds Normal Width") - .AddRow("repo", "/home/user/worktrees/github.com/Organization/project-name/feature-branch") - .Build(); + [ModuleInitializer] + internal static void Register() => RegisterTests(); - // Act - render to narrow terminal (60 chars) - string[] lines = table.Render(60); + public static async Task Should_shrink_table_to_fit_terminal_width() + { + // Arrange + Table table = new TableBuilder() + .AddColumn("Name") + .AddColumn("Very Long Path That Exceeds Normal Width") + .AddRow("repo", "/home/user/worktrees/github.com/Organization/project-name/feature-branch") + .Build(); - // Assert - int topLineWidth = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]); - topLineWidth.ShouldBeLessThanOrEqualTo(60); + // Act - render to narrow terminal (60 chars) + string[] lines = table.Render(60); - await Task.CompletedTask; - } + // Assert + int topLineWidth = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]); + topLineWidth.ShouldBeLessThanOrEqualTo(60); - public static async Task Should_shrink_wider_columns_more_aggressively() - { - // Arrange - // Column A has 5 chars content, Column B has 50 chars content - Table table = new TableBuilder() - .AddColumn("A") - .AddColumn("B") - .AddRow("Short", "This is a very long string that takes up lots of space") - .Build(); - - // Act - render to 40 chars (forces shrinking) - string[] lines = table.Render(40); - - // Assert - int topLineWidth = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]); - topLineWidth.ShouldBeLessThanOrEqualTo(40); - - // The wider column B should be truncated with ellipsis - string dataRow = lines[3]; - dataRow.ShouldContain("..."); - - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_respect_column_min_width() - { - // Arrange - TableColumn columnWithMinWidth = new("Description") + public static async Task Should_shrink_wider_columns_more_aggressively() { - MinWidth = 20 - }; - Table table = new TableBuilder() - .AddColumn("ID") - .AddColumn(columnWithMinWidth) - .AddRow("1", "This is a long description that would normally be truncated heavily") - .Build(); - - // Act - render to narrow terminal - string[] lines = table.Render(40); - - // Assert - // Table should fit, but Description column should maintain at least 20 chars - int topLineWidth = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]); - topLineWidth.ShouldBeLessThanOrEqualTo(40); - - await Task.CompletedTask; - } + // Arrange + // Column A has 5 chars content, Column B has 50 chars content + Table table = new TableBuilder() + .AddColumn("A") + .AddColumn("B") + .AddRow("Short", "This is a very long string that takes up lots of space") + .Build(); - public static async Task Should_use_default_min_width_of_4() - { - // Arrange - table with many columns that would need extreme shrinking - Table table = new TableBuilder() - .AddColumn("Column One With Long Header") - .AddColumn("Column Two With Long Header") - .AddColumn("Column Three With Long Header") - .AddRow("Value 1", "Value 2", "Value 3") - .Build(); - - // Act - render to very narrow terminal (30 chars) - string[] lines = table.Render(30); - - // Assert - columns should shrink but not below 4 chars (shows "..." at minimum) - // The table may exceed width if min widths can't be satisfied, but it tried - lines.Length.ShouldBeGreaterThan(0); - - await Task.CompletedTask; - } + // Act - render to 40 chars (forces shrinking) + string[] lines = table.Render(40); - public static async Task Should_truncate_content_after_shrinking() - { - // Arrange - Table table = new TableBuilder() - .AddColumn("Path") - .AddRow("/home/steventcramer/worktrees/github.com/TimeWarpEngineering/timewarp-nuru/feature") - .Build(); - - // Act - render to 30 chars - string[] lines = table.Render(30); - - // Assert - string dataRow = lines[3]; - dataRow.ShouldContain("..."); - // Should not contain the full path - dataRow.ShouldNotContain("feature"); - - await Task.CompletedTask; - } + // Assert + int topLineWidth = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]); + topLineWidth.ShouldBeLessThanOrEqualTo(40); - public static async Task Should_handle_table_already_fits() - { - // Arrange - small table that already fits - Table table = new TableBuilder() - .AddColumn("A") - .AddColumn("B") - .AddRow("1", "2") - .Build(); + // The wider column B should be truncated with ellipsis + string dataRow = lines[3]; + dataRow.ShouldContain("..."); - // Act - render to wide terminal (80 chars) - string[] lines = table.Render(80); + await Task.CompletedTask; + } - // Assert - table should render normally without shrinking - int topLineWidth = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]); - topLineWidth.ShouldBeLessThan(80); + public static async Task Should_respect_column_min_width() + { + // Arrange + TableColumn columnWithMinWidth = new("Description") + { + MinWidth = 20 + }; + Table table = new TableBuilder() + .AddColumn("ID") + .AddColumn(columnWithMinWidth) + .AddRow("1", "This is a long description that would normally be truncated heavily") + .Build(); - // Content should not be truncated - string dataRow = lines[3]; - dataRow.ShouldNotContain("..."); + // Act - render to narrow terminal + string[] lines = table.Render(40); - await Task.CompletedTask; - } + // Assert + // Table should fit, but Description column should maintain at least 20 chars + int topLineWidth = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]); + topLineWidth.ShouldBeLessThanOrEqualTo(40); - public static async Task Should_work_with_ansi_colored_content() - { - // Arrange - string coloredPath = $"{AnsiColors.Cyan}/home/user/very/long/path/that/needs/truncation{AnsiColors.Reset}"; - Table table = new TableBuilder() - .AddColumn("Path") - .AddRow(coloredPath) - .Build(); + await Task.CompletedTask; + } - // Act - render to narrow terminal - string[] lines = table.Render(30); + public static async Task Should_use_default_min_width_of_4() + { + // Arrange - table with many columns that would need extreme shrinking + Table table = new TableBuilder() + .AddColumn("Column One With Long Header") + .AddColumn("Column Two With Long Header") + .AddColumn("Column Three With Long Header") + .AddRow("Value 1", "Value 2", "Value 3") + .Build(); - // Assert - int topLineWidth = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]); - topLineWidth.ShouldBeLessThanOrEqualTo(30); + // Act - render to very narrow terminal (30 chars) + string[] lines = table.Render(30); - await Task.CompletedTask; - } + // Assert - columns should shrink but not below 4 chars (shows "..." at minimum) + // The table may exceed width if min widths can't be satisfied, but it tried + lines.Length.ShouldBeGreaterThan(0); - public static async Task Should_work_with_different_border_styles() - { - // Arrange & Act & Assert for each border style - BorderStyle[] styles = [BorderStyle.Square, BorderStyle.Rounded, BorderStyle.Doubled, BorderStyle.Heavy]; + await Task.CompletedTask; + } - foreach (BorderStyle style in styles) + public static async Task Should_truncate_content_after_shrinking() { + // Arrange Table table = new TableBuilder() - .AddColumn("Long Column Header Name") - .AddRow("Some content that is fairly long") - .Border(style) + .AddColumn("Path") + .AddRow("/home/steventcramer/worktrees/github.com/TimeWarpEngineering/timewarp-nuru/feature") .Build(); + // Act - render to 30 chars string[] lines = table.Render(30); + // Assert + string dataRow = lines[3]; + dataRow.ShouldContain("..."); + // Should not contain the full path + dataRow.ShouldNotContain("feature"); + + await Task.CompletedTask; + } + + public static async Task Should_handle_table_already_fits() + { + // Arrange - small table that already fits + Table table = new TableBuilder() + .AddColumn("A") + .AddColumn("B") + .AddRow("1", "2") + .Build(); + + // Act - render to wide terminal (80 chars) + string[] lines = table.Render(80); + + // Assert - table should render normally without shrinking int topLineWidth = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]); - topLineWidth.ShouldBeLessThanOrEqualTo(30, $"Border style {style} should fit within 30 chars"); + topLineWidth.ShouldBeLessThan(80); + + // Content should not be truncated + string dataRow = lines[3]; + dataRow.ShouldNotContain("..."); + + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_work_with_ansi_colored_content() + { + // Arrange + string coloredPath = $"{AnsiColors.Cyan}/home/user/very/long/path/that/needs/truncation{AnsiColors.Reset}"; + Table table = new TableBuilder() + .AddColumn("Path") + .AddRow(coloredPath) + .Build(); - public static async Task Should_work_with_borderless_table() - { - // Arrange - Table table = new TableBuilder() - .AddColumn("Long Column Header Name") - .AddColumn("Another Long Column Header") - .AddRow("Content one", "Content two that is long") - .Border(BorderStyle.None) - .Build(); - - // Act - render to narrow terminal - string[] lines = table.Render(40); - - // Assert - borderless tables should also shrink - int lineWidth = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]); - lineWidth.ShouldBeLessThanOrEqualTo(40); - - await Task.CompletedTask; - } + // Act - render to narrow terminal + string[] lines = table.Render(30); - public static async Task Should_truncate_at_start_with_truncate_mode_start() - { - // Arrange - TableColumn pathColumn = new("Path") + // Assert + int topLineWidth = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]); + topLineWidth.ShouldBeLessThanOrEqualTo(30); + + await Task.CompletedTask; + } + + public static async Task Should_work_with_different_border_styles() { - TruncateMode = TruncateMode.Start, - MaxWidth = 20 - }; - Table table = new TableBuilder() - .AddColumn(pathColumn) - .AddRow("/home/user/worktrees/github.com/TimeWarpEngineering/timewarp-nuru") - .Build(); - - // Act - string[] lines = table.Render(30); - - // Assert - should show ellipsis at start, end of path visible - string dataRow = lines[3]; - dataRow.ShouldContain("..."); - dataRow.ShouldContain("nuru"); // End of path should be visible - dataRow.ShouldNotContain("/home"); // Start should be truncated - - await Task.CompletedTask; - } + // Arrange & Act & Assert for each border style + BorderStyle[] styles = [BorderStyle.Square, BorderStyle.Rounded, BorderStyle.Doubled, BorderStyle.Heavy]; - public static async Task Should_truncate_at_middle_with_truncate_mode_middle() - { - // Arrange - TableColumn column = new("Description") + foreach (BorderStyle style in styles) + { + Table table = new TableBuilder() + .AddColumn("Long Column Header Name") + .AddRow("Some content that is fairly long") + .Border(style) + .Build(); + + string[] lines = table.Render(30); + + int topLineWidth = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]); + topLineWidth.ShouldBeLessThanOrEqualTo(30, $"Border style {style} should fit within 30 chars"); + } + + await Task.CompletedTask; + } + + public static async Task Should_work_with_borderless_table() { - TruncateMode = TruncateMode.Middle, - MaxWidth = 20 - }; - Table table = new TableBuilder() - .AddColumn(column) - .AddRow("beginning-of-text-middle-part-end-of-text") - .Build(); - - // Act - string[] lines = table.Render(30); - - // Assert - should show ellipsis in middle, both start and end visible - string dataRow = lines[3]; - dataRow.ShouldContain("..."); - dataRow.ShouldContain("begin"); // Start should be visible - dataRow.ShouldContain("text"); // End should be visible - - await Task.CompletedTask; - } + // Arrange + Table table = new TableBuilder() + .AddColumn("Long Column Header Name") + .AddColumn("Another Long Column Header") + .AddRow("Content one", "Content two that is long") + .Border(BorderStyle.None) + .Build(); - public static async Task Should_truncate_at_end_by_default() - { - // Arrange - default TruncateMode is End - Table table = new TableBuilder() - .AddColumn(new TableColumn("Path") { MaxWidth = 20 }) - .AddRow("/home/user/worktrees/github.com/TimeWarpEngineering/timewarp-nuru") - .Build(); - - // Act - string[] lines = table.Render(30); - - // Assert - should show ellipsis at end, start of path visible - string dataRow = lines[3]; - dataRow.ShouldContain("..."); - dataRow.ShouldContain("/home"); // Start should be visible - dataRow.ShouldNotContain("nuru"); // End should be truncated - - await Task.CompletedTask; - } + // Act - render to narrow terminal + string[] lines = table.Render(40); - public static async Task Should_show_end_of_path_when_shrinking_with_start_mode() - { - // Arrange - This is the real-world use case from the issue - TableColumn repoColumn = new("Repository"); - TableColumn pathColumn = new("Worktree Path") + // Assert - borderless tables should also shrink + int lineWidth = TimeWarp.Terminal.AnsiStringUtils.GetVisibleLength(lines[0]); + lineWidth.ShouldBeLessThanOrEqualTo(40); + + await Task.CompletedTask; + } + + public static async Task Should_truncate_at_start_with_truncate_mode_start() + { + // Arrange + TableColumn pathColumn = new("Path") + { + TruncateMode = TruncateMode.Start, + MaxWidth = 20 + }; + Table table = new TableBuilder() + .AddColumn(pathColumn) + .AddRow("/home/user/worktrees/github.com/TimeWarpEngineering/timewarp-nuru") + .Build(); + + // Act + string[] lines = table.Render(30); + + // Assert - should show ellipsis at start, end of path visible + string dataRow = lines[3]; + dataRow.ShouldContain("..."); + dataRow.ShouldContain("nuru"); // End of path should be visible + dataRow.ShouldNotContain("/home"); // Start should be truncated + + await Task.CompletedTask; + } + + public static async Task Should_truncate_at_middle_with_truncate_mode_middle() + { + // Arrange + TableColumn column = new("Description") + { + TruncateMode = TruncateMode.Middle, + MaxWidth = 20 + }; + Table table = new TableBuilder() + .AddColumn(column) + .AddRow("beginning-of-text-middle-part-end-of-text") + .Build(); + + // Act + string[] lines = table.Render(30); + + // Assert - should show ellipsis in middle, both start and end visible + string dataRow = lines[3]; + dataRow.ShouldContain("..."); + dataRow.ShouldContain("begin"); // Start should be visible + dataRow.ShouldContain("text"); // End should be visible + + await Task.CompletedTask; + } + + public static async Task Should_truncate_at_end_by_default() { - TruncateMode = TruncateMode.Start - }; - TableColumn branchColumn = new("Branch"); - - Table table = new TableBuilder() - .AddColumn(repoColumn) - .AddColumn(pathColumn) - .AddColumn(branchColumn) - .AddRow("timewarp-nuru", "/home/user/worktrees/github.com/TimeWarpEngineering/timewarp-nuru/feature-branch-name", "feature-xyz") - .Build(); - - // Act - render to constrained width - string[] lines = table.Render(80); - - // Assert - string dataRow = lines[3]; - // Path column should show the end (branch name) not the start - dataRow.ShouldContain("feature-branch-name"); - - await Task.CompletedTask; + // Arrange - default TruncateMode is End + Table table = new TableBuilder() + .AddColumn(new TableColumn("Path") { MaxWidth = 20 }) + .AddRow("/home/user/worktrees/github.com/TimeWarpEngineering/timewarp-nuru") + .Build(); + + // Act + string[] lines = table.Render(30); + + // Assert - should show ellipsis at end, start of path visible + string dataRow = lines[3]; + dataRow.ShouldContain("..."); + dataRow.ShouldContain("/home"); // Start should be visible + dataRow.ShouldNotContain("nuru"); // End should be truncated + + await Task.CompletedTask; + } + + public static async Task Should_show_end_of_path_when_shrinking_with_start_mode() + { + // Arrange - This is the real-world use case from the issue + TableColumn repoColumn = new("Repository"); + TableColumn pathColumn = new("Worktree Path") + { + TruncateMode = TruncateMode.Start + }; + TableColumn branchColumn = new("Branch"); + + Table table = new TableBuilder() + .AddColumn(repoColumn) + .AddColumn(pathColumn) + .AddColumn(branchColumn) + .AddRow("timewarp-nuru", "/home/user/worktrees/github.com/TimeWarpEngineering/timewarp-nuru/feature-branch-name", "feature-xyz") + .Build(); + + // Act - render to constrained width + string[] lines = table.Render(80); + + // Assert + string dataRow = lines[3]; + // Path column should show the end (branch name) not the start + dataRow.ShouldContain("feature-branch-name"); + + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.TableWidgetShrink diff --git a/tests/table-widget-06-emoji.cs b/tests/table-widget-06-emoji.cs index ecf27d6..e042b2b 100755 --- a/tests/table-widget-06-emoji.cs +++ b/tests/table-widget-06-emoji.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test Table widget with emoji content @@ -10,97 +10,97 @@ namespace TimeWarp.Terminal.Tests.Core.TableWidgetEmoji { -[TestTag("Widgets")] -public class TableWidgetEmojiTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_render_table_with_emoji_in_cells_aligned() + [TestTag("Widgets")] + public class TableWidgetEmojiTests { - // Arrange - weather report table from kanban issue - Table table = new TableBuilder() - .AddColumn("Info") - .AddColumn("Value") - .AddRow("📍 Location", "Dallas, United States") - .AddRow("🌡️ Temperature", "25.2°C (77.4°F)") - .AddRow("☁️ Condition", "Overcast") - .Build(); - - // Act - string[] lines = table.Render(80); - - // Assert - all border lines should have the same visible length - // Top border (line 0), header separator (line 2), bottom border (last line) - int topWidth = AnsiStringUtils.GetVisibleLength(lines[0]); - int separatorWidth = AnsiStringUtils.GetVisibleLength(lines[2]); - int bottomWidth = AnsiStringUtils.GetVisibleLength(lines[^1]); - - topWidth.ShouldBe(separatorWidth); - topWidth.ShouldBe(bottomWidth); - - // All data rows should also match - for (int i = 1; i < lines.Length - 1; i++) + [ModuleInitializer] + internal static void Register() => RegisterTests(); + + public static async Task Should_render_table_with_emoji_in_cells_aligned() { - AnsiStringUtils.GetVisibleLength(lines[i]).ShouldBe(topWidth); + // Arrange - weather report table from kanban issue + Table table = new TableBuilder() + .AddColumn("Info") + .AddColumn("Value") + .AddRow("📍 Location", "Dallas, United States") + .AddRow("🌡️ Temperature", "25.2°C (77.4°F)") + .AddRow("☁️ Condition", "Overcast") + .Build(); + + // Act + string[] lines = table.Render(80); + + // Assert - all border lines should have the same visible length + // Top border (line 0), header separator (line 2), bottom border (last line) + int topWidth = AnsiStringUtils.GetVisibleLength(lines[0]); + int separatorWidth = AnsiStringUtils.GetVisibleLength(lines[2]); + int bottomWidth = AnsiStringUtils.GetVisibleLength(lines[^1]); + + topWidth.ShouldBe(separatorWidth); + topWidth.ShouldBe(bottomWidth); + + // All data rows should also match + for (int i = 1; i < lines.Length - 1; i++) + { + AnsiStringUtils.GetVisibleLength(lines[i]).ShouldBe(topWidth); + } + + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_render_table_with_emoji_headers_aligned() + { + // Arrange + Table table = new TableBuilder() + .AddColumn("📍 Location") + .AddColumn("🌡️ Temp") + .AddRow("Dallas", "25°C") + .AddRow("London", "12°C") + .Build(); - public static async Task Should_render_table_with_emoji_headers_aligned() - { - // Arrange - Table table = new TableBuilder() - .AddColumn("📍 Location") - .AddColumn("🌡️ Temp") - .AddRow("Dallas", "25°C") - .AddRow("London", "12°C") - .Build(); + // Act + string[] lines = table.Render(80); - // Act - string[] lines = table.Render(80); + // Assert - borders align + int topWidth = AnsiStringUtils.GetVisibleLength(lines[0]); + int bottomWidth = AnsiStringUtils.GetVisibleLength(lines[^1]); - // Assert - borders align - int topWidth = AnsiStringUtils.GetVisibleLength(lines[0]); - int bottomWidth = AnsiStringUtils.GetVisibleLength(lines[^1]); + topWidth.ShouldBe(bottomWidth); - topWidth.ShouldBe(bottomWidth); + for (int i = 1; i < lines.Length - 1; i++) + { + AnsiStringUtils.GetVisibleLength(lines[i]).ShouldBe(topWidth); + } - for (int i = 1; i < lines.Length - 1; i++) - { - AnsiStringUtils.GetVisibleLength(lines[i]).ShouldBe(topWidth); + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_render_table_without_emoji_unchanged() + { + // Regression test: ASCII-only table renders correctly + Table table = new TableBuilder() + .AddColumn("Name") + .AddColumn("Value") + .AddRow("Foo", "123") + .AddRow("Bar", "456") + .Build(); - public static async Task Should_render_table_without_emoji_unchanged() - { - // Regression test: ASCII-only table renders correctly - Table table = new TableBuilder() - .AddColumn("Name") - .AddColumn("Value") - .AddRow("Foo", "123") - .AddRow("Bar", "456") - .Build(); + // Act + string[] lines = table.Render(40); - // Act - string[] lines = table.Render(40); + // Assert + int topWidth = AnsiStringUtils.GetVisibleLength(lines[0]); + int bottomWidth = AnsiStringUtils.GetVisibleLength(lines[^1]); - // Assert - int topWidth = AnsiStringUtils.GetVisibleLength(lines[0]); - int bottomWidth = AnsiStringUtils.GetVisibleLength(lines[^1]); + topWidth.ShouldBe(bottomWidth); - topWidth.ShouldBe(bottomWidth); + for (int i = 1; i < lines.Length - 1; i++) + { + AnsiStringUtils.GetVisibleLength(lines[i]).ShouldBe(topWidth); + } - for (int i = 1; i < lines.Length - 1; i++) - { - AnsiStringUtils.GetVisibleLength(lines[i]).ShouldBe(topWidth); + await Task.CompletedTask; } - - await Task.CompletedTask; } -} } // namespace TimeWarp.Terminal.Tests.Core.TableWidgetEmoji diff --git a/tests/table-widget-07-grow.cs b/tests/table-widget-07-grow.cs index 0079f40..b57b419 100755 --- a/tests/table-widget-07-grow.cs +++ b/tests/table-widget-07-grow.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test Table widget Grow column functionality @@ -10,191 +10,219 @@ namespace TimeWarp.Terminal.Tests.Core.TableWidgetGrow { -[TestTag("Widgets")] -public class TableWidgetGrowTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_grow_column_to_fill_terminal_width() - { - // Arrange - TableColumn growColumn = new("Description") { Grow = true }; - Table table = new TableBuilder() - .AddColumn("ID") - .AddColumn(growColumn) - .AddRow("1", "Short text") - .Border(BorderStyle.Rounded) - .Build(); - - // Act - render to 80 chars - string[] lines = table.Render(80); - - // Assert - table should fill terminal width - int topLineWidth = AnsiStringUtils.GetVisibleLength(lines[0]); - topLineWidth.ShouldBe(80); - - await Task.CompletedTask; - } - - public static async Task Should_keep_fixed_columns_at_natural_width() - { - // Arrange - ID column is fixed, Description grows - TableColumn growColumn = new("Description") { Grow = true }; - Table table = new TableBuilder() - .AddColumn("ID") - .AddColumn(growColumn) - .AddRow("1", "Some text") - .Border(BorderStyle.Rounded) - .Build(); - - // Act - string[] lines = table.Render(80); - - // Assert - ID column stays at natural width (1 char content), Description fills the rest - // lines[0] = top border, lines[1] = header, lines[2] = header separator, lines[3] = data row - string dataRow = lines[3]; - dataRow.ShouldContain("1"); // ID content present - dataRow.ShouldContain("Some text"); // Description content present - - await Task.CompletedTask; - } - - public static async Task Should_split_remaining_space_evenly_between_multiple_grow_columns() - { - // Arrange - two grow columns share remaining space - TableColumn col1 = new("Left") { Grow = true }; - TableColumn col2 = new("Right") { Grow = true }; - Table table = new TableBuilder() - .AddColumn("ID") - .AddColumn(col1) - .AddColumn(col2) - .AddRow("1", "A", "B") - .Border(BorderStyle.Rounded) - .Build(); - - // Act - render to 80 chars - string[] lines = table.Render(80); - - // Assert - total width fills terminal - int topLineWidth = AnsiStringUtils.GetVisibleLength(lines[0]); - topLineWidth.ShouldBe(80); - - await Task.CompletedTask; - } - - public static async Task Should_shrink_fixed_columns_when_overflow() - { - // Arrange - fixed columns are wide, terminal is narrow - TableColumn growColumn = new("Description") { Grow = true }; - Table table = new TableBuilder() - .AddColumn(new TableColumn("Fixed Header")) - .AddColumn(growColumn) - .AddRow("Fixed content", "Grow content") - .Border(BorderStyle.Rounded) - .Build(); - - // Act - narrow terminal forces shrinking of fixed columns - string[] lines = table.Render(40); - - // Assert - table fits within terminal width - int topLineWidth = AnsiStringUtils.GetVisibleLength(lines[0]); - topLineWidth.ShouldBeLessThanOrEqualTo(40); - - await Task.CompletedTask; - } - - public static async Task Should_give_grow_column_min_width_when_no_space_available() - { - // Arrange - very narrow terminal, no room for grow column - TableColumn growColumn = new("G") { Grow = true }; - Table table = new TableBuilder() - .AddColumn(new TableColumn("Fixed") { MinWidth = 10 }) - .AddColumn(growColumn) - .AddRow("Fixed text", "Grow text") - .Border(BorderStyle.Rounded) - .Build(); - - // Act - very narrow terminal - string[] lines = table.Render(20); - - // Assert - table still renders (grow column falls back to MinWidth ?? 4) - lines.Length.ShouldBeGreaterThan(0); - - await Task.CompletedTask; - } - - public static async Task Should_grow_column_with_three_fixed_columns() - { - // Arrange - real-world: ID, Status, Description (grows), Timestamp - TableColumn descColumn = new("Description") { Grow = true }; - Table table = new TableBuilder() - .AddColumn("ID") - .AddColumn("Status") - .AddColumn(descColumn) - .AddColumn("Time") - .AddRow("1", "Active", "This description will expand to fill available space", "12:00") - .Border(BorderStyle.Rounded) - .Build(); - - // Act - string[] lines = table.Render(100); - - // Assert - table fills terminal - int topLineWidth = AnsiStringUtils.GetVisibleLength(lines[0]); - topLineWidth.ShouldBe(100); - - await Task.CompletedTask; - } - - public static async Task Should_render_grow_column_with_ellipsis_when_fixed_overflow() - { - // Arrange - fixed columns exceed terminal, grow column falls back to MinWidth and truncates - TableColumn growColumn = new("Description") { Grow = true, MinWidth = 4 }; - Table table = new TableBuilder() - .AddColumn(new TableColumn("VeryLongFixedColumnHeader")) - .AddColumn(growColumn) - .AddRow("Very long fixed content that takes a lot of space", "Some description text here") - .Border(BorderStyle.Rounded) - .Build(); - - // Act - narrow terminal - string[] lines = table.Render(30); - - // Assert - still renders without throwing - lines.Length.ShouldBeGreaterThan(0); - - await Task.CompletedTask; - } - - public static async Task Should_keep_grow_column_natural_width_when_no_remaining_space() + [TestTag("Widgets")] + public class TableWidgetGrowTests { - // Bug: When terminal is narrower than fixed columns + grow column natural width, - // Grow columns were being forced to MinWidth instead of keeping natural width. - // This caused branch names like "Cramer-2025-12-22-dev" to truncate to "C..." - // when they should display their full content. - // - // Arrange - create a table where fixed columns are wide enough to consume most space - // leaving no room for grow column to expand, but grow column should still show - // its natural content width (not be forced to MinWidth) - TableColumn growColumn = new("Branch") { Grow = true }; - Table table = new TableBuilder() - .AddColumn(new TableColumn("Repository") { TruncateMode = TruncateMode.Start }) - .AddColumn(growColumn) - .AddColumn(new TableColumn("Path") { TruncateMode = TruncateMode.Start }) - .AddRow("timewarp-terminal", "Cramer-2025-12-22-dev", "/very/long/path/here") - .Border(BorderStyle.Rounded) - .Build(); - - // Act - render to 100 chars - wide enough that content should be visible - string[] lines = table.Render(100); - - // Assert - Branch column (Grow) should show full content, not be truncated - string dataRow = lines[3]; // Data row - dataRow.ShouldContain("Cramer-2025-12-22-dev"); // Full branch name should be visible - - await Task.CompletedTask; + [ModuleInitializer] + internal static void Register() => RegisterTests(); + + public static async Task Should_grow_column_to_fill_terminal_width() + { + // Arrange + TableColumn growColumn = new("Description") { Grow = true }; + Table table = new TableBuilder() + .AddColumn("ID") + .AddColumn(growColumn) + .AddRow("1", "Short text") + .Border(BorderStyle.Rounded) + .Build(); + + // Act - render to 80 chars + string[] lines = table.Render(80); + + // Assert - table should fill terminal width + int topLineWidth = AnsiStringUtils.GetVisibleLength(lines[0]); + topLineWidth.ShouldBe(80); + + await Task.CompletedTask; + } + + public static async Task Should_keep_fixed_columns_at_natural_width() + { + // Arrange - ID column is fixed, Description grows + TableColumn growColumn = new("Description") { Grow = true }; + Table table = new TableBuilder() + .AddColumn("ID") + .AddColumn(growColumn) + .AddRow("1", "Some text") + .Border(BorderStyle.Rounded) + .Build(); + + // Act + string[] lines = table.Render(80); + + // Assert - ID column stays at natural width (1 char content), Description fills the rest + // lines[0] = top border, lines[1] = header, lines[2] = header separator, lines[3] = data row + string dataRow = lines[3]; + dataRow.ShouldContain("1"); // ID content present + dataRow.ShouldContain("Some text"); // Description content present + + await Task.CompletedTask; + } + + public static async Task Should_split_remaining_space_evenly_between_multiple_grow_columns() + { + // Arrange - two grow columns share remaining space + TableColumn col1 = new("Left") { Grow = true }; + TableColumn col2 = new("Right") { Grow = true }; + Table table = new TableBuilder() + .AddColumn("ID") + .AddColumn(col1) + .AddColumn(col2) + .AddRow("1", "A", "B") + .Border(BorderStyle.Rounded) + .Build(); + + // Act - render to 80 chars + string[] lines = table.Render(80); + + // Assert - total width fills terminal + int topLineWidth = AnsiStringUtils.GetVisibleLength(lines[0]); + topLineWidth.ShouldBe(80); + + await Task.CompletedTask; + } + + public static async Task Should_shrink_fixed_columns_when_overflow() + { + // Arrange - fixed columns are wide, terminal is narrow + TableColumn growColumn = new("Description") { Grow = true }; + Table table = new TableBuilder() + .AddColumn(new TableColumn("Fixed Header")) + .AddColumn(growColumn) + .AddRow("Fixed content", "Grow content") + .Border(BorderStyle.Rounded) + .Build(); + + // Act - narrow terminal forces shrinking of fixed columns + string[] lines = table.Render(40); + + // Assert - table fits within terminal width + int topLineWidth = AnsiStringUtils.GetVisibleLength(lines[0]); + topLineWidth.ShouldBeLessThanOrEqualTo(40); + + await Task.CompletedTask; + } + + public static async Task Should_give_grow_column_min_width_when_no_space_available() + { + // Arrange - very narrow terminal, no room for grow column + TableColumn growColumn = new("G") { Grow = true }; + Table table = new TableBuilder() + .AddColumn(new TableColumn("Fixed") { MinWidth = 10 }) + .AddColumn(growColumn) + .AddRow("Fixed text", "Grow text") + .Border(BorderStyle.Rounded) + .Build(); + + // Act - very narrow terminal + string[] lines = table.Render(20); + + // Assert - table still renders (grow column falls back to MinWidth ?? 4) + lines.Length.ShouldBeGreaterThan(0); + + await Task.CompletedTask; + } + + public static async Task Should_grow_column_with_three_fixed_columns() + { + // Arrange - real-world: ID, Status, Description (grows), Timestamp + TableColumn descColumn = new("Description") { Grow = true }; + Table table = new TableBuilder() + .AddColumn("ID") + .AddColumn("Status") + .AddColumn(descColumn) + .AddColumn("Time") + .AddRow("1", "Active", "This description will expand to fill available space", "12:00") + .Border(BorderStyle.Rounded) + .Build(); + + // Act + string[] lines = table.Render(100); + + // Assert - table fills terminal + int topLineWidth = AnsiStringUtils.GetVisibleLength(lines[0]); + topLineWidth.ShouldBe(100); + + await Task.CompletedTask; + } + + public static async Task Should_render_grow_column_with_ellipsis_when_fixed_overflow() + { + // Arrange - fixed columns exceed terminal, grow column falls back to MinWidth and truncates + TableColumn growColumn = new("Description") { Grow = true, MinWidth = 4 }; + Table table = new TableBuilder() + .AddColumn(new TableColumn("VeryLongFixedColumnHeader")) + .AddColumn(growColumn) + .AddRow("Very long fixed content that takes a lot of space", "Some description text here") + .Border(BorderStyle.Rounded) + .Build(); + + // Act - narrow terminal + string[] lines = table.Render(30); + + // Assert - still renders without throwing + lines.Length.ShouldBeGreaterThan(0); + + await Task.CompletedTask; + } + + public static async Task Should_keep_grow_column_at_min_width_in_tight_terminal() + { + // Regression: when fixed columns consumed the full terminal width, + // grow columns were hard-set to width 0 and their content silently vanished. + // A grow column must never be sized below max(4, MinWidth). + // Arrange + TableColumn growColumn = new("Grow") { Grow = true, MinWidth = 6 }; + Table table = new TableBuilder() + .AddColumn(new TableColumn("Fixed")) + .AddColumn(growColumn) + .AddRow(new string('x', 50), "GrowDataValue") + .Border(BorderStyle.Rounded) + .Build(); + + // Act - fixed column alone exceeds the 30-char terminal + string[] lines = table.Render(30); + + // Assert - grow column keeps MinWidth 6: content truncates instead of vanishing + string dataRow = lines[3]; + dataRow.ShouldContain("Gro..."); + + // Fixed column absorbs the shrink so the table still fits the terminal + int topLineWidth = AnsiStringUtils.GetVisibleLength(lines[0]); + topLineWidth.ShouldBeLessThanOrEqualTo(30); + + await Task.CompletedTask; + } + + public static async Task Should_keep_grow_column_natural_width_when_no_remaining_space() + { + // Bug: When terminal is narrower than fixed columns + grow column natural width, + // Grow columns were being forced to MinWidth instead of keeping natural width. + // This caused branch names like "Cramer-2025-12-22-dev" to truncate to "C..." + // when they should display their full content. + // + // Arrange - create a table where fixed columns are wide enough to consume most space + // leaving no room for grow column to expand, but grow column should still show + // its natural content width (not be forced to MinWidth) + TableColumn growColumn = new("Branch") { Grow = true }; + Table table = new TableBuilder() + .AddColumn(new TableColumn("Repository") { TruncateMode = TruncateMode.Start }) + .AddColumn(growColumn) + .AddColumn(new TableColumn("Path") { TruncateMode = TruncateMode.Start }) + .AddRow("timewarp-terminal", "Cramer-2025-12-22-dev", "/very/long/path/here") + .Border(BorderStyle.Rounded) + .Build(); + + // Act - render to 100 chars - wide enough that content should be visible + string[] lines = table.Render(100); + + // Assert - Branch column (Grow) should show full content, not be truncated + string dataRow = lines[3]; // Data row + dataRow.ShouldContain("Cramer-2025-12-22-dev"); // Full branch name should be visible + + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.TableWidgetGrow diff --git a/tests/terminal-color-state-01-basic.cs b/tests/terminal-color-state-01-basic.cs index 95509e2..28b95e3 100755 --- a/tests/terminal-color-state-01-basic.cs +++ b/tests/terminal-color-state-01-basic.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test color state properties on ITerminal @@ -10,108 +10,108 @@ namespace TimeWarp.Terminal.Tests.Core.TerminalColorState { -[TestTag("Terminal")] -public class TerminalColorStateTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_get_and_set_foreground_color() + [TestTag("Terminal")] + public class TerminalColorStateTests { - // Arrange - using TestTerminal terminal = new(); + [ModuleInitializer] + internal static void Register() => RegisterTests(); - // Act - terminal.ForegroundColor = ConsoleColor.Red; + public static async Task Should_get_and_set_foreground_color() + { + // Arrange + using TestTerminal terminal = new(); - // Assert - terminal.ForegroundColor.ShouldBe(ConsoleColor.Red); + // Act + terminal.ForegroundColor = ConsoleColor.Red; - await Task.CompletedTask; - } + // Assert + terminal.ForegroundColor.ShouldBe(ConsoleColor.Red); - public static async Task Should_get_and_set_background_color() - { - // Arrange - using TestTerminal terminal = new(); + await Task.CompletedTask; + } - // Act - terminal.BackgroundColor = ConsoleColor.Blue; + public static async Task Should_get_and_set_background_color() + { + // Arrange + using TestTerminal terminal = new(); - // Assert - terminal.BackgroundColor.ShouldBe(ConsoleColor.Blue); + // Act + terminal.BackgroundColor = ConsoleColor.Blue; - await Task.CompletedTask; - } + // Assert + terminal.BackgroundColor.ShouldBe(ConsoleColor.Blue); - public static async Task Should_default_foreground_color_to_gray() - { - // Arrange & Act - using TestTerminal terminal = new(); + await Task.CompletedTask; + } - // Assert - terminal.ForegroundColor.ShouldBe(ConsoleColor.Gray); + public static async Task Should_default_foreground_color_to_gray() + { + // Arrange & Act + using TestTerminal terminal = new(); - await Task.CompletedTask; - } + // Assert + terminal.ForegroundColor.ShouldBe(ConsoleColor.Gray); - public static async Task Should_default_background_color_to_black() - { - // Arrange & Act - using TestTerminal terminal = new(); + await Task.CompletedTask; + } - // Assert - terminal.BackgroundColor.ShouldBe(ConsoleColor.Black); + public static async Task Should_default_background_color_to_black() + { + // Arrange & Act + using TestTerminal terminal = new(); - await Task.CompletedTask; - } + // Assert + terminal.BackgroundColor.ShouldBe(ConsoleColor.Black); - public static async Task Should_reset_color_reset_foreground_to_gray() - { - // Arrange - using TestTerminal terminal = new(); - terminal.ForegroundColor = ConsoleColor.Green; + await Task.CompletedTask; + } - // Act - terminal.ResetColor(); + public static async Task Should_reset_color_reset_foreground_to_gray() + { + // Arrange + using TestTerminal terminal = new(); + terminal.ForegroundColor = ConsoleColor.Green; - // Assert - terminal.ForegroundColor.ShouldBe(ConsoleColor.Gray); + // Act + terminal.ResetColor(); - await Task.CompletedTask; - } + // Assert + terminal.ForegroundColor.ShouldBe(ConsoleColor.Gray); - public static async Task Should_reset_color_reset_background_to_black() - { - // Arrange - using TestTerminal terminal = new(); - terminal.BackgroundColor = ConsoleColor.Yellow; + await Task.CompletedTask; + } - // Act - terminal.ResetColor(); + public static async Task Should_reset_color_reset_background_to_black() + { + // Arrange + using TestTerminal terminal = new(); + terminal.BackgroundColor = ConsoleColor.Yellow; - // Assert - terminal.BackgroundColor.ShouldBe(ConsoleColor.Black); + // Act + terminal.ResetColor(); - await Task.CompletedTask; - } + // Assert + terminal.BackgroundColor.ShouldBe(ConsoleColor.Black); - public static async Task Should_reset_color_reset_both_colors() - { - // Arrange - using TestTerminal terminal = new(); - terminal.ForegroundColor = ConsoleColor.Cyan; - terminal.BackgroundColor = ConsoleColor.Magenta; + await Task.CompletedTask; + } + + public static async Task Should_reset_color_reset_both_colors() + { + // Arrange + using TestTerminal terminal = new(); + terminal.ForegroundColor = ConsoleColor.Cyan; + terminal.BackgroundColor = ConsoleColor.Magenta; - // Act - terminal.ResetColor(); + // Act + terminal.ResetColor(); - // Assert - terminal.ForegroundColor.ShouldBe(ConsoleColor.Gray); - terminal.BackgroundColor.ShouldBe(ConsoleColor.Black); + // Assert + terminal.ForegroundColor.ShouldBe(ConsoleColor.Gray); + terminal.BackgroundColor.ShouldBe(ConsoleColor.Black); - await Task.CompletedTask; + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.TerminalColorState diff --git a/tests/terminal-control-utilities-01-basic.cs b/tests/terminal-control-utilities-01-basic.cs index 013736e..ca98fcf 100755 --- a/tests/terminal-control-utilities-01-basic.cs +++ b/tests/terminal-control-utilities-01-basic.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test control/utility APIs on ITerminal (Beep, TreatControlCAsInput, Title, KeyAvailable) @@ -10,199 +10,222 @@ namespace TimeWarp.Terminal.Tests.Core.TerminalControlUtilities { -[TestTag("Terminal")] -public class TerminalControlUtilityTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_beep_increment_count() + [TestTag("Terminal")] + public class TerminalControlUtilityTests { - // Arrange - using TestTerminal terminal = new(); + [ModuleInitializer] + internal static void Register() => RegisterTests(); - // Act - terminal.Beep(); + public static async Task Should_beep_increment_count() + { + // Arrange + using TestTerminal terminal = new(); - // Assert - terminal.BeepCount.ShouldBe(1); + // Act + terminal.Beep(); - await Task.CompletedTask; - } + // Assert + terminal.BeepCount.ShouldBe(1); - public static async Task Should_beep_multiple_times_increment_count() - { - // Arrange - using TestTerminal terminal = new(); + await Task.CompletedTask; + } - // Act - terminal.Beep(); - terminal.Beep(); - terminal.Beep(); + public static async Task Should_beep_multiple_times_increment_count() + { + // Arrange + using TestTerminal terminal = new(); - // Assert - terminal.BeepCount.ShouldBe(3); + // Act + terminal.Beep(); + terminal.Beep(); + terminal.Beep(); - await Task.CompletedTask; - } + // Assert + terminal.BeepCount.ShouldBe(3); - public static async Task Should_beep_with_parameters_capture_frequency_and_duration() - { - // Arrange - using TestTerminal terminal = new(); + await Task.CompletedTask; + } - // Act - terminal.Beep(800, 200); + public static async Task Should_beep_with_parameters_capture_frequency_and_duration() + { + // Arrange + using TestTerminal terminal = new(); - // Assert - terminal.LastBeepFrequency.ShouldBe(800); - terminal.LastBeepDuration.ShouldBe(200); - terminal.BeepCount.ShouldBe(1); + // Act + terminal.Beep(800, 200); - await Task.CompletedTask; - } + // Assert + terminal.LastBeepFrequency.ShouldBe(800); + terminal.LastBeepDuration.ShouldBe(200); + terminal.BeepCount.ShouldBe(1); - public static async Task Should_beep_with_parameters_overwrite_previous() - { - // Arrange - using TestTerminal terminal = new(); + await Task.CompletedTask; + } - // Act - terminal.Beep(500, 100); - terminal.Beep(1000, 300); + public static async Task Should_beep_with_parameters_overwrite_previous() + { + // Arrange + using TestTerminal terminal = new(); - // Assert - terminal.LastBeepFrequency.ShouldBe(1000); - terminal.LastBeepDuration.ShouldBe(300); - terminal.BeepCount.ShouldBe(2); + // Act + terminal.Beep(500, 100); + terminal.Beep(1000, 300); - await Task.CompletedTask; - } + // Assert + terminal.LastBeepFrequency.ShouldBe(1000); + terminal.LastBeepDuration.ShouldBe(300); + terminal.BeepCount.ShouldBe(2); - public static async Task Should_treat_control_c_as_input_default_false() - { - // Arrange & Act - using TestTerminal terminal = new(); + await Task.CompletedTask; + } - // Assert - terminal.TreatControlCAsInput.ShouldBeFalse(); + public static async Task Should_treat_control_c_as_input_default_false() + { + // Arrange & Act + using TestTerminal terminal = new(); - await Task.CompletedTask; - } + // Assert + terminal.TreatControlCAsInput.ShouldBeFalse(); - public static async Task Should_treat_control_c_as_input_get_and_set() - { - // Arrange - using TestTerminal terminal = new(); + await Task.CompletedTask; + } - // Act - terminal.TreatControlCAsInput = true; + public static async Task Should_treat_control_c_as_input_get_and_set() + { + // Arrange + using TestTerminal terminal = new(); - // Assert - terminal.TreatControlCAsInput.ShouldBeTrue(); + // Act + terminal.TreatControlCAsInput = true; - // Act - terminal.TreatControlCAsInput = false; + // Assert + terminal.TreatControlCAsInput.ShouldBeTrue(); - // Assert - terminal.TreatControlCAsInput.ShouldBeFalse(); + // Act + terminal.TreatControlCAsInput = false; - await Task.CompletedTask; - } + // Assert + terminal.TreatControlCAsInput.ShouldBeFalse(); - public static async Task Should_title_default_empty_string() - { - // Arrange & Act - using TestTerminal terminal = new(); + await Task.CompletedTask; + } - // Assert - terminal.Title.ShouldBe(string.Empty); + public static async Task Should_title_default_empty_string() + { + // Arrange & Act + using TestTerminal terminal = new(); - await Task.CompletedTask; - } + // Assert + terminal.Title.ShouldBe(string.Empty); - public static async Task Should_title_get_and_set() - { - // Arrange - using TestTerminal terminal = new(); + await Task.CompletedTask; + } - // Act - terminal.Title = "My App"; + public static async Task Should_title_get_and_set() + { + // Arrange + using TestTerminal terminal = new(); - // Assert - terminal.Title.ShouldBe("My App"); + // Act + terminal.Title = "My App"; - // Act - terminal.Title = "Different Title"; + // Assert + terminal.Title.ShouldBe("My App"); - // Assert - terminal.Title.ShouldBe("Different Title"); + // Act + terminal.Title = "Different Title"; - await Task.CompletedTask; - } + // Assert + terminal.Title.ShouldBe("Different Title"); - public static async Task Should_key_available_false_when_no_keys_queued() - { - // Arrange - using TestTerminal terminal = new(); + await Task.CompletedTask; + } - // Assert - terminal.KeyAvailable.ShouldBeFalse(); + public static async Task Should_key_available_false_when_no_keys_queued() + { + // Arrange + using TestTerminal terminal = new(); - await Task.CompletedTask; - } + // Assert + terminal.KeyAvailable.ShouldBeFalse(); - public static async Task Should_key_available_true_when_keys_queued() - { - // Arrange - using TestTerminal terminal = new(); - terminal.QueueKey(ConsoleKey.A); + await Task.CompletedTask; + } - // Assert - terminal.KeyAvailable.ShouldBeTrue(); + public static async Task Should_key_available_true_when_keys_queued() + { + // Arrange + using TestTerminal terminal = new(); + terminal.QueueKey(ConsoleKey.A); - await Task.CompletedTask; - } + // Assert + terminal.KeyAvailable.ShouldBeTrue(); - public static async Task Should_key_available_false_after_keys_dequeued() - { - // Arrange - using TestTerminal terminal = new(); - terminal.QueueKey(ConsoleKey.A); + await Task.CompletedTask; + } - // Act - terminal.ReadKey(); + public static async Task Should_key_available_false_after_keys_dequeued() + { + // Arrange + using TestTerminal terminal = new(); + terminal.QueueKey(ConsoleKey.A); - // Assert - terminal.KeyAvailable.ShouldBeFalse(); + // Act + terminal.ReadKey(); - await Task.CompletedTask; - } + // Assert + terminal.KeyAvailable.ShouldBeFalse(); - public static async Task Should_key_available_true_with_multiple_keys() - { - // Arrange - using TestTerminal terminal = new(); - terminal.QueueKeys("abc"); + await Task.CompletedTask; + } + + public static async Task Should_key_available_true_with_multiple_keys() + { + // Arrange + using TestTerminal terminal = new(); + terminal.QueueKeys("abc"); + + // Assert + terminal.KeyAvailable.ShouldBeTrue(); + + // Act - read one key + terminal.ReadKey(); + + // Assert - still true + terminal.KeyAvailable.ShouldBeTrue(); + + // Act - read remaining keys + terminal.ReadKey(); + terminal.ReadKey(); + + // Assert - now false + terminal.KeyAvailable.ShouldBeFalse(); + + await Task.CompletedTask; + } + + public static async Task Should_key_available_true_with_constructor_input() + { + // Arrange - no queued keys, but unread constructor input remains + using TestTerminal terminal = new("x"); - // Assert - terminal.KeyAvailable.ShouldBeTrue(); + // Assert - ReadKey would synthesize keys from the constructor input + terminal.KeyAvailable.ShouldBeTrue(); - // Act - read one key - terminal.ReadKey(); + // Act - consume 'x' (ReadKey also queues the synthesized Enter) + terminal.ReadKey(); - // Assert - still true - terminal.KeyAvailable.ShouldBeTrue(); + // Assert - Enter still queued + terminal.KeyAvailable.ShouldBeTrue(); - // Act - read remaining keys - terminal.ReadKey(); - terminal.ReadKey(); + // Act - consume Enter + terminal.ReadKey(); - // Assert - now false - terminal.KeyAvailable.ShouldBeFalse(); + // Assert - queue empty and constructor input exhausted + terminal.KeyAvailable.ShouldBeFalse(); - await Task.CompletedTask; + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.TerminalControlUtilities diff --git a/tests/terminal-cursor-properties.cs b/tests/terminal-cursor-properties.cs index 2033dc5..f8d8785 100755 --- a/tests/terminal-cursor-properties.cs +++ b/tests/terminal-cursor-properties.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test cursor properties on ITerminal @@ -10,194 +10,134 @@ namespace TimeWarp.Terminal.Tests.Core.TerminalCursorProperties { -[TestTag("Terminal")] -public class TerminalCursorPropertyTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_get_and_set_cursor_left() - { - // Arrange - using TestTerminal terminal = new(); - - // Act - terminal.CursorLeft = 5; - - // Assert - terminal.CursorLeft.ShouldBe(5); - - await Task.CompletedTask; - } - - public static async Task Should_get_and_set_cursor_top() - { - // Arrange - using TestTerminal terminal = new(); - - // Act - terminal.CursorTop = 10; - - // Assert - terminal.CursorTop.ShouldBe(10); - - await Task.CompletedTask; - } - - public static async Task Should_get_and_set_cursor_visible() - { - // Arrange - using TestTerminal terminal = new(); - - // Act - terminal.CursorVisible = false; - - // Assert - terminal.CursorVisible.ShouldBeFalse(); - - await Task.CompletedTask; - } - - public static async Task Should_default_cursor_visible_to_true() - { - // Arrange & Act - using TestTerminal terminal = new(); - - // Assert - terminal.CursorVisible.ShouldBeTrue(); - - await Task.CompletedTask; - } - - public static async Task Should_get_and_set_cursor_size() + [TestTag("Terminal")] + public class TerminalCursorPropertyTests { - // Arrange - using TestTerminal terminal = new(); + [ModuleInitializer] + internal static void Register() => RegisterTests(); - // Act - terminal.CursorSize = 50; + public static async Task Should_get_and_set_cursor_left() + { + // Arrange + using TestTerminal terminal = new(); - // Assert - terminal.CursorSize.ShouldBe(50); + // Act + terminal.CursorLeft = 5; - await Task.CompletedTask; - } + // Assert + terminal.CursorLeft.ShouldBe(5); - public static async Task Should_default_cursor_size_to_100() - { - // Arrange & Act - using TestTerminal terminal = new(); + await Task.CompletedTask; + } - // Assert - terminal.CursorSize.ShouldBe(100); + public static async Task Should_get_and_set_cursor_top() + { + // Arrange + using TestTerminal terminal = new(); - await Task.CompletedTask; - } + // Act + terminal.CursorTop = 10; - public static async Task Should_reject_cursor_size_below_1() - { - // Arrange - using TestTerminal terminal = new(); + // Assert + terminal.CursorTop.ShouldBe(10); - // Act & Assert - Should.Throw(() => terminal.CursorSize = 0); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_get_and_set_cursor_visible() + { + // Arrange + using TestTerminal terminal = new(); - public static async Task Should_reject_cursor_size_above_100() - { - // Arrange - using TestTerminal terminal = new(); + // Act + terminal.CursorVisible = false; - // Act & Assert - Should.Throw(() => terminal.CursorSize = 101); + // Assert + terminal.CursorVisible.ShouldBeFalse(); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_accept_cursor_size_at_minimum_1() - { - // Arrange - using TestTerminal terminal = new(); + public static async Task Should_default_cursor_visible_to_true() + { + // Arrange & Act + using TestTerminal terminal = new(); - // Act - terminal.CursorSize = 1; + // Assert + terminal.CursorVisible.ShouldBeTrue(); - // Assert - terminal.CursorSize.ShouldBe(1); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_set_cursor_position_still_works() + { + // Arrange + using TestTerminal terminal = new(); - public static async Task Should_accept_cursor_size_at_maximum_100() - { - // Arrange - using TestTerminal terminal = new(); + // Act + terminal.SetCursorPosition(15, 25); - // Act - terminal.CursorSize = 100; + // Assert + terminal.CursorLeft.ShouldBe(15); + terminal.CursorTop.ShouldBe(25); - // Assert - terminal.CursorSize.ShouldBe(100); - - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_set_cursor_position_still_works() - { - // Arrange - using TestTerminal terminal = new(); + public static async Task Should_get_cursor_position_still_works() + { + // Arrange + using TestTerminal terminal = new(); + terminal.SetCursorPosition(20, 30); - // Act - terminal.SetCursorPosition(15, 25); + // Act + (int left, int top) = terminal.GetCursorPosition(); - // Assert - terminal.CursorLeft.ShouldBe(15); - terminal.CursorTop.ShouldBe(25); + // Assert + left.ShouldBe(20); + top.ShouldBe(30); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_get_cursor_position_still_works() - { - // Arrange - using TestTerminal terminal = new(); - terminal.SetCursorPosition(20, 30); + public static async Task Should_cursor_properties_sync_with_position_methods() + { + // Arrange + using TestTerminal terminal = new(); - // Act - (int left, int top) = terminal.GetCursorPosition(); + // Act - Set via property, get via method + terminal.CursorLeft = 12; + terminal.CursorTop = 8; + (int left, int top) = terminal.GetCursorPosition(); - // Assert - left.ShouldBe(20); - top.ShouldBe(30); + // Assert + left.ShouldBe(12); + top.ShouldBe(8); - await Task.CompletedTask; - } + // Act - Set via method, get via property + terminal.SetCursorPosition(5, 3); - public static async Task Should_cursor_properties_sync_with_position_methods() - { - // Arrange - using TestTerminal terminal = new(); + // Assert + terminal.CursorLeft.ShouldBe(5); + terminal.CursorTop.ShouldBe(3); - // Act - Set via property, get via method - terminal.CursorLeft = 12; - terminal.CursorTop = 8; - (int left, int top) = terminal.GetCursorPosition(); + await Task.CompletedTask; + } - // Assert - left.ShouldBe(12); - top.ShouldBe(8); + public static async Task Should_get_cursor_position_atomically_from_real_terminal() + { + // Arrange - TimeWarpTerminal uses the atomic Console.GetCursorPosition() + TimeWarpTerminal terminal = TimeWarpTerminal.Default; - // Act - Set via method, get via property - terminal.SetCursorPosition(5, 3); + // Act - must not throw, even when the console is redirected (falls back to (0, 0)) + (int left, int top) = Should.NotThrow(terminal.GetCursorPosition); - // Assert - terminal.CursorLeft.ShouldBe(5); - terminal.CursorTop.ShouldBe(3); + // Assert + left.ShouldBeGreaterThanOrEqualTo(0); + top.ShouldBeGreaterThanOrEqualTo(0); - await Task.CompletedTask; + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.TerminalCursorProperties diff --git a/tests/terminal-static-01-basic.cs b/tests/terminal-static-01-basic.cs index 1f89667..613c70d 100755 --- a/tests/terminal-static-01-basic.cs +++ b/tests/terminal-static-01-basic.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test Terminal static facade class @@ -12,221 +12,221 @@ namespace TimeWarp.Terminal.Tests.Core.TerminalStatic { -[TestTag("Terminal")] -public class TerminalStaticBasicTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_default_instance_to_timewarp_terminal() + [TestTag("Terminal")] + public class TerminalStaticBasicTests { - // Arrange & Act - ITerminal instance = Terminal.Instance; - - // Assert - instance.ShouldNotBeNull(); - instance.ShouldBeOfType(); - - await Task.CompletedTask; - } + [ModuleInitializer] + internal static void Register() => RegisterTests(); - public static async Task Should_allow_setting_custom_instance() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - - try + public static async Task Should_default_instance_to_timewarp_terminal() { - // Act - Terminal.Instance = testTerminal; + // Arrange & Act + ITerminal instance = Terminal.Instance; // Assert - Terminal.Instance.ShouldBe(testTerminal); + instance.ShouldNotBeNull(); + instance.ShouldBeOfType(); + + await Task.CompletedTask; } - finally + + public static async Task Should_allow_setting_custom_instance() { - // Restore - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); - await Task.CompletedTask; - } + try + { + // Act + Terminal.Instance = testTerminal; - public static async Task Should_throw_when_setting_null_instance() - { - // Arrange - ITerminal original = Terminal.Instance; + // Assert + Terminal.Instance.ShouldBe(testTerminal); + } + finally + { + // Restore + Terminal.Instance = original; + } - try - { - // Act & Assert - Should.Throw(() => Terminal.Instance = null!); + await Task.CompletedTask; } - finally + + public static async Task Should_throw_when_setting_null_instance() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; - await Task.CompletedTask; - } + try + { + // Act & Assert + Should.Throw(() => Terminal.Instance = null!); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_write_to_instance() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + await Task.CompletedTask; + } - try + public static async Task Should_write_to_instance() { - // Act - Terminal.Write("Hello"); + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - // Assert - testTerminal.Output.ShouldBe("Hello"); - } - finally - { - Terminal.Instance = original; - } + try + { + // Act + Terminal.Write("Hello"); - await Task.CompletedTask; - } + // Assert + testTerminal.Output.ShouldBe("Hello"); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_write_empty_string_for_null() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + await Task.CompletedTask; + } - try + public static async Task Should_write_empty_string_for_null() { - // Act - Terminal.Write(null); + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - // Assert - testTerminal.Output.ShouldBe(string.Empty); - } - finally - { - Terminal.Instance = original; - } + try + { + // Act + Terminal.Write(null); - await Task.CompletedTask; - } + // Assert + testTerminal.Output.ShouldBe(string.Empty); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_writeline_to_instance() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + await Task.CompletedTask; + } - try + public static async Task Should_writeline_to_instance() { - // Act - Terminal.WriteLine("Hello"); + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - // Assert - testTerminal.Output.ShouldBe("Hello" + Environment.NewLine); - } - finally - { - Terminal.Instance = original; - } + try + { + // Act + Terminal.WriteLine("Hello"); - await Task.CompletedTask; - } + // Assert + testTerminal.Output.ShouldBe("Hello" + Environment.NewLine); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_writeline_with_no_args() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + await Task.CompletedTask; + } - try + public static async Task Should_writeline_with_no_args() { - // Act - Terminal.WriteLine(); + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - // Assert - testTerminal.Output.ShouldBe(Environment.NewLine); - } - finally - { - Terminal.Instance = original; - } + try + { + // Act + Terminal.WriteLine(); - await Task.CompletedTask; - } + // Assert + testTerminal.Output.ShouldBe(Environment.NewLine); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_writeline_async() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + await Task.CompletedTask; + } - try + public static async Task Should_writeline_async() { - // Act - await Terminal.WriteLineAsync("Async"); + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - // Assert - testTerminal.Output.ShouldBe("Async" + Environment.NewLine); - } - finally - { - Terminal.Instance = original; - } - } + try + { + // Act + await Terminal.WriteLineAsync("Async"); - public static async Task Should_write_error_line() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + // Assert + testTerminal.Output.ShouldBe("Async" + Environment.NewLine); + } + finally + { + Terminal.Instance = original; + } + } - try + public static async Task Should_write_error_line() { - // Act - Terminal.WriteErrorLine("Error"); + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - // Assert - testTerminal.ErrorOutput.ShouldBe("Error" + Environment.NewLine); - } - finally - { - Terminal.Instance = original; - } + try + { + // Act + Terminal.WriteErrorLine("Error"); - await Task.CompletedTask; - } + // Assert + testTerminal.ErrorOutput.ShouldBe("Error" + Environment.NewLine); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_write_error_line_async() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + await Task.CompletedTask; + } - try + public static async Task Should_write_error_line_async() { - // Act - await Terminal.WriteErrorLineAsync("AsyncError"); + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - // Assert - testTerminal.ErrorOutput.ShouldBe("AsyncError" + Environment.NewLine); - } - finally - { - Terminal.Instance = original; + try + { + // Act + await Terminal.WriteErrorLineAsync("AsyncError"); + + // Assert + testTerminal.ErrorOutput.ShouldBe("AsyncError" + Environment.NewLine); + } + finally + { + Terminal.Instance = original; + } } } -} } // namespace TimeWarp.Terminal.Tests.Core.TerminalStatic diff --git a/tests/terminal-static-02-properties.cs b/tests/terminal-static-02-properties.cs index 6e5c55e..c7dd5ee 100755 --- a/tests/terminal-static-02-properties.cs +++ b/tests/terminal-static-02-properties.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test Terminal static facade class - Properties @@ -10,143 +10,217 @@ namespace TimeWarp.Terminal.Tests.Core.TerminalStaticProperties { -[TestTag("Terminal")] -public class TerminalStaticPropertyTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_get_window_width_from_instance() + [TestTag("Terminal")] + public class TerminalStaticPropertyTests { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { WindowWidth = 120 }; - Terminal.Instance = testTerminal; + [ModuleInitializer] + internal static void Register() => RegisterTests(); - try + public static async Task Should_get_window_width_from_instance() { - // Act - int width = Terminal.WindowWidth; - - // Assert - width.ShouldBe(120); + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { WindowWidth = 120 }; + Terminal.Instance = testTerminal; + + try + { + // Act + int width = Terminal.WindowWidth; + + // Assert + width.ShouldBe(120); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - finally + + public static async Task Should_get_is_interactive_from_instance() { - Terminal.Instance = original; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { IsInteractive = true }; + Terminal.Instance = testTerminal; + + try + { + // Act + bool interactive = Terminal.IsInteractive; + + // Assert + interactive.ShouldBeTrue(); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - await Task.CompletedTask; - } - - public static async Task Should_get_is_interactive_from_instance() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { IsInteractive = true }; - Terminal.Instance = testTerminal; - - try + public static async Task Should_get_supports_color_from_instance() { - // Act - bool interactive = Terminal.IsInteractive; - - // Assert - interactive.ShouldBeTrue(); + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { SupportsColor = true }; + Terminal.Instance = testTerminal; + + try + { + // Act + bool supportsColor = Terminal.SupportsColor; + + // Assert + supportsColor.ShouldBeTrue(); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - finally + + public static async Task Should_get_supports_hyperlinks_from_instance() { - Terminal.Instance = original; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { SupportsHyperlinks = true }; + Terminal.Instance = testTerminal; + + try + { + // Act + bool supportsHyperlinks = Terminal.SupportsHyperlinks; + + // Assert + supportsHyperlinks.ShouldBeTrue(); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - await Task.CompletedTask; - } - - public static async Task Should_get_supports_color_from_instance() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { SupportsColor = true }; - Terminal.Instance = testTerminal; - - try + public static async Task Should_default_supports_color_to_true_in_test_terminal() { - // Act - bool supportsColor = Terminal.SupportsColor; - - // Assert - supportsColor.ShouldBeTrue(); + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; + + try + { + // Act & Assert - TestTerminal defaults to SupportsColor = true + Terminal.SupportsColor.ShouldBeTrue(); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - finally + + public static async Task Should_default_supports_hyperlinks_to_false_in_test_terminal() { - Terminal.Instance = original; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; + + try + { + // Act & Assert + Terminal.SupportsHyperlinks.ShouldBeFalse(); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - await Task.CompletedTask; - } - - public static async Task Should_get_supports_hyperlinks_from_instance() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { SupportsHyperlinks = true }; - Terminal.Instance = testTerminal; - - try + public static async Task Should_require_both_stdin_and_stdout_for_is_interactive() { + // Arrange + TimeWarpTerminal terminal = TimeWarpTerminal.Default; + // Act - bool supportsHyperlinks = Terminal.SupportsHyperlinks; + bool interactive = terminal.IsInteractive; - // Assert - supportsHyperlinks.ShouldBeTrue(); + // Assert - interactive requires BOTH stdin and stdout attached to a terminal + // (e.g., `app | tee` redirects stdout only and must not report interactive) + bool expected = !Console.IsInputRedirected && !Console.IsOutputRedirected; + interactive.ShouldBe(expected); + + await Task.CompletedTask; } - finally + + public static async Task Should_disable_color_when_no_color_is_non_empty() { - Terminal.Instance = original; + // Arrange + string? original = Environment.GetEnvironmentVariable("NO_COLOR"); + + try + { + Environment.SetEnvironmentVariable("NO_COLOR", "1"); + + // Act & Assert - a non-empty NO_COLOR disables color regardless of terminal state + TimeWarpTerminal.Default.SupportsColor.ShouldBeFalse(); + } + finally + { + Environment.SetEnvironmentVariable("NO_COLOR", original); + } + + await Task.CompletedTask; } - await Task.CompletedTask; - } - - public static async Task Should_default_supports_color_to_true_in_test_terminal() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; - - try + public static async Task Should_disable_color_when_term_is_dumb() { - // Act & Assert - TestTerminal defaults to SupportsColor = true - Terminal.SupportsColor.ShouldBeTrue(); + // Arrange + string? original = Environment.GetEnvironmentVariable("TERM"); + + try + { + Environment.SetEnvironmentVariable("TERM", "dumb"); + + // Act & Assert + TimeWarpTerminal.Default.SupportsColor.ShouldBeFalse(); + } + finally + { + Environment.SetEnvironmentVariable("TERM", original); + } + + await Task.CompletedTask; } - finally + + public static async Task Should_not_throw_for_treat_control_c_as_input_without_console() { - Terminal.Instance = original; - } + // Arrange + TimeWarpTerminal terminal = TimeWarpTerminal.Default; - await Task.CompletedTask; - } + // Act & Assert - getter and setter must follow the swallow-and-default policy + bool value = Should.NotThrow(() => terminal.TreatControlCAsInput); + Should.NotThrow(() => terminal.TreatControlCAsInput = value); - public static async Task Should_default_supports_hyperlinks_to_false_in_test_terminal() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + if (Console.IsInputRedirected) + { + // With no console attached the getter defaults to false + value.ShouldBeFalse(); + } - try - { - // Act & Assert - Terminal.SupportsHyperlinks.ShouldBeFalse(); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - - await Task.CompletedTask; } -} } // namespace TimeWarp.Terminal.Tests.Core.TerminalStaticProperties diff --git a/tests/terminal-static-03-operations.cs b/tests/terminal-static-03-operations.cs index 83f78b0..c912593 100755 --- a/tests/terminal-static-03-operations.cs +++ b/tests/terminal-static-03-operations.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test Terminal static facade class - Operations @@ -13,180 +13,180 @@ namespace TimeWarp.Terminal.Tests.Core.TerminalStaticOperations { -[TestTag("Terminal")] -public class TerminalStaticOperationTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_clear_terminal() + [TestTag("Terminal")] + public class TerminalStaticOperationTests { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; - Terminal.WriteLine("Some content"); - - try - { - // Act - Terminal.Clear(); + [ModuleInitializer] + internal static void Register() => RegisterTests(); - // Assert - TestTerminal writes [CLEAR] marker - testTerminal.Output.ShouldContain("[CLEAR]"); - } - finally + public static async Task Should_clear_terminal() { - Terminal.Instance = original; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; + Terminal.WriteLine("Some content"); + + try + { + // Act + Terminal.Clear(); + + // Assert - TestTerminal writes [CLEAR] marker + testTerminal.Output.ShouldContain("[CLEAR]"); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - await Task.CompletedTask; - } - - public static async Task Should_set_cursor_position() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; - - try - { - // Act - Terminal.SetCursorPosition(10, 5); - - // Assert - var position = testTerminal.GetCursorPosition(); - position.Left.ShouldBe(10); - position.Top.ShouldBe(5); - } - finally + public static async Task Should_set_cursor_position() { - Terminal.Instance = original; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; + + try + { + // Act + Terminal.SetCursorPosition(10, 5); + + // Assert + (int left, int top) = testTerminal.GetCursorPosition(); + left.ShouldBe(10); + top.ShouldBe(5); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - await Task.CompletedTask; - } - - public static async Task Should_get_cursor_position() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; - testTerminal.SetCursorPosition(15, 20); - - try - { - // Act - var position = Terminal.GetCursorPosition(); - - // Assert - position.Left.ShouldBe(15); - position.Top.ShouldBe(20); - } - finally + public static async Task Should_get_cursor_position() { - Terminal.Instance = original; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; + testTerminal.SetCursorPosition(15, 20); + + try + { + // Act + (int left, int top) = Terminal.GetCursorPosition(); + + // Assert + left.ShouldBe(15); + top.ShouldBe(20); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - await Task.CompletedTask; - } - - public static async Task Should_read_line_from_instance() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new("user input"); - Terminal.Instance = testTerminal; - - try - { - // Act - string? result = Terminal.ReadLine(); - - // Assert - result.ShouldBe("user input"); - } - finally + public static async Task Should_read_line_from_instance() { - Terminal.Instance = original; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new("user input"); + Terminal.Instance = testTerminal; + + try + { + // Act + string? result = Terminal.ReadLine(); + + // Assert + result.ShouldBe("user input"); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - await Task.CompletedTask; - } - - public static async Task Should_read_key_from_instance() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - testTerminal.QueueKeyInfo(new ConsoleKeyInfo('A', ConsoleKey.A, false, false, false)); - Terminal.Instance = testTerminal; - - try + public static async Task Should_read_key_from_instance() { - // Act - ConsoleKeyInfo key = Terminal.ReadKey(); - - // Assert - key.Key.ShouldBe(ConsoleKey.A); - key.KeyChar.ShouldBe('A'); - } - finally - { - Terminal.Instance = original; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + testTerminal.QueueKeyInfo(new ConsoleKeyInfo('A', ConsoleKey.A, false, false, false)); + Terminal.Instance = testTerminal; + + try + { + // Act + ConsoleKeyInfo key = Terminal.ReadKey(); + + // Assert + key.Key.ShouldBe(ConsoleKey.A); + key.KeyChar.ShouldBe('A'); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - await Task.CompletedTask; - } - - public static async Task Should_read_key_with_intercept() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - testTerminal.QueueKeyInfo(new ConsoleKeyInfo('B', ConsoleKey.B, false, false, false)); - Terminal.Instance = testTerminal; - - try - { - // Act - ConsoleKeyInfo key = Terminal.ReadKey(intercept: true); - - // Assert - key.Key.ShouldBe(ConsoleKey.B); - } - finally + public static async Task Should_read_key_with_intercept() { - Terminal.Instance = original; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + testTerminal.QueueKeyInfo(new ConsoleKeyInfo('B', ConsoleKey.B, false, false, false)); + Terminal.Instance = testTerminal; + + try + { + // Act + ConsoleKeyInfo key = Terminal.ReadKey(intercept: true); + + // Assert + key.Key.ShouldBe(ConsoleKey.B); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - await Task.CompletedTask; - } - - public static async Task Should_return_null_when_readline_has_no_input() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); // Empty input - Terminal.Instance = testTerminal; - - try - { - // Act - string? result = Terminal.ReadLine(); - - // Assert - result.ShouldBeNull(); - } - finally + public static async Task Should_return_null_when_readline_has_no_input() { - Terminal.Instance = original; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); // Empty input + Terminal.Instance = testTerminal; + + try + { + // Act + string? result = Terminal.ReadLine(); + + // Assert + result.ShouldBeNull(); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - - await Task.CompletedTask; } -} } // namespace TimeWarp.Terminal.Tests.Core.TerminalStaticOperations diff --git a/tests/terminal-static-04-format.cs b/tests/terminal-static-04-format.cs index c9e96db..8af9523 100755 --- a/tests/terminal-static-04-format.cs +++ b/tests/terminal-static-04-format.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test Terminal format method overloads @@ -12,389 +12,442 @@ namespace TimeWarp.Terminal.Tests.Core.TerminalStatic { -[TestTag("Terminal")] -public class TerminalStaticFormatTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - // ========== Write Format Tests ========== - - public static async Task Should_write_format_single_arg() + [TestTag("Terminal")] + public class TerminalStaticFormatTests { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + [ModuleInitializer] + internal static void Register() => RegisterTests(); - try - { - // Act - Terminal.Write("Hello {0}", "World"); + // ========== Write Format Tests ========== - // Assert - testTerminal.Output.ShouldBe("Hello World"); - } - finally + public static async Task Should_write_format_single_arg() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } + try + { + // Act + Terminal.Write("Hello {0}", "World"); - public static async Task Should_write_format_two_args() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; - - try - { - // Act - Terminal.Write("{0} {1}", "Hello", "World"); + // Assert + testTerminal.Output.ShouldBe("Hello World"); + } + finally + { + Terminal.Instance = original; + } - // Assert - testTerminal.Output.ShouldBe("Hello World"); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_write_format_two_args() + { + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - public static async Task Should_write_format_three_args() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + try + { + // Act + Terminal.Write("{0} {1}", "Hello", "World"); - try - { - // Act - Terminal.Write("{0} {1} {2}", "a", "b", "c"); + // Assert + testTerminal.Output.ShouldBe("Hello World"); + } + finally + { + Terminal.Instance = original; + } - // Assert - testTerminal.Output.ShouldBe("a b c"); + await Task.CompletedTask; } - finally + + public static async Task Should_write_format_three_args() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } + try + { + // Act + Terminal.Write("{0} {1} {2}", "a", "b", "c"); - public static async Task Should_write_format_params() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + // Assert + testTerminal.Output.ShouldBe("a b c"); + } + finally + { + Terminal.Instance = original; + } - try - { - // Act - Terminal.Write("{0}{1}{2}", "x", "y", "z"); - - // Assert - testTerminal.Output.ShouldBe("xyz"); + await Task.CompletedTask; } - finally + + public static async Task Should_write_format_params() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } + try + { + // Act + Terminal.Write("{0}{1}{2}", "x", "y", "z"); - // ========== WriteLine Format Tests ========== + // Assert + testTerminal.Output.ShouldBe("xyz"); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_writeline_format_single_arg() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + await Task.CompletedTask; + } - try - { - // Act - Terminal.WriteLine("Hello {0}", "World"); + // ========== WriteLine Format Tests ========== - // Assert - testTerminal.Output.ShouldBe("Hello World" + Environment.NewLine); - } - finally + public static async Task Should_writeline_format_single_arg() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } + try + { + // Act + Terminal.WriteLine("Hello {0}", "World"); - public static async Task Should_writeline_format_two_args() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + // Assert + testTerminal.Output.ShouldBe("Hello World" + Environment.NewLine); + } + finally + { + Terminal.Instance = original; + } - try - { - // Act - Terminal.WriteLine("{0} {1}", "Hello", "World"); - - // Assert - testTerminal.Output.ShouldBe("Hello World" + Environment.NewLine); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_writeline_format_two_args() + { + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - public static async Task Should_writeline_format_three_args() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + try + { + // Act + Terminal.WriteLine("{0} {1}", "Hello", "World"); - try - { - // Act - Terminal.WriteLine("{0} {1} {2}", "a", "b", "c"); + // Assert + testTerminal.Output.ShouldBe("Hello World" + Environment.NewLine); + } + finally + { + Terminal.Instance = original; + } - // Assert - testTerminal.Output.ShouldBe("a b c" + Environment.NewLine); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_writeline_format_three_args() + { + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - public static async Task Should_writeline_format_params() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + try + { + // Act + Terminal.WriteLine("{0} {1} {2}", "a", "b", "c"); - try - { - // Act - Terminal.WriteLine("{0}{1}{2}", "x", "y", "z"); + // Assert + testTerminal.Output.ShouldBe("a b c" + Environment.NewLine); + } + finally + { + Terminal.Instance = original; + } - // Assert - testTerminal.Output.ShouldBe("xyz" + Environment.NewLine); + await Task.CompletedTask; } - finally + + public static async Task Should_writeline_format_params() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } + try + { + // Act + Terminal.WriteLine("{0}{1}{2}", "x", "y", "z"); - // ========== WriteErrorLine Format Tests ========== + // Assert + testTerminal.Output.ShouldBe("xyz" + Environment.NewLine); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_write_error_format_single_arg() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + await Task.CompletedTask; + } - try - { - // Act - Terminal.WriteErrorLine("Error: {0}", "404"); + // ========== WriteErrorLine Format Tests ========== - // Assert - testTerminal.ErrorOutput.ShouldBe("Error: 404" + Environment.NewLine); - } - finally + public static async Task Should_write_error_format_single_arg() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } + try + { + // Act + Terminal.WriteErrorLine("Error: {0}", "404"); - public static async Task Should_write_error_format_two_args() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + // Assert + testTerminal.ErrorOutput.ShouldBe("Error: 404" + Environment.NewLine); + } + finally + { + Terminal.Instance = original; + } - try - { - // Act - Terminal.WriteErrorLine("{0}: {1}", "Error", "fail"); - - // Assert - testTerminal.ErrorOutput.ShouldBe("Error: fail" + Environment.NewLine); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_write_error_format_two_args() + { + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - public static async Task Should_write_error_format_three_args() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + try + { + // Act + Terminal.WriteErrorLine("{0}: {1}", "Error", "fail"); - try - { - // Act - Terminal.WriteErrorLine("{0} {1} {2}", "a", "b", "c"); + // Assert + testTerminal.ErrorOutput.ShouldBe("Error: fail" + Environment.NewLine); + } + finally + { + Terminal.Instance = original; + } - // Assert - testTerminal.ErrorOutput.ShouldBe("a b c" + Environment.NewLine); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_write_error_format_three_args() + { + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - public static async Task Should_write_error_format_params() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + try + { + // Act + Terminal.WriteErrorLine("{0} {1} {2}", "a", "b", "c"); - try - { - // Act - Terminal.WriteErrorLine("{0}{1}{2}", "x", "y", "z"); + // Assert + testTerminal.ErrorOutput.ShouldBe("a b c" + Environment.NewLine); + } + finally + { + Terminal.Instance = original; + } - // Assert - testTerminal.ErrorOutput.ShouldBe("xyz" + Environment.NewLine); + await Task.CompletedTask; } - finally + + public static async Task Should_write_error_format_params() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } + try + { + // Act + Terminal.WriteErrorLine("{0}{1}{2}", "x", "y", "z"); - // ========== Numeric Formatting Tests ========== + // Assert + testTerminal.ErrorOutput.ShouldBe("xyz" + Environment.NewLine); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_write_format_with_numeric() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + await Task.CompletedTask; + } - try - { - // Act - Terminal.Write("Number: {0:D4}", 42); + // ========== Numeric Formatting Tests ========== - // Assert - testTerminal.Output.ShouldBe("Number: 0042"); - } - finally + public static async Task Should_write_format_with_numeric() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } - - public static async Task Should_writeline_format_with_numeric() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + try + { + // Act + Terminal.Write("Number: {0:D4}", 42); - try - { - // Act - Use F2 format (fixed-point) instead of C (currency) since Terminal uses InvariantCulture - Terminal.WriteLine("Value: {0:F2}", 99.99); + // Assert + testTerminal.Output.ShouldBe("Number: 0042"); + } + finally + { + Terminal.Instance = original; + } - // Assert - testTerminal.Output.ShouldBe("Value: 99.99" + Environment.NewLine); + await Task.CompletedTask; } - finally + + public static async Task Should_writeline_format_with_numeric() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } + try + { + // Act - Use F2 format (fixed-point) instead of C (currency) since Terminal uses InvariantCulture + Terminal.WriteLine("Value: {0:F2}", 99.99); - // ========== Null Argument Tests ========== + // Assert + testTerminal.Output.ShouldBe("Value: 99.99" + Environment.NewLine); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_write_format_null_arg() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + await Task.CompletedTask; + } + + // ========== Null Argument Tests ========== - try + public static async Task Should_write_format_null_arg() { - // Act - Terminal.Write("Value: {0}", (object?)null); + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - // Assert - testTerminal.Output.ShouldBe("Value: "); + try + { + // Act + Terminal.Write("Value: {0}", (object?)null); + + // Assert + testTerminal.Output.ShouldBe("Value: "); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - finally + + public static async Task Should_write_error_format_null_arg() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } + try + { + // Act + Terminal.WriteErrorLine("Error: {0}", (object?)null); - public static async Task Should_write_error_format_null_arg() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + // Assert + testTerminal.ErrorOutput.ShouldBe("Error: " + Environment.NewLine); + } + finally + { + Terminal.Instance = original; + } - try + await Task.CompletedTask; + } + + // ========== FormatProvider Tests ========== + + public static async Task Should_format_with_custom_format_provider() { - // Act - Terminal.WriteErrorLine("Error: {0}", (object?)null); + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; + + try + { + // Act - a provider with a comma decimal separator, independent of OS cultures + System.Globalization.NumberFormatInfo commaProvider = new() { NumberDecimalSeparator = "," }; + Terminal.FormatProvider = commaProvider; + Terminal.Write("Value: {0:0.0}", 1.5); - // Assert - testTerminal.ErrorOutput.ShouldBe("Error: " + Environment.NewLine); + // Assert + testTerminal.Output.ShouldBe("Value: 1,5"); + } + finally + { + Terminal.FormatProvider = null; + Terminal.Instance = original; + } + + await Task.CompletedTask; } - finally + + public static async Task Should_format_with_current_culture_by_default() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - await Task.CompletedTask; + try + { + // Act - FormatProvider defaults to null = CurrentCulture resolved per call + Terminal.FormatProvider.ShouldBeNull(); + Terminal.Write("Value: {0:0.0}", 1.5); + + // Assert + string expected = string.Format(System.Globalization.CultureInfo.CurrentCulture, "Value: {0:0.0}", 1.5); + testTerminal.Output.ShouldBe(expected); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.TerminalStatic diff --git a/tests/terminal-static-05-widgets.cs b/tests/terminal-static-05-widgets.cs index 17bdc2c..62598f3 100755 --- a/tests/terminal-static-05-widgets.cs +++ b/tests/terminal-static-05-widgets.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test Terminal static facade widget methods @@ -12,376 +12,405 @@ namespace TimeWarp.Terminal.Tests.Core.TerminalStaticWidget { -[TestTag("Terminal")] -public class TerminalStaticWidgetTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - // ========== Table Tests ========== - - public static async Task Should_write_table_with_builder() + [TestTag("Terminal")] + public class TerminalStaticWidgetTests { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { WindowWidth = 40 }; - Terminal.Instance = testTerminal; - - try - { - // Act - Terminal.WriteTable(table => table - .AddColumn("Name") - .AddColumn("Value") - .AddRow("Foo", "123")); - - // Assert - testTerminal.Output.ShouldContain("┌"); - testTerminal.Output.ShouldContain("Foo"); - testTerminal.Output.ShouldContain("123"); - } - finally - { - Terminal.Instance = original; - } - - await Task.CompletedTask; - } + [ModuleInitializer] + internal static void Register() => RegisterTests(); - public static async Task Should_write_preconfigured_table() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { WindowWidth = 40 }; - Terminal.Instance = testTerminal; + // ========== Table Tests ========== - try - { - Table table = new TableBuilder() - .AddColumn("Name") - .AddColumn("Value") - .AddRow("Foo", "123") - .Build(); - - // Act - Terminal.WriteTable(table); - - // Assert - testTerminal.Output.ShouldContain("┌"); - testTerminal.Output.ShouldContain("Foo"); - testTerminal.Output.ShouldContain("123"); - } - finally + public static async Task Should_write_table_with_builder() { - Terminal.Instance = original; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { WindowWidth = 40 }; + Terminal.Instance = testTerminal; + + try + { + // Act + Terminal.WriteTable(table => table + .AddColumn("Name") + .AddColumn("Value") + .AddRow("Foo", "123")); + + // Assert + testTerminal.Output.ShouldContain("┌"); + testTerminal.Output.ShouldContain("Foo"); + testTerminal.Output.ShouldContain("123"); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - await Task.CompletedTask; - } - - // ========== Panel Tests ========== - - public static async Task Should_write_panel_with_builder() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { WindowWidth = 40 }; - Terminal.Instance = testTerminal; - - try + public static async Task Should_write_preconfigured_table() { - // Act - Terminal.WritePanel(panel => panel - .Header("Configuration") - .Content("Setting: value")); - - // Assert - testTerminal.Output.ShouldContain("Configuration"); - testTerminal.Output.ShouldContain("Setting: value"); - testTerminal.Output.ShouldContain("╭"); + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { WindowWidth = 40 }; + Terminal.Instance = testTerminal; + + try + { + Table table = new TableBuilder() + .AddColumn("Name") + .AddColumn("Value") + .AddRow("Foo", "123") + .Build(); + + // Act + Terminal.WriteTable(table); + + // Assert + testTerminal.Output.ShouldContain("┌"); + testTerminal.Output.ShouldContain("Foo"); + testTerminal.Output.ShouldContain("123"); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - finally - { - Terminal.Instance = original; - } - - await Task.CompletedTask; - } - - public static async Task Should_write_panel_with_content_and_header() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { WindowWidth = 40 }; - Terminal.Instance = testTerminal; - try - { - // Act - Terminal.WritePanel("Hello World", header: "Greeting"); + // ========== Panel Tests ========== - // Assert - testTerminal.Output.ShouldContain("Greeting"); - testTerminal.Output.ShouldContain("Hello World"); - } - finally + public static async Task Should_write_panel_with_builder() { - Terminal.Instance = original; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { WindowWidth = 40 }; + Terminal.Instance = testTerminal; + + try + { + // Act + Terminal.WritePanel(panel => panel + .Header("Configuration") + .Content("Setting: value")); + + // Assert + testTerminal.Output.ShouldContain("Configuration"); + testTerminal.Output.ShouldContain("Setting: value"); + testTerminal.Output.ShouldContain("╭"); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - await Task.CompletedTask; - } - - public static async Task Should_write_panel_with_content_only() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { WindowWidth = 40 }; - Terminal.Instance = testTerminal; - - try + public static async Task Should_write_panel_with_content_and_header() { - // Act - Terminal.WritePanel("Simple content", header: null); - - // Assert - testTerminal.Output.ShouldContain("Simple content"); - } - finally - { - Terminal.Instance = original; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { WindowWidth = 40 }; + Terminal.Instance = testTerminal; + + try + { + // Act + Terminal.WritePanel("Hello World", header: "Greeting"); + + // Assert + testTerminal.Output.ShouldContain("Greeting"); + testTerminal.Output.ShouldContain("Hello World"); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - await Task.CompletedTask; - } - - // ========== Rule Tests ========== - - public static async Task Should_write_rule_with_title() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { WindowWidth = 40 }; - Terminal.Instance = testTerminal; - - try - { - // Act - Terminal.WriteRule("Section Title"); - - // Assert - testTerminal.Output.ShouldContain("Section Title"); - testTerminal.Output.ShouldContain("─"); - } - finally + public static async Task Should_write_panel_with_content_only() { - Terminal.Instance = original; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { WindowWidth = 40 }; + Terminal.Instance = testTerminal; + + try + { + // Act + Terminal.WritePanel("Simple content", header: null); + + // Assert + testTerminal.Output.ShouldContain("Simple content"); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - await Task.CompletedTask; - } - - public static async Task Should_write_rule_without_title() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { WindowWidth = 40 }; - Terminal.Instance = testTerminal; - - try - { - // Act - Terminal.WriteRule(); - - // Assert - testTerminal.Output.ShouldContain("─"); - // Output should be approximately WindowWidth (40) + newline - testTerminal.Output.Length.ShouldBeGreaterThanOrEqualTo(40); - } - finally + // Regression: the positional forms were once ambiguous (CS0121) between the + // (string, string?) and (string, string?, ConsoleColor?, ConsoleColor?) overloads, + // which only consumer code exercised. Compiling these calls IS the test. + public static async Task Should_write_panel_with_positional_arguments() { - Terminal.Instance = original; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { WindowWidth = 40 }; + Terminal.Instance = testTerminal; + + try + { + // Act + Terminal.WritePanel("Positional content"); + Terminal.WritePanel("More content", "Positional Header"); + + // Assert + testTerminal.Output.ShouldContain("Positional content"); + testTerminal.Output.ShouldContain("Positional Header"); + testTerminal.Output.ShouldContain("More content"); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - await Task.CompletedTask; - } - - public static async Task Should_write_rule_with_builder() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { WindowWidth = 40 }; - Terminal.Instance = testTerminal; + // ========== Rule Tests ========== - try + public static async Task Should_write_rule_with_title() { - // Act - Terminal.WriteRule(rule => rule - .Title("Configuration") - .Style(LineStyle.Doubled) - .Color(AnsiColors.Cyan)); - - // Assert - testTerminal.Output.ShouldContain("Configuration"); - testTerminal.Output.ShouldContain("═"); - testTerminal.Output.ShouldContain(AnsiColors.Cyan); + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { WindowWidth = 40 }; + Terminal.Instance = testTerminal; + + try + { + // Act + Terminal.WriteRule("Section Title"); + + // Assert + testTerminal.Output.ShouldContain("Section Title"); + testTerminal.Output.ShouldContain("─"); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - finally - { - Terminal.Instance = original; - } - - await Task.CompletedTask; - } - - // ========== Link Tests ========== - - public static async Task Should_write_hyperlink() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { WindowWidth = 40 }; - Terminal.Instance = testTerminal; - - try - { - // Act - Terminal.WriteLink("https://github.com", "GitHub"); - // Assert - Should contain OSC 8 escape sequences - testTerminal.Output.ShouldContain("\x1b]8;;"); - testTerminal.Output.ShouldContain("https://github.com"); - testTerminal.Output.ShouldContain("GitHub"); - } - finally + public static async Task Should_write_rule_without_title() { - Terminal.Instance = original; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { WindowWidth = 40 }; + Terminal.Instance = testTerminal; + + try + { + // Act + Terminal.WriteRule(); + + // Assert + testTerminal.Output.ShouldContain("─"); + // Output should be approximately WindowWidth (40) + newline + testTerminal.Output.Length.ShouldBeGreaterThanOrEqualTo(40); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - await Task.CompletedTask; - } - - // ========== Null Argument Tests ========== - - public static async Task Should_throw_write_table_with_null_configure() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { WindowWidth = 40 }; - Terminal.Instance = testTerminal; - - try + public static async Task Should_write_rule_with_builder() { - // Act & Assert - Should.Throw(() => Terminal.WriteTable((Action)null!)); + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { WindowWidth = 40 }; + Terminal.Instance = testTerminal; + + try + { + // Act + Terminal.WriteRule(rule => rule + .Title("Configuration") + .Style(LineStyle.Doubled) + .Color(AnsiColors.Cyan)); + + // Assert + testTerminal.Output.ShouldContain("Configuration"); + testTerminal.Output.ShouldContain("═"); + testTerminal.Output.ShouldContain(AnsiColors.Cyan); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - finally - { - Terminal.Instance = original; - } - - await Task.CompletedTask; - } - public static async Task Should_throw_write_table_with_null_table() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { WindowWidth = 40 }; - Terminal.Instance = testTerminal; + // ========== Link Tests ========== - try + public static async Task Should_write_hyperlink() { - // Act & Assert - Should.Throw(() => Terminal.WriteTable((Table)null!)); + // Arrange - WriteLink only emits OSC 8 when the terminal supports hyperlinks + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { WindowWidth = 40, SupportsHyperlinks = true }; + Terminal.Instance = testTerminal; + + try + { + // Act + Terminal.WriteLink("https://github.com", "GitHub"); + + // Assert - Should contain OSC 8 escape sequences + testTerminal.Output.ShouldContain("\x1b]8;;"); + testTerminal.Output.ShouldContain("https://github.com"); + testTerminal.Output.ShouldContain("GitHub"); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - finally - { - Terminal.Instance = original; - } - - await Task.CompletedTask; - } - public static async Task Should_throw_write_panel_with_null_configure() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { WindowWidth = 40 }; - Terminal.Instance = testTerminal; + // ========== Null Argument Tests ========== - try - { - // Act & Assert - Should.Throw(() => Terminal.WritePanel((Action)null!)); - } - finally + public static async Task Should_throw_write_table_with_null_configure() { - Terminal.Instance = original; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { WindowWidth = 40 }; + Terminal.Instance = testTerminal; + + try + { + // Act & Assert + Should.Throw(() => Terminal.WriteTable((Action)null!)); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - await Task.CompletedTask; - } - - public static async Task Should_throw_write_rule_with_null_configure() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { WindowWidth = 40 }; - Terminal.Instance = testTerminal; - - try - { - // Act & Assert - Should.Throw(() => Terminal.WriteRule((Action)null!)); - } - finally + public static async Task Should_throw_write_table_with_null_table() { - Terminal.Instance = original; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { WindowWidth = 40 }; + Terminal.Instance = testTerminal; + + try + { + // Act & Assert + Should.Throw(() => Terminal.WriteTable((Table)null!)); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - await Task.CompletedTask; - } - - public static async Task Should_throw_write_link_with_null_url() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { WindowWidth = 40 }; - Terminal.Instance = testTerminal; - - try + public static async Task Should_throw_write_panel_with_null_configure() { - // Act & Assert - Should.Throw(() => Terminal.WriteLink(null!, "text")); + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { WindowWidth = 40 }; + Terminal.Instance = testTerminal; + + try + { + // Act & Assert + Should.Throw(() => Terminal.WritePanel(null!)); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - finally + + public static async Task Should_throw_write_rule_with_null_configure() { - Terminal.Instance = original; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { WindowWidth = 40 }; + Terminal.Instance = testTerminal; + + try + { + // Act & Assert + Should.Throw(() => Terminal.WriteRule((Action)null!)); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - await Task.CompletedTask; - } - - public static async Task Should_throw_write_link_with_null_text() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { WindowWidth = 40 }; - Terminal.Instance = testTerminal; - - try + public static async Task Should_throw_write_link_with_null_url() { - // Act & Assert - Should.Throw(() => Terminal.WriteLink("https://example.com", null!)); + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { WindowWidth = 40 }; + Terminal.Instance = testTerminal; + + try + { + // Act & Assert + Should.Throw(() => Terminal.WriteLink(null!, "text")); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - finally + + public static async Task Should_throw_write_link_with_null_text() { - Terminal.Instance = original; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { WindowWidth = 40 }; + Terminal.Instance = testTerminal; + + try + { + // Act & Assert + Should.Throw(() => Terminal.WriteLink("https://example.com", null!)); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - - await Task.CompletedTask; } -} } // namespace TimeWarp.Terminal.Tests.Core.TerminalStaticWidget diff --git a/tests/terminal-static-06-color.cs b/tests/terminal-static-06-color.cs index ed202f1..24583e6 100755 --- a/tests/terminal-static-06-color.cs +++ b/tests/terminal-static-06-color.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test Terminal static class color overloads @@ -12,238 +12,415 @@ namespace TimeWarp.Terminal.Tests.Core.TerminalStaticColor { -[TestTag("Terminal")] -public class TerminalStaticColorTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - // ========== Basic Color Output Tests ========== - - public static async Task Should_write_with_foreground_color() + [TestTag("Terminal")] + public class TerminalStaticColorTests { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + [ModuleInitializer] + internal static void Register() => RegisterTests(); - try - { - // Act - Terminal.Write("Red text", ConsoleColor.Red); + // ========== Basic Color Output Tests ========== - // Assert - testTerminal.Output.ShouldContain(AnsiColors.Red); - testTerminal.Output.ShouldContain("Red text"); - testTerminal.Output.ShouldContain(AnsiColors.Reset); - } - finally + public static async Task Should_write_with_foreground_color() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } + try + { + // Act + Terminal.Write("Red text", ConsoleColor.Red); - public static async Task Should_writeline_with_foreground_color() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + // Assert - ConsoleColor.Red maps to bright red (91) + testTerminal.Output.ShouldContain(AnsiColors.BrightRed); + testTerminal.Output.ShouldContain("Red text"); + testTerminal.Output.ShouldContain(AnsiColors.Reset); + } + finally + { + Terminal.Instance = original; + } - try - { - // Act - Terminal.WriteLine("Green text", ConsoleColor.Green); - - // Assert - testTerminal.Output.ShouldContain(AnsiColors.Green); - testTerminal.Output.ShouldContain("Green text"); - testTerminal.Output.ShouldContain(AnsiColors.Reset); - testTerminal.Output.ShouldEndWith(Environment.NewLine); + await Task.CompletedTask; } - finally + + public static async Task Should_writeline_with_foreground_color() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } + try + { + // Act + Terminal.WriteLine("Green text", ConsoleColor.Green); - public static async Task Should_writeline_with_foreground_and_background_color() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + // Assert - ConsoleColor.Green maps to bright green (92) + testTerminal.Output.ShouldContain(AnsiColors.BrightGreen); + testTerminal.Output.ShouldContain("Green text"); + testTerminal.Output.ShouldContain(AnsiColors.Reset); + testTerminal.Output.ShouldEndWith(Environment.NewLine); + } + finally + { + Terminal.Instance = original; + } - try - { - // Act - Terminal.WriteLine("Colored text", ConsoleColor.White, ConsoleColor.Blue); - - // Assert - testTerminal.Output.ShouldContain(AnsiColors.White); - testTerminal.Output.ShouldContain(AnsiColors.BgBlue); - testTerminal.Output.ShouldContain("Colored text"); - testTerminal.Output.ShouldContain(AnsiColors.Reset); - testTerminal.Output.ShouldEndWith(Environment.NewLine); + await Task.CompletedTask; } - finally + + public static async Task Should_writeline_with_foreground_and_background_color() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } + try + { + // Act + Terminal.WriteLine("Colored text", ConsoleColor.White, ConsoleColor.Blue); - public static async Task Should_write_error_line_with_foreground_color() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + // Assert - White maps to bright white (97), Blue maps to bright blue background (104) + testTerminal.Output.ShouldContain(AnsiColors.BrightWhite); + testTerminal.Output.ShouldContain(AnsiColors.BgBrightBlue); + testTerminal.Output.ShouldContain("Colored text"); + testTerminal.Output.ShouldContain(AnsiColors.Reset); + testTerminal.Output.ShouldEndWith(Environment.NewLine); + } + finally + { + Terminal.Instance = original; + } - try - { - // Act - Terminal.WriteErrorLine("Error message", ConsoleColor.Red); - - // Assert - testTerminal.ErrorOutput.ShouldContain(AnsiColors.Red); - testTerminal.ErrorOutput.ShouldContain("Error message"); - testTerminal.ErrorOutput.ShouldContain(AnsiColors.Reset); - testTerminal.ErrorOutput.ShouldEndWith(Environment.NewLine); + await Task.CompletedTask; } - finally + + public static async Task Should_write_error_line_with_foreground_color() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } + try + { + // Act + Terminal.WriteErrorLine("Error message", ConsoleColor.Red); + + // Assert - ConsoleColor.Red maps to bright red (91) + testTerminal.ErrorOutput.ShouldContain(AnsiColors.BrightRed); + testTerminal.ErrorOutput.ShouldContain("Error message"); + testTerminal.ErrorOutput.ShouldContain(AnsiColors.Reset); + testTerminal.ErrorOutput.ShouldEndWith(Environment.NewLine); + } + finally + { + Terminal.Instance = original; + } - // ========== Widget Color Tests ========== + await Task.CompletedTask; + } - public static async Task Should_writetable_with_colors() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { WindowWidth = 40 }; - Terminal.Instance = testTerminal; + // ========== Widget Color Tests ========== - try + public static async Task Should_writetable_with_colors() { - // Act - Terminal.WriteTable( - table => table - .AddColumn("Name") - .AddColumn("Value") - .AddRow("Foo", "123"), - ConsoleColor.White, - ConsoleColor.DarkBlue); - - // Assert - testTerminal.Output.ShouldContain(AnsiColors.White); - testTerminal.Output.ShouldContain(AnsiColors.BgBlue); // DarkBlue maps to BgBlue - testTerminal.Output.ShouldContain("Foo"); - testTerminal.Output.ShouldContain("123"); + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { WindowWidth = 40 }; + Terminal.Instance = testTerminal; + + try + { + // Act + Terminal.WriteTable( + table => table + .AddColumn("Name") + .AddColumn("Value") + .AddRow("Foo", "123"), + ConsoleColor.White, + ConsoleColor.DarkBlue); + + // Assert - White maps to bright white (97), DarkBlue maps to dim blue background (44) + testTerminal.Output.ShouldContain(AnsiColors.BrightWhite); + testTerminal.Output.ShouldContain(AnsiColors.BgBlue); + testTerminal.Output.ShouldContain("Foo"); + testTerminal.Output.ShouldContain("123"); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - finally + + public static async Task Should_writepanel_with_colors() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { WindowWidth = 40 }; + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } + try + { + // Act + Terminal.WritePanel( + panel => panel + .Header("Test Panel") + .Content("Panel content"), + ConsoleColor.White, + ConsoleColor.DarkBlue); + + // Assert - White maps to bright white (97), DarkBlue maps to dim blue background (44) + testTerminal.Output.ShouldContain(AnsiColors.BrightWhite); + testTerminal.Output.ShouldContain(AnsiColors.BgBlue); + testTerminal.Output.ShouldContain("Test Panel"); + testTerminal.Output.ShouldContain("Panel content"); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_writepanel_with_colors() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { WindowWidth = 40 }; - Terminal.Instance = testTerminal; + await Task.CompletedTask; + } + + // ========== Edge Case Tests ========== - try + public static async Task Should_handle_null_message_with_color() { - // Act - Terminal.WritePanel( - panel => panel - .Header("Test Panel") - .Content("Panel content"), - ConsoleColor.White, - ConsoleColor.DarkBlue); - - // Assert - testTerminal.Output.ShouldContain(AnsiColors.White); - testTerminal.Output.ShouldContain(AnsiColors.BgBlue); // DarkBlue maps to BgBlue - testTerminal.Output.ShouldContain("Test Panel"); - testTerminal.Output.ShouldContain("Panel content"); + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; + + try + { + // Act - a null message writes plain, exactly like the non-colored overload + Terminal.Write(null, ConsoleColor.Red); + Terminal.Write(null, ConsoleColor.Red, ConsoleColor.Blue); + Terminal.WriteLine(null, ConsoleColor.Red); + Terminal.WriteLine(null, ConsoleColor.Red, ConsoleColor.Blue); + Terminal.WriteErrorLine(null, ConsoleColor.Red); + Terminal.WriteErrorLine(null, ConsoleColor.Red, ConsoleColor.Blue); + + // Assert - only line terminators are written; no escape sequences at all + testTerminal.AllOutput.ShouldNotContain("\u001b"); + testTerminal.Output.ShouldBe(Environment.NewLine + Environment.NewLine); + testTerminal.ErrorOutput.ShouldBe(Environment.NewLine + Environment.NewLine); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - finally + + public static async Task Should_map_all_console_colors_correctly() { - Terminal.Instance = original; + // Arrange - standard mapping: Dark* colors use SGR 30-37/40-47, + // normal colors use the bright range 90-97/100-107, with the grays crossing over + Dictionary expected = new() + { + [ConsoleColor.Black] = ("\x1b[30m", "\x1b[40m"), + [ConsoleColor.DarkRed] = ("\x1b[31m", "\x1b[41m"), + [ConsoleColor.DarkGreen] = ("\x1b[32m", "\x1b[42m"), + [ConsoleColor.DarkYellow] = ("\x1b[33m", "\x1b[43m"), + [ConsoleColor.DarkBlue] = ("\x1b[34m", "\x1b[44m"), + [ConsoleColor.DarkMagenta] = ("\x1b[35m", "\x1b[45m"), + [ConsoleColor.DarkCyan] = ("\x1b[36m", "\x1b[46m"), + [ConsoleColor.Gray] = ("\x1b[37m", "\x1b[47m"), + [ConsoleColor.DarkGray] = ("\x1b[90m", "\x1b[100m"), + [ConsoleColor.Red] = ("\x1b[91m", "\x1b[101m"), + [ConsoleColor.Green] = ("\x1b[92m", "\x1b[102m"), + [ConsoleColor.Yellow] = ("\x1b[93m", "\x1b[103m"), + [ConsoleColor.Blue] = ("\x1b[94m", "\x1b[104m"), + [ConsoleColor.Magenta] = ("\x1b[95m", "\x1b[105m"), + [ConsoleColor.Cyan] = ("\x1b[96m", "\x1b[106m"), + [ConsoleColor.White] = ("\x1b[97m", "\x1b[107m") + }; + + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; + + try + { + // Act & Assert - verify each ConsoleColor produces its exact ANSI escape code + foreach (ConsoleColor color in Enum.GetValues()) + { + AnsiColors.GetForeground(color).ShouldBe(expected[color].Foreground); + AnsiColors.GetBackground(color).ShouldBe(expected[color].Background); + + testTerminal.ClearOutput(); + Terminal.Write($"Test {color}", color); + + testTerminal.Output.ShouldStartWith(expected[color].Foreground); + testTerminal.Output.ShouldContain(AnsiColors.Reset); + testTerminal.Output.ShouldContain($"Test {color}"); + } + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_map_dark_and_normal_colors_to_distinct_sgr_codes() + { + // Regression: dark and normal ConsoleColors previously collapsed to the same SGR code + (ConsoleColor Dark, ConsoleColor Normal)[] pairs = + [ + (ConsoleColor.DarkRed, ConsoleColor.Red), + (ConsoleColor.DarkGreen, ConsoleColor.Green), + (ConsoleColor.DarkYellow, ConsoleColor.Yellow), + (ConsoleColor.DarkBlue, ConsoleColor.Blue), + (ConsoleColor.DarkMagenta, ConsoleColor.Magenta), + (ConsoleColor.DarkCyan, ConsoleColor.Cyan), + (ConsoleColor.DarkGray, ConsoleColor.Gray), + (ConsoleColor.Black, ConsoleColor.White) + ]; + + foreach ((ConsoleColor dark, ConsoleColor normal) in pairs) + { + AnsiColors.GetForeground(dark).ShouldNotBe(AnsiColors.GetForeground(normal)); + AnsiColors.GetBackground(dark).ShouldNotBe(AnsiColors.GetBackground(normal)); + } - // ========== Edge Case Tests ========== + // The two grays cross over: DarkGray is bright black, Gray is dim white + AnsiColors.GetForeground(ConsoleColor.DarkGray).ShouldBe("\x1b[90m"); + AnsiColors.GetForeground(ConsoleColor.Gray).ShouldBe("\x1b[37m"); - public static async Task Should_handle_null_message_with_color() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + await Task.CompletedTask; + } - try + public static async Task Should_write_with_foreground_and_background_color() { - // Act - Terminal.WriteLine(null, ConsoleColor.Red); + // Arrange - Write and WriteErrorLine (message, fg, bg) overloads mirror WriteLine's + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; + + try + { + // Act + Terminal.Write("Highlighted", ConsoleColor.Black, ConsoleColor.Yellow); + Terminal.WriteErrorLine("Fatal", ConsoleColor.White, ConsoleColor.DarkRed); + + // Assert + testTerminal.Output.ShouldContain(AnsiColors.Black); + testTerminal.Output.ShouldContain(AnsiColors.BgBrightYellow); // Yellow maps to bright yellow background + testTerminal.Output.ShouldContain("Highlighted"); + testTerminal.Output.ShouldContain(AnsiColors.Reset); + testTerminal.Output.ShouldNotEndWith(Environment.NewLine); + + testTerminal.ErrorOutput.ShouldContain(AnsiColors.BrightWhite); + testTerminal.ErrorOutput.ShouldContain(AnsiColors.BgRed); // DarkRed maps to dim red background + testTerminal.ErrorOutput.ShouldContain("Fatal"); + testTerminal.ErrorOutput.ShouldContain(AnsiColors.Reset); + testTerminal.ErrorOutput.ShouldEndWith(Environment.NewLine); + } + finally + { + Terminal.Instance = original; + } - // Assert - testTerminal.Output.ShouldContain(AnsiColors.Red); - testTerminal.Output.ShouldContain(AnsiColors.Reset); - testTerminal.Output.ShouldNotContain("null"); + await Task.CompletedTask; } - finally + + public static async Task Should_reapply_table_color_after_embedded_border_reset() { - Terminal.Instance = original; - } + // Regression: a BorderColor emits its own Reset inside each rendered line, which used to + // cancel the requested foreground for everything after the first border segment + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { WindowWidth = 40 }; + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } + try + { + // Act + Terminal.WriteTable( + table => table + .AddColumn("Name") + .AddRow("Foo") + .BorderColor(AnsiColors.Yellow), + ConsoleColor.Cyan); + + // Assert - the foreground code re-appears after each embedded Reset + testTerminal.Output.ShouldContain(AnsiColors.Yellow); + testTerminal.Output.ShouldContain(AnsiColors.Reset + AnsiColors.BrightCyan); // Cyan maps to bright cyan + testTerminal.Output.ShouldContain("Foo"); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_map_all_console_colors_correctly() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + await Task.CompletedTask; + } + + // ========== SupportsColor Gating Tests ========== - try + public static async Task Should_write_plain_text_when_color_not_supported() { - // Act & Assert - verify each ConsoleColor produces an ANSI escape sequence - foreach (ConsoleColor color in Enum.GetValues()) - { - testTerminal.ClearOutput(); - Terminal.Write($"Test {color}", color); + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { SupportsColor = false }; + Terminal.Instance = testTerminal; - // All outputs should contain escape sequence start and reset - testTerminal.Output.ShouldContain("\x1b["); - testTerminal.Output.ShouldContain(AnsiColors.Reset); - testTerminal.Output.ShouldContain($"Test {color}"); + try + { + // Act + Terminal.Write("no color", ConsoleColor.Red); + Terminal.WriteLine("still none", ConsoleColor.Green, ConsoleColor.Black); + Terminal.WriteErrorLine("error plain", ConsoleColor.Yellow); + + // Assert - no escape sequences anywhere + testTerminal.AllOutput.ShouldNotContain("\u001b"); + testTerminal.Output.ShouldContain("no color"); + testTerminal.Output.ShouldContain("still none"); + testTerminal.ErrorOutput.ShouldContain("error plain"); + } + finally + { + Terminal.Instance = original; } + + await Task.CompletedTask; } - finally + + public static async Task Should_write_widgets_plain_when_color_not_supported() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { WindowWidth = 40, SupportsColor = false }; + Terminal.Instance = testTerminal; - await Task.CompletedTask; + try + { + // Act + Terminal.WritePanel("panel content", header: null, foregroundColor: ConsoleColor.White, backgroundColor: ConsoleColor.DarkBlue); + Terminal.WriteTable(t => t.AddColumn("H").AddRow("V"), ConsoleColor.Cyan); + testTerminal.WriteTable(t => t.AddColumn("X").AddRow("Y"), ConsoleColor.Red); + + // Assert + testTerminal.Output.ShouldNotContain("\u001b"); + testTerminal.Output.ShouldContain("panel content"); + } + finally + { + Terminal.Instance = original; + } + + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.TerminalStaticColor diff --git a/tests/terminal-static-08-new-apis.cs b/tests/terminal-static-08-new-apis.cs index b61d148..8b63890 100755 --- a/tests/terminal-static-08-new-apis.cs +++ b/tests/terminal-static-08-new-apis.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test Terminal static facade class - New APIs from tasks 020-001 through 020-007 @@ -11,1329 +11,1050 @@ namespace TimeWarp.Terminal.Tests.Core.TerminalStaticNewApis { -[TestTag("Terminal")] -public class TerminalStaticNewApisTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - // ========== Stream Access Tests (IConsole - task 020-001) ========== - - public static async Task Should_open_standard_input() + [TestTag("Terminal")] + public class TerminalStaticNewApisTests { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + [ModuleInitializer] + internal static void Register() => RegisterTests(); - try - { - // Act - Stream stream = Terminal.OpenStandardInput(); + // ========== Stream Access Tests (IConsole - task 020-001) ========== - // Assert - stream.ShouldBe(testTerminal.StandardInputStream); - } - finally + public static async Task Should_open_standard_input() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } - - public static async Task Should_open_standard_output() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + try + { + // Act + Stream stream = Terminal.OpenStandardInput(); - try - { - // Act - Stream stream = Terminal.OpenStandardOutput(); + // Assert + stream.ShouldBe(testTerminal.StandardInputStream); + } + finally + { + Terminal.Instance = original; + } - // Assert - stream.ShouldBe(testTerminal.StandardOutputStream); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } - - public static async Task Should_open_standard_error() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; - - try + public static async Task Should_open_standard_output() { - // Act - Stream stream = Terminal.OpenStandardError(); + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - // Assert - stream.ShouldBe(testTerminal.StandardErrorStream); - } - finally - { - Terminal.Instance = original; - } + try + { + // Act + Stream stream = Terminal.OpenStandardOutput(); - await Task.CompletedTask; - } + // Assert + stream.ShouldBe(testTerminal.StandardOutputStream); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_get_in_reader() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; - - try - { - // Act - TextReader reader = Terminal.In; - - // Assert - reader.ShouldBe(testTerminal.In); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } - - public static async Task Should_get_out_writer() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; - - try + public static async Task Should_open_standard_error() { - // Act - TextWriter writer = Terminal.Out; - - // Assert - writer.ShouldBe(testTerminal.Out); - } - finally - { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } - - public static async Task Should_get_error_writer() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + try + { + // Act + Stream stream = Terminal.OpenStandardError(); - try - { - // Act - TextWriter writer = Terminal.Error; + // Assert + stream.ShouldBe(testTerminal.StandardErrorStream); + } + finally + { + Terminal.Instance = original; + } - // Assert - writer.ShouldBe(testTerminal.Error); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } - - public static async Task Should_set_in_reader() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; - StringReader newReader = new("test"); - - try - { - // Act - Terminal.SetIn(newReader); - - // Assert - testTerminal.In.ShouldBe(newReader); - } - finally + public static async Task Should_get_in_reader() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } - - public static async Task Should_set_out_writer() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; - StringWriter newWriter = new(); + try + { + // Act + TextReader reader = Terminal.In; - try - { - // Act - Terminal.SetOut(newWriter); + // Assert + reader.ShouldBe(testTerminal.In); + } + finally + { + Terminal.Instance = original; + } - // Assert - testTerminal.Out.ShouldBe(newWriter); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } - - public static async Task Should_set_error_writer() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; - StringWriter newWriter = new(); - - try - { - // Act - Terminal.SetError(newWriter); - - // Assert - testTerminal.Error.ShouldBe(newWriter); - } - finally + public static async Task Should_get_out_writer() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } + try + { + // Act + TextWriter writer = Terminal.Out; - // ========== Encoding Tests (IConsole - task 020-002) ========== + // Assert + writer.ShouldBe(testTerminal.Out); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_get_input_encoding() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { InputEncoding = Encoding.ASCII }; - Terminal.Instance = testTerminal; - - try - { - // Act - Encoding encoding = Terminal.InputEncoding; - - // Assert - encoding.ShouldBe(Encoding.ASCII); + await Task.CompletedTask; } - finally - { - Terminal.Instance = original; - } - - await Task.CompletedTask; - } - public static async Task Should_set_input_encoding() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; - - try + public static async Task Should_get_error_writer() { - // Act - Terminal.InputEncoding = Encoding.Unicode; - - // Assert - testTerminal.InputEncoding.ShouldBe(Encoding.Unicode); - } - finally - { - Terminal.Instance = original; - } - - await Task.CompletedTask; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - public static async Task Should_get_output_encoding() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { OutputEncoding = Encoding.UTF32 }; - Terminal.Instance = testTerminal; + try + { + // Act + TextWriter writer = Terminal.Error; - try - { - // Act - Encoding encoding = Terminal.OutputEncoding; + // Assert + writer.ShouldBe(testTerminal.Error); + } + finally + { + Terminal.Instance = original; + } - // Assert - encoding.ShouldBe(Encoding.UTF32); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } - - public static async Task Should_set_output_encoding() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; - - try + public static async Task Should_set_in_reader() { - // Act - Terminal.OutputEncoding = Encoding.Latin1; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; + StringReader newReader = new("test"); - // Assert - testTerminal.OutputEncoding.ShouldBe(Encoding.Latin1); - } - finally - { - Terminal.Instance = original; - } + try + { + // Act + Terminal.SetIn(newReader); - await Task.CompletedTask; - } - - // ========== Redirection Tests (IConsole - task 020-002) ========== - - public static async Task Should_get_is_input_redirected() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { IsInputRedirected = true }; - Terminal.Instance = testTerminal; - - try - { - // Act - bool redirected = Terminal.IsInputRedirected; + // Assert + testTerminal.In.ShouldBe(newReader); + } + finally + { + Terminal.Instance = original; + } - // Assert - redirected.ShouldBeTrue(); + await Task.CompletedTask; } - finally - { - Terminal.Instance = original; - } - - await Task.CompletedTask; - } - - public static async Task Should_get_is_output_redirected() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { IsOutputRedirected = true }; - Terminal.Instance = testTerminal; - try + public static async Task Should_set_out_writer() { - // Act - bool redirected = Terminal.IsOutputRedirected; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; + StringWriter newWriter = new(); - // Assert - redirected.ShouldBeTrue(); - } - finally - { - Terminal.Instance = original; - } + try + { + // Act + Terminal.SetOut(newWriter); - await Task.CompletedTask; - } - - public static async Task Should_get_is_error_redirected() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { IsErrorRedirected = true }; - Terminal.Instance = testTerminal; + // Assert + testTerminal.Out.ShouldBe(newWriter); + } + finally + { + Terminal.Instance = original; + } - try - { - // Act - bool redirected = Terminal.IsErrorRedirected; - - // Assert - redirected.ShouldBeTrue(); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } - - // ========== Rich Input Tests (IConsole - task 020-003) ========== - - public static async Task Should_read_character() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; - testTerminal.QueueKeys("abc"); - - try + public static async Task Should_set_error_writer() { - // Act - int ch = Terminal.Read(); + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; + StringWriter newWriter = new(); - // Assert - ch.ShouldBe('a'); - } - finally - { - Terminal.Instance = original; - } - - await Task.CompletedTask; - } - - public static async Task Should_read_key_without_intercept() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; - testTerminal.QueueKey(ConsoleKey.Enter); + try + { + // Act + Terminal.SetError(newWriter); - try - { - // Act - ConsoleKeyInfo keyInfo = Terminal.ReadKey(); + // Assert + testTerminal.Error.ShouldBe(newWriter); + } + finally + { + Terminal.Instance = original; + } - // Assert - keyInfo.Key.ShouldBe(ConsoleKey.Enter); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } - - // ========== Cursor Properties Tests (ITerminal - task 020-004) ========== + // ========== Encoding Tests (IConsole - task 020-002) ========== - public static async Task Should_get_cursor_left() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { CursorLeft = 10 }; - Terminal.Instance = testTerminal; - - try + public static async Task Should_get_input_encoding() { - // Act - int left = Terminal.CursorLeft; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { InputEncoding = Encoding.ASCII }; + Terminal.Instance = testTerminal; - // Assert - left.ShouldBe(10); - } - finally - { - Terminal.Instance = original; - } + try + { + // Act + Encoding encoding = Terminal.InputEncoding; - await Task.CompletedTask; - } + // Assert + encoding.ShouldBe(Encoding.ASCII); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_set_cursor_left() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; - - try - { - // Act - Terminal.CursorLeft = 20; - - // Assert - testTerminal.CursorLeft.ShouldBe(20); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } - - public static async Task Should_get_cursor_top() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { CursorTop = 5 }; - Terminal.Instance = testTerminal; - - try + public static async Task Should_set_input_encoding() { - // Act - int top = Terminal.CursorTop; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - // Assert - top.ShouldBe(5); - } - finally - { - Terminal.Instance = original; - } + try + { + // Act + Terminal.InputEncoding = Encoding.Unicode; - await Task.CompletedTask; - } + // Assert + testTerminal.InputEncoding.ShouldBe(Encoding.Unicode); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_set_cursor_top() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + await Task.CompletedTask; + } - try + public static async Task Should_get_output_encoding() { - // Act - Terminal.CursorTop = 15; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { OutputEncoding = Encoding.UTF32 }; + Terminal.Instance = testTerminal; - // Assert - testTerminal.CursorTop.ShouldBe(15); - } - finally - { - Terminal.Instance = original; - } + try + { + // Act + Encoding encoding = Terminal.OutputEncoding; - await Task.CompletedTask; - } + // Assert + encoding.ShouldBe(Encoding.UTF32); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_get_cursor_visible() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { CursorVisible = false }; - Terminal.Instance = testTerminal; + await Task.CompletedTask; + } - try + public static async Task Should_set_output_encoding() { - // Act - bool visible = Terminal.CursorVisible; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - // Assert - visible.ShouldBeFalse(); - } - finally - { - Terminal.Instance = original; - } + try + { + // Act + Terminal.OutputEncoding = Encoding.Latin1; - await Task.CompletedTask; - } + // Assert + testTerminal.OutputEncoding.ShouldBe(Encoding.Latin1); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_set_cursor_visible() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + await Task.CompletedTask; + } - try - { - // Act - Terminal.CursorVisible = false; + // ========== Redirection Tests (IConsole - task 020-002) ========== - // Assert - testTerminal.CursorVisible.ShouldBeFalse(); - } - finally + public static async Task Should_get_is_input_redirected() { - Terminal.Instance = original; - } - - await Task.CompletedTask; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { IsInputRedirected = true }; + Terminal.Instance = testTerminal; - public static async Task Should_get_cursor_size() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { CursorSize = 50 }; - Terminal.Instance = testTerminal; + try + { + // Act + bool redirected = Terminal.IsInputRedirected; - try - { - // Act - int size = Terminal.CursorSize; + // Assert + redirected.ShouldBeTrue(); + } + finally + { + Terminal.Instance = original; + } - // Assert - size.ShouldBe(50); + await Task.CompletedTask; } - finally + + public static async Task Should_get_is_output_redirected() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { IsOutputRedirected = true }; + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } + try + { + // Act + bool redirected = Terminal.IsOutputRedirected; - public static async Task Should_set_cursor_size() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + // Assert + redirected.ShouldBeTrue(); + } + finally + { + Terminal.Instance = original; + } - try - { - // Act - Terminal.CursorSize = 75; - - // Assert - testTerminal.CursorSize.ShouldBe(75); + await Task.CompletedTask; } - finally + + public static async Task Should_get_is_error_redirected() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { IsErrorRedirected = true }; + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } + try + { + // Act + bool redirected = Terminal.IsErrorRedirected; - // ========== Window/Buffer Geometry Tests (ITerminal - task 020-005) ========== + // Assert + redirected.ShouldBeTrue(); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_get_window_height() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { WindowHeight = 40 }; - Terminal.Instance = testTerminal; + await Task.CompletedTask; + } - try - { - // Act - int height = Terminal.WindowHeight; + // ========== Rich Input Tests (IConsole - task 020-003) ========== - // Assert - height.ShouldBe(40); - } - finally + public static async Task Should_read_character() { - Terminal.Instance = original; - } - - await Task.CompletedTask; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; + testTerminal.QueueKeys("abc"); - public static async Task Should_set_window_height() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + try + { + // Act + int ch = Terminal.Read(); - try - { - // Act - Terminal.WindowHeight = 50; + // Assert + ch.ShouldBe('a'); + } + finally + { + Terminal.Instance = original; + } - // Assert - testTerminal.WindowHeight.ShouldBe(50); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_read_key_without_intercept() + { + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; + testTerminal.QueueKey(ConsoleKey.Enter); - public static async Task Should_set_window_width() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + try + { + // Act + ConsoleKeyInfo keyInfo = Terminal.ReadKey(); - try - { - // Act - Terminal.WindowWidth = 150; + // Assert + keyInfo.Key.ShouldBe(ConsoleKey.Enter); + } + finally + { + Terminal.Instance = original; + } - // Assert - testTerminal.WindowWidth.ShouldBe(150); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } + // ========== Cursor Properties Tests (ITerminal - task 020-004) ========== - public static async Task Should_get_window_left() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { WindowLeft = 5 }; - Terminal.Instance = testTerminal; - - try + public static async Task Should_get_cursor_left() { - // Act - int left = Terminal.WindowLeft; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { CursorLeft = 10 }; + Terminal.Instance = testTerminal; - // Assert - left.ShouldBe(5); - } - finally - { - Terminal.Instance = original; - } + try + { + // Act + int left = Terminal.CursorLeft; - await Task.CompletedTask; - } + // Assert + left.ShouldBe(10); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_set_window_left() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + await Task.CompletedTask; + } - try + public static async Task Should_set_cursor_left() { - // Act - Terminal.WindowLeft = 10; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - // Assert - testTerminal.WindowLeft.ShouldBe(10); - } - finally - { - Terminal.Instance = original; - } + try + { + // Act + Terminal.CursorLeft = 20; - await Task.CompletedTask; - } + // Assert + testTerminal.CursorLeft.ShouldBe(20); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_get_window_top() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { WindowTop = 3 }; - Terminal.Instance = testTerminal; + await Task.CompletedTask; + } - try + public static async Task Should_get_cursor_top() { - // Act - int top = Terminal.WindowTop; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { CursorTop = 5 }; + Terminal.Instance = testTerminal; - // Assert - top.ShouldBe(3); - } - finally - { - Terminal.Instance = original; - } + try + { + // Act + int top = Terminal.CursorTop; - await Task.CompletedTask; - } + // Assert + top.ShouldBe(5); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_set_window_top() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + await Task.CompletedTask; + } - try + public static async Task Should_set_cursor_top() { - // Act - Terminal.WindowTop = 8; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - // Assert - testTerminal.WindowTop.ShouldBe(8); - } - finally - { - Terminal.Instance = original; - } + try + { + // Act + Terminal.CursorTop = 15; - await Task.CompletedTask; - } + // Assert + testTerminal.CursorTop.ShouldBe(15); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_get_buffer_width() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { BufferWidth = 200 }; - Terminal.Instance = testTerminal; + await Task.CompletedTask; + } - try + public static async Task Should_get_cursor_visible() { - // Act - int width = Terminal.BufferWidth; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { CursorVisible = false }; + Terminal.Instance = testTerminal; - // Assert - width.ShouldBe(200); - } - finally - { - Terminal.Instance = original; - } + try + { + // Act + bool visible = Terminal.CursorVisible; - await Task.CompletedTask; - } + // Assert + visible.ShouldBeFalse(); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_set_buffer_width() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + await Task.CompletedTask; + } - try + public static async Task Should_set_cursor_visible() { - // Act - Terminal.BufferWidth = 250; + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - // Assert - testTerminal.BufferWidth.ShouldBe(250); - } - finally - { - Terminal.Instance = original; - } + try + { + // Act + Terminal.CursorVisible = false; - await Task.CompletedTask; - } + // Assert + testTerminal.CursorVisible.ShouldBeFalse(); + } + finally + { + Terminal.Instance = original; + } - public static async Task Should_get_buffer_height() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { BufferHeight = 500 }; - Terminal.Instance = testTerminal; + await Task.CompletedTask; + } - try - { - // Act - int height = Terminal.BufferHeight; + // ========== Window/Buffer Geometry Tests (ITerminal - task 020-005) ========== - // Assert - height.ShouldBe(500); - } - finally + public static async Task Should_get_window_height() { - Terminal.Instance = original; - } - - await Task.CompletedTask; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { WindowHeight = 40 }; + Terminal.Instance = testTerminal; - public static async Task Should_set_buffer_height() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + try + { + // Act + int height = Terminal.WindowHeight; - try - { - // Act - Terminal.BufferHeight = 600; + // Assert + height.ShouldBe(40); + } + finally + { + Terminal.Instance = original; + } - // Assert - testTerminal.BufferHeight.ShouldBe(600); + await Task.CompletedTask; } - finally + + public static async Task Should_get_window_height_after_test_terminal_change() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } + try + { + // Act + testTerminal.WindowHeight = 50; - public static async Task Should_get_largest_window_width() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { LargestWindowWidth = 200 }; - Terminal.Instance = testTerminal; + // Assert + Terminal.WindowHeight.ShouldBe(50); + } + finally + { + Terminal.Instance = original; + } - try - { - // Act - int width = Terminal.LargestWindowWidth; - - // Assert - width.ShouldBe(200); + await Task.CompletedTask; } - finally + + public static async Task Should_get_window_width() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { WindowWidth = 150 }; + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } + try + { + // Act + int width = Terminal.WindowWidth; - public static async Task Should_get_largest_window_height() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { LargestWindowHeight = 60 }; - Terminal.Instance = testTerminal; + // Assert + width.ShouldBe(150); + } + finally + { + Terminal.Instance = original; + } - try - { - // Act - int height = Terminal.LargestWindowHeight; - - // Assert - height.ShouldBe(60); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_get_buffer_width() + { + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { BufferWidth = 200 }; + Terminal.Instance = testTerminal; - public static async Task Should_set_window_size() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + try + { + // Act + int width = Terminal.BufferWidth; - try - { - // Act - Terminal.SetWindowSize(100, 30); + // Assert + width.ShouldBe(200); + } + finally + { + Terminal.Instance = original; + } - // Assert - testTerminal.WindowWidth.ShouldBe(100); - testTerminal.WindowHeight.ShouldBe(30); + await Task.CompletedTask; } - finally + + public static async Task Should_get_buffer_width_after_test_terminal_change() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } + try + { + // Act + testTerminal.BufferWidth = 250; - public static async Task Should_set_window_position() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + // Assert + Terminal.BufferWidth.ShouldBe(250); + } + finally + { + Terminal.Instance = original; + } - try - { - // Act - Terminal.SetWindowPosition(5, 10); - - // Assert - testTerminal.WindowLeft.ShouldBe(5); - testTerminal.WindowTop.ShouldBe(10); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_get_buffer_height() + { + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { BufferHeight = 500 }; + Terminal.Instance = testTerminal; - public static async Task Should_set_buffer_size() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + try + { + // Act + int height = Terminal.BufferHeight; - try - { - // Act - Terminal.SetBufferSize(150, 400); + // Assert + height.ShouldBe(500); + } + finally + { + Terminal.Instance = original; + } - // Assert - testTerminal.BufferWidth.ShouldBe(150); - testTerminal.BufferHeight.ShouldBe(400); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_get_buffer_height_after_test_terminal_change() + { + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - public static async Task Should_move_buffer_area() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + try + { + // Act + testTerminal.BufferHeight = 600; - try - { - // Act - Terminal.MoveBufferArea(0, 0, 10, 5, 20, 10, ' ', ConsoleColor.White, ConsoleColor.Black); + // Assert + Terminal.BufferHeight.ShouldBe(600); + } + finally + { + Terminal.Instance = original; + } - // Assert - testTerminal.MoveBufferAreaCallCount.ShouldBe(1); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } + // ========== Color State Tests (ITerminal - task 020-006) ========== - // ========== Color State Tests (ITerminal - task 020-006) ========== + public static async Task Should_get_foreground_color() + { + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { ForegroundColor = ConsoleColor.Cyan }; + Terminal.Instance = testTerminal; - public static async Task Should_get_foreground_color() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { ForegroundColor = ConsoleColor.Cyan }; - Terminal.Instance = testTerminal; + try + { + // Act + ConsoleColor color = Terminal.ForegroundColor; - try - { - // Act - ConsoleColor color = Terminal.ForegroundColor; + // Assert + color.ShouldBe(ConsoleColor.Cyan); + } + finally + { + Terminal.Instance = original; + } - // Assert - color.ShouldBe(ConsoleColor.Cyan); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_set_foreground_color() + { + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - public static async Task Should_set_foreground_color() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + try + { + // Act + Terminal.ForegroundColor = ConsoleColor.Magenta; - try - { - // Act - Terminal.ForegroundColor = ConsoleColor.Magenta; + // Assert + testTerminal.ForegroundColor.ShouldBe(ConsoleColor.Magenta); + } + finally + { + Terminal.Instance = original; + } - // Assert - testTerminal.ForegroundColor.ShouldBe(ConsoleColor.Magenta); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_get_background_color() + { + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { BackgroundColor = ConsoleColor.DarkBlue }; + Terminal.Instance = testTerminal; - public static async Task Should_get_background_color() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { BackgroundColor = ConsoleColor.DarkBlue }; - Terminal.Instance = testTerminal; + try + { + // Act + ConsoleColor color = Terminal.BackgroundColor; - try - { - // Act - ConsoleColor color = Terminal.BackgroundColor; + // Assert + color.ShouldBe(ConsoleColor.DarkBlue); + } + finally + { + Terminal.Instance = original; + } - // Assert - color.ShouldBe(ConsoleColor.DarkBlue); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_set_background_color() + { + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - public static async Task Should_set_background_color() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + try + { + // Act + Terminal.BackgroundColor = ConsoleColor.DarkGreen; - try - { - // Act - Terminal.BackgroundColor = ConsoleColor.DarkGreen; + // Assert + testTerminal.BackgroundColor.ShouldBe(ConsoleColor.DarkGreen); + } + finally + { + Terminal.Instance = original; + } - // Assert - testTerminal.BackgroundColor.ShouldBe(ConsoleColor.DarkGreen); + await Task.CompletedTask; } - finally + + public static async Task Should_reset_color() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; + testTerminal.ForegroundColor = ConsoleColor.Red; + testTerminal.BackgroundColor = ConsoleColor.Blue; - await Task.CompletedTask; - } + try + { + // Act + Terminal.ResetColor(); - public static async Task Should_reset_color() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; - testTerminal.ForegroundColor = ConsoleColor.Red; - testTerminal.BackgroundColor = ConsoleColor.Blue; - - try - { - // Act - Terminal.ResetColor(); + // Assert + testTerminal.ForegroundColor.ShouldBe(ConsoleColor.Gray); + testTerminal.BackgroundColor.ShouldBe(ConsoleColor.Black); + } + finally + { + Terminal.Instance = original; + } - // Assert - testTerminal.ForegroundColor.ShouldBe(ConsoleColor.Gray); - testTerminal.BackgroundColor.ShouldBe(ConsoleColor.Black); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } + // ========== Control/Utility Tests (ITerminal - task 020-007) ========== - // ========== Control/Utility Tests (ITerminal - task 020-007) ========== + public static async Task Should_beep() + { + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - public static async Task Should_beep() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + try + { + // Act + Terminal.Beep(); - try - { - // Act - Terminal.Beep(); + // Assert + testTerminal.BeepCount.ShouldBe(1); + } + finally + { + Terminal.Instance = original; + } - // Assert - testTerminal.BeepCount.ShouldBe(1); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_beep_with_frequency_and_duration() + { + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - public static async Task Should_beep_with_frequency_and_duration() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + try + { + // Act + Terminal.Beep(800, 200); - try - { - // Act - Terminal.Beep(800, 200); + // Assert + testTerminal.BeepCount.ShouldBe(1); + testTerminal.LastBeepFrequency.ShouldBe(800); + testTerminal.LastBeepDuration.ShouldBe(200); + } + finally + { + Terminal.Instance = original; + } - // Assert - testTerminal.BeepCount.ShouldBe(1); - testTerminal.LastBeepFrequency.ShouldBe(800); - testTerminal.LastBeepDuration.ShouldBe(200); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_get_treat_control_c_as_input() + { + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { TreatControlCAsInput = true }; + Terminal.Instance = testTerminal; - public static async Task Should_get_treat_control_c_as_input() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { TreatControlCAsInput = true }; - Terminal.Instance = testTerminal; + try + { + // Act + bool treatAsInput = Terminal.TreatControlCAsInput; - try - { - // Act - bool treatAsInput = Terminal.TreatControlCAsInput; + // Assert + treatAsInput.ShouldBeTrue(); + } + finally + { + Terminal.Instance = original; + } - // Assert - treatAsInput.ShouldBeTrue(); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_set_treat_control_c_as_input() + { + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - public static async Task Should_set_treat_control_c_as_input() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + try + { + // Act + Terminal.TreatControlCAsInput = true; - try - { - // Act - Terminal.TreatControlCAsInput = true; + // Assert + testTerminal.TreatControlCAsInput.ShouldBeTrue(); + } + finally + { + Terminal.Instance = original; + } - // Assert - testTerminal.TreatControlCAsInput.ShouldBeTrue(); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_get_title() + { + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new() { Title = "Test Title" }; + Terminal.Instance = testTerminal; - public static async Task Should_get_title() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new() { Title = "Test Title" }; - Terminal.Instance = testTerminal; + try + { + // Act + string title = Terminal.Title; - try - { - // Act - string title = Terminal.Title; + // Assert + title.ShouldBe("Test Title"); + } + finally + { + Terminal.Instance = original; + } - // Assert - title.ShouldBe("Test Title"); + await Task.CompletedTask; } - finally + + public static async Task Should_set_title() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } + try + { + // Act + Terminal.Title = "New Title"; - public static async Task Should_set_title() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + // Assert + testTerminal.Title.ShouldBe("New Title"); + } + finally + { + Terminal.Instance = original; + } - try - { - // Act - Terminal.Title = "New Title"; - - // Assert - testTerminal.Title.ShouldBe("New Title"); - } - finally - { - Terminal.Instance = original; + await Task.CompletedTask; } - await Task.CompletedTask; - } + public static async Task Should_get_key_available() + { + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; + testTerminal.QueueKey(ConsoleKey.A); - public static async Task Should_get_key_available() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; - testTerminal.QueueKey(ConsoleKey.A); + try + { + // Act + bool available = Terminal.KeyAvailable; - try - { - // Act - bool available = Terminal.KeyAvailable; + // Assert + available.ShouldBeTrue(); + } + finally + { + Terminal.Instance = original; + } - // Assert - available.ShouldBeTrue(); + await Task.CompletedTask; } - finally + + public static async Task Should_get_key_available_false_when_empty() { - Terminal.Instance = original; - } + // Arrange + ITerminal original = Terminal.Instance; + using TestTerminal testTerminal = new(); + Terminal.Instance = testTerminal; - await Task.CompletedTask; - } + try + { + // Act + bool available = Terminal.KeyAvailable; - public static async Task Should_get_key_available_false_when_empty() - { - // Arrange - ITerminal original = Terminal.Instance; - using TestTerminal testTerminal = new(); - Terminal.Instance = testTerminal; + // Assert + available.ShouldBeFalse(); + } + finally + { + Terminal.Instance = original; + } - try - { - // Act - bool available = Terminal.KeyAvailable; - - // Assert - available.ShouldBeFalse(); + await Task.CompletedTask; } - finally - { - Terminal.Instance = original; - } - - await Task.CompletedTask; } -} } // namespace TimeWarp.Terminal.Tests.Core.TerminalStaticNewApis diff --git a/tests/terminal-window-buffer-geometry.cs b/tests/terminal-window-buffer-geometry.cs index 2fa0839..c97d554 100755 --- a/tests/terminal-window-buffer-geometry.cs +++ b/tests/terminal-window-buffer-geometry.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Test window and buffer geometry properties on ITerminal @@ -10,323 +10,144 @@ namespace TimeWarp.Terminal.Tests.Core.TerminalWindowBufferGeometry { -[TestTag("Terminal")] -public class TerminalWindowBufferGeometryTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - // ========== WindowHeight Tests ========== - - public static async Task Should_get_and_set_window_height() - { - // Arrange - using TestTerminal terminal = new(); - - // Act - terminal.WindowHeight = 40; - - // Assert - terminal.WindowHeight.ShouldBe(40); - - await Task.CompletedTask; - } - - public static async Task Should_default_window_height_to_24() - { - // Arrange & Act - using TestTerminal terminal = new(); - - // Assert - terminal.WindowHeight.ShouldBe(24); - - await Task.CompletedTask; - } - - // ========== WindowLeft Tests ========== - - public static async Task Should_get_and_set_window_left() - { - // Arrange - using TestTerminal terminal = new(); - - // Act - terminal.WindowLeft = 10; - - // Assert - terminal.WindowLeft.ShouldBe(10); - - await Task.CompletedTask; - } - - public static async Task Should_default_window_left_to_0() - { - // Arrange & Act - using TestTerminal terminal = new(); - - // Assert - terminal.WindowLeft.ShouldBe(0); - - await Task.CompletedTask; - } - - // ========== WindowTop Tests ========== - - public static async Task Should_get_and_set_window_top() - { - // Arrange - using TestTerminal terminal = new(); - - // Act - terminal.WindowTop = 15; - - // Assert - terminal.WindowTop.ShouldBe(15); - - await Task.CompletedTask; - } - - public static async Task Should_default_window_top_to_0() - { - // Arrange & Act - using TestTerminal terminal = new(); - - // Assert - terminal.WindowTop.ShouldBe(0); - - await Task.CompletedTask; - } - - // ========== BufferWidth Tests ========== - - public static async Task Should_get_and_set_buffer_width() - { - // Arrange - using TestTerminal terminal = new(); - - // Act - terminal.BufferWidth = 120; - - // Assert - terminal.BufferWidth.ShouldBe(120); - - await Task.CompletedTask; - } - - public static async Task Should_default_buffer_width_to_80() + [TestTag("Terminal")] + public class TerminalWindowBufferGeometryTests { - // Arrange & Act - using TestTerminal terminal = new(); + [ModuleInitializer] + internal static void Register() => RegisterTests(); - // Assert - terminal.BufferWidth.ShouldBe(80); + // ========== WindowHeight Tests ========== - await Task.CompletedTask; - } + public static async Task Should_get_and_set_window_height() + { + // Arrange + using TestTerminal terminal = new(); - // ========== BufferHeight Tests ========== + // Act + terminal.WindowHeight = 40; - public static async Task Should_get_and_set_buffer_height() - { - // Arrange - using TestTerminal terminal = new(); + // Assert + terminal.WindowHeight.ShouldBe(40); - // Act - terminal.BufferHeight = 500; + await Task.CompletedTask; + } - // Assert - terminal.BufferHeight.ShouldBe(500); + public static async Task Should_default_window_height_to_24() + { + // Arrange & Act + using TestTerminal terminal = new(); - await Task.CompletedTask; - } + // Assert + terminal.WindowHeight.ShouldBe(24); - public static async Task Should_default_buffer_height_to_300() - { - // Arrange & Act - using TestTerminal terminal = new(); + await Task.CompletedTask; + } - // Assert - terminal.BufferHeight.ShouldBe(300); + // ========== BufferWidth Tests ========== - await Task.CompletedTask; - } + public static async Task Should_get_and_set_buffer_width() + { + // Arrange + using TestTerminal terminal = new(); - // ========== SetWindowSize Tests ========== + // Act + terminal.BufferWidth = 120; - public static async Task Should_set_window_size() - { - // Arrange - using TestTerminal terminal = new(); + // Assert + terminal.BufferWidth.ShouldBe(120); - // Act - terminal.SetWindowSize(100, 30); + await Task.CompletedTask; + } - // Assert - terminal.WindowWidth.ShouldBe(100); - terminal.WindowHeight.ShouldBe(30); + public static async Task Should_default_buffer_width_to_80() + { + // Arrange & Act + using TestTerminal terminal = new(); - await Task.CompletedTask; - } + // Assert + terminal.BufferWidth.ShouldBe(80); - // ========== SetWindowPosition Tests ========== + await Task.CompletedTask; + } - public static async Task Should_set_window_position() - { - // Arrange - using TestTerminal terminal = new(); + // ========== BufferHeight Tests ========== - // Act - terminal.SetWindowPosition(5, 10); + public static async Task Should_get_and_set_buffer_height() + { + // Arrange + using TestTerminal terminal = new(); - // Assert - terminal.WindowLeft.ShouldBe(5); - terminal.WindowTop.ShouldBe(10); + // Act + terminal.BufferHeight = 500; - await Task.CompletedTask; - } + // Assert + terminal.BufferHeight.ShouldBe(500); - // ========== SetBufferSize Tests ========== + await Task.CompletedTask; + } - public static async Task Should_set_buffer_size() - { - // Arrange - using TestTerminal terminal = new(); + public static async Task Should_default_buffer_height_to_300() + { + // Arrange & Act + using TestTerminal terminal = new(); - // Act - terminal.SetBufferSize(120, 400); + // Assert + terminal.BufferHeight.ShouldBe(300); - // Assert - terminal.BufferWidth.ShouldBe(120); - terminal.BufferHeight.ShouldBe(400); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + // ========== WindowWidth Tests ========== - // ========== MoveBufferArea Tests ========== + public static async Task Should_get_and_set_window_width() + { + // Arrange + using TestTerminal terminal = new(); - public static async Task Should_call_move_buffer_area() - { - // Arrange - using TestTerminal terminal = new(); - - // Act - terminal.MoveBufferArea - ( - sourceLeft: 0, - sourceTop: 0, - sourceWidth: 10, - sourceHeight: 5, - targetLeft: 20, - targetTop: 10, - sourceChar: ' ', - sourceForeColor: ConsoleColor.Gray, - sourceBackColor: ConsoleColor.Black - ); - - // Assert - terminal.MoveBufferAreaCallCount.ShouldBe(1); - - await Task.CompletedTask; - } - - public static async Task Should_track_multiple_move_buffer_area_calls() - { - // Arrange - using TestTerminal terminal = new(); + // Act + terminal.WindowWidth = 100; - // Act - terminal.MoveBufferArea(0, 0, 10, 5, 20, 10, ' ', ConsoleColor.Gray, ConsoleColor.Black); - terminal.MoveBufferArea(5, 5, 15, 10, 30, 20, ' ', ConsoleColor.Gray, ConsoleColor.Black); - terminal.MoveBufferArea(10, 10, 20, 15, 40, 30, ' ', ConsoleColor.Gray, ConsoleColor.Black); + // Assert + terminal.WindowWidth.ShouldBe(100); - // Assert - terminal.MoveBufferAreaCallCount.ShouldBe(3); + await Task.CompletedTask; + } - await Task.CompletedTask; - } - - // ========== LargestWindowWidth Tests ========== + public static async Task Should_default_window_width_to_80() + { + // Arrange & Act + using TestTerminal terminal = new(); - public static async Task Should_get_largest_window_width() - { - // Arrange - using TestTerminal terminal = new(); - terminal.LargestWindowWidth = 200; + // Assert + terminal.WindowWidth.ShouldBe(80); - // Act - int width = terminal.LargestWindowWidth; - - // Assert - width.ShouldBe(200); - - await Task.CompletedTask; - } - - public static async Task Should_default_largest_window_width_to_120() - { - // Arrange & Act - using TestTerminal terminal = new(); + await Task.CompletedTask; + } - // Assert - terminal.LargestWindowWidth.ShouldBe(120); + // ========== ITerminal Getter Tests ========== - await Task.CompletedTask; - } - - // ========== LargestWindowHeight Tests ========== - - public static async Task Should_get_largest_window_height() - { - // Arrange - using TestTerminal terminal = new(); - terminal.LargestWindowHeight = 60; - - // Act - int height = terminal.LargestWindowHeight; - - // Assert - height.ShouldBe(60); - - await Task.CompletedTask; - } - - public static async Task Should_default_largest_window_height_to_40() - { - // Arrange & Act - using TestTerminal terminal = new(); - - // Assert - terminal.LargestWindowHeight.ShouldBe(40); - - await Task.CompletedTask; - } - - // ========== WindowWidth Tests (now has setter) ========== - - public static async Task Should_get_and_set_window_width() - { - // Arrange - using TestTerminal terminal = new(); - - // Act - terminal.WindowWidth = 100; - - // Assert - terminal.WindowWidth.ShouldBe(100); - - await Task.CompletedTask; - } - - public static async Task Should_default_window_width_to_80() - { - // Arrange & Act - using TestTerminal terminal = new(); + public static async Task Should_expose_geometry_via_iterminal_getters() + { + // Arrange + using TestTerminal terminal = new() + { + WindowWidth = 100, + WindowHeight = 30, + BufferWidth = 120, + BufferHeight = 400 + }; +#pragma warning disable CA1859 // Intentionally testing ITerminal interface access + ITerminal iterminal = terminal; +#pragma warning restore CA1859 - // Assert - terminal.WindowWidth.ShouldBe(80); + // Act & Assert + iterminal.WindowWidth.ShouldBe(100); + iterminal.WindowHeight.ShouldBe(30); + iterminal.BufferWidth.ShouldBe(120); + iterminal.BufferHeight.ShouldBe(400); - await Task.CompletedTask; + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.TerminalWindowBufferGeometry diff --git a/tests/test-terminal-context-01-integration.cs b/tests/test-terminal-context-01-integration.cs index 951a2c9..328c29b 100755 --- a/tests/test-terminal-context-01-integration.cs +++ b/tests/test-terminal-context-01-integration.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj // Tests for TestTerminalContext integration with Terminal.Instance @@ -11,133 +11,216 @@ 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() + [TestTag("TestTerminalContext")] + public class TestTerminalContextIntegrationTests { - // Arrange - ITerminal original = TimeWarp.Terminal.Terminal.Instance; - using TestTerminal testTerminal = new(); + [ModuleInitializer] + internal static void Register() => RegisterTests(); - // Act - TestTerminalContext.SetCurrent(testTerminal); + public static async Task Should_set_terminal_instance_when_context_set() + { + // Arrange + ITerminal original = TimeWarp.Terminal.Terminal.Instance; + using TestTerminal testTerminal = new(); - // Assert - TimeWarp.Terminal.Terminal.Instance.ShouldBe(testTerminal); + // Act + TestTerminalContext.SetCurrent(testTerminal); - // Cleanup - TestTerminalContext.ClearCurrent(); - TimeWarp.Terminal.Terminal.Instance = original; + // Assert + TimeWarp.Terminal.Terminal.Instance.ShouldBe(testTerminal); - await Task.CompletedTask; - } + // Cleanup + TestTerminalContext.ClearCurrent(); + TimeWarp.Terminal.Terminal.Instance = original; - 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); + await Task.CompletedTask; + } - // Act - TestTerminalContext.ClearCurrent(); + 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); - // Assert - TimeWarp.Terminal.Terminal.Instance.ShouldBe(original); + // Act + TestTerminalContext.ClearCurrent(); - await Task.CompletedTask; - } + // Assert + TimeWarp.Terminal.Terminal.Instance.ShouldBe(original); - public static async Task Should_restore_terminal_instance_on_dispose() - { - // Arrange - ITerminal original = TimeWarp.Terminal.Terminal.Instance; + await Task.CompletedTask; + } - // Act - using (TestTerminal testTerminal = new()) + public static async Task Should_restore_terminal_instance_on_dispose() { - TestTerminalContext.SetCurrent(testTerminal); - TimeWarp.Terminal.Terminal.Instance.ShouldBe(testTerminal); - } + // Arrange + ITerminal original = TimeWarp.Terminal.Terminal.Instance; - // Assert - Terminal.Instance should be restored after dispose - TimeWarp.Terminal.Terminal.Instance.ShouldBe(original); + // Act + using (TestTerminal testTerminal = new()) + { + TestTerminalContext.SetCurrent(testTerminal); + TimeWarp.Terminal.Terminal.Instance.ShouldBe(testTerminal); + } - await Task.CompletedTask; - } + // Assert - Terminal.Instance should be restored after dispose + TimeWarp.Terminal.Terminal.Instance.ShouldBe(original); - 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); + 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"); + // Act + TimeWarp.Terminal.Terminal.WriteLine("Hello from static API"); - // Assert - testTerminal.OutputContains("Hello from static API").ShouldBeTrue(); + // Assert + testTerminal.OutputContains("Hello from static API").ShouldBeTrue(); - // Cleanup - TestTerminalContext.ClearCurrent(); - TimeWarp.Terminal.Terminal.Instance = original; + // Cleanup + TestTerminalContext.ClearCurrent(); + TimeWarp.Terminal.Terminal.Instance = original; - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_provide_terminal_property() - { - // Arrange - using TestTerminal testTerminal = new(); - TestTerminalContext.SetCurrent(testTerminal); + public static async Task Should_provide_terminal_property() + { + // Arrange + using TestTerminal testTerminal = new(); + TestTerminalContext.SetCurrent(testTerminal); - // Act - TestTerminal retrieved = TestTerminalContext.Terminal; + // Act + TestTerminal retrieved = TestTerminalContext.Terminal; - // Assert - retrieved.ShouldBe(testTerminal); + // Assert + retrieved.ShouldBe(testTerminal); - // Cleanup - TestTerminalContext.ClearCurrent(); + // Cleanup + TestTerminalContext.ClearCurrent(); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_throw_when_terminal_accessed_without_context() - { - // Arrange - ensure no context - TestTerminalContext.ClearCurrent(); + public static async Task Should_throw_when_terminal_accessed_without_context() + { + // Arrange - ensure no context + TestTerminalContext.ClearCurrent(); - // Act & Assert - Should.Throw(() => _ = TestTerminalContext.Terminal); + // Act & Assert + Should.Throw(() => _ = TestTerminalContext.Terminal); - await Task.CompletedTask; - } + await Task.CompletedTask; + } - public static async Task Should_restore_on_use_scope_dispose() - { - // Arrange - ITerminal original = TimeWarp.Terminal.Terminal.Instance; - using TestTerminal testTerminal = new(); + public static async Task Should_not_touch_global_instance_when_clearing_without_snapshot() + { + // Arrange - set the process-global Instance directly (bypassing SetCurrent) + ITerminal original = TimeWarp.Terminal.Terminal.Instance; + using TestTerminal testTerminal = new(); + TimeWarp.Terminal.Terminal.Instance = testTerminal; + + try + { + // Act - ClearCurrent only manages the async-local context + TestTerminalContext.ClearCurrent(); + + // Assert - the directly-assigned global is not TestTerminalContext's to clean + TimeWarp.Terminal.Terminal.Instance.ShouldBe(testTerminal); + } + finally + { + TimeWarp.Terminal.Terminal.Instance = original; + } + + await Task.CompletedTask; + } - // Act - using (IDisposable scope = TestTerminalContext.Use(testTerminal)) + public static async Task Should_isolate_parallel_contexts() { - TimeWarp.Terminal.Terminal.Instance.ShouldBe(testTerminal); - TimeWarp.Terminal.Terminal.WriteLine("Scoped output"); + // Arrange - two async flows, each with its own context; the global is never mutated + ITerminal original = TimeWarp.Terminal.Terminal.Instance; + using TestTerminal terminalA = new(); + using TestTerminal terminalB = new(); + + // Act + Task taskA = Task.Run(async () => + { + using IDisposable scope = TestTerminalContext.Use(terminalA); + await Task.Delay(10); + TimeWarp.Terminal.Terminal.WriteLine("from A"); + await Task.Delay(10); + TimeWarp.Terminal.Terminal.WriteLine("A again"); + }); + Task taskB = Task.Run(async () => + { + using IDisposable scope = TestTerminalContext.Use(terminalB); + await Task.Delay(10); + TimeWarp.Terminal.Terminal.WriteLine("from B"); + await Task.Delay(10); + TimeWarp.Terminal.Terminal.WriteLine("B again"); + }); + await Task.WhenAll(taskA, taskB); + + // Assert - each context saw only its own writes and the global never changed + terminalA.Output.ShouldContain("from A"); + terminalA.Output.ShouldContain("A again"); + terminalA.Output.ShouldNotContain("from B"); + terminalB.Output.ShouldContain("from B"); + terminalB.Output.ShouldContain("B again"); + terminalB.Output.ShouldNotContain("from A"); + TimeWarp.Terminal.Terminal.Instance.ShouldBe(original); } - // Assert - testTerminal.OutputContains("Scoped output").ShouldBeTrue(); - TimeWarp.Terminal.Terminal.Instance.ShouldBe(original); + public static async Task Should_restore_format_provider_on_clear() + { + // Arrange + IFormatProvider? originalProvider = TimeWarp.Terminal.Terminal.FormatProvider; + using TestTerminal testTerminal = new(); + + try + { + // Act - a test that sets FormatProvider inside a context gets it restored on clear + TestTerminalContext.SetCurrent(testTerminal); + TimeWarp.Terminal.Terminal.FormatProvider = System.Globalization.CultureInfo.InvariantCulture; + TestTerminalContext.ClearCurrent(); + + // Assert + TimeWarp.Terminal.Terminal.FormatProvider.ShouldBe(originalProvider); + } + finally + { + TimeWarp.Terminal.Terminal.FormatProvider = originalProvider; + } + + await Task.CompletedTask; + } - 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; + } } -} } diff --git a/tests/unicode-width-01-basic.cs b/tests/unicode-width-01-basic.cs index 3db2e6a..eb39109 100755 --- a/tests/unicode-width-01-basic.cs +++ b/tests/unicode-width-01-basic.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #:project $(SourceDirectory)timewarp-terminal/timewarp-terminal.csproj using System.Text; @@ -12,149 +12,184 @@ namespace TimeWarp.Terminal.Tests.Core.UnicodeWidthTests { -[TestTag("Widgets")] -public class UnicodeWidthBasicTests -{ - [ModuleInitializer] - internal static void Register() => RegisterTests(); - - public static async Task Should_return_width_1_for_ascii_characters() + [TestTag("Widgets")] + public class UnicodeWidthBasicTests { - UnicodeWidth.GetTextWidth("Hello").ShouldBe(5); - UnicodeWidth.GetTextWidth("ABC").ShouldBe(3); + [ModuleInitializer] + internal static void Register() => RegisterTests(); - await Task.CompletedTask; - } + public static async Task Should_return_width_1_for_ascii_characters() + { + UnicodeWidth.GetTextWidth("Hello").ShouldBe(5); + UnicodeWidth.GetTextWidth("ABC").ShouldBe(3); - public static async Task Should_return_width_0_for_empty_and_null() - { - UnicodeWidth.GetTextWidth("").ShouldBe(0); - UnicodeWidth.GetTextWidth(null).ShouldBe(0); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_return_width_0_for_empty_and_null() + { + UnicodeWidth.GetTextWidth("").ShouldBe(0); + UnicodeWidth.GetTextWidth(null).ShouldBe(0); - public static async Task Should_return_width_2_for_basic_emoji() - { - UnicodeWidth.GetTextWidth("😀").ShouldBe(2); - UnicodeWidth.GetTextWidth("🎉").ShouldBe(2); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_return_width_2_for_basic_emoji() + { + UnicodeWidth.GetTextWidth("😀").ShouldBe(2); + UnicodeWidth.GetTextWidth("🎉").ShouldBe(2); - public static async Task Should_return_width_2_for_weather_emoji() - { - // 🌤️ is U+1F324 + U+FE0F (variation selector) - UnicodeWidth.GetTextWidth("🌤️").ShouldBe(2); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_return_width_2_for_weather_emoji() + { + // 🌤️ is U+1F324 + U+FE0F (variation selector) + UnicodeWidth.GetTextWidth("🌤️").ShouldBe(2); - public static async Task Should_return_width_2_for_pin_emoji() - { - UnicodeWidth.GetTextWidth("📍").ShouldBe(2); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_return_width_2_for_pin_emoji() + { + UnicodeWidth.GetTextWidth("📍").ShouldBe(2); - public static async Task Should_return_width_2_for_thermometer_emoji() - { - // 🌡️ is U+1F321 + U+FE0F - UnicodeWidth.GetTextWidth("🌡️").ShouldBe(2); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_return_width_2_for_thermometer_emoji() + { + // 🌡️ is U+1F321 + U+FE0F + UnicodeWidth.GetTextWidth("🌡️").ShouldBe(2); - public static async Task Should_return_width_2_for_cloud_emoji() - { - // ☁️ is U+2601 + U+FE0F - UnicodeWidth.GetTextWidth("☁️").ShouldBe(2); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_return_width_2_for_cloud_emoji() + { + // ☁️ is U+2601 + U+FE0F + UnicodeWidth.GetTextWidth("☁️").ShouldBe(2); - public static async Task Should_return_width_2_for_cjk_character() - { - UnicodeWidth.GetTextWidth("漢").ShouldBe(2); - UnicodeWidth.GetTextWidth("字").ShouldBe(2); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_return_width_2_for_cjk_character() + { + UnicodeWidth.GetTextWidth("漢").ShouldBe(2); + UnicodeWidth.GetTextWidth("字").ShouldBe(2); - public static async Task Should_return_width_2_for_fullwidth_form() - { - // U+FF21 = fullwidth 'A' - UnicodeWidth.GetTextWidth("\uFF21").ShouldBe(2); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_return_width_2_for_fullwidth_form() + { + // U+FF21 = fullwidth 'A' + UnicodeWidth.GetTextWidth("\uFF21").ShouldBe(2); - public static async Task Should_return_width_2_for_hangul() - { - UnicodeWidth.GetTextWidth("한").ShouldBe(2); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_return_width_2_for_hangul() + { + UnicodeWidth.GetTextWidth("한").ShouldBe(2); - public static async Task Should_return_width_2_for_flag_emoji() - { - // 🇺🇸 = U+1F1FA + U+1F1F8 (two regional indicators forming one grapheme cluster) - UnicodeWidth.GetTextWidth("🇺🇸").ShouldBe(2); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_return_width_2_for_flag_emoji() + { + // 🇺🇸 = U+1F1FA + U+1F1F8 (two regional indicators forming one grapheme cluster) + UnicodeWidth.GetTextWidth("🇺🇸").ShouldBe(2); - public static async Task Should_return_width_2_for_skin_tone_emoji() - { - // 👋🏽 = wave + Fitzpatrick modifier - UnicodeWidth.GetTextWidth("👋🏽").ShouldBe(2); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_return_width_2_for_skin_tone_emoji() + { + // 👋🏽 = wave + Fitzpatrick modifier + UnicodeWidth.GetTextWidth("👋🏽").ShouldBe(2); - public static async Task Should_return_correct_width_for_mixed_string() - { - // "📍 Location" = emoji(2) + space(1) + "Location"(8) = 11 - UnicodeWidth.GetTextWidth("📍 Location").ShouldBe(11); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_return_correct_width_for_mixed_string() + { + // "📍 Location" = emoji(2) + space(1) + "Location"(8) = 11 + UnicodeWidth.GetTextWidth("📍 Location").ShouldBe(11); - public static async Task Should_return_correct_width_for_multiple_emoji() - { - // "📍🌡️☁️" = 2 + 2 + 2 = 6 - UnicodeWidth.GetTextWidth("📍🌡️☁️").ShouldBe(6); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_return_correct_width_for_multiple_emoji() + { + // "📍🌡️☁️" = 2 + 2 + 2 = 6 + UnicodeWidth.GetTextWidth("📍🌡️☁️").ShouldBe(6); - public static async Task Should_return_correct_width_for_cjk_string() - { - // 5 CJK characters = 10 - UnicodeWidth.GetTextWidth("漢字テスト").ShouldBe(10); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_return_correct_width_for_cjk_string() + { + // 5 CJK characters = 10 + UnicodeWidth.GetTextWidth("漢字テスト").ShouldBe(10); - public static async Task Should_return_width_1_for_box_drawing_characters() - { - UnicodeWidth.GetTextWidth("─").ShouldBe(1); - UnicodeWidth.GetTextWidth("│").ShouldBe(1); - UnicodeWidth.GetTextWidth("╭").ShouldBe(1); - UnicodeWidth.GetTextWidth("╮").ShouldBe(1); + await Task.CompletedTask; + } - await Task.CompletedTask; - } + public static async Task Should_return_width_1_for_box_drawing_characters() + { + UnicodeWidth.GetTextWidth("─").ShouldBe(1); + UnicodeWidth.GetTextWidth("│").ShouldBe(1); + UnicodeWidth.GetTextWidth("╭").ShouldBe(1); + UnicodeWidth.GetTextWidth("╮").ShouldBe(1); - public static async Task Should_return_rune_width_for_single_runes() - { - UnicodeWidth.GetRuneWidth(new Rune('A')).ShouldBe(1); - UnicodeWidth.GetRuneWidth(new Rune('漢')).ShouldBe(2); - UnicodeWidth.GetRuneWidth(new Rune(0x200D)).ShouldBe(0); // ZWJ - UnicodeWidth.GetRuneWidth(new Rune(0xFE0F)).ShouldBe(0); // VS16 + await Task.CompletedTask; + } + + public static async Task Should_return_rune_width_for_single_runes() + { + UnicodeWidth.GetRuneWidth(new Rune('A')).ShouldBe(1); + UnicodeWidth.GetRuneWidth(new Rune('漢')).ShouldBe(2); + UnicodeWidth.GetRuneWidth(new Rune(0x200D)).ShouldBe(0); // ZWJ + UnicodeWidth.GetRuneWidth(new Rune(0xFE0F)).ShouldBe(0); // VS16 + + await Task.CompletedTask; + } + + public static async Task Should_return_width_1_for_nfd_combining_sequence() + { + // NFD "é" = "e" + U+0301 (combining acute accent) renders as one column + UnicodeWidth.GetTextWidth("e\u0301").ShouldBe(1); + + await Task.CompletedTask; + } + + public static async Task Should_return_width_1_for_narrow_smp_symbol_blocks() + { + // U+1F0A1 Playing Card Ace of Spades (EAW=N) + UnicodeWidth.GetRuneWidth(new Rune(0x1F0A1)).ShouldBe(1); + + // U+1F70A Alchemical Symbol for Vinegar (EAW=N) + UnicodeWidth.GetRuneWidth(new Rune(0x1F70A)).ShouldBe(1); + + await Task.CompletedTask; + } + + public static async Task Should_return_width_2_for_mahjong_red_dragon() + { + // U+1F004 has Emoji_Presentation=Yes despite its narrow block + UnicodeWidth.GetRuneWidth(new Rune(0x1F004)).ShouldBe(2); + + await Task.CompletedTask; + } + + public static async Task Should_return_width_2_for_tangut_character() + { + // U+17000 Tangut Ideograph (EAW=W) + UnicodeWidth.GetRuneWidth(new Rune(0x17000)).ShouldBe(2); - await Task.CompletedTask; + await Task.CompletedTask; + } } -} } // namespace TimeWarp.Terminal.Tests.Core.UnicodeWidthTests diff --git a/tools/dev-cli/Directory.Build.props b/tools/dev-cli/Directory.Build.props index 5bee005..1fc3d4b 100644 --- a/tools/dev-cli/Directory.Build.props +++ b/tools/dev-cli/Directory.Build.props @@ -14,7 +14,14 @@ - $(NoWarn);CA1031;CA1034;CA1303;CA1508;CA1515;CA1849;CA2007;CA2016;CA2000;RCS1046;CA5399;IL2104;IL3053;IDE0065 + + + + $(NoWarn);CA1031;CA1034;CA1303;CA1508;CA1515;CA1849;CA2007;CA2016;CA2000;RCS1046;CA5399;IL2026;IL2104;IL3050;IL3053;IDE0065;IDE0005;IDE0052;IDE0055;IDE0058;IDE0160;IDE0211;IDE0290 true diff --git a/tools/dev-cli/dev.cs b/tools/dev-cli/dev.cs old mode 100644 new mode 100755 index 4975ac9..37bfb73 --- a/tools/dev-cli/dev.cs +++ b/tools/dev-cli/dev.cs @@ -1,4 +1,4 @@ -#!/usr/bin/dotnet -- +#!/usr/bin/env -S dotnet -- #region Purpose // Development CLI for TimeWarp.Terminal repository operations diff --git a/tools/dev-cli/endpoints/test.cs b/tools/dev-cli/endpoints/test.cs index 3c213db..56fc83a 100644 --- a/tools/dev-cli/endpoints/test.cs +++ b/tools/dev-cli/endpoints/test.cs @@ -29,19 +29,41 @@ public async ValueTask Handle(TestCommand command, CancellationToken ct) string? repoRoot = Git.FindRoot() ?? throw new InvalidOperationException("Could not find git repository root"); - Terminal.WriteLine("Running tests..."); + // The test suite is .NET 10 file-based apps (runfiles) under tests/, + // not VSTest projects, so `dotnet test` on the solution finds nothing + string testsDir = Path.Combine(repoRoot, "tests"); + string[] testFiles = Directory.GetFiles(testsDir, "*.cs", SearchOption.TopDirectoryOnly); + Array.Sort(testFiles, StringComparer.Ordinal); - int exitCode = await Shell.Builder("dotnet") - .WithArguments("test", Path.Combine(repoRoot, "timewarp-terminal.slnx"), "--no-build", "-v", "n") - .WithWorkingDirectory(repoRoot) - .RunAsync(); + Terminal.WriteLine($"Running {testFiles.Length} test file(s)..."); - if (exitCode != 0) + List failed = []; + foreach (string testFile in testFiles) { + string fileName = Path.GetFileName(testFile); + Terminal.WriteLine($" Running {fileName}..."); + int exitCode = await Shell.Builder("dotnet") + .WithArguments(testFile) + .WithWorkingDirectory(repoRoot) + .RunAsync(); + if (exitCode != 0) + { + failed.Add(fileName); + } + } + + if (failed.Count > 0) + { + Terminal.WriteLine($"\n✗ {failed.Count} of {testFiles.Length} test file(s) failed:"); + foreach (string fileName in failed) + { + Terminal.WriteLine($" {fileName}"); + } + throw new InvalidOperationException("Tests failed!"); } - Terminal.WriteLine("\n✓ All tests passed"); + Terminal.WriteLine($"\n✓ All {testFiles.Length} test file(s) passed"); return Unit.Value; } } diff --git a/tools/dev-cli/endpoints/workflow.cs b/tools/dev-cli/endpoints/workflow.cs index fc0c35e..7229589 100644 --- a/tools/dev-cli/endpoints/workflow.cs +++ b/tools/dev-cli/endpoints/workflow.cs @@ -7,7 +7,7 @@ // ═══════════════════════════════════════════════════════════════════════════════ // Orchestrates the full CI/CD pipeline. // For PR: clean -> build -> verify-samples -> test -// For release: clean -> build -> check-version -> pack +// For release: clean -> build -> verify-samples -> test -> check-version -> pack -> push -> notify timewarp-software namespace DevCli; @@ -65,7 +65,10 @@ private async Task RunPrWorkflowAsync(string repoRoot, CancellationToken cancell .WithArguments("clean", Path.Combine(repoRoot, "timewarp-terminal.slnx"), "-v", "q") .WithWorkingDirectory(repoRoot) .RunAsync(); - if (exitCode != 0) throw new InvalidOperationException("Clean failed!"); + if (exitCode != 0) + { + throw new InvalidOperationException("Clean failed!"); + } // Step 2: Build Terminal.WriteLine("\nStep 2/4: Build"); @@ -73,60 +76,60 @@ private async Task RunPrWorkflowAsync(string repoRoot, CancellationToken cancell .WithArguments("build", Path.Combine(repoRoot, "timewarp-terminal.slnx"), "-c", "Release") .WithWorkingDirectory(repoRoot) .RunAsync(); - if (exitCode != 0) throw new InvalidOperationException("Build failed!"); + if (exitCode != 0) + { + throw new InvalidOperationException("Build failed!"); + } // Step 3: Verify Samples Terminal.WriteLine("\nStep 3/4: Verify Samples"); - string samplesDir = Path.Combine(repoRoot, "samples"); - if (Directory.Exists(samplesDir)) - { - string[] sampleFiles = Directory.GetFiles(samplesDir, "*.cs", SearchOption.TopDirectoryOnly); - foreach (string sampleFile in sampleFiles) - { - string fileName = Path.GetFileName(sampleFile); - Terminal.WriteLine($" Verifying {fileName}..."); - exitCode = await Shell.Builder("dotnet") - .WithArguments("run", sampleFile, "--", "--help") - .WithWorkingDirectory(samplesDir) - .RunAsync(); - if (exitCode != 0) throw new InvalidOperationException($"Sample verification failed: {fileName}"); - } - } + await VerifySamplesAsync(repoRoot); // Step 4: Test Terminal.WriteLine("\nStep 4/4: Test"); - exitCode = await Shell.Builder("dotnet") - .WithArguments("test", Path.Combine(repoRoot, "timewarp-terminal.slnx"), "--no-build", "-v", "n") - .WithWorkingDirectory(repoRoot) - .RunAsync(); - if (exitCode != 0) throw new InvalidOperationException("Tests failed!"); + await RunTestSuiteAsync(repoRoot); Terminal.WriteLine("\n✓ CI Pipeline completed successfully"); } private async Task RunReleaseWorkflowAsync(string repoRoot, string? apiKey, CancellationToken ct) { - Terminal.WriteLine("Release Pipeline: clean -> build -> check-version -> pack"); + Terminal.WriteLine("Release Pipeline: clean -> build -> verify-samples -> test -> check-version -> pack"); Terminal.WriteLine(""); // Step 1: Clean - Terminal.WriteLine("Step 1/4: Clean"); + Terminal.WriteLine("Step 1/6: Clean"); int exitCode = await Shell.Builder("dotnet") .WithArguments("clean", Path.Combine(repoRoot, "timewarp-terminal.slnx"), "-v", "q") .WithWorkingDirectory(repoRoot) .RunAsync(); - if (exitCode != 0) throw new InvalidOperationException("Clean failed!"); + if (exitCode != 0) + { + throw new InvalidOperationException("Clean failed!"); + } // Step 2: Build - Terminal.WriteLine("\nStep 2/4: Build"); + Terminal.WriteLine("\nStep 2/6: Build"); exitCode = await Shell.Builder("dotnet") .WithArguments("build", Path.Combine(repoRoot, "timewarp-terminal.slnx"), "-c", "Release") .WithWorkingDirectory(repoRoot) .RunAsync(); - if (exitCode != 0) throw new InvalidOperationException("Build failed!"); + if (exitCode != 0) + { + throw new InvalidOperationException("Build failed!"); + } + + // Step 3: Verify Samples — a release must never ship from a commit + // whose samples or tests were not exercised on the release event itself + Terminal.WriteLine("\nStep 3/6: Verify Samples"); + await VerifySamplesAsync(repoRoot); + + // Step 4: Test + Terminal.WriteLine("\nStep 4/6: Test"); + await RunTestSuiteAsync(repoRoot); - // Step 3: Check Version - Terminal.WriteLine("\nStep 3/4: Check Version"); + // Step 5: Check Version + Terminal.WriteLine("\nStep 5/6: Check Version"); string propsPath = Path.Combine(repoRoot, "source", "Directory.Build.props"); string? version = null; if (File.Exists(propsPath)) @@ -150,6 +153,38 @@ private async Task RunReleaseWorkflowAsync(string repoRoot, string? apiKey, Canc } Terminal.WriteLine($"Current version: {version}"); + + // A release event must publish the version named by its tag — otherwise publishing + // a "v1.0.0" GitHub release with props still at an older version silently pushes that stale version + string? tagName = Environment.GetEnvironmentVariable("GITHUB_REF_NAME"); + if (string.IsNullOrEmpty(tagName)) + { + string? gitHubRef = Environment.GetEnvironmentVariable("GITHUB_REF"); + if (!string.IsNullOrEmpty(gitHubRef) && gitHubRef.StartsWith("refs/tags/", StringComparison.Ordinal)) + { + tagName = gitHubRef["refs/tags/".Length..]; + } + } + + string? releaseEventName = Environment.GetEnvironmentVariable("GITHUB_EVENT_NAME"); + if (releaseEventName == "release" && !string.IsNullOrEmpty(tagName)) + { + string tagVersion = tagName.StartsWith('v') ? tagName[1..] : tagName; + if (!string.Equals(tagVersion, version, StringComparison.Ordinal)) + { + Terminal.WriteLine("\n✗ Release tag does not match project version"); + Terminal.WriteLine($" Tag: {tagName} (version {tagVersion})"); + Terminal.WriteLine($" Project: {version}"); + throw new InvalidOperationException($"Release tag '{tagName}' does not match project version '{version}'"); + } + + Terminal.WriteLine($"✓ Release tag {tagName} matches project version"); + } + else + { + Terminal.WriteLine("No release tag available (local run) - skipping tag/version match check"); + } + Terminal.WriteLine("Checking NuGet.org..."); using HttpClient client = new(); @@ -177,13 +212,13 @@ private async Task RunReleaseWorkflowAsync(string repoRoot, string? apiKey, Canc Terminal.WriteLine(" Assuming version is available"); } - // Step 4: Pack - Terminal.WriteLine("\nStep 4/4: Pack"); + // Step 6: Pack + Terminal.WriteLine("\nStep 6/6: Pack"); string artifactsDir = Path.Combine(repoRoot, "artifacts", "packages"); Directory.CreateDirectory(artifactsDir); exitCode = await Shell.Builder("dotnet") - .WithArguments("pack", Path.Combine(repoRoot, "source", "timewarp-terminal", "timewarp-terminal.csproj"), "-c", "Release", "-o", artifactsDir) + .WithArguments("pack", Path.Combine(repoRoot, "source", "timewarp-terminal", "timewarp-terminal.csproj"), "-c", "Release", "-o", artifactsDir, "-p:ContinuousIntegrationBuild=true") .WithWorkingDirectory(repoRoot) .RunAsync(); @@ -211,11 +246,111 @@ private async Task RunReleaseWorkflowAsync(string repoRoot, string? apiKey, Canc .WithApiKey(apiKey) .RunAsync(ct); - if (exitCode != 0) throw new InvalidOperationException($"NuGet push failed: {packageName}"); + if (exitCode != 0) + { + throw new InvalidOperationException($"NuGet push failed: {packageName}"); + } } Terminal.WriteLine("✓ Packages pushed to NuGet.org"); + + await NotifySoftwareSiteAsync(repoRoot, version); + } + } + + private async Task NotifySoftwareSiteAsync(string repoRoot, string version) + { + // Signal timewarp-software to rebuild the site so the new release shows up + // immediately instead of waiting for its nightly cron backstop. Best effort: + // a failure here must never fail a release that already pushed to NuGet. + // Cross-repo repository_dispatch needs a credential with write access to + // timewarp-software — locally gh's stored auth suffices; in GitHub Actions + // the default GITHUB_TOKEN cannot reach other repos, so workflow.yml mints a + // short-lived installation token from the org's Rebuild Dispatcher GitHub App + // and passes it as GH_TOKEN. + Terminal.WriteLine("\nNotifying timewarp-software to rebuild the site..."); + int exitCode = await Shell.Builder("gh") + .WithArguments + ( + "api", + "repos/TimeWarpEngineering/timewarp-software/dispatches", + "-f", "event_type=rebuild", + "-f", "client_payload[package]=TimeWarp.Terminal", + "-f", $"client_payload[version]={version}" + ) + .WithWorkingDirectory(repoRoot) + .RunAsync(); + + if (exitCode == 0) + { + Terminal.WriteLine("✓ timewarp-software rebuild dispatched"); + } + else + { + Terminal.WriteLine("⚠ Could not dispatch timewarp-software rebuild (non-fatal; the site rebuilds nightly)"); + } + } + + private async Task VerifySamplesAsync(string repoRoot) + { + string samplesDir = Path.Combine(repoRoot, "samples"); + if (!Directory.Exists(samplesDir)) + { + return; } + + string[] sampleFiles = Directory.GetFiles(samplesDir, "*.cs", SearchOption.TopDirectoryOnly); + Array.Sort(sampleFiles, StringComparer.Ordinal); + foreach (string sampleFile in sampleFiles) + { + string fileName = Path.GetFileName(sampleFile); + Terminal.WriteLine($" Verifying {fileName}..."); + int exitCode = await Shell.Builder("dotnet") + .WithArguments("run", sampleFile, "--", "--help") + .WithWorkingDirectory(samplesDir) + .RunAsync(); + if (exitCode != 0) + { + throw new InvalidOperationException($"Sample verification failed: {fileName}"); + } + } + } + + private async Task RunTestSuiteAsync(string repoRoot) + { + // The test suite is .NET 10 file-based apps (runfiles) under tests/, + // not VSTest projects, so `dotnet test` on the solution finds nothing + string testsDir = Path.Combine(repoRoot, "tests"); + string[] testFiles = Directory.GetFiles(testsDir, "*.cs", SearchOption.TopDirectoryOnly); + Array.Sort(testFiles, StringComparer.Ordinal); + + List failed = []; + foreach (string testFile in testFiles) + { + string fileName = Path.GetFileName(testFile); + Terminal.WriteLine($" Running {fileName}..."); + int exitCode = await Shell.Builder("dotnet") + .WithArguments(testFile) + .WithWorkingDirectory(repoRoot) + .RunAsync(); + if (exitCode != 0) + { + failed.Add(fileName); + } + } + + if (failed.Count > 0) + { + Terminal.WriteLine($"\n✗ {failed.Count} of {testFiles.Length} test file(s) failed:"); + foreach (string fileName in failed) + { + Terminal.WriteLine($" {fileName}"); + } + + throw new InvalidOperationException("Tests failed!"); + } + + Terminal.WriteLine($" ✓ {testFiles.Length} test file(s) passed"); } } }