diff --git a/.llm/skills/index.md b/.llm/skills/index.md index bdd8bf4..04c57eb 100644 --- a/.llm/skills/index.md +++ b/.llm/skills/index.md @@ -34,6 +34,7 @@ Agent Skills ([SKILL.md format](https://agentskills.io)) for specific tasks. Inv | [context-aware-completion](./context-aware-completion/SKILL.md) | Register context-aware commands with CommandDefinition, gate execution by CommandExecutionContexts, and attach CommandCompletionProvider argument completion (staged providers, TryComplete, TerminalUI token cycling) in DxCommandTerminal. Use when writing commands that need argument completion or execution-context gating, wiring chained completions like item name then item action, or debugging why a command does not run or complete. | | [custom-argument-parsing](./custom-argument-parsing/SKILL.md) | Parse CommandArg values with TryGet, register custom CommandArgParser functions, and control input cleaning, delimiters, and quote handling sets. Use when writing command handlers that read arguments, adding parsers for new types (e.g. JSON), or fixing "failed to parse" behavior. | | [input-system-integration](./input-system-integration/SKILL.md) | Wire DxCommandTerminal input - configurable keyboard hotkeys, Unity's new Input System / PlayerInput bindings, the HandlePrevious/HandleNext/ToggleSmall/ToggleFull/CompleteCommand/EnterCommand messages, and input precedence. Use when adding or changing terminal keybindings, PlayerInput wiring, or fixing input handling bugs (including WebGL). | +| [log-echo-contract](./log-echo-contract/SKILL.md) | Preserve the DxCommandTerminal log echo contract - both console surfaces write the typed line into Terminal.Buffer as TerminalLogType.Input before a command handler runs, so a handler that reads the log sees its own echo as the newest entry. Covers OutputLength, why a fixed -1/-2 offset is wrong, why the palette and the copy commands filter differently, why the log view must still SHOW echoes, and the test-helper trap that hides all of it. Use when writing or reviewing a command that reads the log, when a command returns its own name or a command line instead of a message, when touching trace/copy-last/copy-log/CommandPaletteUI.CollectOutput, or when a log-reading test passes against code the product never runs. | | [register-terminal-command](./register-terminal-command/SKILL.md) | Register terminal commands in DxCommandTerminal via RegisterCommandAttribute or Terminal.Shell.AddCommand, including name inference, arg count bounds, hint text, editor/development-only flags, and ignoring built-in commands. Use when adding console commands/cheats, changing command signatures, debugging why a command is not recognized, or extending the typed builder and argument-spec internals. | | [run-terminal-tests](./run-terminal-tests/SKILL.md) | Write and run DxCommandTerminal PlayMode tests (Unity Test Runner, Tests/Runtime/, NUnit) following the repo's test style for CommandArg, CommandShell, and Terminal behavior. Use when adding tests, running the test suite, or debugging failing terminal tests. | | [session-facade](./session-facade/SKILL.md) | Explain and preserve the DxCommandTerminal backend session nullability contract - TerminalSession.Current is never null, the Terminal facade getters (Buffer/Shell/History/AutoComplete) read null before bootstrap and after play-session reset, TerminalUI and CommandPaletteUI apply or bootstrap the session on enable, and command registration stays deferred to first use. Use when touching TerminalUI/CommandPaletteUI lifecycle, adding session consumers, writing bootstrap code, reviewing null-chain questions on Terminal accessors, or debugging why Terminal.Log or Terminal.Shell is null. | diff --git a/.llm/skills/log-echo-contract/SKILL.md b/.llm/skills/log-echo-contract/SKILL.md new file mode 100644 index 0000000..a360327 --- /dev/null +++ b/.llm/skills/log-echo-contract/SKILL.md @@ -0,0 +1,108 @@ +--- +name: log-echo-contract +description: Preserve the DxCommandTerminal log echo contract - both console surfaces write the typed line into Terminal.Buffer as TerminalLogType.Input before a command handler runs, so a handler that reads the log sees its own echo as the newest entry. Covers OutputLength, why a fixed -1/-2 offset is wrong, why the palette and the copy commands filter differently, why the log view must still SHOW echoes, and the test-helper trap that hides all of it. Use when writing or reviewing a command that reads the log, when a command returns its own name or a command line instead of a message, when touching trace/copy-last/copy-log/CommandPaletteUI.CollectOutput, or when a log-reading test passes against code the product never runs. +metadata: + category: Feature +--- + +# The Log Echo Contract + +## The rule + +Both console surfaces echo the line a developer typed into the log BEFORE the +command handler is reached: + +| Surface | Site | +| --- | --- | +| `TerminalUI.EnterCommand` | `Runtime/CommandTerminal/UI/TerminalUI.cs` - `Terminal.Log(TerminalLogType.Input, commandText)` then `RunCommand` | +| `CommandPaletteUI` submit | `Runtime/CommandTerminal/UI/CommandPaletteUI.cs` - same order | + +So **any code running inside a command handler that reads "the newest entry" is +reading the echo of the command that is running.** `copy-last` returned the +literal text `copy-last`. The palette's own echo at submit time is in its +output window for the same reason. + +`TerminalLogType.Input` has exactly two writers, both one per submit. Anything +else that logs is output, whatever its type. + +## Read the log through OutputLength, never a fixed offset + +`BuiltinCommands.OutputLength(window, logCount)` returns how much of the window +is not trailing command echoes, walking back from the newest end. + +```csharp +int logCount = ReadLogWindow(buffer, out LogItem[] window); +int usable = OutputLength(window, logCount); // echoes are window[usable..logCount) +int first = Math.Max(0, usable - Math.Min(lineCount, usable)); +``` + +Why a fixed offset is wrong twice over: + +- `logCount - 1` is the echo, always, from a console. +- `logCount - 2` is the message before it ONLY when the command above this one + said something. A command that produced no output leaves its own echo there, + so `trace` printed a command line. And dispatched programmatically + (`shell.RunCommand("trace")`) there is no echo, so the fixed offset skipped + the newest message. + +`OutputLength` returns 0 for an all-echo window, so `usable - 1 == -1` is a +clean "nothing before the echo" sentinel. + +## Two valid filter shapes, and why they differ + +| Consumer | Window | Shape | Why | +| --- | --- | --- | --- | +| `BuiltinCommands` copy and trace | the whole buffer | trailing-echo walk (`OutputLength`) | an echo further back is a command the developer ran, and a transcript wants those as much as the output | +| `CommandPaletteUI.CollectOutput` | this run only, from a `Version` delta captured before the echo | whole-window `type == Input` skip | the window starts at the version delta, so it cannot contain another submit's echo | + +Neither is a missed case. Pick by what the window means, and say which in a +comment. Do not unify them. + +## The log view must SHOW echoes + +`TerminalUI.RefreshLogs` renders every entry, echo included, and colors it via +the `--text-input-echo` class. That is correct: the developer typed it, and it +belongs on screen. The contract above is about reading the log programmatically +for a result, not about what is displayed. Do not "fix" the view by filtering +echoes out. + +## The test-helper trap (this is how the bug survived two review rounds) + +A test helper that calls `shell.RunCommand(line)` directly exercises a path the +product never takes, because the product echoes first. Every log-reading test +then passes against code that is broken in the console. + +```csharp +// WRONG: no echo is ever written, so a copy bug is invisible. +_shell.RunCommand(line); + +// RIGHT: mirrors EnterCommand's order. +Terminal.Log(TerminalLogType.Input, line); +_shell.RunCommand(line); +``` + +When a handler reads the log, its test helper owes the echo. When it does not, +the plain dispatch is fine - but the difference has to be a decision, not an +accident. + +Symptom to recognise: a test that asserts the copied text equals the message +above, and passes, while the command returns its own name in the console. + +## Sweep checklist + +When a command starts reading the log: + +1. Grep `Terminal.Buffer`, `CommandLog`, `CopyTo`, `Logs`, `LogItem` in the + handler. Any hit needs `OutputLength` or an explicit type filter. +2. Grep for `- 1`, `- 2`, "second newest", "previous message" near a log read. +3. Re-read the test helper. Does it echo? +4. Check the other two consumers still agree: `CommandTrace`, + `CommandPaletteUI.CollectOutput`, `TerminalUI.RefreshLogs`. + +## Related + +- [session-facade](../session-facade/SKILL.md) - the nullability boundary on + `Terminal.Buffer` itself. This skill is about what is IN the buffer once it is + non-null during dispatch. +- [register-terminal-command](../register-terminal-command/SKILL.md) - how the + handler gets here. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0847408..2dcc8ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- `copy-last` and `copy-log [n]` put the console log on the system clipboard: the newest line, or the last N, joined with newlines. Until now nothing could be read out of the console at all - the log view is the only place a message, a command echo, or a failing value ever appears, and none of it could be selected, so a developer who saw `Command 'give' threw ArgumentException` in a device build had to retype it or screenshot the game window. Both surfaces echo the line you typed into the log before the command runs, so a copy skips the echoes at the newest end: `copy-last` returns the line you were reading, not the word `copy-last`. An echo further back stays, because a transcript of a session wants the commands in it as much as the output. A copied line is the line the log shows, so the stack trace is not included (`trace` is how you read one) and copying 256 lines does not carry 256 traces. A clipboard write is a request a platform can decline - tvOS has none, and a browser may refuse without a user gesture - so the write is read back and compared, and a copy that did not happen says so instead of silently doing nothing. An empty log, a log holding only commands, a count that is not a number, and a count below one are each reported rather than ignored. +- The log scrolls with the keyboard while the command line holds focus. Page Up and Page Down page it by one viewport, and Ctrl+Home (Cmd+Home on macOS) and Ctrl+End reach its oldest and newest lines, so a developer looking for one error in a full 256-entry buffer no longer has to drag a 10px scrollbar. Home and End without the modifier stay with the command line, where they move the caret as a text field should, and every other key is untouched. Paging back detaches the tail exactly as scrolling back does, so new output no longer pulls the view to the end while it is being read; Ctrl+End is the way back to following. A page that reaches the end leaves the log following rather than parked a line short of an end that is still growing. - `CommandLog.CopyTo(LogItem[] destination)` copies the log's visible window, oldest first, into a caller-owned array and returns how many entries it wrote. Reading `CommandLog.Logs` counts and then indexes as two separate reads, so a log written from another thread between them can be missed or, if the buffer was cleared or shrunk, throw. One `CopyTo` call is one consistent view. A destination shorter than the window truncates to its oldest entries, so size it for the largest buffer the session configures. - Paste on both console surfaces: Ctrl+V (Cmd+V on macOS) pastes the system clipboard into the terminal's command line and the quick-launch bar's search input, replacing the current selection. A UI Toolkit text field has no clipboard of its own, so the terminal answers the key. A pasted block arrives as the arguments it reads as - every run of whitespace (newlines from a copied stack trace, tabs from a copied log line, the trailing newline of a copied command) collapses to the single space that separates arguments - and a run at the start of the field is dropped because it separates nothing. A quote still groups, so `set name "two words"` pastes as one argument, but the paste normalizes before the tokenizer sees the text, so quoted whitespace survives typing and not pasting. The clipboard is unavailable on tvOS, and a platform that will not answer without a user gesture may read empty; neither is an error, the key is left alone, and anything the platform does with it still happens. One limit: a control character that is not whitespace stays in the field, where a single line cannot show it, and reaches the argument as the character that was copied. - `TerminalUI.SetCursorBlinkPaused(bool)` freezes the caret blink schedule with the caret pinned visible (or restores normal blinking), so screenshots, video capture, and tests produce deterministic caret pixels regardless of when the frame lands. @@ -42,6 +44,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed +- `trace` no longer prints a command line. It read a fixed entry back, which is the console's echo of `trace` itself - so it showed the message before it correctly only when the command above `trace` had said something, and printed that command's echo when it had not. It now reads the newest entry that is not a command echo, which also fixes `trace` dispatched programmatically (`shell.RunCommand("trace")`), where the old fixed offset skipped the newest message. +- `time` no longer drops argument quoting. It rejoined its arguments with single spaces and re-tokenized, so a quoted argument arrived as several: `time set-variable greet "two words"` was rejected with `set-variable requires exactly 2 arguments` before the timed command ran at all. It now dispatches the parsed arguments it already has, so `time` can time anything the console can run. `log` and `log-terminal` are unchanged - they want the flattened form, so `log "a b"` still prints `a b`. A `$variable` is now substituted once rather than twice, so a stored value that is itself a variable reference is no longer expanded again. - The shipped documentation now matches the package. The README pointed at a directory that no longer exists, named a component and two inspector options that are not the ones it meant, and listed four argument types as "planned" that have shipped. `doc.md` documented four APIs whose real signatures take required arguments or do not exist at all. The completion guide promised layer and tag completion from an adapter whose constructor throws for those types. The themes and fonts guide was reachable only through the documentation sidebar. - Every character is typeable in a command line and in a palette search bar. A keyboard hotkey bound to a character key is now left to the field being typed into: with the defaults `` ` `` and `` #` ``, pressing the console key while the terminal is open types a backtick or a tilde instead of closing the terminal (and the character used to be dropped from the line as well), and typing the console key in a palette search no longer closes the palette and opens the terminal behind it. Bindings that press no character are unchanged and keep working while you type: navigation and editing keys, function keys, modifiers, lock and media keys, mouse or joystick buttons, and any `ctrl+` chord. Escape still closes an open terminal; a `closeHotkey` bound to a character cannot. PlayerInput bindings are unaffected and still fire while a field has focus, because the keyboard controller reads the binding string rather than the key an action pressed. - First command readiness is faster in projects with many loaded assemblies: the discovery scan no longer re-reads every loaded assembly's metadata on each registration cycle — that per-assembly check is immutable, so it is now cached for the assembly's lifetime. Discovered commands, their order, and the discovery filters' decisions are unchanged. diff --git a/README.md b/README.md index 3e79771..f204bf4 100644 --- a/README.md +++ b/README.md @@ -461,6 +461,28 @@ The clipboard is unavailable on tvOS, and a platform that will not answer withou One limit: a control character that is not whitespace is left in the field. A single line cannot show it, so it is invisible while you are typing, and it reaches the argument as the exact character you copied. Dropping it would lose your text; escaping it there would change the command. A command that prints it gets the visible escape from the log. +## Copying out of the console + +The log view is the only place a message, a command echo, or a failing value ever appears, so two commands put it on the system clipboard: + +- `copy-last` copies the newest line. +- `copy-log` copies every buffered line; `copy-log 20` copies the last 20. + +A copied line is the line the log shows, so the stack trace is not included - `trace` is how you read one, and copying 256 lines does not carry 256 traces. Each command answers in the console like every other one: an empty log, a log with no text in it, a count that is not a number, and a count below one are each reported rather than ignored. + +A clipboard write is a request a platform can decline. tvOS has no clipboard, and a browser clipboard may refuse without a user gesture, so the write is read back and compared and a copy that did not happen says so instead of silently doing nothing. The read-back is not an acknowledgement: copying the same window twice reports success whether or not the second write landed, and a browser that accepts the write asynchronously can read back the old value. + +## Reading the log with the keyboard + +The command line holds panel focus for as long as the console is open, so the terminal routes these to the log itself: + +- **Page Up / Page Down** page the log by one viewport. +- **Ctrl+Home** and **Ctrl+End** (Cmd on macOS) jump to its oldest and newest lines. + +Home and End without the modifier stay with the command line, where they move the caret as a text field should. Every other key is untouched - typing, history recall, completion, and closing work exactly as they did. + +Paging back detaches the tail exactly as scrolling back does, so new output does not pull the view to the end while you are reading. Ctrl+End is the way back to following. A log that has not been laid out - a headless editor, or a view with no rendered Game view - has nothing to page through and stays where it is. + ## Typing wins over a character binding A binding that presses a character key is left to the field being typed into. While the command line or the palette search bar has focus, pressing the key types the character and does not run the binding - so with the defaults `` ` `` (toggle) and `` #` `` (full), those characters are typeable and the console key no longer closes an open console. diff --git a/Runtime/CommandTerminal/Backend/BuiltinCommands.cs b/Runtime/CommandTerminal/Backend/BuiltinCommands.cs index 26f33bb..98b0196 100644 --- a/Runtime/CommandTerminal/Backend/BuiltinCommands.cs +++ b/Runtime/CommandTerminal/Backend/BuiltinCommands.cs @@ -19,19 +19,26 @@ public static class BuiltInCommands private const int AverageFontNameCapacity = 32; + /* + What separates two copied lines. `Environment.NewLine` because that + is what the log itself uses to join a stack trace, and a developer + pasting a log is pasting a log. + */ + private static readonly string LogCopySeparator = Environment.NewLine; + private static readonly StringBuilder StringBuilder = new(); /* - The window `trace` reads, sized to the log buffer's capacity and - reused. Per-thread: CommandShell.RunCommand is public, and the - log buffer is reachable from a thread the caller chose, so two - threads running `trace` must not write into one array. A thread's - first use allocates its own; with domain reload disabled it keeps - it across Play Mode sessions, which is one array per thread that - ever traced. + The window `trace` and `copy-log` read, sized to the log buffer's + capacity and reused. Per-thread: CommandShell.RunCommand is + public, and the log buffer is reachable from a thread the caller + chose, so two threads reading the log must not write into one + array. A thread's first use allocates its own; with domain reload + disabled it keeps it across Play Mode sessions, which is one array + per thread that ever read the log. */ [ThreadStatic] - private static LogItem[] TraceWindow; + private static LogItem[] LogWindow; [RegisterCommand( isDefault: true, @@ -325,6 +332,48 @@ public static void CommandClearHistory(CommandArg[] args) Terminal.History?.Clear(); } + [RegisterCommand( + isDefault: true, + Name = "copy-last", + Help = "Copy the most recent log line to the clipboard", + MaxArgCount = 0 + )] + public static void CommandCopyLast(CommandArg[] args) + { + CopyLogLines(1); + } + + [RegisterCommand( + isDefault: true, + Name = "copy-log", + Help = "Copy the last N log lines to the clipboard", + MaxArgCount = 1 + )] + public static void CommandCopyLog(CommandArg[] args) + { + if (0 < args.Length) + { + if (!args[0].TryGet(out int lineCount)) + { + Terminal.Log(TerminalLogType.Warning, $"Invalid line count {args[0]}."); + return; + } + + if (lineCount < 1) + { + Terminal.Log(TerminalLogType.Warning, "Line count must be at least 1."); + return; + } + + CopyLogLines(lineCount); + return; + } + + /* No count is every buffered line: the gesture a developer makes + when they want the log itself, not a slice of it. */ + CopyLogLines(int.MaxValue); + } + [RegisterCommand( isDefault: true, Name = "help", @@ -384,8 +433,36 @@ public static void CommandTime(CommandArg[] args) return; } + /* + The parsed arguments, not a flattened string re-tokenized. A + join of `contents` with single spaces drops `startQuote` and + `endQuote`, so `time set-variable greet "two words"` re-entered + the shell as three arguments and was rejected before the timed + command ran at all. The shell already accepts pre-parsed + arguments, so nothing has to round-trip through text here. + + Two behaviours follow from that, and both are measured rather + than assumed. `$name` is still substituted, and the timed + command's line still reaches history through the same funnel, + because substitution happens when the outer line is parsed and + `RunCommandCore` pushes in both paths. What does change is that + a stored value which is itself `$name` is no longer expanded a + second time: the old path re-tokenized, and this dispatches the + arguments the outer line already produced - which is what + "time X" means. + + `args[0]` is the command being timed and the rest are its + arguments, so the tail is what runs. The tail is a fresh array + because slicing one out of a `CommandArg[]` is a copy, and + because the shell hands that array to the timed handler + directly. One small array per `time` is the right trade - this + is a command a developer types, not a keystroke or a frame. + */ + CommandArg[] timed = new CommandArg[args.Length - 1]; + Array.Copy(args, 1, timed, 0, timed.Length); + Stopwatch sw = Stopwatch.StartNew(); - shell.RunCommand(JoinArguments(args)); + shell.RunCommand(args[0].contents, timed); sw.Stop(); Terminal.Log($"Time: {sw.ElapsedMilliseconds}ms"); } @@ -446,28 +523,23 @@ public static void CommandTrace(CommandArg[] args) } /* - One consistent read of the window: a background log landing - between the count and the entry would make the second read - index a different line than the count described. `trace` reads - the second-newest entry, so two slots is the floor. + One consistent read of the window, and the newest entry that is + not the echo of this command. `OutputLength` is what makes that + second part true: the console echoed `trace` itself before this + handler ran, so the newest entry is always the command, and + reading a fixed one back - the second-newest - also handed back a + command echo whenever the command above this one said nothing. */ - int capacity = buffer.Capacity; - LogItem[] window = TraceWindow; - if (window == null || window.Length < capacity) - { - window = new LogItem[Math.Max(capacity, 2)]; - TraceWindow = window; - } - - int logCount = buffer.CopyTo(window); + int logCount = ReadLogWindow(buffer, out LogItem[] window); + int previous = OutputLength(window, logCount) - 1; - if (logCount - 2 < 0) + if (previous < 0) { Terminal.Log(TerminalLogType.Warning, "Nothing to trace."); return; } - LogItem logItem = window[logCount - 2]; + LogItem logItem = window[previous]; if (string.IsNullOrWhiteSpace(logItem.stackTrace)) { @@ -659,6 +731,172 @@ public static void CommandQuit(CommandArg[] args) #endif } + /* + How much of the window is output, with the command echoes at its + newest end left off. + + Both surfaces that run a command echo the typed line into the log + as `Input` before the handler is reached, so the newest entries + are the commands that produced them and not anything said. A + built-in that reads "the newest line" without this returns the + word the developer just typed - `copy-last` returned "copy-last". + The palette already applies the same rule when it collects output + to show. + + Only the newest end is walked. A command echo further back is a + command the developer ran, and a transcript of a session wants + those as much as the output; what it does not want is the + keystroke that is producing the transcript. + + Returns the count of entries that are not trailing echoes, so the + caller's output is `window[0..usable)` and the entries past it are + the echoes. + */ + private static int OutputLength(LogItem[] window, int logCount) + { + int usable = logCount; + while (0 < usable && window[usable - 1].type == TerminalLogType.Input) + { + --usable; + } + + return usable; + } + + /* + One consistent read of the buffer's visible window into a reused + array, returning how many entries it holds. Reading a count and + then indexing it separately is two moments: a background log + landing between them makes the second read a different line than + the first described, and a resize or a clear between them throws. + + The capacity is read outside the buffer's lock, so a resize from + another thread in that window can leave the destination shorter + than the window and `CopyTo` truncates to the oldest entries. The + array is grown to the largest capacity seen so that a resize to a + *smaller* size cannot be what a later read trips over, and a + caller that has to be exact about the newest entries reads the + window at the capacity it just saw. `trace` shares the same + exposure; neither widens it. + */ + private static int ReadLogWindow(CommandLog buffer, out LogItem[] window) + { + int capacity = buffer.Capacity; + LogItem[] rented = LogWindow; + if (rented == null || rented.Length < capacity) + { + /* Two is the floor `trace` needs, and it is above zero. */ + rented = new LogItem[Math.Max(capacity, 2)]; + LogWindow = rented; + } + + window = rented; + return buffer.CopyTo(rented); + } + + /* + Copies the newest `lineCount` entries of the log to the clipboard + and answers in the console, which is where a developer running a + command in a device build is looking. + + The messages are what the log view renders, so a copied line is the + line that was on screen. The stack trace is deliberately not + included: a copy of 256 lines would carry 256 traces, and `trace` + is already the way to read one. + */ + private static void CopyLogLines(int lineCount) + { + CommandLog buffer = Terminal.Buffer; + if (buffer == null) + { + return; + } + + int logCount = ReadLogWindow(buffer, out LogItem[] window); + int usable = OutputLength(window, logCount); + if (usable == 0) + { + Terminal.Log( + TerminalLogType.Warning, + logCount == 0 + ? "Nothing to copy: the log is empty." + : "Nothing to copy: the log holds no output, only commands." + ); + return; + } + + int first = Math.Max(0, usable - Math.Min(lineCount, usable)); + int copied = usable - first; + + using CachedStringBuilder.Scope scope = CachedStringBuilder.Rent( + MeasureCopyLength(window, first, usable) + ); + StringBuilder builder = scope.Builder; + bool hasText = false; + for (int i = first; i < usable; ++i) + { + if (first != i) + { + builder.Append(LogCopySeparator); + } + + string message = window[i].message; + hasText |= 0 < message.Length; + builder.Append(message); + } + + /* + Whether the lines held text, not whether the joined string did. + The separators are written whatever the lines say, so two empty + lines still join to one newline, and asking about the string + would report a successful copy of a single character. + */ + if (!hasText) + { + /* + A log with nothing in it to copy, which is a different + thing from a platform declining a copy. Telling a developer + their platform refused when the text was never written is + the one answer this command must not give wrongly. + */ + Terminal.Log(TerminalLogType.Warning, "Nothing to copy: the log holds no text."); + return; + } + + if (!TerminalClipboard.TryWrite(builder.ToString())) + { + Terminal.Log( + TerminalLogType.Warning, + "The platform did not keep the text. tvOS has no clipboard, and a " + + "browser clipboard may refuse without a user gesture." + ); + return; + } + + Terminal.Log( + copied == 1 + ? "Copied the most recent log line to the clipboard." + : $"Copied {copied} log lines to the clipboard." + ); + } + + /* + What `CopyLogLines` is about to build, so the pooled builder is + rented once at the right size instead of growing into it. A log of + 256 lines is a few kilobytes, well under the retention ceiling, so + the pooled buffer survives the next copy. + */ + private static int MeasureCopyLength(LogItem[] window, int first, int logCount) + { + int length = (logCount - first - 1) * LogCopySeparator.Length; + for (int i = first; i < logCount; ++i) + { + length += window[i].message?.Length ?? 0; + } + + return length; + } + private static string JoinArguments(CommandArg[] args, int start = 0) { StringBuilder.Clear(); diff --git a/Runtime/CommandTerminal/Backend/TerminalClipboard.cs b/Runtime/CommandTerminal/Backend/TerminalClipboard.cs new file mode 100644 index 0000000..407b109 --- /dev/null +++ b/Runtime/CommandTerminal/Backend/TerminalClipboard.cs @@ -0,0 +1,74 @@ +namespace WallstopStudios.DxCommandTerminal.Backend +{ + using System; + using UnityEngine; + + /* + The system clipboard, in both directions, and the one place that knows + what it is. + + GUIUtility.systemCopyBuffer (UnityEngine.IMGUIModule) is the only + cross-version public path, and it is always reachable from an assembly + with engine references. It is not universal: tvOS has no clipboard, and + a platform that will not answer without a user gesture - a browser + clipboard API, as on WebGL - may refuse a write or read back empty. The + paste direction detects that by the empty answer it gets; a write has + no such answer, so the only portable evidence is to write and read back + and compare. A platform that kept the text reads it back identically, + and one that dropped it does not, which is what makes a refused copy + reportable instead of silent. + + What that evidence cannot distinguish is a platform that already held + exactly this text: copying the same window twice reports success the + second time whether or not the second write landed. Nor is a read-back + an acknowledgement. A browser clipboard is promise-based, so the + read-back can run before the write lands, and a copy that did succeed + reads back as the old value - a false negative on a working platform, + reported as "the platform did not keep what was written" rather than as + a guarantee. It is the only portable signal there is. + + A limit worth stating: the clipboard is a main-thread engine API. The + read side has always been driven from a key handler, but a write from + `CommandShell.RunCommand` can be driven off the main thread by a caller + that chose to. The copy commands answer on whatever thread ran them, + which is the shell's existing contract rather than one this changes. + */ + internal static class TerminalClipboard + { + /* + The clipboard's text, or empty where the platform has none. The + empty answer is the detection: tvOS has no clipboard, and a + platform that needs a gesture may read empty, and neither is an + error the caller has to raise. + */ + public static string Read() + { + return GUIUtility.systemCopyBuffer ?? string.Empty; + } + + /* + Writes the text, and reports whether the platform kept it. False is + not a failure the caller should hide: it is the only way a copy + that never happened can be told apart from one that did. + */ + public static bool TryWrite(string text) + { + if (string.IsNullOrEmpty(text)) + { + return false; + } + + GUIUtility.systemCopyBuffer = text; + + /* + Compared, not assumed: the write is a request to a platform + that can decline it, and the value the clipboard reports back + is the only answer available. A platform that has no clipboard + leaves the previous value in place, and a platform that + truncated the text reports a different one; both are a copy the + developer did not get. + */ + return string.Equals(GUIUtility.systemCopyBuffer, text, StringComparison.Ordinal); + } + } +} diff --git a/Runtime/CommandTerminal/Backend/TerminalClipboard.cs.meta b/Runtime/CommandTerminal/Backend/TerminalClipboard.cs.meta new file mode 100644 index 0000000..90fb29e --- /dev/null +++ b/Runtime/CommandTerminal/Backend/TerminalClipboard.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 06cf82db42f5485e8d07f8f23036d1f1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/CommandTerminal/UI/LogScrollIntent.cs b/Runtime/CommandTerminal/UI/LogScrollIntent.cs new file mode 100644 index 0000000..17c725a --- /dev/null +++ b/Runtime/CommandTerminal/UI/LogScrollIntent.cs @@ -0,0 +1,23 @@ +namespace WallstopStudios.DxCommandTerminal.UI +{ + using System; + + /* What a key asked the log view to do. A key the log does not answer is + not a member here: TryResolve reports that with a false return, so the + only values that exist are the scrolls a log can make. The ordinals are + explicit so a member added later cannot renumber one already compiled + against. */ + internal enum LogScrollIntent + { + [Obsolete("A key the log does not answer has no intent; TryResolve returns false")] + None = 0, + + /* One viewport of the log, in the direction named. */ + PageUp = 1, + PageDown = 2, + + /* The oldest line the buffer still holds, and its newest. */ + ToStart = 3, + ToEnd = 4, + } +} diff --git a/Runtime/CommandTerminal/UI/LogScrollIntent.cs.meta b/Runtime/CommandTerminal/UI/LogScrollIntent.cs.meta new file mode 100644 index 0000000..148cc51 --- /dev/null +++ b/Runtime/CommandTerminal/UI/LogScrollIntent.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 5f1c07e9ab2d4c8ea1b3d6f90c74e582 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/CommandTerminal/UI/LogScrollKeys.cs b/Runtime/CommandTerminal/UI/LogScrollKeys.cs new file mode 100644 index 0000000..a7d9891 --- /dev/null +++ b/Runtime/CommandTerminal/UI/LogScrollKeys.cs @@ -0,0 +1,92 @@ +namespace WallstopStudios.DxCommandTerminal.UI +{ + using UnityEngine; + + /* + How a key moves the log view, and what the view's scroll position + becomes. + + The command line owns panel focus for as long as the console is open, + so Page Up, Page Down, Home, and End all arrive at the command field + instead of at the log. Routing them is the terminal's job, and it is + split out here so the decision is a table rather than a branch buried + in a key handler. + + What the log does not take is as much of the rule as what it does. + Home and End move the caret in a text field, and a developer editing + the command they are about to run needs that, so plain Home and End + stay with the field and the log is reached with the platform's command + modifier held - Ctrl on Windows and Linux, Cmd on macOS. Page Up and + Page Down are free: a one-line field has nothing to page. + + A key the log does not want resolves to None and is left alone, which + is what keeps typing, history recall, completion, and closing working + exactly as they did. + */ + internal static class LogScrollKeys + { + /* + Where a scroll value wants to land. The value is clamped to the + scroller's own range on the way, so a page that runs past either + end stops there rather than leaving the view past its content. + */ + public static float Target(LogScrollIntent intent, float value, float highValue, float page) + { + return intent switch + { + LogScrollIntent.ToStart => 0f, + LogScrollIntent.ToEnd => highValue, + LogScrollIntent.PageUp => Clamp(value - page, highValue), + LogScrollIntent.PageDown => Clamp(value + page, highValue), + _ => Clamp(value, highValue), + }; + } + + /* + Which scroll a key asks for. False means the key is not the log's + to answer and the caller leaves it alone, which is what keeps + typing, history recall, completion, and closing untouched. + + `commandKey` is the platform's own command modifier, so this is + Ctrl on Windows and Linux and Cmd on macOS without the terminal + having to know which. + */ + public static bool TryResolve(KeyCode keyCode, bool commandKey, out LogScrollIntent intent) + { + switch (keyCode) + { + case KeyCode.PageUp: + intent = LogScrollIntent.PageUp; + return true; + case KeyCode.PageDown: + intent = LogScrollIntent.PageDown; + return true; + case KeyCode.Home when commandKey: + intent = LogScrollIntent.ToStart; + return true; + case KeyCode.End when commandKey: + intent = LogScrollIntent.ToEnd; + return true; + default: + intent = default; + return false; + } + } + + /* + Clamped on both sides. The scroller clamps a write to the extent it + holds, but a value computed from a viewport height taken before a + layout pass can land outside the range, and the tail follower reads + whatever the scroller ended up holding as the developer's position. + */ + private static float Clamp(float value, float highValue) + { + if (value < 0f) + { + return 0f; + } + + return highValue < value ? highValue : value; + } + } +} diff --git a/Runtime/CommandTerminal/UI/LogScrollKeys.cs.meta b/Runtime/CommandTerminal/UI/LogScrollKeys.cs.meta new file mode 100644 index 0000000..caa99a2 --- /dev/null +++ b/Runtime/CommandTerminal/UI/LogScrollKeys.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 34e364a4c47d49e583cf47a9c9f5aaf1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/CommandTerminal/UI/LogTailFollower.cs b/Runtime/CommandTerminal/UI/LogTailFollower.cs index d8f6240..715cfa5 100644 --- a/Runtime/CommandTerminal/UI/LogTailFollower.cs +++ b/Runtime/CommandTerminal/UI/LogTailFollower.cs @@ -88,5 +88,36 @@ public void Attach() Detached = false; _pinned = null; } + + /* + A scroll the developer made, from the terminal that placed it. This + is the one move `Observe` cannot infer on its own, and the reason + is the window above: `Attach` clears the pin, and a developer who + pages back before the terminal's next pin has landed has no pin to + be "below". The caller is the only thing that knows this was a + scroll, so it says so. + + The position and the extent are read back so the end rule stays + here, where the tolerance lives, rather than being restated at + every call site. A scroll that lands on the end is not a scroll + away from it: the developer paged down to the bottom and wants to + follow, and recording a detach there would freeze the view a line + short of an end that is still growing - the last press of a + catch-up sweep is exactly the press that did it. + + No pin is written. The flag is the whole signal, and a pin here + would outlive the extent it described without anything reading it: + the branch that compares against it can only set a flag this + method has already set. + */ + public void Detach(float value, float highValue) + { + if (highValue - EndTolerance <= value) + { + return; + } + + Detached = true; + } } } diff --git a/Runtime/CommandTerminal/UI/TerminalUI.cs b/Runtime/CommandTerminal/UI/TerminalUI.cs index 41c1410..412687b 100644 --- a/Runtime/CommandTerminal/UI/TerminalUI.cs +++ b/Runtime/CommandTerminal/UI/TerminalUI.cs @@ -2421,6 +2421,13 @@ here before the field sees it. The callback is static and captureless for the reason the change callback above states, and the key is stopped only when a paste happened, so an ordinary V types a V. + + The log is answered here for the same reason. The command line + holds panel focus for as long as the console is open, so the + keys that scroll the log never reach the log view; routing + them is the terminal's job, and it is the same trickle-down + callback so the two answers cannot disagree about what + "consumed" means. */ _commandInput.RegisterCallback( static (evt, context) => @@ -2439,6 +2446,12 @@ element below the field is a TextElement with its newlines and all. */ KeyEvents.Consume(context._commandInput, evt); + return; + } + + if (context.TryScrollLog(evt)) + { + KeyEvents.Consume(context._commandInput, evt); } }, userArgs: this, @@ -2828,6 +2841,83 @@ private bool ObserveLogTail(bool newLogs) return _logTail.Observe(scroller.value, scroller.highValue, newLogs); } + /* + Moves the log for a key that arrived at the command field, because + the command line holds panel focus for as long as the console is + open. False means the key was not the log's to answer and the + caller leaves it alone, so typing, history recall, completion, and + closing are untouched. + + A key the log answers detaches the tail, which is what makes the + scroll a first-class state: a developer paging back to read an + error is not yanked to the newest line by the next frame's output. + The follower learns that from the scroller's own value on the next + pass, so all this has to do is place the value and clear a tail + pin that has not been spent yet. + */ + private bool TryScrollLog(KeyDownEvent evt) + { + /* + Both flags, for the reason the paste path checks both: which + flag a command modifier arrives in is a per-editor detail + (Command on macOS, Control elsewhere, and a Windows key can + arrive as Command as well), and a developer who reaches the log + with the key their platform calls the command key has to get + there whichever flag it arrived in. + */ + if ( + !LogScrollKeys.TryResolve( + evt.keyCode, + evt.commandKey || evt.ctrlKey, + out LogScrollIntent intent + ) + ) + { + return false; + } + + Scroller scroller = _logScrollView?.verticalScroller; + if (scroller == null) + { + return false; + } + + /* + The extent the log actually shows, which is the page. Neither + Scroller nor ScrollView exposes a page size on Unity 2021.3, + the oldest editor this package supports, so it is read from the + content viewport's laid-out rectangle - a Rect, whose height is + a float on every supported version. A zero height is a log + that has not been laid out, where there is nothing to page + through and the clamp leaves the view where it is. + */ + float target = LogScrollKeys.Target( + intent, + scroller.value, + scroller.highValue, + _logScrollView.contentViewport.layout.height + ); + + scroller.value = target; + + /* + The developer's scroll, said so. The follower infers one from a + position below the pin it already holds, but running a command + calls `Attach`, which clears that pin, and the pin lands a + frame later - so a page taken in that window would have + nothing to be below and the very next pass would read it as + output and snap the view back to the end. `Detach` is what + closes that window, and it decides for itself whether the key + actually left the view away from its end: a scroll that lands + on the end is a developer paged down to the bottom, and + recording a detach there would freeze the view a line short of + an end that is still growing. + */ + _logTail.Detach(scroller.value, scroller.highValue); + _needsScrollToEnd = false; + return true; + } + private void ScrollToEnd() { Scroller scroller = _logScrollView?.verticalScroller; diff --git a/Runtime/CommandTerminal/UI/TextFieldPaste.cs b/Runtime/CommandTerminal/UI/TextFieldPaste.cs index ae46e7d..0fcf409 100644 --- a/Runtime/CommandTerminal/UI/TextFieldPaste.cs +++ b/Runtime/CommandTerminal/UI/TextFieldPaste.cs @@ -17,13 +17,14 @@ is a key the application owns. On 6000.4 the whole TextField The clipboard read is the one cross-version public path, GUIUtility.systemCopyBuffer (UnityEngine.IMGUIModule, always - referenced by an assembly with engine references). It is not - universal: tvOS has no clipboard, and a platform that needs a user - gesture before it will answer - a browser clipboard API, as on - WebGL - may read empty. An empty answer is the detection, and the - key is left unconsumed so the platform keeps whatever it does with - it. Neither case is measured here; the degradation is the design, - not a fallback that failed. + referenced by an assembly with engine references), and TerminalClipboard + is where that name is written down, so the paste and the copy paths + cannot drift onto different ones. It is not universal: tvOS has no + clipboard, and a platform that needs a user gesture before it will + answer - a browser clipboard API, as on WebGL - may read empty. An + empty answer is the detection, and the key is left unconsumed so the + platform keeps whatever it does with it. Neither case is measured + here; the degradation is the design, not a fallback that failed. A pasted block is not typed input, and it must not arrive as one. A stack trace is newlines, a log line is tabs, the field is single-line @@ -73,7 +74,7 @@ public static bool TryApply(TextField field, KeyDownEvent evt, out int caret) return false; } - string flattened = Flatten(GUIUtility.systemCopyBuffer); + string flattened = Flatten(TerminalClipboard.Read()); if (flattened.Length == 0) { caret = 0; diff --git a/Tests/Editor/BuiltinCommandsTests.cs b/Tests/Editor/BuiltinCommandsTests.cs new file mode 100644 index 0000000..6a0deae --- /dev/null +++ b/Tests/Editor/BuiltinCommandsTests.cs @@ -0,0 +1,491 @@ +namespace WallstopStudios.DxCommandTerminal.Tests.Runtime +{ + using System; + using System.Collections.Generic; + using System.Globalization; + using System.Linq; + using Backend; + using NUnit.Framework; + using UI; + using UnityEngine; + + /* + The built-in commands, driven through a real shell. + + EditMode, because none of this needs a panel: the built-ins read and + write the session's buffer and shell, both of which are settable here, + and the clipboard is the same `GUIUtility.systemCopyBuffer` the paste + direction reads. The ambient execution context is pinned so the + dispatch is the same one a player gets rather than whatever the editor + happens to be doing when the test runs. + + The three here fail for three different reasons, which is why they are + three tests and not one: + + - `time` rejoined its arguments with single spaces, so a quoted + argument arrived as several and the timed command was rejected + before it ran. + - Nothing could read the log out. The commands here write the window + they name to the clipboard and say what happened, including on the + platforms that have no clipboard to write to. + */ + public sealed class BuiltinCommandsTests + { + private const int LogCapacity = 16; + + /* What `copy-log` separates its lines with, so a test splits the way + the command joined rather than assuming a newline it never chose. */ + private static readonly string LogCopySeparator = Environment.NewLine; + + private CommandLog _originalBuffer; + private CommandShell _originalShell; + private CommandHistory _originalHistory; + private CommandAutoComplete _originalAutoComplete; + private Func _originalAmbientProvider; + private string _originalClipboard; + private CommandLog _buffer; + private CommandShell _shell; + private CommandHistory _history; + private readonly List _recorded = new(); + + private static bool Contains(LogItem[] window, string message) + { + for (int i = 0; i < window.Length; ++i) + { + if (string.Equals(window[i].message, message, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static string[] Contents(CommandArg[] args) + { + string[] contents = new string[args.Length]; + for (int i = 0; i < args.Length; ++i) + { + contents[i] = args[i].contents; + } + + return contents; + } + + [SetUp] + public void SetUp() + { + _originalBuffer = Terminal.Buffer; + _originalShell = Terminal.Shell; + _originalHistory = Terminal.History; + _originalAutoComplete = Terminal.AutoComplete; + _originalAmbientProvider = CommandExecutionContext.AmbientContextProvider; + _originalClipboard = GUIUtility.systemCopyBuffer; + + /* A player-shaped context, so an editor-side run is not a + context-eligibility test in disguise. */ + CommandExecutionContext.AmbientContextProvider = () => + new CommandExecutionContext(CommandExecutionContexts.Player); + + _buffer = new CommandLog(LogCapacity); + _history = new CommandHistory(16); + _shell = new CommandShell(_history); + _shell.InitializeAutoRegisteredCommands(); + _shell.EnsureAutoCommandsRegistered(); + Terminal.Buffer = _buffer; + Terminal.Shell = _shell; + Terminal.History = _history; + _recorded.Clear(); + } + + [TearDown] + public void TearDown() + { + Terminal.Buffer = _originalBuffer; + Terminal.Shell = _originalShell; + Terminal.History = _originalHistory; + Terminal.AutoComplete = _originalAutoComplete; + CommandExecutionContext.AmbientContextProvider = _originalAmbientProvider; + GUIUtility.systemCopyBuffer = _originalClipboard; + } + + [Test] + public void TimeRunsTheCommandItWasGivenWithItsQuotedArgumentIntact() + { + _shell.AddCommand( + "record", + args => _recorded.Add(string.Join("|", Contents(args))), + minArgs: 0 + ); + + string error = Run("time record \"two words\" plain"); + + Assert.That( + error, + Is.Null, + $"`time` must run the command it names, not reject its arguments: {error}" + ); + Assert.That( + _recorded, + Is.EqualTo(new[] { "two words|plain" }), + "`time` re-tokenized a flattened string, so a quoted argument split" + ); + } + + [Test] + public void TimeReachesTheCommandItNamesAtAll() + { + _shell.AddCommand("record", args => _recorded.Add("ran"), minArgs: 0); + + Run("time record"); + + Assert.That( + _recorded, + Is.EqualTo(new[] { "ran" }), + "`time` must invoke the command it names" + ); + } + + [Test] + public void TimeRejectsNoCommandTheSameWayItAlwaysDid() + { + string error = Run("time"); + + Assert.That( + error, + Is.Not.Null, + "A `time` with no command is still an argument-count failure" + ); + } + + [Test] + public void TimeStillSubstitutesAVariable() + { + /* + The documented idiom: a value stored by `set-variable` and + reached with `$name` on a later line. Substitution happens when + the outer line is parsed, so the timed command has to see the + value the same way it would have through the string path. + */ + Run("set-variable greet \"Hello World!\""); + + string error = Run("time log-terminal $greet"); + + Assert.That(error, Is.Null, $"`time` must run the substituted line: {error}"); + Assert.That( + Contains(Newest(3), "Hello World!"), + Is.True, + "`time` must not hand the timed command the literal `$greet`" + ); + } + + [Test] + public void TimeStillPushesTheTimedCommandToHistoryExactlyOnce() + { + Run("time log-terminal once"); + + string[] history = _history.GetHistory(true, true).ToArray(); + + Assert.That( + history, + Does.Contain("time log-terminal once"), + "The line the developer typed is in history" + ); + Assert.That( + history.Count(line => + string.Equals(line, "log-terminal once", StringComparison.Ordinal) + ), + Is.EqualTo(1), + "The timed command's own line reaches history once, through the same " + + "funnel the string path used, so Up recalls exactly what it did before" + ); + } + + [Test] + public void CopyLastPutsTheNewestLineOnTheClipboard() + { + Run("log-terminal first"); + Run("log-terminal second"); + Run("copy-last"); + + Assert.That( + GUIUtility.systemCopyBuffer, + Is.EqualTo("second"), + "`copy-last` copies the newest line, which is the one above the echo" + ); + Assert.That( + Contains(Newest(3), "Copied the most recent log line to the clipboard."), + Is.True, + "`copy-last` answers in the console like every other command" + ); + } + + [Test] + public void CopyLogPutsTheWholeWindowOnTheClipboard() + { + Run("log-terminal alpha"); + Run("log-terminal beta"); + Run("copy-log"); + + Assert.That( + GUIUtility.systemCopyBuffer, + Does.Contain("alpha"), + "`copy-log` with no count copies every buffered line" + ); + Assert.That( + GUIUtility.systemCopyBuffer, + Does.Contain("beta"), + "`copy-log` with no count copies every buffered line" + ); + } + + [TestCase("1")] + [TestCase("3")] + public void CopyLogTakesTheNewestCount(string count) + { + for (int i = 0; i < 5; ++i) + { + Run($"log-terminal line{i}"); + } + + Run($"copy-log {count}"); + + string[] copied = GUIUtility.systemCopyBuffer.Split(LogCopySeparator); + Assert.That( + copied.Length, + Is.EqualTo(int.Parse(count, CultureInfo.InvariantCulture)), + $"`copy-log {count}` copies exactly {count} lines" + ); + Assert.That( + copied[copied.Length - 1], + Is.EqualTo("line4"), + "The window is the newest lines, and its own end is the newest of them" + ); + } + + [Test] + public void CopyLogRejectsACountItCannotRead() + { + string untouched = GUIUtility.systemCopyBuffer; + Run("log-terminal alpha"); + Run("copy-log not-a-number"); + + Assert.That( + GUIUtility.systemCopyBuffer, + Is.EqualTo(untouched), + "A count that does not parse must not fall through to copying everything" + ); + Assert.That( + Contains(Newest(2), "Invalid line count not-a-number."), + Is.True, + "A count that does not parse is reported, not silently ignored" + ); + } + + [Test] + public void CopyLogRejectsAZeroCount() + { + string untouched = GUIUtility.systemCopyBuffer; + Run("log-terminal alpha"); + Run("copy-log 0"); + + Assert.That( + GUIUtility.systemCopyBuffer, + Is.EqualTo(untouched), + "`copy-log 0` copies nothing rather than everything" + ); + Assert.That( + Contains(Newest(2), "Line count must be at least 1."), + Is.True, + "A zero count is reported" + ); + } + + [Test] + public void CopyLastTakesTheLineAboveTheEchoOfTheCommandAskingForIt() + { + /* + The bug this file's `Run` helper hid. The console echoes the + typed line as `Input` before any handler runs, so from a real + console the newest entry is "copy-last" itself, and copying the + newest entry copied the word back at the developer instead of + the error they were reaching for. + */ + Run("log-terminal the error line"); + Run("copy-last"); + + Assert.That( + GUIUtility.systemCopyBuffer, + Is.EqualTo("the error line"), + "`copy-last` copies the line above its own echo, not the echo" + ); + } + + [Test] + public void CopyLogDoesNotPutTheCommandAskingForItOnTheClipboard() + { + Run("log-terminal alpha"); + Run("log-terminal beta"); + Run("copy-log 2"); + + string[] copied = GUIUtility.systemCopyBuffer.Split(LogCopySeparator); + + Assert.That( + copied, + Has.None.EqualTo("copy-log 2"), + "A paste must not open with the keystroke that produced it" + ); + Assert.That( + copied, + Is.EqualTo(new[] { "log-terminal beta", "beta" }), + "`copy-log 2` takes the two entries above the echo, and they are the ones the developer saw last" + ); + } + + [Test] + public void CopyLogKeepsTheCommandsYouRanInTheTranscript() + { + /* + The other half of the rule, so it is not "drop every echo". A + bug report wants to show what was typed as much as what came + back, so only the echoes at the newest end are the ones the + developer has already in front of them. + */ + Run("log-terminal alpha"); + Run("log-terminal beta"); + Run("copy-log"); + + Assert.That( + GUIUtility.systemCopyBuffer, + Does.Contain("log-terminal alpha"), + "An earlier command echo is part of the log and stays in the transcript" + ); + } + + [Test] + public void CopyingAnEmptyLogReportsNothingToCopy() + { + /* Dispatched rather than echoed, so the log really is empty: + `Run` writes the typed line into it before dispatching. */ + _shell.RunCommand("copy-last"); + + Assert.That( + Contains(Newest(1), "Nothing to copy: the log is empty."), + Is.True, + "An empty log is a report, not a silent no-op" + ); + } + + [Test] + public void ALogOfEmptyLinesIsNotReportedAsAClipboardRefusal() + { + /* + The distinction the whole report exists to make. A log whose + lines are empty has nothing to copy, which is not the same + failure as a platform that declined a copy, and answering the + second for the first sends a developer looking at their OS. + + Two lines, because the separators are written whatever the lines + say: two empty lines still join to one newline, so asking + whether the copied text was empty called this a successful copy + of a single character. + */ + _buffer.HandleLog(string.Empty, TerminalLogType.Message); + _buffer.HandleLog(string.Empty, TerminalLogType.Message); + string untouched = GUIUtility.systemCopyBuffer; + + Run("copy-log 2"); + + Assert.That( + Contains(Newest(2), "Nothing to copy: the log holds no text."), + Is.True, + "Empty lines are not a platform that refused the copy" + ); + Assert.That( + Contains(Newest(2), "did not keep the text"), + Is.False, + "The refusal message is reserved for a platform that declined" + ); + Assert.That(GUIUtility.systemCopyBuffer, Is.EqualTo(untouched)); + } + + [Test] + public void AnEmptyLineAmongRealOnesIsStillCopied() + { + Run("log-terminal alpha"); + _buffer.HandleLog(string.Empty, TerminalLogType.Message); + Run("log-terminal beta"); + Run("copy-log 3"); + + string[] copied = GUIUtility.systemCopyBuffer.Split(LogCopySeparator); + + Assert.That( + copied.Length, + Is.EqualTo(3), + "A blank line is a line like any other, so it takes a slot of the count" + ); + Assert.That( + copied, + Is.EqualTo(new[] { string.Empty, "log-terminal beta", "beta" }), + "The three entries above the echo, blank one included" + ); + } + + [Test] + public void ALogOfNothingButCommandsSaysSoRatherThanCopyingOne() + { + Run("no-op"); + Run("copy-last"); + + Assert.That( + Contains(Newest(2), "Nothing to copy: the log holds no output, only commands."), + Is.True, + "The only entries are command echoes, and copying one would hand the developer their own keystroke" + ); + } + + [Test] + public void CopyLastTakesTheMessageAndNotTheTrace() + { + _buffer.HandleLog("boom", "at Frame", TerminalLogType.Error); + Run("copy-last"); + + Assert.That( + GUIUtility.systemCopyBuffer, + Is.EqualTo("boom"), + "A copied line is the line the developer sees; `trace` is how they get a trace" + ); + } + + /* + Runs a line the way the console does: the typed line is echoed into + the log as `Input` first, then dispatched. The echo is not a detail + of the UI - `EnterCommand` and the palette both write it before any + handler runs - and a helper that skipped it exercised a path the + product never takes. `copy-last` was broken for exactly this + reason: with no echo in the window, the text it copied looked + right, and from the console it copied the word "copy-last". + */ + private string Run(string line) + { + Terminal.Log(TerminalLogType.Input, line); + _shell.RunCommand(line); + return _shell.TryConsumeErrorMessage(out string error) ? error : null; + } + + /* Newest first, so an assertion can name the entry it means without + counting back through the window itself. */ + private LogItem[] Newest(int count) + { + LogItem[] window = new LogItem[LogCapacity]; + int written = _buffer.CopyTo(window); + LogItem[] newest = new LogItem[count]; + for (int i = 0; i < count; ++i) + { + newest[count - 1 - i] = window[written - 1 - i]; + } + + return newest; + } + } +} diff --git a/Tests/Editor/BuiltinCommandsTests.cs.meta b/Tests/Editor/BuiltinCommandsTests.cs.meta new file mode 100644 index 0000000..c4d6059 --- /dev/null +++ b/Tests/Editor/BuiltinCommandsTests.cs.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 36b1bd2f9a464eb59bd6cffbfe1426fe +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/LogScrollKeysTests.cs b/Tests/Editor/LogScrollKeysTests.cs new file mode 100644 index 0000000..eef53b8 --- /dev/null +++ b/Tests/Editor/LogScrollKeysTests.cs @@ -0,0 +1,117 @@ +namespace WallstopStudios.DxCommandTerminal.Tests.Runtime +{ + using NUnit.Framework; + using UI; + using UnityEngine; + + /* + Which key moves the log, and how far. + + The command line owns panel focus for as long as the console is open, + so every one of these keys arrives at the command field rather than at + the log view. This is the decision the terminal makes about a key that + reached it; applying it needs a live panel and lives in + TerminalUILogScrollTests. + + The tables drive keys rather than scroll intents, because a key is what + a developer presses and an intent is this package's own vocabulary. + Asserting the effect - where the view ends up - also keeps the two + halves of the decision in one test, so a key that scrolls the wrong way + cannot pass by naming the right intent. + + Data rather than one test per key because the failures are not + independent. Taking Home away from a text field, paging a view that + cannot scroll, and letting through a key the field wanted are all the + same class of mistake - the log answering a key that was not its to + answer - so one table states the whole rule. + */ + public sealed class LogScrollKeysTests + { + private static float ScrollTo( + KeyCode keyCode, + bool commandKey, + float value, + float highValue, + float page + ) + { + Assert.That( + LogScrollKeys.TryResolve(keyCode, commandKey, out LogScrollIntent intent), + Is.True, + $"{keyCode} is the log's key to answer here" + ); + return LogScrollKeys.Target(intent, value, highValue, page); + } + + [TestCase(KeyCode.PageUp)] + [TestCase(KeyCode.PageDown)] + [TestCase(KeyCode.Home)] + [TestCase(KeyCode.End)] + [TestCase(KeyCode.LeftArrow)] + [TestCase(KeyCode.RightArrow)] + [TestCase(KeyCode.UpArrow)] + [TestCase(KeyCode.DownArrow)] + [TestCase(KeyCode.A)] + [TestCase(KeyCode.Escape)] + [TestCase(KeyCode.Return)] + [TestCase(KeyCode.Tab)] + [TestCase(KeyCode.Backspace)] + public void APlainKeyReachesTheLogOnlyWhenTheLogWantsIt(KeyCode keyCode) + { + bool answered = LogScrollKeys.TryResolve(keyCode, commandKey: false, out _); + + Assert.That( + answered, + Is.EqualTo(keyCode is KeyCode.PageUp or KeyCode.PageDown), + "Paging is the log's alone; every other key stays with the command line" + ); + } + + [TestCase(KeyCode.PageUp)] + [TestCase(KeyCode.PageDown)] + [TestCase(KeyCode.Home)] + [TestCase(KeyCode.End)] + public void ACommandKeyReachesTheLogEvenWhereTheCaretWould(KeyCode keyCode) + { + Assert.That( + LogScrollKeys.TryResolve(keyCode, commandKey: true, out _), + Is.True, + "The command modifier is what makes Home and End the log's to answer " + + "rather than the caret's; without it they stay with the field" + ); + } + + [TestCase(KeyCode.PageUp, 500f, 1000f, 400f, 100f)] + [TestCase(KeyCode.PageDown, 100f, 1000f, 400f, 500f)] + [TestCase(KeyCode.Home, 500f, 1000f, 400f, 0f)] + [TestCase(KeyCode.End, 100f, 1000f, 400f, 1000f)] + [TestCase(KeyCode.PageUp, 50f, 1000f, 400f, 0f)] + [TestCase(KeyCode.PageDown, 950f, 1000f, 400f, 1000f)] + [TestCase(KeyCode.PageUp, 0f, 1000f, 0f, 0f)] + public void AKeyMovesTheLogByAPageAndClampsToItsScrollableRange( + KeyCode keyCode, + float value, + float highValue, + float page, + float expected + ) + { + Assert.That( + ScrollTo(keyCode, commandKey: true, value, highValue, page), + Is.EqualTo(expected).Within(0.001f), + "The log moves by a page and never past either end of its scroll" + ); + } + + [TestCase(KeyCode.PageUp)] + [TestCase(KeyCode.PageDown)] + public void ALogThatCannotScrollStaysPut(KeyCode keyCode) + { + Assert.That( + ScrollTo(keyCode, commandKey: true, 300f, 300f, 0f), + Is.EqualTo(300f), + "A view with no overflow has nothing to page through, and a page of zero would not move" + ); + } + } +} diff --git a/Tests/Editor/LogScrollKeysTests.cs.meta b/Tests/Editor/LogScrollKeysTests.cs.meta new file mode 100644 index 0000000..a1d1ce5 --- /dev/null +++ b/Tests/Editor/LogScrollKeysTests.cs.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 11d2a6e7d18d4c28bce06da851698004 +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Tests/Editor/LogTailFollowerTests.cs b/Tests/Editor/LogTailFollowerTests.cs index aa1e5af..12cee03 100644 --- a/Tests/Editor/LogTailFollowerTests.cs +++ b/Tests/Editor/LogTailFollowerTests.cs @@ -116,6 +116,163 @@ public void ScrollingUpDetachesTheTail() Assert.That(follower.Detached, Is.True); } + [Test] + public void RepeatedScrollsStayDetachedBecauseThePinStaysAtTheEnd() + { + /* + The keyboard paging path places the scroller and pins nothing: + the pin the terminal holds is the end, and every page below it + has to read as the developer's own scroll. This is the rule + that path leans on, and pinning to the new position instead + would make the position equal the pin, which is the state a + developer cannot reach. + */ + LogTailFollower follower = AttachedFollower(); + + for (int page = 1; page <= 5; ++page) + { + float scrolled = Extent - (200f * page); + bool request = follower.Observe(scrolled, Extent, false); + + Assert.That( + request, + Is.False, + $"Page {page} must not ask for a pin, or the view snaps back to the end" + ); + Assert.That(follower.Detached, Is.True, $"Page {page} detaches the tail"); + } + } + + [Test] + public void PagingBackToTheEndReattachesTheTail() + { + LogTailFollower follower = AttachedFollower(); + follower.Observe(Extent - 400f, Extent, false); + + bool request = follower.Observe(Extent, Extent, false); + + Assert.That(follower.Detached, Is.False, "Landing at the end follows again"); + Assert.That(request, Is.False, "The view is already where the pin would put it"); + } + + [Test] + public void APageInsideTheAttachWindowIsStillTheDevelopersScroll() + { + /* + The keyboard paging path calls `Detach`, and that is what makes + the window safe. A page taken before the terminal's first pin + has no pin to be "below", so without it the very next pass + would read the page as output and snap the view back to the + end. + */ + LogTailFollower follower = new(); + follower.Attach(); + follower.Detach(Extent - 400f, Extent); + + bool request = follower.Observe(Extent - 400f, Extent, true); + + Assert.That( + request, + Is.False, + "A page taken inside the attach window must not ask for a pin that undoes it" + ); + Assert.That(follower.Detached, Is.True); + } + + [Test] + public void AScrollThatLandsOnTheEndIsFollowingNotADetach() + { + /* + The freeze this prevents, and the gesture that caused it is the + ordinary one: catching up on a log that is still growing. The + last Page Down of a sweep lands on the end, and by then the + extent has usually moved again between the key and the pass + that reads it. Detaching there left the view pinned a line + short of a growing end, which is the one state a log can sit + in that nothing recovers it from. + */ + LogTailFollower follower = AttachedFollower(); + + follower.Detach(Extent, Extent); + bool request = follower.Observe(Extent, Extent + 20f, true); + + Assert.That( + follower.Detached, + Is.False, + "A developer who paged down to the bottom wants to follow again" + ); + Assert.That( + request, + Is.True, + "The end grew after the key, so the view has to be taken to the new one" + ); + } + + [Test] + public void AParkOnePageFromTheEndStillDetaches() + { + LogTailFollower follower = AttachedFollower(); + + follower.Detach(Extent - 200f, Extent); + + Assert.That( + follower.Detached, + Is.True, + "Everything short of the end is the developer having scrolled away" + ); + } + + [Test] + public void ADetachedPositionSurvivesThePinANewViewWouldOtherwiseGive() + { + LogTailFollower follower = new(); + follower.Attach(); + follower.Detach(Extent - 400f, Extent); + + /* Content grows under the parked view, which is what would + otherwise re-pin it: the extent moved and the value is below it. */ + bool request = follower.Observe(Extent - 400f, Extent + 500f, true); + + Assert.That(request, Is.False, "Output under a parked view does not pull it down"); + Assert.That(follower.Detached, Is.True); + } + + [Test] + public void LandingAtTheEndAfterADetachFollowsAgain() + { + LogTailFollower follower = new(); + follower.Attach(); + follower.Detach(Extent - 400f, Extent); + + follower.Observe(Extent, Extent, false); + + Assert.That( + follower.Detached, + Is.False, + "Reaching the end is the way back to following, whichever key did it" + ); + } + + [Test] + public void AFollowerThatHasNotBeenMovedFollowsAViewItHasNotSeenTheEndOf() + { + /* + The other side of the contract `Detach` exists to keep: a + follower nobody has told about a scroll still follows. Reading a + fresh view's zero position as a developer's scroll would detach + every log the moment it opened, so the inference in `Observe` + stays tied to a pin and nothing else. + */ + LogTailFollower follower = new(); + + Assert.That( + follower.Observe(0f, Extent, true), + Is.True, + "A view nobody has scrolled takes new output" + ); + Assert.That(follower.Detached, Is.False); + } + [Test] public void ADetachedTailIgnoresOutputThatArrivesWhileDetached() { diff --git a/Tests/Runtime/TerminalUILogScrollTests.cs b/Tests/Runtime/TerminalUILogScrollTests.cs new file mode 100644 index 0000000..d4ca6d1 --- /dev/null +++ b/Tests/Runtime/TerminalUILogScrollTests.cs @@ -0,0 +1,469 @@ +namespace WallstopStudios.DxCommandTerminal.Tests.Runtime +{ + using System; + using System.Collections; + using Backend; + using Components; + using Input; + using NUnit.Framework; + using Themes; + using UI; + using UnityEngine; + using UnityEngine.TestTools; + using UnityEngine.UIElements; +#if UNITY_EDITOR + using UnityEditor; +#endif + + /* + The log can be moved with the keyboard, which is the only way a + developer can get to an error in a full 256-entry buffer. + + The command line holds panel focus for as long as the console is open, + so these keys arrive at the command field and the terminal routes them + to the log. LogScrollKeysTests owns the decision; this suite is the + wiring, and only a live panel can answer it: that the key travels, + that the scroller moves by a page, and - the part that matters most - + that a developer who has paged back is not yanked to the newest line by + the output that arrives next. + + Keys are injected at the document root, which is what makes the event + travel down to the field the way a real keystroke does. A panel routes + a key by focus, and this synthetic panel has none, so an event aimed at + the field itself is dropped. + + Every poll waits for a value to hold rather than a frame: the write + lands during event dispatch and the layout that grows the extent runs + after it, so a scroll settles across frames. + */ + public sealed class TerminalUILogScrollTests + { + private const string PackageRoot = "Packages/com.wallstop-studios.dxcommandterminal"; + private const int FrameBudget = 300; + private const int StableLayoutFrames = 5; + private const float Tolerance = 0.5f; + private const int FillLines = 400; + private const string FillMarker = "of 400"; + private const string LateLine = "logged after the developer paged back"; + private const string ParkedLine = "logged while the log was parked"; + private const string FollowedLine = "logged after the log started following again"; + + private TerminalUI _terminal; + private GameObject _terminalObject; + private PanelSettings _panelSettings; + + private static T LoadAsset(string relativePath) + where T : ScriptableObject + { +#if UNITY_EDITOR + return UnityEditor.AssetDatabase.LoadAssetAtPath($"{PackageRoot}/{relativePath}"); +#else + return null; +#endif + } + + [UnityTearDown] + public IEnumerator TearDown() + { + if (_terminalObject != null) + { + UnityEngine.Object.Destroy(_terminalObject); + } + + if (_panelSettings != null) + { + UnityEngine.Object.Destroy(_panelSettings); + } + + yield return null; + } + + [UnityTest] + public IEnumerator PageUpMovesTheLogBackOneViewport() + { + yield return SpawnOpenTerminal(); + yield return FillTheLog(); + yield return WaitForLogAtEnd(FillMarker, "The log follows its own output to the end"); + + float atEnd = LogScroller().value; + yield return SendKey(KeyCode.PageUp); + + yield return WaitForLogValue( + expected: atEnd - LogView().contentViewport.layout.height, + message: "Page Up moves the log back by what one viewport shows" + ); + Assert.That( + LogScroller().value, + Is.LessThan(atEnd), + "A key the log answered moved it, so the route is not just a consumed key" + ); + } + + [UnityTest] + public IEnumerator PageDownReturnsToWherePagingStarted() + { + yield return SpawnOpenTerminal(); + yield return FillTheLog(); + yield return WaitForLogAtEnd(FillMarker, "The log follows its own output to the end"); + + float atEnd = LogScroller().value; + yield return SendKey(KeyCode.PageUp); + yield return WaitForLogValue( + atEnd - LogView().contentViewport.layout.height, + "The first page lands before the second is measured against it" + ); + + float paged = LogScroller().value; + yield return SendKey(KeyCode.PageDown); + yield return WaitForLogValue( + paged + LogView().contentViewport.layout.height, + "Page Down moves forward by the same viewport, so the two are inverses" + ); + } + + [UnityTest] + public IEnumerator OutputAfterPagingBackDoesNotYankTheLogToTheEnd() + { + yield return SpawnOpenTerminal(); + yield return FillTheLog(); + yield return WaitForLogAtEnd(FillMarker, "The log follows its own output to the end"); + + yield return PageAwayFromTheEnd( + "The page is where the developer put it before anything else happens" + ); + float paged = LogScroller().value; + + Terminal.Log(LateLine); + yield return null; + yield return null; + + Assert.That( + LogScroller().value, + Is.EqualTo(paged).Within(Tolerance), + "A developer reading an earlier line is not dragged to the newest one" + ); + } + + [UnityTest] + public IEnumerator CommandEndReturnsToTheTailAndFollowsAgain() + { + yield return SpawnOpenTerminal(); + yield return FillTheLog(); + yield return WaitForLogAtEnd(FillMarker, "The log follows its own output to the end"); + + yield return PageAwayFromTheEnd( + "Ctrl+End is only meaningful if the page actually parked the view" + ); + + yield return SendKey(KeyCode.End, EventModifiers.Command); + yield return WaitForLogAtEnd(ParkedLine, "Ctrl+End is the way back to the tail"); + + /* + Logged after the key, not before. A line that was already in the + buffer when Ctrl+End arrived is at the end whether or not the + key did anything, so it cannot tell a developer that following + resumed - only a line that arrives afterwards can, and it can + only arrive on screen if the view is following again. + */ + Terminal.Log(FollowedLine); + + yield return WaitForLogAtEnd( + FollowedLine, + "After Ctrl+End the view follows new output again, which is what it is for" + ); + } + + [UnityTest] + public IEnumerator CommandHomeJumpsToTheOldestBufferedLine() + { + yield return SpawnOpenTerminal(); + yield return FillTheLog(); + yield return WaitForLogAtEnd(FillMarker, "The log follows its own output to the end"); + + yield return SendKey(KeyCode.Home, EventModifiers.Command); + + yield return WaitForLogValue( + expected: 0f, + message: "Command+Home reaches the oldest line the buffer still holds" + ); + } + + [UnityTest] + public IEnumerator PagingDownToTheBottomLeavesTheLogFollowing() + { + /* + The gesture that found the freeze: catching up on a log that is + still growing. The last Page Down lands on the end, and by then + the extent has usually moved again between the key and the pass + that reads it. Recording a detach there left the view a line + short of a growing end, which nothing recovers it from - the log + simply stops scrolling. + */ + yield return SpawnOpenTerminal(); + yield return FillTheLog(); + yield return WaitForLogAtEnd(FillMarker, "The log follows its own output to the end"); + + int frameBudget = FrameBudget; + while (0 < frameBudget--) + { + Scroller scroller = LogScroller(); + if (Tolerance < scroller.highValue - scroller.value) + { + yield return SendKey(KeyCode.PageDown); + } + else + { + break; + } + + yield return null; + } + + yield return SendKey(KeyCode.PageDown); + Terminal.Log(ParkedLine); + + yield return WaitForLogAtEnd( + ParkedLine, + "A Page Down that reaches the end leaves the view following, not parked one line short" + ); + } + + [UnityTest] + public IEnumerator AKeyTheFieldWantsIsLeftAlone() + { + yield return SpawnOpenTerminal(); + yield return FillTheLog(); + yield return WaitForLogAtEnd(FillMarker, "The log follows its own output to the end"); + + yield return PageAwayFromTheEnd( + "A negative test means nothing unless the log was parked to begin with" + ); + float paged = LogScroller().value; + + yield return SendKey(KeyCode.DownArrow); + + Assert.That( + LogScroller().value, + Is.EqualTo(paged).Within(Tolerance), + "History recall belongs to the command line, so the log does not answer it" + ); + } + + [UnityTest] + public IEnumerator PlainHomeStillBelongsToTheCommandLine() + { + yield return SpawnOpenTerminal(); + yield return FillTheLog(); + yield return WaitForLogAtEnd(FillMarker, "The log follows its own output to the end"); + + yield return PageAwayFromTheEnd( + "A negative test means nothing unless the log was parked to begin with" + ); + float paged = LogScroller().value; + + yield return SendKey(KeyCode.Home); + + Assert.That( + LogScroller().value, + Is.EqualTo(paged).Within(Tolerance), + "A bare Home moves the caret in a one-line field, which is what a developer editing a command expects" + ); + } + + /* + Paginates the log and waits for the view to actually leave the end. + Every negative test - "this key is not the log's" - has to start + from a view that is parked, or it passes just as well with the whole + feature removed, because a log that never moved has nothing left + to move. The caller reads the settled position from the scroller + afterwards, which is where the value this poll waited for lives. + */ + private IEnumerator PageAwayFromTheEnd(string message) + { + float atEnd = LogScroller().value; + yield return SendKey(KeyCode.PageUp); + + int frameBudget = FrameBudget; + while (0 < frameBudget-- && Tolerance < LogScroller().highValue - LogScroller().value) + { + yield return null; + } + + Scroller scroller = LogScroller(); + Assert.That(scroller.highValue - scroller.value, Is.GreaterThan(Tolerance), message); + Assert.That( + scroller.value, + Is.LessThan(atEnd), + "The page moved the view up rather than leaving it where it was" + ); + } + + private ScrollView LogView() + { + ScrollView logView = + _terminal._uiDocument.rootVisualElement.Q("LogScrollView") as ScrollView; + Assert.That(logView, Is.Not.Null, "The log scroll view exists on an open terminal"); + return logView; + } + + private Scroller LogScroller() + { + ScrollView logView = LogView(); + Assert.That(logView.verticalScroller, Is.Not.Null, "It exposes a vertical scroller"); + return logView.verticalScroller; + } + + private IEnumerator FillTheLog() + { + for (int line = 1; line <= FillLines; ++line) + { + Terminal.Log($"fill line {line} {FillMarker}"); + } + + yield return null; + + /* + A scroller with no high value is a log view the panel never laid + out, which is what a headless editor with no rendered view + gives: the content exists, the geometry does not, and every + assertion below would read a zero the environment produced. + That is a host limit, not a defect, so it is reported as one - + skipping with the reason - rather than as seven red tests that + say nothing about the change. See #193 for the wider gap this + host has with UI coverage. + + Held, not merely seen. A panel left half-built by an earlier + test can report a scroller range for a frame or two and lose it + again, and a test that starts on that flicker and asserts into + a geometry that has gone is a flaky red that says the change + is broken. A layout that survives several consecutive frames is + one this environment can actually answer; one that does not is + a host limit, and the honest report is the same either way. + */ + int stableFrames = 0; + int frameBudget = FrameBudget; + while (0 < frameBudget-- && stableFrames < StableLayoutFrames) + { + ScrollView view = LogView(); + bool laidOut = + 0f < LogScroller().highValue && 0f < view.contentViewport.layout.height; + stableFrames = laidOut ? stableFrames + 1 : 0; + yield return null; + } + + if (stableFrames < StableLayoutFrames) + { + Assert.Ignore( + "The log view did not hold a layout in this environment, so its scroller " + + "has nothing to scroll. A headless editor with no rendered view cannot " + + "answer this suite; run it where the Game view renders." + ); + } + } + + private IEnumerator WaitForLogAtEnd(string expectedLastLine, string message) + { + int frameBudget = FrameBudget; + while (0 < frameBudget--) + { + Scroller scroller = LogScroller(); + if ( + 0f < scroller.highValue + && scroller.highValue - scroller.value < Tolerance + && LastLogText().Contains(expectedLastLine, StringComparison.Ordinal) + ) + { + break; + } + + yield return null; + } + + Scroller settled = LogScroller(); + Assert.That(0f < settled.highValue, Is.True, "The log content overflows the view"); + Assert.That(settled.highValue - settled.value, Is.LessThan(Tolerance), message); + } + + private IEnumerator WaitForLogValue(float expected, string message) + { + int frameBudget = FrameBudget; + while (0 < frameBudget-- && Tolerance < Mathf.Abs(LogScroller().value - expected)) + { + yield return null; + } + + Assert.That(LogScroller().value, Is.EqualTo(expected).Within(Tolerance), message); + } + + private string LastLogText() + { + VisualElement content = LogView().contentContainer; + if (0 == content.childCount) + { + return string.Empty; + } + + return (content[content.childCount - 1] as Label)?.text ?? string.Empty; + } + + private IEnumerator SendKey(KeyCode keyCode, EventModifiers modifiers = EventModifiers.None) + { + using (KeyDownEvent key = KeyDownEvent.GetPooled('\0', keyCode, modifiers)) + { + _terminal._uiDocument.rootVisualElement.SendEvent(key); + } + + yield return null; + } + + private IEnumerator SpawnOpenTerminal() + { +#if UNITY_EDITOR + _panelSettings = ScriptableObject.CreateInstance(); + _terminalObject = new GameObject("TerminalUILogScroll"); + _terminalObject.SetActive(false); + UIDocument document = _terminalObject.AddComponent(); + document.panelSettings = _panelSettings; + _terminal = _terminalObject.AddComponent(); + _terminal._uiDocument = document; + _terminal.resetStateOnInit = true; + _terminal._themePack = LoadAsset("Packs/Themes/Medium.asset"); + _terminal._fontPack = LoadAsset("Packs/Fonts/Medium.asset"); + StartTracker tracker = _terminalObject.AddComponent(); + _terminalObject.SetActive(true); + yield return new WaitUntil(() => tracker.Started); +#else + Assert.Ignore("Log scrolling needs the editor Play Mode suite."); + yield break; +#endif + + _terminal.SetState(TerminalState.OpenFull); + + /* Two frames, not one: SetState flags a command as issued for the + frame it runs on, and the frame that clears the flag is the frame + after the one that set it. */ + yield return null; + yield return null; + + int frameBudget = 600; + while ( + 0 < frameBudget-- + && ( + _terminal._commandInput == null + || _terminal._commandInput.resolvedStyle.display != DisplayStyle.Flex + ) + ) + { + yield return null; + } + + Assert.That( + _terminal._commandInput, + Is.Not.Null, + "The terminal input field should exist after the terminal opens" + ); + + DefaultTerminalInput.Instance.CommandText = string.Empty; + } + } +} diff --git a/Tests/Runtime/TerminalUILogScrollTests.cs.meta b/Tests/Runtime/TerminalUILogScrollTests.cs.meta new file mode 100644 index 0000000..4c42ca9 --- /dev/null +++ b/Tests/Runtime/TerminalUILogScrollTests.cs.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 71bc5f6ae11d41d18653d96cd3b0d59a +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: