diff --git a/.github/workflows/nitro-cli-e2e.yml b/.github/workflows/nitro-cli-e2e.yml new file mode 100644 index 00000000000..592fd60844a --- /dev/null +++ b/.github/workflows/nitro-cli-e2e.yml @@ -0,0 +1,197 @@ +name: nitro-cli-e2e + +on: + push: + paths: + - 'src/Nitro/CommandLine/**' + - 'src/Nitro/Common/**' + - '.github/workflows/nitro-cli-e2e.yml' + - 'global.json' + - 'src/Directory.Packages.props' + pull_request: + paths: + - 'src/Nitro/CommandLine/**' + - 'src/Nitro/Common/**' + - '.github/workflows/nitro-cli-e2e.yml' + - 'global.json' + - 'src/Directory.Packages.props' + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + +concurrency: + group: nitro-cli-e2e-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + aot-publish-smoke: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Install .NET + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 + with: + dotnet-version: 11.0.100-preview.6.26359.118 + + - name: Publish AOT self-contained binary (linux-x64) + run: | + dotnet publish src/Nitro/CommandLine/src/CommandLine \ + -c Release \ + -r linux-x64 \ + -f net11.0 \ + -o publish \ + --self-contained \ + -p:PublishAot=true \ + -p:TargetFrameworks=net11.0 \ + -p:RuntimeIdentifiers=linux-x64 + + - name: Smoke test the published binary + run: | + set -euo pipefail + + nitro_bin="$(pwd)/publish/nitro" + "$nitro_bin" --version + + work_dir="$(mktemp -d)" + cd "$work_dir" + export NITRO_TASK_ACTOR=ci + + "$nitro_bin" agent tasks init + "$nitro_bin" agent tasks list + + create_output="$("$nitro_bin" agent tasks create "AOT smoke test task")" + echo "$create_output" + task_id=$(printf '%s' "$create_output" | sed -n "s/.*'\([^']*\)'.*/\1/p") + if [ -z "$task_id" ]; then + echo "::error::Could not parse a task ID out of the create output above." >&2 + exit 1 + fi + + "$nitro_bin" agent tasks show "$task_id" + + record-and-verify: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + + - name: Install .NET + uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 + with: + dotnet-version: 11.0.100-preview.6.26359.118 + + - name: Record + verify all flows + id: record + continue-on-error: true + run: ./src/Nitro/CommandLine/test/e2e/run.sh + + - name: Upload snapshot artifacts + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: nitro-e2e-snapshots + path: | + src/Nitro/CommandLine/test/e2e/out/*.gif + src/Nitro/CommandLine/test/e2e/out/report/** + if-no-files-found: warn + + - name: Comment changed snapshots on PR + if: always() && github.event_name == 'pull_request' + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 + env: + RUN_ID: ${{ github.run_id }} + with: + script: | + const { owner, repo } = context.repo; + const pr = context.payload.pull_request; + const prNum = pr.number; + const baseRef = pr.base.ref; + const MARKER = ''; + const bust = `?run=${process.env.RUN_ID}`; + + const rawBase = `https://raw.githubusercontent.com/${pr.base.repo.full_name}/${pr.base.sha}`; + const headFull = pr.head.repo && pr.head.repo.full_name; + const rawHead = headFull + ? `https://raw.githubusercontent.com/${headFull}/${pr.head.sha}` + : null; + + const files = await github.paginate(github.rest.pulls.listFiles, + { owner, repo, pull_number: prNum, per_page: 100 }); + const re = /^src\/Nitro\/CommandLine\/test\/e2e\/([^/]+)-flow\.gif$/; + const changed = []; + for (const f of files) { + const m = f.filename.match(re); + if (m) changed.push( + { flow: m[1], status: f.status, path: f.filename, prev: f.previous_filename }); + } + changed.sort((a, b) => a.flow.localeCompare(b.flow)); + + async function upsertComment(body) { + const { data: comments } = await github.rest.issues.listComments( + { owner, repo, issue_number: prNum, per_page: 100 }); + const existing = comments.find(c => c.body && c.body.includes(MARKER)); + if (existing) { + await github.rest.issues.updateComment( + { owner, repo, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment( + { owner, repo, issue_number: prNum, body }); + } + } + + if (changed.length === 0) { + try { + await upsertComment( + `${MARKER}\nโœ… **Nitro CLI E2E snapshots**: no flow recordings changed vs \`${baseRef}\`.`); + } catch (e) { + core.warning(`comment failed (expected on forks): ${e}`); + } + return; + } + + const img = url => `recording`; + const ICON = { added: '๐Ÿ†•', removed: '๐Ÿ—‘๏ธ' }; + + const blocks = []; + for (const { flow, status, path, prev } of changed) { + const before = `${rawBase}/${prev || path}`; + const after = rawHead ? `${rawHead}/${path}` : null; + let inner; + if (status === 'added') { + inner = after ? `\n\n${img(after)}\n` : '\n\n_after unavailable_\n'; + } else if (status === 'removed') { + inner = `\n\n${img(before)}\n`; + } else { + const afterCell = after ? img(after) : 'unavailable'; + inner = + `\n\n` + + `
Before: ${baseRef}After: this PR
${img(before)}${afterCell}
\n`; + } + blocks.push( + `
${ICON[status] || '๐Ÿ”'} ${flow}: ${status}${inner}
`); + } + + const body = [ + MARKER, + '### ๐ŸŽฌ Nitro CLI E2E snapshots changed', + '', + `These flow recordings differ from \`${baseRef}\` (added, removed, or modified). ` + + 'Expand each for the before/after.', + '', + ...blocks, + '', + `Run ${process.env.RUN_ID} ยท full set in the nitro-e2e-snapshots artifact.`, + ].join('\n'); + + try { await upsertComment(body); } + catch (e) { core.warning(`comment failed (expected on forks): ${e}`); } + + - name: Fail if any flow drifted from its golden + if: always() && steps.record.outcome == 'failure' + run: | + echo "::error::E2E recordings drifted from their committed golden, review the nitro-e2e-snapshots artifact, then run ./src/Nitro/CommandLine/test/e2e/run.sh --update if intended." >&2 + exit 1 diff --git a/src/All.slnx b/src/All.slnx index 63155413f31..30ed27cbf90 100644 --- a/src/All.slnx +++ b/src/All.slnx @@ -404,6 +404,7 @@ + diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index a2219d370d4..869af9ffab7 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -19,7 +19,10 @@ + + + diff --git a/src/Nitro/CommandLine/Nitro.CommandLine.slnx b/src/Nitro/CommandLine/Nitro.CommandLine.slnx index d66da32314c..f2d3a834e7d 100644 --- a/src/Nitro/CommandLine/Nitro.CommandLine.slnx +++ b/src/Nitro/CommandLine/Nitro.CommandLine.slnx @@ -5,5 +5,6 @@ + diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/AgentCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/AgentCommand.cs new file mode 100644 index 00000000000..dcae8536330 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Agent/AgentCommand.cs @@ -0,0 +1,13 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Agent; + +internal sealed class AgentCommand : Command +{ + public AgentCommand() : base("agent") + { + Description = "Commands for coding agents."; + + Subcommands.Add(new TasksCommand()); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionComposeCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionComposeCommand.cs index 688eafde016..d68418dfdaa 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionComposeCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Fusion/FusionComposeCommand.cs @@ -605,8 +605,8 @@ private static async Task ComposeAsync( if (removeSourceSchemas.Count > 0) { - var existing = (await archive.GetSourceSchemaNamesAsync(cancellationToken)) - .ToHashSet(StringComparer.Ordinal); + var sourceSchemaNames = await archive.GetSourceSchemaNamesAsync(cancellationToken); + var existing = sourceSchemaNames.ToHashSet(StringComparer.Ordinal); var missingSourceSchema = removeSourceSchemas.FirstOrDefault(name => !existing.Contains(name)); diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/AddTaskCommentCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/AddTaskCommentCommand.cs new file mode 100644 index 00000000000..1c994dac85d --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/AddTaskCommentCommand.cs @@ -0,0 +1,52 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class AddTaskCommentCommand : Command +{ + public AddTaskCommentCommand() : base("add") + { + Description = "Add a comment to a task."; + + Arguments.Add(Opt.Instance); + Arguments.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks comment add \"acme-1a2\" \"Looks good to me.\""); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var environmentVariableProvider = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var id = parseResult.GetRequiredValue(Opt.Instance); + var text = parseResult.GetRequiredValue(Opt.Instance); + var actor = TaskActor.Resolve( + parseResult.GetValue(Opt.Instance), environmentVariableProvider); + + var comment = await store.AddCommentAsync(id, text, actor, cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ObjectResult(comment)); + return ExitCodes.Success; + } + + console.OkLine($"Added comment to '{comment.TaskId.EscapeMarkup()}'."); + + return ExitCodes.Success; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/AddTaskDependencyCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/AddTaskDependencyCommand.cs new file mode 100644 index 00000000000..42cbc21e155 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/AddTaskDependencyCommand.cs @@ -0,0 +1,77 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class AddTaskDependencyCommand : Command +{ + public AddTaskDependencyCommand() : base("add") + { + Description = "Add a dependency between two tasks."; + + Arguments.Add(Opt.Instance); + Arguments.Add(Opt.Instance); + + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples( + "agent tasks dep add \"acme-1a2\" \"acme-9z8\"", + "agent tasks dep add \"acme-1a2\" \"acme-9z8\" --type waits-for"); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var environmentVariableProvider = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var id = parseResult.GetRequiredValue(Opt.Instance); + var dependsOnId = parseResult.GetRequiredValue(Opt.Instance); + var typeValue = parseResult.GetValue(Opt.Instance); + var type = typeValue is null + ? TaskDependencyTypes.Blocks + : TaskDependencyTypes.Normalize(typeValue); + var actor = TaskActor.Resolve( + parseResult.GetValue(Opt.Instance), environmentVariableProvider); + + var result = await store.AddDependencyAsync(id, dependsOnId, type, actor, cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ObjectResult(new TaskDependencyAddedResult + { + Id = id, + DependsOnId = dependsOnId, + Type = type, + Cycle = result.Cycle + })); + + return ExitCodes.Success; + } + + console.OkLine( + $"Added {type.EscapeMarkup()} dependency: " + + $"'{id.EscapeMarkup()}' -> '{dependsOnId.EscapeMarkup()}'."); + + return ExitCodes.Success; + } + + public sealed record TaskDependencyAddedResult + { + public required string Id { get; init; } + public required string DependsOnId { get; init; } + public required string Type { get; init; } + public IReadOnlyList? Cycle { get; init; } + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/AddTaskLabelCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/AddTaskLabelCommand.cs new file mode 100644 index 00000000000..62130975205 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/AddTaskLabelCommand.cs @@ -0,0 +1,64 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class AddTaskLabelCommand : Command +{ + public AddTaskLabelCommand() : base("add") + { + Description = "Add one or more labels to a task."; + + Arguments.Add(Opt.Instance); + Arguments.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks label add \"acme-1a2\" api parser"); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var environmentVariableProvider = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var id = parseResult.GetRequiredValue(Opt.Instance); + var labels = parseResult.GetRequiredValue(Opt.Instance); + var actor = TaskActor.Resolve( + parseResult.GetValue(Opt.Instance), environmentVariableProvider); + + var results = await store.AddLabelAsync(id, labels, actor, cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ObjectResult(new TaskLabelAddResult(id, results))); + return ExitCodes.Success; + } + + foreach (var result in results) + { + if (result.Added) + { + console.OkLine($"Added label '{result.Label.EscapeMarkup()}' to '{id.EscapeMarkup()}'."); + } + else + { + console.WriteLine($"Label '{result.Label}' is already on '{id}'."); + } + } + + return ExitCodes.Success; + } + + public sealed record TaskLabelAddResult(string Id, IReadOnlyList Labels); +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/BlockedTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/BlockedTaskCommand.cs new file mode 100644 index 00000000000..fdfad499e4a --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/BlockedTaskCommand.cs @@ -0,0 +1,76 @@ +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class BlockedTaskCommand : Command +{ + public BlockedTaskCommand() : base("blocked") + { + Description = "List tasks that are blocked by unfinished dependencies."; + + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks blocked"); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var blocked = await store.ComputeBlockedAsync(cancellationToken); + + var tasks = new List(); + + foreach (var id in blocked.Keys) + { + var task = await store.GetTaskAsync(id, cancellationToken); + + if (task is not null && !TaskStates.IsTerminal(task.Status)) + { + tasks.Add(task); + } + } + + tasks = tasks.OrderBy(t => t.Id, StringComparer.Ordinal).ToList(); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ListResult( + tasks.Select(t => new TaskBlockedResult( + t.Id, t.Priority, t.Type, t.Status, t.Title, blocked[t.Id])) + .ToArray())); + return ExitCodes.Success; + } + + if (tasks.Count == 0) + { + console.WriteLine("No blocked tasks."); + return ExitCodes.Success; + } + + foreach (var task in tasks) + { + console.WriteLine(FormatRow(task, blocked[task.Id])); + } + + console.WriteLine(); + console.WriteLine($"{tasks.Count} task(s)"); + + return ExitCodes.Success; + } + + private static string FormatRow(TaskItem task, IReadOnlyList blockers) + => $"{task.Id} {TaskPriorities.Format(task.Priority)} {task.Type} " + + $"{task.Status} {task.Title} " + + $"(blocked by: {string.Join(", ", blockers)})"; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/BoardTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/BoardTaskCommand.cs new file mode 100644 index 00000000000..b7f1c774dff --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/BoardTaskCommand.cs @@ -0,0 +1,67 @@ +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Board; +using ChilliCream.Nitro.CommandLine.Tui.Input; +using ChilliCream.Nitro.CommandLine.Tui.Runtime; +using ChilliCream.Nitro.CommandLine.Tui.Search; +using ChilliCream.Nitro.CommandLine.Tui.Shell; +using ChilliCream.Nitro.CommandLine.Tui.Tree; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class BoardTaskCommand : Command +{ + public BoardTaskCommand() : base("board") + { + Description = "Open the interactive task board."; + + this.AddExamples("agent tasks board"); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var timeProvider = services.GetRequiredService(); + var environmentVariableProvider = services.GetRequiredService(); + + if (!console.IsInteractive) + { + throw new ExitException("agent tasks board requires an interactive terminal."); + } + + var workspaceDirectory = store.FindWorkspaceDirectory() + ?? throw new ExitException("No task workspace found. Run `nitro agent tasks init` first."); + + var actor = TaskActor.Resolve(null, environmentVariableProvider); + var loader = new BoardDataLoader(store, timeProvider); + var mode = new BoardMode(loader); + var searchMode = new SearchMode(store); + var treeView = new DependencyTreeView(store, rootId: ""); + var dispatcher = new KeyDispatcher(KeyMap.CreateDefaultGlobal()); + var shell = new TuiShell( + dispatcher, + mode, + console.Profile.Width, + console.Profile.Height, + searchMode, + treeView, + store, + actor); + var application = new TuiApplication(console); + var dbWatcher = new TaskDbWatcher(TaskWorkspace.GetDatabasePath(workspaceDirectory)); + + using var quitCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + shell.QuitConfirmed += () => quitCts.Cancel(); + + await application.RunAsync(shell.Handle, shell.Render, quitCts.Token, [dbWatcher.RunAsync]); + + return ExitCodes.Success; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CloseEligibleTaskEpicCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CloseEligibleTaskEpicCommand.cs new file mode 100644 index 00000000000..d4379abf447 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CloseEligibleTaskEpicCommand.cs @@ -0,0 +1,57 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class CloseEligibleTaskEpicCommand : Command +{ + public CloseEligibleTaskEpicCommand() : base("close-eligible") + { + Description = "Close every epic whose children are all closed."; + + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks epic close-eligible"); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var environmentVariableProvider = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var actor = TaskActor.Resolve( + parseResult.GetValue(Opt.Instance), environmentVariableProvider); + + var eligible = await store.CloseEligibleEpicsAsync(actor, cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ListResult(eligible)); + return ExitCodes.Success; + } + + if (eligible.Count == 0) + { + console.WriteLine("No eligible epics."); + return ExitCodes.Success; + } + + foreach (var epic in eligible) + { + console.OkLine($"Closed epic '{epic.Id.EscapeMarkup()}'."); + } + + return ExitCodes.Success; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CloseTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CloseTaskCommand.cs new file mode 100644 index 00000000000..ef6d0ba4f4c --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CloseTaskCommand.cs @@ -0,0 +1,61 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class CloseTaskCommand : Command +{ + public CloseTaskCommand() : base("close") + { + Description = "Close one or more tasks."; + + Arguments.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples( + "agent tasks close \"app-1a2\"", + "agent tasks close \"app-1a2\" \"app-9z8\" --reason \"Fixed in v2\""); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var environmentVariableProvider = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var ids = parseResult.GetRequiredValue(Opt.Instance) + .Distinct() + .ToArray(); + var reason = parseResult.GetValue(Opt.Instance) ?? ""; + var actor = TaskActor.Resolve( + parseResult.GetValue(Opt.Instance), environmentVariableProvider); + + var tasks = await store.CloseTaskAsync(ids, reason, actor, cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult( + new ListResult(tasks.Select(task => TaskSnapshotResult.From(task)).ToArray())); + + return ExitCodes.Success; + } + + foreach (var task in tasks) + { + console.OkLine($"Closed task '{task.Id.EscapeMarkup()}'."); + } + + return ExitCodes.Success; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CountTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CountTaskCommand.cs new file mode 100644 index 00000000000..889029e0147 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CountTaskCommand.cs @@ -0,0 +1,78 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class CountTaskCommand : Command +{ + public CountTaskCommand() : base("count") + { + Description = "Count tasks."; + + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks count", "agent tasks count --by status"); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var by = parseResult.GetValue(Opt.Instance); + + if (string.IsNullOrEmpty(by)) + { + var total = await store.CountTasksAsync(cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ObjectResult(new TaskTotalCountResult(total))); + return ExitCodes.Success; + } + + console.WriteLine(total.ToString()); + + return ExitCodes.Success; + } + + var dimension = ParseDimension(by); + var rows = await store.CountTasksByAsync(dimension, cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ListResult(rows)); + return ExitCodes.Success; + } + + foreach (var row in rows) + { + console.WriteLine($"{row.Value} {row.Count}"); + } + + return ExitCodes.Success; + } + + public sealed record TaskTotalCountResult(int Total); + + private static TaskCountDimension ParseDimension(string by) => by.Trim().ToLowerInvariant() switch + { + "status" => TaskCountDimension.Status, + "type" => TaskCountDimension.Type, + "priority" => TaskCountDimension.Priority, + "assignee" => TaskCountDimension.Assignee, + "label" => TaskCountDimension.Label, + _ => throw new ExitException( + $"Invalid --by value '{by}'. Use status, type, priority, assignee, or label.") + }; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CreateTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CreateTaskCommand.cs new file mode 100644 index 00000000000..5be32b58102 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CreateTaskCommand.cs @@ -0,0 +1,139 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class CreateTaskCommand : Command +{ + public CreateTaskCommand() : base("create") + { + Description = "Create a task."; + + Arguments.Add(Opt.Instance); + + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples( + "agent tasks create \"Fix the parser\"", + "agent tasks create \"Fix the parser\" --priority p1 --type bug --depends-on \"acme-9z8\""); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var environmentVariables = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var title = parseResult.GetRequiredValue(Opt.Instance); + + if (title.Trim().Length is 0 || title.Length > 500) + { + throw new ExitException("The title must be 1-500 characters."); + } + + var priorityValue = parseResult.GetValue(Opt.Instance); + var priority = priorityValue is null + ? TaskPriorities.Medium + : TaskPriorities.Parse(priorityValue); + + var typeValue = parseResult.GetValue(Opt.Instance); + var type = typeValue is null ? TaskTypes.Task : TaskTypes.Normalize(typeValue); + + var dueValue = parseResult.GetValue(Opt.Instance); + var dueAt = dueValue is null + ? (DateTimeOffset?)null + : TaskDates.Parse(dueValue, Opt.Instance.Name); + + var deferValue = parseResult.GetValue(Opt.Instance); + var deferUntil = deferValue is null + ? (DateTimeOffset?)null + : TaskDates.Parse(deferValue, Opt.Instance.Name); + + var description = parseResult.GetValue(Opt.Instance) ?? ""; + var assignee = parseResult.GetValue(Opt.Instance); + var estimate = parseResult.GetValue(Opt.Instance); + var labels = (parseResult.GetValue(Opt.Instance) ?? []) + .Select(label => label.Trim().ToLowerInvariant()) + .Distinct() + .ToArray(); + + if (labels.Any(string.IsNullOrEmpty)) + { + throw new ExitException("Labels must be non-empty."); + } + + var dependsOn = parseResult.GetValue(Opt.Instance) ?? []; + var parentId = parseResult.GetValue(Opt.Instance); + + var actor = TaskActor.Resolve( + parseResult.GetValue(Opt.Instance), environmentVariables); + + var dependencyRequests = dependsOn.Select(dependency => + { + var separatorIndex = dependency.IndexOf(':'); + var dependencyType = separatorIndex < 0 + ? TaskDependencyTypes.Blocks + : TaskDependencyTypes.Normalize(dependency[..separatorIndex]); + var dependsOnId = separatorIndex < 0 ? dependency : dependency[(separatorIndex + 1)..]; + + return new TaskDependencyRequest(dependsOnId, dependencyType); + }).ToList(); + + var result = await store.CreateTaskAsync( + new TaskCreation + { + Title = title, + Description = description, + Priority = priority, + Type = type, + Assignee = assignee, + EstimatedMinutes = estimate, + DueAt = dueAt, + DeferUntil = deferUntil, + Labels = labels, + DependsOn = dependencyRequests, + ParentId = parentId, + Actor = actor + }, + cancellationToken); + + if (!console.IsHumanReadable) + { + var createdTask = await store.GetRequiredTaskAsync(result.Id, cancellationToken); + + resultHolder.SetResult( + new ObjectResult(TaskSnapshotResult.From(createdTask, result.BlockedBy))); + + return ExitCodes.Success; + } + + console.OkLine($"Created task '{result.Id}': {title.EscapeMarkup()}."); + + if (result.BlockedBy.Count > 0) + { + console.WriteLine($" Blocked by: {string.Join(", ", result.BlockedBy)}"); + } + + return ExitCodes.Success; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CyclesTaskDependencyCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CyclesTaskDependencyCommand.cs new file mode 100644 index 00000000000..8c09c03e049 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/CyclesTaskDependencyCommand.cs @@ -0,0 +1,125 @@ +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class CyclesTaskDependencyCommand : Command +{ + public CyclesTaskDependencyCommand() : base("cycles") + { + Description = "Detect cycles among blocking dependencies."; + + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks dep cycles"); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var dependencyEdges = await store.GetDependencyEdgesAsync(cancellationToken); + var edges = dependencyEdges.Where(e => TaskDependencyTypes.IsBlocking(e.Type)).ToList(); + + var adjacency = edges + .GroupBy(e => e.TaskId) + .ToDictionary( + g => g.Key, + g => g.Select(e => e.DependsOnId) + .Distinct() + .OrderBy(x => x, StringComparer.Ordinal) + .ToList()); + + var cycles = FindCycles(adjacency); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ListResult( + cycles.Select(c => new TaskCycleResult(c)).ToArray())); + return ExitCodes.Success; + } + + if (cycles.Count == 0) + { + console.WriteLine("No cycles found."); + return ExitCodes.Success; + } + + foreach (var cycle in cycles) + { + console.WriteLine(FormatCycle(cycle)); + } + + return ExitCodes.Success; + } + + // Enumerates each elementary cycle exactly once: a search that starts at + // a given node only continues through nodes ordinally greater than or + // equal to it, so a cycle is only ever found while starting from its + // smallest member. + // + // Internal, not private: DoctorTaskCommand reuses this to report + // blocking-dependency cycles as part of its workspace health check. + internal static List> FindCycles(IReadOnlyDictionary> adjacency) + { + var cycles = new List>(); + + foreach (var start in adjacency.Keys.OrderBy(x => x, StringComparer.Ordinal)) + { + var path = new List { start }; + var onPath = new HashSet { start }; + + Walk(start, start, adjacency, path, onPath, cycles); + } + + return cycles; + } + + private static void Walk( + string start, + string current, + IReadOnlyDictionary> adjacency, + List path, + HashSet onPath, + List> cycles) + { + if (!adjacency.TryGetValue(current, out var neighbors)) + { + return; + } + + foreach (var next in neighbors) + { + if (next == start) + { + cycles.Add([.. path]); + continue; + } + + if (string.CompareOrdinal(next, start) < 0 || onPath.Contains(next)) + { + continue; + } + + path.Add(next); + onPath.Add(next); + + Walk(start, next, adjacency, path, onPath, cycles); + + path.RemoveAt(path.Count - 1); + onPath.Remove(next); + } + } + + private static string FormatCycle(IReadOnlyList cycle) + => string.Join(" -> ", cycle) + " -> " + cycle[0]; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/DeferTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/DeferTaskCommand.cs new file mode 100644 index 00000000000..47bf0b581de --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/DeferTaskCommand.cs @@ -0,0 +1,55 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class DeferTaskCommand : Command +{ + public DeferTaskCommand() : base("defer") + { + Description = "Defer a task until a future date."; + + Arguments.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks defer \"acme-1a2\" --until \"2026-02-01\""); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var environmentVariableProvider = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var id = parseResult.GetRequiredValue(Opt.Instance); + var untilValue = parseResult.GetRequiredValue(Opt.Instance); + var actor = TaskActor.Resolve( + parseResult.GetValue(Opt.Instance), environmentVariableProvider); + + var deferUntil = TaskDates.Parse(untilValue, Opt.Instance.Name); + + var task = await store.DeferTaskAsync(id, deferUntil, actor, cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ObjectResult(TaskSnapshotResult.From(task))); + return ExitCodes.Success; + } + + console.OkLine( + $"Deferred task '{task.Id.EscapeMarkup()}' until {TaskDates.Format(deferUntil).EscapeMarkup()}."); + + return ExitCodes.Success; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/DeleteTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/DeleteTaskCommand.cs new file mode 100644 index 00000000000..c3a9d3de273 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/DeleteTaskCommand.cs @@ -0,0 +1,79 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class DeleteTaskCommand : Command +{ + public DeleteTaskCommand() : base("delete") + { + Description = "Delete a task."; + + Arguments.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples( + "agent tasks delete \"app-1a2\"", + "agent tasks delete \"app-1a2\" --force"); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var environmentVariableProvider = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var id = parseResult.GetRequiredValue(Opt.Instance); + var reason = parseResult.GetValue(Opt.Instance) ?? ""; + var actor = TaskActor.Resolve( + parseResult.GetValue(Opt.Instance), environmentVariableProvider); + var force = parseResult.GetValue(Opt.Instance); + + // Existence is checked up front, before the confirmation prompt, so a + // nonexistent task fails immediately instead of asking to confirm it. + await store.GetRequiredTaskAsync(id, cancellationToken); + + if (!force) + { + if (console.IsInteractive) + { + var confirmed = await console.ConfirmAsync( + $"Delete task '{id.EscapeMarkup()}'?", cancellationToken); + + if (!confirmed) + { + console.WriteLine("Aborted."); + return ExitCodes.Success; + } + } + else + { + throw new ExitException("Use --force to delete without confirmation."); + } + } + + var task = await store.DeleteTaskAsync(id, reason, actor, cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ObjectResult(TaskSnapshotResult.From(task))); + return ExitCodes.Success; + } + + console.OkLine($"Deleted task '{task.Id.EscapeMarkup()}'."); + + return ExitCodes.Success; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/DoctorTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/DoctorTaskCommand.cs new file mode 100644 index 00000000000..d91a1a67d41 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/DoctorTaskCommand.cs @@ -0,0 +1,159 @@ +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class DoctorTaskCommand : Command +{ + public DoctorTaskCommand() : base("doctor") + { + Description = "Check the task workspace for integrity problems."; + + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks doctor"); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var workspaceDirectory = store.FindWorkspaceDirectory() + ?? throw new ExitException( + "No task workspace found. Run `nitro agent tasks init` first."); + + var prefix = await store.GetPrefixAsync(cancellationToken); + var taskCount = await store.CountTasksAsync(cancellationToken); + var integrity = await store.CheckIntegrityAsync(cancellationToken); + var cycles = await FindBlockingCyclesAsync(store, cancellationToken); + + var healthy = integrity.QuickCheckOk + && cycles.Count == 0 + && integrity.OrphanDependencies.Count == 0 + && integrity.OrphanLabels.Count == 0 + && integrity.OrphanComments.Count == 0 + && integrity.TombstonedParentEdges.Count == 0; + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ObjectResult(new TaskDoctorResult( + workspaceDirectory, + prefix, + taskCount, + integrity.QuickCheckOk, + integrity.QuickCheckMessage, + cycles.Select(cycle => new TaskCycleResult(cycle)).ToArray(), + integrity.OrphanDependencies, + integrity.OrphanLabels, + integrity.OrphanComments, + integrity.TombstonedParentEdges, + healthy))); + + return healthy ? ExitCodes.Success : ExitCodes.Error; + } + + console.WriteLine($"Workspace: {workspaceDirectory}"); + console.WriteLine($"Prefix: {prefix}"); + console.WriteLine($"Tasks: {taskCount}"); + console.WriteLine(); + + WriteCheck( + console, + "SQLite quick_check", + integrity.QuickCheckOk, + [integrity.QuickCheckMessage]); + + WriteCheck( + console, + "Dependency cycles", + cycles.Count == 0, + cycles.Select(cycle => string.Join(" -> ", cycle) + " -> " + cycle[0])); + + WriteCheck( + console, + "Orphan dependencies", + integrity.OrphanDependencies.Count == 0, + integrity.OrphanDependencies.Select(edge => $"{edge.TaskId} -> {edge.DependsOnId}")); + + WriteCheck( + console, + "Orphan labels", + integrity.OrphanLabels.Count == 0, + integrity.OrphanLabels.Select(label => $"{label.TaskId}: '{label.Label}'")); + + WriteCheck( + console, + "Orphan comments", + integrity.OrphanComments.Count == 0, + integrity.OrphanComments.Select(comment => $"{comment.TaskId}: comment #{comment.CommentId}")); + + WriteCheck( + console, + "Tombstoned parent edges", + integrity.TombstonedParentEdges.Count == 0, + integrity.TombstonedParentEdges.Select(edge => $"{edge.TaskId} -> {edge.DependsOnId}")); + + return healthy ? ExitCodes.Success : ExitCodes.Error; + } + + private static void WriteCheck( + INitroConsole console, + string name, + bool ok, + IEnumerable problems) + { + if (ok) + { + console.OkLine(name); + return; + } + + console.WriteLine($"FAIL {name}:"); + + foreach (var problem in problems) + { + console.WriteLine($" {problem}"); + } + } + + private static async Task>> FindBlockingCyclesAsync( + ITaskStore store, + CancellationToken cancellationToken) + { + var dependencyEdges = await store.GetDependencyEdgesAsync(cancellationToken); + var edges = dependencyEdges.Where(edge => TaskDependencyTypes.IsBlocking(edge.Type)).ToList(); + + var adjacency = edges + .GroupBy(edge => edge.TaskId) + .ToDictionary( + group => group.Key, + group => group.Select(edge => edge.DependsOnId) + .Distinct() + .OrderBy(x => x, StringComparer.Ordinal) + .ToList()); + + return CyclesTaskDependencyCommand.FindCycles(adjacency); + } + + public sealed record TaskDoctorResult( + string WorkspacePath, + string Prefix, + int TaskCount, + bool QuickCheckOk, + string QuickCheckMessage, + IReadOnlyList Cycles, + IReadOnlyList OrphanDependencies, + IReadOnlyList OrphanLabels, + IReadOnlyList OrphanComments, + IReadOnlyList TombstonedParentEdges, + bool Healthy); +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/GetTaskConfigCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/GetTaskConfigCommand.cs new file mode 100644 index 00000000000..938615cf4b3 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/GetTaskConfigCommand.cs @@ -0,0 +1,49 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class GetTaskConfigCommand : Command +{ + public GetTaskConfigCommand() : base("get") + { + Description = "Get a configuration value."; + + Arguments.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks config get prefix"); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var key = parseResult.GetRequiredValue(Opt.Instance); + + var value = await store.GetConfigAsync(key, cancellationToken) + ?? throw new ExitException($"Configuration key '{key}' is not set."); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ObjectResult(new TaskConfigValueResult(value))); + return ExitCodes.Success; + } + + console.WriteLine(value); + + return ExitCodes.Success; + } + + public sealed record TaskConfigValueResult(string Value); +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/InitTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/InitTaskCommand.cs new file mode 100644 index 00000000000..0c37def607f --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/InitTaskCommand.cs @@ -0,0 +1,82 @@ +using System.Text; +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class InitTaskCommand : Command +{ + public InitTaskCommand() : base("init") + { + Description = "Initialize a task workspace in the current directory."; + + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks init", "agent tasks init --prefix \"app\""); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var fileSystem = services.GetRequiredService(); + var store = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var currentDirectory = fileSystem.GetCurrentDirectory(); + var workspaceDirectory = TaskWorkspace.GetDirectory(currentDirectory); + var databasePath = TaskWorkspace.GetDatabasePath(workspaceDirectory); + var force = parseResult.GetValue(Opt.Instance); + + if (!force && fileSystem.FileExists(databasePath)) + { + throw new ExitException( + $"Already initialized at '{TaskWorkspace.DisplayPath}'. " + + "Use --force to reinitialize."); + } + + var prefix = TaskWorkspace.NormalizePrefix( + parseResult.GetValue(Opt.Instance) + ?? Path.GetFileName(Path.TrimEndingDirectorySeparator(currentDirectory))); + + if (!fileSystem.DirectoryExists(workspaceDirectory)) + { + fileSystem.CreateDirectory(workspaceDirectory); + } + + await store.InitializeWorkspaceAsync(workspaceDirectory, prefix, cancellationToken); + + var gitIgnorePath = Path.Combine( + workspaceDirectory, TaskWorkspace.GitIgnoreFileName); + + if (force || !fileSystem.FileExists(gitIgnorePath)) + { + await using var gitIgnoreStream = fileSystem.CreateFile(gitIgnorePath); + await gitIgnoreStream.WriteAsync( + Encoding.UTF8.GetBytes(TaskWorkspace.GitIgnoreContent), + cancellationToken); + } + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ObjectResult(new TaskWorkspaceInitResult(workspaceDirectory, prefix))); + return ExitCodes.Success; + } + + console.OkLine($"Initialized task workspace at '{TaskWorkspace.DisplayPath}'."); + console.OkLine($"Task ID prefix set to '{prefix}'."); + + return ExitCodes.Success; + } + + public sealed record TaskWorkspaceInitResult(string Path, string Prefix); +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/LintTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/LintTaskCommand.cs new file mode 100644 index 00000000000..7baccbe9ab9 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/LintTaskCommand.cs @@ -0,0 +1,107 @@ +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class LintTaskCommand : Command +{ + public LintTaskCommand() : base("lint") + { + Description = "Report task quality findings."; + + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks lint"); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var findings = await CollectFindingsAsync(store, cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ListResult(findings)); + return ExitCodes.Success; + } + + if (findings.Count == 0) + { + console.WriteLine("No lint findings."); + return ExitCodes.Success; + } + + foreach (var finding in findings) + { + console.WriteLine($"{finding.TaskId} {finding.Rule}: {finding.Message}"); + } + + return ExitCodes.Success; + } + + private static async Task> CollectFindingsAsync( + ITaskStore store, + CancellationToken cancellationToken) + { + var findings = new List(); + + var tasks = await store.QueryTasksAsync( + new TaskFilter { IncludeAll = true, ExcludeTombstones = true }, + cancellationToken); + + foreach (var task in tasks) + { + if (task.Status == TaskStates.Open && string.IsNullOrWhiteSpace(task.Description)) + { + findings.Add(new TaskLintFinding( + task.Id, "empty-description", "Open task has an empty description.")); + } + + if (task.Status == TaskStates.Deferred && task.DeferUntil is null) + { + findings.Add(new TaskLintFinding( + task.Id, "deferred-without-defer-until", "Deferred task has no defer_until.")); + } + + if (task.Status == TaskStates.InProgress && string.IsNullOrEmpty(task.Assignee)) + { + findings.Add(new TaskLintFinding( + task.Id, "in-progress-without-assignee", "In-progress task has no assignee.")); + } + } + + var epicStatuses = await store.GetEpicStatusesAsync(cancellationToken); + + foreach (var epic in epicStatuses) + { + if (epic.Total == 0) + { + findings.Add(new TaskLintFinding( + epic.Id, "epic-no-children", "Epic has zero children.")); + } + else if (epic.Status == TaskStates.Closed && epic.Closed < epic.Total) + { + findings.Add(new TaskLintFinding( + epic.Id, "closed-epic-open-children", "Closed epic has open children.")); + } + } + + findings.Sort((left, right) => + { + var byTaskId = string.CompareOrdinal(left.TaskId, right.TaskId); + return byTaskId != 0 ? byTaskId : string.CompareOrdinal(left.Rule, right.Rule); + }); + + return findings; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskCommand.cs new file mode 100644 index 00000000000..d17563b0d76 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskCommand.cs @@ -0,0 +1,100 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class ListTaskCommand : Command +{ + public ListTaskCommand() : base("list") + { + Description = "List tasks."; + + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples( + "agent tasks list", + "agent tasks list --status open --status in_progress", + "agent tasks list --assignee alice --priority p1"); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var statuses = parseResult.GetValue(Opt.Instance); + var type = parseResult.GetValue(Opt.Instance); + var priority = parseResult.GetValue(Opt.Instance); + var assignee = parseResult.GetValue(Opt.Instance); + var labels = parseResult.GetValue(Opt.Instance); + var limit = parseResult.GetValue(Opt.Instance); + var all = parseResult.GetValue(Opt.Instance); + + var priorityRange = priority is null ? default((int Min, int Max)?) : TaskPriorities.ParseRange(priority); + + var filter = new TaskFilter + { + Statuses = statuses, + IncludeAll = all, + Type = type, + PriorityMin = priorityRange?.Min, + PriorityMax = priorityRange?.Max, + Assignee = assignee, + Labels = labels, + Limit = limit + }; + + var tasks = await store.QueryTasksAsync(filter, cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ListResult(tasks.Select(ToSummary).ToArray())); + return ExitCodes.Success; + } + + if (tasks.Count == 0) + { + console.WriteLine("No tasks found."); + return ExitCodes.Success; + } + + foreach (var task in tasks) + { + console.WriteLine(FormatRow(task)); + } + + console.WriteLine(); + console.WriteLine($"{tasks.Count} task(s)"); + + return ExitCodes.Success; + } + + private static string FormatRow(TaskItem task) + => new TaskListRow + { + Id = task.Id, + Priority = task.Priority, + Type = task.Type, + Status = task.Status, + Title = task.Title + }.Format(); + + private static TaskSummaryResult ToSummary(TaskItem task) + => new(task.Id, task.Priority, task.Type, task.Status, task.Title); +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskCommentCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskCommentCommand.cs new file mode 100644 index 00000000000..61c685181c7 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskCommentCommand.cs @@ -0,0 +1,68 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class ListTaskCommentCommand : Command +{ + public ListTaskCommentCommand() : base("list") + { + Description = "List a task's comments."; + + Arguments.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks comment list \"acme-1a2\""); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var id = parseResult.GetRequiredValue(Opt.Instance); + + var task = await store.GetRequiredTaskAsync(id, cancellationToken); + var comments = await store.GetCommentsAsync(task.Id, cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ListResult(comments)); + return ExitCodes.Success; + } + + if (comments.Count == 0) + { + console.WriteLine("No comments."); + return ExitCodes.Success; + } + + for (var i = 0; i < comments.Count; i++) + { + if (i > 0) + { + console.WriteLine(); + } + + var comment = comments[i]; + + console.WriteLine($" [{i + 1}] {comment.Author} {TaskDates.Format(comment.CreatedAt)}"); + + foreach (var line in comment.Text.ReplaceLineEndings("\n").Split('\n')) + { + console.WriteLine(" " + line); + } + } + + return ExitCodes.Success; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskConfigCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskConfigCommand.cs new file mode 100644 index 00000000000..c97afb26889 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskConfigCommand.cs @@ -0,0 +1,45 @@ +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class ListTaskConfigCommand : Command +{ + public ListTaskConfigCommand() : base("list") + { + Description = "List all configuration values."; + + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks config list"); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var rows = await store.ListConfigAsync(cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ListResult(rows)); + return ExitCodes.Success; + } + + foreach (var row in rows) + { + console.WriteLine($"{row.Key} = {row.Value}"); + } + + return ExitCodes.Success; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskDependencyCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskDependencyCommand.cs new file mode 100644 index 00000000000..017d8400a23 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskDependencyCommand.cs @@ -0,0 +1,98 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class ListTaskDependencyCommand : Command +{ + public ListTaskDependencyCommand() : base("list") + { + Description = "List a task's dependencies."; + + Arguments.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks dep list \"acme-1a2\""); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var id = parseResult.GetRequiredValue(Opt.Instance); + + var task = await store.GetRequiredTaskAsync(id, cancellationToken); + var dependencies = await store.GetDependenciesAsync(task.Id, cancellationToken); + var dependents = await store.GetDependentsAsync(task.Id, cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ObjectResult(new TaskDependenciesResult(dependencies, dependents))); + return ExitCodes.Success; + } + + if (dependencies.Count == 0 && dependents.Count == 0) + { + console.WriteLine("No dependencies."); + return ExitCodes.Success; + } + + if (dependencies.Count > 0) + { + console.WriteLine($"Dependencies of {task.Id}:"); + + foreach (var dependency in dependencies) + { + console.WriteLine(FormatDependency(dependency)); + } + } + + if (dependents.Count > 0) + { + console.WriteLine("Depended on by:"); + + foreach (var dependent in dependents) + { + console.WriteLine(FormatDependent(dependent)); + } + } + + return ExitCodes.Success; + } + + private static string FormatDependency(TaskDependencyDetail dependency) + { + var status = dependency.Status ?? "unknown"; + var line = $" {dependency.Type} -> {dependency.DependsOnId} ({status})"; + + if (!string.IsNullOrEmpty(dependency.Title)) + { + line += $" {dependency.Title}"; + } + + return line; + } + + private static string FormatDependent(TaskDependentDetail dependent) + { + var status = dependent.Status ?? "unknown"; + var line = $" {dependent.TaskId} ({dependent.Type}, {status})"; + + if (!string.IsNullOrEmpty(dependent.Title)) + { + line += $" {dependent.Title}"; + } + + return line; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskLabelCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskLabelCommand.cs new file mode 100644 index 00000000000..bb889f9f92e --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ListTaskLabelCommand.cs @@ -0,0 +1,81 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class ListTaskLabelCommand : Command +{ + public ListTaskLabelCommand() : base("list") + { + Description = "List a task's labels, or every label in use."; + + Arguments.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks label list", "agent tasks label list \"acme-1a2\""); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var id = parseResult.GetValue(Opt.Instance); + + if (id is not null) + { + var task = await store.GetRequiredTaskAsync(id, cancellationToken); + var labels = await store.GetLabelsAsync(task.Id, cancellationToken); + + if (!console.IsHumanReadable) + { + var items = labels.Select(label => new TaskLabelCount(label, 1)).ToList(); + resultHolder.SetResult(new ListResult(items)); + return ExitCodes.Success; + } + + if (labels.Count == 0) + { + console.WriteLine("No labels."); + return ExitCodes.Success; + } + + foreach (var label in labels) + { + console.WriteLine(label); + } + + return ExitCodes.Success; + } + + var rows = await store.GetLabelCountsAsync(cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ListResult(rows)); + return ExitCodes.Success; + } + + if (rows.Count == 0) + { + console.WriteLine("No labels."); + return ExitCodes.Success; + } + + foreach (var row in rows) + { + console.WriteLine($"{row.Label} {row.Count}"); + } + + return ExitCodes.Success; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/CommentTextArgument.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/CommentTextArgument.cs new file mode 100644 index 00000000000..a9ec7af3801 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/CommentTextArgument.cs @@ -0,0 +1,9 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class CommentTextArgument : Argument +{ + public CommentTextArgument() : base("text") + { + Description = "The comment text"; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/ConfigKeyArgument.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/ConfigKeyArgument.cs new file mode 100644 index 00000000000..a8983c28d50 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/ConfigKeyArgument.cs @@ -0,0 +1,9 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class ConfigKeyArgument : Argument +{ + public ConfigKeyArgument() : base("key") + { + Description = "The configuration key"; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/ConfigValueArgument.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/ConfigValueArgument.cs new file mode 100644 index 00000000000..3c612c937fc --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/ConfigValueArgument.cs @@ -0,0 +1,9 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class ConfigValueArgument : Argument +{ + public ConfigValueArgument() : base("value") + { + Description = "The configuration value"; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/DependsOnIdArgument.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/DependsOnIdArgument.cs new file mode 100644 index 00000000000..179be0ad0ec --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/DependsOnIdArgument.cs @@ -0,0 +1,9 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class DependsOnIdArgument : Argument +{ + public DependsOnIdArgument() : base("depends-on-id") + { + Description = "The task this dependency points to"; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/ForceReinitializeOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/ForceReinitializeOption.cs new file mode 100644 index 00000000000..eae6207722f --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/ForceReinitializeOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class ForceReinitializeOption : Option +{ + public ForceReinitializeOption() : base("--force") + { + Description = "Reinitialize an existing task workspace"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/LabelArgument.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/LabelArgument.cs new file mode 100644 index 00000000000..d3b84d80fc4 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/LabelArgument.cs @@ -0,0 +1,9 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class LabelArgument : Argument +{ + public LabelArgument() : base("label") + { + Description = "The label"; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/LabelsArgument.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/LabelsArgument.cs new file mode 100644 index 00000000000..6f7cc1eedb5 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/LabelsArgument.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class LabelsArgument : Argument +{ + public LabelsArgument() : base("labels") + { + Description = "One or more labels"; + Arity = ArgumentArity.OneOrMore; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/OptionalTaskIdArgument.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/OptionalTaskIdArgument.cs new file mode 100644 index 00000000000..c570acde122 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/OptionalTaskIdArgument.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class OptionalTaskIdArgument : Argument +{ + public OptionalTaskIdArgument() : base("id") + { + Description = "The task ID"; + Arity = ArgumentArity.ZeroOrOne; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/SearchTextArgument.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/SearchTextArgument.cs new file mode 100644 index 00000000000..96a650769cb --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/SearchTextArgument.cs @@ -0,0 +1,9 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class SearchTextArgument : Argument +{ + public SearchTextArgument() : base("text") + { + Description = "The search text"; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/SyncFlushOnlyOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/SyncFlushOnlyOption.cs new file mode 100644 index 00000000000..d0660bb3882 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/SyncFlushOnlyOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class SyncFlushOnlyOption : Option +{ + public SyncFlushOnlyOption() : base("--flush-only") + { + Description = "Write the task database to tasks.jsonl"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/SyncImportOnlyOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/SyncImportOnlyOption.cs new file mode 100644 index 00000000000..f7ebc254e06 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/SyncImportOnlyOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class SyncImportOnlyOption : Option +{ + public SyncImportOnlyOption() : base("--import-only") + { + Description = "Load tasks.jsonl into the task database"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/SyncStatusOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/SyncStatusOption.cs new file mode 100644 index 00000000000..6e9176c2ab7 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/SyncStatusOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class SyncStatusOption : Option +{ + public SyncStatusOption() : base("--status") + { + Description = "Report whether tasks.jsonl and the task database diverge"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskAcceptanceCriteriaOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskAcceptanceCriteriaOption.cs new file mode 100644 index 00000000000..8d496699bef --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskAcceptanceCriteriaOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskAcceptanceCriteriaOption : Option +{ + public TaskAcceptanceCriteriaOption() : base("--acceptance-criteria") + { + Description = "The acceptance criteria"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskActorOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskActorOption.cs new file mode 100644 index 00000000000..29048306d08 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskActorOption.cs @@ -0,0 +1,11 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskActorOption : Option +{ + public TaskActorOption() : base("--actor") + { + Description = "The acting identity recorded on the audit log " + + "(defaults to NITRO_TASK_ACTOR or the OS user name)"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskAddLabelOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskAddLabelOption.cs new file mode 100644 index 00000000000..ec86b18ef10 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskAddLabelOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskAddLabelOption : Option +{ + public TaskAddLabelOption() : base("--add-label") + { + Description = "A label to add; can be used multiple times"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskAllOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskAllOption.cs new file mode 100644 index 00000000000..8af3137362c --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskAllOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskAllOption : Option +{ + public TaskAllOption() : base("--all") + { + Description = "Include closed and tombstoned tasks"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskAssigneeOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskAssigneeOption.cs new file mode 100644 index 00000000000..47c88002f62 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskAssigneeOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskAssigneeOption : Option +{ + public TaskAssigneeOption() : base("--assignee") + { + Description = "The assignee"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskByOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskByOption.cs new file mode 100644 index 00000000000..b722e1e4776 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskByOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskByOption : Option +{ + public TaskByOption() : base("--by") + { + Description = "Group counts by: status, type, priority, assignee, or label"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskClaimOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskClaimOption.cs new file mode 100644 index 00000000000..4017ab4360f --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskClaimOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskClaimOption : Option +{ + public TaskClaimOption() : base("--claim") + { + Description = "Shorthand for --status in_progress --assignee "; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskDaysOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskDaysOption.cs new file mode 100644 index 00000000000..a08902278de --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskDaysOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskDaysOption : Option +{ + public TaskDaysOption() : base("--days") + { + Description = "The staleness threshold in days (default 30)"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskDeferUntilOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskDeferUntilOption.cs new file mode 100644 index 00000000000..f9cb019cafc --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskDeferUntilOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskDeferUntilOption : Option +{ + public TaskDeferUntilOption() : base("--defer-until") + { + Description = "Hide the task from ready work until this ISO 8601 date or timestamp"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskDependencyTypeOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskDependencyTypeOption.cs new file mode 100644 index 00000000000..d6473eda513 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskDependencyTypeOption.cs @@ -0,0 +1,11 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskDependencyTypeOption : Option +{ + public TaskDependencyTypeOption() : base("--type") + { + Description = "The dependency type (blocks, parent-child, waits-for, related, " + + "...; default blocks)"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskDependsOnOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskDependsOnOption.cs new file mode 100644 index 00000000000..dc0da21763e --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskDependsOnOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskDependsOnOption : Option +{ + public TaskDependsOnOption() : base("--depends-on") + { + Description = "A dependency as 'id' or 'type:id'; can be used multiple times"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskDescriptionOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskDescriptionOption.cs new file mode 100644 index 00000000000..098a079b1c4 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskDescriptionOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskDescriptionOption : Option +{ + public TaskDescriptionOption() : base("--description") + { + Description = "The task description"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskDesignOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskDesignOption.cs new file mode 100644 index 00000000000..d5834b5b185 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskDesignOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskDesignOption : Option +{ + public TaskDesignOption() : base("--design") + { + Description = "The task design"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskDueOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskDueOption.cs new file mode 100644 index 00000000000..7e0b787c83a --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskDueOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskDueOption : Option +{ + public TaskDueOption() : base("--due") + { + Description = "The due date as an ISO 8601 date or timestamp"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskEstimateOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskEstimateOption.cs new file mode 100644 index 00000000000..82fe65a52da --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskEstimateOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskEstimateOption : Option +{ + public TaskEstimateOption() : base("--estimate") + { + Description = "The estimated effort in minutes"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskIdArgument.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskIdArgument.cs new file mode 100644 index 00000000000..8f759cdfcc8 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskIdArgument.cs @@ -0,0 +1,9 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskIdArgument : Argument +{ + public TaskIdArgument() : base("id") + { + Description = "The task ID"; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskIdsArgument.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskIdsArgument.cs new file mode 100644 index 00000000000..eaa3181ffe1 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskIdsArgument.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskIdsArgument : Argument +{ + public TaskIdsArgument() : base("ids") + { + Description = "One or more task IDs"; + Arity = ArgumentArity.OneOrMore; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskIncludeDeferredOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskIncludeDeferredOption.cs new file mode 100644 index 00000000000..5d7eec1fc03 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskIncludeDeferredOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskIncludeDeferredOption : Option +{ + public TaskIncludeDeferredOption() : base("--include-deferred") + { + Description = "Include deferred tasks"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskLabelOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskLabelOption.cs new file mode 100644 index 00000000000..cbc25fbfde7 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskLabelOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskLabelOption : Option +{ + public TaskLabelOption() : base("--label") + { + Description = "A label; can be used multiple times"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskLimitOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskLimitOption.cs new file mode 100644 index 00000000000..5e437e3e4bd --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskLimitOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskLimitOption : Option +{ + public TaskLimitOption() : base("--limit") + { + Description = "The maximum number of tasks to show"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskNotesOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskNotesOption.cs new file mode 100644 index 00000000000..e96e7981b4d --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskNotesOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskNotesOption : Option +{ + public TaskNotesOption() : base("--notes") + { + Description = "The task notes"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskParentOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskParentOption.cs new file mode 100644 index 00000000000..3e2ca47d0fe --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskParentOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskParentOption : Option +{ + public TaskParentOption() : base("--parent") + { + Description = "The parent task ID; the new task becomes its child"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskPrefixOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskPrefixOption.cs new file mode 100644 index 00000000000..5a4ba8f8111 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskPrefixOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskPrefixOption : Option +{ + public TaskPrefixOption() : base("--prefix") + { + Description = "The task ID prefix (defaults to the current directory name)"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskPriorityOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskPriorityOption.cs new file mode 100644 index 00000000000..704a8b73b20 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskPriorityOption.cs @@ -0,0 +1,11 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskPriorityOption : Option +{ + public TaskPriorityOption() : base("--priority") + { + Description = "The task priority, 0-4 or p0-p4 (0 = critical, 4 = backlog); " + + "list/ready also accept a range like 0-1 or p0-p1"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskReasonOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskReasonOption.cs new file mode 100644 index 00000000000..df3d31bdd80 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskReasonOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskReasonOption : Option +{ + public TaskReasonOption() : base("--reason") + { + Description = "The reason recorded for this change"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskRemoveLabelOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskRemoveLabelOption.cs new file mode 100644 index 00000000000..6b7cc0ee9c1 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskRemoveLabelOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskRemoveLabelOption : Option +{ + public TaskRemoveLabelOption() : base("--remove-label") + { + Description = "A label to remove; can be used multiple times"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskStatusFilterOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskStatusFilterOption.cs new file mode 100644 index 00000000000..b2f67a66c5f --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskStatusFilterOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskStatusFilterOption : Option +{ + public TaskStatusFilterOption() : base("--status") + { + Description = "Filter by status; can be used multiple times"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskStatusOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskStatusOption.cs new file mode 100644 index 00000000000..5510aeaeabb --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskStatusOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskStatusOption : Option +{ + public TaskStatusOption() : base("--status") + { + Description = "The task status (open, in_progress, blocked, deferred, closed, or custom)"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskTitleArgument.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskTitleArgument.cs new file mode 100644 index 00000000000..9023a93587b --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskTitleArgument.cs @@ -0,0 +1,9 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskTitleArgument : Argument +{ + public TaskTitleArgument() : base("title") + { + Description = "The task title"; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskTitleOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskTitleOption.cs new file mode 100644 index 00000000000..474eb12560e --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskTitleOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskTitleOption : Option +{ + public TaskTitleOption() : base("--title") + { + Description = "The task title"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskTypeOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskTypeOption.cs new file mode 100644 index 00000000000..8e78786f069 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskTypeOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskTypeOption : Option +{ + public TaskTypeOption() : base("--type") + { + Description = "The task type (task, bug, feature, epic, chore, docs, question, or custom)"; + Required = false; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskUntilOption.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskUntilOption.cs new file mode 100644 index 00000000000..f706e0e4b0e --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/Options/TaskUntilOption.cs @@ -0,0 +1,10 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; + +internal sealed class TaskUntilOption : Option +{ + public TaskUntilOption() : base("--until") + { + Description = "Hide the task from ready work until this ISO 8601 date or timestamp"; + Required = true; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/QuickTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/QuickTaskCommand.cs new file mode 100644 index 00000000000..04d7f36a2a2 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/QuickTaskCommand.cs @@ -0,0 +1,93 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class QuickTaskCommand : Command +{ + public QuickTaskCommand() : base("q") + { + Description = "Quickly create a task and print only its ID."; + + Arguments.Add(Opt.Instance); + + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples( + "agent tasks q \"Fix the parser\"", + "agent tasks q \"Fix the parser\" --priority p1 --type bug --label api"); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var environmentVariables = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var title = parseResult.GetRequiredValue(Opt.Instance); + + if (title.Trim().Length is 0 || title.Length > 500) + { + throw new ExitException("The title must be 1-500 characters."); + } + + var priorityValue = parseResult.GetValue(Opt.Instance); + var priority = priorityValue is null + ? TaskPriorities.Medium + : TaskPriorities.Parse(priorityValue); + + var typeValue = parseResult.GetValue(Opt.Instance); + var type = typeValue is null ? TaskTypes.Task : TaskTypes.Normalize(typeValue); + + var labels = (parseResult.GetValue(Opt.Instance) ?? []) + .Select(label => label.Trim().ToLowerInvariant()) + .Distinct() + .ToArray(); + + if (labels.Any(string.IsNullOrEmpty)) + { + throw new ExitException("Labels must be non-empty."); + } + + var actor = TaskActor.Resolve( + parseResult.GetValue(Opt.Instance), environmentVariables); + + var result = await store.CreateTaskAsync( + new TaskCreation + { + Title = title, + Priority = priority, + Type = type, + Labels = labels, + Actor = actor + }, + cancellationToken); + + if (!console.IsHumanReadable) + { + var createdTask = await store.GetRequiredTaskAsync(result.Id, cancellationToken); + + resultHolder.SetResult( + new ObjectResult(TaskSnapshotResult.From(createdTask, result.BlockedBy))); + + return ExitCodes.Success; + } + + console.WriteLine(result.Id); + + return ExitCodes.Success; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ReadyTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ReadyTaskCommand.cs new file mode 100644 index 00000000000..537963ed9e1 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ReadyTaskCommand.cs @@ -0,0 +1,95 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class ReadyTaskCommand : Command +{ + public ReadyTaskCommand() : base("ready") + { + Description = "List tasks that are ready to work on."; + + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks ready", "agent tasks ready --assignee alice --limit 5"); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var timeProvider = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var priorityValue = parseResult.GetValue(Opt.Instance); + var assignee = parseResult.GetValue(Opt.Instance); + var labels = parseResult.GetValue(Opt.Instance); + var limit = parseResult.GetValue(Opt.Instance); + var includeDeferred = parseResult.GetValue(Opt.Instance); + + var unassigned = !string.IsNullOrEmpty(assignee) + && string.Equals(assignee, "unassigned", StringComparison.OrdinalIgnoreCase); + + var priorityRange = priorityValue is null + ? default((int Min, int Max)?) + : TaskPriorities.ParseRange(priorityValue); + + var filter = new TaskFilter + { + Statuses = [TaskStates.Open], + PriorityMin = priorityRange?.Min, + PriorityMax = priorityRange?.Max, + Unassigned = unassigned, + Assignee = unassigned ? null : assignee, + Labels = labels, + DeferredVisibleAt = includeDeferred ? null : timeProvider.GetUtcNow(), + ExcludeBlocked = true, + Limit = limit, + Ordering = TaskOrdering.ReadyPick + }; + + var readyTasks = await store.QueryTasksAsync(filter, cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ListResult(readyTasks.Select(ToSummary).ToArray())); + return ExitCodes.Success; + } + + if (readyTasks.Count == 0) + { + console.WriteLine("No ready tasks."); + return ExitCodes.Success; + } + + foreach (var task in readyTasks) + { + console.WriteLine(FormatRow(task)); + } + + console.WriteLine(); + console.WriteLine($"{readyTasks.Count} task(s)"); + + return ExitCodes.Success; + } + + private static string FormatRow(TaskItem task) + => $"{task.Id} {TaskPriorities.Format(task.Priority)} {task.Type} " + + $"{task.Status} {task.Title}"; + + private static TaskSummaryResult ToSummary(TaskItem task) + => new(task.Id, task.Priority, task.Type, task.Status, task.Title); +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/RemoveTaskDependencyCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/RemoveTaskDependencyCommand.cs new file mode 100644 index 00000000000..1329eabd2e7 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/RemoveTaskDependencyCommand.cs @@ -0,0 +1,56 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class RemoveTaskDependencyCommand : Command +{ + public RemoveTaskDependencyCommand() : base("remove") + { + Description = "Remove a dependency between two tasks."; + + Arguments.Add(Opt.Instance); + Arguments.Add(Opt.Instance); + + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks dep remove \"acme-1a2\" \"acme-9z8\""); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var environmentVariableProvider = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var id = parseResult.GetRequiredValue(Opt.Instance); + var dependsOnId = parseResult.GetRequiredValue(Opt.Instance); + var actor = TaskActor.Resolve( + parseResult.GetValue(Opt.Instance), environmentVariableProvider); + + await store.RemoveDependencyAsync(id, dependsOnId, actor, cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ObjectResult(new TaskDependencyRemovedResult(id, dependsOnId))); + return ExitCodes.Success; + } + + console.OkLine( + $"Removed dependency: '{id.EscapeMarkup()}' -> '{dependsOnId.EscapeMarkup()}'."); + + return ExitCodes.Success; + } + + public sealed record TaskDependencyRemovedResult(string Id, string DependsOnId); +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/RemoveTaskLabelCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/RemoveTaskLabelCommand.cs new file mode 100644 index 00000000000..7677af4875a --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/RemoveTaskLabelCommand.cs @@ -0,0 +1,54 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class RemoveTaskLabelCommand : Command +{ + public RemoveTaskLabelCommand() : base("remove") + { + Description = "Remove a label from a task."; + + Arguments.Add(Opt.Instance); + Arguments.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks label remove \"acme-1a2\" api"); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var environmentVariableProvider = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var id = parseResult.GetRequiredValue(Opt.Instance); + var label = parseResult.GetRequiredValue(Opt.Instance).Trim().ToLowerInvariant(); + var actor = TaskActor.Resolve( + parseResult.GetValue(Opt.Instance), environmentVariableProvider); + + await store.RemoveLabelAsync(id, label, actor, cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ObjectResult(new TaskLabelRemovedResult(id, label))); + return ExitCodes.Success; + } + + console.OkLine($"Removed label '{label.EscapeMarkup()}' from '{id.EscapeMarkup()}'."); + + return ExitCodes.Success; + } + + public sealed record TaskLabelRemovedResult(string Id, string Label); +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ReopenTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ReopenTaskCommand.cs new file mode 100644 index 00000000000..9e84099454a --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ReopenTaskCommand.cs @@ -0,0 +1,52 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class ReopenTaskCommand : Command +{ + public ReopenTaskCommand() : base("reopen") + { + Description = "Reopen a closed task."; + + Arguments.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks reopen \"app-1a2\""); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var environmentVariableProvider = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var id = parseResult.GetRequiredValue(Opt.Instance); + var reason = parseResult.GetValue(Opt.Instance) ?? ""; + var actor = TaskActor.Resolve( + parseResult.GetValue(Opt.Instance), environmentVariableProvider); + + var task = await store.ReopenTaskAsync(id, reason, actor, cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ObjectResult(TaskSnapshotResult.From(task))); + return ExitCodes.Success; + } + + console.OkLine($"Reopened task '{task.Id.EscapeMarkup()}'."); + + return ExitCodes.Success; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/SearchTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/SearchTaskCommand.cs new file mode 100644 index 00000000000..e4ce7ab3876 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/SearchTaskCommand.cs @@ -0,0 +1,81 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class SearchTaskCommand : Command +{ + public SearchTaskCommand() : base("search") + { + Description = "Search tasks by text."; + + Arguments.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks search \"parser\"", "agent tasks search \"parser\" --limit 5"); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var text = parseResult.GetRequiredValue(Opt.Instance); + var limit = parseResult.GetValue(Opt.Instance); + + var filter = new TaskFilter + { + IncludeAll = true, + ExcludeTombstones = true, + Text = text, + Limit = limit + }; + + var tasks = await store.QueryTasksAsync(filter, cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ListResult(tasks.Select(ToSummary).ToArray())); + return ExitCodes.Success; + } + + if (tasks.Count == 0) + { + console.WriteLine("No tasks found."); + return ExitCodes.Success; + } + + foreach (var task in tasks) + { + console.WriteLine(FormatRow(task)); + } + + console.WriteLine(); + console.WriteLine($"{tasks.Count} task(s)"); + + return ExitCodes.Success; + } + + private static string FormatRow(TaskItem task) + => new TaskListRow + { + Id = task.Id, + Priority = task.Priority, + Type = task.Type, + Status = task.Status, + Title = task.Title + }.Format(); + + private static TaskSummaryResult ToSummary(TaskItem task) + => new(task.Id, task.Priority, task.Type, task.Status, task.Title); +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/SetTaskConfigCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/SetTaskConfigCommand.cs new file mode 100644 index 00000000000..94ebccb5e69 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/SetTaskConfigCommand.cs @@ -0,0 +1,55 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class SetTaskConfigCommand : Command +{ + private const string PrefixKey = "prefix"; + + public SetTaskConfigCommand() : base("set") + { + Description = "Set a configuration value."; + + Arguments.Add(Opt.Instance); + Arguments.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks config set prefix \"app\""); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var key = parseResult.GetRequiredValue(Opt.Instance); + var value = parseResult.GetRequiredValue(Opt.Instance); + + if (key == PrefixKey) + { + value = TaskWorkspace.NormalizePrefix(value); + } + + await store.SetConfigAsync(key, value, cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ObjectResult(new TaskConfigEntry(key, value))); + return ExitCodes.Success; + } + + console.OkLine($"Set '{key.EscapeMarkup()}' to '{value.EscapeMarkup()}'."); + + return ExitCodes.Success; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ShowTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ShowTaskCommand.cs new file mode 100644 index 00000000000..a2c833d464a --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/ShowTaskCommand.cs @@ -0,0 +1,269 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class ShowTaskCommand : Command +{ + public ShowTaskCommand() : base("show") + { + Description = "Show a task's details."; + + Arguments.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks show \"acme-1a2\""); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var id = parseResult.GetRequiredValue(Opt.Instance); + + var task = await store.GetRequiredTaskAsync(id, cancellationToken); + var labels = await store.GetLabelsAsync(task.Id, cancellationToken); + var dependencies = await store.GetDependenciesAsync(task.Id, cancellationToken); + var blocks = await store.GetDependentsAsync(task.Id, cancellationToken); + var comments = await store.GetCommentsAsync(task.Id, cancellationToken); + var blocked = await store.ComputeBlockedAsync(cancellationToken); + + if (!console.IsHumanReadable) + { + var blockers = blocked.TryGetValue(task.Id, out var taskBlockers) + ? taskBlockers + : []; + + resultHolder.SetResult(new ObjectResult(new TaskDetailResult + { + Id = task.Id, + Title = task.Title, + Status = task.Status, + Priority = task.Priority, + Type = task.Type, + Assignee = task.Assignee, + EstimatedMinutes = task.EstimatedMinutes, + DueAt = task.DueAt, + DeferUntil = task.DeferUntil, + CreatedAt = task.CreatedAt, + CreatedBy = task.CreatedBy, + UpdatedAt = task.UpdatedAt, + ClosedAt = task.ClosedAt, + CloseReason = string.IsNullOrEmpty(task.CloseReason) ? null : task.CloseReason, + Description = task.Description, + Design = task.Design, + AcceptanceCriteria = task.AcceptanceCriteria, + Notes = task.Notes, + Labels = labels, + Blockers = blockers, + Dependencies = dependencies, + Dependents = blocks, + Comments = comments + })); + + return ExitCodes.Success; + } + + console.WriteLine($"{task.Id}: {task.Title}"); + console.WriteLine(); + + var separatorPending = false; + + WriteSection(console, BuildHeader(task, labels, blocked), ref separatorPending); + WriteSection(console, BuildTextBlock("Description:", task.Description), ref separatorPending); + WriteSection(console, BuildTextBlock("Design:", task.Design), ref separatorPending); + WriteSection( + console, + BuildTextBlock("Acceptance criteria:", task.AcceptanceCriteria), + ref separatorPending); + WriteSection(console, BuildTextBlock("Notes:", task.Notes), ref separatorPending); + WriteSection(console, BuildDependencies(dependencies, blocks), ref separatorPending); + WriteSection(console, BuildComments(comments), ref separatorPending); + + return ExitCodes.Success; + } + + private static List BuildHeader( + TaskItem task, + IReadOnlyList labels, + IReadOnlyDictionary> blocked) + { + var lines = new List + { + $"Status: {task.Status} Priority: {TaskPriorities.Format(task.Priority)} Type: {task.Type}" + }; + + if (!string.IsNullOrEmpty(task.Assignee)) + { + lines.Add($"Assignee: {task.Assignee}"); + } + + if (task.DueAt is { } dueAt) + { + lines.Add($"Due: {TaskDates.Format(dueAt)}"); + } + + if (task.DeferUntil is { } deferUntil) + { + lines.Add($"Deferred until: {TaskDates.Format(deferUntil)}"); + } + + if (task.EstimatedMinutes is { } estimatedMinutes) + { + lines.Add($"Estimate: {estimatedMinutes}m"); + } + + lines.Add($"Created: {TaskDates.Format(task.CreatedAt)} by {task.CreatedBy}"); + lines.Add($"Updated: {TaskDates.Format(task.UpdatedAt)}"); + + if (task.Status == TaskStates.Closed && task.ClosedAt is { } closedAt) + { + var closedLine = $"Closed: {TaskDates.Format(closedAt)}"; + + if (!string.IsNullOrEmpty(task.CloseReason)) + { + closedLine += $" ({task.CloseReason})"; + } + + lines.Add(closedLine); + } + + if (labels.Count > 0) + { + lines.Add($"Labels: {string.Join(", ", labels)}"); + } + + if (blocked.TryGetValue(task.Id, out var blockers) && blockers.Count > 0) + { + lines.Add($"Blocked by: {string.Join(", ", blockers)}"); + } + + return lines; + } + + private static List BuildTextBlock(string header, string text) + { + if (string.IsNullOrEmpty(text)) + { + return []; + } + + var lines = new List { header }; + + foreach (var line in text.ReplaceLineEndings("\n").Split('\n')) + { + lines.Add(" " + line); + } + + return lines; + } + + private static List BuildDependencies( + IReadOnlyList dependencies, + IReadOnlyList blocks) + { + var lines = new List(); + + if (dependencies.Count > 0) + { + lines.Add("Dependencies:"); + + foreach (var dependency in dependencies) + { + var status = dependency.Status ?? "unknown"; + var line = $" {dependency.Type} -> {dependency.DependsOnId} ({status})"; + + if (!string.IsNullOrEmpty(dependency.Title)) + { + line += $" {dependency.Title}"; + } + + lines.Add(line); + } + } + + if (blocks.Count > 0) + { + lines.Add("Blocks:"); + + foreach (var block in blocks) + { + var status = block.Status ?? "unknown"; + var line = $" {block.TaskId} ({status})"; + + if (!string.IsNullOrEmpty(block.Title)) + { + line += $" {block.Title}"; + } + + lines.Add(line); + } + } + + return lines; + } + + private static List BuildComments(IReadOnlyList comments) + { + var lines = new List(); + + if (comments.Count == 0) + { + return lines; + } + + lines.Add("Comments:"); + + for (var i = 0; i < comments.Count; i++) + { + if (i > 0) + { + lines.Add(string.Empty); + } + + var comment = comments[i]; + + lines.Add($" [{i + 1}] {comment.Author} {TaskDates.Format(comment.CreatedAt)}"); + + foreach (var line in comment.Text.ReplaceLineEndings("\n").Split('\n')) + { + lines.Add(" " + line); + } + } + + return lines; + } + + private static void WriteSection( + INitroConsole console, + IReadOnlyList lines, + ref bool separatorPending) + { + if (lines.Count == 0) + { + return; + } + + if (separatorPending) + { + console.WriteLine(); + } + + foreach (var line in lines) + { + console.WriteLine(line); + } + + separatorPending = true; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/StaleTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/StaleTaskCommand.cs new file mode 100644 index 00000000000..57779a70e74 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/StaleTaskCommand.cs @@ -0,0 +1,80 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class StaleTaskCommand : Command +{ + public StaleTaskCommand() : base("stale") + { + Description = "List open tasks that have not been updated recently."; + + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks stale", "agent tasks stale --days 14"); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var timeProvider = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var days = parseResult.GetValue(Opt.Instance) ?? 30; + var threshold = timeProvider.GetUtcNow() - TimeSpan.FromDays(days); + + var filter = new TaskFilter + { + Statuses = [TaskStates.Open, TaskStates.InProgress], + UpdatedBefore = threshold, + Ordering = TaskOrdering.UpdatedAtAscending + }; + + var tasks = await store.QueryTasksAsync(filter, cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ListResult(tasks.Select(ToSummary).ToArray())); + return ExitCodes.Success; + } + + if (tasks.Count == 0) + { + console.WriteLine("No stale tasks."); + return ExitCodes.Success; + } + + foreach (var task in tasks) + { + console.WriteLine(FormatRow(task)); + } + + console.WriteLine(); + console.WriteLine($"{tasks.Count} task(s)"); + + return ExitCodes.Success; + } + + private static string FormatRow(TaskItem task) + => new TaskListRow + { + Id = task.Id, + Priority = task.Priority, + Type = task.Type, + Status = task.Status, + Title = task.Title + }.Format(); + + private static TaskSummaryResult ToSummary(TaskItem task) + => new(task.Id, task.Priority, task.Type, task.Status, task.Title); +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/StatsTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/StatsTaskCommand.cs new file mode 100644 index 00000000000..2ab7e031680 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/StatsTaskCommand.cs @@ -0,0 +1,55 @@ +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class StatsTaskCommand : Command +{ + public StatsTaskCommand() : base("stats") + { + Description = "Show summary statistics for the task workspace."; + + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks stats"); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var stats = await store.GetStatsAsync(cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ObjectResult(stats)); + return ExitCodes.Success; + } + + var totalTasks = stats.StatusCounts.Sum(row => row.Count); + + console.WriteLine($"Tasks: {totalTasks}"); + + foreach (var row in stats.StatusCounts) + { + console.WriteLine($" {row.Value}: {row.Count}"); + } + + console.WriteLine($"Ready: {stats.ReadyCount}"); + console.WriteLine($"Blocked: {stats.BlockedTaskStatuses.Count}"); + console.WriteLine($"Labels: {stats.LabelCount}"); + console.WriteLine($"Comments: {stats.CommentCount}"); + console.WriteLine($"Events: {stats.EventCount}"); + + return ExitCodes.Success; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/StatusTaskEpicCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/StatusTaskEpicCommand.cs new file mode 100644 index 00000000000..d23c01badce --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/StatusTaskEpicCommand.cs @@ -0,0 +1,58 @@ +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class StatusTaskEpicCommand : Command +{ + public StatusTaskEpicCommand() : base("status") + { + Description = "Show epics with their child completion counts."; + + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks epic status"); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var epics = await store.GetEpicStatusesAsync(cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ListResult(epics)); + return ExitCodes.Success; + } + + if (epics.Count == 0) + { + console.WriteLine("No epics found."); + return ExitCodes.Success; + } + + foreach (var epic in epics) + { + var line = $"{epic.Id} {epic.Closed}/{epic.Total} {epic.Title}"; + + if (epic.IsEligibleForClose) + { + line += " (eligible for close)"; + } + + console.WriteLine(line); + } + + return ExitCodes.Success; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/SyncTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/SyncTaskCommand.cs new file mode 100644 index 00000000000..5c07cd45c60 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/SyncTaskCommand.cs @@ -0,0 +1,267 @@ +using System.Text; +using System.Text.Json; +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class SyncTaskCommand : Command +{ + public SyncTaskCommand() : base("sync") + { + Description = "Synchronize the task database with the committed tasks.jsonl file."; + + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + + Validators.Add(result => + { + var selected = 0; + selected += result.GetValue(Opt.Instance) ? 1 : 0; + selected += result.GetValue(Opt.Instance) ? 1 : 0; + selected += result.GetValue(Opt.Instance) ? 1 : 0; + + if (selected != 1) + { + result.AddError( + "Specify exactly one of '--flush-only', '--import-only', or '--status'."); + } + }); + + this.AddExamples( + "agent tasks sync --flush-only", + "agent tasks sync --import-only", + "agent tasks sync --status"); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var fileSystem = services.GetRequiredService(); + + if (parseResult.GetValue(Opt.Instance)) + { + return await FlushAsync(console, store, fileSystem, cancellationToken); + } + + if (parseResult.GetValue(Opt.Instance)) + { + return await ImportAsync(console, store, fileSystem, cancellationToken); + } + + return await ReportStatusAsync(console, store, fileSystem, cancellationToken); + } + + private static async Task FlushAsync( + INitroConsole console, + ITaskStore store, + IFileSystem fileSystem, + CancellationToken cancellationToken) + { + var workspaceDirectory = store.FindWorkspaceDirectory() + ?? throw new ExitException( + "No task workspace found. Run `nitro agent tasks init` first."); + + var config = await store.ListConfigAsync(cancellationToken); + var records = await store.ExportTasksAsync(cancellationToken); + + await fileSystem.WriteAllTextAsync( + TaskWorkspace.GetJsonlPath(workspaceDirectory), + SerializeJsonl(config, records), + cancellationToken); + + console.OkLine( + $"Flushed {records.Count} {(records.Count == 1 ? "task" : "tasks")} to " + + $"'{TaskWorkspace.DisplayPath}/{TaskWorkspace.JsonlFileName}'."); + + return ExitCodes.Success; + } + + private static async Task ImportAsync( + INitroConsole console, + ITaskStore store, + IFileSystem fileSystem, + CancellationToken cancellationToken) + { + var workspaceDirectory = store.FindWorkspaceDirectory() + ?? TaskWorkspace.FindDatabaseOrJsonl(fileSystem, fileSystem.GetCurrentDirectory()) + ?? throw new ExitException( + "No task workspace found. Run `nitro agent tasks init` first."); + + var jsonlPath = TaskWorkspace.GetJsonlPath(workspaceDirectory); + + if (!fileSystem.FileExists(jsonlPath)) + { + throw new ExitException( + $"No '{TaskWorkspace.JsonlFileName}' found at " + + $"'{TaskWorkspace.DisplayPath}/{TaskWorkspace.JsonlFileName}'."); + } + + var (config, records) = await ReadJsonlAsync(fileSystem, jsonlPath, cancellationToken); + + await store.EnsureWorkspaceAsync(workspaceDirectory, cancellationToken); + + foreach (var entry in config) + { + await store.SetConfigAsync(entry.Key, entry.Value, cancellationToken); + } + + var result = await store.ImportTasksAsync(records, cancellationToken); + + console.OkLine( + $"Imported {result.Applied} of {result.Total} " + + $"{(result.Total == 1 ? "task" : "tasks")} " + + $"({result.Skipped} already current)."); + + return ExitCodes.Success; + } + + private static async Task ReportStatusAsync( + INitroConsole console, + ITaskStore store, + IFileSystem fileSystem, + CancellationToken cancellationToken) + { + var workspaceDirectory = store.FindWorkspaceDirectory() + ?? TaskWorkspace.FindDatabaseOrJsonl(fileSystem, fileSystem.GetCurrentDirectory()) + ?? throw new ExitException( + "No task workspace found. Run `nitro agent tasks init` first."); + + var jsonlPath = TaskWorkspace.GetJsonlPath(workspaceDirectory); + var hasDatabase = fileSystem.FileExists(TaskWorkspace.GetDatabasePath(workspaceDirectory)); + var hasJsonl = fileSystem.FileExists(jsonlPath); + + if (!hasDatabase) + { + console.WriteLine( + "No task database. Run `nitro agent tasks sync --import-only` to create it from " + + $"'{TaskWorkspace.JsonlFileName}'."); + + return ExitCodes.Error; + } + + var config = await store.ListConfigAsync(cancellationToken); + var records = await store.ExportTasksAsync(cancellationToken); + + if (!hasJsonl) + { + console.WriteLine( + $"No '{TaskWorkspace.JsonlFileName}'. Run `nitro agent tasks sync --flush-only` to " + + $"create it from {records.Count} " + + $"{(records.Count == 1 ? "task" : "tasks")} in the database."); + + return ExitCodes.Error; + } + + var expectedContent = SerializeJsonl(config, records); + var actualContent = await fileSystem.ReadAllTextAsync(jsonlPath, cancellationToken); + + if (NormalizeLineEndings(expectedContent) == NormalizeLineEndings(actualContent)) + { + console.OkLine( + $"'{TaskWorkspace.JsonlFileName}' is in sync with the task database " + + $"({records.Count} {(records.Count == 1 ? "task" : "tasks")})."); + + return ExitCodes.Success; + } + + console.WriteLine( + $"'{TaskWorkspace.JsonlFileName}' and the task database have diverged. Run " + + "`nitro agent tasks sync --flush-only` or `nitro agent tasks sync --import-only` to reconcile."); + + return ExitCodes.Error; + } + + /// + /// Serializes one compact JSON object per line, newline-separated, so the + /// result is valid JSONL: one workspace config entry per line, ordered by + /// key, followed by one task per line. + /// + private static string SerializeJsonl( + IReadOnlyList config, + IReadOnlyList records) + { + var builder = new StringBuilder(); + + foreach (var entry in config) + { + builder.Append(JsonSerializer.Serialize(entry, TaskSyncJsonContext.Default.TaskConfigEntry)); + builder.Append('\n'); + } + + foreach (var record in records) + { + builder.Append(JsonSerializer.Serialize(record, TaskSyncJsonContext.Default.TaskSyncRecord)); + builder.Append('\n'); + } + + return builder.ToString(); + } + + /// + /// Parses tasks.jsonl, splitting its lines into workspace config entries + /// and task records. A line is a config entry when it has no "id" + /// property, which every task line carries; a config line instead + /// carries "key" and "value". + /// + private static async Task<(IReadOnlyList Config, IReadOnlyList Records)> + ReadJsonlAsync( + IFileSystem fileSystem, + string jsonlPath, + CancellationToken cancellationToken) + { + var content = await fileSystem.ReadAllTextAsync(jsonlPath, cancellationToken); + var config = new List(); + var records = new List(); + var lineNumber = 0; + + foreach (var line in content.ReplaceLineEndings("\n").Split('\n')) + { + lineNumber++; + + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + try + { + using var document = JsonDocument.Parse(line); + + if (document.RootElement.TryGetProperty("id", out _)) + { + records.Add( + JsonSerializer.Deserialize(line, TaskSyncJsonContext.Default.TaskSyncRecord) + ?? throw new ExitException( + $"'{TaskWorkspace.JsonlFileName}' line {lineNumber} is empty.")); + } + else + { + config.Add( + JsonSerializer.Deserialize(line, TaskSyncJsonContext.Default.TaskConfigEntry) + ?? throw new ExitException( + $"'{TaskWorkspace.JsonlFileName}' line {lineNumber} is empty.")); + } + } + catch (JsonException exception) + { + throw new ExitException( + $"'{TaskWorkspace.JsonlFileName}' line {lineNumber} is not valid JSON: " + + exception.Message); + } + } + + return (config, records); + } + + private static string NormalizeLineEndings(string value) => value.ReplaceLineEndings("\n"); +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TaskCommentCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TaskCommentCommand.cs new file mode 100644 index 00000000000..288a4d25a11 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TaskCommentCommand.cs @@ -0,0 +1,12 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class TaskCommentCommand : Command +{ + public TaskCommentCommand() : base("comment") + { + Description = "Add and list task comments."; + + Subcommands.Add(new AddTaskCommentCommand()); + Subcommands.Add(new ListTaskCommentCommand()); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TaskConfigCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TaskConfigCommand.cs new file mode 100644 index 00000000000..ce078ede358 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TaskConfigCommand.cs @@ -0,0 +1,13 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class TaskConfigCommand : Command +{ + public TaskConfigCommand() : base("config") + { + Description = "View and manage task workspace configuration."; + + Subcommands.Add(new GetTaskConfigCommand()); + Subcommands.Add(new ListTaskConfigCommand()); + Subcommands.Add(new SetTaskConfigCommand()); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TaskDependencyCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TaskDependencyCommand.cs new file mode 100644 index 00000000000..af77593490c --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TaskDependencyCommand.cs @@ -0,0 +1,15 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class TaskDependencyCommand : Command +{ + public TaskDependencyCommand() : base("dep") + { + Description = "Manage task dependencies."; + + Subcommands.Add(new AddTaskDependencyCommand()); + Subcommands.Add(new RemoveTaskDependencyCommand()); + Subcommands.Add(new ListTaskDependencyCommand()); + Subcommands.Add(new TreeTaskDependencyCommand()); + Subcommands.Add(new CyclesTaskDependencyCommand()); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TaskEpicCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TaskEpicCommand.cs new file mode 100644 index 00000000000..ee8fb119e0d --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TaskEpicCommand.cs @@ -0,0 +1,12 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class TaskEpicCommand : Command +{ + public TaskEpicCommand() : base("epic") + { + Description = "View and close epics based on child task completion."; + + Subcommands.Add(new CloseEligibleTaskEpicCommand()); + Subcommands.Add(new StatusTaskEpicCommand()); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TaskLabelCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TaskLabelCommand.cs new file mode 100644 index 00000000000..c1970309fd9 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TaskLabelCommand.cs @@ -0,0 +1,13 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class TaskLabelCommand : Command +{ + public TaskLabelCommand() : base("label") + { + Description = "Add, remove, and list task labels."; + + Subcommands.Add(new AddTaskLabelCommand()); + Subcommands.Add(new RemoveTaskLabelCommand()); + Subcommands.Add(new ListTaskLabelCommand()); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TaskListRow.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TaskListRow.cs new file mode 100644 index 00000000000..93263112aba --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TaskListRow.cs @@ -0,0 +1,18 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +/// +/// The subset of a task's columns needed to print one list row. +/// +internal sealed class TaskListRow +{ + public required string Id { get; init; } + public required int Priority { get; init; } + public required string Type { get; init; } + public required string Status { get; init; } + public required string Title { get; init; } + + public string Format() + => $"{Id} {TaskPriorities.Format(Priority)} {Type} {Status} {Title}"; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TasksCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TasksCommand.cs new file mode 100644 index 00000000000..89a9ce437fb --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TasksCommand.cs @@ -0,0 +1,37 @@ +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class TasksCommand : Command +{ + public TasksCommand() : base("tasks") + { + Description = "Create and manage tasks for coding agents."; + + Subcommands.Add(new BlockedTaskCommand()); + Subcommands.Add(new BoardTaskCommand()); + Subcommands.Add(new CloseTaskCommand()); + Subcommands.Add(new TaskCommentCommand()); + Subcommands.Add(new TaskConfigCommand()); + Subcommands.Add(new CountTaskCommand()); + Subcommands.Add(new CreateTaskCommand()); + Subcommands.Add(new DeferTaskCommand()); + Subcommands.Add(new DeleteTaskCommand()); + Subcommands.Add(new TaskDependencyCommand()); + Subcommands.Add(new DoctorTaskCommand()); + Subcommands.Add(new TaskEpicCommand()); + Subcommands.Add(new InitTaskCommand()); + Subcommands.Add(new TaskLabelCommand()); + Subcommands.Add(new LintTaskCommand()); + Subcommands.Add(new ListTaskCommand()); + Subcommands.Add(new QuickTaskCommand()); + Subcommands.Add(new ReadyTaskCommand()); + Subcommands.Add(new ReopenTaskCommand()); + Subcommands.Add(new SearchTaskCommand()); + Subcommands.Add(new ShowTaskCommand()); + Subcommands.Add(new StaleTaskCommand()); + Subcommands.Add(new StatsTaskCommand()); + Subcommands.Add(new SyncTaskCommand()); + Subcommands.Add(new UndeferTaskCommand()); + Subcommands.Add(new UpdateTaskCommand()); + Subcommands.Add(new WhereTaskCommand()); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TreeTaskDependencyCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TreeTaskDependencyCommand.cs new file mode 100644 index 00000000000..e526f265f19 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/TreeTaskDependencyCommand.cs @@ -0,0 +1,146 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class TreeTaskDependencyCommand : Command +{ + private const int MaxDepth = 10; + + public TreeTaskDependencyCommand() : base("tree") + { + Description = "Show a task's outgoing dependency tree."; + + Arguments.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks dep tree \"acme-1a2\""); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var id = parseResult.GetRequiredValue(Opt.Instance); + + var root = await store.GetRequiredTaskAsync(id, cancellationToken); + + var taskList = await store.QueryTasksAsync(new TaskFilter { IncludeAll = true }, cancellationToken); + var tasks = taskList.ToDictionary(t => t.Id); + + var edges = await store.GetDependencyEdgesAsync(cancellationToken); + + var childrenByParent = edges + .GroupBy(e => e.TaskId) + .ToDictionary( + g => g.Key, + g => g.OrderBy(e => e.Type, StringComparer.Ordinal) + .ThenBy(e => e.DependsOnId, StringComparer.Ordinal) + .ToList()); + + var printed = new HashSet { root.Id }; + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ObjectResult(new TaskDependencyTreeNode + { + Id = root.Id, + Type = null, + Status = root.Status, + Title = root.Title, + Repeated = false, + Children = BuildChildren(root.Id, tasks, childrenByParent, printed, depth: 1) + })); + + return ExitCodes.Success; + } + + console.WriteLine($"{root.Id} ({root.Status}) {root.Title}"); + + WriteChildren(console, root.Id, tasks, childrenByParent, printed, depth: 1); + + return ExitCodes.Success; + } + + private static IReadOnlyList BuildChildren( + string parentId, + IReadOnlyDictionary tasks, + IReadOnlyDictionary> childrenByParent, + HashSet printed, + int depth) + { + if (depth > MaxDepth || !childrenByParent.TryGetValue(parentId, out var children)) + { + return []; + } + + var nodes = new List(); + + foreach (var edge in children) + { + var alreadyPrinted = !printed.Add(edge.DependsOnId); + var hasNode = tasks.TryGetValue(edge.DependsOnId, out var node); + + nodes.Add(new TaskDependencyTreeNode + { + Id = edge.DependsOnId, + Type = edge.Type, + Status = hasNode ? node!.Status : null, + Title = hasNode ? node!.Title : null, + Repeated = alreadyPrinted, + Children = alreadyPrinted + ? [] + : BuildChildren(edge.DependsOnId, tasks, childrenByParent, printed, depth + 1) + }); + } + + return nodes; + } + + private static void WriteChildren( + INitroConsole console, + string parentId, + IReadOnlyDictionary tasks, + IReadOnlyDictionary> childrenByParent, + HashSet printed, + int depth) + { + if (depth > MaxDepth || !childrenByParent.TryGetValue(parentId, out var children)) + { + return; + } + + var indent = new string(' ', depth * 2); + + foreach (var edge in children) + { + var alreadyPrinted = !printed.Add(edge.DependsOnId); + var hasNode = tasks.TryGetValue(edge.DependsOnId, out var node); + var status = hasNode ? node!.Status : "unknown"; + var title = hasNode ? node!.Title : ""; + var line = $"{indent}{edge.DependsOnId} ({edge.Type}, {status}) {title}"; + + if (alreadyPrinted) + { + line += " *"; + } + + console.WriteLine(line); + + if (!alreadyPrinted) + { + WriteChildren(console, edge.DependsOnId, tasks, childrenByParent, printed, depth + 1); + } + } + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/UndeferTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/UndeferTaskCommand.cs new file mode 100644 index 00000000000..4d70cd738b0 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/UndeferTaskCommand.cs @@ -0,0 +1,50 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class UndeferTaskCommand : Command +{ + public UndeferTaskCommand() : base("undefer") + { + Description = "Make a deferred task ready again."; + + Arguments.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks undefer \"acme-1a2\""); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var environmentVariableProvider = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var id = parseResult.GetRequiredValue(Opt.Instance); + var actor = TaskActor.Resolve( + parseResult.GetValue(Opt.Instance), environmentVariableProvider); + + var task = await store.UndeferTaskAsync(id, actor, cancellationToken); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ObjectResult(TaskSnapshotResult.From(task))); + return ExitCodes.Success; + } + + console.OkLine($"Undeferred task '{task.Id.EscapeMarkup()}'."); + + return ExitCodes.Success; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/UpdateTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/UpdateTaskCommand.cs new file mode 100644 index 00000000000..e3b2cb19877 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/UpdateTaskCommand.cs @@ -0,0 +1,241 @@ +using ChilliCream.Nitro.CommandLine.Commands.Tasks.Options; +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class UpdateTaskCommand : Command +{ + public UpdateTaskCommand() : base("update") + { + Description = "Update one or more tasks' fields."; + + Arguments.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + Options.Add(Opt.Instance); + + this.AddExamples( + "agent tasks update \"app-1a2\" --status in_progress", + "agent tasks update \"app-1a2\" --priority p1 --assignee alice", + "agent tasks update \"app-1a2\" \"app-9z8\" --priority p1", + "agent tasks update \"app-1a2\" --claim", + "agent tasks update \"app-1a2\" --add-label api --remove-label triage", + "agent tasks update \"app-1a2\" --parent \"app-9z8\""); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static async Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var environmentVariables = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var ids = parseResult.GetRequiredValue(Opt.Instance) + .Distinct() + .ToArray(); + var actor = TaskActor.Resolve( + parseResult.GetValue(Opt.Instance), environmentVariables); + + var titleGiven = parseResult.GetResult(Opt.Instance) is { Implicit: false }; + var descriptionGiven = + parseResult.GetResult(Opt.Instance) is { Implicit: false }; + var statusGiven = parseResult.GetResult(Opt.Instance) is { Implicit: false }; + var priorityGiven = parseResult.GetResult(Opt.Instance) is { Implicit: false }; + var typeGiven = parseResult.GetResult(Opt.Instance) is { Implicit: false }; + var assigneeGiven = parseResult.GetResult(Opt.Instance) is { Implicit: false }; + var notesGiven = parseResult.GetResult(Opt.Instance) is { Implicit: false }; + var designGiven = parseResult.GetResult(Opt.Instance) is { Implicit: false }; + var acceptanceCriteriaGiven = + parseResult.GetResult(Opt.Instance) is { Implicit: false }; + var dueGiven = parseResult.GetResult(Opt.Instance) is { Implicit: false }; + var deferUntilGiven = + parseResult.GetResult(Opt.Instance) is { Implicit: false }; + var estimateGiven = parseResult.GetResult(Opt.Instance) is { Implicit: false }; + var addLabelsGiven = parseResult.GetResult(Opt.Instance) is { Implicit: false }; + var removeLabelsGiven = + parseResult.GetResult(Opt.Instance) is { Implicit: false }; + var parentGiven = parseResult.GetResult(Opt.Instance) is { Implicit: false }; + var claimGiven = parseResult.GetResult(Opt.Instance) is { Implicit: false }; + + if (!titleGiven && !descriptionGiven && !statusGiven && !priorityGiven && !typeGiven + && !assigneeGiven && !notesGiven && !designGiven && !acceptanceCriteriaGiven + && !dueGiven && !deferUntilGiven && !estimateGiven && !addLabelsGiven + && !removeLabelsGiven && !parentGiven && !claimGiven) + { + throw new ExitException("Nothing to update. Pass at least one option."); + } + + // --claim is shorthand for "--status in_progress --assignee "; + // an explicit --status or --assignee on the same call wins. + var explicitStatusGiven = statusGiven; + var explicitAssigneeGiven = assigneeGiven; + + if (claimGiven) + { + statusGiven = true; + assigneeGiven = true; + } + + string? title = null; + + if (titleGiven) + { + title = parseResult.GetValue(Opt.Instance) ?? ""; + + if (title.Trim().Length is 0 || title.Length > 500) + { + throw new ExitException("The title must be 1-500 characters."); + } + } + + DateTimeOffset? due = null; + + if (dueGiven) + { + due = TaskDates.Parse( + parseResult.GetValue(Opt.Instance) ?? "", + Opt.Instance.Name); + } + + DateTimeOffset? deferUntil = null; + + if (deferUntilGiven) + { + deferUntil = TaskDates.Parse( + parseResult.GetValue(Opt.Instance) ?? "", + Opt.Instance.Name); + } + + int? priority = null; + + if (priorityGiven) + { + priority = TaskPriorities.Parse(parseResult.GetValue(Opt.Instance) ?? ""); + } + + var addLabels = parseResult.GetValue(Opt.Instance) ?? []; + var removeLabels = parseResult.GetValue(Opt.Instance) ?? []; + var parentValue = parseResult.GetValue(Opt.Instance); + + var coreFieldGiven = titleGiven || descriptionGiven || statusGiven || priorityGiven + || typeGiven || assigneeGiven || notesGiven || designGiven || acceptanceCriteriaGiven + || dueGiven || deferUntilGiven || estimateGiven; + + // Every id is validated up front so a bad id fails before any write, + // including the --add-label/--remove-label/--parent writes below, + // which go through the existing per-id store methods rather than the + // bulk-update transaction. + foreach (var id in ids) + { + await store.GetRequiredTaskAsync(id, cancellationToken); + } + + if (coreFieldGiven) + { + await store.UpdateTasksAsync( + ids, + new TaskUpdate + { + Actor = actor, + Title = title, + TitleGiven = titleGiven, + Description = parseResult.GetValue(Opt.Instance), + DescriptionGiven = descriptionGiven, + Status = statusGiven + ? (explicitStatusGiven + ? TaskStates.Normalize(parseResult.GetValue(Opt.Instance) ?? "") + : TaskStates.InProgress) + : null, + StatusGiven = statusGiven, + Priority = priority, + PriorityGiven = priorityGiven, + Type = typeGiven + ? TaskTypes.Normalize(parseResult.GetValue(Opt.Instance) ?? "") + : null, + TypeGiven = typeGiven, + Assignee = assigneeGiven + ? (explicitAssigneeGiven + ? parseResult.GetValue(Opt.Instance) + : actor) + : null, + AssigneeGiven = assigneeGiven, + Notes = parseResult.GetValue(Opt.Instance), + NotesGiven = notesGiven, + Design = parseResult.GetValue(Opt.Instance), + DesignGiven = designGiven, + AcceptanceCriteria = parseResult.GetValue(Opt.Instance), + AcceptanceCriteriaGiven = acceptanceCriteriaGiven, + DueAt = due, + DueAtGiven = dueGiven, + DeferUntil = deferUntil, + DeferUntilGiven = deferUntilGiven, + EstimatedMinutes = parseResult.GetValue(Opt.Instance), + EstimatedMinutesGiven = estimateGiven + }, + cancellationToken); + } + + foreach (var id in ids) + { + if (parentGiven) + { + await store.SetParentAsync(id, parentValue, actor, cancellationToken); + } + + if (addLabelsGiven) + { + await store.AddLabelAsync(id, addLabels, actor, cancellationToken); + } + + foreach (var label in removeLabels) + { + await store.RemoveLabelAsync(id, label, actor, cancellationToken); + } + } + + var tasks = new List(ids.Length); + + foreach (var id in ids) + { + tasks.Add(await store.GetRequiredTaskAsync(id, cancellationToken)); + } + + if (!console.IsHumanReadable) + { + resultHolder.SetResult( + new ListResult(tasks.Select(task => TaskSnapshotResult.From(task)).ToArray())); + + return ExitCodes.Success; + } + + foreach (var task in tasks) + { + console.OkLine($"Updated task '{task.Id.EscapeMarkup()}'."); + } + + return ExitCodes.Success; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/WhereTaskCommand.cs b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/WhereTaskCommand.cs new file mode 100644 index 00000000000..529a27ff068 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Commands/Tasks/WhereTaskCommand.cs @@ -0,0 +1,45 @@ +using ChilliCream.Nitro.CommandLine.Helpers; +using ChilliCream.Nitro.CommandLine.Results; +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Commands.Tasks; + +internal sealed class WhereTaskCommand : Command +{ + public WhereTaskCommand() : base("where") + { + Description = "Print the absolute path of the current task workspace."; + + Options.Add(Opt.Instance); + + this.AddExamples("agent tasks where"); + + this.SetActionWithExceptionHandling(ExecuteAsync); + } + + private static Task ExecuteAsync( + ICommandServices services, + ParseResult parseResult, + CancellationToken cancellationToken) + { + var console = services.GetRequiredService(); + var store = services.GetRequiredService(); + var resultHolder = services.GetRequiredService(); + + var workspaceDirectory = store.FindWorkspaceDirectory() + ?? throw new ExitException( + "No task workspace found. Run `nitro agent tasks init` first."); + + if (!console.IsHumanReadable) + { + resultHolder.SetResult(new ObjectResult(new TaskWorkspaceLocationResult(workspaceDirectory))); + return Task.FromResult(ExitCodes.Success); + } + + console.WriteLine(workspaceDirectory); + + return Task.FromResult(ExitCodes.Success); + } + + public sealed record TaskWorkspaceLocationResult(string Path); +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Extensions/ServiceCollectionExtensions.cs b/src/Nitro/CommandLine/src/CommandLine/Extensions/ServiceCollectionExtensions.cs index 290f34e0dcb..9fef3f28e80 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Extensions/ServiceCollectionExtensions.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Extensions/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using ChilliCream.Nitro.CommandLine.Services; using ChilliCream.Nitro.CommandLine.Services.Configuration; using ChilliCream.Nitro.CommandLine.Services.Sessions; +using ChilliCream.Nitro.CommandLine.Services.Tasks; using Microsoft.Extensions.DependencyInjection.Extensions; namespace ChilliCream.Nitro.CommandLine; @@ -26,6 +27,9 @@ public static IServiceCollection AddNitroServices(this IServiceCollection servic services.TryAddSingleton(); + services.TryAddSingleton(TimeProvider.System); + services.TryAddSingleton(); + return services; } } diff --git a/src/Nitro/CommandLine/src/CommandLine/Nitro.CommandLine.csproj b/src/Nitro/CommandLine/src/CommandLine/Nitro.CommandLine.csproj index 448c315a5a6..3f7cdf47887 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Nitro.CommandLine.csproj +++ b/src/Nitro/CommandLine/src/CommandLine/Nitro.CommandLine.csproj @@ -9,7 +9,14 @@ ChilliCream.Nitro.CommandLine nitro ChilliCream.Nitro.CommandLine - $(NoWarn);CS0618 + + $(NoWarn);CS0618;IL3053;IL2104 + + $(InterceptorsNamespaces);Dapper.AOT + $(InterceptorsPreviewNamespaces);Dapper.AOT @@ -42,8 +49,12 @@ + + + + diff --git a/src/Nitro/CommandLine/src/CommandLine/NitroRootCommand.cs b/src/Nitro/CommandLine/src/CommandLine/NitroRootCommand.cs index 6b7822fdcdd..52a01bbb2e0 100644 --- a/src/Nitro/CommandLine/src/CommandLine/NitroRootCommand.cs +++ b/src/Nitro/CommandLine/src/CommandLine/NitroRootCommand.cs @@ -1,3 +1,4 @@ +using ChilliCream.Nitro.CommandLine.Commands.Agent; using ChilliCream.Nitro.CommandLine.Commands.ApiKeys; using ChilliCream.Nitro.CommandLine.Commands.Apis; using ChilliCream.Nitro.CommandLine.Commands.Clients; @@ -35,6 +36,7 @@ public NitroRootCommand() { Description = "Nitro CLI"; + Subcommands.Add(new AgentCommand()); Subcommands.Add(new ApiKeyCommand()); Subcommands.Add(new ApiCommand()); Subcommands.Add(new ClientCommand()); diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Results/JsonSourceGenerationContext.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Results/JsonSourceGenerationContext.cs index 5108dbc68f0..a60c920ca8d 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Results/JsonSourceGenerationContext.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Results/JsonSourceGenerationContext.cs @@ -12,7 +12,9 @@ using ChilliCream.Nitro.CommandLine.Commands.PersonalAccessTokens; using ChilliCream.Nitro.CommandLine.Commands.PersonalAccessTokens.Components; using ChilliCream.Nitro.CommandLine.Commands.Stages.Components; +using ChilliCream.Nitro.CommandLine.Commands.Tasks; using ChilliCream.Nitro.CommandLine.Commands.Workspaces.Components; +using ChilliCream.Nitro.CommandLine.Services.Tasks; namespace ChilliCream.Nitro.CommandLine.Results; @@ -42,4 +44,30 @@ namespace ChilliCream.Nitro.CommandLine.Results; [JsonSerializable(typeof(PaginatedListResult))] [JsonSerializable(typeof(McpFeatureCollectionDetailPrompt.McpFeatureCollectionDetailPromptResult))] [JsonSerializable(typeof(PaginatedListResult))] +[JsonSerializable(typeof(ListResult))] +[JsonSerializable(typeof(ListResult))] +[JsonSerializable(typeof(TaskDetailResult))] +[JsonSerializable(typeof(CountTaskCommand.TaskTotalCountResult))] +[JsonSerializable(typeof(ListResult))] +[JsonSerializable(typeof(TaskStats))] +[JsonSerializable(typeof(TaskDependenciesResult))] +[JsonSerializable(typeof(TaskDependencyTreeNode))] +[JsonSerializable(typeof(ListResult))] +[JsonSerializable(typeof(ListResult))] +[JsonSerializable(typeof(ListResult))] +[JsonSerializable(typeof(GetTaskConfigCommand.TaskConfigValueResult))] +[JsonSerializable(typeof(ListResult))] +[JsonSerializable(typeof(WhereTaskCommand.TaskWorkspaceLocationResult))] +[JsonSerializable(typeof(ListResult))] +[JsonSerializable(typeof(TaskSnapshotResult))] +[JsonSerializable(typeof(ListResult))] +[JsonSerializable(typeof(AddTaskDependencyCommand.TaskDependencyAddedResult))] +[JsonSerializable(typeof(RemoveTaskDependencyCommand.TaskDependencyRemovedResult))] +[JsonSerializable(typeof(AddTaskLabelCommand.TaskLabelAddResult))] +[JsonSerializable(typeof(RemoveTaskLabelCommand.TaskLabelRemovedResult))] +[JsonSerializable(typeof(TaskComment))] +[JsonSerializable(typeof(TaskConfigEntry))] +[JsonSerializable(typeof(InitTaskCommand.TaskWorkspaceInitResult))] +[JsonSerializable(typeof(DoctorTaskCommand.TaskDoctorResult))] +[JsonSerializable(typeof(ListResult))] internal partial class JsonSourceGenerationContext : JsonSerializerContext; diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Results/Result.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Results/Result.cs index f7fcec475fc..33e76ade33f 100644 --- a/src/Nitro/CommandLine/src/CommandLine/Services/Results/Result.cs +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Results/Result.cs @@ -5,3 +5,5 @@ internal abstract record Result; internal sealed record ObjectResult(object Value) : Result; internal sealed record PaginatedListResult(TItem[] Values, string? Cursor) : Result; + +internal sealed record ListResult(IReadOnlyList Items) : Result; diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/ITaskStore.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/ITaskStore.cs new file mode 100644 index 00000000000..943520be481 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/ITaskStore.cs @@ -0,0 +1,421 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// Backend-agnostic task store used by every task command. No member exposes +/// ADO.NET or SQLite types, so the backend can change without touching a +/// command and the interface can be mocked for the TUI. +/// +internal interface ITaskStore +{ + /// + /// Returns the nearest workspace directory at or above the current + /// directory, or null when no workspace exists. + /// + string? FindWorkspaceDirectory(); + + /// + /// Returns the tasks matching the given filter, sorted per + /// . + /// + Task> QueryTasksAsync( + TaskFilter filter, + CancellationToken cancellationToken); + + /// + /// Returns the task with the given ID, or null. Tombstones are returned; + /// callers decide whether they count. + /// + Task GetTaskAsync( + string id, + CancellationToken cancellationToken); + + /// + /// Returns the task with the given ID or throws + /// when it does not exist or is a tombstone. + /// + Task GetRequiredTaskAsync( + string id, + CancellationToken cancellationToken); + + /// + /// Returns a task's labels, ordered by label. + /// + Task> GetLabelsAsync( + string taskId, + CancellationToken cancellationToken); + + /// + /// Returns every label in use on a non-tombstone task, with how many + /// tasks carry it, ordered by label. + /// + Task> GetLabelCountsAsync( + CancellationToken cancellationToken); + + /// + /// Returns a task's comments, ordered by created_at then id. + /// + Task> GetCommentsAsync( + string taskId, + CancellationToken cancellationToken); + + /// + /// Returns a task's outgoing dependencies, ordered by created_at then + /// depends_on_id. + /// + Task> GetDependenciesAsync( + string taskId, + CancellationToken cancellationToken); + + /// + /// Returns the tasks that depend on a task, ordered by created_at then + /// task_id. + /// + Task> GetDependentsAsync( + string taskId, + CancellationToken cancellationToken); + + /// + /// Returns every dependency edge in the workspace. + /// + Task> GetDependencyEdgesAsync( + CancellationToken cancellationToken); + + /// + /// Computes the set of blocked tasks from the dependency graph. Maps a + /// blocked task ID to its blocker descriptions ("id:reason"). + /// + Task>> ComputeBlockedAsync( + CancellationToken cancellationToken); + + /// + /// Returns every non-tombstone epic with its direct, non-tombstone child + /// completion counts, ordered by id. + /// + Task> GetEpicStatusesAsync( + CancellationToken cancellationToken); + + /// + /// Returns the number of non-tombstone tasks. + /// + Task CountTasksAsync( + CancellationToken cancellationToken); + + /// + /// Returns non-tombstone task counts grouped by the given dimension. + /// + Task> CountTasksByAsync( + TaskCountDimension dimension, + CancellationToken cancellationToken); + + /// + /// Returns summary statistics for the workspace. + /// + Task GetStatsAsync( + CancellationToken cancellationToken); + + /// + /// Returns a configuration value, or null when the key is not set. + /// + Task GetConfigAsync( + string key, + CancellationToken cancellationToken); + + /// + /// Sets a configuration value, overwriting any existing value for the + /// key. + /// + Task SetConfigAsync( + string key, + string value, + CancellationToken cancellationToken); + + /// + /// Returns every configuration key-value pair, ordered by key. + /// + Task> ListConfigAsync( + CancellationToken cancellationToken); + + /// + /// Returns the workspace's task ID prefix. + /// + Task GetPrefixAsync( + CancellationToken cancellationToken); + + /// + /// Creates a workspace database in the given directory, applies the + /// schema, and sets the task ID prefix, atomically. + /// + Task InitializeWorkspaceAsync( + string workspaceDirectory, + string prefix, + CancellationToken cancellationToken); + + /// + /// Creates a task, its labels, and its dependencies, and records the + /// creation event. + /// + Task CreateTaskAsync( + TaskCreation creation, + CancellationToken cancellationToken); + + /// + /// Applies the given field changes to a task and records the + /// corresponding events. Throws when the task + /// does not exist, is a tombstone, or a status guard is violated. + /// + Task UpdateTaskAsync( + string id, + TaskUpdate update, + CancellationToken cancellationToken); + + /// + /// Applies the given field changes to every task and records the + /// corresponding events for each. Throws when + /// any task does not exist, is a tombstone, or a status guard is + /// violated. Implementations should validate every task before writing + /// any, mirroring 's all-or-nothing + /// behavior; the default implementation here instead applies the update + /// one task at a time via . + /// + async Task> UpdateTasksAsync( + IReadOnlyList ids, + TaskUpdate update, + CancellationToken cancellationToken) + { + var results = new List(ids.Count); + + foreach (var id in ids) + { + await UpdateTaskAsync(id, update, cancellationToken); + results.Add(await GetRequiredTaskAsync(id, cancellationToken)); + } + + return results; + } + + /// + /// Closes every given task and records a closed event for each. All + /// tasks are validated before any is written: either every task closes + /// or none does. Throws when any task does + /// not exist, is a tombstone, or is already closed. + /// + Task> CloseTaskAsync( + IReadOnlyList ids, + string reason, + string actor, + CancellationToken cancellationToken); + + /// + /// Reopens a closed task and records a reopened event. Throws + /// when the task does not exist or is not + /// closed. + /// + Task ReopenTaskAsync( + string id, + string reason, + string actor, + CancellationToken cancellationToken); + + /// + /// Defers a task until the given instant and records a deferred event. + /// Throws when the task does not exist or is + /// not open or in-progress. + /// + Task DeferTaskAsync( + string id, + DateTimeOffset until, + string actor, + CancellationToken cancellationToken); + + /// + /// Makes a deferred task open again and records an undeferred event. + /// Throws when the task does not exist or is + /// not deferred. + /// + Task UndeferTaskAsync( + string id, + string actor, + CancellationToken cancellationToken); + + /// + /// Tombstones a task and records a deleted event. Throws + /// when the task does not exist. + /// + Task DeleteTaskAsync( + string id, + string reason, + string actor, + CancellationToken cancellationToken); + + /// + /// Closes every epic whose non-tombstone children all closed, and records + /// a closed event for each. + /// + Task> CloseEligibleEpicsAsync( + string actor, + CancellationToken cancellationToken); + + /// + /// Adds a comment to a task, bumps its updated_at, and records a + /// commented event. Throws when the task does + /// not exist or the text is empty. + /// + Task AddCommentAsync( + string id, + string text, + string actor, + CancellationToken cancellationToken); + + /// + /// Adds one or more labels to a task, bumps its updated_at when any label + /// is new, and records a label-added event per newly added label. Throws + /// when the task does not exist or a label is + /// empty. + /// + Task> AddLabelAsync( + string id, + IReadOnlyList labels, + string actor, + CancellationToken cancellationToken); + + /// + /// Removes a label from a task, bumps its updated_at, and records a + /// label-removed event. Throws when the task + /// does not exist or does not carry the label. + /// + Task RemoveLabelAsync( + string id, + string label, + string actor, + CancellationToken cancellationToken); + + /// + /// Adds a dependency between two tasks, bumps the dependent's updated_at, + /// and records a dependency-added event. Throws + /// when either task does not exist, they are + /// the same task, the dependency already exists, or (for a blocking + /// dependency type) adding it would close a cycle; a rejected write is + /// rolled back before it commits. + /// + Task AddDependencyAsync( + string id, + string dependsOnId, + string type, + string actor, + CancellationToken cancellationToken); + + /// + /// Removes a dependency between two tasks, bumps the dependent's + /// updated_at, and records a dependency-removed event. Throws + /// when the dependency does not exist. + /// + Task RemoveDependencyAsync( + string id, + string dependsOnId, + string actor, + CancellationToken cancellationToken); + + /// + /// Replaces a task's parent-child edge: removes any existing parent-child + /// edge from the task and, when is not null + /// or empty, adds a new one to that parent, bumping the task's + /// updated_at and recording a dependency-removed event per edge removed + /// and a dependency-added event for the new edge. Does nothing when the + /// task's parent is already . Throws + /// when the task or the new parent does not + /// exist, the new parent is the task itself, or the new edge would close + /// a blocking-dependency cycle; a rejected write is rolled back before it + /// commits. The default implementation here composes + /// and , + /// non-atomically; implementations should prefer a single-transaction + /// override. + /// + async Task SetParentAsync( + string id, + string? parentId, + string actor, + CancellationToken cancellationToken) + { + var normalizedParentId = string.IsNullOrEmpty(parentId) ? null : parentId; + + if (normalizedParentId == id) + { + throw new ExitException("A task cannot be its own parent."); + } + + await GetRequiredTaskAsync(id, cancellationToken); + + var dependencies = await GetDependenciesAsync(id, cancellationToken); + var existingParentIds = dependencies + .Where(dependency => dependency.Type == TaskDependencyTypes.ParentChild) + .Select(dependency => dependency.DependsOnId) + .ToList(); + + if (existingParentIds.Count == 1 && existingParentIds[0] == normalizedParentId) + { + return new TaskDependencyAddResult { Cycle = null }; + } + + foreach (var existingParentId in existingParentIds) + { + await RemoveDependencyAsync(id, existingParentId, actor, cancellationToken); + } + + if (normalizedParentId is not null) + { + return await AddDependencyAsync( + id, normalizedParentId, TaskDependencyTypes.ParentChild, actor, cancellationToken); + } + + return new TaskDependencyAddResult { Cycle = null }; + } + + /// + /// Returns every task, including tombstones, with its labels, outgoing + /// dependencies, and comments embedded, ordered by id. + /// + Task> ExportTasksAsync( + CancellationToken cancellationToken); + + /// + /// Upserts each record by id: a record whose updated_at is at or after + /// the stored task's updated_at, or whose task does not exist yet, + /// replaces the stored task together with its labels, outgoing + /// dependencies, and comments. A record whose stored task is newer is + /// left untouched. Tombstoned records are stored as tombstones. + /// + Task ImportTasksAsync( + IReadOnlyList records, + CancellationToken cancellationToken); + + /// + /// Creates the workspace database and schema in the given directory when + /// it does not already exist. Does nothing when the database already + /// exists. Unlike , this does not + /// set a task ID prefix. + /// + Task EnsureWorkspaceAsync( + string workspaceDirectory, + CancellationToken cancellationToken); + + /// + /// Runs the workspace integrity checks used by task doctor: an + /// SQLite quick_check, dependency/label/comment rows referencing a + /// missing task, and parent-child edges whose parent is a tombstone. The + /// default implementation here reports no problems, appropriate for a + /// store that cannot become inconsistent through its own API; + /// implementations backed by a mutable external store should override + /// it. + /// + Task CheckIntegrityAsync( + CancellationToken cancellationToken) + => Task.FromResult(new TaskIntegrityReport + { + QuickCheckOk = true, + QuickCheckMessage = "ok", + OrphanDependencies = [], + OrphanLabels = [], + OrphanComments = [], + TombstonedParentEdges = [] + }); +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskActor.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskActor.cs new file mode 100644 index 00000000000..21e07de5922 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskActor.cs @@ -0,0 +1,21 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// Resolves the acting identity recorded on task mutations. +/// +internal static class TaskActor +{ + public const string EnvironmentVariableName = "TASK_ACTOR"; + + /// + /// Resolves the actor from the given option value, the NITRO_TASK_ACTOR + /// environment variable, or the OS user name, in that order. + /// + public static string Resolve( + string? optionValue, + IEnvironmentVariableProvider environmentVariables) + => optionValue + ?? environmentVariables.GetEnvironmentVariable( + "NITRO_" + EnvironmentVariableName) + ?? Environment.UserName; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskBlockedResult.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskBlockedResult.cs new file mode 100644 index 00000000000..80694557fbd --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskBlockedResult.cs @@ -0,0 +1,13 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// A blocked task with the IDs and statuses of the dependencies blocking it, +/// as returned by the structured (JSON) output of task blocked. +/// +internal sealed record TaskBlockedResult( + string Id, + int Priority, + string Type, + string Status, + string Title, + IReadOnlyList Blockers); diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskComment.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskComment.cs new file mode 100644 index 00000000000..511f7a60a72 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskComment.cs @@ -0,0 +1,20 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// A timestamped comment on a task. +/// +internal sealed class TaskComment +{ + /// + /// The column list matching this type's properties, for use in SELECT + /// statements against the comments table. + /// + public const string Columns = + "id AS Id, task_id AS TaskId, author AS Author, text AS Text, created_at AS CreatedAt"; + + public long Id { get; init; } + public required string TaskId { get; init; } + public required string Author { get; init; } + public required string Text { get; init; } + public required DateTimeOffset CreatedAt { get; init; } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskCommentRow.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskCommentRow.cs new file mode 100644 index 00000000000..450701ce134 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskCommentRow.cs @@ -0,0 +1,25 @@ +using System.Globalization; + +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// An all-primitives projection of a comment row; the intercepted read path +/// cannot convert the TEXT-stored timestamp column to DateTimeOffset. +/// +internal sealed class TaskCommentRow +{ + public long Id { get; init; } + public required string TaskId { get; init; } + public required string Author { get; init; } + public required string Text { get; init; } + public required string CreatedAt { get; init; } + + public TaskComment ToTaskComment() => new() + { + Id = Id, + TaskId = TaskId, + Author = Author, + Text = Text, + CreatedAt = DateTimeOffset.Parse(CreatedAt, CultureInfo.InvariantCulture) + }; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskConfigEntry.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskConfigEntry.cs new file mode 100644 index 00000000000..b7f2fae1627 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskConfigEntry.cs @@ -0,0 +1,7 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// One key-value pair from the workspace configuration, as returned by +/// . +/// +internal sealed record TaskConfigEntry(string Key, string Value); diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskCount.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskCount.cs new file mode 100644 index 00000000000..81e793a44e3 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskCount.cs @@ -0,0 +1,7 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// One group and how many non-tombstone tasks fall into it, as returned by +/// . +/// +internal sealed record TaskCount(string Value, int Count); diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskCountDimension.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskCountDimension.cs new file mode 100644 index 00000000000..4c90f2098ec --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskCountDimension.cs @@ -0,0 +1,13 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// The dimension groups tasks by. +/// +internal enum TaskCountDimension +{ + Status, + Type, + Priority, + Assignee, + Label +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskCreation.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskCreation.cs new file mode 100644 index 00000000000..75cda31d841 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskCreation.cs @@ -0,0 +1,50 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// The parameters for . +/// +internal sealed record TaskCreation +{ + public required string Title { get; init; } + public string Description { get; init; } = ""; + public required int Priority { get; init; } + public required string Type { get; init; } + public string? Assignee { get; init; } + public int? EstimatedMinutes { get; init; } + public DateTimeOffset? DueAt { get; init; } + public DateTimeOffset? DeferUntil { get; init; } + public IReadOnlyList Labels { get; init; } = []; + + /// + /// Dependencies to create alongside the task, applied in addition to the + /// parent-child edge implied by . + /// + public IReadOnlyList DependsOn { get; init; } = []; + + /// + /// The parent task to create this task under, or null for a top-level + /// task. Determines both the parent-child dependency and ID allocation. + /// + public string? ParentId { get; init; } + + public required string Actor { get; init; } +} + +/// +/// One dependency to create between a new task and an existing one. +/// +internal sealed record TaskDependencyRequest(string DependsOnId, string Type); + +/// +/// The outcome of . +/// +internal sealed record TaskCreationResult +{ + public required string Id { get; init; } + + /// + /// The IDs of tasks that block the new task, per the same rules as + /// ComputeBlockedAsync. + /// + public IReadOnlyList BlockedBy { get; init; } = []; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskCycleResult.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskCycleResult.cs new file mode 100644 index 00000000000..ea0ba7af585 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskCycleResult.cs @@ -0,0 +1,9 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// One elementary cycle among blocking dependencies, as returned by the +/// structured (JSON) output of agent tasks dep cycles. +/// lists the cycle's members in order; it wraps back around to its first +/// entry. +/// +internal sealed record TaskCycleResult(IReadOnlyList Tasks); diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDates.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDates.cs new file mode 100644 index 00000000000..87470ab75dc --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDates.cs @@ -0,0 +1,32 @@ +using System.Globalization; + +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// Parses and formats the timestamps used by task commands. Values without an +/// offset are interpreted as UTC. +/// +internal static class TaskDates +{ + public static DateTimeOffset Parse(string value, string optionName) + { + if (DateTimeOffset.TryParse( + value, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal, + out var result)) + { + // Stored timestamps must all carry the +00:00 offset so SQLite's + // lexicographic TEXT comparison stays chronologically correct. + return result.ToUniversalTime(); + } + + throw new ExitException( + $"Invalid date '{value}' for '{optionName}'. " + + "Use an ISO 8601 date or timestamp."); + } + + public static string Format(DateTimeOffset value) + => value.ToUniversalTime() + .ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture); +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDependenciesResult.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDependenciesResult.cs new file mode 100644 index 00000000000..918e1d56eea --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDependenciesResult.cs @@ -0,0 +1,9 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// A task's outgoing dependencies and incoming dependents, as returned by the +/// structured (JSON) output of agent tasks dep list. +/// +internal sealed record TaskDependenciesResult( + IReadOnlyList Dependencies, + IReadOnlyList Dependents); diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDependency.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDependency.cs new file mode 100644 index 00000000000..f031a31d16a --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDependency.cs @@ -0,0 +1,22 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// A dependency edge from to . +/// Only one edge can exist per ordered task pair. +/// +internal sealed class TaskDependency +{ + /// + /// The column list matching this type's properties, for use in SELECT + /// statements against the dependencies table. + /// + public const string Columns = + "task_id AS TaskId, depends_on_id AS DependsOnId, " + + "dependency_type AS Type, created_at AS CreatedAt, created_by AS CreatedBy"; + + public required string TaskId { get; init; } + public required string DependsOnId { get; init; } + public string Type { get; init; } = TaskDependencyTypes.Blocks; + public required DateTimeOffset CreatedAt { get; init; } + public string CreatedBy { get; init; } = ""; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDependencyAddResult.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDependencyAddResult.cs new file mode 100644 index 00000000000..87e76cbf189 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDependencyAddResult.cs @@ -0,0 +1,14 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// The outcome of . +/// +internal sealed record TaskDependencyAddResult +{ + /// + /// Always null: a new edge that would close a blocking-dependency cycle + /// is rejected with an before it commits, so + /// a successful call never reports one. + /// + public IReadOnlyList? Cycle { get; init; } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDependencyDetail.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDependencyDetail.cs new file mode 100644 index 00000000000..5deb8920c8c --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDependencyDetail.cs @@ -0,0 +1,29 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// One outgoing dependency edge from a task, joined with the target task's +/// current status and title, as returned by +/// . Null +/// and mean the target task no longer exists. +/// +internal sealed record TaskDependencyDetail +{ + public required string Type { get; init; } + public required string DependsOnId { get; init; } + public string? Status { get; init; } + public string? Title { get; init; } +} + +/// +/// One incoming dependency edge onto a task, joined with the dependent +/// task's current status and title, as returned by +/// . Null and +/// mean the dependent task no longer exists. +/// +internal sealed record TaskDependentDetail +{ + public required string TaskId { get; init; } + public required string Type { get; init; } + public string? Status { get; init; } + public string? Title { get; init; } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDependencyRow.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDependencyRow.cs new file mode 100644 index 00000000000..49daa56a354 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDependencyRow.cs @@ -0,0 +1,33 @@ +using System.Globalization; + +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// An all-primitives projection of a dependency edge; the intercepted read +/// path cannot convert the TEXT-stored timestamp column to DateTimeOffset. +/// +internal sealed class TaskDependencyRow +{ + /// + /// The column list matching this type's properties, for use in SELECT + /// statements against the dependencies table. + /// + public const string Columns = + "task_id AS TaskId, depends_on_id AS DependsOnId, " + + "dependency_type AS Type, created_at AS CreatedAt, created_by AS CreatedBy"; + + public required string TaskId { get; init; } + public required string DependsOnId { get; init; } + public string Type { get; init; } = TaskDependencyTypes.Blocks; + public required string CreatedAt { get; init; } + public string CreatedBy { get; init; } = ""; + + public TaskDependency ToTaskDependency() => new() + { + TaskId = TaskId, + DependsOnId = DependsOnId, + Type = Type, + CreatedAt = DateTimeOffset.Parse(CreatedAt, CultureInfo.InvariantCulture), + CreatedBy = CreatedBy + }; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDependencyTreeNode.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDependencyTreeNode.cs new file mode 100644 index 00000000000..264eb76c165 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDependencyTreeNode.cs @@ -0,0 +1,18 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// One node of a task's outgoing dependency tree, as returned by the +/// structured (JSON) output of agent tasks dep tree. Null +/// and mean the task no longer exists. A node whose +/// is true was already printed elsewhere in the +/// tree and is not expanded further. +/// +internal sealed record TaskDependencyTreeNode +{ + public required string Id { get; init; } + public string? Type { get; init; } + public string? Status { get; init; } + public string? Title { get; init; } + public required bool Repeated { get; init; } + public required IReadOnlyList Children { get; init; } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDependencyTypes.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDependencyTypes.cs new file mode 100644 index 00000000000..57af43932b6 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDependencyTypes.cs @@ -0,0 +1,29 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// The well-known dependency types. Custom lowercase kebab-case types are +/// accepted and round-tripped verbatim. +/// +internal static class TaskDependencyTypes +{ + public const string Blocks = "blocks"; + public const string ParentChild = "parent-child"; + public const string ConditionalBlocks = "conditional-blocks"; + public const string WaitsFor = "waits-for"; + public const string Related = "related"; + public const string DiscoveredFrom = "discovered-from"; + public const string RelatesTo = "relates-to"; + public const string Duplicates = "duplicates"; + public const string Supersedes = "supersedes"; + public const string CausedBy = "caused-by"; + + /// + /// A blocking dependency gates readiness of the dependent task until the + /// target task reaches a terminal state. + /// + public static bool IsBlocking(string type) + => type is Blocks or ParentChild or ConditionalBlocks or WaitsFor; + + public static string Normalize(string type) + => type.Trim().ToLowerInvariant().Replace('_', '-'); +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDetailResult.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDetailResult.cs new file mode 100644 index 00000000000..4b5220e65cd --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskDetailResult.cs @@ -0,0 +1,33 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// A task's full details, including its labels, dependencies, dependents, +/// comments, and computed blockers, as returned by the structured (JSON) +/// output of task show. +/// +internal sealed record TaskDetailResult +{ + public required string Id { get; init; } + public required string Title { get; init; } + public required string Status { get; init; } + public required int Priority { get; init; } + public required string Type { get; init; } + public string? Assignee { get; init; } + public int? EstimatedMinutes { get; init; } + public DateTimeOffset? DueAt { get; init; } + public DateTimeOffset? DeferUntil { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + public required string CreatedBy { get; init; } + public required DateTimeOffset UpdatedAt { get; init; } + public DateTimeOffset? ClosedAt { get; init; } + public string? CloseReason { get; init; } + public required string Description { get; init; } + public required string Design { get; init; } + public required string AcceptanceCriteria { get; init; } + public required string Notes { get; init; } + public required IReadOnlyList Labels { get; init; } + public required IReadOnlyList Blockers { get; init; } + public required IReadOnlyList Dependencies { get; init; } + public required IReadOnlyList Dependents { get; init; } + public required IReadOnlyList Comments { get; init; } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskEpicStatus.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskEpicStatus.cs new file mode 100644 index 00000000000..d77893f9134 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskEpicStatus.cs @@ -0,0 +1,22 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// An epic's direct child completion counts, as returned by +/// . and +/// count only non-tombstone children. +/// +internal sealed record TaskEpicStatus +{ + public required string Id { get; init; } + public required string Title { get; init; } + public required string Status { get; init; } + public required int Total { get; init; } + public required int Closed { get; init; } + + /// + /// An epic is eligible for close when it has at least one child, all of + /// its children are closed, and the epic itself is not already closed. + /// + public bool IsEligibleForClose + => Total > 0 && Closed == Total && Status != TaskStates.Closed; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskEvent.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskEvent.cs new file mode 100644 index 00000000000..6c7bbc16bec --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskEvent.cs @@ -0,0 +1,26 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// An audit log entry for a task mutation. Null old or new values mean the +/// event carries no field transition. +/// +internal sealed class TaskEvent +{ + /// + /// The column list matching this type's properties, for use in SELECT + /// statements against the events table. + /// + public const string Columns = + "id AS Id, task_id AS TaskId, event_type AS Type, actor AS Actor, " + + "old_value AS OldValue, new_value AS NewValue, comment AS Comment, " + + "created_at AS CreatedAt"; + + public long Id { get; init; } + public required string TaskId { get; init; } + public required string Type { get; init; } + public string Actor { get; init; } = ""; + public string? OldValue { get; init; } + public string? NewValue { get; init; } + public string? Comment { get; init; } + public required DateTimeOffset CreatedAt { get; init; } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskEventTypes.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskEventTypes.cs new file mode 100644 index 00000000000..b7ef6cd0b00 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskEventTypes.cs @@ -0,0 +1,23 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// The well-known event types recorded in the task audit log. +/// +internal static class TaskEventTypes +{ + public const string Created = "created"; + public const string Updated = "updated"; + public const string StatusChanged = "status_changed"; + public const string PriorityChanged = "priority_changed"; + public const string AssigneeChanged = "assignee_changed"; + public const string Commented = "commented"; + public const string Closed = "closed"; + public const string Reopened = "reopened"; + public const string Deleted = "deleted"; + public const string DependencyAdded = "dependency_added"; + public const string DependencyRemoved = "dependency_removed"; + public const string LabelAdded = "label_added"; + public const string LabelRemoved = "label_removed"; + public const string Deferred = "deferred"; + public const string Undeferred = "undeferred"; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskFilter.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskFilter.cs new file mode 100644 index 00000000000..55d65ca019f --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskFilter.cs @@ -0,0 +1,102 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// The parameters for . This is the +/// backend-agnostic shape the TUI's filter query parser targets; its +/// "TaskQuery" record maps onto this type. Every filter applies +/// as AND with the others; apply as AND across labels. +/// +internal sealed record TaskFilter +{ + /// + /// Statuses a task must have one of. Null defers to + /// . + /// + public string[]? Statuses { get; init; } + + /// + /// When is null, true includes closed and + /// tombstone tasks and false excludes them. + /// + public bool IncludeAll { get; init; } + + /// + /// Excludes tombstone tasks regardless of or + /// . + /// + public bool ExcludeTombstones { get; init; } + + /// + /// A task type a task must have. + /// + public string? Type { get; init; } + + /// + /// A priority a task must have. + /// + public int? Priority { get; init; } + + /// + /// The inclusive low bound of a priority range a task's priority must + /// fall within. Ignored unless is also set. + /// + public int? PriorityMin { get; init; } + + /// + /// The inclusive high bound of a priority range a task's priority must + /// fall within. Ignored unless is also set. + /// + public int? PriorityMax { get; init; } + + /// + /// An assignee a task must have. Ignored when + /// is true. + /// + public string? Assignee { get; init; } + + /// + /// Matches tasks with no assignee. Takes precedence over + /// . + /// + public bool Unassigned { get; init; } + + /// + /// Labels a task must all carry. + /// + public string[]? Labels { get; init; } + + /// + /// Text matched against a task's title, description, design, acceptance + /// criteria, notes, and comment text. A task matching on several + /// comments still appears once. + /// + public string? Text { get; init; } + + /// + /// Includes only tasks last updated at or before this instant. + /// + public DateTimeOffset? UpdatedBefore { get; init; } + + /// + /// Includes only tasks whose defer_until is null or at or before this + /// instant. + /// + public DateTimeOffset? DeferredVisibleAt { get; init; } + + /// + /// Excludes tasks the blocked-task computation reports as blocked. Applied + /// before . + /// + public bool ExcludeBlocked { get; init; } + + /// + /// The maximum number of tasks to return, applied after + /// . Null means unlimited. + /// + public int? Limit { get; init; } + + /// + /// The sort order applied to the results. + /// + public TaskOrdering Ordering { get; init; } = TaskOrdering.PriorityCreatedId; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskImportResult.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskImportResult.cs new file mode 100644 index 00000000000..f166e5fd64c --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskImportResult.cs @@ -0,0 +1,8 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// The outcome of importing sync records: how many replaced the stored task +/// state because they were at least as new, how many were skipped because +/// the stored task was newer, and how many records were read in total. +/// +internal sealed record TaskImportResult(int Applied, int Skipped, int Total); diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskIntegrityReport.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskIntegrityReport.cs new file mode 100644 index 00000000000..68ebffc30b9 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskIntegrityReport.cs @@ -0,0 +1,57 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// The result of the workspace integrity checks run by +/// : an SQLite quick_check, +/// dependency/label/comment rows referencing a missing task, and +/// parent-child edges whose parent is a tombstone. +/// +internal sealed record TaskIntegrityReport +{ + /// + /// True when SQLite's quick_check reported no problems. + /// + public required bool QuickCheckOk { get; init; } + + /// + /// The raw quick_check message: "ok", or a description of the first + /// problem found. + /// + public required string QuickCheckMessage { get; init; } + + /// + /// Dependency rows whose task or target task does not exist. + /// + public required IReadOnlyList OrphanDependencies { get; init; } + + /// + /// Label rows whose task does not exist. + /// + public required IReadOnlyList OrphanLabels { get; init; } + + /// + /// Comment rows whose task does not exist. + /// + public required IReadOnlyList OrphanComments { get; init; } + + /// + /// Parent-child dependency edges whose parent task is a tombstone. + /// + public required IReadOnlyList TombstonedParentEdges { get; init; } +} + +/// +/// A dependency edge identified by its task pair, without the edge's type or +/// timestamps. +/// +internal sealed record TaskDependencyReference(string TaskId, string DependsOnId); + +/// +/// A label row identified by its task and label text. +/// +internal sealed record TaskOrphanLabel(string TaskId, string Label); + +/// +/// A comment row identified by its task and comment ID. +/// +internal sealed record TaskOrphanComment(string TaskId, long CommentId); diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskItem.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskItem.cs new file mode 100644 index 00000000000..9624928613e --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskItem.cs @@ -0,0 +1,42 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// A task row. Null means unassigned; null date values +/// mean the corresponding lifecycle step has not happened. +/// +internal sealed class TaskItem +{ + /// + /// The column list matching this type's properties, for use in SELECT + /// statements against the tasks table. + /// + public const string Columns = + "id AS Id, title AS Title, description AS Description, design AS Design, " + + "acceptance_criteria AS AcceptanceCriteria, notes AS Notes, status AS Status, " + + "priority AS Priority, task_type AS Type, assignee AS Assignee, " + + "estimated_minutes AS EstimatedMinutes, due_at AS DueAt, defer_until AS DeferUntil, " + + "created_at AS CreatedAt, created_by AS CreatedBy, updated_at AS UpdatedAt, " + + "closed_at AS ClosedAt, close_reason AS CloseReason, deleted_at AS DeletedAt, " + + "delete_reason AS DeleteReason"; + + public required string Id { get; init; } + public required string Title { get; set; } + public string Description { get; set; } = ""; + public string Design { get; set; } = ""; + public string AcceptanceCriteria { get; set; } = ""; + public string Notes { get; set; } = ""; + public string Status { get; set; } = TaskStates.Open; + public int Priority { get; set; } = TaskPriorities.Medium; + public string Type { get; set; } = TaskTypes.Task; + public string? Assignee { get; set; } + public int? EstimatedMinutes { get; set; } + public DateTimeOffset? DueAt { get; set; } + public DateTimeOffset? DeferUntil { get; set; } + public required DateTimeOffset CreatedAt { get; init; } + public string CreatedBy { get; init; } = ""; + public required DateTimeOffset UpdatedAt { get; set; } + public DateTimeOffset? ClosedAt { get; set; } + public string CloseReason { get; set; } = ""; + public DateTimeOffset? DeletedAt { get; set; } + public string DeleteReason { get; set; } = ""; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskLabelChange.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskLabelChange.cs new file mode 100644 index 00000000000..4497799bf46 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskLabelChange.cs @@ -0,0 +1,7 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// One label from an call and whether +/// it was newly added or already present. +/// +internal sealed record TaskLabelChange(string Label, bool Added); diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskLabelCount.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskLabelCount.cs new file mode 100644 index 00000000000..5a846af2984 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskLabelCount.cs @@ -0,0 +1,7 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// A label and how many non-tombstone tasks carry it, as returned by +/// . +/// +internal sealed record TaskLabelCount(string Label, int Count); diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskLintFinding.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskLintFinding.cs new file mode 100644 index 00000000000..6368b712f44 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskLintFinding.cs @@ -0,0 +1,7 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// One workspace quality finding reported by task lint: a task, the +/// short rule slug that flagged it, and a human-readable message. +/// +internal sealed record TaskLintFinding(string TaskId, string Rule, string Message); diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskOrdering.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskOrdering.cs new file mode 100644 index 00000000000..99e7d38aaa9 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskOrdering.cs @@ -0,0 +1,24 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// The sort order applied by . +/// +internal enum TaskOrdering +{ + /// + /// Priority ascending, then created_at ascending, then id ascending. The + /// default for list and search. + /// + PriorityCreatedId, + + /// + /// updated_at ascending, then id ascending. Used by stale. + /// + UpdatedAtAscending, + + /// + /// Priority 0 or 1 first, then created_at ascending, then id ascending. + /// Used by ready. + /// + ReadyPick +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskPriorities.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskPriorities.cs new file mode 100644 index 00000000000..8f41c6e7188 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskPriorities.cs @@ -0,0 +1,67 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// Task priorities range from 0 (critical) to 4 (backlog); 2 is the default. +/// +internal static class TaskPriorities +{ + public const int Critical = 0; + public const int High = 1; + public const int Medium = 2; + public const int Low = 3; + public const int Backlog = 4; + + public static string Format(int priority) => $"P{priority}"; + + /// + /// Parses "0".."4" or "p0".."p4" (case-insensitive). Throws + /// for any other value. + /// + public static int Parse(string value) + { + var span = value.AsSpan().Trim(); + + if (span.Length > 1 && (span[0] is 'p' or 'P')) + { + span = span[1..]; + } + + if (span.Length == 1 && span[0] is >= '0' and <= '4') + { + return span[0] - '0'; + } + + throw new ExitException( + $"Invalid priority '{value}'. Use 0-4 or p0-p4 (0 = critical, 4 = backlog)."); + } + + /// + /// Parses a single priority or an inclusive range "N-M" / "pN-pM" + /// (each bound accepted in the same forms as ). A + /// single value returns a range with equal bounds. Throws + /// when a bound is invalid or the low + /// bound is greater than the high bound. + /// + public static (int Min, int Max) ParseRange(string value) + { + var trimmed = value.Trim(); + var dashIndex = trimmed.IndexOf('-'); + + if (dashIndex > 0) + { + var min = Parse(trimmed[..dashIndex]); + var max = Parse(trimmed[(dashIndex + 1)..]); + + if (min > max) + { + throw new ExitException( + $"Invalid priority range '{value}'. The low bound must be <= the high bound."); + } + + return (min, max); + } + + var single = Parse(trimmed); + return (single, single); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskSnapshotResult.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskSnapshotResult.cs new file mode 100644 index 00000000000..cab2b32449b --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskSnapshotResult.cs @@ -0,0 +1,52 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// A task's core fields, as returned by the structured (JSON) output of task +/// mutation commands. is populated only where the +/// mutation already computes it; otherwise it is empty. +/// +internal sealed record TaskSnapshotResult +{ + public required string Id { get; init; } + public required string Title { get; init; } + public required string Status { get; init; } + public required int Priority { get; init; } + public required string Type { get; init; } + public string? Assignee { get; init; } + public int? EstimatedMinutes { get; init; } + public DateTimeOffset? DueAt { get; init; } + public DateTimeOffset? DeferUntil { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + public required string CreatedBy { get; init; } + public required DateTimeOffset UpdatedAt { get; init; } + public DateTimeOffset? ClosedAt { get; init; } + public string? CloseReason { get; init; } + public required string Description { get; init; } + public required string Design { get; init; } + public required string AcceptanceCriteria { get; init; } + public required string Notes { get; init; } + public IReadOnlyList Blockers { get; init; } = []; + + public static TaskSnapshotResult From(TaskItem task, IReadOnlyList? blockers = null) => new() + { + Id = task.Id, + Title = task.Title, + Status = task.Status, + Priority = task.Priority, + Type = task.Type, + Assignee = task.Assignee, + EstimatedMinutes = task.EstimatedMinutes, + DueAt = task.DueAt, + DeferUntil = task.DeferUntil, + CreatedAt = task.CreatedAt, + CreatedBy = task.CreatedBy, + UpdatedAt = task.UpdatedAt, + ClosedAt = task.ClosedAt, + CloseReason = string.IsNullOrEmpty(task.CloseReason) ? null : task.CloseReason, + Description = task.Description, + Design = task.Design, + AcceptanceCriteria = task.AcceptanceCriteria, + Notes = task.Notes, + Blockers = blockers ?? [] + }; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskStates.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskStates.cs new file mode 100644 index 00000000000..c5003b2d41a --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskStates.cs @@ -0,0 +1,25 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// The well-known task states. Custom lowercase states are accepted and +/// round-tripped verbatim. +/// +internal static class TaskStates +{ + public const string Open = "open"; + public const string InProgress = "in_progress"; + public const string Blocked = "blocked"; + public const string Deferred = "deferred"; + public const string Closed = "closed"; + public const string Tombstone = "tombstone"; + + /// + /// A terminal state ends the task's lifecycle and releases anything + /// blocked on it. + /// + public static bool IsTerminal(string state) + => state is Closed or Tombstone; + + public static string Normalize(string state) + => state.Trim().ToLowerInvariant().Replace('-', '_'); +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskStats.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskStats.cs new file mode 100644 index 00000000000..798c479f53b --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskStats.cs @@ -0,0 +1,30 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// The summary statistics returned by . +/// +internal sealed record TaskStats +{ + /// + /// Non-tombstone task counts grouped by status. + /// + public required IReadOnlyList StatusCounts { get; init; } + + /// + /// The number of open, non-deferred, unblocked tasks. + /// + public required int ReadyCount { get; init; } + + /// + /// The blocked, non-terminal tasks, mapping task ID to status. + /// + public required IReadOnlyDictionary BlockedTaskStatuses { get; init; } + + /// + /// The number of distinct labels in use on non-tombstone tasks. + /// + public required int LabelCount { get; init; } + + public required int CommentCount { get; init; } + public required int EventCount { get; init; } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskStore.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskStore.cs new file mode 100644 index 00000000000..d02801efb76 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskStore.cs @@ -0,0 +1,3069 @@ +using System.Data.Common; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Dapper; +using Microsoft.Data.Sqlite; + +[module: DapperAot] + +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +internal sealed class TaskStore(IFileSystem fileSystem, TimeProvider timeProvider) : ITaskStore +{ + private const string PrefixConfigKey = "prefix"; + private const string IdAlphabet = "0123456789abcdefghijklmnopqrstuvwxyz"; + private const int MinIdLength = 3; + private const int MaxIdAttempts = 10; + + private static readonly string[] StatusOrder = + [ + TaskStates.Open, + TaskStates.InProgress, + TaskStates.Blocked, + TaskStates.Deferred, + TaskStates.Closed, + TaskStates.Tombstone + ]; + + static TaskStore() => SQLitePCL.Batteries_V2.Init(); + + public async Task InitializeAsync( + string workspaceDirectory, + CancellationToken cancellationToken) + { + var connection = await OpenAsync( + TaskWorkspace.GetDatabasePath(workspaceDirectory), + cancellationToken); + + await connection.ExecuteAsync(TaskStoreSchema.Create); + await connection.ExecuteAsync( + $"""PRAGMA user_version = {TaskStoreSchema.CurrentVersion};"""); + + return connection; + } + + private async Task ConnectAsync(CancellationToken cancellationToken) + { + var workspaceDirectory = FindWorkspaceDirectory() + ?? throw new ExitException( + "No task workspace found. Run `nitro agent tasks init` first."); + + var connection = await OpenAsync( + TaskWorkspace.GetDatabasePath(workspaceDirectory), + cancellationToken); + + var version = await connection.ExecuteScalarAsync("PRAGMA user_version;"); + + if (version > TaskStoreSchema.CurrentVersion) + { + throw new ExitException( + "The task workspace was created by a newer version of the Nitro CLI " + + $"(schema v{version}, supported up to v{TaskStoreSchema.CurrentVersion}). " + + "Update the CLI to use it."); + } + + return connection; + } + + public string? FindWorkspaceDirectory() + => TaskWorkspace.Find(fileSystem, fileSystem.GetCurrentDirectory()); + + private async Task GetConfigAsync( + SqliteConnection connection, + string key, + CancellationToken cancellationToken, + DbTransaction? transaction = null) + => await connection.QueryFirstOrDefaultAsync( + "SELECT value FROM config WHERE key = @key", + new { key, cancellationToken }, + transaction); + + private async Task SetConfigAsync( + SqliteConnection connection, + string key, + string value, + CancellationToken cancellationToken, + DbTransaction? transaction = null) + => await connection.ExecuteAsync( + """ + INSERT INTO config (key, value) + VALUES (@key, @value) + ON CONFLICT (key) DO UPDATE SET value = @value + """, + new { key, value, cancellationToken }, + transaction); + + private async Task GetPrefixAsync( + SqliteConnection connection, + CancellationToken cancellationToken, + DbTransaction? transaction = null) + => await GetConfigAsync(connection, PrefixConfigKey, cancellationToken, transaction) + ?? TaskWorkspace.FallbackPrefix; + + private async Task CreateTaskIdAsync( + SqliteConnection connection, + string? parentId, + string seed, + CancellationToken cancellationToken, + DbTransaction? transaction = null) + { + if (parentId is not null) + { + var childNumber = await connection.ExecuteScalarAsync( + """ + INSERT INTO child_counters (parent_id, last_child) + VALUES (@parentId, 1) + ON CONFLICT (parent_id) DO UPDATE SET last_child = last_child + 1 + RETURNING last_child + """, + new { parentId, cancellationToken }, + transaction); + + return $"{parentId}.{childNumber}"; + } + + var prefix = await GetPrefixAsync(connection, cancellationToken, transaction); + var taskCount = await connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM tasks", + transaction: transaction); + + for (var attempt = 0; attempt < MaxIdAttempts; attempt++) + { + var id = $"{prefix}-{CreateIdSuffix(seed, taskCount, attempt)}"; + + var exists = await connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM tasks WHERE id = @id", + new { id, cancellationToken }, + transaction); + + if (exists == 0) + { + return id; + } + } + + throw new ExitException("Could not allocate a unique task ID."); + } + + private async Task RecordEventAsync( + SqliteConnection connection, + TaskEvent taskEvent, + CancellationToken cancellationToken, + DbTransaction? transaction = null) + => await connection.ExecuteAsync( + """ + INSERT INTO events ( + task_id, + event_type, + actor, + old_value, + new_value, + comment, + created_at + ) + VALUES ( + @TaskId, + @Type, + @Actor, + @OldValue, + @NewValue, + @Comment, + @CreatedAt + ) + """, + new + { + taskEvent.TaskId, + taskEvent.Type, + taskEvent.Actor, + taskEvent.OldValue, + taskEvent.NewValue, + taskEvent.Comment, + taskEvent.CreatedAt, + cancellationToken + }, + transaction); + + private async Task GetTaskAsync( + SqliteConnection connection, + string id, + CancellationToken cancellationToken, + DbTransaction? transaction = null) + { + // Materializes an all-primitives row and parses the timestamps + // itself, since the DateTimeOffset columns are stored as TEXT. + var row = await connection.QueryFirstOrDefaultAsync( + $"SELECT {TaskItem.Columns} FROM tasks WHERE id = @id", + new { id, cancellationToken }, + transaction); + + return row?.ToTaskItem(); + } + + private async Task GetRequiredTaskAsync( + SqliteConnection connection, + string id, + CancellationToken cancellationToken, + DbTransaction? transaction = null) + { + var task = await GetTaskAsync(connection, id, cancellationToken, transaction); + + if (task is null || task.Status == TaskStates.Tombstone) + { + throw new ExitException($"Task '{id}' does not exist."); + } + + return task; + } + + private async Task<( + IReadOnlyDictionary> Blocked, + IReadOnlyDictionary Tasks)> ComputeBlockedAsync( + SqliteConnection connection, + CancellationToken cancellationToken) + { + // Both queries below run against the whole table and take no filter + // parameters, so there is nothing for @-placeholder analysis to key + // on; cancellation is checked up front instead of plumbed through a + // parameter object. + cancellationToken.ThrowIfCancellationRequested(); + + var taskRows = await connection.QueryAsync( + "SELECT id AS Id, status AS Status, task_type AS Type FROM tasks"); + var tasks = taskRows.ToDictionary(t => t.Id); + + var dependencyRows = await connection.QueryAsync( + """ + SELECT task_id AS TaskId, depends_on_id AS DependsOnId, dependency_type AS Type + FROM dependencies + """); + var dependencies = dependencyRows.ToList(); + + var blocked = new Dictionary>(); + + // Pass 1: tasks with a blocking dependency on a non-terminal or + // missing target are blocked. Parent-child edges gate children only + // through pass 2 (blocked parents); a merely open parent does not + // block its children. + foreach (var edge in dependencies) + { + if (edge.Type == TaskDependencyTypes.ParentChild + || !TaskDependencyTypes.IsBlocking(edge.Type)) + { + continue; + } + + if (!tasks.ContainsKey(edge.TaskId)) + { + continue; + } + + if (!tasks.TryGetValue(edge.DependsOnId, out var target)) + { + AddBlocker(blocked, edge.TaskId, $"{edge.DependsOnId}:unknown"); + } + else if (!TaskStates.IsTerminal(target.Status)) + { + AddBlocker(blocked, edge.TaskId, $"{edge.DependsOnId}:{target.Status}"); + } + } + + // Pass 2: blocked parents propagate to their children, transitively. + var childrenByParent = dependencies + .Where(e => e.Type == TaskDependencyTypes.ParentChild) + .GroupBy(e => e.DependsOnId) + .ToDictionary(g => g.Key, g => g.Select(e => e.TaskId).ToList()); + + var queue = new Queue(blocked.Keys); + var visited = new HashSet(blocked.Keys); + + while (queue.TryDequeue(out var parentId)) + { + if (!childrenByParent.TryGetValue(parentId, out var children)) + { + continue; + } + + foreach (var childId in children) + { + AddBlocker(blocked, childId, $"{parentId}:parent-blocked"); + + if (visited.Add(childId)) + { + queue.Enqueue(childId); + } + } + } + + // Pass 3: epics with non-terminal children are blocked. Runs after + // pass 2 so a parent blocked only by its children does not re-block + // those children. + foreach (var edge in dependencies) + { + if (edge.Type != TaskDependencyTypes.ParentChild) + { + continue; + } + + if (tasks.TryGetValue(edge.DependsOnId, out var parent) + && parent.Type == TaskTypes.Epic + && tasks.TryGetValue(edge.TaskId, out var child) + && !TaskStates.IsTerminal(child.Status)) + { + AddBlocker(blocked, edge.DependsOnId, $"{edge.TaskId}:child-open"); + } + } + + var result = blocked.ToDictionary( + pair => pair.Key, + IReadOnlyList (pair) => + [.. pair.Value.Order(StringComparer.Ordinal).Distinct()]); + + return (result, tasks); + } + + public async Task> QueryTasksAsync( + TaskFilter filter, + CancellationToken cancellationToken) + { + var (whereClause, parameters) = BuildTaskFilterClause(filter); + var orderBy = OrderByClause(filter.Ordering); + + var sql = $"SELECT {TaskItem.Columns} FROM tasks{whereClause}{orderBy}"; + + if (!filter.ExcludeBlocked && filter.Limit is { } sqlLimit) + { + parameters["limit"] = sqlLimit; + sql += " LIMIT @limit"; + } + + await using var connection = await ConnectAsync(cancellationToken); + + if (!filter.ExcludeBlocked) + { + return await ExecuteTaskQueryAsync(connection, sql, parameters, cancellationToken); + } + + var (blocked, _) = await ComputeBlockedAsync(connection, cancellationToken); + + IEnumerable tasks = + await ExecuteTaskQueryAsync(connection, sql, parameters, cancellationToken); + + tasks = tasks.Where(t => !blocked.ContainsKey(t.Id)); + + if (filter.Limit is { } limit) + { + tasks = tasks.Take(limit); + } + + return tasks.ToList(); + } + + // The WHERE/ORDER/LIMIT clauses here are assembled at runtime from the + // filter, so the SQL text is never a call-site literal; Dapper.AOT can + // only intercept calls whose SQL it can read at compile time. Reading + // through plain ADO.NET instead of Dapper's reflection fallback keeps + // this path free of runtime code generation. + private static async Task> ExecuteTaskQueryAsync( + SqliteConnection connection, + string sql, + IReadOnlyDictionary parameters, + CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.CommandText = sql; + + foreach (var (name, value) in parameters) + { + command.Parameters.AddWithValue("@" + name, value ?? DBNull.Value); + } + + var results = new List(); + + await using var reader = await command.ExecuteReaderAsync(cancellationToken); + var columns = TaskRowColumns.From(reader); + + while (await reader.ReadAsync(cancellationToken)) + { + results.Add(columns.Read(reader).ToTaskItem()); + } + + return results; + } + + public async Task GetTaskAsync( + string id, + CancellationToken cancellationToken) + { + await using var connection = await ConnectAsync(cancellationToken); + + return await GetTaskAsync(connection, id, cancellationToken); + } + + public async Task GetRequiredTaskAsync( + string id, + CancellationToken cancellationToken) + { + await using var connection = await ConnectAsync(cancellationToken); + + return await GetRequiredTaskAsync(connection, id, cancellationToken); + } + + public async Task> GetLabelsAsync( + string taskId, + CancellationToken cancellationToken) + { + await using var connection = await ConnectAsync(cancellationToken); + + var labels = await connection.QueryAsync( + "SELECT label FROM labels WHERE task_id = @taskId ORDER BY label", + new { taskId, cancellationToken }); + + return labels.ToList(); + } + + public async Task> GetLabelCountsAsync( + CancellationToken cancellationToken) + { + await using var connection = await ConnectAsync(cancellationToken); + + // A row class, not the TaskLabelCount record, receives the COUNT(*) + // column: SQLite's COUNT(*) always reads back as Int64, not Int32. A + // settable property tolerates the narrowing. + var rows = await connection.QueryAsync( + """ + SELECT l.label AS Label, COUNT(*) AS Count + FROM labels l + JOIN tasks t ON t.id = l.task_id + WHERE t.status != @tombstoneStatus + GROUP BY l.label + ORDER BY l.label + """, + new { tombstoneStatus = TaskStates.Tombstone, cancellationToken }); + + return rows.Select(r => new TaskLabelCount(r.Label, r.Count)).ToList(); + } + + public async Task> GetCommentsAsync( + string taskId, + CancellationToken cancellationToken) + { + await using var connection = await ConnectAsync(cancellationToken); + + // The intercepted read path cannot convert the TEXT-stored timestamp + // column to DateTimeOffset, so this materializes an all-primitives + // row and parses the timestamp itself. + var rows = await connection.QueryAsync( + $""" + SELECT {TaskComment.Columns} FROM comments WHERE task_id = @taskId + ORDER BY created_at, id + """, + new { taskId, cancellationToken }); + + return rows.Select(r => r.ToTaskComment()).ToList(); + } + + public async Task> GetDependenciesAsync( + string taskId, + CancellationToken cancellationToken) + { + await using var connection = await ConnectAsync(cancellationToken); + + var rows = await connection.QueryAsync( + """ + SELECT d.dependency_type AS Type, d.depends_on_id AS DependsOnId, + t.status AS Status, t.title AS Title + FROM dependencies d + LEFT JOIN tasks t ON t.id = d.depends_on_id + WHERE d.task_id = @taskId + ORDER BY d.created_at, d.depends_on_id + """, + new { taskId, cancellationToken }); + + return rows.ToList(); + } + + public async Task> GetDependentsAsync( + string taskId, + CancellationToken cancellationToken) + { + await using var connection = await ConnectAsync(cancellationToken); + + var rows = await connection.QueryAsync( + """ + SELECT d.task_id AS TaskId, d.dependency_type AS Type, + t.status AS Status, t.title AS Title + FROM dependencies d + LEFT JOIN tasks t ON t.id = d.task_id + WHERE d.depends_on_id = @taskId + ORDER BY d.created_at, d.task_id + """, + new { taskId, cancellationToken }); + + return rows.ToList(); + } + + public async Task> GetDependencyEdgesAsync( + CancellationToken cancellationToken) + { + await using var connection = await ConnectAsync(cancellationToken); + + // Materializes an all-primitives row and parses the timestamp + // itself, since the created_at column is stored as TEXT. The query + // takes no filter parameters, so cancellation here is best-effort + // rather than plumbed through a parameter object. + var rows = await connection.QueryAsync( + $""" + SELECT {TaskDependencyRow.Columns} FROM dependencies + ORDER BY task_id, depends_on_id + """); + + return rows.Select(r => r.ToTaskDependency()).ToList(); + } + + public async Task>> ComputeBlockedAsync( + CancellationToken cancellationToken) + { + await using var connection = await ConnectAsync(cancellationToken); + + var (blocked, _) = await ComputeBlockedAsync(connection, cancellationToken); + + return blocked; + } + + public async Task> GetEpicStatusesAsync( + CancellationToken cancellationToken) + { + await using var connection = await ConnectAsync(cancellationToken); + + return await QueryEpicStatusesAsync(connection, cancellationToken); + } + + private static async Task> QueryEpicStatusesAsync( + SqliteConnection connection, + CancellationToken cancellationToken) + { + var rows = await connection.QueryAsync( + """ + SELECT e.id AS Id, e.title AS Title, e.status AS Status, + COUNT(c.id) AS Total, + SUM(CASE WHEN c.status = @closed THEN 1 ELSE 0 END) AS Closed + FROM tasks e + LEFT JOIN dependencies d + ON d.depends_on_id = e.id AND d.dependency_type = @parentChild + LEFT JOIN tasks c + ON c.id = d.task_id AND c.status != @tombstone + WHERE e.task_type = @epic AND e.status != @tombstone + GROUP BY e.id, e.title, e.status + ORDER BY e.id + """, + new + { + closed = TaskStates.Closed, + parentChild = TaskDependencyTypes.ParentChild, + tombstone = TaskStates.Tombstone, + epic = TaskTypes.Epic, + cancellationToken + }); + + return rows.ToList(); + } + + public async Task CountTasksAsync( + CancellationToken cancellationToken) + { + await using var connection = await ConnectAsync(cancellationToken); + + return await connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM tasks WHERE status != @tombstone", + new { tombstone = TaskStates.Tombstone, cancellationToken }); + } + + public async Task> CountTasksByAsync( + TaskCountDimension dimension, + CancellationToken cancellationToken) + { + await using var connection = await ConnectAsync(cancellationToken); + + // A row class, not the TaskCount record, receives the COUNT(*) + // column in every branch: SQLite's COUNT(*) always reads back as + // Int64, not Int32. A settable property tolerates the narrowing. + switch (dimension) + { + case TaskCountDimension.Status: + { + var rows = await connection.QueryAsync( + """ + SELECT status AS Value, COUNT(*) AS Count FROM tasks + WHERE status != @tombstone GROUP BY status ORDER BY status ASC + """, + new { tombstone = TaskStates.Tombstone, cancellationToken }); + + return rows.Select(r => new TaskCount(r.Value, r.Count)).ToList(); + } + + case TaskCountDimension.Type: + { + var rows = await connection.QueryAsync( + """ + SELECT task_type AS Value, COUNT(*) AS Count FROM tasks + WHERE status != @tombstone GROUP BY task_type ORDER BY task_type ASC + """, + new { tombstone = TaskStates.Tombstone, cancellationToken }); + + return rows.Select(r => new TaskCount(r.Value, r.Count)).ToList(); + } + + case TaskCountDimension.Priority: + { + var rows = await connection.QueryAsync( + """ + SELECT priority AS Priority, COUNT(*) AS Count FROM tasks + WHERE status != @tombstone GROUP BY priority ORDER BY priority ASC + """, + new { tombstone = TaskStates.Tombstone, cancellationToken }); + + return rows.Select(r => new TaskCount(TaskPriorities.Format(r.Priority), r.Count)).ToList(); + } + + case TaskCountDimension.Assignee: + { + var rows = await connection.QueryAsync( + """ + SELECT COALESCE(NULLIF(assignee, ''), 'unassigned') AS Value, + COUNT(*) AS Count + FROM tasks + WHERE status != @tombstone + GROUP BY COALESCE(NULLIF(assignee, ''), 'unassigned') + ORDER BY COALESCE(NULLIF(assignee, ''), 'unassigned') ASC + """, + new { tombstone = TaskStates.Tombstone, cancellationToken }); + + return rows.Select(r => new TaskCount(r.Value, r.Count)).ToList(); + } + + case TaskCountDimension.Label: + { + var rows = await connection.QueryAsync( + """ + SELECT label AS Value, COUNT(*) AS Count + FROM labels + INNER JOIN tasks ON tasks.id = labels.task_id + WHERE tasks.status != @tombstone GROUP BY label ORDER BY label ASC + """, + new { tombstone = TaskStates.Tombstone, cancellationToken }); + + return rows.Select(r => new TaskCount(r.Value, r.Count)).ToList(); + } + + default: + throw new ArgumentOutOfRangeException( + nameof(dimension), dimension, "Unknown task count dimension."); + } + } + + public async Task GetStatsAsync( + CancellationToken cancellationToken) + { + await using var connection = await ConnectAsync(cancellationToken); + + // A row class, not the TaskCount record, receives the COUNT(*) + // column: SQLite's COUNT(*) always reads back as Int64, not Int32. A + // settable property tolerates the narrowing. + var statusCountRows = await connection.QueryAsync( + """ + SELECT status AS Value, COUNT(*) AS Count FROM tasks + WHERE status != @tombstone GROUP BY status + """, + new { tombstone = TaskStates.Tombstone, cancellationToken }); + var statusCounts = statusCountRows.Select(r => new TaskCount(r.Value, r.Count)).ToList(); + + var now = timeProvider.GetUtcNow(); + var (blocked, tasksById) = await ComputeBlockedAsync(connection, cancellationToken); + + var readyIds = await connection.QueryAsync( + """ + SELECT id FROM tasks WHERE status = @status + AND (defer_until IS NULL OR defer_until <= @now) + """, + new { status = TaskStates.Open, now, cancellationToken }); + + var readyCount = readyIds.Count(id => !blocked.ContainsKey(id)); + + // Reuses the full task set ComputeBlockedAsync already loaded instead + // of a second "id IN (...)" query, whose parameter count varies with + // the blocked set and so is never a fixed, interceptable shape. + var blockedTaskStatuses = new Dictionary(); + + foreach (var id in blocked.Keys) + { + if (tasksById.TryGetValue(id, out var node) && !TaskStates.IsTerminal(node.Status)) + { + blockedTaskStatuses[id] = node.Status; + } + } + + var labelCount = await connection.ExecuteScalarAsync( + """ + SELECT COUNT(DISTINCT l.label) FROM labels l + INNER JOIN tasks t ON t.id = l.task_id WHERE t.status != @tombstone + """, + new { tombstone = TaskStates.Tombstone, cancellationToken }); + + var commentCount = await connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM comments"); + + var eventCount = await connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM events"); + + return new TaskStats + { + StatusCounts = statusCounts + .OrderBy(StatusOrderIndex) + .ThenBy(row => row.Value, StringComparer.Ordinal) + .ToList(), + ReadyCount = readyCount, + BlockedTaskStatuses = blockedTaskStatuses, + LabelCount = labelCount, + CommentCount = commentCount, + EventCount = eventCount + }; + } + + public async Task GetConfigAsync( + string key, + CancellationToken cancellationToken) + { + await using var connection = await ConnectAsync(cancellationToken); + + return await GetConfigAsync(connection, key, cancellationToken); + } + + public async Task SetConfigAsync( + string key, + string value, + CancellationToken cancellationToken) + { + await using var connection = await ConnectAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + + await SetConfigAsync(connection, key, value, cancellationToken, transaction); + + await transaction.CommitAsync(cancellationToken); + } + + public async Task> ListConfigAsync( + CancellationToken cancellationToken) + { + await using var connection = await ConnectAsync(cancellationToken); + + var rows = await connection.QueryAsync( + "SELECT key AS Key, value AS Value FROM config ORDER BY key ASC"); + + return rows.ToList(); + } + + public async Task GetPrefixAsync( + CancellationToken cancellationToken) + { + await using var connection = await ConnectAsync(cancellationToken); + + return await GetPrefixAsync(connection, cancellationToken); + } + + public async Task InitializeWorkspaceAsync( + string workspaceDirectory, + string prefix, + CancellationToken cancellationToken) + { + await using var connection = await InitializeAsync(workspaceDirectory, cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + + await SetConfigAsync(connection, PrefixConfigKey, prefix, cancellationToken, transaction); + + await transaction.CommitAsync(cancellationToken); + } + + public async Task CreateTaskAsync( + TaskCreation creation, + CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + var seed = $"{creation.Title}|{now:O}"; + + await using var connection = await ConnectAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + + var resolvedDependencies = new List<(string TargetId, string Type, string Status)>(); + string id; + + if (creation.ParentId is not null) + { + var parent = await GetRequiredTaskAsync( + connection, creation.ParentId, cancellationToken, transaction); + resolvedDependencies.Add( + (creation.ParentId, TaskDependencyTypes.ParentChild, parent.Status)); + id = await CreateTaskIdAsync( + connection, creation.ParentId, seed, cancellationToken, transaction); + } + else + { + id = await CreateTaskIdAsync(connection, null, seed, cancellationToken, transaction); + } + + foreach (var dependency in creation.DependsOn) + { + var target = await GetRequiredTaskAsync( + connection, dependency.DependsOnId, cancellationToken, transaction); + + resolvedDependencies.Add((dependency.DependsOnId, dependency.Type, target.Status)); + } + + var task = new TaskItem + { + Id = id, + Title = creation.Title, + Description = creation.Description, + Status = TaskStates.Open, + Priority = creation.Priority, + Type = creation.Type, + Assignee = creation.Assignee, + EstimatedMinutes = creation.EstimatedMinutes, + DueAt = creation.DueAt, + DeferUntil = creation.DeferUntil, + CreatedAt = now, + CreatedBy = creation.Actor, + UpdatedAt = now + }; + + await connection.ExecuteAsync( + """ + INSERT INTO tasks ( + id, + title, + description, + design, + acceptance_criteria, + notes, + status, + priority, + task_type, + assignee, + estimated_minutes, + due_at, + defer_until, + created_at, + created_by, + updated_at, + closed_at, + close_reason, + deleted_at, + delete_reason + ) + VALUES ( + @Id, + @Title, + @Description, + @Design, + @AcceptanceCriteria, + @Notes, + @Status, + @Priority, + @Type, + @Assignee, + @EstimatedMinutes, + @DueAt, + @DeferUntil, + @CreatedAt, + @CreatedBy, + @UpdatedAt, + @ClosedAt, + @CloseReason, + @DeletedAt, + @DeleteReason + ) + """, + task, + transaction); + + foreach (var label in creation.Labels) + { + await connection.ExecuteAsync( + """ + INSERT OR IGNORE INTO labels ( + task_id, + label + ) + VALUES ( + @TaskId, + @Label + ) + """, + new { TaskId = id, Label = label, cancellationToken }, + transaction); + } + + foreach (var (targetId, dependencyType, _) in resolvedDependencies) + { + await connection.ExecuteAsync( + """ + INSERT OR IGNORE INTO dependencies ( + task_id, + depends_on_id, + dependency_type, + created_at, + created_by + ) + VALUES ( + @TaskId, + @DependsOnId, + @Type, + @CreatedAt, + @CreatedBy + ) + """, + new TaskDependency + { + TaskId = id, + DependsOnId = targetId, + Type = dependencyType, + CreatedAt = now, + CreatedBy = creation.Actor + }, + transaction); + } + + await RecordEventAsync( + connection, + new TaskEvent + { + TaskId = id, + Type = TaskEventTypes.Created, + Actor = creation.Actor, + CreatedAt = now + }, + cancellationToken, + transaction); + + await transaction.CommitAsync(cancellationToken); + + // A parent-child edge alone does not block the new task; only the + // other blocking dependency types gate it (matching ComputeBlockedAsync). + var blockedBy = resolvedDependencies + .Where(d => d.Type != TaskDependencyTypes.ParentChild + && TaskDependencyTypes.IsBlocking(d.Type) + && !TaskStates.IsTerminal(d.Status)) + .Select(d => d.TargetId) + .Distinct() + .ToList(); + + return new TaskCreationResult { Id = id, BlockedBy = blockedBy }; + } + + /// + /// The in-memory outcome of validating and applying a + /// to a single task, before it is written. + /// + private sealed record TaskUpdateChange( + TaskItem Task, + List ChangedFields, + string? OldStatus, + string? NewStatus, + string? OldPriority, + string? NewPriority, + string? OldAssignee, + string? NewAssignee); + + public async Task UpdateTaskAsync( + string id, + TaskUpdate update, + CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + + await using var connection = await ConnectAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + var task = await GetRequiredTaskAsync(connection, id, cancellationToken, transaction); + + var change = ApplyUpdate(task, update); + task.UpdatedAt = now; + + await WriteUpdateAsync(connection, transaction, task, change, update.Actor, now, cancellationToken); + + await transaction.CommitAsync(cancellationToken); + + return new TaskUpdateResult { ChangedFields = change.ChangedFields }; + } + + /// + /// Applies the given field changes to every task and records the + /// corresponding events for each. Every task is loaded and validated + /// before any is written: either every task updates or none does, + /// mirroring . Throws + /// when any task does not exist, is a + /// tombstone, or a status guard is violated. + /// + public async Task> UpdateTasksAsync( + IReadOnlyList ids, + TaskUpdate update, + CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + + await using var connection = await ConnectAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + + // Every task is loaded and validated (and has the update applied + // in-memory) before any write happens, which gives the bulk update + // its all-or-nothing behavior: nothing is written until every id has + // passed. + var changes = new List(); + + foreach (var id in ids) + { + var task = await GetRequiredTaskAsync(connection, id, cancellationToken, transaction); + changes.Add(ApplyUpdate(task, update)); + } + + foreach (var change in changes) + { + change.Task.UpdatedAt = now; + + await WriteUpdateAsync( + connection, transaction, change.Task, change, update.Actor, now, cancellationToken); + } + + await transaction.CommitAsync(cancellationToken); + + return changes.Select(change => change.Task).ToArray(); + } + + /// + /// Validates the given update against the task's current state and + /// applies the resulting field changes to in + /// memory. Performs no I/O. Throws when a + /// status guard is violated. + /// + private static TaskUpdateChange ApplyUpdate(TaskItem task, TaskUpdate update) + { + var changedFields = new List(); + string? oldStatus = null; + string? newStatus = null; + string? oldPriority = null; + string? newPriority = null; + string? oldAssignee = null; + string? newAssignee = null; + + if (update.TitleGiven) + { + var title = update.Title ?? ""; + + if (title.Length is 0 or > 500) + { + throw new ExitException("The title must be 1-500 characters."); + } + + if (title != task.Title) + { + task.Title = title; + changedFields.Add("title"); + } + } + + if (update.DescriptionGiven) + { + var description = update.Description ?? ""; + + if (description != task.Description) + { + task.Description = description; + changedFields.Add("description"); + } + } + + if (update.TypeGiven) + { + var type = TaskTypes.Normalize(update.Type ?? ""); + + if (type != task.Type) + { + task.Type = type; + changedFields.Add("task_type"); + } + } + + if (update.NotesGiven) + { + var notes = update.Notes ?? ""; + + if (notes != task.Notes) + { + task.Notes = notes; + changedFields.Add("notes"); + } + } + + if (update.DesignGiven) + { + var design = update.Design ?? ""; + + if (design != task.Design) + { + task.Design = design; + changedFields.Add("design"); + } + } + + if (update.AcceptanceCriteriaGiven) + { + var acceptanceCriteria = update.AcceptanceCriteria ?? ""; + + if (acceptanceCriteria != task.AcceptanceCriteria) + { + task.AcceptanceCriteria = acceptanceCriteria; + changedFields.Add("acceptance_criteria"); + } + } + + if (update.DueAtGiven) + { + if (update.DueAt != task.DueAt) + { + task.DueAt = update.DueAt; + changedFields.Add("due_at"); + } + } + + if (update.DeferUntilGiven) + { + if (update.DeferUntil != task.DeferUntil) + { + task.DeferUntil = update.DeferUntil; + changedFields.Add("defer_until"); + } + } + + if (update.EstimatedMinutesGiven) + { + if (update.EstimatedMinutes != task.EstimatedMinutes) + { + task.EstimatedMinutes = update.EstimatedMinutes; + changedFields.Add("estimated_minutes"); + } + } + + if (update.StatusGiven) + { + var status = TaskStates.Normalize(update.Status ?? ""); + + if (status == TaskStates.Closed) + { + throw new ExitException("Use `nitro agent tasks close` to close a task."); + } + + if (status == TaskStates.Tombstone) + { + throw new ExitException("Use `nitro agent tasks delete` to delete a task."); + } + + if (task.Status == TaskStates.Closed) + { + throw new ExitException("Use `nitro agent tasks reopen` to reopen a task."); + } + + if (status != task.Status) + { + oldStatus = task.Status; + newStatus = status; + task.Status = status; + } + } + + if (update.PriorityGiven) + { + var priority = update.Priority ?? TaskPriorities.Medium; + + if (priority != task.Priority) + { + oldPriority = TaskPriorities.Format(task.Priority); + newPriority = TaskPriorities.Format(priority); + task.Priority = priority; + } + } + + if (update.AssigneeGiven) + { + var assignee = string.IsNullOrEmpty(update.Assignee) ? null : update.Assignee; + + if (assignee != task.Assignee) + { + oldAssignee = task.Assignee ?? ""; + newAssignee = assignee ?? ""; + task.Assignee = assignee; + } + } + + return new TaskUpdateChange( + task, changedFields, oldStatus, newStatus, oldPriority, newPriority, oldAssignee, newAssignee); + } + + /// + /// Persists a previously-computed and + /// records the corresponding events. Performs no validation, since + /// already validated and applied the change to + /// in memory. + /// + private async Task WriteUpdateAsync( + SqliteConnection connection, + DbTransaction transaction, + TaskItem task, + TaskUpdateChange change, + string actor, + DateTimeOffset now, + CancellationToken cancellationToken) + { + await connection.ExecuteAsync( + """ + UPDATE tasks + SET title = @Title, + description = @Description, + design = @Design, + acceptance_criteria = @AcceptanceCriteria, + notes = @Notes, + status = @Status, + priority = @Priority, + task_type = @Type, + assignee = @Assignee, + estimated_minutes = @EstimatedMinutes, + due_at = @DueAt, + defer_until = @DeferUntil, + updated_at = @UpdatedAt + WHERE id = @Id + """, + new + { + task.Title, + task.Description, + task.Design, + task.AcceptanceCriteria, + task.Notes, + task.Status, + task.Priority, + task.Type, + task.Assignee, + task.EstimatedMinutes, + task.DueAt, + task.DeferUntil, + task.UpdatedAt, + Id = task.Id, + cancellationToken + }, + transaction); + + if (change.OldStatus is not null) + { + await RecordEventAsync( + connection, + new TaskEvent + { + TaskId = task.Id, + Type = TaskEventTypes.StatusChanged, + Actor = actor, + OldValue = change.OldStatus, + NewValue = change.NewStatus, + CreatedAt = now + }, + cancellationToken, + transaction); + } + + if (change.OldPriority is not null) + { + await RecordEventAsync( + connection, + new TaskEvent + { + TaskId = task.Id, + Type = TaskEventTypes.PriorityChanged, + Actor = actor, + OldValue = change.OldPriority, + NewValue = change.NewPriority, + CreatedAt = now + }, + cancellationToken, + transaction); + } + + if (change.OldAssignee is not null) + { + await RecordEventAsync( + connection, + new TaskEvent + { + TaskId = task.Id, + Type = TaskEventTypes.AssigneeChanged, + Actor = actor, + OldValue = change.OldAssignee, + NewValue = change.NewAssignee, + CreatedAt = now + }, + cancellationToken, + transaction); + } + + if (change.ChangedFields.Count > 0) + { + await RecordEventAsync( + connection, + new TaskEvent + { + TaskId = task.Id, + Type = TaskEventTypes.Updated, + Actor = actor, + Comment = string.Join(", ", change.ChangedFields), + CreatedAt = now + }, + cancellationToken, + transaction); + } + } + + public async Task> CloseTaskAsync( + IReadOnlyList ids, + string reason, + string actor, + CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + + await using var connection = await ConnectAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + + // Every task is loaded and validated before any write happens, which + // gives close its all-or-nothing behavior: nothing is written until + // every id has passed. + var tasks = new List(); + + foreach (var id in ids) + { + var task = await GetRequiredTaskAsync(connection, id, cancellationToken, transaction); + + if (task.Status == TaskStates.Closed) + { + throw new ExitException($"Task '{id}' is already closed."); + } + + tasks.Add(task); + } + + foreach (var task in tasks) + { + var oldStatus = task.Status; + + await connection.ExecuteAsync( + """ + UPDATE tasks + SET status = @status, + closed_at = @closedAt, + close_reason = @closeReason, + updated_at = @updatedAt + WHERE id = @id + """, + new + { + status = TaskStates.Closed, + closedAt = now, + closeReason = reason, + updatedAt = now, + id = task.Id, + cancellationToken + }, + transaction); + + await RecordEventAsync( + connection, + new TaskEvent + { + TaskId = task.Id, + Type = TaskEventTypes.Closed, + Actor = actor, + OldValue = oldStatus, + NewValue = TaskStates.Closed, + Comment = string.IsNullOrEmpty(reason) ? null : reason, + CreatedAt = now + }, + cancellationToken, + transaction); + + task.Status = TaskStates.Closed; + task.ClosedAt = now; + task.CloseReason = reason; + task.UpdatedAt = now; + } + + await transaction.CommitAsync(cancellationToken); + + return tasks; + } + + public async Task ReopenTaskAsync( + string id, + string reason, + string actor, + CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + + await using var connection = await ConnectAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + var task = await GetRequiredTaskAsync(connection, id, cancellationToken, transaction); + + if (task.Status != TaskStates.Closed) + { + throw new ExitException($"Task '{id}' is not closed."); + } + + var oldStatus = task.Status; + + await connection.ExecuteAsync( + """ + UPDATE tasks + SET status = @status, + closed_at = NULL, + close_reason = @closeReason, + updated_at = @updatedAt + WHERE id = @id + """, + new + { + status = TaskStates.Open, + closeReason = "", + updatedAt = now, + id = task.Id, + cancellationToken + }, + transaction); + + await RecordEventAsync( + connection, + new TaskEvent + { + TaskId = task.Id, + Type = TaskEventTypes.Reopened, + Actor = actor, + OldValue = oldStatus, + NewValue = TaskStates.Open, + Comment = string.IsNullOrEmpty(reason) ? null : reason, + CreatedAt = now + }, + cancellationToken, + transaction); + + await transaction.CommitAsync(cancellationToken); + + task.Status = TaskStates.Open; + task.ClosedAt = null; + task.CloseReason = ""; + task.UpdatedAt = now; + + return task; + } + + public async Task DeferTaskAsync( + string id, + DateTimeOffset until, + string actor, + CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + + await using var connection = await ConnectAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + var task = await GetRequiredTaskAsync(connection, id, cancellationToken, transaction); + + if (task.Status is not (TaskStates.Open or TaskStates.InProgress)) + { + throw new ExitException("Only open or in-progress tasks can be deferred."); + } + + var oldStatus = task.Status; + + await connection.ExecuteAsync( + """ + UPDATE tasks + SET status = @status, + defer_until = @deferUntil, + updated_at = @updatedAt + WHERE id = @id + """, + new + { + status = TaskStates.Deferred, + deferUntil = until, + updatedAt = now, + id = task.Id, + cancellationToken + }, + transaction); + + await RecordEventAsync( + connection, + new TaskEvent + { + TaskId = task.Id, + Type = TaskEventTypes.Deferred, + Actor = actor, + OldValue = oldStatus, + NewValue = TaskDates.Format(until), + CreatedAt = now + }, + cancellationToken, + transaction); + + await transaction.CommitAsync(cancellationToken); + + task.Status = TaskStates.Deferred; + task.DeferUntil = until; + task.UpdatedAt = now; + + return task; + } + + public async Task UndeferTaskAsync( + string id, + string actor, + CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + + await using var connection = await ConnectAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + var task = await GetRequiredTaskAsync(connection, id, cancellationToken, transaction); + + if (task.Status != TaskStates.Deferred) + { + throw new ExitException($"Task '{id}' is not deferred."); + } + + var oldStatus = task.Status; + + await connection.ExecuteAsync( + """ + UPDATE tasks + SET status = @status, + defer_until = NULL, + updated_at = @updatedAt + WHERE id = @id + """, + new + { + status = TaskStates.Open, + updatedAt = now, + id = task.Id, + cancellationToken + }, + transaction); + + await RecordEventAsync( + connection, + new TaskEvent + { + TaskId = task.Id, + Type = TaskEventTypes.Undeferred, + Actor = actor, + OldValue = oldStatus, + NewValue = TaskStates.Open, + CreatedAt = now + }, + cancellationToken, + transaction); + + await transaction.CommitAsync(cancellationToken); + + task.Status = TaskStates.Open; + task.DeferUntil = null; + task.UpdatedAt = now; + + return task; + } + + public async Task DeleteTaskAsync( + string id, + string reason, + string actor, + CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + + await using var connection = await ConnectAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + var task = await GetRequiredTaskAsync(connection, id, cancellationToken, transaction); + + var oldStatus = task.Status; + + await connection.ExecuteAsync( + """ + UPDATE tasks + SET status = @status, + deleted_at = @deletedAt, + delete_reason = @deleteReason, + updated_at = @updatedAt + WHERE id = @id + """, + new + { + status = TaskStates.Tombstone, + deletedAt = now, + deleteReason = reason, + updatedAt = now, + id = task.Id, + cancellationToken + }, + transaction); + + await RecordEventAsync( + connection, + new TaskEvent + { + TaskId = task.Id, + Type = TaskEventTypes.Deleted, + Actor = actor, + OldValue = oldStatus, + NewValue = TaskStates.Tombstone, + Comment = string.IsNullOrEmpty(reason) ? null : reason, + CreatedAt = now + }, + cancellationToken, + transaction); + + await transaction.CommitAsync(cancellationToken); + + task.Status = TaskStates.Tombstone; + task.DeletedAt = now; + task.DeleteReason = reason; + task.UpdatedAt = now; + + return task; + } + + public async Task> CloseEligibleEpicsAsync( + string actor, + CancellationToken cancellationToken) + { + const string closeReason = "All children are closed."; + var now = timeProvider.GetUtcNow(); + + await using var connection = await ConnectAsync(cancellationToken); + + var epics = await QueryEpicStatusesAsync(connection, cancellationToken); + var eligible = epics.Where(epic => epic.IsEligibleForClose).ToList(); + + if (eligible.Count == 0) + { + return eligible; + } + + // Every epic is validated up front (IsEligibleForClose, checked + // against data read before the transaction opens); the update loop + // below is then all-or-nothing, matching CloseTaskCommand's pattern. + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + + foreach (var epic in eligible) + { + await connection.ExecuteAsync( + """ + UPDATE tasks + SET status = @status, + closed_at = @closedAt, + close_reason = @closeReason, + updated_at = @updatedAt + WHERE id = @id + """, + new + { + status = TaskStates.Closed, + closedAt = now, + closeReason, + updatedAt = now, + id = epic.Id, + cancellationToken + }, + transaction); + + await RecordEventAsync( + connection, + new TaskEvent + { + TaskId = epic.Id, + Type = TaskEventTypes.Closed, + Actor = actor, + OldValue = epic.Status, + NewValue = TaskStates.Closed, + Comment = closeReason, + CreatedAt = now + }, + cancellationToken, + transaction); + } + + await transaction.CommitAsync(cancellationToken); + + return eligible.Select(epic => epic with { Status = TaskStates.Closed }).ToList(); + } + + public async Task AddCommentAsync( + string id, + string text, + string actor, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(text)) + { + throw new ExitException("The comment text must not be empty."); + } + + var now = timeProvider.GetUtcNow(); + + await using var connection = await ConnectAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + var task = await GetRequiredTaskAsync(connection, id, cancellationToken, transaction); + + var commentId = await connection.ExecuteScalarAsync( + """ + INSERT INTO comments ( + task_id, + author, + text, + created_at + ) + VALUES ( + @TaskId, + @Author, + @Text, + @CreatedAt + ) + RETURNING id + """, + new { TaskId = task.Id, Author = actor, Text = text, CreatedAt = now, cancellationToken }, + transaction); + + await connection.ExecuteAsync( + "UPDATE tasks SET updated_at = @updatedAt WHERE id = @id", + new { updatedAt = now, id = task.Id, cancellationToken }, + transaction); + + await RecordEventAsync( + connection, + new TaskEvent + { + TaskId = task.Id, + Type = TaskEventTypes.Commented, + Actor = actor, + CreatedAt = now + }, + cancellationToken, + transaction); + + await transaction.CommitAsync(cancellationToken); + + return new TaskComment + { + Id = commentId, + TaskId = task.Id, + Author = actor, + Text = text, + CreatedAt = now + }; + } + + public async Task> AddLabelAsync( + string id, + IReadOnlyList labels, + string actor, + CancellationToken cancellationToken) + { + var normalizedLabels = labels.Select(label => label.Trim().ToLowerInvariant()).ToList(); + + if (normalizedLabels.Any(label => label.Length == 0)) + { + throw new ExitException("Labels must be non-empty."); + } + + var now = timeProvider.GetUtcNow(); + + await using var connection = await ConnectAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + var task = await GetRequiredTaskAsync(connection, id, cancellationToken, transaction); + + var results = new List(); + + foreach (var label in normalizedLabels) + { + var rowsAffected = await connection.ExecuteAsync( + """ + INSERT OR IGNORE INTO labels ( + task_id, + label + ) + VALUES ( + @TaskId, + @Label + ) + """, + new { TaskId = task.Id, Label = label, cancellationToken }, + transaction); + + results.Add(new TaskLabelChange(label, rowsAffected > 0)); + } + + if (results.Any(result => result.Added)) + { + await connection.ExecuteAsync( + "UPDATE tasks SET updated_at = @updatedAt WHERE id = @id", + new { updatedAt = now, id = task.Id, cancellationToken }, + transaction); + } + + foreach (var result in results) + { + if (result.Added) + { + await RecordEventAsync( + connection, + new TaskEvent + { + TaskId = task.Id, + Type = TaskEventTypes.LabelAdded, + Actor = actor, + NewValue = result.Label, + CreatedAt = now + }, + cancellationToken, + transaction); + } + } + + await transaction.CommitAsync(cancellationToken); + + return results; + } + + public async Task RemoveLabelAsync( + string id, + string label, + string actor, + CancellationToken cancellationToken) + { + var normalizedLabel = label.Trim().ToLowerInvariant(); + var now = timeProvider.GetUtcNow(); + + await using var connection = await ConnectAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + var task = await GetRequiredTaskAsync(connection, id, cancellationToken, transaction); + + var exists = await connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM labels WHERE task_id = @TaskId AND label = @Label", + new { TaskId = task.Id, Label = normalizedLabel, cancellationToken }, + transaction); + + if (exists == 0) + { + throw new ExitException($"Label '{normalizedLabel}' is not on '{task.Id}'."); + } + + await connection.ExecuteAsync( + "DELETE FROM labels WHERE task_id = @TaskId AND label = @Label", + new { TaskId = task.Id, Label = normalizedLabel, cancellationToken }, + transaction); + + await connection.ExecuteAsync( + "UPDATE tasks SET updated_at = @updatedAt WHERE id = @id", + new { updatedAt = now, id = task.Id, cancellationToken }, + transaction); + + await RecordEventAsync( + connection, + new TaskEvent + { + TaskId = task.Id, + Type = TaskEventTypes.LabelRemoved, + Actor = actor, + OldValue = normalizedLabel, + CreatedAt = now + }, + cancellationToken, + transaction); + + await transaction.CommitAsync(cancellationToken); + } + + public async Task AddDependencyAsync( + string id, + string dependsOnId, + string type, + string actor, + CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + + await using var connection = await ConnectAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + + await GetRequiredTaskAsync(connection, id, cancellationToken, transaction); + await GetRequiredTaskAsync(connection, dependsOnId, cancellationToken, transaction); + + if (id == dependsOnId) + { + throw new ExitException("A task cannot depend on itself."); + } + + var existingCount = await connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM dependencies WHERE task_id = @id AND depends_on_id = @dependsOnId", + new { id, dependsOnId, cancellationToken }, + transaction); + + if (existingCount > 0) + { + throw new ExitException("Dependency already exists."); + } + + await connection.ExecuteAsync( + """ + INSERT INTO dependencies ( + task_id, + depends_on_id, + dependency_type, + created_at, + created_by + ) + VALUES ( + @TaskId, + @DependsOnId, + @Type, + @CreatedAt, + @CreatedBy + ) + """, + new TaskDependency + { + TaskId = id, + DependsOnId = dependsOnId, + Type = type, + CreatedAt = now, + CreatedBy = actor + }, + transaction); + + await connection.ExecuteAsync( + "UPDATE tasks SET updated_at = @updatedAt WHERE id = @id", + new { updatedAt = now, id, cancellationToken }, + transaction); + + await RecordEventAsync( + connection, + new TaskEvent + { + TaskId = id, + Type = TaskEventTypes.DependencyAdded, + Actor = actor, + OldValue = null, + NewValue = $"{type}:{dependsOnId}", + CreatedAt = now + }, + cancellationToken, + transaction); + + if (TaskDependencyTypes.IsBlocking(type)) + { + var cycle = await FindBlockingCycleAsync(connection, transaction, id, dependsOnId); + + if (cycle is { Count: > 0 }) + { + throw new ExitException( + $"Adding this dependency would create a cycle: {FormatCycle(cycle)}."); + } + } + + await transaction.CommitAsync(cancellationToken); + + return new TaskDependencyAddResult { Cycle = null }; + } + + public async Task RemoveDependencyAsync( + string id, + string dependsOnId, + string actor, + CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + + await using var connection = await ConnectAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + + await GetRequiredTaskAsync(connection, id, cancellationToken, transaction); + + var existingType = await connection.QueryFirstOrDefaultAsync( + """ + SELECT dependency_type FROM dependencies + WHERE task_id = @id AND depends_on_id = @dependsOnId + """, + new { id, dependsOnId, cancellationToken }, + transaction); + + if (existingType is null) + { + throw new ExitException("Dependency does not exist."); + } + + await connection.ExecuteAsync( + "DELETE FROM dependencies WHERE task_id = @id AND depends_on_id = @dependsOnId", + new { id, dependsOnId, cancellationToken }, + transaction); + + await connection.ExecuteAsync( + "UPDATE tasks SET updated_at = @updatedAt WHERE id = @id", + new { updatedAt = now, id, cancellationToken }, + transaction); + + await RecordEventAsync( + connection, + new TaskEvent + { + TaskId = id, + Type = TaskEventTypes.DependencyRemoved, + Actor = actor, + OldValue = null, + NewValue = $"{existingType}:{dependsOnId}", + CreatedAt = now + }, + cancellationToken, + transaction); + + await transaction.CommitAsync(cancellationToken); + } + + public async Task SetParentAsync( + string id, + string? parentId, + string actor, + CancellationToken cancellationToken) + { + var normalizedParentId = string.IsNullOrEmpty(parentId) ? null : parentId; + + if (normalizedParentId == id) + { + throw new ExitException("A task cannot be its own parent."); + } + + var now = timeProvider.GetUtcNow(); + + await using var connection = await ConnectAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + + await GetRequiredTaskAsync(connection, id, cancellationToken, transaction); + + if (normalizedParentId is not null) + { + await GetRequiredTaskAsync(connection, normalizedParentId, cancellationToken, transaction); + } + + var existingParentIdRows = await connection.QueryAsync( + """ + SELECT depends_on_id FROM dependencies + WHERE task_id = @id AND dependency_type = @type + """, + new { id, type = TaskDependencyTypes.ParentChild, cancellationToken }, + transaction); + var existingParentIds = existingParentIdRows.ToList(); + + if (existingParentIds.Count == 1 && existingParentIds[0] == normalizedParentId) + { + await transaction.CommitAsync(cancellationToken); + return new TaskDependencyAddResult { Cycle = null }; + } + + if (existingParentIds.Count == 0 && normalizedParentId is null) + { + await transaction.CommitAsync(cancellationToken); + return new TaskDependencyAddResult { Cycle = null }; + } + + foreach (var existingParentId in existingParentIds) + { + await connection.ExecuteAsync( + """ + DELETE FROM dependencies + WHERE task_id = @id AND depends_on_id = @existingParentId AND dependency_type = @type + """, + new { id, existingParentId, type = TaskDependencyTypes.ParentChild, cancellationToken }, + transaction); + + await RecordEventAsync( + connection, + new TaskEvent + { + TaskId = id, + Type = TaskEventTypes.DependencyRemoved, + Actor = actor, + OldValue = null, + NewValue = $"{TaskDependencyTypes.ParentChild}:{existingParentId}", + CreatedAt = now + }, + cancellationToken, + transaction); + } + + if (normalizedParentId is not null) + { + var existingCount = await connection.ExecuteScalarAsync( + "SELECT COUNT(*) FROM dependencies WHERE task_id = @id AND depends_on_id = @normalizedParentId", + new { id, normalizedParentId, cancellationToken }, + transaction); + + if (existingCount > 0) + { + throw new ExitException("Dependency already exists."); + } + + await connection.ExecuteAsync( + """ + INSERT INTO dependencies ( + task_id, + depends_on_id, + dependency_type, + created_at, + created_by + ) + VALUES ( + @TaskId, + @DependsOnId, + @Type, + @CreatedAt, + @CreatedBy + ) + """, + new TaskDependency + { + TaskId = id, + DependsOnId = normalizedParentId, + Type = TaskDependencyTypes.ParentChild, + CreatedAt = now, + CreatedBy = actor + }, + transaction); + + await RecordEventAsync( + connection, + new TaskEvent + { + TaskId = id, + Type = TaskEventTypes.DependencyAdded, + Actor = actor, + OldValue = null, + NewValue = $"{TaskDependencyTypes.ParentChild}:{normalizedParentId}", + CreatedAt = now + }, + cancellationToken, + transaction); + + var cycle = await FindBlockingCycleAsync(connection, transaction, id, normalizedParentId); + + if (cycle is { Count: > 0 }) + { + throw new ExitException( + $"Setting this parent would create a cycle: {FormatCycle(cycle)}."); + } + } + + await connection.ExecuteAsync( + "UPDATE tasks SET updated_at = @updatedAt WHERE id = @id", + new { updatedAt = now, id, cancellationToken }, + transaction); + + await transaction.CommitAsync(cancellationToken); + + return new TaskDependencyAddResult { Cycle = null }; + } + + public async Task EnsureWorkspaceAsync( + string workspaceDirectory, + CancellationToken cancellationToken) + { + if (fileSystem.FileExists(TaskWorkspace.GetDatabasePath(workspaceDirectory))) + { + return; + } + + if (!fileSystem.DirectoryExists(workspaceDirectory)) + { + fileSystem.CreateDirectory(workspaceDirectory); + } + + await using var connection = await InitializeAsync(workspaceDirectory, cancellationToken); + } + + public async Task> ExportTasksAsync( + CancellationToken cancellationToken) + { + // Every query below runs against a whole table and takes no filter + // parameters, so there is nothing for @-placeholder analysis to key + // on; cancellation is checked up front instead of plumbed through a + // parameter object (see ComputeBlockedAsync). + cancellationToken.ThrowIfCancellationRequested(); + + await using var connection = await ConnectAsync(cancellationToken); + + var taskRows = await connection.QueryAsync( + $"SELECT {TaskItem.Columns} FROM tasks ORDER BY id"); + + var labelRows = await connection.QueryAsync( + "SELECT task_id AS TaskId, label AS Label FROM labels ORDER BY task_id, label"); + var labelsByTask = labelRows + .GroupBy(row => row.TaskId) + .ToDictionary( + group => group.Key, + IReadOnlyList (group) => [.. group.Select(row => row.Label)]); + + var dependencyRows = await connection.QueryAsync( + $""" + SELECT {TaskDependencyRow.Columns} FROM dependencies + ORDER BY task_id, depends_on_id + """); + var dependenciesByTask = dependencyRows + .GroupBy(row => row.TaskId) + .ToDictionary( + group => group.Key, + IReadOnlyList (group) => + [.. group.Select(row => new TaskSyncDependency + { + DependsOnId = row.DependsOnId, + Type = row.Type, + CreatedAt = DateTimeOffset.Parse(row.CreatedAt, CultureInfo.InvariantCulture), + CreatedBy = row.CreatedBy + })]); + + var commentRows = await connection.QueryAsync( + $"SELECT {TaskComment.Columns} FROM comments ORDER BY task_id, created_at, id"); + var commentsByTask = commentRows + .GroupBy(row => row.TaskId) + .ToDictionary( + group => group.Key, + IReadOnlyList (group) => + [.. group.Select(row => new TaskSyncComment + { + Id = row.Id, + Author = row.Author, + Text = row.Text, + CreatedAt = DateTimeOffset.Parse(row.CreatedAt, CultureInfo.InvariantCulture) + })]); + + return taskRows.Select(row => + { + var task = row.ToTaskItem(); + + return new TaskSyncRecord + { + Id = task.Id, + Title = task.Title, + Description = task.Description, + Design = task.Design, + AcceptanceCriteria = task.AcceptanceCriteria, + Notes = task.Notes, + Status = task.Status, + Priority = task.Priority, + Type = task.Type, + Assignee = task.Assignee, + EstimatedMinutes = task.EstimatedMinutes, + DueAt = task.DueAt, + DeferUntil = task.DeferUntil, + CreatedAt = task.CreatedAt, + CreatedBy = task.CreatedBy, + UpdatedAt = task.UpdatedAt, + ClosedAt = task.ClosedAt, + CloseReason = task.CloseReason, + DeletedAt = task.DeletedAt, + DeleteReason = task.DeleteReason, + Labels = labelsByTask.GetValueOrDefault(task.Id, []), + Dependencies = dependenciesByTask.GetValueOrDefault(task.Id, []), + Comments = commentsByTask.GetValueOrDefault(task.Id, []) + }; + }).ToList(); + } + + public async Task ImportTasksAsync( + IReadOnlyList records, + CancellationToken cancellationToken) + { + await using var connection = await ConnectAsync(cancellationToken); + await using var transaction = await connection.BeginTransactionAsync(cancellationToken); + + var applied = 0; + var skipped = 0; + + foreach (var record in records) + { + var existing = await GetTaskAsync(connection, record.Id, cancellationToken, transaction); + + if (existing is not null && existing.UpdatedAt > record.UpdatedAt) + { + skipped++; + continue; + } + + await connection.ExecuteAsync( + """ + INSERT INTO tasks (id, title, description, design, acceptance_criteria, notes, + status, priority, task_type, assignee, estimated_minutes, due_at, defer_until, + created_at, created_by, updated_at, closed_at, close_reason, deleted_at, + delete_reason) + VALUES (@Id, @Title, @Description, @Design, @AcceptanceCriteria, @Notes, + @Status, @Priority, @Type, @Assignee, @EstimatedMinutes, @DueAt, @DeferUntil, + @CreatedAt, @CreatedBy, @UpdatedAt, @ClosedAt, @CloseReason, @DeletedAt, + @DeleteReason) + ON CONFLICT (id) DO UPDATE SET + title = excluded.title, + description = excluded.description, + design = excluded.design, + acceptance_criteria = excluded.acceptance_criteria, + notes = excluded.notes, + status = excluded.status, + priority = excluded.priority, + task_type = excluded.task_type, + assignee = excluded.assignee, + estimated_minutes = excluded.estimated_minutes, + due_at = excluded.due_at, + defer_until = excluded.defer_until, + created_at = excluded.created_at, + created_by = excluded.created_by, + updated_at = excluded.updated_at, + closed_at = excluded.closed_at, + close_reason = excluded.close_reason, + deleted_at = excluded.deleted_at, + delete_reason = excluded.delete_reason + """, + record, + transaction); + + await connection.ExecuteAsync( + "DELETE FROM labels WHERE task_id = @TaskId", + new { TaskId = record.Id, cancellationToken }, + transaction); + + foreach (var label in record.Labels) + { + await connection.ExecuteAsync( + """ + INSERT INTO labels ( + task_id, + label + ) + VALUES ( + @TaskId, + @Label + ) + """, + new { TaskId = record.Id, Label = label, cancellationToken }, + transaction); + } + + await connection.ExecuteAsync( + "DELETE FROM dependencies WHERE task_id = @TaskId", + new { TaskId = record.Id, cancellationToken }, + transaction); + + foreach (var dependency in record.Dependencies) + { + await connection.ExecuteAsync( + """ + INSERT INTO dependencies ( + task_id, + depends_on_id, + dependency_type, + created_at, + created_by + ) + VALUES ( + @TaskId, + @DependsOnId, + @Type, + @CreatedAt, + @CreatedBy + ) + """, + new + { + TaskId = record.Id, + dependency.DependsOnId, + dependency.Type, + dependency.CreatedAt, + dependency.CreatedBy, + cancellationToken + }, + transaction); + } + + await connection.ExecuteAsync( + "DELETE FROM comments WHERE task_id = @TaskId", + new { TaskId = record.Id, cancellationToken }, + transaction); + + foreach (var comment in record.Comments) + { + // comments.id is a database-global AUTOINCREMENT counter, so + // the same numeric id can be assigned independently by two + // clones to comments on different tasks. Reusing the + // exported id verbatim would collide with an unrelated + // comment already occupying that id in this database (from + // an earlier record in this same import or from a task + // outside the imported set); detect that case and let + // SQLite assign a fresh id instead of failing the import. + var idOwner = await connection.ExecuteScalarAsync( + "SELECT task_id FROM comments WHERE id = @Id", + new { comment.Id, cancellationToken }, + transaction); + + if (idOwner is null || idOwner == record.Id) + { + await connection.ExecuteAsync( + """ + INSERT INTO comments ( + id, + task_id, + author, + text, + created_at + ) + VALUES ( + @Id, + @TaskId, + @Author, + @Text, + @CreatedAt + ) + """, + new + { + comment.Id, + TaskId = record.Id, + comment.Author, + comment.Text, + comment.CreatedAt, + cancellationToken + }, + transaction); + } + else + { + await connection.ExecuteAsync( + """ + INSERT INTO comments ( + task_id, + author, + text, + created_at + ) + VALUES ( + @TaskId, + @Author, + @Text, + @CreatedAt + ) + """, + new + { + TaskId = record.Id, + comment.Author, + comment.Text, + comment.CreatedAt, + cancellationToken + }, + transaction); + } + } + + applied++; + } + + await RestoreChildCountersAsync(connection, transaction, cancellationToken); + + await transaction.CommitAsync(cancellationToken); + + return new TaskImportResult(applied, skipped, records.Count); + } + + // Import writes task rows directly and never calls CreateTaskIdAsync, so + // child_counters is never populated for the imported tasks. Without this, + // the next child created for an imported parent collides with a child id + // that already exists (e.g. after "flush, delete db, import", creating a + // second child for the same parent reuses ".1"). Rebuild each parent's + // counter from the highest numeric child suffix present in the tasks + // table, never lowering a counter that is already ahead. + private static async Task RestoreChildCountersAsync( + SqliteConnection connection, + DbTransaction transaction, + CancellationToken cancellationToken) + { + var taskIds = await connection.QueryAsync( + "SELECT id FROM tasks", + transaction: transaction); + + var maxChildByParent = new Dictionary(); + + foreach (var id in taskIds) + { + var dotIndex = id.LastIndexOf('.'); + + if (dotIndex <= 0 + || !long.TryParse( + id.AsSpan(dotIndex + 1), + NumberStyles.None, + CultureInfo.InvariantCulture, + out var childNumber)) + { + continue; + } + + var parentId = id[..dotIndex]; + + if (!maxChildByParent.TryGetValue(parentId, out var current) || childNumber > current) + { + maxChildByParent[parentId] = childNumber; + } + } + + foreach (var (parentId, lastChild) in maxChildByParent) + { + await connection.ExecuteAsync( + """ + INSERT INTO child_counters (parent_id, last_child) + VALUES (@parentId, @lastChild) + ON CONFLICT (parent_id) DO UPDATE SET last_child = MAX(last_child, excluded.last_child) + """, + new { parentId, lastChild, cancellationToken }, + transaction); + } + } + + public async Task CheckIntegrityAsync( + CancellationToken cancellationToken) + { + // Every query below except the tombstoned-parent-edge check runs + // against a whole table and takes no filter parameters, so there is + // nothing for @-placeholder analysis to key on; cancellation is + // checked up front instead of plumbed through a parameter object + // (see ComputeBlockedAsync). + cancellationToken.ThrowIfCancellationRequested(); + + await using var connection = await ConnectAsync(cancellationToken); + + var quickCheckMessage = await connection.ExecuteScalarAsync("PRAGMA quick_check;") + ?? "ok"; + + var orphanDependencyRows = await connection.QueryAsync( + """ + SELECT d.task_id AS TaskId, d.depends_on_id AS DependsOnId, d.dependency_type AS Type + FROM dependencies d + WHERE NOT EXISTS (SELECT 1 FROM tasks t WHERE t.id = d.task_id) + OR NOT EXISTS (SELECT 1 FROM tasks t WHERE t.id = d.depends_on_id) + ORDER BY d.task_id, d.depends_on_id + """); + var orphanDependencies = orphanDependencyRows + .Select(row => new TaskDependencyReference(row.TaskId, row.DependsOnId)) + .ToList(); + + var orphanLabelRows = await connection.QueryAsync( + """ + SELECT l.task_id AS TaskId, l.label AS Label + FROM labels l + WHERE NOT EXISTS (SELECT 1 FROM tasks t WHERE t.id = l.task_id) + ORDER BY l.task_id, l.label + """); + var orphanLabels = orphanLabelRows + .Select(row => new TaskOrphanLabel(row.TaskId, row.Label)) + .ToList(); + + var orphanCommentRows = await connection.QueryAsync( + """ + SELECT c.task_id AS TaskId, c.id AS CommentId + FROM comments c + WHERE NOT EXISTS (SELECT 1 FROM tasks t WHERE t.id = c.task_id) + ORDER BY c.task_id, c.id + """); + var orphanComments = orphanCommentRows + .Select(row => new TaskOrphanComment(row.TaskId, row.CommentId)) + .ToList(); + + var tombstonedParentEdgeRows = await connection.QueryAsync( + """ + SELECT d.task_id AS TaskId, d.depends_on_id AS DependsOnId, d.dependency_type AS Type + FROM dependencies d + JOIN tasks t ON t.id = d.depends_on_id + WHERE d.dependency_type = @parentChild AND t.status = @tombstone + ORDER BY d.task_id + """, + new + { + parentChild = TaskDependencyTypes.ParentChild, + tombstone = TaskStates.Tombstone, + cancellationToken + }); + var tombstonedParentEdges = tombstonedParentEdgeRows + .Select(row => new TaskDependencyReference(row.TaskId, row.DependsOnId)) + .ToList(); + + return new TaskIntegrityReport + { + QuickCheckOk = quickCheckMessage == "ok", + QuickCheckMessage = quickCheckMessage, + OrphanDependencies = orphanDependencies, + OrphanLabels = orphanLabels, + OrphanComments = orphanComments, + TombstonedParentEdges = tombstonedParentEdges + }; + } + + // Searches the blocking-dependency graph for a path from dependsOnId + // back to id. Combined with the edge just inserted (id -> dependsOnId), + // such a path closes a cycle. Runs inside the same transaction as the + // insert so the check sees a consistent snapshot. + private static async Task?> FindBlockingCycleAsync( + SqliteConnection connection, + DbTransaction transaction, + string id, + string dependsOnId) + { + var edgeRows = await connection.QueryAsync( + """ + SELECT task_id AS TaskId, depends_on_id AS DependsOnId, dependency_type AS Type + FROM dependencies + """, + transaction: transaction); + var edges = edgeRows.Where(e => TaskDependencyTypes.IsBlocking(e.Type)).ToList(); + + var adjacency = edges + .GroupBy(e => e.TaskId) + .ToDictionary( + g => g.Key, + g => g.Select(e => e.DependsOnId).OrderBy(x => x, StringComparer.Ordinal).ToList()); + + var predecessor = new Dictionary { [dependsOnId] = null }; + var queue = new Queue(); + queue.Enqueue(dependsOnId); + + while (queue.TryDequeue(out var current)) + { + if (current == id) + { + var path = new List(); + string? node = current; + + while (node is not null) + { + path.Add(node); + node = predecessor[node]; + } + + path.Reverse(); + + // path runs dependsOnId..id; drop the trailing id (already the + // list's head) and close the loop by repeating the dependent + // task at the end, so the cycle both starts and ends at id. + var cycle = new List { id }; + cycle.AddRange(path.Take(path.Count - 1)); + cycle.Add(id); + + return cycle; + } + + if (!adjacency.TryGetValue(current, out var neighbors)) + { + continue; + } + + foreach (var next in neighbors) + { + if (!predecessor.ContainsKey(next)) + { + predecessor[next] = current; + queue.Enqueue(next); + } + } + } + + return null; + } + + // Formats a cycle as returned by FindBlockingCycleAsync, which already + // starts and ends at the dependent task. + private static string FormatCycle(IReadOnlyList cycle) => string.Join(" -> ", cycle); + + // Builds a plain ADO.NET-ready SQL fragment and parameter map. The + // "statuses" filter expands its own IN-list placeholders (rather than + // relying on Dapper's array-parameter rewriting) because the resulting + // query executes through plain ADO.NET, not Dapper: see the comment on + // ExecuteTaskQueryAsync. + private static (string WhereClause, Dictionary Parameters) BuildTaskFilterClause( + TaskFilter filter) + { + var conditions = new List(); + var parameters = new Dictionary(); + + if (filter.Statuses is { Length: > 0 }) + { + var placeholders = new string[filter.Statuses.Length]; + + for (var i = 0; i < filter.Statuses.Length; i++) + { + var parameterName = $"status{i}"; + parameters[parameterName] = TaskStates.Normalize(filter.Statuses[i]); + placeholders[i] = $"@{parameterName}"; + } + + conditions.Add($"status IN ({string.Join(", ", placeholders)})"); + } + else if (!filter.IncludeAll) + { + parameters["closedStatus"] = TaskStates.Closed; + parameters["tombstoneStatus"] = TaskStates.Tombstone; + conditions.Add("status NOT IN (@closedStatus, @tombstoneStatus)"); + } + + if (filter.ExcludeTombstones) + { + parameters["tombstone"] = TaskStates.Tombstone; + conditions.Add("status != @tombstone"); + } + + if (!string.IsNullOrEmpty(filter.Type)) + { + parameters["type"] = TaskTypes.Normalize(filter.Type); + conditions.Add("task_type = @type"); + } + + if (filter.Priority is { } priority) + { + parameters["priority"] = priority; + conditions.Add("priority = @priority"); + } + + if (filter.PriorityMin is { } priorityMin && filter.PriorityMax is { } priorityMax) + { + parameters["priorityMin"] = priorityMin; + parameters["priorityMax"] = priorityMax; + conditions.Add("priority BETWEEN @priorityMin AND @priorityMax"); + } + + if (filter.Unassigned) + { + conditions.Add("(assignee IS NULL OR assignee = '')"); + } + else if (!string.IsNullOrEmpty(filter.Assignee)) + { + parameters["assignee"] = filter.Assignee; + conditions.Add("assignee = @assignee"); + } + + if (filter.Labels is { Length: > 0 }) + { + for (var i = 0; i < filter.Labels.Length; i++) + { + var parameterName = $"label{i}"; + parameters[parameterName] = filter.Labels[i].Trim().ToLowerInvariant(); + conditions.Add( + $"""EXISTS (SELECT 1 FROM labels WHERE task_id = tasks.id AND label = @{parameterName})"""); + } + } + + if (!string.IsNullOrEmpty(filter.Text)) + { + parameters["text"] = EscapeLikeText(filter.Text); + conditions.Add( + """ + (LOWER(title) LIKE '%' || LOWER(@text) || '%' ESCAPE '\' OR + LOWER(description) LIKE '%' || LOWER(@text) || '%' ESCAPE '\' OR + LOWER(design) LIKE '%' || LOWER(@text) || '%' ESCAPE '\' OR + LOWER(acceptance_criteria) LIKE '%' || LOWER(@text) || '%' ESCAPE '\' OR + LOWER(notes) LIKE '%' || LOWER(@text) || '%' ESCAPE '\' OR + EXISTS (SELECT 1 FROM comments WHERE task_id = tasks.id + AND LOWER(text) LIKE '%' || LOWER(@text) || '%' ESCAPE '\')) + """); + } + + if (filter.UpdatedBefore is { } updatedBefore) + { + parameters["updatedBefore"] = updatedBefore; + conditions.Add("updated_at <= @updatedBefore"); + } + + if (filter.DeferredVisibleAt is { } visibleAt) + { + parameters["visibleAt"] = visibleAt; + conditions.Add("(defer_until IS NULL OR defer_until <= @visibleAt)"); + } + + var whereClause = conditions.Count > 0 ? $" WHERE {string.Join(" AND ", conditions)}" : ""; + + return (whereClause, parameters); + } + + private static string OrderByClause(TaskOrdering ordering) => ordering switch + { + TaskOrdering.PriorityCreatedId => " ORDER BY priority ASC, created_at ASC, id ASC", + TaskOrdering.UpdatedAtAscending => " ORDER BY updated_at ASC, id ASC", + TaskOrdering.ReadyPick => + " ORDER BY CASE WHEN priority <= 1 THEN 0 ELSE 1 END, created_at ASC, id ASC", + _ => throw new ArgumentOutOfRangeException(nameof(ordering), ordering, "Unknown task ordering.") + }; + + /// + /// Escapes the LIKE wildcard characters '%' and '_' (and the escape + /// character itself) so search text is matched literally, other than the + /// wildcards callers wrap around it. + /// + private static string EscapeLikeText(string value) + => value.Replace("\\", "\\\\").Replace("%", "\\%").Replace("_", "\\_"); + + private static int StatusOrderIndex(TaskCount statusCount) + { + var index = Array.IndexOf(StatusOrder, statusCount.Value); + + return index < 0 ? StatusOrder.Length : index; + } + + private static void AddBlocker( + Dictionary> blocked, + string taskId, + string blocker) + { + if (!blocked.TryGetValue(taskId, out var blockers)) + { + blockers = []; + blocked[taskId] = blockers; + } + + blockers.Add(blocker); + } + + private static string CreateIdSuffix(string seed, long taskCount, int attempt) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes($"{seed}|{taskCount}|{attempt}")); + var length = MinIdLength + attempt / 3; + var suffix = new char[length]; + + for (var i = 0; i < length; i++) + { + suffix[i] = IdAlphabet[hash[i] % IdAlphabet.Length]; + } + + return new string(suffix); + } + + private static async Task OpenAsync( + string databasePath, + CancellationToken cancellationToken) + { + // Pooling would keep the database file open after the connection is + // disposed; a CLI process runs one command and exits, so it gains + // nothing from the pool. + var connection = new SqliteConnection($"Data Source={databasePath};Pooling=False"); + + await connection.OpenAsync(cancellationToken); + await connection.ExecuteAsync( + "PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;"); + + return connection; + } + + // These nested row types are internal, not private: Dapper.AOT's + // generated interceptors live outside TaskStore and cannot reference a + // private nested type, so a private row type would silently fall back to + // Dapper's reflection-emit deserializer. + internal sealed class TaskRow + { + public required string Id { get; init; } + public required string Title { get; init; } + public string Description { get; init; } = ""; + public string Design { get; init; } = ""; + public string AcceptanceCriteria { get; init; } = ""; + public string Notes { get; init; } = ""; + public string Status { get; init; } = TaskStates.Open; + public int Priority { get; init; } + public string Type { get; init; } = TaskTypes.Task; + public string? Assignee { get; init; } + public int? EstimatedMinutes { get; init; } + public string? DueAt { get; init; } + public string? DeferUntil { get; init; } + public required string CreatedAt { get; init; } + public string CreatedBy { get; init; } = ""; + public required string UpdatedAt { get; init; } + public string? ClosedAt { get; init; } + public string CloseReason { get; init; } = ""; + public string? DeletedAt { get; init; } + public string DeleteReason { get; init; } = ""; + + public TaskItem ToTaskItem() => new() + { + Id = Id, + Title = Title, + Description = Description, + Design = Design, + AcceptanceCriteria = AcceptanceCriteria, + Notes = Notes, + Status = Status, + Priority = Priority, + Type = Type, + Assignee = Assignee, + EstimatedMinutes = EstimatedMinutes, + DueAt = ParseDate(DueAt), + DeferUntil = ParseDate(DeferUntil), + CreatedAt = ParseDate(CreatedAt)!.Value, + CreatedBy = CreatedBy, + UpdatedAt = ParseDate(UpdatedAt)!.Value, + ClosedAt = ParseDate(ClosedAt), + CloseReason = CloseReason, + DeletedAt = ParseDate(DeletedAt), + DeleteReason = DeleteReason + }; + + private static DateTimeOffset? ParseDate(string? value) + => value is null + ? null + : DateTimeOffset.Parse(value, CultureInfo.InvariantCulture); + } + + /// + /// Column ordinals for , captured once per reader + /// and reused for every row so reads + /// plain ADO.NET fields without any per-row reflection. + /// + private readonly struct TaskRowColumns( + int id, int title, int description, int design, int acceptanceCriteria, int notes, + int status, int priority, int type, int assignee, int estimatedMinutes, int dueAt, + int deferUntil, int createdAt, int createdBy, int updatedAt, int closedAt, + int closeReason, int deletedAt, int deleteReason) + { + public static TaskRowColumns From(DbDataReader reader) => new( + reader.GetOrdinal("Id"), + reader.GetOrdinal("Title"), + reader.GetOrdinal("Description"), + reader.GetOrdinal("Design"), + reader.GetOrdinal("AcceptanceCriteria"), + reader.GetOrdinal("Notes"), + reader.GetOrdinal("Status"), + reader.GetOrdinal("Priority"), + reader.GetOrdinal("Type"), + reader.GetOrdinal("Assignee"), + reader.GetOrdinal("EstimatedMinutes"), + reader.GetOrdinal("DueAt"), + reader.GetOrdinal("DeferUntil"), + reader.GetOrdinal("CreatedAt"), + reader.GetOrdinal("CreatedBy"), + reader.GetOrdinal("UpdatedAt"), + reader.GetOrdinal("ClosedAt"), + reader.GetOrdinal("CloseReason"), + reader.GetOrdinal("DeletedAt"), + reader.GetOrdinal("DeleteReason")); + + public TaskRow Read(DbDataReader reader) => new() + { + Id = reader.GetString(id), + Title = reader.GetString(title), + Description = reader.GetString(description), + Design = reader.GetString(design), + AcceptanceCriteria = reader.GetString(acceptanceCriteria), + Notes = reader.GetString(notes), + Status = reader.GetString(status), + Priority = reader.GetInt32(priority), + Type = reader.GetString(type), + Assignee = reader.IsDBNull(assignee) ? null : reader.GetString(assignee), + EstimatedMinutes = reader.IsDBNull(estimatedMinutes) + ? null + : reader.GetInt32(estimatedMinutes), + DueAt = reader.IsDBNull(dueAt) ? null : reader.GetString(dueAt), + DeferUntil = reader.IsDBNull(deferUntil) ? null : reader.GetString(deferUntil), + CreatedAt = reader.GetString(createdAt), + CreatedBy = reader.GetString(createdBy), + UpdatedAt = reader.GetString(updatedAt), + ClosedAt = reader.IsDBNull(closedAt) ? null : reader.GetString(closedAt), + CloseReason = reader.GetString(closeReason), + DeletedAt = reader.IsDBNull(deletedAt) ? null : reader.GetString(deletedAt), + DeleteReason = reader.GetString(deleteReason) + }; + } + + internal sealed class TaskGraphNode + { + public required string Id { get; init; } + public required string Status { get; init; } + public required string Type { get; init; } + } + + internal sealed class TaskGraphEdge + { + public required string TaskId { get; init; } + public required string DependsOnId { get; init; } + public required string Type { get; init; } + } + + /// + /// A group-by-priority row, kept numeric so it can be formatted as + /// P0..P4. + /// + internal sealed class PriorityCountRow + { + public required int Priority { get; init; } + public required int Count { get; init; } + } + + /// + /// One group-by-value row: the grouped value and how many tasks fall + /// into it. + /// + internal sealed class CountRow + { + public required string Value { get; init; } + public required int Count { get; init; } + } + + /// + /// A label's name and how many non-tombstone tasks carry it. + /// + internal sealed class LabelCountRow + { + public required string Label { get; init; } + public required int Count { get; init; } + } + + /// + /// A dependency edge's task pair and type, for the blocking-cycle walk in + /// and the integrity checks in + /// . + /// + internal sealed class DependencyEdgeRow + { + public required string TaskId { get; init; } + public required string DependsOnId { get; init; } + public required string Type { get; init; } + } + + /// + /// A comment's task ID and row ID, for the orphan-comment check in + /// . + /// + internal sealed class CommentOrphanRow + { + public required string TaskId { get; init; } + public required long CommentId { get; init; } + } + + /// + /// A task-label pair, for grouping labels by task in + /// . + /// + internal sealed class TaskLabelRow + { + public required string TaskId { get; init; } + public required string Label { get; init; } + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskStoreSchema.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskStoreSchema.cs new file mode 100644 index 00000000000..7b6aa4a262f --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskStoreSchema.cs @@ -0,0 +1,93 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +internal static class TaskStoreSchema +{ + public const int CurrentVersion = 1; + + /// + /// The complete schema. Statements are idempotent so applying them to an + /// existing database is non-destructive. + /// + public const string Create = + """ + CREATE TABLE IF NOT EXISTS tasks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL CHECK (length(title) <= 500), + description TEXT NOT NULL DEFAULT '', + design TEXT NOT NULL DEFAULT '', + acceptance_criteria TEXT NOT NULL DEFAULT '', + notes TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'open', + priority INTEGER NOT NULL DEFAULT 2 CHECK (priority BETWEEN 0 AND 4), + task_type TEXT NOT NULL DEFAULT 'task', + assignee TEXT, + estimated_minutes INTEGER, + due_at TEXT, + defer_until TEXT, + created_at TEXT NOT NULL, + created_by TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL, + closed_at TEXT, + close_reason TEXT NOT NULL DEFAULT '', + deleted_at TEXT, + delete_reason TEXT NOT NULL DEFAULT '' + ); + + CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks (status); + CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks (priority); + CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks (assignee); + CREATE INDEX IF NOT EXISTS idx_tasks_updated_at ON tasks (updated_at); + + CREATE TABLE IF NOT EXISTS dependencies ( + task_id TEXT NOT NULL REFERENCES tasks (id) ON DELETE CASCADE, + depends_on_id TEXT NOT NULL, + dependency_type TEXT NOT NULL DEFAULT 'blocks', + created_at TEXT NOT NULL, + created_by TEXT NOT NULL DEFAULT '', + PRIMARY KEY (task_id, depends_on_id) + ); + + CREATE INDEX IF NOT EXISTS idx_dependencies_depends_on ON dependencies (depends_on_id); + + CREATE TABLE IF NOT EXISTS labels ( + task_id TEXT NOT NULL REFERENCES tasks (id) ON DELETE CASCADE, + label TEXT NOT NULL, + PRIMARY KEY (task_id, label) + ); + + CREATE INDEX IF NOT EXISTS idx_labels_label ON labels (label); + + CREATE TABLE IF NOT EXISTS comments ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL REFERENCES tasks (id) ON DELETE CASCADE, + author TEXT NOT NULL, + text TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_comments_task ON comments (task_id); + + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id TEXT NOT NULL REFERENCES tasks (id) ON DELETE CASCADE, + event_type TEXT NOT NULL, + actor TEXT NOT NULL DEFAULT '', + old_value TEXT, + new_value TEXT, + comment TEXT, + created_at TEXT NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_events_task ON events (task_id); + + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS child_counters ( + parent_id TEXT PRIMARY KEY REFERENCES tasks (id) ON DELETE CASCADE, + last_child INTEGER NOT NULL DEFAULT 0 + ); + """; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskSummaryResult.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskSummaryResult.cs new file mode 100644 index 00000000000..5abf58ea82e --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskSummaryResult.cs @@ -0,0 +1,7 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// The subset of a task's columns returned by the structured (JSON) output of +/// the list-style task query commands. +/// +internal sealed record TaskSummaryResult(string Id, int Priority, string Type, string Status, string Title); diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskSyncComment.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskSyncComment.cs new file mode 100644 index 00000000000..917eb79552a --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskSyncComment.cs @@ -0,0 +1,12 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// One comment embedded in a . +/// +internal sealed class TaskSyncComment +{ + public long Id { get; init; } + public required string Author { get; init; } + public required string Text { get; init; } + public required DateTimeOffset CreatedAt { get; init; } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskSyncDependency.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskSyncDependency.cs new file mode 100644 index 00000000000..9971a2dca57 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskSyncDependency.cs @@ -0,0 +1,12 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// One outgoing dependency edge embedded in a . +/// +internal sealed class TaskSyncDependency +{ + public required string DependsOnId { get; init; } + public required string Type { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + public string CreatedBy { get; init; } = ""; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskSyncRecord.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskSyncRecord.cs new file mode 100644 index 00000000000..6f5cbff47b1 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskSyncRecord.cs @@ -0,0 +1,47 @@ +using System.Text.Json.Serialization; + +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// One line of tasks.jsonl: a task with its labels, outgoing dependencies, +/// and comments embedded, so the line alone recreates the task. Represents +/// both live tasks and tombstones. +/// +internal sealed class TaskSyncRecord +{ + public required string Id { get; init; } + public required string Title { get; init; } + public string Description { get; init; } = ""; + public string Design { get; init; } = ""; + public string AcceptanceCriteria { get; init; } = ""; + public string Notes { get; init; } = ""; + public required string Status { get; init; } + public int Priority { get; init; } + public required string Type { get; init; } + public string? Assignee { get; init; } + public int? EstimatedMinutes { get; init; } + public DateTimeOffset? DueAt { get; init; } + public DateTimeOffset? DeferUntil { get; init; } + public required DateTimeOffset CreatedAt { get; init; } + public string CreatedBy { get; init; } = ""; + public required DateTimeOffset UpdatedAt { get; init; } + public DateTimeOffset? ClosedAt { get; init; } + public string CloseReason { get; init; } = ""; + public DateTimeOffset? DeletedAt { get; init; } + public string DeleteReason { get; init; } = ""; + public IReadOnlyList Labels { get; init; } = []; + public IReadOnlyList Dependencies { get; init; } = []; + public IReadOnlyList Comments { get; init; } = []; +} + +/// +/// AOT source-generated (de)serialization for tasks.jsonl. Kept separate +/// from JsonSourceGenerationContext, whose options are indented for +/// pretty-printed `--output json` results: JSONL requires exactly one +/// compact JSON object per line. Also serializes , +/// which shares tasks.jsonl with task records as its own line per key. +/// +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +[JsonSerializable(typeof(TaskSyncRecord))] +[JsonSerializable(typeof(TaskConfigEntry))] +internal partial class TaskSyncJsonContext : JsonSerializerContext; diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskTypes.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskTypes.cs new file mode 100644 index 00000000000..e212ede0e68 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskTypes.cs @@ -0,0 +1,19 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// The well-known task types. Custom lowercase types are accepted and +/// round-tripped verbatim. +/// +internal static class TaskTypes +{ + public const string Task = "task"; + public const string Bug = "bug"; + public const string Feature = "feature"; + public const string Epic = "epic"; + public const string Chore = "chore"; + public const string Docs = "docs"; + public const string Question = "question"; + + public static string Normalize(string type) + => type.Trim().ToLowerInvariant(); +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskUpdate.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskUpdate.cs new file mode 100644 index 00000000000..b8ba489b6e5 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskUpdate.cs @@ -0,0 +1,64 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// The parameters for . Each field +/// pairs a value with a "given" flag so the store can tell "not passed" apart +/// from "passed as empty", matching the CLI's per-option update semantics. +/// +internal sealed record TaskUpdate +{ + public required string Actor { get; init; } + + public string? Title { get; init; } + public bool TitleGiven { get; init; } + + public string? Description { get; init; } + public bool DescriptionGiven { get; init; } + + /// + /// The normalized target status. Setting + /// or here throws + /// ; use CloseTaskAsync, ReopenTaskAsync, or + /// DeleteTaskAsync instead. + /// + public string? Status { get; init; } + public bool StatusGiven { get; init; } + + public int? Priority { get; init; } + public bool PriorityGiven { get; init; } + + public string? Type { get; init; } + public bool TypeGiven { get; init; } + + public string? Assignee { get; init; } + public bool AssigneeGiven { get; init; } + + public string? Notes { get; init; } + public bool NotesGiven { get; init; } + + public string? Design { get; init; } + public bool DesignGiven { get; init; } + + public string? AcceptanceCriteria { get; init; } + public bool AcceptanceCriteriaGiven { get; init; } + + public DateTimeOffset? DueAt { get; init; } + public bool DueAtGiven { get; init; } + + public DateTimeOffset? DeferUntil { get; init; } + public bool DeferUntilGiven { get; init; } + + public int? EstimatedMinutes { get; init; } + public bool EstimatedMinutesGiven { get; init; } +} + +/// +/// The outcome of . +/// +internal sealed record TaskUpdateResult +{ + /// + /// The database column names that changed value. + /// + public IReadOnlyList ChangedFields { get; init; } = []; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskWorkspace.cs b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskWorkspace.cs new file mode 100644 index 00000000000..2a7eb2f0220 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Services/Tasks/TaskWorkspace.cs @@ -0,0 +1,112 @@ +namespace ChilliCream.Nitro.CommandLine.Services.Tasks; + +/// +/// Defines the on-disk layout of a task workspace and shared helpers for it. +/// +internal static class TaskWorkspace +{ + public const string RootDirectoryName = ".nitro"; + public const string TasksDirectoryName = "tasks"; + public const string DatabaseFileName = "tasks.db"; + public const string JsonlFileName = "tasks.jsonl"; + public const string GitIgnoreFileName = ".gitignore"; + public const string FallbackPrefix = "task"; + public const string DisplayPath = RootDirectoryName + "/" + TasksDirectoryName; + + private const int MaxPrefixLength = 64; + + /// + /// Ignores only the SQLite database files, which are local state; the + /// JSONL export and this file itself are the tracker's committed, + /// durable state and must not be ignored. + /// + public const string GitIgnoreContent = + """ + # The task database is local state; tasks.jsonl is the source of truth in git. + tasks.db + tasks.db-wal + tasks.db-shm + """; + + public static string GetDirectory(string baseDirectory) + => Path.Combine(baseDirectory, RootDirectoryName, TasksDirectoryName); + + public static string GetDatabasePath(string workspaceDirectory) + => Path.Combine(workspaceDirectory, DatabaseFileName); + + public static string GetJsonlPath(string workspaceDirectory) + => Path.Combine(workspaceDirectory, JsonlFileName); + + /// + /// Finds the nearest workspace directory at or above the given directory. + /// Returns null when no workspace exists. + /// + public static string? Find(IFileSystem fileSystem, string startDirectory) + { + for (var directory = startDirectory; + !string.IsNullOrEmpty(directory); + directory = Path.GetDirectoryName(directory)) + { + var workspaceDirectory = GetDirectory(directory); + + if (fileSystem.FileExists(GetDatabasePath(workspaceDirectory))) + { + return workspaceDirectory; + } + } + + return null; + } + + /// + /// Finds the nearest workspace directory at or above the given directory + /// that has either a task database or a committed tasks.jsonl. Returns + /// null when neither exists. + /// + public static string? FindDatabaseOrJsonl(IFileSystem fileSystem, string startDirectory) + { + for (var directory = startDirectory; + !string.IsNullOrEmpty(directory); + directory = Path.GetDirectoryName(directory)) + { + var workspaceDirectory = GetDirectory(directory); + + if (fileSystem.FileExists(GetDatabasePath(workspaceDirectory)) + || fileSystem.FileExists(GetJsonlPath(workspaceDirectory))) + { + return workspaceDirectory; + } + } + + return null; + } + + /// + /// Normalizes a task ID prefix to lowercase letters, digits, hyphens, and + /// underscores. Returns when nothing remains. + /// + public static string NormalizePrefix(string value) + { + Span buffer = stackalloc char[MaxPrefixLength]; + var length = 0; + + foreach (var c in value) + { + if (length == MaxPrefixLength) + { + break; + } + + var lower = char.ToLowerInvariant(c); + + if (lower is >= 'a' and <= 'z' or >= '0' and <= '9' or '-' or '_') + { + buffer[length++] = lower; + } + } + + var trimmed = buffer[..length].Trim("-_"); + + return trimmed.IsEmpty ? FallbackPrefix : trimmed.ToString(); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Board/BoardDataLoader.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Board/BoardDataLoader.cs new file mode 100644 index 00000000000..8939c0a0ff7 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Board/BoardDataLoader.cs @@ -0,0 +1,78 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Tui.Board; + +/// +/// Evaluates a board column against the task store: translates its +/// declarative filter into a query, applies the +/// computed ready/blocked/deferred semantics the filter cannot express +/// alone, then sorts and caps the result. Issues no SQL of its own. +/// +internal sealed class BoardDataLoader(ITaskStore store, TimeProvider timeProvider) +{ + /// + /// Loads the tasks currently matching one column. + /// + public async Task> LoadColumnAsync( + ColumnDefinition column, + CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + var isUnassigned = IsUnassigned(column.Assignee); + + var filter = new TaskFilter + { + Statuses = column.Statuses, + IncludeAll = column.ComputedFilter == ColumnComputedFilter.Blocked, + Assignee = isUnassigned ? null : column.Assignee, + Unassigned = isUnassigned, + Labels = column.Labels, + ExcludeBlocked = column.ComputedFilter == ColumnComputedFilter.Ready, + DeferredVisibleAt = column.ComputedFilter == ColumnComputedFilter.Ready ? now : null + }; + + var tasks = (IEnumerable)await store.QueryTasksAsync(filter, cancellationToken); + + if (column.ComputedFilter == ColumnComputedFilter.Blocked) + { + var blocked = await store.ComputeBlockedAsync(cancellationToken); + tasks = tasks.Where(t => + !TaskStates.IsTerminal(t.Status) + && (t.Status == TaskStates.Blocked || blocked.ContainsKey(t.Id))); + } + else if (column.ComputedFilter == ColumnComputedFilter.Deferred) + { + tasks = tasks.Where(t => + t.Status == TaskStates.Deferred || (t.DeferUntil is { } deferUntil && deferUntil > now)); + } + + if (column.Types is { Length: > 0 } types) + { + tasks = tasks.Where(t => types.Contains(t.Type)); + } + + if (column.Priorities is { Length: > 0 } priorities) + { + tasks = tasks.Where(t => priorities.Contains(t.Priority)); + } + + var sorted = Sort(tasks, column.Sort); + + return column.Limit is { } limit ? sorted.Take(limit).ToList() : sorted.ToList(); + } + + private static bool IsUnassigned(string? assignee) + => string.Equals(assignee, "unassigned", StringComparison.OrdinalIgnoreCase); + + private static IEnumerable Sort(IEnumerable tasks, BoardColumnSort sort) + => sort switch + { + BoardColumnSort.RecentFirst => tasks + .OrderByDescending(t => t.ClosedAt ?? t.UpdatedAt) + .ThenBy(t => t.Id, StringComparer.Ordinal), + _ => tasks + .OrderBy(t => t.Priority) + .ThenBy(t => t.CreatedAt) + .ThenBy(t => t.Id, StringComparer.Ordinal) + }; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Board/BoardDetailMode.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Board/BoardDetailMode.cs new file mode 100644 index 00000000000..ee57cbcfb07 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Board/BoardDetailMode.cs @@ -0,0 +1,111 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Details; +using ChilliCream.Nitro.CommandLine.Tui.Input; +using ChilliCream.Nitro.CommandLine.Tui.Shell; +using Spectre.Console.Rendering; +using CursorDirection = ChilliCream.Nitro.CommandLine.Tui.Input.CursorDirection; + +namespace ChilliCream.Nitro.CommandLine.Tui.Board; + +/// +/// The full-screen task detail the shell switches to +/// when Enter is pressed on the board: renders one task through +/// and , the same +/// pair SearchMode's detail pane uses, with the body scrollable via +/// the global cursor and edge gestures. +/// +/// +/// Opening the mode on a task, driven directly by the shell the same way +/// DependencyTreeView.EnterOnTask is, is not reachable through +/// . +/// +internal sealed class BoardDetailMode : ITuiMode +{ + private readonly TaskDetailModel _model; + private readonly TaskDetailView _view; + + public BoardDetailMode(ITaskStore store) + { + ArgumentNullException.ThrowIfNull(store); + + _model = new TaskDetailModel(store); + _view = new TaskDetailView(_model); + } + + /// + /// Escape's binding mirrors the global table's (leave the mode) and + /// exists only to carry a footer hint: detail has no other bindings of + /// its own, scroll and edit come from the global table. + /// + public KeyMap? KeyMap { get; } = new KeyMap( + [ + new KeyBinding( + new KeyChord(ConsoleKey.Escape, ConsoleModifiers.None, ''), + () => new TuiMessage.Back(), + new KeyHint("esc", "back")) + ]); + + /// + public string? SelectedTaskId => _model.CurrentTaskId; + + /// + public void OnEnter() + { + } + + /// + public void OnResize(int width, int height) + { + } + + /// + public IReadOnlyList Handle(TuiMessage message) + { + switch (message) + { + case TuiMessage.MoveCursor(CursorDirection.Down): + _view.ScrollDown(); + return []; + + case TuiMessage.MoveCursor(CursorDirection.Up): + _view.ScrollUp(); + return []; + + case TuiMessage.MoveToEdge(EdgeTarget.Top): + _view.ScrollToTop(); + return []; + + case TuiMessage.MoveToEdge(EdgeTarget.Bottom): + _view.ScrollToBottom(); + return []; + + case TuiMessage.RefreshRequested: + Reload(); + return []; + + default: + return []; + } + } + + /// + /// Loads through the store, replacing whichever + /// task was previously loaded. + /// + public void OpenOnTask(string taskId) + { + ArgumentNullException.ThrowIfNull(taskId); + _model.LoadAsync(taskId, CancellationToken.None).GetAwaiter().GetResult(); + } + + /// + public IRenderable Render(int width, int height) => _view.Render(width, height, focused: true); + + private void Reload() + { + if (_model.CurrentTaskId is { } id) + { + _model.LoadAsync(id, CancellationToken.None).GetAwaiter().GetResult(); + } + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Board/BoardLayout.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Board/BoardLayout.cs new file mode 100644 index 00000000000..fb9ab83c2e8 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Board/BoardLayout.cs @@ -0,0 +1,221 @@ +namespace ChilliCream.Nitro.CommandLine.Tui.Board; + +/// +/// The arrangement a resolves to for one frame. +/// +internal enum BoardLayoutKind +{ + /// Every column is rendered side by side at equal width. + Grid, + + /// Only the focused column is rendered, at the full content width. + Maximized, + + /// + /// Columns are stacked vertically, each sharing an equal slice of the + /// available height. On frames too short to give every column a usable + /// slice, the focused column expands instead and the rest collapse to a + /// single title line. + /// + Stacked +} + +/// +/// One column's computed slot within a . +/// +/// The column's width in cells. +/// The column's height in cells. +/// +/// Whether the column renders its full panel, as opposed to a collapsed +/// title line. +/// +internal readonly record struct BoardColumnLayout(int Width, int Height, bool Expanded); + +/// +/// The layout chosen for one frame: which applies +/// and the slot each column renders into, indexed the same as the board's +/// column list. +/// +internal sealed class BoardLayoutDecision +{ + /// + /// The arrangement this decision resolved to. + /// + public required BoardLayoutKind Kind { get; init; } + + /// + /// Each column's computed slot, indexed the same as the board's column list. + /// + public required IReadOnlyList Columns { get; init; } +} + +/// +/// Computes how a board's columns are arranged for one frame: a pure function +/// of terminal size, column count, focus, and the maximize toggle, with no +/// dependency on rendering or a console. +/// +internal static class BoardLayout +{ + /// + /// The minimum per-column content width below which columns switch from + /// a side-by-side grid to a vertically stacked layout. + /// + private const int StackedWidthThreshold = 24; + + /// + /// The height, in rows, a collapsed column takes in + /// 's too-short-to-share fallback. + /// + private const int CollapsedStackedHeight = 1; + + /// + /// The border/padding rows a column's panel spends above and below its + /// content, mirroring BoardMode.PanelChromeHeight. + /// + private const int StackedPanelChromeHeight = 2; + + /// + /// The fewest content rows an equally-shared stacked column needs to be + /// usable, below which every column sharing the height equally would be + /// too cramped to read. + /// + private const int MinStackedInteriorHeight = 3; + + /// + /// The smallest per-column height requires + /// before it shares the frame equally across every column. + /// + private const int MinEqualStackedHeight = MinStackedInteriorHeight + StackedPanelChromeHeight; + + /// + /// The blank rows reserves between + /// consecutive column panels: one row per gap, so + /// panels need columnCount - 1 separator rows. + /// + private static int SeparatorRows(int columnCount) => Math.Max(0, columnCount - 1); + + /// + /// Decides the layout for a frame of the given size over + /// columns, with + /// focused and + /// indicating whether the focused column's + /// maximize toggle is on. + /// + public static BoardLayoutDecision Decide( + int width, int height, int columnCount, int focusedColumnIndex, bool maximized) + { + width = Math.Max(0, width); + height = Math.Max(0, height); + + if (columnCount <= 0) + { + return new BoardLayoutDecision { Kind = BoardLayoutKind.Grid, Columns = [] }; + } + + var focused = Math.Clamp(focusedColumnIndex, 0, columnCount - 1); + + if (maximized) + { + return new BoardLayoutDecision + { + Kind = BoardLayoutKind.Maximized, + Columns = BuildMaximized(width, height, columnCount, focused) + }; + } + + if (width / columnCount < StackedWidthThreshold) + { + return new BoardLayoutDecision + { + Kind = BoardLayoutKind.Stacked, + Columns = BuildStacked(width, height, columnCount, focused) + }; + } + + return new BoardLayoutDecision + { + Kind = BoardLayoutKind.Grid, + Columns = BuildGrid(width, height, columnCount) + }; + } + + private static IReadOnlyList BuildGrid(int width, int height, int columnCount) + { + var baseWidth = width / columnCount; + var remainder = width % columnCount; + var columns = new BoardColumnLayout[columnCount]; + + for (var i = 0; i < columnCount; i++) + { + var columnWidth = baseWidth + (i < remainder ? 1 : 0); + columns[i] = new BoardColumnLayout(columnWidth, height, Expanded: true); + } + + return columns; + } + + private static IReadOnlyList BuildMaximized( + int width, int height, int columnCount, int focused) + { + var columns = new BoardColumnLayout[columnCount]; + + for (var i = 0; i < columnCount; i++) + { + columns[i] = i == focused + ? new BoardColumnLayout(width, height, Expanded: true) + : new BoardColumnLayout(0, 0, Expanded: false); + } + + return columns; + } + + /// + /// Stacks every column vertically, sharing the frame's height equally + /// among them so all are visible at once. Falls back to expanding only + /// the focused column, with the rest collapsed to a title line, when the + /// frame is too short to give every column a usable slice. + /// + private static IReadOnlyList BuildStacked( + int width, int height, int columnCount, int focused) + { + var separatorRows = SeparatorRows(columnCount); + + return height >= columnCount * MinEqualStackedHeight + separatorRows + ? BuildStackedEqual(width, height, columnCount, separatorRows) + : BuildStackedFocusedFallback(width, height, columnCount, focused, separatorRows); + } + + private static IReadOnlyList BuildStackedEqual( + int width, int height, int columnCount, int separatorRows) + { + var distributable = height - separatorRows; + var baseHeight = distributable / columnCount; + var remainder = distributable % columnCount; + var columns = new BoardColumnLayout[columnCount]; + + for (var i = 0; i < columnCount; i++) + { + var columnHeight = baseHeight + (i < remainder ? 1 : 0); + columns[i] = new BoardColumnLayout(width, columnHeight, Expanded: true); + } + + return columns; + } + + private static IReadOnlyList BuildStackedFocusedFallback( + int width, int height, int columnCount, int focused, int separatorRows) + { + var columns = new BoardColumnLayout[columnCount]; + var collapsedTotal = CollapsedStackedHeight * (columnCount - 1); + var expandedHeight = Math.Max(1, height - collapsedTotal - separatorRows); + + for (var i = 0; i < columnCount; i++) + { + columns[i] = i == focused + ? new BoardColumnLayout(width, expandedHeight, Expanded: true) + : new BoardColumnLayout(width, CollapsedStackedHeight, Expanded: false); + } + + return columns; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Board/BoardMode.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Board/BoardMode.cs new file mode 100644 index 00000000000..27140cf4454 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Board/BoardMode.cs @@ -0,0 +1,391 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Input; +using ChilliCream.Nitro.CommandLine.Tui.Shell; +using ChilliCream.Nitro.CommandLine.Tui.Widgets; +using Spectre.Console.Rendering; +using CursorDirection = ChilliCream.Nitro.CommandLine.Tui.Input.CursorDirection; + +namespace ChilliCream.Nitro.CommandLine.Tui.Board; + +/// +/// The kanban board : renders a board view as equal-width +/// columns and turns navigation, refresh, and selection intents into changes on +/// a . +/// +internal sealed class BoardMode : ITuiMode +{ + /// + /// Border and padding columns a panel spends on + /// either side of its content, at a given panel width. + /// + private const int PanelChromeWidth = 4; + + /// + /// Border rows a panel spends above and below its + /// content; the header is drawn on the top border row. + /// + private const int PanelChromeHeight = 2; + + /// + /// The number of distinct above/below indicator combinations a column's + /// viewport can settle on, bounding how many times reserving space for + /// them needs to be recomputed. + /// + private const int MaxIndicatorSettlePasses = 3; + + private readonly BoardDataLoader _loader; + private readonly IReadOnlyList _views; + + private BoardState _state; + private Viewport[] _viewports; + private int _viewIndex; + private bool _maximized; + + /// + /// Creates the board mode over , starting on the + /// first of . Defaults to the single v1 built-in + /// view when is not given. + /// + public BoardMode(BoardDataLoader loader, IReadOnlyList? views = null) + { + ArgumentNullException.ThrowIfNull(loader); + + _loader = loader; + _views = views is { Count: > 0 } ? views : [BoardView.Default]; + _state = new BoardState(_views[0], _loader); + _viewports = CreateViewports(_state.Columns.Count); + } + + /// + /// The board's current live state: columns, tasks, selection, and focus. + /// + public BoardState State => _state; + + /// + public KeyMap? KeyMap => null; + + /// + public string? SelectedTaskId => FocusedColumn()?.SelectedTaskId; + + /// + public void SelectTask(string id) + { + var columns = _state.Columns; + + for (var i = 0; i < columns.Count; i++) + { + var row = IndexOfTask(columns[i].Tasks, id); + + if (row < 0) + { + continue; + } + + _state.FocusColumn(i); + columns[i].SelectedRow = row; + return; + } + } + + /// + public void OnEnter() => RefreshBlocking(); + + /// + public void OnResize(int width, int height) + { + // Render(width, height) recomputes the layout decision and every + // column's viewport window from its parameters on every frame, so + // there is no per-resize state to update ahead of time. + } + + /// + public IReadOnlyList Handle(TuiMessage message) => message switch + { + TuiMessage.MoveCursor(CursorDirection.Left) => FocusColumn(-1), + TuiMessage.MoveCursor(CursorDirection.Right) => FocusColumn(1), + TuiMessage.MoveCursor(CursorDirection.Up) => MoveSelection(-1), + TuiMessage.MoveCursor(CursorDirection.Down) => MoveSelection(1), + TuiMessage.MoveToEdge(EdgeTarget.Top) => MoveSelectionToEdge(top: true), + TuiMessage.MoveToEdge(EdgeTarget.Bottom) => MoveSelectionToEdge(top: false), + TuiMessage.RefreshRequested => Refresh(), + TuiMessage.CycleView(var delta) => CycleView(delta), + TuiMessage.ToggleMaximize => ToggleMaximize(), + // OpenSelected on the board is handled by TuiShell before it ever + // reaches here: the shell switches to a BoardDetailMode showing the + // selection, mirroring how 't' opens the dependency tree. + TuiMessage.CopySelectedId => CopySelectedId(), + _ => [] + }; + + /// + public IRenderable Render(int width, int height) + { + var columns = _state.Columns; + + if (columns.Count == 0 || width <= 0 || height <= 0) + { + return new Markup(string.Empty); + } + + var decision = BoardLayout.Decide(width, height, columns.Count, _state.FocusedColumnIndex, _maximized); + + return decision.Kind switch + { + BoardLayoutKind.Maximized => RenderMaximized(decision), + BoardLayoutKind.Stacked => RenderStacked(decision), + _ => RenderGrid(decision) + }; + } + + private IRenderable RenderGrid(BoardLayoutDecision decision) + { + var columns = _state.Columns; + var columnLayouts = new Layout[columns.Count]; + + for (var i = 0; i < columns.Count; i++) + { + var slot = decision.Columns[i]; + var focused = i == _state.FocusedColumnIndex; + var panel = RenderColumnPanel(i, slot.Width, slot.Height, focused, headerSuffix: null); + + columnLayouts[i] = new Layout($"board-column-{i}", panel).Size(Math.Max(1, slot.Width)); + } + + return new Layout("board").SplitColumns(columnLayouts); + } + + private IRenderable RenderMaximized(BoardLayoutDecision decision) + { + var columns = _state.Columns; + var focused = _state.FocusedColumnIndex; + var slot = decision.Columns[focused]; + var suffix = $"{focused + 1}/{columns.Count}"; + + return RenderColumnPanel(focused, slot.Width, slot.Height, focused: true, headerSuffix: suffix); + } + + private IRenderable RenderStacked(BoardLayoutDecision decision) + { + var columns = _state.Columns; + var rows = new List(Math.Max(0, columns.Count * 2 - 1)); + + for (var i = 0; i < columns.Count; i++) + { + if (i > 0) + { + // One blank row between consecutive column panels, mirroring + // the separator rows BoardLayout reserves for stacked heights. + // An empty Markup collapses to nothing inside Rows, so Text is + // used here to force a real blank line. + rows.Add(new Text(string.Empty)); + } + + var slot = decision.Columns[i]; + + rows.Add(slot.Expanded + ? RenderColumnPanel(i, slot.Width, slot.Height, focused: i == _state.FocusedColumnIndex, headerSuffix: null) + : new Markup(Markup.Escape($"{columns[i].Definition.Name} ({columns[i].Tasks.Count})"))); + } + + return new Rows(rows); + } + + /// + /// Builds one column's bordered panel: its visible task lines, sized to + /// and , with + /// appended to the column name when the + /// column is the only one shown. + /// + private Panel RenderColumnPanel(int index, int columnWidth, int panelHeight, bool focused, string? headerSuffix) + { + var column = _state.Columns[index]; + var safeWidth = Math.Max(1, columnWidth); + var contentWidth = Math.Max(0, safeWidth - PanelChromeWidth); + var interiorHeight = Math.Max(0, panelHeight - PanelChromeHeight); + + var lines = RenderColumnLines(column, _viewports[index], contentWidth, interiorHeight, focused); + var name = headerSuffix is null ? column.Definition.Name : $"{column.Definition.Name} - {headerSuffix}"; + var panel = ColumnPane.Render(name, column.Tasks.Count, lines, focused); + + // Panel header title inherits the same accent color as its border, + // per column, rather than the global focused-border override + // ColumnPane computes for itself. + var borderStyle = column.Definition.ResolveBorderStyle(focused); + panel.BorderStyle = borderStyle; + panel.Header = panel.Header!.SetStyle(borderStyle); + + panel.Width = safeWidth; + panel.Height = Math.Max(1, panelHeight); + + return panel; + } + + private IReadOnlyList ToggleMaximize() + { + _maximized = !_maximized; + return []; + } + + private IReadOnlyList FocusColumn(int delta) + { + _state.FocusColumn(_state.FocusedColumnIndex + delta); + return []; + } + + private IReadOnlyList MoveSelection(int delta) + { + var column = FocusedColumn(); + + if (column is { Tasks.Count: > 0 }) + { + column.SelectedRow = Math.Clamp(column.SelectedRow + delta, 0, column.Tasks.Count - 1); + } + + return []; + } + + private IReadOnlyList MoveSelectionToEdge(bool top) + { + var column = FocusedColumn(); + + if (column is { Tasks.Count: > 0 }) + { + column.SelectedRow = top ? 0 : column.Tasks.Count - 1; + } + + return []; + } + + private IReadOnlyList Refresh() + { + RefreshBlocking(); + return []; + } + + private IReadOnlyList CycleView(int delta) + { + if (_views.Count <= 1) + { + // Nothing to switch to in v1's single built-in view; the intent is + // still accepted so the wiring is exercised once a second view lands. + return []; + } + + _viewIndex = ((_viewIndex + delta) % _views.Count + _views.Count) % _views.Count; + _state = new BoardState(_views[_viewIndex], _loader); + _viewports = CreateViewports(_state.Columns.Count); + RefreshBlocking(); + return []; + } + + private IReadOnlyList CopySelectedId() + { + var id = FocusedColumn()?.SelectedTaskId; + + return id is null + ? [new TuiMessage.ShowToast("No task selected.", ToastStyle.Warn)] + : [new TuiMessage.ShowToast(id, ToastStyle.Info)]; + } + + private BoardColumnState? FocusedColumn() + { + var columns = _state.Columns; + return _state.FocusedColumnIndex >= 0 && _state.FocusedColumnIndex < columns.Count + ? columns[_state.FocusedColumnIndex] + : null; + } + + private void RefreshBlocking() => _state.RefreshAsync(CancellationToken.None).GetAwaiter().GetResult(); + + private static Viewport[] CreateViewports(int columnCount) + { + var viewports = new Viewport[columnCount]; + + for (var i = 0; i < columnCount; i++) + { + viewports[i] = new Viewport(0, 0); + } + + return viewports; + } + + /// + /// Renders one column's visible rows: the scrolled task badges, padded + /// with blank lines so every column reports the same line count, with + /// "N more above/below" indicators reserving their own rows once the + /// column's tasks no longer fit . + /// + private static IReadOnlyList RenderColumnLines( + BoardColumnState column, + Viewport viewport, + int contentWidth, + int interiorHeight, + bool focused) + { + if (interiorHeight <= 0) + { + return []; + } + + var reservedRows = 0; + + for (var pass = 0; pass < MaxIndicatorSettlePasses; pass++) + { + var windowHeight = Math.Max(0, interiorHeight - reservedRows); + viewport.Update(column.Tasks.Count, windowHeight); + viewport.EnsureVisible(column.SelectedRow); + + var needed = (viewport.HiddenAbove > 0 ? 1 : 0) + (viewport.HiddenBelow > 0 ? 1 : 0); + + if (needed == reservedRows) + { + break; + } + + reservedRows = needed; + } + + var (start, visibleCount) = viewport.Slice(); + var lines = new List(interiorHeight); + + if (viewport.HiddenAbove > 0) + { + lines.Add(FormatIndicator(viewport.HiddenAbove, "above")); + } + + for (var i = 0; i < visibleCount; i++) + { + var task = column.Tasks[start + i]; + var selected = focused && start + i == column.SelectedRow; + lines.Add(TaskBadge.Render( + task.Id, task.Title, task.Status, task.Priority, task.Type, selected, contentWidth)); + } + + if (viewport.HiddenBelow > 0) + { + lines.Add(FormatIndicator(viewport.HiddenBelow, "below")); + } + + while (lines.Count < interiorHeight) + { + lines.Add(string.Empty); + } + + return lines; + } + + private static string FormatIndicator(int hiddenCount, string direction) => $" {hiddenCount} more {direction}"; + + private static int IndexOfTask(IReadOnlyList tasks, string id) + { + for (var i = 0; i < tasks.Count; i++) + { + if (tasks[i].Id == id) + { + return i; + } + } + + return -1; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Board/BoardState.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Board/BoardState.cs new file mode 100644 index 00000000000..bd48d20a2df --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Board/BoardState.cs @@ -0,0 +1,106 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Tui.Board; + +/// +/// One board column's live data: its definition, the tasks currently loaded +/// for it, and which row is selected. +/// +internal sealed class BoardColumnState(ColumnDefinition definition) +{ + /// + /// The column's declarative filter and sort. + /// + public ColumnDefinition Definition { get; } = definition; + + /// + /// The tasks currently loaded for this column, in display order. + /// + public IReadOnlyList Tasks { get; internal set; } = []; + + /// + /// The index of the selected row within . + /// + public int SelectedRow { get; internal set; } + + /// + /// The id of the task at , or null when the + /// column is empty or the row is out of range. + /// + public string? SelectedTaskId + => SelectedRow >= 0 && SelectedRow < Tasks.Count ? Tasks[SelectedRow].Id : null; +} + +/// +/// The live state of a board: per-column task lists and selection, plus +/// which column has focus. +/// +internal sealed class BoardState +{ + private readonly BoardDataLoader _loader; + + public BoardState(BoardView view, BoardDataLoader loader) + { + View = view; + _loader = loader; + Columns = [.. view.Columns.Select(column => new BoardColumnState(column))]; + } + + /// + /// The board layout this state was built from. + /// + public BoardView View { get; } + + /// + /// The board's columns, in display order. + /// + public IReadOnlyList Columns { get; } + + /// + /// The index of the column that has focus. + /// + public int FocusedColumnIndex { get; private set; } + + /// + /// Moves focus to the given column index, clamped to the valid range. + /// + public void FocusColumn(int index) + { + FocusedColumnIndex = Columns.Count == 0 ? 0 : Math.Clamp(index, 0, Columns.Count - 1); + } + + /// + /// Reloads every column from the task store. Each column's selected task + /// stays selected when it is still present in the reloaded list; + /// otherwise the selected row is clamped to the new list's bounds. + /// + public async Task RefreshAsync(CancellationToken cancellationToken) + { + foreach (var column in Columns) + { + var selectedTaskId = column.SelectedTaskId; + + var tasks = await _loader.LoadColumnAsync(column.Definition, cancellationToken); + column.Tasks = tasks; + + var preservedIndex = selectedTaskId is null ? -1 : IndexOf(tasks, selectedTaskId); + + column.SelectedRow = preservedIndex >= 0 + ? preservedIndex + : Math.Clamp(column.SelectedRow, 0, Math.Max(0, tasks.Count - 1)); + } + } + + private static int IndexOf(IReadOnlyList tasks, string taskId) + { + for (var i = 0; i < tasks.Count; i++) + { + if (tasks[i].Id == taskId) + { + return i; + } + } + + return -1; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Board/BoardView.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Board/BoardView.cs new file mode 100644 index 00000000000..1dee1dc28bc --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Board/BoardView.cs @@ -0,0 +1,71 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Tui.Board; + +/// +/// A named board layout made of ordered columns. +/// +internal sealed record BoardView +{ + /// + /// The maximum number of tasks kept in the closed column, most recently + /// closed first. + /// + private const int ClosedColumnLimit = 50; + + /// + /// The board's display name. + /// + public required string Name { get; init; } + + /// + /// The board's columns, in display order. + /// + public required IReadOnlyList Columns { get; init; } + + /// + /// The v1 built-in board view: Blocked, Deferred, Ready, In Progress, and + /// Closed columns, matching the semantics of the blocked, ready, and list + /// task commands. + /// + public static BoardView Default { get; } = new() + { + Name = "Board", + Columns = + [ + new ColumnDefinition + { + Name = "Blocked", + ComputedFilter = ColumnComputedFilter.Blocked, + BorderToken = "board.column.status.blocked" + }, + new ColumnDefinition + { + Name = "Deferred", + ComputedFilter = ColumnComputedFilter.Deferred, + BorderToken = "board.column.status.deferred" + }, + new ColumnDefinition + { + Name = "Ready", + Statuses = [TaskStates.Open], + ComputedFilter = ColumnComputedFilter.Ready, + BorderToken = "board.column.status.ready" + }, + new ColumnDefinition + { + Name = "In Progress", + Statuses = [TaskStates.InProgress], + BorderToken = "board.column.status.inprogress" + }, + new ColumnDefinition + { + Name = "Closed", + Statuses = [TaskStates.Closed], + Sort = BoardColumnSort.RecentFirst, + Limit = ClosedColumnLimit, + BorderToken = "board.column.status.closed" + } + ] + }; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Board/ColumnDefinition.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Board/ColumnDefinition.cs new file mode 100644 index 00000000000..8ae21c1f31c --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Board/ColumnDefinition.cs @@ -0,0 +1,125 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Theming; + +namespace ChilliCream.Nitro.CommandLine.Tui.Board; + +/// +/// A computed membership test a column applies on top of its declarative +/// filter, for semantics that cannot express alone. +/// +internal enum ColumnComputedFilter +{ + /// + /// No computed test; the declarative filter alone decides membership. + /// + None, + + /// + /// Open, unblocked, and not deferred into the future. + /// + Ready, + + /// + /// Not closed or tombstoned, and blocked by an unresolved dependency. + /// + Blocked, + + /// + /// Deferred: either its status is deferred, or its defer date is in the + /// future. + /// + Deferred +} + +/// +/// How a column orders the tasks it loads. +/// +internal enum BoardColumnSort +{ + /// + /// Priority ascending, then created date ascending, then id ascending, + /// matching the list task command's default order. + /// + Default, + + /// + /// Closed date descending (falling back to updated date), then id + /// ascending. + /// + RecentFirst +} + +/// +/// A named board column: a declarative task filter plus how the matching +/// tasks are sorted and how many are kept. +/// +internal sealed record ColumnDefinition +{ + /// + /// The column's display name. + /// + public required string Name { get; init; } + + /// + /// Statuses a task must have one of. Null means every non-terminal + /// status. + /// + public string[]? Statuses { get; init; } + + /// + /// Task types a task must have one of. Null means every type. + /// + public string[]? Types { get; init; } + + /// + /// Priorities a task must have one of. Null means every priority. + /// + public int[]? Priorities { get; init; } + + /// + /// An assignee a task must have, or "unassigned" to match tasks with no + /// assignee. Null means every assignee. + /// + public string? Assignee { get; init; } + + /// + /// Labels a task must all carry. Null means no label requirement. + /// + public string[]? Labels { get; init; } + + /// + /// The computed membership test applied on top of the fields above. + /// + public ColumnComputedFilter ComputedFilter { get; init; } = ColumnComputedFilter.None; + + /// + /// The order matching tasks are sorted in. + /// + public BoardColumnSort Sort { get; init; } = BoardColumnSort.Default; + + /// + /// The maximum number of tasks kept after sorting. Null means unlimited. + /// + public int? Limit { get; init; } + + /// + /// The theme token for this column's border and header accent color, for + /// example board.column.status.blocked. Null falls back to the + /// board's default neutral border tokens. + /// + public string? BorderToken { get; init; } + + /// + /// Resolves this column's border and header style: its own accent token, + /// brightened when is true, or the default + /// neutral border tokens when no accent is assigned. + /// + public Style ResolveBorderStyle(bool focused) + { + var token = BorderToken is { } accent + ? focused ? $"{accent}.focused" : accent + : focused ? "board.column.border.focused" : "board.column.border"; + + return ThemeTokens.GetStyle(token); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Details/TaskDetailBody.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Details/TaskDetailBody.cs new file mode 100644 index 00000000000..32d496cb95a --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Details/TaskDetailBody.cs @@ -0,0 +1,157 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Theming; + +namespace ChilliCream.Nitro.CommandLine.Tui.Details; + +/// +/// One rendered line of a task detail body. +/// distinguishes section headers, section box borders, dependency rows, and +/// blocks rows, which already carry Spectre markup, from plain-text section +/// lines, including a section box's content rows, that still need +/// Markup.Escape before display. marks +/// the single dependency or blocks row the body's scroll position keeps +/// visible. +/// +internal readonly record struct TaskDetailBodyLine(string Content, bool IsMarkup, bool IsSelectedRow = false); + +/// +/// Composes a task detail body's sections, in the order Description, Design, +/// Acceptance criteria, Notes, Dependencies, Blocks, Comments, with a blank +/// line between consecutive non-empty sections. Description, Design, +/// Acceptance criteria, and Notes each render as their own bordered box, see +/// ; Dependencies, Blocks, and Comments +/// stay a styled header line followed by a blank line and their rows. Empty +/// sections are omitted entirely, including their header. +/// +internal static class TaskDetailBody +{ + public static IReadOnlyList Build(TaskDetailModel model, int width, bool focused) + { + ArgumentNullException.ThrowIfNull(model); + + if (model.Task is not { } task) + { + return []; + } + + var dependencyRows = model.Rows.Where(r => r.Kind == TaskDetailRowKind.Dependency).ToList(); + var blockRows = model.Rows.Where(r => r.Kind == TaskDetailRowKind.Blocks).ToList(); + + var sections = new List> + { + TextSection("Description", task.Description, width), + TextSection("Design", task.Design, width), + TextSection("Acceptance criteria", task.AcceptanceCriteria, width), + TextSection("Notes", task.Notes, width), + RowSection("Dependencies", dependencyRows, model.SelectedRowIndex, width, focused), + RowSection("Blocks", blockRows, model.SelectedRowIndex, width, focused), + CommentsSection(model.Comments, width) + }; + + var lines = new List(); + var separatorPending = false; + + foreach (var section in sections) + { + if (section.Count == 0) + { + continue; + } + + if (separatorPending) + { + lines.Add(new TaskDetailBodyLine(string.Empty, false)); + } + + lines.AddRange(section); + separatorPending = true; + } + + return lines; + } + + private static IReadOnlyList TextSection(string header, string text, int width) + => TaskDetailSectionBox.Render(header, text, width); + + private static IReadOnlyList CommentsSection(IReadOnlyList comments, int width) + => WithStyledHeader("Comments", PlainBody(TaskDetailSections.BuildCommentsSection(comments, width))); + + private static IReadOnlyList RowSection( + string header, + IReadOnlyList rows, + int selectedIndex, + int width, + bool focused) + { + if (rows.Count == 0) + { + return []; + } + + var body = new List(rows.Count); + + foreach (var row in rows) + { + var selected = focused && row.Index == selectedIndex; + + body.Add(new TaskDetailBodyLine( + TaskDetailRowRenderer.Render(row, selected, width), + IsMarkup: true, + IsSelectedRow: selected)); + } + + return WithStyledHeader(header, body); + } + + /// + /// Drops a raw section's leading header line, since the caller re-adds + /// it styled, and wraps the remaining lines as plain, unescaped body + /// content. + /// + private static IReadOnlyList PlainBody(IReadOnlyList rawLines) + { + if (rawLines.Count == 0) + { + return []; + } + + var body = new List(rawLines.Count - 1); + + for (var i = 1; i < rawLines.Count; i++) + { + body.Add(new TaskDetailBodyLine(rawLines[i], false)); + } + + return body; + } + + /// + /// Prefixes non-empty section content with a bold header line and a + /// blank separator line. Empty content stays empty, so the caller omits + /// the section entirely, header included. + /// + private static IReadOnlyList WithStyledHeader(string header, IReadOnlyList body) + { + if (body.Count == 0) + { + return []; + } + + var lines = new List(body.Count + 2) + { + StyledHeader(header), + new TaskDetailBodyLine(string.Empty, false) + }; + + lines.AddRange(body); + return lines; + } + + private static TaskDetailBodyLine StyledHeader(string header) + { + var style = ThemeTokens.GetStyle("detail.section.header").ToMarkup(); + var text = Markup.Escape(header); + var content = style.Length == 0 ? $"[bold]{text}[/]" : $"[{style}]{text}[/]"; + return new TaskDetailBodyLine(content, IsMarkup: true); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Details/TaskDetailModel.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Details/TaskDetailModel.cs new file mode 100644 index 00000000000..85eaddf3a5b --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Details/TaskDetailModel.cs @@ -0,0 +1,236 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Input; +using CursorDirection = ChilliCream.Nitro.CommandLine.Tui.Input.CursorDirection; + +namespace ChilliCream.Nitro.CommandLine.Tui.Details; + +/// +/// The distinguishing direction of a : an outgoing +/// dependency of the loaded task, or an incoming dependent that the loaded +/// task blocks. +/// +internal enum TaskDetailRowKind +{ + Dependency, + Blocks +} + +/// +/// One row in the loaded task's combined dependency and blocks list, indexed +/// by its position in . Null +/// and mean the target task no +/// longer exists. +/// +internal sealed record TaskDetailRow( + int Index, + TaskDetailRowKind Kind, + string Type, + string TargetId, + string? Status, + string? Title) +{ + /// + /// Whether this row's dependency type gates readiness, per + /// . + /// + public bool IsBlocking => TaskDependencyTypes.IsBlocking(Type); +} + +/// +/// The loaded state behind a task detail view: the task and its related +/// data, a cursor over its combined dependency and blocks rows, and a +/// navigation stack for drilling into related tasks and returning. +/// +internal sealed class TaskDetailModel +{ + private readonly ITaskStore _store; + private readonly Stack _backStack = new(); + + public TaskDetailModel(ITaskStore store) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + } + + /// + /// The currently loaded task, or null when the last load target does not + /// exist. + /// + public TaskItem? Task { get; private set; } + + /// + /// The id last passed to , even when it did not + /// resolve to a task. + /// + public string? CurrentTaskId { get; private set; } + + /// + /// The loaded task's labels, ordered by label. + /// + public IReadOnlyList Labels { get; private set; } = []; + + /// + /// The loaded task's comments, ordered by created_at then id. + /// + public IReadOnlyList Comments { get; private set; } = []; + + /// + /// The descriptions of the tasks currently blocking the loaded task, from + /// the store's computed blocked set. + /// + public IReadOnlyList BlockedBy { get; private set; } = []; + + /// + /// The loaded task's outgoing dependencies followed by its incoming + /// blocks, in that order. indexes into + /// this list. + /// + public IReadOnlyList Rows { get; private set; } = []; + + /// + /// The index of the selected row within . + /// + public int SelectedRowIndex { get; private set; } + + /// + /// Whether has a task to return to. + /// + public bool CanGoBack => _backStack.Count > 0; + + /// + /// Loads the task with the given id and its related data, without + /// affecting the navigation stack. Resets the row cursor. Returns whether + /// the task exists. + /// + public async Task LoadAsync(string taskId, CancellationToken cancellationToken) + { + CurrentTaskId = taskId; + + var task = await _store.GetTaskAsync(taskId, cancellationToken).ConfigureAwait(false); + + if (task is null) + { + Task = null; + Labels = []; + Comments = []; + BlockedBy = []; + Rows = []; + SelectedRowIndex = 0; + return false; + } + + var labels = await _store.GetLabelsAsync(taskId, cancellationToken).ConfigureAwait(false); + var dependencies = await _store.GetDependenciesAsync(taskId, cancellationToken).ConfigureAwait(false); + var blocks = await _store.GetDependentsAsync(taskId, cancellationToken).ConfigureAwait(false); + var comments = await _store.GetCommentsAsync(taskId, cancellationToken).ConfigureAwait(false); + var blocked = await _store.ComputeBlockedAsync(cancellationToken).ConfigureAwait(false); + + Task = task; + Labels = labels; + Comments = comments; + BlockedBy = blocked.TryGetValue(taskId, out var blockers) ? blockers : []; + Rows = BuildRows(dependencies, blocks); + SelectedRowIndex = 0; + return true; + } + + /// + /// Moves one step in , + /// clamped to . Directions other than up and down have + /// no effect. + /// + public void MoveRowCursor(CursorDirection direction) + { + if (Rows.Count == 0) + { + return; + } + + SelectedRowIndex = direction switch + { + CursorDirection.Down => Math.Min(Rows.Count - 1, SelectedRowIndex + 1), + CursorDirection.Up => Math.Max(0, SelectedRowIndex - 1), + _ => SelectedRowIndex + }; + } + + /// + /// Loads the task targeted by the selected row, pushing the current + /// task's id onto the navigation stack. Has no effect, and returns false, + /// when no row is selected or the target task no longer exists. + /// + public async Task OpenSelectedRowAsync(CancellationToken cancellationToken) + { + if (Task is not { } current) + { + return false; + } + + if (SelectedRowIndex < 0 || SelectedRowIndex >= Rows.Count) + { + return false; + } + + var row = Rows[SelectedRowIndex]; + + if (row.Status is null) + { + return false; + } + + if (!await LoadAsync(row.TargetId, cancellationToken).ConfigureAwait(false)) + { + return false; + } + + _backStack.Push(current.Id); + return true; + } + + /// + /// Pops the navigation stack and loads the popped task id. Has no + /// effect, and returns false, when the stack is empty. + /// + public async Task GoBackAsync(CancellationToken cancellationToken) + { + if (_backStack.Count == 0) + { + return false; + } + + var previousId = _backStack.Pop(); + await LoadAsync(previousId, cancellationToken).ConfigureAwait(false); + return true; + } + + private static IReadOnlyList BuildRows( + IReadOnlyList dependencies, + IReadOnlyList blocks) + { + var rows = new List(dependencies.Count + blocks.Count); + var index = 0; + + foreach (var dependency in dependencies) + { + rows.Add(new TaskDetailRow( + index++, + TaskDetailRowKind.Dependency, + dependency.Type, + dependency.DependsOnId, + dependency.Status, + dependency.Title)); + } + + foreach (var block in blocks) + { + rows.Add(new TaskDetailRow( + index++, + TaskDetailRowKind.Blocks, + block.Type, + block.TaskId, + block.Status, + block.Title)); + } + + return rows; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Details/TaskDetailRowRenderer.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Details/TaskDetailRowRenderer.cs new file mode 100644 index 00000000000..7c14596e2f8 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Details/TaskDetailRowRenderer.cs @@ -0,0 +1,84 @@ +using ChilliCream.Nitro.CommandLine.Tui.Theming; +using ChilliCream.Nitro.CommandLine.Tui.Widgets; + +namespace ChilliCream.Nitro.CommandLine.Tui.Details; + +/// +/// Renders one dependency or blocks row as a single Spectre markup line: +/// selection prefix, status glyph, dependency type, direction arrow, target +/// id, and title, mirroring 's row conventions. +/// Blocking dependency types are rendered bold to distinguish them from +/// non-blocking ones. +/// +internal static class TaskDetailRowRenderer +{ + private const string Ellipsis = "โ€ฆ"; + private const string SelectedPrefix = "> "; + private const string UnselectedPrefix = " "; + private const string DependencyArrow = "->"; + private const string BlocksArrow = "<-"; + + /// + /// Builds the markup line for one row, truncating the title with an + /// ellipsis so the whole line fits within + /// display columns. A of 0 or less produces + /// an empty line. + /// + public static string Render(TaskDetailRow row, bool selected, int maxWidth) + { + if (maxWidth <= 0) + { + return string.Empty; + } + + var prefix = selected ? SelectedPrefix : UnselectedPrefix; + var status = row.Status ?? "unknown"; + var arrow = row.Kind == TaskDetailRowKind.Dependency ? DependencyArrow : BlocksArrow; + var title = row.Title ?? "(deleted)"; + + var fixedPlainLength = prefix.Length + TaskGlyphs.Status(status).Length + 1 + + row.Type.Length + 1 + arrow.Length + 1 + + row.TargetId.Length + 1; + + var titleBudget = Math.Max(0, maxWidth - fixedPlainLength); + var truncatedTitle = Truncate(title, titleBudget); + + var line = + $"{Markup.Escape(prefix)}{TaskGlyphs.StatusMarkup(status)} " + + $"{Markup.Escape(row.Type)} {Markup.Escape(arrow)} " + + $"{Markup.Escape(row.TargetId)} {Markup.Escape(truncatedTitle)}"; + + if (row.IsBlocking) + { + line = $"[bold]{line}[/]"; + } + + if (selected) + { + var highlightStyle = ThemeTokens.GetStyle("selection.highlight").ToMarkup(); + line = $"[{highlightStyle}]{line}[/]"; + } + + return line; + } + + private static string Truncate(string value, int width) + { + if (width <= 0) + { + return string.Empty; + } + + if (value.Length <= width) + { + return value; + } + + if (width == 1) + { + return Ellipsis; + } + + return string.Concat(value.AsSpan(0, width - 1), Ellipsis); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Details/TaskDetailSectionBox.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Details/TaskDetailSectionBox.cs new file mode 100644 index 00000000000..db50b3dce13 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Details/TaskDetailSectionBox.cs @@ -0,0 +1,65 @@ +using ChilliCream.Nitro.CommandLine.Tui.Theming; + +namespace ChilliCream.Nitro.CommandLine.Tui.Details; + +/// +/// Renders one long-text detail section, Description, Design, Acceptance +/// criteria, or Notes, as a rounded-border box with the section name as the +/// border title and a padded interior, matching the bordered field sections +/// the task edit form renders. Produces a flat list of plain-text display +/// rows, one per line, so the body's viewport can scroll over the box the +/// same way it scrolls over any other section. +/// +internal static class TaskDetailSectionBox +{ + /// + /// Border and padding columns the box spends on either side of its + /// content: the rounded border and one padding column, each side. + /// + private const int ChromeWidth = 4; + + /// + /// Builds the box for under , + /// at most display columns wide. Returns an empty + /// list when is empty, so the caller omits the + /// section entirely. + /// + public static IReadOnlyList Render(string title, string text, int width) + { + if (string.IsNullOrEmpty(text)) + { + return []; + } + + var boxWidth = Math.Max(width, ChromeWidth + 1); + var interiorWidth = boxWidth - ChromeWidth; + var contentLines = TaskDetailSections.WrapText(text, interiorWidth); + + var lines = new List(contentLines.Count + 2) { TopBorder(title, boxWidth) }; + + foreach (var line in contentLines) + { + lines.Add(new TaskDetailBodyLine($"โ”‚ {line.PadRight(interiorWidth)} โ”‚", IsMarkup: false)); + } + + lines.Add(new TaskDetailBodyLine($"โ•ฐ{new string('โ”€', Math.Max(0, boxWidth - 2))}โ•ฏ", IsMarkup: true)); + return lines; + } + + private static TaskDetailBodyLine TopBorder(string title, int width) + { + var borderStyle = ThemeTokens.GetStyle("detail.section.border").ToMarkup(); + var titleStyle = ThemeTokens.GetStyle("detail.section.header").ToMarkup(); + var escapedTitle = Markup.Escape(title); + var fill = Math.Max(0, width - 3 - title.Length); + + var content = + Styled(borderStyle, "โ•ญโ”€") + + (titleStyle.Length == 0 ? $"[bold]{escapedTitle}[/]" : Styled(titleStyle, escapedTitle)) + + Styled(borderStyle, $"{new string('โ”€', fill)}โ•ฎ"); + + return new TaskDetailBodyLine(content, IsMarkup: true); + } + + private static string Styled(string style, string content) => style.Length == 0 ? content : $"[{style}]{content}[/]"; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Details/TaskDetailSections.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Details/TaskDetailSections.cs new file mode 100644 index 00000000000..2bc11a0ae09 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Details/TaskDetailSections.cs @@ -0,0 +1,148 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Tui.Details; + +/// +/// Builds the plain-text, word-wrapped lines of a task detail body's +/// long-text sections and comments. Lines are unescaped: rendering escapes +/// them via Markup.Escape before display, matching the plain-text +/// (not markdown) v1 body decision. +/// +internal static class TaskDetailSections +{ + /// + /// Builds a header line followed by word-wrapped + /// to , one entry per display line. Returns an + /// empty list when is empty, so the caller omits + /// the section entirely. + /// + public static IReadOnlyList BuildTextSection(string header, string text, int width) + { + if (string.IsNullOrEmpty(text)) + { + return []; + } + + var lines = new List { header }; + lines.AddRange(WrapText(text, width)); + return lines; + } + + /// + /// Word-wraps to display + /// columns, one entry per display line, preserving the source paragraph + /// breaks (each line of wraps independently). + /// + public static IReadOnlyList WrapText(string text, int width) + { + var lines = new List(); + + foreach (var paragraphLine in text.ReplaceLineEndings("\n").Split('\n')) + { + lines.AddRange(WrapLine(paragraphLine, width)); + } + + return lines; + } + + /// + /// Builds the "Comments:" section: each comment's author and timestamp + /// followed by its text word-wrapped to , with a + /// blank line between comments. Returns an empty list when there are no + /// comments, so the caller omits the section entirely. + /// + public static IReadOnlyList BuildCommentsSection(IReadOnlyList comments, int width) + { + if (comments.Count == 0) + { + return []; + } + + var lines = new List { "Comments" }; + + for (var i = 0; i < comments.Count; i++) + { + if (i > 0) + { + lines.Add(string.Empty); + } + + var comment = comments[i]; + lines.Add($"{comment.Author} - {TaskDates.Format(comment.CreatedAt)}"); + + foreach (var paragraphLine in comment.Text.ReplaceLineEndings("\n").Split('\n')) + { + lines.AddRange(WrapLine(paragraphLine, width)); + } + } + + return lines; + } + + /// + /// Greedily word-wraps one line of text to + /// display columns. A word longer than is + /// hard-broken across lines. A of 0 or less + /// returns the line unwrapped, and an empty line returns one empty entry. + /// + public static IReadOnlyList WrapLine(string line, int width) + { + if (width <= 0) + { + return [line]; + } + + if (line.Length == 0) + { + return [string.Empty]; + } + + var result = new List(); + var current = new System.Text.StringBuilder(); + + foreach (var word in line.Split(' ')) + { + if (word.Length > width) + { + if (current.Length > 0) + { + result.Add(current.ToString()); + current.Clear(); + } + + var remaining = word; + + while (remaining.Length > width) + { + result.Add(remaining[..width]); + remaining = remaining[width..]; + } + + current.Append(remaining); + continue; + } + + if (current.Length == 0) + { + current.Append(word); + } + else if (current.Length + 1 + word.Length <= width) + { + current.Append(' ').Append(word); + } + else + { + result.Add(current.ToString()); + current.Clear(); + current.Append(word); + } + } + + if (current.Length > 0) + { + result.Add(current.ToString()); + } + + return result; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Details/TaskDetailSidebar.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Details/TaskDetailSidebar.cs new file mode 100644 index 00000000000..4e2369ad15a --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Details/TaskDetailSidebar.cs @@ -0,0 +1,119 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Theming; +using ChilliCream.Nitro.CommandLine.Tui.Widgets; + +namespace ChilliCream.Nitro.CommandLine.Tui.Details; + +/// +/// Builds the markup lines of a task detail view's metadata sidebar, grouped +/// into blocks separated by a blank line: state (status, priority, type), +/// dates and people (assignee, due, deferred-until, estimate, created, +/// updated, closed), labels, and blocked-by. Fields with no value are +/// omitted, and each key is dimmed relative to its value. +/// +internal static class TaskDetailSidebar +{ + public static IReadOnlyList Build( + TaskItem task, + IReadOnlyList labels, + IReadOnlyList blockedBy) + { + var groups = new List> + { + BuildStateGroup(task), + BuildDatesGroup(task), + labels.Count > 0 + ? [Field("Labels", Markup.Escape(string.Join(", ", labels)))] + : [], + blockedBy.Count > 0 + ? [Field("Blocked by", Markup.Escape(string.Join(", ", blockedBy)))] + : [] + }; + + var lines = new List(); + var separatorPending = false; + + foreach (var group in groups) + { + if (group.Count == 0) + { + continue; + } + + if (separatorPending) + { + lines.Add(string.Empty); + } + + lines.AddRange(group); + separatorPending = true; + } + + return lines; + } + + private static IReadOnlyList BuildStateGroup(TaskItem task) + { + var statusValue = Stylize( + ThemeTokens.GetStyle($"status.glyph.{task.Status}").ToMarkup(), Markup.Escape(task.Status)); + var priorityValue = Stylize( + ThemeTokens.GetStyle($"badge.priority.p{task.Priority}").ToMarkup(), + Markup.Escape(TaskPriorities.Format(task.Priority))); + var typeValue = Stylize( + ThemeTokens.GetStyle($"badge.type.{task.Type}").ToMarkup(), Markup.Escape(task.Type)); + + return + [ + $"{TaskGlyphs.StatusMarkup(task.Status)} {statusValue}", + Field("Priority", priorityValue), + Field("Type", typeValue) + ]; + } + + private static IReadOnlyList BuildDatesGroup(TaskItem task) + { + var lines = new List(); + + if (!string.IsNullOrEmpty(task.Assignee)) + { + lines.Add(Field("Assignee", Markup.Escape(task.Assignee))); + } + + if (task.DueAt is { } dueAt) + { + lines.Add(Field("Due", Markup.Escape(TaskDates.Format(dueAt)))); + } + + if (task.DeferUntil is { } deferUntil) + { + lines.Add(Field("Deferred until", Markup.Escape(TaskDates.Format(deferUntil)))); + } + + if (task.EstimatedMinutes is { } estimatedMinutes) + { + lines.Add(Field("Estimate", $"{estimatedMinutes}m")); + } + + lines.Add(Field("Created", $"{Markup.Escape(TaskDates.Format(task.CreatedAt))} by {Markup.Escape(task.CreatedBy)}")); + lines.Add(Field("Updated", Markup.Escape(TaskDates.Format(task.UpdatedAt)))); + + if (task.Status == TaskStates.Closed && task.ClosedAt is { } closedAt) + { + var closedValue = Markup.Escape(TaskDates.Format(closedAt)); + + if (!string.IsNullOrEmpty(task.CloseReason)) + { + closedValue += $" ({Markup.Escape(task.CloseReason)})"; + } + + lines.Add(Field("Closed", closedValue)); + } + + return lines; + } + + private static string Field(string key, string value) => $"[dim]{Markup.Escape(key)}[/]: {value}"; + + private static string Stylize(string styleMarkup, string content) => + styleMarkup.Length == 0 ? content : $"[{styleMarkup}]{content}[/]"; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Details/TaskDetailView.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Details/TaskDetailView.cs new file mode 100644 index 00000000000..939a737d1db --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Details/TaskDetailView.cs @@ -0,0 +1,299 @@ +using ChilliCream.Nitro.CommandLine.Tui.Theming; +using ChilliCream.Nitro.CommandLine.Tui.Widgets; +using Spectre.Console.Rendering; + +namespace ChilliCream.Nitro.CommandLine.Tui.Details; + +/// +/// Renders a as a scrollable body of the task's +/// long-text sections, dependencies, blocks, and comments, next to a metadata +/// sidebar. Owns the body's scroll position; the model owns everything else. +/// +internal sealed class TaskDetailView +{ + /// + /// The sidebar's fixed width when shown beside the body. + /// + private const int SidebarWidth = 34; + + /// + /// The total frame width below which the body and sidebar stack in a + /// single column instead of sitting side by side. + /// + private const int StackedWidthThreshold = 100; + + /// + /// Border and padding columns a panel spends on either side of its + /// content, at a given panel width. + /// + private const int PanelChromeWidth = 4; + + /// + /// Border rows a panel spends above and below its content; the header is + /// drawn on the top border row. + /// + private const int PanelChromeHeight = 2; + + /// + /// The number of distinct above/below indicator combinations the body's + /// viewport can settle on, bounding how many times reserving space for + /// them needs to be recomputed. + /// + private const int MaxIndicatorSettlePasses = 3; + + private readonly TaskDetailModel _model; + private readonly Viewport _bodyViewport = new(0, 0); + private int _lastSelectedRowIndex = -1; + + public TaskDetailView(TaskDetailModel model) + { + _model = model ?? throw new ArgumentNullException(nameof(model)); + } + + /// + /// Scrolls the body down one line. + /// + public void ScrollDown() => Scroll(1); + + /// + /// Scrolls the body up one line. + /// + public void ScrollUp() => Scroll(-1); + + /// + /// Scrolls the body down by half the last rendered body height. + /// + public void PageDown() => Scroll(Math.Max(1, _bodyViewport.WindowHeight / 2)); + + /// + /// Scrolls the body up by half the last rendered body height. + /// + public void PageUp() => Scroll(-Math.Max(1, _bodyViewport.WindowHeight / 2)); + + /// + /// Scrolls the body to its first line. + /// + public void ScrollToTop() => Scroll(int.MinValue / 2); + + /// + /// Scrolls the body to its last line. + /// + public void ScrollToBottom() => Scroll(int.MaxValue / 2); + + /// + /// Renders the body and sidebar into the given content area, always + /// filling it completely: side by side when is + /// at least , stacked with the body + /// above the sidebar otherwise. + /// + public IRenderable Render(int width, int height, bool focused) + { + if (width <= 0 || height <= 0) + { + return new Markup(string.Empty); + } + + return width < StackedWidthThreshold + ? RenderStacked(width, height, focused) + : RenderSideBySide(width, height, focused); + } + + private void Scroll(int delta) => _bodyViewport.ScrollBy(delta); + + private IRenderable RenderSideBySide(int width, int height, bool focused) + { + var sidebarWidth = Math.Min(SidebarWidth, Math.Max(1, width - 1)); + var bodyWidth = Math.Max(1, width - sidebarWidth); + + return new Layout("detail").SplitColumns( + new Layout("body", RenderBodyPanel(bodyWidth, height, focused)), + new Layout("sidebar", RenderSidebarPanel(sidebarWidth, height, focused)).Size(sidebarWidth)); + } + + private IRenderable RenderStacked(int width, int height, bool focused) + { + const int minSidebarHeight = PanelChromeHeight + 1; + var maxSidebarHeight = Math.Max(minSidebarHeight, height - minSidebarHeight); + var desiredSidebarHeight = SidebarLineCount(width) + PanelChromeHeight; + var sidebarHeight = Math.Clamp(desiredSidebarHeight, minSidebarHeight, maxSidebarHeight); + var bodyHeight = Math.Max(1, height - sidebarHeight); + + return new Rows( + RenderBodyPanel(width, bodyHeight, focused), + RenderSidebarPanel(width, sidebarHeight, focused)); + } + + /// + /// The number of rows the sidebar's content occupies once its lines wrap + /// to the interior width the sidebar panel renders at , + /// matching the wrapping 's underlying + /// panel applies so the fixed panel height it is sized against never + /// clips a wrapped line. + /// + private int SidebarLineCount(int width) + { + if (_model.Task is not { } task) + { + return 1; + } + + var interiorWidth = Math.Max(1, width - PanelChromeWidth); + + return TaskDetailSidebar.Build(task, _model.Labels, _model.BlockedBy) + .Sum(line => TaskDetailSections.WrapLine(Markup.Remove(line), interiorWidth).Count); + } + + private Panel RenderSidebarPanel(int width, int height, bool focused) + { + var safeWidth = Math.Max(1, width); + var interiorHeight = Math.Max(1, height - PanelChromeHeight); + + IRenderable content = _model.Task is { } task + ? new Rows(PadToHeight( + TaskDetailSidebar.Build(task, _model.Labels, _model.BlockedBy), interiorHeight) + .Select(Row)) + : new Markup(Markup.Escape(NoTaskMessage())); + + var borderToken = focused ? "board.column.border.focused" : "board.column.border"; + + return new Panel(content) + { + Header = new PanelHeader("Details"), + Border = BoxBorder.Rounded, + BorderStyle = ThemeTokens.GetStyle(borderToken), + Width = safeWidth, + Height = Math.Max(1, height) + }; + } + + private Panel RenderBodyPanel(int width, int height, bool focused) + { + var safeWidth = Math.Max(1, width); + var interiorWidth = Math.Max(1, safeWidth - PanelChromeWidth); + var interiorHeight = Math.Max(1, height - PanelChromeHeight); + + var lines = TaskDetailBody.Build(_model, interiorWidth, focused); + + IRenderable content = lines.Count == 0 + ? Align.Center( + new Markup(Markup.Escape(_model.Task is null ? NoTaskMessage() : "No details.")), + VerticalAlignment.Middle) + : new Rows(RenderBodyLines(lines, interiorHeight).Select(Row)); + + var borderToken = focused ? "board.column.border.focused" : "board.column.border"; + + return new Panel(content) + { + Header = new PanelHeader(BuildBodyHeader()), + Border = BoxBorder.Rounded, + BorderStyle = ThemeTokens.GetStyle(borderToken), + Width = safeWidth, + Height = Math.Max(1, height) + }; + } + + private string BuildBodyHeader() + => _model.Task is { } task + ? $"[dim]{Markup.Escape(task.Id)}[/] [bold]{Markup.Escape(task.Title)}[/]" + : "Detail"; + + /// + /// Slices the body's visible window, reserving rows for "N more + /// above/below" indicators once the lines no longer fit + /// , keeping the selected dependency or + /// blocks row visible when the row selection changes, and padding the + /// result with blank lines so the panel's border reaches the bottom. + /// + private IReadOnlyList RenderBodyLines(IReadOnlyList lines, int interiorHeight) + { + var selectedLineIndex = IndexOfSelectedRow(lines); + var selectionChanged = _model.SelectedRowIndex != _lastSelectedRowIndex; + var reservedRows = 0; + + for (var pass = 0; pass < MaxIndicatorSettlePasses; pass++) + { + var windowHeight = Math.Max(0, interiorHeight - reservedRows); + _bodyViewport.Update(lines.Count, windowHeight); + + if (selectionChanged && selectedLineIndex >= 0) + { + _bodyViewport.EnsureVisible(selectedLineIndex); + } + + var needed = (_bodyViewport.HiddenAbove > 0 ? 1 : 0) + (_bodyViewport.HiddenBelow > 0 ? 1 : 0); + + if (needed == reservedRows) + { + break; + } + + reservedRows = needed; + } + + _lastSelectedRowIndex = _model.SelectedRowIndex; + + var (start, count) = _bodyViewport.Slice(); + var visible = new List(interiorHeight); + + if (_bodyViewport.HiddenAbove > 0) + { + visible.Add(FormatIndicator(_bodyViewport.HiddenAbove, "above")); + } + + for (var i = start; i < start + count; i++) + { + var line = lines[i]; + visible.Add(line.IsMarkup ? line.Content : Markup.Escape(line.Content)); + } + + if (_bodyViewport.HiddenBelow > 0) + { + visible.Add(FormatIndicator(_bodyViewport.HiddenBelow, "below")); + } + + return PadToHeight(visible, interiorHeight); + } + + /// + /// Wraps one already-escaped display line as markup. A blank line is + /// rendered as a single space: silently drops a + /// literal empty content row instead of showing it blank. + /// + private static IRenderable Row(string line) => new Markup(line.Length == 0 ? " " : line); + + private static IReadOnlyList PadToHeight(IReadOnlyList lines, int height) + { + if (lines.Count >= height) + { + return lines; + } + + var padded = new List(height); + padded.AddRange(lines); + + while (padded.Count < height) + { + padded.Add(string.Empty); + } + + return padded; + } + + private static int IndexOfSelectedRow(IReadOnlyList lines) + { + for (var i = 0; i < lines.Count; i++) + { + if (lines[i].IsSelectedRow) + { + return i; + } + } + + return -1; + } + + private static string FormatIndicator(int hiddenCount, string direction) => $" {hiddenCount} more {direction}"; + + private string NoTaskMessage() + => _model.CurrentTaskId is { } id ? $"Task {id} not found." : "No task selected."; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Editing/ConfirmDialog.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Editing/ConfirmDialog.cs new file mode 100644 index 00000000000..aecb9f7cd4a --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Editing/ConfirmDialog.cs @@ -0,0 +1,164 @@ +using ChilliCream.Nitro.CommandLine.Tui.Input; +using ChilliCream.Nitro.CommandLine.Tui.Widgets.Form; +using Spectre.Console.Rendering; + +namespace ChilliCream.Nitro.CommandLine.Tui.Editing; + +/// +/// The outcome of a completed interaction. +/// +internal abstract record ConfirmDialogResult +{ + private ConfirmDialogResult() + { + } + + /// + /// The confirm button was activated, or Enter was pressed while the + /// reason field had focus. Carries the reason text entered, or an empty + /// string when left blank. + /// + public sealed record Confirmed(string Reason) : ConfirmDialogResult; + + /// + /// The dialog was cancelled: Escape was pressed, or the cancel button was + /// activated. + /// + public sealed record Cancelled : ConfirmDialogResult; +} + +/// +/// A confirmation modal with a message, an optional single-line reason +/// , and a confirm/cancel button row. Escape cancels +/// from anywhere; Enter confirms from the reason field regardless of which +/// button is selected, or activates whichever button has focus. The host is +/// expected to feed it raw key input and stop showing it once +/// returns a non-null result. +/// +internal sealed class ConfirmDialog +{ + private const string ConfirmId = "confirm"; + private const string CancelId = "cancel"; + + private readonly TextField _reasonField; + private readonly FormButtons _buttons; + + private bool _fieldFocused = true; + + public ConfirmDialog( + string message, + string confirmLabel, + ButtonKind confirmKind = ButtonKind.Primary, + string cancelLabel = "Cancel") + { + ArgumentException.ThrowIfNullOrEmpty(message); + ArgumentException.ThrowIfNullOrEmpty(confirmLabel); + ArgumentException.ThrowIfNullOrEmpty(cancelLabel); + + Message = message; + _reasonField = new TextField("reason", "Reason (optional)"); + _buttons = new FormButtons( + [ + new FormButtonSpec(ConfirmId, confirmLabel, confirmKind), + new FormButtonSpec(CancelId, cancelLabel, ButtonKind.Secondary) + ]); + } + + /// + /// The confirmation prompt shown above the reason field. + /// + public string Message { get; } + + /// + /// The footer hints for this dialog: its keys are consumed entirely + /// while it is active, so no global hints follow. + /// + public static readonly IReadOnlyList Hints = + [ + new KeyHint("enter", "confirm"), + new KeyHint("esc", "cancel") + ]; + + /// + /// Handles one raw key. Returns while the dialog is + /// still active, or the terminal once + /// the interaction ends. + /// + public ConfirmDialogResult? HandleKey(ConsoleKeyInfo info) + { + if (_fieldFocused) + { + if (_reasonField.HandleKey(info)) + { + return null; + } + + return info.Key switch + { + ConsoleKey.Escape => new ConfirmDialogResult.Cancelled(), + ConsoleKey.Enter => new ConfirmDialogResult.Confirmed(GetReason()), + _ => HandleTraversal(info) + }; + } + + if (_buttons.HandleKey(info)) + { + return null; + } + + return info.Key switch + { + ConsoleKey.Escape => new ConfirmDialogResult.Cancelled(), + ConsoleKey.Enter => ActivateSelectedButton(), + _ => HandleTraversal(info) + }; + } + + /// + /// Renders the dialog as a rounded panel, centered within the given area. + /// + public IRenderable Render(int width, int height) + { + var panelWidth = Math.Clamp(width - 4, 20, 96); + + var sections = new List + { + new Markup(Markup.Escape(Message)), + _reasonField.Render(panelWidth, focused: _fieldFocused), + _buttons.Render(focused: !_fieldFocused) + }; + + var panel = new Panel(new Rows(sections)) + { + Border = BoxBorder.Rounded, + Width = panelWidth + 4 + }; + + return new Align(panel, HorizontalAlignment.Center, VerticalAlignment.Middle) + .Width(Math.Max(0, width)) + .Height(Math.Max(0, height)); + } + + private ConfirmDialogResult? HandleTraversal(ConsoleKeyInfo info) + { + switch (info.Key) + { + case ConsoleKey.Tab: + case ConsoleKey.UpArrow: + case ConsoleKey.DownArrow: + _fieldFocused = !_fieldFocused; + return null; + + default: + return null; + } + } + + private ConfirmDialogResult? ActivateSelectedButton() + => _buttons.SelectedId == CancelId + ? new ConfirmDialogResult.Cancelled() + : new ConfirmDialogResult.Confirmed(GetReason()); + + private string GetReason() + => _reasonField.GetValue() is FormValue.Text { Value: var value } ? value : ""; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Editing/PriorityPicker.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Editing/PriorityPicker.cs new file mode 100644 index 00000000000..7f8487b3612 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Editing/PriorityPicker.cs @@ -0,0 +1,77 @@ +using System.Globalization; +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Theming; + +namespace ChilliCream.Nitro.CommandLine.Tui.Editing; + +/// +/// Builds the priority quick picker for a task, and applies a picked +/// priority to the task store. +/// +internal static class PriorityPicker +{ + private static readonly (int Priority, string Label)[] WellKnownPriorities = + [ + (TaskPriorities.Critical, "Critical"), + (TaskPriorities.High, "High"), + (TaskPriorities.Medium, "Medium"), + (TaskPriorities.Low, "Low"), + (TaskPriorities.Backlog, "Backlog") + ]; + + /// + /// Builds the picker for : P0 critical through P4 + /// backlog with the priority color ramp, current value pre-selected. + /// + public static QuickPicker Create(TaskItem task) + => new( + "Priority", + [.. WellKnownPriorities.Select(p => new QuickPickerOption( + p.Priority.ToString(CultureInfo.InvariantCulture), RenderOption(p.Priority, p.Label)))], + initialSelectedId: task.Priority.ToString(CultureInfo.InvariantCulture)); + + /// + /// Writes to 's + /// priority field through . + /// + public static async Task ApplyAsync( + ITaskStore store, + TaskItem task, + int priority, + string actor, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(task); + ArgumentException.ThrowIfNullOrEmpty(actor); + + try + { + var update = new TaskUpdate + { + Actor = actor, + Priority = priority, + PriorityGiven = true + }; + + var result = await store.UpdateTaskAsync(task.Id, update, cancellationToken); + + var toastText = result.ChangedFields.Count == 0 + ? $"No changes to task '{task.Id}'." + : $"Priority set to '{TaskPriorities.Format(priority)}' for task '{task.Id}'."; + + return new TaskEditorOutcome.Succeeded(result.ChangedFields, toastText); + } + catch (ExitException ex) + { + return new TaskEditorOutcome.Failed(ex.Message); + } + } + + private static string RenderOption(int priority, string label) + { + var style = ThemeTokens.GetStyle($"badge.priority.p{priority}").ToMarkup(); + var text = $"{TaskPriorities.Format(priority)} {label}"; + return style.Length == 0 ? Markup.Escape(text) : $"[{style}]{Markup.Escape(text)}[/]"; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Editing/QuickPicker.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Editing/QuickPicker.cs new file mode 100644 index 00000000000..56e7bde124d --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Editing/QuickPicker.cs @@ -0,0 +1,156 @@ +using ChilliCream.Nitro.CommandLine.Tui.Input; +using Spectre.Console.Rendering; + +namespace ChilliCream.Nitro.CommandLine.Tui.Editing; + +/// +/// One selectable choice in a : an id and the +/// pre-styled Spectre markup fragment rendered for it. +/// +internal sealed record QuickPickerOption(string Id, string Markup); + +/// +/// The outcome of a completed interaction. +/// +internal abstract record QuickPickerResult +{ + private QuickPickerResult() + { + } + + /// + /// Enter was pressed: carries the id of the option that was selected. + /// + public sealed record Applied(string SelectedId) : QuickPickerResult; + + /// + /// Escape was pressed; nothing should change. + /// + public sealed record Cancelled : QuickPickerResult; +} + +/// +/// A single-select overlay listing a fixed set of options with one +/// pre-selected: j/k or the arrow keys move the selection, Enter applies it, +/// Escape cancels. The host is expected to feed it raw key input and stop +/// showing it once returns a non-null result. +/// +internal sealed class QuickPicker +{ + private const string SelectedMarker = "(o)"; + private const string UnselectedMarker = "( )"; + private const string SelectedRowStyle = "aqua"; + + private readonly IReadOnlyList _options; + + private int _selectedIndex; + + public QuickPicker(string title, IReadOnlyList options, string? initialSelectedId = null) + { + ArgumentException.ThrowIfNullOrEmpty(title); + ArgumentNullException.ThrowIfNull(options); + + if (options.Count == 0) + { + throw new ArgumentException("A quick picker must have at least one option.", nameof(options)); + } + + Title = title; + _options = options; + + var initialIndex = initialSelectedId is null + ? 0 + : _options.ToList().FindIndex(o => o.Id == initialSelectedId); + + _selectedIndex = Math.Max(0, initialIndex); + } + + /// + /// The title shown above the option list. + /// + public string Title { get; } + + /// + /// The footer hints for a quick picker: its keys are consumed entirely + /// while it is active, so no global hints follow. + /// + public static readonly IReadOnlyList Hints = + [ + new KeyHint("j/k", "select"), + new KeyHint("enter", "apply"), + new KeyHint("esc", "cancel") + ]; + + /// + /// Handles one raw key. Returns while the picker is + /// still active, or the terminal once the + /// interaction ends. + /// + public QuickPickerResult? HandleKey(ConsoleKeyInfo info) + { + switch (info.Key) + { + case ConsoleKey.J: + case ConsoleKey.DownArrow: + if (_selectedIndex < _options.Count - 1) + { + _selectedIndex++; + } + + return null; + + case ConsoleKey.K: + case ConsoleKey.UpArrow: + if (_selectedIndex > 0) + { + _selectedIndex--; + } + + return null; + + case ConsoleKey.Enter: + return new QuickPickerResult.Applied(_options[_selectedIndex].Id); + + case ConsoleKey.Escape: + return new QuickPickerResult.Cancelled(); + + default: + return null; + } + } + + /// + /// Renders the picker as a titled, rounded panel, centered within the + /// given area. + /// + public IRenderable Render(int width, int height) + { + var panelWidth = Math.Clamp(width - 4, 20, 60); + + var rows = new List(_options.Count); + + for (var i = 0; i < _options.Count; i++) + { + var marker = i == _selectedIndex ? SelectedMarker : UnselectedMarker; + var line = $"{marker} {_options[i].Markup}"; + + if (i == _selectedIndex) + { + line = $"[{SelectedRowStyle}]{line}[/]"; + } + + rows.Add(new Markup(line)); + } + + var panel = new Panel(new Rows(rows)) + { + Header = new PanelHeader(Markup.Escape(Title)), + Border = BoxBorder.Rounded, + Width = panelWidth + 4 + }; + + return new Align(panel, HorizontalAlignment.Center, VerticalAlignment.Middle) + .Width(Math.Max(0, width)) + .Height(Math.Max(0, height)); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Editing/StatusPicker.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Editing/StatusPicker.cs new file mode 100644 index 00000000000..5091f40e1aa --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Editing/StatusPicker.cs @@ -0,0 +1,75 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Widgets; + +namespace ChilliCream.Nitro.CommandLine.Tui.Editing; + +/// +/// Builds the status quick picker for a task, and applies a picked status to +/// the task store. Picking must not be +/// passed to : the store rejects a bare status write +/// to it, and the host is expected to route that selection through the close +/// confirmation flow () instead. +/// +internal static class StatusPicker +{ + private static readonly (string Id, string Label)[] WellKnownStatuses = + [ + (TaskStates.Open, "Open"), + (TaskStates.InProgress, "In Progress"), + (TaskStates.Blocked, "Blocked"), + (TaskStates.Deferred, "Deferred"), + (TaskStates.Closed, "Closed") + ]; + + /// + /// Builds the picker for : the well-known statuses + /// with their glyphs, current value pre-selected. + /// + public static QuickPicker Create(TaskItem task) + => new( + "Status", + [.. WellKnownStatuses.Select(status => new QuickPickerOption(status.Id, RenderOption(status.Id, status.Label)))], + initialSelectedId: task.Status); + + /// + /// Writes to 's status + /// field through . + /// + public static async Task ApplyAsync( + ITaskStore store, + TaskItem task, + string status, + string actor, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(task); + ArgumentException.ThrowIfNullOrEmpty(status); + ArgumentException.ThrowIfNullOrEmpty(actor); + + try + { + var update = new TaskUpdate + { + Actor = actor, + Status = status, + StatusGiven = true + }; + + var result = await store.UpdateTaskAsync(task.Id, update, cancellationToken); + + var toastText = result.ChangedFields.Count == 0 + ? $"No changes to task '{task.Id}'." + : $"Status set to '{status}' for task '{task.Id}'."; + + return new TaskEditorOutcome.Succeeded(result.ChangedFields, toastText); + } + catch (ExitException ex) + { + return new TaskEditorOutcome.Failed(ex.Message); + } + } + + private static string RenderOption(string status, string label) + => $"{TaskGlyphs.StatusMarkup(status)} {Markup.Escape(label)}"; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Editing/TaskCreateForm.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Editing/TaskCreateForm.cs new file mode 100644 index 00000000000..5fe71463cf3 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Editing/TaskCreateForm.cs @@ -0,0 +1,285 @@ +using System.Globalization; +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Input; +using ChilliCream.Nitro.CommandLine.Tui.Widgets.Form; +using Spectre.Console.Rendering; +using Form = ChilliCream.Nitro.CommandLine.Tui.Widgets.Form.Form; + +namespace ChilliCream.Nitro.CommandLine.Tui.Editing; + +/// +/// The outcome of submitting a against the task +/// store. +/// +internal abstract record TaskCreateOutcome +{ + private TaskCreateOutcome() + { + } + + /// + /// The task was created, carrying the id the store allocated for it. + /// + public sealed record Succeeded(string TaskId, string ToastText) : TaskCreateOutcome; + + /// + /// The store rejected the write, carrying its message. + /// + public sealed record Failed(string ToastText) : TaskCreateOutcome; + + /// + /// The shell toast this outcome should show: success styled for + /// , error styled for . + /// + public TuiMessage.ShowToast ToShowToast() => this switch + { + Succeeded succeeded => new TuiMessage.ShowToast(succeeded.ToastText, ToastStyle.Success), + Failed failed => new TuiMessage.ShowToast(failed.ToastText, ToastStyle.Error), + _ => throw new NotSupportedException() + }; +} + +/// +/// The task create form: title, type, priority, labels, and description. +/// Status is always open on create and is not a field; due, defer, and +/// estimate are not fields either. Built with an optional parent task id: when +/// given, the form gains a parent field defaulted to that parent, letting the +/// user switch it to create a root task instead. Submitting passes the +/// resulting parent id (or ) through to +/// , the same as the CLI's +/// task create --parent when a parent is kept. The host is expected to +/// feed it raw key input via and call +/// once it returns +/// on the primary button. +/// +internal sealed class TaskCreateForm +{ + public const string TitleFieldId = "title"; + public const string TypeFieldId = "type"; + public const string ParentFieldId = "parent"; + public const string PriorityFieldId = "priority"; + public const string LabelsFieldId = "labels"; + public const string DescriptionFieldId = "description"; + + public const string CreateButtonId = "create"; + public const string CancelButtonId = "cancel"; + + /// + /// The option that keeps the parent this form + /// was built with. + /// + public const string ChildParentOptionId = "child"; + + /// + /// The option that clears the parent this + /// form was built with, creating a root task instead. + /// + public const string NoParentOptionId = "none"; + + /// + /// The footer hints for the task create form: its keys are consumed + /// entirely while it is active, so no global hints follow. The create + /// hint advertises Ctrl+S rather than Ctrl+Enter because it is the chord + /// reliably distinguishable from a plain Enter across terminals. + /// + public static readonly IReadOnlyList Hints = + [ + new KeyHint("tab", "next field"), + new KeyHint("ctrl+s", "create"), + new KeyHint("esc", "cancel") + ]; + + private const string DefaultPriorityId = "2"; + + private static readonly SelectOption[] WellKnownTypes = + [ + new(TaskTypes.Task, "Task"), + new(TaskTypes.Bug, "Bug"), + new(TaskTypes.Feature, "Feature"), + new(TaskTypes.Epic, "Epic"), + new(TaskTypes.Chore, "Chore"), + new(TaskTypes.Docs, "Docs"), + new(TaskTypes.Question, "Question") + ]; + + private static readonly SelectOption[] WellKnownPriorities = + [ + new("0", TaskPriorities.Format(0)), + new("1", TaskPriorities.Format(1)), + new("2", TaskPriorities.Format(2)), + new("3", TaskPriorities.Format(3)), + new("4", TaskPriorities.Format(4)) + ]; + + private readonly string _typePreset; + private readonly string? _parentId; + private readonly Form _form; + private readonly TextField _titleField; + private readonly SelectField _typeField; + private readonly SelectField? _parentField; + private readonly SelectField _priorityField; + private readonly EditableListField _labelsField; + private readonly TextAreaField _descriptionField; + + public TaskCreateForm(string typePreset, string? parentId = null) + { + ArgumentException.ThrowIfNullOrEmpty(typePreset); + + _typePreset = typePreset; + _parentId = parentId; + + _titleField = new TextField( + TitleFieldId, + "Title", + required: true, + validator: RequireNonBlankTitle); + + _typeField = new SelectField( + TypeFieldId, + "Type", + WellKnownTypes, + initialSelectedId: typePreset); + + // A selected board row becomes the new task's parent by default, but + // this field lets the user clear it and create a root task instead: + // without it, a populated board column (which always has a + // selection) could never create a top-level task from the board. + _parentField = parentId is null + ? null + : new SelectField( + ParentFieldId, + "Parent", + [ + new SelectOption(ChildParentOptionId, $"Child of '{parentId}'"), + new SelectOption(NoParentOptionId, "No parent (top-level)") + ], + initialSelectedId: ChildParentOptionId); + + _priorityField = new SelectField( + PriorityFieldId, + "Priority", + WellKnownPriorities, + initialSelectedId: DefaultPriorityId); + + _labelsField = new EditableListField(LabelsFieldId, "Labels"); + _descriptionField = new TextAreaField(DescriptionFieldId, "Description"); + + var buttons = new FormButtons( + [ + new FormButtonSpec(CreateButtonId, "Create", ButtonKind.Primary), + new FormButtonSpec(CancelButtonId, "Cancel", ButtonKind.Secondary) + ]); + + IReadOnlyList fields = _parentField is null + ? [_titleField, _typeField, _priorityField, _labelsField, _descriptionField] + : [_titleField, _typeField, _parentField, _priorityField, _labelsField, _descriptionField]; + + _form = new Form(Title(typePreset, parentId), fields, buttons); + } + + /// + /// Whether any field's current value differs from its blank default: gates + /// whether Esc should ask for discard confirmation before cancelling. + /// + public bool IsDirty + => Text(_titleField).Length != 0 + || Text(_typeField) != _typePreset + || (_parentField is not null && Text(_parentField) != ChildParentOptionId) + || Text(_priorityField) != DefaultPriorityId + || Text(_descriptionField).Length != 0 + || List(_labelsField).Count != 0; + + /// + /// The field currently holding focus, or when + /// focus is on the button row. + /// + public FormField? FocusedField => _form.FocusedField; + + /// + /// Handles one raw key. Returns while the form is + /// still active, or the terminal once the + /// interaction ends. + /// + public FormResult? HandleKey(ConsoleKeyInfo info) => _form.HandleKey(info); + + /// + /// Renders the form as a titled, rounded panel centered within the given + /// area. + /// + public IRenderable Render(int width, int height) => _form.Render(width, height); + + /// + /// Creates the task from the submitted through + /// the task store, carrying the parent id this form was built with, if + /// any. + /// + public async Task SubmitAsync( + ITaskStore store, + IReadOnlyDictionary values, + string actor, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(values); + ArgumentException.ThrowIfNullOrEmpty(actor); + + var title = Text(values, TitleFieldId); + var type = Text(values, TypeFieldId); + var priority = int.Parse(Text(values, PriorityFieldId), CultureInfo.InvariantCulture); + var labels = List(values, LabelsFieldId); + var description = Text(values, DescriptionFieldId); + var parentId = _parentField is null + ? _parentId + : Text(values, ParentFieldId) == NoParentOptionId ? null : _parentId; + + try + { + var result = await store.CreateTaskAsync( + new TaskCreation + { + Title = title, + Description = description, + Priority = priority, + Type = type, + Labels = labels, + ParentId = parentId, + Actor = actor + }, + cancellationToken); + + return new TaskCreateOutcome.Succeeded(result.Id, $"Created task '{result.Id}'."); + } + catch (ExitException ex) + { + return new TaskCreateOutcome.Failed(ex.Message); + } + } + + private static string? RequireNonBlankTitle(FormValue value) + => value is FormValue.Text { Value: var text } && text.Trim().Length == 0 + ? "Title is required." + : null; + + private static string Title(string typePreset, string? parentId) + { + var typeLabel = typePreset.Length == 0 + ? typePreset + : char.ToUpperInvariant(typePreset[0]) + typePreset[1..]; + + return parentId is null + ? $"Create {typeLabel}" + : $"Create {typeLabel} (child of '{parentId}')"; + } + + private static string Text(FormField field) + => field.GetValue() is FormValue.Text { Value: var value } ? value : ""; + + private static IReadOnlyList List(FormField field) + => field.GetValue() is FormValue.List { Values: var values } ? values : []; + + private static string Text(IReadOnlyDictionary values, string id) + => values[id] is FormValue.Text { Value: var value } ? value : ""; + + private static IReadOnlyList List(IReadOnlyDictionary values, string id) + => values[id] is FormValue.List { Values: var value } ? value : []; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Editing/TaskEditorForm.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Editing/TaskEditorForm.cs new file mode 100644 index 00000000000..8fde2da1d90 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Editing/TaskEditorForm.cs @@ -0,0 +1,386 @@ +using System.Globalization; +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Input; +using ChilliCream.Nitro.CommandLine.Tui.Widgets.Form; +using Spectre.Console.Rendering; +using Form = ChilliCream.Nitro.CommandLine.Tui.Widgets.Form.Form; + +namespace ChilliCream.Nitro.CommandLine.Tui.Editing; + +/// +/// The task field values a was built from, used to +/// diff the submitted values against what the store last had. +/// +internal sealed record TaskEditorSnapshot( + string Title, + string Status, + int Priority, + string Type, + IReadOnlyList Labels, + string Description, + string Notes) +{ + /// + /// Captures a snapshot from a task and its labels, normalizing + /// 's description and notes line endings to match + /// what 's text areas will echo back + /// unmodified. + /// + public static TaskEditorSnapshot FromTask(TaskItem task, IReadOnlyList labels) + => new( + task.Title, + task.Status, + task.Priority, + task.Type, + labels, + NormalizeLineEndings(task.Description), + NormalizeLineEndings(task.Notes)); + + private static string NormalizeLineEndings(string value) + => value.Contains('\r') ? value.Replace("\r\n", "\n").Replace('\r', '\n') : value; +} + +/// +/// The outcome of submitting a against the task +/// store. +/// +internal abstract record TaskEditorOutcome +{ + private TaskEditorOutcome() + { + } + + /// + /// Every field that differed from the snapshot was written. Carries the + /// changed store field names, empty when no field differed. + /// + public sealed record Succeeded(IReadOnlyList ChangedFields, string ToastText) : TaskEditorOutcome; + + /// + /// The store rejected a write, carrying its message. + /// + public sealed record Failed(string ToastText) : TaskEditorOutcome; + + /// + /// The shell toast this outcome should show: success styled for + /// , error styled for . + /// + public TuiMessage.ShowToast ToShowToast() => this switch + { + Succeeded succeeded => new TuiMessage.ShowToast(succeeded.ToastText, ToastStyle.Success), + Failed failed => new TuiMessage.ShowToast(failed.ToastText, ToastStyle.Error), + _ => throw new NotSupportedException() + }; +} + +/// +/// The task edit form: title, status, priority, type, labels, description, +/// and notes, pre-populated from a task and its labels. Submitting computes +/// the diff against the loaded snapshot and writes only the changed fields +/// through the task store; label changes are applied as adds and removes +/// against the unchanged set. The host is expected to feed it raw key input +/// via and call once it +/// returns on the primary button. +/// +internal sealed class TaskEditorForm +{ + public const string TitleFieldId = "title"; + public const string StatusFieldId = "status"; + public const string PriorityFieldId = "priority"; + public const string TypeFieldId = "type"; + public const string LabelsFieldId = "labels"; + public const string DescriptionFieldId = "description"; + public const string NotesFieldId = "notes"; + + public const string SaveButtonId = "save"; + public const string CancelButtonId = "cancel"; + + /// + /// The footer hints for the task editor: its keys are consumed entirely + /// while it is active, so no global hints follow. The save hint advertises + /// Ctrl+S rather than Ctrl+Enter because it is the chord reliably + /// distinguishable from a plain Enter across terminals. + /// + public static readonly IReadOnlyList Hints = + [ + new KeyHint("tab", "next field"), + new KeyHint("ctrl+s", "save"), + new KeyHint("esc", "cancel") + ]; + + private static readonly SelectOption[] WellKnownStatuses = + [ + new(TaskStates.Open, "Open"), + new(TaskStates.InProgress, "In Progress"), + new(TaskStates.Blocked, "Blocked"), + new(TaskStates.Deferred, "Deferred") + ]; + + private static readonly SelectOption[] WellKnownPriorities = + [ + new("0", TaskPriorities.Format(0)), + new("1", TaskPriorities.Format(1)), + new("2", TaskPriorities.Format(2)), + new("3", TaskPriorities.Format(3)), + new("4", TaskPriorities.Format(4)) + ]; + + private static readonly SelectOption[] WellKnownTypes = + [ + new(TaskTypes.Task, "Task"), + new(TaskTypes.Bug, "Bug"), + new(TaskTypes.Feature, "Feature"), + new(TaskTypes.Epic, "Epic"), + new(TaskTypes.Chore, "Chore"), + new(TaskTypes.Docs, "Docs"), + new(TaskTypes.Question, "Question") + ]; + + private readonly string _taskId; + private readonly TaskEditorSnapshot _snapshot; + private readonly Form _form; + private readonly TextField _titleField; + private readonly SelectField _statusField; + private readonly SelectField _priorityField; + private readonly SelectField _typeField; + private readonly EditableListField _labelsField; + private readonly TextAreaField _descriptionField; + private readonly TextAreaField _notesField; + + public TaskEditorForm(TaskItem task, IReadOnlyList labels) + { + ArgumentNullException.ThrowIfNull(task); + ArgumentNullException.ThrowIfNull(labels); + + _taskId = task.Id; + _snapshot = TaskEditorSnapshot.FromTask(task, labels); + + _titleField = new TextField( + TitleFieldId, + "Title", + required: true, + initialValue: _snapshot.Title, + validator: RequireNonEmptyTitle); + + _statusField = new SelectField( + StatusFieldId, + "Status", + WithCurrentOption(WellKnownStatuses, _snapshot.Status), + initialSelectedId: _snapshot.Status); + + _priorityField = new SelectField( + PriorityFieldId, + "Priority", + WithCurrentOption(WellKnownPriorities, _snapshot.Priority.ToString(CultureInfo.InvariantCulture)), + initialSelectedId: _snapshot.Priority.ToString(CultureInfo.InvariantCulture)); + + _typeField = new SelectField( + TypeFieldId, + "Type", + WithCurrentOption(WellKnownTypes, _snapshot.Type), + initialSelectedId: _snapshot.Type); + + _labelsField = new EditableListField(LabelsFieldId, "Labels", initialValues: _snapshot.Labels); + _descriptionField = new TextAreaField(DescriptionFieldId, "Description", initialValue: _snapshot.Description); + _notesField = new TextAreaField(NotesFieldId, "Notes", initialValue: _snapshot.Notes); + + var buttons = new FormButtons( + [ + new FormButtonSpec(SaveButtonId, "Save", ButtonKind.Primary), + new FormButtonSpec(CancelButtonId, "Cancel", ButtonKind.Secondary) + ]); + + _form = new Form( + $"Edit Task '{task.Id}'", + [_titleField, _statusField, _priorityField, _typeField, _labelsField, _descriptionField, _notesField], + buttons); + } + + /// + /// Whether any field's current value differs from the loaded snapshot: + /// gates whether Esc should ask for discard confirmation before + /// cancelling. + /// + public bool IsDirty + => Text(_titleField) != _snapshot.Title + || Text(_statusField) != _snapshot.Status + || int.Parse(Text(_priorityField), CultureInfo.InvariantCulture) != _snapshot.Priority + || Text(_typeField) != _snapshot.Type + || Text(_descriptionField) != _snapshot.Description + || Text(_notesField) != _snapshot.Notes + || LabelsChanged(_snapshot.Labels, List(_labelsField)); + + /// + /// The field currently holding focus, or when + /// focus is on the button row. + /// + public FormField? FocusedField => _form.FocusedField; + + /// + /// Handles one raw key. Returns while the form is + /// still active, or the terminal once the + /// interaction ends. + /// + public FormResult? HandleKey(ConsoleKeyInfo info) => _form.HandleKey(info); + + /// + /// Renders the form as a titled, rounded panel centered within the given + /// area. + /// + public IRenderable Render(int width, int height) => _form.Render(width, height); + + /// + /// Applies the diff between (from a + /// ) and the loaded snapshot to the task + /// store: an call carrying only + /// the scalar fields that changed, plus label adds and removes for the + /// labels that changed. Writes nothing when no field differs. + /// + public async Task SubmitAsync( + ITaskStore store, + IReadOnlyDictionary values, + string actor, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(store); + ArgumentNullException.ThrowIfNull(values); + ArgumentException.ThrowIfNullOrEmpty(actor); + + var title = Text(values, TitleFieldId); + var status = Text(values, StatusFieldId); + var priority = int.Parse(Text(values, PriorityFieldId), CultureInfo.InvariantCulture); + var type = Text(values, TypeFieldId); + var description = Text(values, DescriptionFieldId); + var notes = Text(values, NotesFieldId); + var labels = List(values, LabelsFieldId); + + try + { + var changedFields = new List(); + + var titleGiven = title != _snapshot.Title; + var statusGiven = status != _snapshot.Status; + var priorityGiven = priority != _snapshot.Priority; + var typeGiven = type != _snapshot.Type; + var descriptionGiven = description != _snapshot.Description; + var notesGiven = notes != _snapshot.Notes; + + if (titleGiven || statusGiven || priorityGiven || typeGiven || descriptionGiven || notesGiven) + { + var update = new TaskUpdate + { + Actor = actor, + Title = title, + TitleGiven = titleGiven, + Status = status, + StatusGiven = statusGiven, + Priority = priority, + PriorityGiven = priorityGiven, + Type = type, + TypeGiven = typeGiven, + Description = description, + DescriptionGiven = descriptionGiven, + Notes = notes, + NotesGiven = notesGiven + }; + + var result = await store.UpdateTaskAsync(_taskId, update, cancellationToken); + changedFields.AddRange(result.ChangedFields); + } + + var (added, removed) = DiffLabels(_snapshot.Labels, labels); + + if (added.Count > 0) + { + await store.AddLabelAsync(_taskId, added, actor, cancellationToken); + changedFields.Add("labels"); + } + + foreach (var label in removed) + { + await store.RemoveLabelAsync(_taskId, label, actor, cancellationToken); + + if (!changedFields.Contains("labels")) + { + changedFields.Add("labels"); + } + } + + var toastText = changedFields.Count == 0 + ? $"No changes to task '{_taskId}'." + : $"Updated task '{_taskId}'."; + + return new TaskEditorOutcome.Succeeded(changedFields, toastText); + } + catch (ExitException ex) + { + return new TaskEditorOutcome.Failed(ex.Message); + } + } + + private static string? RequireNonEmptyTitle(FormValue value) + => value is FormValue.Text { Value: var text } && text.Trim().Length == 0 + ? "Title is required." + : null; + + /// + /// Returns as-is when it already contains + /// , otherwise appends it: keeps a task whose + /// status, priority, or type is a custom or terminal value round-tripping + /// as its own selected option instead of silently defaulting to the first + /// well-known choice. + /// + private static IReadOnlyList WithCurrentOption( + IReadOnlyList wellKnown, string currentId) + { + foreach (var option in wellKnown) + { + if (option.Id == currentId) + { + return wellKnown; + } + } + + return [.. wellKnown, new SelectOption(currentId, FormatFallbackLabel(currentId))]; + } + + private static string FormatFallbackLabel(string id) + => id.Length == 0 ? id : char.ToUpperInvariant(id[0]) + id[1..].Replace('_', ' '); + + private static bool LabelsChanged(IReadOnlyList before, IReadOnlyList after) + { + var (added, removed) = DiffLabels(before, after); + return added.Count > 0 || removed.Count > 0; + } + + /// + /// Computes the labels to add and remove to turn + /// into , comparing trimmed, lowercased values to + /// match the store's own label normalization. + /// + private static (IReadOnlyList Added, IReadOnlyList Removed) DiffLabels( + IReadOnlyList before, IReadOnlyList after) + { + var normalizedBefore = before.Select(NormalizeLabel).ToHashSet(); + var normalizedAfter = after.Select(NormalizeLabel).ToHashSet(); + + var added = normalizedAfter.Where(label => !normalizedBefore.Contains(label)).ToList(); + var removed = normalizedBefore.Where(label => !normalizedAfter.Contains(label)).ToList(); + + return (added, removed); + } + + private static string NormalizeLabel(string label) => label.Trim().ToLowerInvariant(); + + private static string Text(FormField field) + => field.GetValue() is FormValue.Text { Value: var value } ? value : ""; + + private static IReadOnlyList List(FormField field) + => field.GetValue() is FormValue.List { Values: var values } ? values : []; + + private static string Text(IReadOnlyDictionary values, string id) + => values[id] is FormValue.Text { Value: var value } ? value : ""; + + private static IReadOnlyList List(IReadOnlyDictionary values, string id) + => values[id] is FormValue.List { Values: var value } ? value : []; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Editing/TaskLifecycleActions.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Editing/TaskLifecycleActions.cs new file mode 100644 index 00000000000..6ad001c55cd --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Editing/TaskLifecycleActions.cs @@ -0,0 +1,169 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Input; +using ChilliCream.Nitro.CommandLine.Tui.Widgets.Form; + +namespace ChilliCream.Nitro.CommandLine.Tui.Editing; + +/// +/// The lifecycle write a resulted from. +/// +internal enum TaskLifecycleAction +{ + Close, + Reopen, + Delete +} + +/// +/// The outcome of one write against the +/// task store. +/// +internal abstract record TaskLifecycleOutcome +{ + private TaskLifecycleOutcome() + { + } + + /// + /// The write succeeded, carrying the task's state as returned by the + /// store. + /// + public sealed record Succeeded(TaskLifecycleAction Action, TaskItem Task, string ToastText) : TaskLifecycleOutcome + { + /// + /// Whether a detail view showing this task should pop back to its + /// originating list: true once the task is deleted, since it no + /// longer exists to display. + /// + public bool ShouldPopDetail => Action == TaskLifecycleAction.Delete; + } + + /// + /// The write was rejected: either the store threw , + /// or the action was gated (see ) + /// before ever reaching the store. + /// + public sealed record Failed(TaskLifecycleAction Action, string ToastText) : TaskLifecycleOutcome; + + /// + /// The shell toast this outcome should show: success styled for + /// , error styled for . + /// + public TuiMessage.ShowToast ToShowToast() => this switch + { + Succeeded succeeded => new TuiMessage.ShowToast(succeeded.ToastText, ToastStyle.Success), + Failed failed => new TuiMessage.ShowToast(failed.ToastText, ToastStyle.Error), + _ => throw new NotSupportedException() + }; +} + +/// +/// Close, reopen, and delete actions for the selected task: builds the +/// confirmation dialog for each action, and applies the confirmed action to +/// the task store. +/// +internal static class TaskLifecycleActions +{ + /// + /// Builds the confirmation dialog for closing . + /// + public static ConfirmDialog CreateCloseDialog(TaskItem task) + => new( + $"Close task '{task.Id}': \"{task.Title}\"? Dependents blocked on it are released.", + "Close"); + + /// + /// Builds the confirmation dialog for reopening . + /// Only meaningful when is true. + /// + public static ConfirmDialog CreateReopenDialog(TaskItem task) + => new($"Reopen task '{task.Id}': \"{task.Title}\"?", "Reopen"); + + /// + /// Builds the confirmation dialog for deleting . + /// + public static ConfirmDialog CreateDeleteDialog(TaskItem task) + => new( + $"Delete task '{task.Id}': \"{task.Title}\"? This tombstones the task.", + "Delete", + ButtonKind.Danger); + + /// + /// Whether reopening should be offered for : only a + /// closed task can be reopened. + /// + public static bool CanReopen(TaskItem task) => task.Status == TaskStates.Closed; + + /// + /// Closes with . + /// + public static async Task CloseAsync( + ITaskStore store, + TaskItem task, + string reason, + string actor, + CancellationToken cancellationToken) + { + try + { + var closed = await store.CloseTaskAsync([task.Id], reason, actor, cancellationToken); + return new TaskLifecycleOutcome.Succeeded( + TaskLifecycleAction.Close, closed[0], $"Closed task '{task.Id}'."); + } + catch (ExitException ex) + { + return new TaskLifecycleOutcome.Failed(TaskLifecycleAction.Close, ex.Message); + } + } + + /// + /// Reopens with . Fails + /// without writing to the store when is false. + /// + public static async Task ReopenAsync( + ITaskStore store, + TaskItem task, + string reason, + string actor, + CancellationToken cancellationToken) + { + if (!CanReopen(task)) + { + return new TaskLifecycleOutcome.Failed( + TaskLifecycleAction.Reopen, $"Task '{task.Id}' is not closed."); + } + + try + { + var reopened = await store.ReopenTaskAsync(task.Id, reason, actor, cancellationToken); + return new TaskLifecycleOutcome.Succeeded( + TaskLifecycleAction.Reopen, reopened, $"Reopened task '{task.Id}'."); + } + catch (ExitException ex) + { + return new TaskLifecycleOutcome.Failed(TaskLifecycleAction.Reopen, ex.Message); + } + } + + /// + /// Deletes (tombstones) with . + /// + public static async Task DeleteAsync( + ITaskStore store, + TaskItem task, + string reason, + string actor, + CancellationToken cancellationToken) + { + try + { + var deleted = await store.DeleteTaskAsync(task.Id, reason, actor, cancellationToken); + return new TaskLifecycleOutcome.Succeeded( + TaskLifecycleAction.Delete, deleted, $"Deleted task '{task.Id}'."); + } + catch (ExitException ex) + { + return new TaskLifecycleOutcome.Failed(TaskLifecycleAction.Delete, ex.Message); + } + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Input/KeyBinding.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Input/KeyBinding.cs new file mode 100644 index 00000000000..8434b3dcffb --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Input/KeyBinding.cs @@ -0,0 +1,32 @@ +namespace ChilliCream.Nitro.CommandLine.Tui.Input; + +/// +/// A key combination looked up in a : the console key, its +/// modifiers, and the character it produced. +/// +internal readonly record struct KeyChord(ConsoleKey Key, ConsoleModifiers Modifiers, char KeyChar) +{ + /// + /// Builds the for a raw . + /// + public static KeyChord From(ConsoleKeyInfo keyInfo) => + new(keyInfo.Key, keyInfo.Modifiers, keyInfo.KeyChar); +} + +/// +/// The footer display metadata for a : the short key +/// label shown dimmed (for example j/k or ctrl+e) and the +/// action label shown next to it (for example move or edit). +/// +internal readonly record struct KeyHint(string Key, string Action); + +/// +/// A binding from a to a factory that creates the +/// it produces. is the binding's +/// footer display metadata; a null hides the binding from +/// the footer while leaving it fully functional. Bindings that are paired +/// (for example j/k, or a printable key with its arrow-key equivalent) carry +/// their on a single representative binding so the pair +/// renders as one footer entry. +/// +internal sealed record KeyBinding(KeyChord Chord, Func CreateMessage, KeyHint? Hint = null); diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Input/KeyDispatcher.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Input/KeyDispatcher.cs new file mode 100644 index 00000000000..e8217629f02 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Input/KeyDispatcher.cs @@ -0,0 +1,38 @@ +namespace ChilliCream.Nitro.CommandLine.Tui.Input; + +/// +/// Resolves raw console key input into intents. +/// +internal sealed class KeyDispatcher +{ + private readonly KeyMap _globalKeyMap; + + public KeyDispatcher(KeyMap globalKeyMap) + { + _globalKeyMap = globalKeyMap ?? throw new ArgumentNullException(nameof(globalKeyMap)); + } + + /// + /// The global key table every dispatch falls back to. Exposed so the + /// footer can append its hints after whichever context-specific hints + /// are active. + /// + public KeyMap GlobalKeyMap => _globalKeyMap; + + /// + /// Resolves into a , checking + /// first, then the global key table. Returns + /// when the key is unbound in both. + /// + public TuiMessage? Dispatch(ConsoleKeyInfo keyInfo, KeyMap? modeKeyMap) + { + var chord = KeyChord.From(keyInfo); + + if (modeKeyMap is not null && modeKeyMap.TryResolve(chord, out var modeMessage)) + { + return modeMessage; + } + + return _globalKeyMap.TryResolve(chord, out var globalMessage) ? globalMessage : null; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Input/KeyMap.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Input/KeyMap.cs new file mode 100644 index 00000000000..b9c79603a8e --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Input/KeyMap.cs @@ -0,0 +1,187 @@ +using System.Diagnostics.CodeAnalysis; + +namespace ChilliCream.Nitro.CommandLine.Tui.Input; + +/// +/// A table of key bindings, looked up primarily by key chord. Bindings whose key char +/// is a printable character also resolve when the console key differs but the +/// modifiers and produced character match, so a binding is reachable regardless of +/// which a terminal reports for that character. +/// +internal sealed class KeyMap +{ + private readonly Dictionary> _bindings; + private readonly Dictionary<(char KeyChar, ConsoleModifiers Modifiers), Func> _charFallback; + + public KeyMap(IEnumerable bindings) + { + var bindingList = bindings as IReadOnlyCollection ?? bindings.ToList(); + _bindings = bindingList.ToDictionary(b => b.Chord, b => b.CreateMessage); + + _charFallback = new Dictionary<(char, ConsoleModifiers), Func>(); + foreach (var binding in bindingList) + { + if (IsPrintable(binding.Chord.KeyChar)) + { + _charFallback.TryAdd((binding.Chord.KeyChar, binding.Chord.Modifiers), binding.CreateMessage); + } + } + + Hints = bindingList + .Where(b => b.Hint is not null) + .Select(b => b.Hint!.Value) + .ToList(); + } + + /// + /// The footer hints carried by this map's bindings, in the order they were + /// given to the constructor. Bindings without a + /// are omitted. + /// + public IReadOnlyList Hints { get; } + + /// + /// Resolves the bound to , falling + /// back to a char-based match for printable characters when no binding matches the + /// exact chord. Returns when is + /// unbound in both. + /// + public bool TryResolve(KeyChord chord, [NotNullWhen(true)] out TuiMessage? message) + { + if (_bindings.TryGetValue(chord, out var createMessage)) + { + message = createMessage(); + return true; + } + + if (IsPrintable(chord.KeyChar) + && _charFallback.TryGetValue((chord.KeyChar, chord.Modifiers), out var fallbackMessage)) + { + message = fallbackMessage(); + return true; + } + + message = null; + return false; + } + + private static bool IsPrintable(char keyChar) => keyChar is not '\0' && !char.IsControl(keyChar); + + /// + /// The hardcoded global key bindings: vim-style navigation (j/k/h/l and arrow + /// keys, g/G for edges), Enter to open, r to refresh, y to copy the selected id, + /// q and Ctrl+C to request quit, Ctrl+N/Ctrl+P to cycle views, z to toggle the + /// maximized layout, / to jump into search, t to open the dependency tree on + /// the current selection, e to edit it, x to close or reopen it, X to delete + /// it, s to open the status quick picker on it, p to open the priority quick + /// picker on it, c to open the task create form (as a child of the current + /// selection when there is one), C to open it preset to the epic type, and + /// Escape to leave the active mode. + /// + /// + /// Only a curated subset carries a , so the + /// footer stays a short-help bar rather than a full binding dump: the four + /// direction keys collapse into one hjkl move hint (their arrow-key + /// equivalents stay unhinted), and the rarer or currently inert gestures + /// (g/G, /, t, x, X, s, p, c, C, Ctrl+C, and Ctrl+N/P, which cycles + /// between views but has nothing to cycle to until a second board view + /// exists) stay hidden from the footer while remaining fully bound. Quit's + /// binding is declared last so it is also the last global hint, per the + /// footer's display order. + /// + public static KeyMap CreateDefaultGlobal() => new( + [ + new KeyBinding( + new KeyChord(ConsoleKey.J, ConsoleModifiers.None, 'j'), + () => new TuiMessage.MoveCursor(CursorDirection.Down), + new KeyHint("hjkl", "move")), + new KeyBinding( + new KeyChord(ConsoleKey.DownArrow, ConsoleModifiers.None, '\0'), + () => new TuiMessage.MoveCursor(CursorDirection.Down)), + new KeyBinding( + new KeyChord(ConsoleKey.K, ConsoleModifiers.None, 'k'), + () => new TuiMessage.MoveCursor(CursorDirection.Up)), + new KeyBinding( + new KeyChord(ConsoleKey.UpArrow, ConsoleModifiers.None, '\0'), + () => new TuiMessage.MoveCursor(CursorDirection.Up)), + new KeyBinding( + new KeyChord(ConsoleKey.H, ConsoleModifiers.None, 'h'), + () => new TuiMessage.MoveCursor(CursorDirection.Left)), + new KeyBinding( + new KeyChord(ConsoleKey.LeftArrow, ConsoleModifiers.None, '\0'), + () => new TuiMessage.MoveCursor(CursorDirection.Left)), + new KeyBinding( + new KeyChord(ConsoleKey.L, ConsoleModifiers.None, 'l'), + () => new TuiMessage.MoveCursor(CursorDirection.Right)), + new KeyBinding( + new KeyChord(ConsoleKey.RightArrow, ConsoleModifiers.None, '\0'), + () => new TuiMessage.MoveCursor(CursorDirection.Right)), + new KeyBinding( + new KeyChord(ConsoleKey.G, ConsoleModifiers.None, 'g'), + () => new TuiMessage.MoveToEdge(EdgeTarget.Top)), + new KeyBinding( + new KeyChord(ConsoleKey.G, ConsoleModifiers.Shift, 'G'), + () => new TuiMessage.MoveToEdge(EdgeTarget.Bottom)), + new KeyBinding( + new KeyChord(ConsoleKey.Enter, ConsoleModifiers.None, '\r'), + () => new TuiMessage.OpenSelected(), + new KeyHint("enter", "open")), + new KeyBinding( + new KeyChord(ConsoleKey.R, ConsoleModifiers.None, 'r'), + () => new TuiMessage.RefreshRequested(), + new KeyHint("r", "refresh")), + new KeyBinding( + new KeyChord(ConsoleKey.Y, ConsoleModifiers.None, 'y'), + () => new TuiMessage.CopySelectedId(), + new KeyHint("y", "copy id")), + new KeyBinding( + new KeyChord(ConsoleKey.C, ConsoleModifiers.Control, '\u0003'), + () => new TuiMessage.QuitRequested()), + new KeyBinding( + new KeyChord(ConsoleKey.N, ConsoleModifiers.Control, '\u000e'), + () => new TuiMessage.CycleView(1)), + new KeyBinding( + new KeyChord(ConsoleKey.P, ConsoleModifiers.Control, '\u0010'), + () => new TuiMessage.CycleView(-1)), + new KeyBinding( + new KeyChord(ConsoleKey.Z, ConsoleModifiers.None, 'z'), + () => new TuiMessage.ToggleMaximize(), + new KeyHint("z", "zoom")), + new KeyBinding( + new KeyChord(ConsoleKey.Oem2, ConsoleModifiers.None, '/'), + () => new TuiMessage.FocusSearchRequested()), + new KeyBinding( + new KeyChord(ConsoleKey.T, ConsoleModifiers.None, 't'), + () => new TuiMessage.OpenTreeRequested()), + new KeyBinding( + new KeyChord(ConsoleKey.E, ConsoleModifiers.None, 'e'), + () => new TuiMessage.EditRequested(), + new KeyHint("e", "edit")), + new KeyBinding( + new KeyChord(ConsoleKey.X, ConsoleModifiers.None, 'x'), + () => new TuiMessage.CloseOrReopenRequested()), + new KeyBinding( + new KeyChord(ConsoleKey.X, ConsoleModifiers.Shift, 'X'), + () => new TuiMessage.DeleteRequested()), + new KeyBinding( + new KeyChord(ConsoleKey.S, ConsoleModifiers.None, 's'), + () => new TuiMessage.StatusPickerRequested()), + new KeyBinding( + new KeyChord(ConsoleKey.P, ConsoleModifiers.None, 'p'), + () => new TuiMessage.PriorityPickerRequested()), + new KeyBinding( + new KeyChord(ConsoleKey.C, ConsoleModifiers.None, 'c'), + () => new TuiMessage.CreateTaskRequested()), + new KeyBinding( + new KeyChord(ConsoleKey.C, ConsoleModifiers.Shift, 'C'), + () => new TuiMessage.CreateEpicRequested()), + new KeyBinding( + new KeyChord(ConsoleKey.Escape, ConsoleModifiers.None, ''), + () => new TuiMessage.Back(), + new KeyHint("esc", "back")), + new KeyBinding( + new KeyChord(ConsoleKey.Q, ConsoleModifiers.None, 'q'), + () => new TuiMessage.QuitRequested(), + new KeyHint("q", "quit")) + ]); +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Input/TuiMessage.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Input/TuiMessage.cs new file mode 100644 index 00000000000..aefdf053b22 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Input/TuiMessage.cs @@ -0,0 +1,177 @@ +namespace ChilliCream.Nitro.CommandLine.Tui.Input; + +/// +/// A semantic intent produced by the keymap layer. Modes and the shell handle these +/// instead of raw key input. +/// +internal abstract record TuiMessage +{ + private TuiMessage() + { + } + + /// + /// The user asked to quit. The shell is expected to confirm before exiting. + /// + public sealed record QuitRequested : TuiMessage; + + /// + /// The pending quit was confirmed. + /// + public sealed record ConfirmQuit : TuiMessage; + + /// + /// The pending quit was cancelled. + /// + public sealed record CancelQuit : TuiMessage; + + /// + /// The active mode should reload its data. + /// + public sealed record RefreshRequested : TuiMessage; + + /// + /// The selection cursor should move one step in . + /// + public sealed record MoveCursor(CursorDirection Direction) : TuiMessage; + + /// + /// The selection cursor should jump to of the current list. + /// + public sealed record MoveToEdge(EdgeTarget Edge) : TuiMessage; + + /// + /// The active view should change by positions. + /// + public sealed record CycleView(int Delta) : TuiMessage; + + /// + /// The active mode should toggle its maximized layout. + /// + public sealed record ToggleMaximize : TuiMessage; + + /// + /// The current selection should be opened. + /// + public sealed record OpenSelected : TuiMessage; + + /// + /// The identifier of the current selection should be copied. + /// + public sealed record CopySelectedId : TuiMessage; + + /// + /// A toast with should be shown using . + /// + public sealed record ShowToast(string Text, ToastStyle Style) : TuiMessage; + + /// + /// The active mode should be left in favor of whichever mode preceded it. + /// The shell is expected to pop its mode stack; has no effect at the base + /// mode. + /// + public sealed record Back : TuiMessage; + + /// + /// The search mode should become active with its query input focused. + /// + public sealed record FocusSearchRequested : TuiMessage; + + /// + /// The dependency tree should become active, rooted on the active mode's + /// currently selected task. + /// + public sealed record OpenTreeRequested : TuiMessage; + + /// + /// The task editor should open for the active mode's currently selected + /// task. + /// + public sealed record EditRequested : TuiMessage; + + /// + /// The close (or, when the task is already closed, reopen) confirmation + /// should open for the active mode's currently selected task. + /// + public sealed record CloseOrReopenRequested : TuiMessage; + + /// + /// The delete confirmation should open for the active mode's currently + /// selected task. + /// + public sealed record DeleteRequested : TuiMessage; + + /// + /// The status quick picker should open for the active mode's currently + /// selected task. + /// + public sealed record StatusPickerRequested : TuiMessage; + + /// + /// The priority quick picker should open for the active mode's currently + /// selected task. + /// + public sealed record PriorityPickerRequested : TuiMessage; + + /// + /// The dependency tree should toggle between the blocking-dependency and + /// parent-child edge sets. + /// + public sealed record ToggleTreeEdgeMode : TuiMessage; + + /// + /// The dependency tree should toggle between showing what the root + /// depends on and what depends on the root. + /// + public sealed record ToggleTreeDirection : TuiMessage; + + /// + /// The dependency tree should pop its breadcrumb stack and re-root on the + /// popped task. + /// + public sealed record NavigateTreeBack : TuiMessage; + + /// + /// The task create form should open, preset to the task type. When the + /// active mode has a currently selected task, the new task is created as + /// a child of it; otherwise it is created top-level. + /// + public sealed record CreateTaskRequested : TuiMessage; + + /// + /// The task create form should open the same way as + /// , preset to the epic type. + /// + public sealed record CreateEpicRequested : TuiMessage; +} + +/// +/// A direction the selection cursor can move in. +/// +internal enum CursorDirection +{ + Up, + Down, + Left, + Right +} + +/// +/// An edge of a list the selection cursor can jump to. +/// +internal enum EdgeTarget +{ + Top, + Bottom +} + +/// +/// The visual style of a toast message. +/// +internal enum ToastStyle +{ + Info, + Success, + Warn, + Error +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Runtime/TaskDbWatcher.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Runtime/TaskDbWatcher.cs new file mode 100644 index 00000000000..e2c1e39c48b --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Runtime/TaskDbWatcher.cs @@ -0,0 +1,101 @@ +using System.Threading.Channels; + +namespace ChilliCream.Nitro.CommandLine.Tui.Runtime; + +/// +/// A that watches a task workspace's SQLite database +/// file, including its -wal and -shm siblings, and publishes a +/// debounced whenever the on-disk data may +/// have changed. The parent directory is watched rather than the file itself so +/// that a full file replacement (not just an in-place write) is also caught. +/// +internal sealed class TaskDbWatcher(string databasePath, TimeSpan? debounce = null) +{ + private static readonly TimeSpan DefaultDebounce = TimeSpan.FromMilliseconds(200); + + private readonly string _databasePath = databasePath ?? throw new ArgumentNullException(nameof(databasePath)); + private readonly TimeSpan _debounce = debounce ?? DefaultDebounce; + + /// + /// Watches the database file until is + /// cancelled. When the parent directory does not exist or the file system does + /// not support watching it, this returns without writing anything, so the + /// caller degrades silently to manual refresh. + /// + public async Task RunAsync(ChannelWriter writer, CancellationToken cancellationToken) + { + var directory = Path.GetDirectoryName(Path.GetFullPath(_databasePath)); + + if (string.IsNullOrEmpty(directory) || !Directory.Exists(directory)) + { + return; + } + + FileSystemWatcher watcher; + + try + { + watcher = new FileSystemWatcher(directory) + { + NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.FileName + }; + } + catch (Exception ex) when (ex is ArgumentException or IOException or PlatformNotSupportedException) + { + return; + } + + await using var timer = new Timer(_ => writer.TryWrite(new TuiEvent.DataChangedEvent())); + var databaseFileName = Path.GetFileName(_databasePath); + + void OnEvent(object sender, FileSystemEventArgs e) + { + if (IsRelevant(e.Name, databaseFileName)) + { + timer.Change(_debounce, Timeout.InfiniteTimeSpan); + } + } + + watcher.Created += OnEvent; + watcher.Changed += OnEvent; + watcher.Renamed += OnEvent; + + try + { + try + { + watcher.EnableRaisingEvents = true; + } + catch (Exception ex) when (ex is IOException or PlatformNotSupportedException or UnauthorizedAccessException) + { + return; + } + + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected on shutdown. + } + } + finally + { + watcher.Created -= OnEvent; + watcher.Changed -= OnEvent; + watcher.Renamed -= OnEvent; + watcher.Dispose(); + } + } + + /// + /// Matches the database file itself or its WAL/SHM siblings, ignoring every + /// other file in the watched directory (for example the JSONL export). + /// + private static bool IsRelevant(string? changedName, string databaseFileName) + => changedName is not null + && (changedName == databaseFileName + || changedName == databaseFileName + "-wal" + || changedName == databaseFileName + "-shm"); +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Runtime/TerminalSession.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Runtime/TerminalSession.cs new file mode 100644 index 00000000000..6091a1fe78d --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Runtime/TerminalSession.cs @@ -0,0 +1,39 @@ +namespace ChilliCream.Nitro.CommandLine.Tui.Runtime; + +/// +/// Owns the terminal's alternate screen buffer and cursor visibility for the +/// lifetime of a TUI session. +/// +internal sealed class TerminalSession : IDisposable +{ + private readonly IAnsiConsole _console; + private bool _disposed; + + public TerminalSession(IAnsiConsole console) + { + _console = console ?? throw new ArgumentNullException(nameof(console)); + + _console.WriteAnsi(writer => + { + writer.EnterAltScreen(); + writer.CursorHome(); + }); + _console.Cursor.Hide(); + } + + /// + /// Restores the cursor and the primary screen buffer. Safe to call more than once. + /// + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + _console.Cursor.Show(); + _console.WriteAnsi(writer => writer.ExitAltScreen()); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Runtime/TuiApplication.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Runtime/TuiApplication.cs new file mode 100644 index 00000000000..41c7d8768ef --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Runtime/TuiApplication.cs @@ -0,0 +1,219 @@ +using System.Threading.Channels; +using Spectre.Console.Rendering; + +namespace ChilliCream.Nitro.CommandLine.Tui.Runtime; + +/// +/// Handles one and reports whether the current frame is dirty +/// and needs to be repainted. +/// +internal delegate bool TuiEventHandler(TuiEvent tuiEvent); + +/// +/// Produces the renderable for the current frame. +/// +internal delegate IRenderable TuiFrameRenderer(); + +/// +/// Runs until is cancelled, writing +/// s into as they occur. +/// Merged into the event loop alongside key input and ticks; a source that +/// cannot start (for example an unsupported file system) should return +/// without writing anything rather than throwing, so the loop degrades to +/// its other event sources instead of failing to start. +/// +internal delegate Task TuiEventSource(ChannelWriter writer, CancellationToken cancellationToken); + +/// +/// Runs the TUI event loop: merges raw key input and periodic ticks into a single +/// event stream, dispatches each event to a root handler, and repaints the live +/// display only when the handler reports the frame changed. +/// +internal sealed class TuiApplication +{ + private const int EventChannelCapacity = 64; + + private static readonly TimeSpan DefaultTickInterval = TimeSpan.FromMilliseconds(100); + private static readonly TimeSpan DefaultKeyPollInterval = TimeSpan.FromMilliseconds(15); + + private readonly IAnsiConsole _console; + private readonly TimeSpan _tickInterval; + private readonly TimeSpan _keyPollInterval; + + public TuiApplication( + IAnsiConsole console, + TimeSpan? tickInterval = null, + TimeSpan? keyPollInterval = null) + { + _console = console ?? throw new ArgumentNullException(nameof(console)); + _tickInterval = tickInterval ?? DefaultTickInterval; + _keyPollInterval = keyPollInterval ?? DefaultKeyPollInterval; + } + + /// + /// Runs the event loop until is cancelled or + /// the terminal delivers Ctrl+C, restoring the terminal before returning. + /// + /// Handles each merged event and reports whether the frame is dirty. + /// Produces the renderable for the current frame. + /// Stops the loop and restores the terminal when cancelled. + /// + /// Additional event sources (for example a data-change watcher) merged into the + /// same channel as key input and ticks. Each runs for the lifetime of the loop + /// and is cancelled and awaited alongside the built-in sources on every exit + /// path, including when throws. + /// + public async Task RunAsync( + TuiEventHandler rootHandler, + TuiFrameRenderer rootRenderer, + CancellationToken cancellationToken, + IReadOnlyList? eventSources = null) + { + ArgumentNullException.ThrowIfNull(rootHandler); + ArgumentNullException.ThrowIfNull(rootRenderer); + + var channel = Channel.CreateBounded(new BoundedChannelOptions(EventChannelCapacity) + { + FullMode = BoundedChannelFullMode.DropOldest, + SingleReader = true, + SingleWriter = false + }); + + using var loopCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + + ConsoleCancelEventHandler onCancelKeyPress = (_, e) => + { + // Prevent the default behavior (immediate process termination) so the + // session below still gets to restore the terminal. + e.Cancel = true; + loopCts.Cancel(); + }; + + Console.CancelKeyPress += onCancelKeyPress; + + try + { + using var session = new TerminalSession(_console); + + var keyReaderTask = Task.Run( + () => ReadKeysAsync(channel.Writer, loopCts.Token), + CancellationToken.None); + var tickTask = Task.Run( + () => ProduceTicksAsync(channel.Writer, loopCts.Token), + CancellationToken.None); + var additionalTasks = (eventSources ?? []) + .Select(source => Task.Run(() => source(channel.Writer, loopCts.Token), CancellationToken.None)) + .ToArray(); + + try + { + try + { + await _console.Live(rootRenderer()) + .StartAsync(async ctx => + { + // Spectre's Live display only paints once something calls + // Refresh/UpdateTarget; without this, the initial frame stays + // blank until the root handler first reports a dirty event. + ctx.Refresh(); + + await foreach (var tuiEvent in channel.Reader.ReadAllAsync(loopCts.Token)) + { + if (rootHandler(tuiEvent)) + { + ctx.UpdateTarget(rootRenderer()); + } + } + }) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (loopCts.IsCancellationRequested) + { + // Shutdown was requested (caller cancellation or Ctrl+C). + } + } + finally + { + // Ensure the background key-reader, tick, and additional event-source + // tasks are always stopped and awaited, even if the handler or + // renderer throws, so stdin is never left being consumed and no + // watcher outlives this loop after the method returns. + loopCts.Cancel(); + channel.Writer.TryComplete(); + + try + { + await Task.WhenAll([keyReaderTask, tickTask, .. additionalTasks]).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected once loopCts is cancelled. + } + } + } + finally + { + Console.CancelKeyPress -= onCancelKeyPress; + } + } + + private async Task ReadKeysAsync(ChannelWriter writer, CancellationToken cancellationToken) + { + var input = _console.Input; + + try + { + while (!cancellationToken.IsCancellationRequested) + { + if (input.IsKeyAvailable()) + { + var key = input.ReadKey(intercept: true); + if (key is { } info) + { + writer.TryWrite(new TuiEvent.KeyEvent(info)); + } + } + else + { + await Task.Delay(_keyPollInterval, cancellationToken).ConfigureAwait(false); + } + } + } + catch (OperationCanceledException) + { + // Expected on shutdown. + } + } + + private async Task ProduceTicksAsync(ChannelWriter writer, CancellationToken cancellationToken) + { + using var timer = new PeriodicTimer(_tickInterval); + + var lastWidth = _console.Profile.Width; + var lastHeight = _console.Profile.Height; + + try + { + while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) + { + var width = _console.Profile.Width; + var height = _console.Profile.Height; + + if (width != lastWidth || height != lastHeight) + { + lastWidth = width; + lastHeight = height; + writer.TryWrite(new TuiEvent.ResizeEvent(width, height)); + } + else + { + writer.TryWrite(new TuiEvent.TickEvent(DateTimeOffset.UtcNow)); + } + } + } + catch (OperationCanceledException) + { + // Expected on shutdown. + } + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Runtime/TuiEvent.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Runtime/TuiEvent.cs new file mode 100644 index 00000000000..a5fa0c1f306 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Runtime/TuiEvent.cs @@ -0,0 +1,31 @@ +namespace ChilliCream.Nitro.CommandLine.Tui.Runtime; + +/// +/// A single input to the TUI event loop. +/// +internal abstract record TuiEvent +{ + private TuiEvent() + { + } + + /// + /// A key was read from the terminal. + /// + public sealed record KeyEvent(ConsoleKeyInfo Info) : TuiEvent; + + /// + /// The terminal window size changed. + /// + public sealed record ResizeEvent(int Width, int Height) : TuiEvent; + + /// + /// A periodic tick fired with no other event pending. + /// + public sealed record TickEvent(DateTimeOffset Now) : TuiEvent; + + /// + /// Data outside the terminal changed, so the current frame may be stale. + /// + public sealed record DataChangedEvent : TuiEvent; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Search/SearchFocus.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Search/SearchFocus.cs new file mode 100644 index 00000000000..fc024afdf58 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Search/SearchFocus.cs @@ -0,0 +1,22 @@ +namespace ChilliCream.Nitro.CommandLine.Tui.Search; + +/// +/// Which of search mode's three panes currently holds focus. +/// +internal enum SearchFocus +{ + /// + /// The query input line at the top of the mode. + /// + Input, + + /// + /// The results list. + /// + List, + + /// + /// The detail panel for the selected task. + /// + Detail +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Search/SearchMode.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Search/SearchMode.cs new file mode 100644 index 00000000000..9567acb1653 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Search/SearchMode.cs @@ -0,0 +1,431 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Details; +using ChilliCream.Nitro.CommandLine.Tui.Input; +using ChilliCream.Nitro.CommandLine.Tui.Shell; +using ChilliCream.Nitro.CommandLine.Tui.Theming; +using Spectre.Console.Rendering; +using CursorDirection = ChilliCream.Nitro.CommandLine.Tui.Input.CursorDirection; + +namespace ChilliCream.Nitro.CommandLine.Tui.Search; + +/// +/// The search mode: a query input line, a live-updating results list, and a +/// detail panel for the selected task, with focus switching between the +/// three. Queries the store through and +/// ; issues no SQL of its own. +/// +/// +/// Text typed into the query input and the debounce that follows it are not +/// reachable through : the shell's key dispatch +/// only ever produces the closed set from a +/// . and +/// expose that behavior directly for the shell to +/// drive: raw key input while is , +/// and every tick, respectively. is likewise driven +/// directly by the shell for the '/' gesture, since jumping into search from +/// another mode has no equivalent in the closed set. +/// +internal sealed class SearchMode : ITuiMode +{ + /// + /// How long after the last keystroke a valid query is re-run against the + /// store. + /// + public static readonly TimeSpan DebounceWindow = TimeSpan.FromMilliseconds(200); + + private readonly ITaskStore _store; + private readonly SearchResultsList _results = new(); + private readonly TaskDetailModel _detailModel; + private readonly TaskDetailView _detailView; + + private SearchFocus _focus = SearchFocus.Input; + private string _queryText = ""; + private int _textCursor; + private TaskQuery _lastAppliedQuery = TaskQuery.Empty; + private TaskQuery? _pendingQuery; + private DateTimeOffset? _pendingQueryDueAt; + + public SearchMode(ITaskStore store) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _pendingQuery = TaskQuery.Empty; + _detailModel = new TaskDetailModel(store); + _detailView = new TaskDetailView(_detailModel); + } + + /// + /// Which pane currently holds focus. + /// + public SearchFocus Focus => _focus; + + /// + /// The current contents of the query input line. + /// + public string QueryText => _queryText; + + /// + /// The current parse error for , or null when it + /// parses successfully. + /// + public TaskQueryParseError? ParseError { get; private set; } + + /// + /// The id of the selected task in the results list, or null when the + /// list is empty. Detail and editing modes hook in through this. + /// + public string? SelectedTaskId => _results.SelectedTaskId; + + /// + /// The tasks currently loaded in the results list. + /// + public IReadOnlyList Results => _results.Tasks; + + /// + /// Search mode's own bindings: Escape walks focus back, mirroring h, and + /// Tab moves focus forward, mirroring Enter. Every other gesture (j/k/h/l, + /// arrows, Enter, r) comes from the global key table. + /// + public KeyMap? KeyMap { get; } = new KeyMap( + [ + new KeyBinding( + new KeyChord(ConsoleKey.Escape, ConsoleModifiers.None, '\u001b'), + () => new TuiMessage.MoveCursor(CursorDirection.Left), + new KeyHint("esc", "back")), + new KeyBinding( + new KeyChord(ConsoleKey.Tab, ConsoleModifiers.None, '\t'), + () => new TuiMessage.OpenSelected(), + new KeyHint("tab", "open")) + ]); + + /// + /// The footer hint shown in addition to 's while the + /// query input has focus: every other printable key is typed into the + /// query rather than dispatched. + /// + public static readonly KeyHint TypingHint = new("type", "search"); + + /// + /// The footer hint for Enter while the query input has focus: the one + /// global gesture that still falls through while typing. + /// + public static readonly KeyHint EnterHint = new("enter", "open"); + + public void OnEnter() + { + _pendingQuery = _lastAppliedQuery; + _pendingQueryDueAt = null; + } + + public void OnResize(int width, int height) + { + } + + public IReadOnlyList Handle(TuiMessage message) + { + switch (message) + { + case TuiMessage.RefreshRequested: + _pendingQuery = _lastAppliedQuery; + _pendingQueryDueAt = null; + return []; + + case TuiMessage.MoveCursor(var direction): + return HandleMoveCursor(direction); + + case TuiMessage.MoveToEdge(var edge): + if (_focus == SearchFocus.List) + { + _results.MoveToEdge(edge); + } + else if (_focus == SearchFocus.Detail) + { + if (edge == EdgeTarget.Top) + { + _detailView.ScrollToTop(); + } + else + { + _detailView.ScrollToBottom(); + } + } + + return []; + + case TuiMessage.OpenSelected: + HandleOpenSelected(); + return []; + + default: + return []; + } + } + + /// + /// Moves focus to the query input, the entry point for the '/' gesture. + /// + public void FocusInput() => _focus = SearchFocus.Input; + + /// + /// Handles one raw key while the query input has focus: cursor movement, + /// insertion, and deletion. Every edit re-parses + /// via ; a valid parse schedules a query + /// after , an invalid + /// one sets and schedules nothing. Has no effect + /// when a pane other than the input has focus. + /// + public void HandleQueryKey(ConsoleKeyInfo info, DateTimeOffset now) + { + if (_focus != SearchFocus.Input) + { + return; + } + + switch (info.Key) + { + case ConsoleKey.Backspace: + if (_textCursor == 0) + { + return; + } + + _queryText = _queryText.Remove(_textCursor - 1, 1); + _textCursor--; + break; + + case ConsoleKey.Delete: + if (_textCursor >= _queryText.Length) + { + return; + } + + _queryText = _queryText.Remove(_textCursor, 1); + break; + + case ConsoleKey.LeftArrow: + _textCursor = Math.Max(0, _textCursor - 1); + return; + + case ConsoleKey.RightArrow: + _textCursor = Math.Min(_queryText.Length, _textCursor + 1); + return; + + case ConsoleKey.Home: + _textCursor = 0; + return; + + case ConsoleKey.End: + _textCursor = _queryText.Length; + return; + + default: + if (char.IsControl(info.KeyChar)) + { + return; + } + + _queryText = _queryText.Insert(_textCursor, info.KeyChar.ToString()); + _textCursor++; + break; + } + + OnQueryTextChanged(now); + } + + /// + /// Runs the query pending from a debounced edit, a manual refresh, or + /// mode entry, once reaches its due time. Returns + /// whether a query ran. + /// + public async Task TickAsync(DateTimeOffset now, CancellationToken cancellationToken) + { + if (_pendingQuery is not { } query) + { + return false; + } + + if (_pendingQueryDueAt is { } dueAt && now < dueAt) + { + return false; + } + + _pendingQuery = null; + _pendingQueryDueAt = null; + + await RunQueryAsync(query, now, cancellationToken).ConfigureAwait(false); + return true; + } + + public IRenderable Render(int width, int height) + { + var inputArea = RenderInput(); + var inputHeight = ParseError is null ? 1 : 2; + var bodyHeight = Math.Max(0, height - inputHeight); + + var listWidth = Math.Max(1, width * 3 / 5); + var detailWidth = Math.Max(1, width - listWidth); + + var body = new Layout("body").SplitColumns( + new Layout("list", RenderListPane(listWidth, bodyHeight)), + new Layout("detail", RenderDetailPane(detailWidth, bodyHeight))); + + return new Layout("root").SplitRows( + new Layout("input", inputArea).Size(inputHeight), + body); + } + + private IReadOnlyList HandleMoveCursor(CursorDirection direction) + { + switch (direction) + { + case CursorDirection.Down: + case CursorDirection.Up: + if (_focus == SearchFocus.List) + { + _results.MoveCursor(direction); + } + else if (_focus == SearchFocus.Detail) + { + if (direction == CursorDirection.Down) + { + _detailView.ScrollDown(); + } + else + { + _detailView.ScrollUp(); + } + } + + break; + + case CursorDirection.Left: + if (_focus == SearchFocus.Input) + { + // Already at the leftmost pane: h/Escape leaves search + // mode instead of doing nothing. + return [new TuiMessage.Back()]; + } + + _focus = _focus switch + { + SearchFocus.Detail => SearchFocus.List, + SearchFocus.List => SearchFocus.Input, + _ => _focus + }; + + break; + + case CursorDirection.Right: + if (_focus == SearchFocus.List) + { + _focus = SearchFocus.Detail; + } + + break; + } + + return []; + } + + private void HandleOpenSelected() + { + _focus = _focus switch + { + SearchFocus.Input => SearchFocus.List, + SearchFocus.List => SearchFocus.Detail, + _ => _focus + }; + } + + private void OnQueryTextChanged(DateTimeOffset now) + { + if (TaskQueryParser.TryParse(_queryText, out var query, out var error)) + { + ParseError = null; + _pendingQuery = query; + _pendingQueryDueAt = now + DebounceWindow; + } + else + { + ParseError = error; + _pendingQuery = null; + _pendingQueryDueAt = null; + } + } + + private async Task RunQueryAsync(TaskQuery query, DateTimeOffset now, CancellationToken cancellationToken) + { + _lastAppliedQuery = query; + var filter = query.ToFilter(now); + var tasks = await _store.QueryTasksAsync(filter, cancellationToken).ConfigureAwait(false); + _results.SetTasks(tasks); + } + + private IRenderable RenderInput() + { + const string prefix = "> "; + var content = _focus == SearchFocus.Input + ? RenderWithCursor(_queryText, _textCursor) + : Markup.Escape(_queryText); + + var line = new Markup(Markup.Escape(prefix) + content); + + if (ParseError is not { } error) + { + return line; + } + + var errorStyle = ThemeTokens.GetStyle("toast.error.border").ToMarkup(); + var hint = new Markup($"[{errorStyle}]{Markup.Escape(error.Message)}[/]"); + return new Rows(line, hint); + } + + private static string RenderWithCursor(string text, int cursor) + { + var clampedCursor = Math.Clamp(cursor, 0, text.Length); + var before = Markup.Escape(text[..clampedCursor]); + var atCursor = clampedCursor < text.Length ? text[clampedCursor].ToString() : " "; + var after = clampedCursor < text.Length ? Markup.Escape(text[(clampedCursor + 1)..]) : string.Empty; + + return $"{before}[black on white]{Markup.Escape(atCursor)}[/]{after}"; + } + + private IRenderable RenderListPane(int width, int height) + { + var focused = _focus == SearchFocus.List; + var innerWidth = Math.Max(1, width - 4); + var innerHeight = Math.Max(1, height - 2); + var content = _results.Render(innerWidth, innerHeight, focused); + var borderToken = focused ? "board.column.border.focused" : "board.column.border"; + + return new Panel(content) + { + Header = new PanelHeader("Results"), + Border = BoxBorder.Rounded, + BorderStyle = ThemeTokens.GetStyle(borderToken) + }; + } + + private IRenderable RenderDetailPane(int width, int height) + { + var focused = _focus == SearchFocus.Detail; + + if (SelectedTaskId is not { } id) + { + var borderToken = focused ? "board.column.border.focused" : "board.column.border"; + + return new Panel(new Markup(Markup.Escape("No task selected."))) + { + Header = new PanelHeader("Detail"), + Border = BoxBorder.Rounded, + BorderStyle = ThemeTokens.GetStyle(borderToken) + }; + } + + if (_detailModel.CurrentTaskId != id) + { + _detailModel.LoadAsync(id, CancellationToken.None).GetAwaiter().GetResult(); + } + + return _detailView.Render(width, height, focused); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Search/SearchResultsList.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Search/SearchResultsList.cs new file mode 100644 index 00000000000..4641e7ed0b3 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Search/SearchResultsList.cs @@ -0,0 +1,137 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Input; +using ChilliCream.Nitro.CommandLine.Tui.Widgets; +using Spectre.Console.Rendering; +using CursorDirection = ChilliCream.Nitro.CommandLine.Tui.Input.CursorDirection; + +namespace ChilliCream.Nitro.CommandLine.Tui.Search; + +/// +/// The live results of a search mode query: the matching tasks, the visible +/// window over them, and which row is selected. Renders each row via +/// and a footer line reporting the total result +/// count. +/// +internal sealed class SearchResultsList +{ + private readonly Viewport _viewport = new(0, 0); + + /// + /// The tasks currently loaded, in display order. + /// + public IReadOnlyList Tasks { get; private set; } = []; + + /// + /// The index of the selected row within . + /// + public int SelectedIndex { get; private set; } + + /// + /// The id of the task at , or null when the + /// list is empty. + /// + public string? SelectedTaskId + => SelectedIndex >= 0 && SelectedIndex < Tasks.Count ? Tasks[SelectedIndex].Id : null; + + /// + /// Replaces the result set. The previously selected task stays selected + /// when it is still present in ; otherwise the + /// selected index clamps to the new list's bounds. + /// + public void SetTasks(IReadOnlyList tasks) + { + var selectedTaskId = SelectedTaskId; + Tasks = tasks; + + var preservedIndex = selectedTaskId is null ? -1 : IndexOf(tasks, selectedTaskId); + SelectedIndex = preservedIndex >= 0 + ? preservedIndex + : Math.Clamp(SelectedIndex, 0, Math.Max(0, tasks.Count - 1)); + + _viewport.Update(tasks.Count, _viewport.WindowHeight); + _viewport.EnsureVisible(SelectedIndex); + } + + /// + /// Moves the selected row one step in , + /// clamped to the list bounds. Directions other than up and down have no + /// effect. + /// + public void MoveCursor(CursorDirection direction) + { + if (Tasks.Count == 0) + { + return; + } + + SelectedIndex = direction switch + { + CursorDirection.Down => Math.Min(Tasks.Count - 1, SelectedIndex + 1), + CursorDirection.Up => Math.Max(0, SelectedIndex - 1), + _ => SelectedIndex + }; + + _viewport.EnsureVisible(SelectedIndex); + } + + /// + /// Moves the selected row to the given edge of the list. + /// + public void MoveToEdge(EdgeTarget edge) + { + if (Tasks.Count == 0) + { + return; + } + + SelectedIndex = edge == EdgeTarget.Top ? 0 : Tasks.Count - 1; + _viewport.EnsureVisible(SelectedIndex); + } + + /// + /// Renders the visible window of task rows, followed by a footer line + /// reporting the total result count. + /// + public IRenderable Render(int width, int height, bool focused) + { + var listHeight = Math.Max(0, height - 1); + _viewport.Update(Tasks.Count, listHeight); + _viewport.EnsureVisible(SelectedIndex); + + var (start, count) = _viewport.Slice(); + var lines = new List(count + 1); + + for (var i = start; i < start + count; i++) + { + var task = Tasks[i]; + var line = TaskBadge.Render( + task.Id, + task.Title, + task.Status, + task.Priority, + task.Type, + selected: focused && i == SelectedIndex, + maxWidth: width); + lines.Add(new Markup(line)); + } + + lines.Add(new Markup(Markup.Escape(FooterText()))); + + return new Rows(lines); + } + + private string FooterText() => $"{Tasks.Count} task(s)"; + + private static int IndexOf(IReadOnlyList tasks, string taskId) + { + for (var i = 0; i < tasks.Count; i++) + { + if (tasks[i].Id == taskId) + { + return i; + } + } + + return -1; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Search/TaskQuery.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Search/TaskQuery.cs new file mode 100644 index 00000000000..4a612446af7 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Search/TaskQuery.cs @@ -0,0 +1,122 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Tui.Search; + +/// +/// The structured result of parsing one search-mode query line, produced by +/// . Maps onto the store's +/// through , the single place that translates between +/// the two shapes. +/// +internal sealed record TaskQuery +{ + /// + /// A query with every field at its default, equivalent to an empty + /// input line. + /// + public static readonly TaskQuery Empty = new(); + + /// + /// The lookback window the keyword applies, matching + /// the task stale command's default threshold. + /// + public static readonly TimeSpan DefaultStaleWindow = TimeSpan.FromDays(30); + + /// + /// Free text accumulated from bare words, joined with a single space in + /// the order they appeared. Null when no bare words were given. + /// + public string? Text { get; init; } + + /// + /// Statuses from repeated status: tokens, normalized via + /// . Null when none were given. + /// + public IReadOnlyList? Statuses { get; init; } + + /// + /// The task type from a type: token, normalized via + /// . + /// + public string? Type { get; init; } + + /// + /// The priority from a p0..p4 keyword or a priority: token. + /// + public int? Priority { get; init; } + + /// + /// The assignee from an assignee: token. + /// + public string? Assignee { get; init; } + + /// + /// Labels from repeated label: tokens, trimmed and lowercased. Null + /// when none were given. + /// + public IReadOnlyList? Labels { get; init; } + + /// + /// The bare "ready" keyword: tasks with no unmet dependency and no + /// future defer date. + /// + public bool Ready { get; init; } + + /// + /// The bare "blocked" keyword: tasks the blocked-task computation + /// reports as blocked. has no field for this + /// (it only expresses excluding blocked tasks, for ); + /// leaves it out, and a consumer applies it by + /// intersecting query results with a separate blocked-task computation. + /// + public bool Blocked { get; init; } + + /// + /// The bare "stale" keyword: open or in-progress tasks not updated + /// within . + /// + public bool Stale { get; init; } + + /// + /// Maps this query onto a . + /// resolves the time-relative fields the and + /// keywords set. + /// + public TaskFilter ToFilter(DateTimeOffset now) + { + var filter = new TaskFilter + { + Statuses = Statuses?.ToArray(), + IncludeAll = Statuses is null, + ExcludeTombstones = true, + Type = Type, + Priority = Priority, + Assignee = Assignee, + Labels = Labels?.ToArray(), + Text = Text + }; + + if (Ready) + { + filter = filter with + { + Statuses = filter.Statuses ?? [TaskStates.Open], + ExcludeBlocked = true, + DeferredVisibleAt = now, + Ordering = TaskOrdering.ReadyPick + }; + } + + if (Stale) + { + filter = filter with + { + Statuses = filter.Statuses ?? [TaskStates.Open, TaskStates.InProgress], + UpdatedBefore = now - DefaultStaleWindow, + Ordering = TaskOrdering.UpdatedAtAscending + }; + } + + return filter; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Search/TaskQueryParser.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Search/TaskQueryParser.cs new file mode 100644 index 00000000000..2b863150f53 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Search/TaskQueryParser.cs @@ -0,0 +1,268 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Tui.Search; + +/// +/// A parse failure on a search-mode query line: an unknown key: prefix, or +/// an invalid value for a recognized key. is the +/// zero-based index of the failing token in the input string. +/// +internal readonly record struct TaskQueryParseError(string Message, int Position); + +/// +/// Parses one search-mode query line into a . +/// Whitespace-separated tokens: bare words accumulate into free text; +/// key:value tokens map to filter fields; quoting a token with double +/// quotes keeps embedded spaces and disables keyword and key: interpretation +/// for that token. Pure: no I/O, no wall-clock reads, no exceptions thrown +/// for malformed input. +/// +internal static class TaskQueryParser +{ + private const string ReadyKeyword = "ready"; + private const string BlockedKeyword = "blocked"; + private const string StaleKeyword = "stale"; + + private const string StatusKey = "status"; + private const string TypeKey = "type"; + private const string PriorityKey = "priority"; + private const string LabelKey = "label"; + private const string AssigneeKey = "assignee"; + + /// + /// Parses . Returns true with + /// populated on success. Returns false with + /// populated when a token has an unrecognized key: prefix or an invalid + /// value for a recognized key. + /// + public static bool TryParse( + string input, + out TaskQuery query, + out TaskQueryParseError error) + { + List? text = null; + List? statuses = null; + List? labels = null; + string? type = null; + int? priority = null; + string? assignee = null; + var ready = false; + var blocked = false; + var stale = false; + + foreach (var (raw, position) in Tokenize(input)) + { + if (TrySplitKeyValue(raw, out var key, out var value)) + { + switch (key.ToLowerInvariant()) + { + case StatusKey: + (statuses ??= []).Add(TaskStates.Normalize(value)); + break; + + case TypeKey: + type = TaskTypes.Normalize(value); + break; + + case PriorityKey: + if (!TryParsePriority(value, out var priorityFromKey)) + { + query = TaskQuery.Empty; + error = new TaskQueryParseError( + $"Invalid priority '{value}'. Use 0-4 or p0-p4 " + + "(0 = critical, 4 = backlog).", + position); + return false; + } + + priority = priorityFromKey; + break; + + case LabelKey: + (labels ??= []).Add(value.Trim().ToLowerInvariant()); + break; + + case AssigneeKey: + assignee = value; + break; + + default: + query = TaskQuery.Empty; + error = new TaskQueryParseError($"Unknown key '{key}:'.", position); + return false; + } + + continue; + } + + var isQuoted = raw.Length >= 2 && raw[0] == '"' && raw[^1] == '"'; + var word = Unquote(raw); + + if (!isQuoted && string.Equals(word, ReadyKeyword, StringComparison.OrdinalIgnoreCase)) + { + ready = true; + } + else if (!isQuoted + && string.Equals(word, BlockedKeyword, StringComparison.OrdinalIgnoreCase)) + { + blocked = true; + } + else if (!isQuoted + && string.Equals(word, StaleKeyword, StringComparison.OrdinalIgnoreCase)) + { + stale = true; + } + else if (!isQuoted && TryParseBarePriority(word, out var priorityFromWord)) + { + priority = priorityFromWord; + } + else + { + (text ??= []).Add(word); + } + } + + query = new TaskQuery + { + Text = text is null ? null : string.Join(' ', text), + Statuses = statuses, + Type = type, + Priority = priority, + Assignee = assignee, + Labels = labels, + Ready = ready, + Blocked = blocked, + Stale = stale + }; + error = default; + return true; + } + + /// + /// Splits a whitespace-delimited raw token into a key and value at its + /// first unquoted colon. Returns false when the token has no such + /// colon, so the caller treats it as a bare word. + /// + private static bool TrySplitKeyValue(string raw, out string key, out string value) + { + if (raw.Length == 0 || raw[0] == '"') + { + key = ""; + value = ""; + return false; + } + + var quoteIndex = raw.IndexOf('"'); + var searchLimit = quoteIndex >= 0 ? quoteIndex : raw.Length; + var colonIndex = raw.IndexOf(':', 0, searchLimit); + + if (colonIndex <= 0) + { + key = ""; + value = ""; + return false; + } + + key = raw[..colonIndex]; + value = Unquote(raw[(colonIndex + 1)..]); + return true; + } + + /// + /// Strips a single pair of matching double quotes wrapping the whole + /// value, if present. + /// + private static string Unquote(string value) + => value.Length >= 2 && value[0] == '"' && value[^1] == '"' + ? value[1..^1] + : value; + + /// + /// Parses "0".."4" or "p0".."p4" (case-insensitive), mirroring + /// without throwing. + /// + private static bool TryParsePriority(string value, out int priority) + { + var span = value.AsSpan().Trim(); + + if (span.Length > 1 && span[0] is 'p' or 'P') + { + span = span[1..]; + } + + if (span.Length == 1 && span[0] is >= '0' and <= '4') + { + priority = span[0] - '0'; + return true; + } + + priority = 0; + return false; + } + + /// + /// Matches a bare "p0".."p4" token (case-insensitive), the standalone + /// keyword form of a priority filter. + /// + private static bool TryParseBarePriority(string value, out int priority) + { + if (value.Length == 2 && value[0] is 'p' or 'P' && value[1] is >= '0' and <= '4') + { + priority = value[1] - '0'; + return true; + } + + priority = 0; + return false; + } + + /// + /// Splits input into whitespace-delimited tokens, treating a double-quoted + /// span as part of the enclosing token even when it contains whitespace. + /// Each token is paired with its zero-based start index in the input. + /// + private static IEnumerable<(string Value, int Position)> Tokenize(string input) + { + var i = 0; + var length = input.Length; + + while (i < length) + { + while (i < length && char.IsWhiteSpace(input[i])) + { + i++; + } + + if (i >= length) + { + yield break; + } + + var start = i; + + while (i < length && !char.IsWhiteSpace(input[i])) + { + if (input[i] == '"') + { + i++; + + while (i < length && input[i] != '"') + { + i++; + } + + if (i < length) + { + i++; + } + } + else + { + i++; + } + } + + yield return (input[start..i], start); + } + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Shell/ConfirmDialog.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Shell/ConfirmDialog.cs new file mode 100644 index 00000000000..59c92807eaf --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Shell/ConfirmDialog.cs @@ -0,0 +1,64 @@ +using ChilliCream.Nitro.CommandLine.Tui.Input; +using Spectre.Console.Rendering; + +namespace ChilliCream.Nitro.CommandLine.Tui.Shell; + +/// +/// A yes/no confirmation overlay. While active, it takes key priority over the +/// active mode: y confirms, n or Escape cancels. +/// +internal sealed class ConfirmDialog +{ + public ConfirmDialog(string message) + { + Message = message; + KeyMap = new KeyMap( + [ + new KeyBinding( + new KeyChord(ConsoleKey.Y, ConsoleModifiers.None, 'y'), + () => new TuiMessage.ConfirmQuit()), + new KeyBinding( + new KeyChord(ConsoleKey.N, ConsoleModifiers.None, 'n'), + () => new TuiMessage.CancelQuit()), + new KeyBinding( + new KeyChord(ConsoleKey.Escape, ConsoleModifiers.None, ''), + () => new TuiMessage.CancelQuit()) + ]); + } + + /// + /// The confirmation prompt shown in the dialog. + /// + public string Message { get; } + + /// + /// The key table this dialog resolves while it is active. + /// + public KeyMap KeyMap { get; } + + /// + /// The footer hints for this dialog. While the dialog is active it + /// consumes every key itself, so no global hints follow these. + /// + public static readonly IReadOnlyList Hints = + [ + new KeyHint("y", "confirm"), + new KeyHint("n/esc", "cancel") + ]; + + /// + /// Renders the dialog as a panel centered within the given area, replacing the + /// mode content it covers. + /// + public IRenderable Render(int width, int height) + { + var panel = new Panel(new Markup(Markup.Escape(Message))) + { + Border = BoxBorder.Rounded + }; + + return new Align(panel, HorizontalAlignment.Center, VerticalAlignment.Middle) + .Width(Math.Max(0, width)) + .Height(Math.Max(0, height)); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Shell/ITuiMode.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Shell/ITuiMode.cs new file mode 100644 index 00000000000..5500d2f1efc --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Shell/ITuiMode.cs @@ -0,0 +1,54 @@ +using ChilliCream.Nitro.CommandLine.Tui.Input; +using Spectre.Console.Rendering; + +namespace ChilliCream.Nitro.CommandLine.Tui.Shell; + +/// +/// A full-screen mode hosted by . Mode instances live for the +/// application lifetime so their state survives the shell switching away and back. +/// +internal interface ITuiMode +{ + /// + /// The mode-specific key table, checked before the global key table. + /// + KeyMap? KeyMap { get; } + + /// + /// Called when the mode becomes the active mode. + /// + void OnEnter(); + + /// + /// Called when the available content area changes size. + /// + void OnResize(int width, int height); + + /// + /// Handles , returning zero or more follow-up messages + /// for the shell to dispatch in turn. + /// + IReadOnlyList Handle(TuiMessage message); + + /// + /// Renders the mode into the given content area. + /// + IRenderable Render(int width, int height); + + /// + /// The id of the mode's currently selected task, or null when nothing is + /// selected or the mode has no notion of a single selected task. Drives + /// the shell's cross-mode gestures (open the dependency tree, edit, + /// close/reopen, delete) for whichever mode is active. + /// + string? SelectedTaskId => null; + + /// + /// Asks the mode to move its selection onto the task with the given id, + /// when it tracks one. The default no-op leaves modes that do not (or do + /// not yet) support moving their selection unaffected. + /// + void SelectTask(string id) + { + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Shell/Toaster.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Shell/Toaster.cs new file mode 100644 index 00000000000..a7ed6b46484 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Shell/Toaster.cs @@ -0,0 +1,77 @@ +using ChilliCream.Nitro.CommandLine.Tui.Input; +using ChilliCream.Nitro.CommandLine.Tui.Theming; +using Spectre.Console.Rendering; + +namespace ChilliCream.Nitro.CommandLine.Tui.Shell; + +/// +/// Queues toast messages and shows one at a time in a bottom-aligned status row, +/// advancing to the next queued toast once the current one expires. +/// +internal sealed class Toaster +{ + internal static readonly TimeSpan Duration = TimeSpan.FromSeconds(3); + + private readonly Queue<(string Text, ToastStyle Style)> _pending = new(); + private (string Text, ToastStyle Style)? _current; + private DateTimeOffset _currentShownAt; + + /// + /// Queues a toast with and . If no + /// toast is currently showing, it becomes the current toast immediately. + /// + public void Enqueue(string text, ToastStyle style, DateTimeOffset now) + { + _pending.Enqueue((text, style)); + + if (_current is null) + { + Advance(now); + } + } + + /// + /// Advances past the current toast once has elapsed since it + /// started showing. Returns whether the visible toast changed. + /// + public bool Tick(DateTimeOffset now) + { + if (_current is null || now - _currentShownAt < Duration) + { + return false; + } + + Advance(now); + return true; + } + + /// + /// Renders the current toast, or when none is showing. + /// + public IRenderable? Render() + { + if (_current is not { } toast) + { + return null; + } + + var (icon, token) = toast.Style switch + { + ToastStyle.Success => ("โœ”", "toast.success.border"), + ToastStyle.Warn => ("!", "toast.warn.border"), + ToastStyle.Error => ("โœ˜", "toast.error.border"), + _ => ("i", "toast.info.border") + }; + + var style = ThemeTokens.GetStyle(token).ToMarkup(); + var text = Markup.Escape(toast.Text); + + return new Markup($"[{style}]{icon} {text}[/]"); + } + + private void Advance(DateTimeOffset now) + { + _current = _pending.Count > 0 ? _pending.Dequeue() : null; + _currentShownAt = now; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Shell/TuiShell.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Shell/TuiShell.cs new file mode 100644 index 00000000000..3f695824b7b --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Shell/TuiShell.cs @@ -0,0 +1,877 @@ +using System.Globalization; +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Board; +using ChilliCream.Nitro.CommandLine.Tui.Editing; +using ChilliCream.Nitro.CommandLine.Tui.Input; +using ChilliCream.Nitro.CommandLine.Tui.Runtime; +using ChilliCream.Nitro.CommandLine.Tui.Search; +using ChilliCream.Nitro.CommandLine.Tui.Theming; +using ChilliCream.Nitro.CommandLine.Tui.Tree; +using ChilliCream.Nitro.CommandLine.Tui.Widgets.Form; +using Spectre.Console.Rendering; +using EditingConfirmDialog = ChilliCream.Nitro.CommandLine.Tui.Editing.ConfirmDialog; + +namespace ChilliCream.Nitro.CommandLine.Tui.Shell; + +/// +/// The root handler and renderer for . Owns the mode +/// stack and composites the shell-level overlays (the quit confirmation, the task +/// editor, the close/reopen/delete confirmation, the status and priority quick +/// pickers, the task create form, and the toast row) over whichever mode is active. +/// +internal sealed class TuiShell +{ + private const string QuitConfirmMessage = "Quit? (y/n)"; + private const int StatusRowHeight = 1; + private const string FooterSeparator = " "; + private const string FooterEllipsis = "โ€ฆ"; + + private readonly KeyDispatcher _dispatcher; + private readonly Stack _modeStack = new(); + private readonly Toaster _toaster = new(); + private readonly SearchMode? _searchMode; + private readonly DependencyTreeView? _treeView; + private readonly ITaskStore? _store; + private readonly string? _actor; + + private ITuiMode _activeMode; + private BoardDetailMode? _detailMode; + private ConfirmDialog? _confirmDialog; + private TaskEditorForm? _editorForm; + private EditingConfirmDialog? _lifecycleDialog; + private TaskItem? _lifecycleTask; + private TaskLifecycleAction _lifecycleAction; + private QuickPicker? _picker; + private TaskItem? _pickerTask; + private PickerKind _pickerKind; + private TaskCreateForm? _createForm; + private EditingConfirmDialog? _discardDialog; + private DiscardTarget _discardTarget; + private int _width; + private int _height; + + public TuiShell( + KeyDispatcher dispatcher, + ITuiMode activeMode, + int initialWidth, + int initialHeight, + SearchMode? searchMode = null, + DependencyTreeView? treeView = null, + ITaskStore? store = null, + string? actor = null) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _activeMode = activeMode ?? throw new ArgumentNullException(nameof(activeMode)); + _searchMode = searchMode; + _treeView = treeView; + _store = store; + _actor = actor; + _width = initialWidth; + _height = initialHeight; + _activeMode.OnEnter(); + } + + /// + /// Raised once a pending quit is confirmed. The caller is expected to stop the + /// event loop in response. + /// + public event Action? QuitConfirmed; + + private int ContentHeight => Math.Max(0, _height - StatusRowHeight); + + /// + /// Handles one , returning whether the frame needs to be + /// repainted. Matches the shape expected by + /// . + /// + public bool Handle(TuiEvent tuiEvent) => tuiEvent switch + { + TuiEvent.KeyEvent keyEvent => HandleKey(keyEvent.Info), + TuiEvent.ResizeEvent resize => HandleResize(resize.Width, resize.Height), + TuiEvent.TickEvent tick => HandleTick(tick.Now), + TuiEvent.DataChangedEvent => HandleMessage(new TuiMessage.RefreshRequested()), + _ => false + }; + + /// + /// Renders the current frame: whichever overlay is active, or the active mode, + /// filling the content region, with a reserved bottom row for the current + /// toast. Matches the shape expected by + /// . + /// + public IRenderable Render() + { + var contentHeight = ContentHeight; + + IRenderable content = _confirmDialog is { } quitDialog + ? quitDialog.Render(_width, contentHeight) + : _discardDialog is { } discardDialog + ? discardDialog.Render(_width, contentHeight) + : _editorForm is { } form + ? form.Render(_width, contentHeight) + : _lifecycleDialog is { } lifecycleDialog + ? lifecycleDialog.Render(_width, contentHeight) + : _picker is { } picker + ? picker.Render(_width, contentHeight) + : _createForm is { } createForm + ? createForm.Render(_width, contentHeight) + : _activeMode.Render(_width, contentHeight); + + var toastRow = _toaster.Render() ?? (IRenderable)new Markup(FormatFooter(BuildFooterHints(), _width)); + + return new Layout("root").SplitRows( + new Layout("content", content), + new Layout("status", toastRow).Size(StatusRowHeight)); + } + + private bool HandleResize(int width, int height) + { + _width = width; + _height = height; + _activeMode.OnResize(width, ContentHeight); + return true; + } + + private bool HandleTick(DateTimeOffset now) + { + var toastDirty = _toaster.Tick(now); + var searchDirty = _searchMode is { } search + && ReferenceEquals(_activeMode, search) + && search.TickAsync(now, CancellationToken.None).GetAwaiter().GetResult(); + + return toastDirty || searchDirty; + } + + private bool HandleKey(ConsoleKeyInfo info) + { + // The quit confirmation, the task editor, the close/reopen/delete + // confirmation, and the task create form are modal: while one is + // active it consumes every key itself, and unresolved keys are + // swallowed rather than falling through to the active mode or the + // global table. + if (_confirmDialog is { } quitDialog) + { + var chord = KeyChord.From(info); + return quitDialog.KeyMap.TryResolve(chord, out var quitMessage) && HandleMessage(quitMessage); + } + + if (_discardDialog is not null) + { + return HandleDiscardDialogKey(info); + } + + if (_editorForm is not null) + { + return HandleEditorFormKey(info); + } + + if (_lifecycleDialog is not null) + { + return HandleLifecycleDialogKey(info); + } + + if (_picker is not null) + { + return HandlePickerKey(info); + } + + if (_createForm is not null) + { + return HandleCreateFormKey(info); + } + + if (_searchMode is { } searchMode + && ReferenceEquals(_activeMode, searchMode) + && searchMode.Focus == SearchFocus.Input + && info.Key is not (ConsoleKey.Escape or ConsoleKey.Tab or ConsoleKey.Enter)) + { + searchMode.HandleQueryKey(info, DateTimeOffset.UtcNow); + return true; + } + + var message = _dispatcher.Dispatch(info, _activeMode.KeyMap); + return message is not null && HandleMessage(message); + } + + private bool HandleEditorFormKey(ConsoleKeyInfo info) + { + var result = _editorForm!.HandleKey(info); + + switch (result) + { + case null: + return true; + + case FormResult.Cancelled: + case FormResult.ButtonActivated { ButtonId: TaskEditorForm.CancelButtonId }: + return TryDiscardEditorForm(); + + case FormResult.Submitted submitted: + return SubmitEditorForm(submitted); + + default: + return true; + } + } + + private bool TryDiscardEditorForm() + { + if (_editorForm!.IsDirty) + { + _discardTarget = DiscardTarget.EditorForm; + _discardDialog = CreateDiscardDialog(); + return true; + } + + _editorForm = null; + return true; + } + + private bool SubmitEditorForm(FormResult.Submitted submitted) + { + var outcome = _editorForm!.SubmitAsync(_store!, submitted.Values, _actor!, CancellationToken.None) + .GetAwaiter().GetResult(); + + HandleMessage(outcome.ToShowToast()); + HandleMessage(new TuiMessage.RefreshRequested()); + + // A failed save leaves the form open with its entered values so the + // user can see the error and retry; only a successful save closes it. + if (outcome is TaskEditorOutcome.Succeeded) + { + _editorForm = null; + } + + return true; + } + + private bool HandleLifecycleDialogKey(ConsoleKeyInfo info) + { + var result = _lifecycleDialog!.HandleKey(info); + + switch (result) + { + case null: + return true; + + case ConfirmDialogResult.Cancelled: + _lifecycleDialog = null; + _lifecycleTask = null; + return true; + + case ConfirmDialogResult.Confirmed confirmed: + return SubmitLifecycleAction(confirmed.Reason); + + default: + return true; + } + } + + private bool SubmitLifecycleAction(string reason) + { + var task = _lifecycleTask!; + var action = _lifecycleAction; + _lifecycleDialog = null; + _lifecycleTask = null; + + var outcomeTask = action switch + { + TaskLifecycleAction.Close => TaskLifecycleActions.CloseAsync(_store!, task, reason, _actor!, CancellationToken.None), + TaskLifecycleAction.Reopen => TaskLifecycleActions.ReopenAsync(_store!, task, reason, _actor!, CancellationToken.None), + TaskLifecycleAction.Delete => TaskLifecycleActions.DeleteAsync(_store!, task, reason, _actor!, CancellationToken.None), + _ => throw new NotSupportedException() + }; + var outcome = outcomeTask.GetAwaiter().GetResult(); + + HandleMessage(outcome.ToShowToast()); + HandleMessage(new TuiMessage.RefreshRequested()); + return true; + } + + private bool HandleDiscardDialogKey(ConsoleKeyInfo info) + { + var result = _discardDialog!.HandleKey(info); + + switch (result) + { + case null: + return true; + + case ConfirmDialogResult.Confirmed: + _discardDialog = null; + + if (_discardTarget == DiscardTarget.EditorForm) + { + _editorForm = null; + } + else + { + _createForm = null; + } + + return true; + + case ConfirmDialogResult.Cancelled: + _discardDialog = null; + return true; + + default: + return true; + } + } + + /// + /// Builds the confirmation dialog shown when Esc is pressed on a dirty + /// task editor or create form: confirming discards the form's edits, + /// cancelling returns to it with its values intact. + /// + private static EditingConfirmDialog CreateDiscardDialog() + => new("Discard unsaved changes?", "Discard", ButtonKind.Danger); + + private bool HandlePickerKey(ConsoleKeyInfo info) + { + var result = _picker!.HandleKey(info); + + switch (result) + { + case null: + return true; + + case QuickPickerResult.Cancelled: + _picker = null; + _pickerTask = null; + return true; + + case QuickPickerResult.Applied applied: + return SubmitPicker(applied.SelectedId); + + default: + return true; + } + } + + private bool SubmitPicker(string selectedId) + { + var task = _pickerTask!; + var kind = _pickerKind; + _picker = null; + _pickerTask = null; + + // Picking Closed on the status picker is not a bare status write: it + // routes through the same close confirmation flow the x key uses. + if (kind == PickerKind.Status && selectedId == TaskStates.Closed) + { + _lifecycleTask = task; + _lifecycleAction = TaskLifecycleAction.Close; + _lifecycleDialog = TaskLifecycleActions.CreateCloseDialog(task); + return true; + } + + var outcomeTask = kind switch + { + PickerKind.Status => StatusPicker.ApplyAsync(_store!, task, selectedId, _actor!, CancellationToken.None), + PickerKind.Priority => PriorityPicker.ApplyAsync( + _store!, task, int.Parse(selectedId, CultureInfo.InvariantCulture), _actor!, CancellationToken.None), + _ => throw new NotSupportedException() + }; + var outcome = outcomeTask.GetAwaiter().GetResult(); + + HandleMessage(outcome.ToShowToast()); + HandleMessage(new TuiMessage.RefreshRequested()); + return true; + } + + private bool HandleCreateFormKey(ConsoleKeyInfo info) + { + var result = _createForm!.HandleKey(info); + + switch (result) + { + case null: + return true; + + case FormResult.Cancelled: + case FormResult.ButtonActivated { ButtonId: TaskCreateForm.CancelButtonId }: + return TryDiscardCreateForm(); + + case FormResult.Submitted submitted: + return SubmitCreateForm(submitted); + + default: + return true; + } + } + + private bool TryDiscardCreateForm() + { + if (_createForm!.IsDirty) + { + _discardTarget = DiscardTarget.CreateForm; + _discardDialog = CreateDiscardDialog(); + return true; + } + + _createForm = null; + return true; + } + + private bool SubmitCreateForm(FormResult.Submitted submitted) + { + var outcome = _createForm!.SubmitAsync(_store!, submitted.Values, _actor!, CancellationToken.None) + .GetAwaiter().GetResult(); + + HandleMessage(outcome.ToShowToast()); + HandleMessage(new TuiMessage.RefreshRequested()); + + // A failed save leaves the form open with its entered values so the + // user can see the error and retry; only a successful save closes it. + if (outcome is TaskCreateOutcome.Succeeded succeeded) + { + _createForm = null; + _activeMode.SelectTask(succeeded.TaskId); + } + + return true; + } + + private bool HandleMessage(TuiMessage message) + { + switch (message) + { + case TuiMessage.QuitRequested: + _confirmDialog = new ConfirmDialog(QuitConfirmMessage); + return true; + + case TuiMessage.ConfirmQuit: + _confirmDialog = null; + QuitConfirmed?.Invoke(); + return true; + + case TuiMessage.CancelQuit: + _confirmDialog = null; + return true; + + case TuiMessage.ShowToast showToast: + _toaster.Enqueue(showToast.Text, showToast.Style, DateTimeOffset.UtcNow); + return true; + + case TuiMessage.Back: + PopMode(); + return true; + + case TuiMessage.OpenSelected when _activeMode is BoardMode: + return TryOpenDetail(); + + case TuiMessage.FocusSearchRequested: + if (_searchMode is not { } search) + { + return false; + } + + SwitchTo(search); + search.FocusInput(); + return true; + + case TuiMessage.OpenTreeRequested: + return TryOpenTree(); + + case TuiMessage.EditRequested: + return TryOpenEditor(); + + case TuiMessage.CloseOrReopenRequested: + return TryOpenCloseOrReopenDialog(); + + case TuiMessage.DeleteRequested: + return TryOpenDeleteDialog(); + + case TuiMessage.StatusPickerRequested: + return TryOpenPicker(PickerKind.Status); + + case TuiMessage.PriorityPickerRequested: + return TryOpenPicker(PickerKind.Priority); + + case TuiMessage.CreateTaskRequested: + return TryOpenCreateForm(TaskTypes.Task); + + case TuiMessage.CreateEpicRequested: + return TryOpenCreateForm(TaskTypes.Epic); + + default: + foreach (var followUp in _activeMode.Handle(message)) + { + HandleMessage(followUp); + } + + return true; + } + } + + /// + /// Switches to the board's task detail mode, rooted on the board's + /// currently selected task. Reuses the same mode-stack semantics as + /// : Back returns to the board with its + /// selection untouched. + /// + private bool TryOpenDetail() + { + if (_store is null) + { + return false; + } + + if (_activeMode.SelectedTaskId is not { } id) + { + return ShowToastNow("No task selected.", ToastStyle.Warn); + } + + _detailMode ??= new BoardDetailMode(_store); + _detailMode.OpenOnTask(id); + SwitchTo(_detailMode); + return true; + } + + private bool TryOpenTree() + { + if (_treeView is not { } tree) + { + return false; + } + + if (_activeMode.SelectedTaskId is not { } id) + { + return ShowToastNow("No task selected.", ToastStyle.Warn); + } + + tree.EnterOnTask(id); + SwitchTo(tree); + return true; + } + + private bool TryOpenEditor() + { + if (_store is null) + { + return false; + } + + if (_activeMode.SelectedTaskId is not { } id) + { + return ShowToastNow("No task selected.", ToastStyle.Warn); + } + + var task = _store.GetTaskAsync(id, CancellationToken.None).GetAwaiter().GetResult(); + + if (task is null) + { + return ShowToastNow($"Task '{id}' not found.", ToastStyle.Error); + } + + var labels = _store.GetLabelsAsync(id, CancellationToken.None).GetAwaiter().GetResult(); + _editorForm = new TaskEditorForm(task, labels); + return true; + } + + private bool TryOpenCloseOrReopenDialog() + { + if (_store is null) + { + return false; + } + + if (LoadSelectedTask() is not { } task) + { + return true; + } + + _lifecycleTask = task; + + if (TaskLifecycleActions.CanReopen(task)) + { + _lifecycleAction = TaskLifecycleAction.Reopen; + _lifecycleDialog = TaskLifecycleActions.CreateReopenDialog(task); + } + else + { + _lifecycleAction = TaskLifecycleAction.Close; + _lifecycleDialog = TaskLifecycleActions.CreateCloseDialog(task); + } + + return true; + } + + private bool TryOpenDeleteDialog() + { + if (_store is null) + { + return false; + } + + if (LoadSelectedTask() is not { } task) + { + return true; + } + + _lifecycleTask = task; + _lifecycleAction = TaskLifecycleAction.Delete; + _lifecycleDialog = TaskLifecycleActions.CreateDeleteDialog(task); + return true; + } + + private bool TryOpenPicker(PickerKind kind) + { + if (_store is null) + { + return false; + } + + if (LoadSelectedTask() is not { } task) + { + return true; + } + + _pickerTask = task; + _pickerKind = kind; + _picker = kind == PickerKind.Status ? StatusPicker.Create(task) : PriorityPicker.Create(task); + return true; + } + + private bool TryOpenCreateForm(string typePreset) + { + if (_store is null) + { + return false; + } + + // A selected task becomes the new task's parent: creating unconditionally + // requires no selection (unlike edit, lifecycle, and the pickers), so no + // "no task selected" toast gates this on the active mode's selection. + _createForm = new TaskCreateForm(typePreset, _activeMode.SelectedTaskId); + return true; + } + + private TaskItem? LoadSelectedTask() + { + if (_store is null) + { + return null; + } + + if (_activeMode.SelectedTaskId is not { } id) + { + ShowToastNow("No task selected.", ToastStyle.Warn); + return null; + } + + var task = _store.GetTaskAsync(id, CancellationToken.None).GetAwaiter().GetResult(); + + if (task is null) + { + ShowToastNow($"Task '{id}' not found.", ToastStyle.Error); + } + + return task; + } + + private bool ShowToastNow(string text, ToastStyle style) + { + _toaster.Enqueue(text, style, DateTimeOffset.UtcNow); + return true; + } + + private void SwitchTo(ITuiMode mode) + { + if (ReferenceEquals(_activeMode, mode)) + { + return; + } + + _modeStack.Push(_activeMode); + _activeMode = mode; + _activeMode.OnResize(_width, ContentHeight); + _activeMode.OnEnter(); + } + + private void PopMode() + { + if (_modeStack.Count == 0) + { + return; + } + + _activeMode = _modeStack.Pop(); + _activeMode.OnResize(_width, ContentHeight); + _activeMode.OnEnter(); + } + + /// + /// Builds the footer's hint list for whichever context currently owns + /// key input, mirroring 's own priority order so + /// the footer can never show a hint the active input context would not + /// actually honor. The fully modal overlays (the quit confirmation, the + /// discard confirmation, the task editor, the lifecycle confirmation, + /// the quick pickers, and the task create form) show only their own + /// hints, since they consume every key themselves; the search mode's + /// query input is treated the same way while it has focus, since + /// swallows every key that is + /// not one of its own bindings into the query rather than falling + /// through to the global table. Every other context's hints are + /// followed by the global table's, with quit last. + /// + private IReadOnlyList BuildFooterHints() + { + if (_confirmDialog is not null) + { + return ConfirmDialog.Hints; + } + + if (_discardDialog is not null) + { + return EditingConfirmDialog.Hints; + } + + if (_editorForm is not null) + { + return TaskEditorForm.Hints; + } + + if (_lifecycleDialog is not null) + { + return EditingConfirmDialog.Hints; + } + + if (_picker is not null) + { + return QuickPicker.Hints; + } + + if (_createForm is not null) + { + return TaskCreateForm.Hints; + } + + var contextHints = _activeMode.KeyMap?.Hints ?? []; + + if (_searchMode is { } search + && ReferenceEquals(_activeMode, search) + && search.Focus == SearchFocus.Input) + { + return [SearchMode.TypingHint, .. contextHints, SearchMode.EnterHint]; + } + + return Combine(contextHints); + } + + /// + /// Appends the global key table's hints after , + /// matching how falls back to the + /// global table for anything a context-specific key table does not bind. + /// A global hint already present among + /// (for example a mode's own back-to-global Escape binding) is not + /// repeated. + /// + private IReadOnlyList Combine(IReadOnlyList contextHints) + { + var globalHints = _dispatcher.GlobalKeyMap.Hints; + + if (contextHints.Count == 0) + { + return globalHints; + } + + if (globalHints.Count == 0) + { + return contextHints; + } + + var seen = new HashSet(contextHints); + var combined = new List(contextHints.Count + globalHints.Count); + combined.AddRange(contextHints); + + foreach (var hint in globalHints) + { + if (seen.Add(hint)) + { + combined.Add(hint); + } + } + + return combined; + } + + /// + /// Formats as the footer's single status-row + /// line: dimmed key labels, normal-weight action labels, separated hint + /// entries, truncated with a trailing ellipsis once + /// cannot fit every hint. + /// + private static string FormatFooter(IReadOnlyList hints, int width) + { + if (width <= 0 || hints.Count == 0) + { + return string.Empty; + } + + var keyStyle = ThemeTokens.GetStyle("footer.key").ToMarkup(); + var actionStyle = ThemeTokens.GetStyle("footer.action").ToMarkup(); + + var plainItems = new string[hints.Count]; + var markupItems = new string[hints.Count]; + + for (var i = 0; i < hints.Count; i++) + { + plainItems[i] = $"{hints[i].Key} {hints[i].Action}"; + markupItems[i] = + $"[{keyStyle}]{Markup.Escape(hints[i].Key)}[/] [{actionStyle}]{Markup.Escape(hints[i].Action)}[/]"; + } + + var fullPlainWidth = plainItems.Sum(item => item.Length) + FooterSeparator.Length * (hints.Count - 1); + + if (fullPlainWidth <= width) + { + return string.Join(FooterSeparator, markupItems); + } + + var included = 0; + var usedWidth = 0; + var trailerWidth = FooterSeparator.Length + FooterEllipsis.Length; + + for (var i = 0; i < hints.Count; i++) + { + var itemWidth = (i == 0 ? 0 : FooterSeparator.Length) + plainItems[i].Length; + + if (usedWidth + itemWidth + trailerWidth > width) + { + break; + } + + usedWidth += itemWidth; + included++; + } + + if (included == 0) + { + return width >= FooterEllipsis.Length ? FooterEllipsis : string.Empty; + } + + return string.Join(FooterSeparator, markupItems.Take(included)) + FooterSeparator + FooterEllipsis; + } +} + +/// +/// Which field a 's active quick picker is editing. +/// +internal enum PickerKind +{ + Status, + Priority +} + +/// +/// Which form a 's active discard confirmation applies +/// to. +/// +internal enum DiscardTarget +{ + EditorForm, + CreateForm +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Theming/ThemeTokens.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Theming/ThemeTokens.cs new file mode 100644 index 00000000000..f6778728161 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Theming/ThemeTokens.cs @@ -0,0 +1,89 @@ +namespace ChilliCream.Nitro.CommandLine.Tui.Theming; + +/// +/// Looks up styles and colors by dotted token name (for example +/// board.column.border.focused or badge.priority.p0). One +/// built-in theme is registered; unknown tokens resolve to +/// so callers never need to null-check. +/// +internal static class ThemeTokens +{ + private static readonly IReadOnlyDictionary Active = DefaultTheme.Tokens; + + /// + /// Resolves to a style, or + /// if the token is not registered. + /// + public static Style GetStyle(string token) + => Active.TryGetValue(token, out var style) ? style : Style.Plain; + + /// + /// Resolves to a foreground color, or + /// if the token is not registered. + /// + public static Color GetColor(string token) + => GetStyle(token).Foreground; +} + +/// +/// The single built-in token set backing . +/// +internal static class DefaultTheme +{ + public static readonly IReadOnlyDictionary Tokens = new Dictionary + { + ["board.column.border"] = new Style(Color.Grey), + ["board.column.border.focused"] = new Style(Color.Aqua), + + ["board.column.status.blocked"] = new Style(Color.Red), + ["board.column.status.blocked.focused"] = new Style(Color.Red, decoration: Decoration.Bold), + ["board.column.status.deferred"] = new Style(Color.Yellow), + ["board.column.status.deferred.focused"] = new Style(Color.Yellow, decoration: Decoration.Bold), + ["board.column.status.ready"] = new Style(Color.Green), + ["board.column.status.ready.focused"] = new Style(Color.Green, decoration: Decoration.Bold), + ["board.column.status.inprogress"] = new Style(Color.Blue), + ["board.column.status.inprogress.focused"] = new Style(Color.Blue, decoration: Decoration.Bold), + ["board.column.status.closed"] = new Style(Color.Grey, decoration: Decoration.Dim), + ["board.column.status.closed.focused"] = new Style(Color.Grey, decoration: Decoration.Bold), + + ["badge.priority.p0"] = new Style(Color.Red), + ["badge.priority.p1"] = new Style(Color.Orange1), + ["badge.priority.p2"] = new Style(Color.Yellow), + ["badge.priority.p3"] = new Style(Color.Grey70), + ["badge.priority.p4"] = new Style(Color.Grey50), + + ["badge.type.bug"] = new Style(Color.Red3), + ["badge.type.feature"] = new Style(Color.SkyBlue1), + ["badge.type.task"] = new Style(Color.Grey70), + ["badge.type.epic"] = new Style(Color.MediumPurple1), + ["badge.type.chore"] = new Style(Color.Grey50), + ["badge.type.docs"] = new Style(Color.Grey58), + ["badge.type.question"] = new Style(Color.Gold1), + + ["status.glyph.closed"] = new Style(Color.Green), + ["status.glyph.in_progress"] = new Style(Color.Yellow), + ["status.glyph.open"] = new Style(Color.Grey70), + ["status.glyph.deferred"] = new Style(Color.Grey50), + ["status.glyph.blocked"] = new Style(Color.Red), + + ["selection.highlight"] = new Style(Color.Default, Color.Grey35), + + ["detail.section.header"] = new Style(decoration: Decoration.Bold), + ["detail.section.border"] = new Style(Color.Grey), + + ["toast.info.border"] = new Style(Color.SkyBlue1), + ["toast.success.border"] = new Style(Color.Green), + ["toast.warn.border"] = new Style(Color.Orange1), + ["toast.error.border"] = new Style(Color.Red), + + ["form.button.primary"] = new Style(Color.Black, Color.Green), + ["form.button.primary.focused"] = new Style(Color.Black, Color.Green, Decoration.Bold), + ["form.button.secondary"] = new Style(Color.Black, Color.Grey70), + ["form.button.secondary.focused"] = new Style(Color.Black, Color.Grey70, Decoration.Bold), + ["form.button.danger"] = new Style(Color.White, Color.Red), + ["form.button.danger.focused"] = new Style(Color.White, Color.Red, Decoration.Bold), + + ["footer.key"] = new Style(Color.Grey58, decoration: Decoration.Dim), + ["footer.action"] = new Style(Color.Grey70) + }; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Tree/DependencyTreeView.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Tree/DependencyTreeView.cs new file mode 100644 index 00000000000..29ce0d1b55c --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Tree/DependencyTreeView.cs @@ -0,0 +1,439 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Input; +using ChilliCream.Nitro.CommandLine.Tui.Shell; +using ChilliCream.Nitro.CommandLine.Tui.Theming; +using ChilliCream.Nitro.CommandLine.Tui.Widgets; +using Spectre.Console.Rendering; +using CursorDirection = ChilliCream.Nitro.CommandLine.Tui.Input.CursorDirection; + +namespace ChilliCream.Nitro.CommandLine.Tui.Tree; + +/// +/// The dependency tree explorer : renders a task's +/// dependency graph as a windowed, connector-drawn tree, and lets the cursor +/// refocus the tree on any node while remembering the path back. +/// +/// +/// Enter and f both refocus the tree on the cursor node, and Enter is +/// already reachable through the global key table via +/// ; maps f to the +/// same message. m toggles the edge mode, d toggles the direction, and u +/// pops the breadcrumb stack, via , +/// , and +/// . Entering the tree on a task +/// selected in another mode, and leaving it, are shell-level concerns: the +/// shell drives directly the same way +/// SearchMode.HandleQueryKey is driven directly for gestures outside +/// the closed set. +/// +internal sealed class DependencyTreeView : ITuiMode +{ + private readonly ITaskStore _store; + private readonly Stack _breadcrumbs = new(); + private readonly Viewport _viewport = new(0, 0); + + private Dictionary _tasksById = new(); + private IReadOnlyList _edges = []; + private IReadOnlyList _rows = []; + private string _rootId; + private TreeEdgeMode _edgeMode = TreeEdgeMode.Blocking; + private TreeDirection _direction = TreeDirection.Up; + private int _cursorIndex; + + public DependencyTreeView(ITaskStore store, string rootId) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _rootId = rootId ?? throw new ArgumentNullException(nameof(rootId)); + } + + /// + /// The id of the tree's current root. + /// + public string RootId => _rootId; + + /// + /// Which edges the tree currently follows. + /// + public TreeEdgeMode EdgeMode => _edgeMode; + + /// + /// Which way the tree currently traverses edges from its root. + /// + public TreeDirection Direction => _direction; + + /// + /// The rows of the tree currently loaded, in display order. + /// + public IReadOnlyList Rows => _rows; + + /// + /// The id of the task at the cursor, or null when the tree is empty. + /// Detail navigation hooks in through this. + /// + public string? SelectedTaskId + => _cursorIndex >= 0 && _cursorIndex < _rows.Count ? _rows[_cursorIndex].TaskId : null; + + /// + /// Whether has a previous root to return to. + /// + public bool CanNavigateBack => _breadcrumbs.Count > 0; + + /// + /// f mirrors the global Enter binding, refocusing the tree on the cursor + /// node. m toggles the edge mode, d toggles the direction, and u pops the + /// breadcrumb stack. + /// + public KeyMap? KeyMap { get; } = new KeyMap( + [ + new KeyBinding( + new KeyChord(ConsoleKey.F, ConsoleModifiers.None, 'f'), + () => new TuiMessage.OpenSelected(), + new KeyHint("f", "follow")), + new KeyBinding( + new KeyChord(ConsoleKey.M, ConsoleModifiers.None, 'm'), + () => new TuiMessage.ToggleTreeEdgeMode(), + new KeyHint("m", "edge mode")), + new KeyBinding( + new KeyChord(ConsoleKey.D, ConsoleModifiers.None, 'd'), + () => new TuiMessage.ToggleTreeDirection(), + new KeyHint("d", "direction")), + new KeyBinding( + new KeyChord(ConsoleKey.U, ConsoleModifiers.None, 'u'), + () => new TuiMessage.NavigateTreeBack(), + new KeyHint("u", "back")) + ]); + + public void OnEnter() => RefreshBlocking(); + + public void OnResize(int width, int height) + { + } + + public IReadOnlyList Handle(TuiMessage message) + { + switch (message) + { + case TuiMessage.MoveCursor(CursorDirection.Down): + MoveCursor(1); + return []; + + case TuiMessage.MoveCursor(CursorDirection.Up): + MoveCursor(-1); + return []; + + case TuiMessage.MoveToEdge(var edge): + MoveToEdge(edge); + return []; + + case TuiMessage.OpenSelected: + Refocus(); + return []; + + case TuiMessage.RefreshRequested: + RefreshBlocking(); + return []; + + case TuiMessage.ToggleTreeEdgeMode: + ToggleEdgeMode(); + return []; + + case TuiMessage.ToggleTreeDirection: + ToggleDirection(); + return []; + + case TuiMessage.NavigateTreeBack: + NavigateBack(); + return []; + + default: + return []; + } + } + + /// + /// Resets the tree to a new root and entry state: clears the breadcrumb + /// stack, resets the cursor, and reloads the graph from the store. + /// + public void EnterOnTask(string taskId) + { + ArgumentNullException.ThrowIfNull(taskId); + + _rootId = taskId; + _breadcrumbs.Clear(); + _cursorIndex = 0; + RefreshBlocking(); + } + + /// + /// Pushes the current root onto the breadcrumb stack and rebuilds the + /// tree rooted at the cursor node. Has no effect when the tree is empty + /// or the cursor is already on the root. + /// + public void Refocus() + { + if (SelectedTaskId is not { } targetId || targetId == _rootId) + { + return; + } + + _breadcrumbs.Push(_rootId); + _rootId = targetId; + _cursorIndex = 0; + Rebuild(); + } + + /// + /// Pops the breadcrumb stack and rebuilds the tree rooted at the popped + /// task id. Returns whether the stack had an entry to pop. + /// + public bool NavigateBack() + { + if (_breadcrumbs.Count == 0) + { + return false; + } + + _rootId = _breadcrumbs.Pop(); + _cursorIndex = 0; + Rebuild(); + return true; + } + + /// + /// Toggles between the blocking-dependency tree and the parent-child + /// tree, keeping the cursor on the same task when it is still present. + /// + public void ToggleEdgeMode() + { + _edgeMode = _edgeMode == TreeEdgeMode.Blocking ? TreeEdgeMode.ParentChild : TreeEdgeMode.Blocking; + Rebuild(); + } + + /// + /// Toggles between showing what the root depends on and what depends on + /// the root, keeping the cursor on the same task when it is still + /// present. + /// + public void ToggleDirection() + { + _direction = _direction == TreeDirection.Up ? TreeDirection.Down : TreeDirection.Up; + Rebuild(); + } + + public IRenderable Render(int width, int height) + { + var innerWidth = Math.Max(1, width - 4); + var innerHeight = Math.Max(1, height - 2); + var (lines, maxRowWidth) = RenderLines(innerWidth, innerHeight); + var headerBudget = maxRowWidth == 0 ? innerWidth : Math.Min(innerWidth, maxRowWidth); + + return new Panel(lines.Count == 0 ? new Markup(string.Empty) : new Rows(lines)) + { + Header = new PanelHeader(BuildTitle(headerBudget)), + Border = BoxBorder.Rounded, + BorderStyle = ThemeTokens.GetStyle("board.column.border.focused") + }; + } + + private void MoveCursor(int delta) + { + if (_rows.Count == 0) + { + return; + } + + _cursorIndex = Math.Clamp(_cursorIndex + delta, 0, _rows.Count - 1); + _viewport.EnsureVisible(_cursorIndex); + } + + private void MoveToEdge(EdgeTarget edge) + { + if (_rows.Count == 0) + { + return; + } + + _cursorIndex = edge == EdgeTarget.Top ? 0 : _rows.Count - 1; + _viewport.EnsureVisible(_cursorIndex); + } + + private void RefreshBlocking() + { + var tasks = _store.QueryTasksAsync(new TaskFilter { IncludeAll = true }, CancellationToken.None) + .GetAwaiter().GetResult(); + _tasksById = tasks.ToDictionary(t => t.Id); + _edges = _store.GetDependencyEdgesAsync(CancellationToken.None).GetAwaiter().GetResult(); + Rebuild(); + } + + private void Rebuild() + { + var selectedTaskId = SelectedTaskId; + + _rows = TreeBuilder.Build(_rootId, _edges, _edgeMode, _direction); + + var preservedIndex = selectedTaskId is null ? -1 : IndexOf(selectedTaskId); + _cursorIndex = preservedIndex >= 0 ? preservedIndex : 0; + + _viewport.Update(_rows.Count, _viewport.WindowHeight); + _viewport.EnsureVisible(_cursorIndex); + } + + private int IndexOf(string taskId) + { + for (var i = 0; i < _rows.Count; i++) + { + if (_rows[i].TaskId == taskId) + { + return i; + } + } + + return -1; + } + + /// + /// Builds the panel header: the breadcrumb of ids the tree has been + /// refocused through, followed by the edge mode and direction. Degrades + /// deterministically as shrinks, middle- + /// truncating the id chain first and, if that alone still does not fit, + /// dropping the edge mode and then the direction, so the header never + /// depends on the panel's own ellipsis truncation. + /// + private string BuildTitle(int maxWidth) + { + var trail = _breadcrumbs.Reverse().Append(_rootId); + var trailText = string.Join(" > ", trail); + var edgeLabel = _edgeMode == TreeEdgeMode.Blocking ? "blocking" : "parent-child"; + var directionLabel = _direction == TreeDirection.Up ? "depends on" : "depended on by"; + + const string separator = " ยท "; + var suffixFull = separator + edgeLabel + separator + directionLabel; + var suffixDirectionOnly = separator + directionLabel; + + string title; + + if (maxWidth >= suffixFull.Length) + { + title = MiddleTruncate(trailText, maxWidth - suffixFull.Length) + suffixFull; + } + else if (maxWidth >= suffixDirectionOnly.Length) + { + title = MiddleTruncate(trailText, maxWidth - suffixDirectionOnly.Length) + suffixDirectionOnly; + } + else + { + title = MiddleTruncate(trailText, maxWidth); + } + + return Markup.Escape(title); + } + + /// + /// Truncates to at most + /// characters, replacing the middle with a single ellipsis so both the + /// start and the end of an id chain stay legible. + /// + private static string MiddleTruncate(string text, int maxLength) + { + if (maxLength <= 0) + { + return string.Empty; + } + + if (text.Length <= maxLength) + { + return text; + } + + if (maxLength == 1) + { + return "โ€ฆ"; + } + + var keep = maxLength - 1; + var head = (keep + 1) / 2; + var tail = keep - head; + + return tail > 0 + ? text[..head] + "โ€ฆ" + text[(text.Length - tail)..] + : text[..head] + "โ€ฆ"; + } + + /// + /// Renders the visible tree rows (and the "N more above/below" marker + /// rows, when the viewport hides part of the tree) at the given width + /// and height. Also returns the widest line's plain (markup-stripped) + /// character width, so the header can be budgeted against what the + /// panel actually renders rather than the raw inner width. + /// + private (IReadOnlyList Lines, int MaxRowWidth) RenderLines(int width, int height) + { + if (height <= 0) + { + return ([], 0); + } + + var reservedRows = 0; + + for (var pass = 0; pass < 3; pass++) + { + var windowHeight = Math.Max(0, height - reservedRows); + _viewport.Update(_rows.Count, windowHeight); + _viewport.EnsureVisible(_cursorIndex); + + var needed = (_viewport.HiddenAbove > 0 ? 1 : 0) + (_viewport.HiddenBelow > 0 ? 1 : 0); + + if (needed == reservedRows) + { + break; + } + + reservedRows = needed; + } + + var (start, count) = _viewport.Slice(); + var lines = new List(count + 2); + var maxRowWidth = 0; + + if (_viewport.HiddenAbove > 0) + { + var markup = Markup.Escape($" {_viewport.HiddenAbove} more above"); + maxRowWidth = Math.Max(maxRowWidth, Markup.Remove(markup).Length); + lines.Add(new Markup(markup)); + } + + for (var i = start; i < start + count; i++) + { + var markup = RenderRow(_rows[i], selected: i == _cursorIndex, width); + maxRowWidth = Math.Max(maxRowWidth, Markup.Remove(markup).Length); + lines.Add(new Markup(markup)); + } + + if (_viewport.HiddenBelow > 0) + { + var markup = Markup.Escape($" {_viewport.HiddenBelow} more below"); + maxRowWidth = Math.Max(maxRowWidth, Markup.Remove(markup).Length); + lines.Add(new Markup(markup)); + } + + return (lines, maxRowWidth); + } + + private string RenderRow(TreeNodeRow row, bool selected, int width) + { + var connector = row.BuildConnector(); + var suffix = row.IsCycle ? " (cycle)" : ""; + var badgeWidth = Math.Max(0, width - connector.Length - suffix.Length); + + var task = _tasksById.GetValueOrDefault(row.TaskId); + var title = task?.Title ?? ""; + var status = task?.Status ?? "unknown"; + var priority = task?.Priority ?? TaskPriorities.Medium; + var type = task?.Type ?? TaskTypes.Task; + + var badge = TaskBadge.Render(row.TaskId, title, status, priority, type, selected, badgeWidth); + + return Markup.Escape(connector) + badge + Markup.Escape(suffix); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Tree/TreeBuilder.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Tree/TreeBuilder.cs new file mode 100644 index 00000000000..6d2d93e85cb --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Tree/TreeBuilder.cs @@ -0,0 +1,225 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Tui.Tree; + +/// +/// Which dependency edges a tree traversal follows. +/// +internal enum TreeEdgeMode +{ + /// + /// Edges whose type gates readiness, per + /// . + /// + Blocking, + + /// + /// Only edges. + /// + ParentChild +} + +/// +/// Which way a tree traversal walks a dependency edge from a node. +/// +internal enum TreeDirection +{ + /// + /// Toward what the node depends on. + /// + Up, + + /// + /// Toward what depends on the node. + /// + Down +} + +/// +/// Expands a task's dependency graph into a tree rooted at a given task, and +/// flattens it into for display. +/// +internal static class TreeBuilder +{ + /// + /// The traversal depth used when no explicit depth is requested. + /// + public const int DefaultMaxDepth = 3; + + /// + /// The deepest a traversal is allowed to go, regardless of the requested + /// depth. + /// + public const int HardMaxDepth = 10; + + /// + /// Builds the flattened row list for the tree rooted at + /// over , following + /// only edges that match in + /// . Traversal is breadth-first, so a task + /// reachable by more than one path is expanded once, at the shallowest + /// depth it is reached from; every later occurrence, including one that + /// cycles back to an ancestor, renders as an unexpanded row with + /// set. is + /// clamped to . + /// + public static IReadOnlyList Build( + string rootId, + IReadOnlyList edges, + TreeEdgeMode edgeMode, + TreeDirection direction, + int maxDepth = DefaultMaxDepth) + { + ArgumentNullException.ThrowIfNull(rootId); + ArgumentNullException.ThrowIfNull(edges); + + var cappedDepth = Math.Clamp(maxDepth, 0, HardMaxDepth); + var adjacency = BuildAdjacency(edges, edgeMode, direction); + + // Breadth-first expansion: every node's first-seen depth is its + // shortest distance from the root, and its children are only those + // discovered while it is dequeued. + var depthById = new Dictionary { [rootId] = 0 }; + var childrenByParent = new Dictionary>(); + var queue = new Queue(); + queue.Enqueue(rootId); + + while (queue.Count > 0) + { + var currentId = queue.Dequeue(); + var currentDepth = depthById[currentId]; + + if (currentDepth >= cappedDepth || !adjacency.TryGetValue(currentId, out var children)) + { + continue; + } + + var childList = new List<(string, bool)>(children.Count); + + foreach (var childId in children) + { + if (depthById.ContainsKey(childId)) + { + childList.Add((childId, true)); + continue; + } + + depthById[childId] = currentDepth + 1; + childList.Add((childId, false)); + queue.Enqueue(childId); + } + + childrenByParent[currentId] = childList; + } + + // Pre-order flatten so each row's connector only needs the sibling + // position of its own ancestors, already resolved on the way down. + var rows = new List(); + Flatten(rootId, depth: 0, isLastChild: true, ancestorIsLastChild: [], childrenByParent, rows); + return rows; + } + + private static void Flatten( + string nodeId, + int depth, + bool isLastChild, + IReadOnlyList ancestorIsLastChild, + IReadOnlyDictionary> childrenByParent, + List rows) + { + rows.Add(new TreeNodeRow + { + TaskId = nodeId, + Depth = depth, + IsLastChild = isLastChild, + AncestorIsLastChild = ancestorIsLastChild, + IsCycle = false + }); + + if (!childrenByParent.TryGetValue(nodeId, out var children) || children.Count == 0) + { + return; + } + + var childAncestors = depth == 0 ? [] : Append(ancestorIsLastChild, isLastChild); + + for (var i = 0; i < children.Count; i++) + { + var (childId, isCycle) = children[i]; + var childIsLast = i == children.Count - 1; + + if (isCycle) + { + rows.Add(new TreeNodeRow + { + TaskId = childId, + Depth = depth + 1, + IsLastChild = childIsLast, + AncestorIsLastChild = childAncestors, + IsCycle = true + }); + continue; + } + + Flatten(childId, depth + 1, childIsLast, childAncestors, childrenByParent, rows); + } + } + + private static IReadOnlyList Append(IReadOnlyList list, bool value) + { + var next = new bool[list.Count + 1]; + + for (var i = 0; i < list.Count; i++) + { + next[i] = list[i]; + } + + next[^1] = value; + return next; + } + + private static Dictionary> BuildAdjacency( + IReadOnlyList edges, + TreeEdgeMode edgeMode, + TreeDirection direction) + { + var adjacency = new Dictionary>(); + + foreach (var edge in edges) + { + if (!Matches(edge.Type, edgeMode)) + { + continue; + } + + // TaskId depends on DependsOnId. Up walks toward what a node + // depends on (node -> DependsOnId); down walks toward what + // depends on a node, following the same edge backward + // (node -> TaskId). + var (from, to) = direction == TreeDirection.Up + ? (edge.TaskId, edge.DependsOnId) + : (edge.DependsOnId, edge.TaskId); + + if (!adjacency.TryGetValue(from, out var children)) + { + children = []; + adjacency[from] = children; + } + + children.Add(to); + } + + foreach (var children in adjacency.Values) + { + children.Sort(StringComparer.Ordinal); + } + + return adjacency; + } + + private static bool Matches(string type, TreeEdgeMode edgeMode) => edgeMode switch + { + TreeEdgeMode.ParentChild => type == TaskDependencyTypes.ParentChild, + _ => TaskDependencyTypes.IsBlocking(type) + }; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Tree/TreeNodeRow.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Tree/TreeNodeRow.cs new file mode 100644 index 00000000000..0e2c9ce9e54 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Tree/TreeNodeRow.cs @@ -0,0 +1,65 @@ +using System.Text; + +namespace ChilliCream.Nitro.CommandLine.Tui.Tree; + +/// +/// One flattened row of a dependency tree: the task it names, its depth from +/// the tree's root, and enough sibling-position state to draw its +/// box-drawing connector without consulting any other row. +/// +internal sealed class TreeNodeRow +{ + /// + /// The id of the task this row names. + /// + public required string TaskId { get; init; } + + /// + /// The row's distance from the tree's root. The root itself is depth 0. + /// + public required int Depth { get; init; } + + /// + /// Whether this row is the last of its siblings under its parent. + /// Meaningless, and always , for the root. + /// + public bool IsLastChild { get; init; } = true; + + /// + /// For each ancestor strictly between the root and this row's parent, + /// whether that ancestor was itself the last child of its own parent. + /// Empty for the root and for its direct children. + /// + public IReadOnlyList AncestorIsLastChild { get; init; } = []; + + /// + /// Whether this row is a repeat occurrence of a task already reached + /// earlier, higher up, in the same traversal. A cycle row is rendered + /// but never expanded further. + /// + public bool IsCycle { get; init; } + + /// + /// Builds the plain-text box-drawing connector this row's badge should + /// be prefixed with: empty for the root, otherwise a run of vertical-bar + /// or blank segments tracking which ancestors still have siblings below + /// them, followed by this row's own branch glyph. + /// + public string BuildConnector() + { + if (Depth == 0) + { + return string.Empty; + } + + var builder = new StringBuilder(AncestorIsLastChild.Count * 3 + 3); + + foreach (var ancestorIsLast in AncestorIsLastChild) + { + builder.Append(ancestorIsLast ? " " : "โ”‚ "); + } + + builder.Append(IsLastChild ? "โ””โ”€ " : "โ”œโ”€ "); + return builder.ToString(); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/ColumnPane.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/ColumnPane.cs new file mode 100644 index 00000000000..46529775825 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/ColumnPane.cs @@ -0,0 +1,31 @@ +using ChilliCream.Nitro.CommandLine.Tui.Theming; +using Spectre.Console.Rendering; + +namespace ChilliCream.Nitro.CommandLine.Tui.Widgets; + +/// +/// Wraps a list of already-rendered markup lines in a rounded-border panel +/// titled "Name (count)", with border style driven by focus state. +/// +internal static class ColumnPane +{ + /// + /// Builds the panel for one column. are markup + /// strings, one per visible row, in top-to-bottom order. + /// + public static Panel Render(string name, int count, IReadOnlyList lines, bool focused) + { + IRenderable content = lines.Count == 0 + ? new Markup(string.Empty) + : new Rows(lines.Select(line => (IRenderable)new Markup(line))); + + var borderToken = focused ? "board.column.border.focused" : "board.column.border"; + + return new Panel(content) + { + Header = new PanelHeader($"{name} ({count})"), + Border = BoxBorder.Rounded, + BorderStyle = ThemeTokens.GetStyle(borderToken) + }; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Form/EditableListField.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Form/EditableListField.cs new file mode 100644 index 00000000000..58f40fd466f --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Form/EditableListField.cs @@ -0,0 +1,233 @@ +using Spectre.Console.Rendering; + +namespace ChilliCream.Nitro.CommandLine.Tui.Widgets.Form; + +/// +/// An editable list of string entries, used for labels: add, remove, and edit +/// entries in place. While browsing, up and down move the selected entry and +/// are left unconsumed at the first or last entry so focus can move on; while +/// editing an entry, Escape cancels only that edit rather than the whole form. +/// +internal sealed class EditableListField : FormField +{ + private const string SelectedRowStyle = "aqua"; + private const string Bullet = "- "; + + private readonly List _entries; + + private int _selectedIndex; + private LineEditor? _editor; + private string? _editingOriginalText; + private bool _isEditingNewEntry; + + public EditableListField( + string id, + string label, + bool required = false, + IEnumerable? initialValues = null, + Func? validator = null) + : base(id, label, required, validator) + { + _entries = initialValues is null ? [] : [.. initialValues]; + _selectedIndex = _entries.Count == 0 ? -1 : 0; + } + + private bool IsEditing => _editor is not null; + + public override FormValue GetValue() => new FormValue.List([.. _entries]); + + public override bool HandleKey(ConsoleKeyInfo info) + { + if (IsEditing) + { + return HandleEditingKey(info); + } + + switch (info.Key) + { + case ConsoleKey.UpArrow: + if (_selectedIndex <= 0) + { + return false; + } + + _selectedIndex--; + return true; + + case ConsoleKey.DownArrow: + if (_selectedIndex < 0 || _selectedIndex >= _entries.Count - 1) + { + return false; + } + + _selectedIndex++; + return true; + + case ConsoleKey.Enter: + if (_selectedIndex < 0) + { + return false; + } + + BeginEditing(_entries[_selectedIndex], isNewEntry: false); + return true; + + case ConsoleKey.Delete: + RemoveSelected(); + return true; + + default: + if (info.KeyChar == 'a') + { + AddEntry(); + return true; + } + + if (info.KeyChar == 'd') + { + RemoveSelected(); + return true; + } + + return false; + } + } + + public override IRenderable Render(int width, bool focused) + { + if (_entries.Count == 0) + { + return RenderPanel(new Markup(RenderPlaceholder("no labels - type to add")), width, focused); + } + + var rows = new List(); + + for (var i = 0; i < _entries.Count; i++) + { + var isCurrentRow = focused && i == _selectedIndex; + var text = IsEditing && i == _selectedIndex + ? RenderEditingLine(_editor!) + : Markup.Escape(_entries[i]); + + var line = Bullet + text; + + if (isCurrentRow) + { + line = $"[{SelectedRowStyle}]{line}[/]"; + } + + rows.Add(new Markup(line)); + } + + return RenderPanel(new Rows(rows), width, focused); + } + + private bool HandleEditingKey(ConsoleKeyInfo info) + { + switch (info.Key) + { + case ConsoleKey.Enter: + CommitEditing(); + return true; + + case ConsoleKey.Escape: + CancelEditing(); + return true; + + case ConsoleKey.UpArrow: + case ConsoleKey.DownArrow: + // Swallowed so browsing the list cannot happen mid-edit. + return true; + + default: + if (_editor!.HandleKey(info)) + { + return true; + } + + // An unrecognized key (for example Tab) blurs the entry: commit + // it in place and let the key fall through as focus traversal. + CommitEditing(); + return false; + } + } + + private void BeginEditing(string text, bool isNewEntry) + { + _editor = new LineEditor(text); + _editingOriginalText = text; + _isEditingNewEntry = isNewEntry; + } + + private void CommitEditing() + { + var text = _editor!.Text; + + if (text.Length == 0) + { + _entries.RemoveAt(_selectedIndex); + _selectedIndex = Math.Min(_selectedIndex, _entries.Count - 1); + } + else + { + _entries[_selectedIndex] = text; + } + + StopEditing(); + } + + private void CancelEditing() + { + if (_isEditingNewEntry) + { + _entries.RemoveAt(_selectedIndex); + _selectedIndex = Math.Min(_selectedIndex, _entries.Count - 1); + } + else + { + _entries[_selectedIndex] = _editingOriginalText!; + } + + StopEditing(); + } + + private void StopEditing() + { + _editor = null; + _editingOriginalText = null; + _isEditingNewEntry = false; + } + + private void AddEntry() + { + var insertAt = _selectedIndex + 1; + _entries.Insert(insertAt, string.Empty); + _selectedIndex = insertAt; + BeginEditing(string.Empty, isNewEntry: true); + } + + private void RemoveSelected() + { + if (_selectedIndex < 0) + { + return; + } + + _entries.RemoveAt(_selectedIndex); + _selectedIndex = Math.Min(_selectedIndex, _entries.Count - 1); + } + + private static string RenderEditingLine(LineEditor editor) + { + const string cursorStyle = "black on white"; + + var text = editor.Text; + var cursor = editor.Cursor; + + var before = Markup.Escape(text[..cursor]); + var atCursor = cursor < text.Length ? text[cursor].ToString() : " "; + var after = cursor < text.Length ? Markup.Escape(text[(cursor + 1)..]) : string.Empty; + + return $"{before}[{cursorStyle}]{Markup.Escape(atCursor)}[/]{after}"; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Form/Form.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Form/Form.cs new file mode 100644 index 00000000000..efc5f62609d --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Form/Form.cs @@ -0,0 +1,392 @@ +using Spectre.Console.Rendering; + +namespace ChilliCream.Nitro.CommandLine.Tui.Widgets.Form; + +file static class FormMeasurement +{ + /// + /// An off-screen console used only to obtain a for + /// measuring a renderable's line count; nothing is ever written through it. + /// + private static readonly IAnsiConsole Console = + AnsiConsole.Create(new AnsiConsoleSettings { Out = new AnsiConsoleOutput(TextWriter.Null) }); + + private static readonly RenderOptions Options = RenderOptions.Create(Console, Console.Profile.Capabilities); + + /// + /// Renders at and counts + /// the visual lines it occupies. + /// + public static int MeasureHeight(IRenderable renderable, int width) + => Segment.SplitLines(renderable.Render(Options, Math.Max(1, width))).Count; +} + +/// +/// The outcome of a completed interaction. +/// +internal abstract record FormResult +{ + private FormResult() + { + } + + /// + /// The primary button was activated while every field passed validation. + /// + public sealed record Submitted(IReadOnlyDictionary Values) : FormResult; + + /// + /// The form was cancelled: Escape was pressed while no field consumed it. + /// + public sealed record Cancelled : FormResult; + + /// + /// A non-primary button was activated. The does not + /// interpret the id; the host decides what it means. + /// + public sealed record ButtonActivated(string ButtonId) : FormResult; +} + +/// +/// A scoped modal form: labeled bordered fields with focus traversal, per-field +/// validation, and a confirm/cancel button row. Rendered as a single-column, +/// centered overlay; the host is expected to feed it raw key input and stop +/// showing it once returns a non-null result. +/// +internal sealed class Form +{ + /// + /// The widest a form's content is ever rendered, regardless of how much + /// wider the frame around it is. + /// + private const int MaxFormWidth = 80; + + /// + /// The narrowest a form's content ever renders once the frame clears + /// . + /// + private const int MinFormWidth = 20; + + /// + /// The frame width below which gives up on laying out + /// the form and shows the too-small notice instead. + /// + private const int MinViableWidth = 24; + + /// + /// The frame height below which gives up on laying out + /// the form and shows the too-small notice instead. + /// + private const int MinViableHeight = 10; + + /// + /// Rows of breathing room left between the modal and the edges of the frame. + /// + private const int FrameMargin = 2; + + /// + /// Rows the panel's own top and bottom border occupy. + /// + private const int PanelBorderHeight = 2; + + /// + /// Rows the blank line between the fields and the button row occupies. + /// + private const int SeparatorHeight = 1; + + private readonly string _title; + private readonly IReadOnlyList _fields; + private readonly FormButtons _buttons; + private readonly int _stopCount; + private readonly bool[] _touched; + + private int _focusIndex; + private bool _submitAttempted; + + public Form(string title, IReadOnlyList fields, FormButtons buttons) + { + ArgumentException.ThrowIfNullOrEmpty(title); + ArgumentNullException.ThrowIfNull(fields); + ArgumentNullException.ThrowIfNull(buttons); + + if (fields.Count == 0) + { + throw new ArgumentException("A form must have at least one field.", nameof(fields)); + } + + _title = title; + _fields = fields; + _buttons = buttons; + _touched = new bool[_fields.Count]; + + // Every field is one focus stop, plus one more for the button row. + _stopCount = _fields.Count + 1; + } + + /// + /// The field currently holding focus, or when focus is + /// on the button row. + /// + public FormField? FocusedField => IsFieldFocused ? _fields[_focusIndex] : null; + + private bool IsFieldFocused => _focusIndex < _fields.Count; + + /// + /// Handles one raw key. Returns while the form is still + /// active, or the terminal once the interaction ends. + /// + public FormResult? HandleKey(ConsoleKeyInfo info) + { + if (IsSaveChord(info)) + { + return TrySave(); + } + + if (IsFieldFocused) + { + var field = _fields[_focusIndex]; + + if (field.HandleKey(info)) + { + _touched[_focusIndex] = true; + return null; + } + + return info.Key == ConsoleKey.Escape + ? new FormResult.Cancelled() + : HandleTraversal(info); + } + + if (_buttons.HandleKey(info)) + { + return null; + } + + return info.Key switch + { + ConsoleKey.Escape => new FormResult.Cancelled(), + ConsoleKey.Enter => ActivateSelectedButton(), + _ => HandleTraversal(info) + }; + } + + /// + /// Renders the form as a titled, rounded panel centered within the given + /// area: clamped to at most columns and to the + /// frame's own height, scrolling the field list, when it does not fit, to + /// keep the focused field fully visible while the button row stays pinned + /// at the bottom. Below or + /// , renders a too-small notice instead. + /// + public IRenderable Render(int width, int height) + { + if (width < MinViableWidth || height < MinViableHeight) + { + return RenderTooSmallNotice(width, height); + } + + var formWidth = Math.Clamp(width - 4, MinFormWidth, MaxFormWidth - 4); + + var fieldRenderables = new IRenderable[_fields.Count]; + var fieldHeights = new int[_fields.Count]; + + for (var i = 0; i < _fields.Count; i++) + { + _fields[i].ShowErrors = _submitAttempted || _touched[i]; + var renderable = _fields[i].Render(formWidth, focused: IsFieldFocused && i == _focusIndex); + fieldRenderables[i] = renderable; + fieldHeights[i] = FormMeasurement.MeasureHeight(renderable, formWidth); + } + + var buttonsRenderable = _buttons.Render(focused: !IsFieldFocused); + var buttonsHeight = FormMeasurement.MeasureHeight(buttonsRenderable, formWidth); + + var chrome = PanelBorderHeight + SeparatorHeight + buttonsHeight; + var totalFieldsHeight = fieldHeights.Sum(); + // Guarantees room for the chrome plus at least one field row, even if + // the frame barely clears MinViableHeight. + var maxPanelHeight = Math.Max(chrome + 1, height - FrameMargin); + var availableFieldsHeight = Math.Max(0, maxPanelHeight - chrome); + var needsScrolling = totalFieldsHeight > availableFieldsHeight; + + // Reserve room for the scroll indicators only when scrolling is + // actually happening, so a form that fits entirely never loses a row + // to indicators it doesn't show. + var windowBudget = needsScrolling + ? Math.Max(0, availableFieldsHeight - 2) + : availableFieldsHeight; + + var anchorIndex = IsFieldFocused ? _focusIndex : _fields.Count - 1; + var (startIndex, endIndex) = SelectVisibleFieldRange(fieldHeights, anchorIndex, windowBudget); + var hiddenAbove = startIndex > 0; + var hiddenBelow = endIndex < _fields.Count - 1; + + var sections = new List(endIndex - startIndex + 4); + + if (hiddenAbove) + { + sections.Add(new Markup("[grey italic]โ–ฒ more fields above[/]")); + } + + for (var i = startIndex; i <= endIndex; i++) + { + sections.Add(fieldRenderables[i]); + } + + if (hiddenBelow) + { + sections.Add(new Markup("[grey italic]โ–ผ more fields below[/]")); + } + + // A blank line separates the fields from the button row so the buttons + // don't read as crowded against the last field. + sections.Add(new Markup(" ")); + sections.Add(buttonsRenderable); + + var panel = new Panel(new Rows(sections)) + { + Header = new PanelHeader(Markup.Escape(_title)), + Border = BoxBorder.Rounded, + Width = formWidth + 4 + }; + + return new Align(panel, HorizontalAlignment.Center, VerticalAlignment.Middle) + .Width(Math.Max(0, width)) + .Height(Math.Max(0, height)); + } + + /// + /// Picks the widest contiguous run of whole fields, anchored at + /// , whose combined + /// fit within : the anchor field is always + /// included even when it alone exceeds the budget. + /// + private static (int Start, int End) SelectVisibleFieldRange( + IReadOnlyList heights, int anchorIndex, int budget) + { + var start = anchorIndex; + var end = anchorIndex; + var used = heights[anchorIndex]; + + while (end + 1 < heights.Count && used + heights[end + 1] <= budget) + { + end++; + used += heights[end]; + } + + while (start - 1 >= 0 && used + heights[start - 1] <= budget) + { + start--; + used += heights[start]; + } + + return (start, end); + } + + /// + /// Renders a centered notice in place of the form when the frame is too + /// small to lay out any field usefully. + /// + private static IRenderable RenderTooSmallNotice(int width, int height) + { + var notice = new Markup("[red]Terminal too small.[/] Resize to at least 80x24."); + + return new Align(notice, HorizontalAlignment.Center, VerticalAlignment.Middle) + .Width(Math.Max(0, width)) + .Height(Math.Max(0, height)); + } + + private FormResult? HandleTraversal(ConsoleKeyInfo info) + { + switch (info.Key) + { + case ConsoleKey.Tab when info.Modifiers.HasFlag(ConsoleModifiers.Shift): + case ConsoleKey.UpArrow: + FocusPrevious(); + return null; + + case ConsoleKey.Tab: + case ConsoleKey.DownArrow: + FocusNext(); + return null; + + default: + return null; + } + } + + private void FocusNext() => _focusIndex = (_focusIndex + 1) % _stopCount; + + private void FocusPrevious() => _focusIndex = (_focusIndex - 1 + _stopCount) % _stopCount; + + private FormResult? ActivateSelectedButton() + { + var id = _buttons.SelectedId; + + return _buttons.SelectedKind != ButtonKind.Primary + ? new FormResult.ButtonActivated(id) + : TryValidateAndSubmit(); + } + + /// + /// Whether is the save chord that submits the form + /// from any focus position, as if the primary button were activated: + /// Ctrl+Enter, or Ctrl+S as a fallback for terminals whose legacy input + /// mode reports Ctrl+Enter identically to a plain Enter. + /// + private static bool IsSaveChord(ConsoleKeyInfo info) + => info.Modifiers.HasFlag(ConsoleModifiers.Control) + && info.Key is ConsoleKey.Enter or ConsoleKey.S; + + /// + /// Submits the form as if the primary button were activated, regardless of + /// which field or button currently has focus. On validation failure, + /// moves focus to the first invalid field instead of leaving focus + /// wherever the chord was pressed, so the surfaced error is immediately + /// visible. + /// + private FormResult? TrySave() + { + var result = TryValidateAndSubmit(); + + if (result is null) + { + _focusIndex = FirstInvalidFieldIndex(); + } + + return result; + } + + /// + /// Validates every field, marking the form as having attempted a submit so + /// every field's error becomes visible; returns the submitted values when + /// all fields are valid, or to keep the form open + /// otherwise. + /// + private FormResult? TryValidateAndSubmit() + { + _submitAttempted = true; + + if (_fields.Any(field => field.Validate() is not null)) + { + return null; + } + + return new FormResult.Submitted(_fields.ToDictionary(field => field.Id, field => field.GetValue())); + } + + private int FirstInvalidFieldIndex() + { + for (var i = 0; i < _fields.Count; i++) + { + if (_fields[i].Validate() is not null) + { + return i; + } + } + + // TrySave only calls this after TryValidateAndSubmit found an invalid + // field, so one is always present here. + throw new InvalidOperationException("Expected an invalid field."); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Form/FormButtons.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Form/FormButtons.cs new file mode 100644 index 00000000000..3d4f93bd22c --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Form/FormButtons.cs @@ -0,0 +1,115 @@ +using ChilliCream.Nitro.CommandLine.Tui.Theming; +using Spectre.Console.Rendering; + +namespace ChilliCream.Nitro.CommandLine.Tui.Widgets.Form; + +/// +/// The visual style of a button. Only +/// is gated by field validation when activated. +/// +internal enum ButtonKind +{ + Primary, + Secondary, + Danger +} + +/// +/// One button in a row. +/// +internal sealed record FormButtonSpec(string Id, string Label, ButtonKind Kind); + +/// +/// The button row at the bottom of a : left and right move the +/// selection between buttons, rendered inline in order. +/// +internal sealed class FormButtons +{ + private readonly IReadOnlyList _buttons; + + private int _selectedIndex; + + public FormButtons(IReadOnlyList buttons) + { + ArgumentNullException.ThrowIfNull(buttons); + + if (buttons.Count == 0) + { + throw new ArgumentException("A form must have at least one button.", nameof(buttons)); + } + + _buttons = buttons; + } + + /// + /// The identifier of the currently selected button. + /// + public string SelectedId => _buttons[_selectedIndex].Id; + + /// + /// The style of the currently selected button. + /// + public ButtonKind SelectedKind => _buttons[_selectedIndex].Kind; + + /// + /// Moves the selection between buttons with left and right. Returns whether + /// the key was consumed. + /// + public bool HandleKey(ConsoleKeyInfo info) + { + switch (info.Key) + { + case ConsoleKey.LeftArrow: + if (_selectedIndex == 0) + { + return false; + } + + _selectedIndex--; + return true; + + case ConsoleKey.RightArrow: + if (_selectedIndex == _buttons.Count - 1) + { + return false; + } + + _selectedIndex++; + return true; + + default: + return false; + } + } + + /// + /// Renders the button row, highlighting the selected button when the row has + /// form focus. + /// + public IRenderable Render(bool focused) + { + var parts = new List(_buttons.Count); + + for (var i = 0; i < _buttons.Count; i++) + { + var isSelected = focused && i == _selectedIndex; + parts.Add(new Markup(RenderButton(_buttons[i], isSelected))); + } + + return new Columns(parts) { Padding = new Padding(2, 0, 0, 0) }; + } + + private static string RenderButton(FormButtonSpec button, bool selected) + { + var tokenPrefix = button.Kind switch + { + ButtonKind.Primary => "form.button.primary", + ButtonKind.Danger => "form.button.danger", + _ => "form.button.secondary" + }; + + var style = ThemeTokens.GetStyle(selected ? $"{tokenPrefix}.focused" : tokenPrefix); + + return $"[{style.ToMarkup()}] {Markup.Escape(button.Label)} [/]"; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Form/FormField.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Form/FormField.cs new file mode 100644 index 00000000000..dac53d09c07 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Form/FormField.cs @@ -0,0 +1,263 @@ +using Spectre.Console.Rendering; + +namespace ChilliCream.Nitro.CommandLine.Tui.Widgets.Form; + +/// +/// One value a form field can produce: a single string, or an ordered list of +/// strings. Closed so consumers pattern-match on the shape instead of binding +/// through reflection. +/// +internal abstract record FormValue +{ + private FormValue() + { + } + + /// + /// A single string value, produced by text, text area, and select fields. + /// + public sealed record Text(string Value) : FormValue; + + /// + /// An ordered list of string values, produced by the editable list field. + /// + public sealed record List(IReadOnlyList Values) : FormValue + { + /// + /// Two lists are equal when they contain the same values in the same order. + /// + public bool Equals(List? other) => other is not null && Values.SequenceEqual(other.Values); + + /// + public override int GetHashCode() + { + var hash = new HashCode(); + + foreach (var value in Values) + { + hash.Add(value); + } + + return hash.ToHashCode(); + } + } +} + +/// +/// One field in a : a labeled, bordered input that owns its own +/// editing state and can validate its current value. +/// +internal abstract class FormField +{ + private static readonly Style DefaultBorderStyle = new(Color.Grey); + private static readonly Style FocusedBorderStyle = new(Color.Aqua); + private static readonly Style ErrorBorderStyle = new(Color.Red); + private static readonly Style ErrorTextStyle = new(Color.Red); + + private readonly Func? _validator; + + protected FormField(string id, string label, bool required, Func? validator) + { + ArgumentException.ThrowIfNullOrEmpty(id); + ArgumentException.ThrowIfNullOrEmpty(label); + + Id = id; + Label = label; + Required = required; + _validator = validator; + } + + /// + /// The identifier this field's value is keyed under in the result. + /// + public string Id { get; } + + /// + /// The label shown in the field's border title. + /// + public string Label { get; } + + /// + /// Whether the field's title carries the required marker. + /// + public bool Required { get; } + + /// + /// Whether should surface this field's validation + /// error, if any. The hosting sets this once the field + /// has been touched or a submit was attempted, so a required field stays + /// quiet until the user has had a chance to fill it in. + /// + public bool ShowErrors { get; set; } + + /// + /// The current value, in the closed shape for its field type. + /// + public abstract FormValue GetValue(); + + /// + /// Handles one key while this field has focus. Returns whether the key was + /// consumed by the field; an unconsumed key is left for the form to interpret + /// as focus traversal. + /// + public abstract bool HandleKey(ConsoleKeyInfo info); + + /// + /// Renders the field as a bordered section, styled by its focus and validation + /// state, at most display columns wide. + /// + public abstract IRenderable Render(int width, bool focused); + + /// + /// Runs the field's validator against its current value, returning the error + /// message to render under the field, or when the value + /// is valid or the field has no validator. + /// + public string? Validate() => _validator?.Invoke(GetValue()); + + /// + /// Renders as the dim placeholder line a field body + /// shows in place of a blank line, so an empty field never collapses to a + /// border pair with no interior line. + /// + protected static string RenderPlaceholder(string text) => $"[grey italic]{Markup.Escape(text)}[/]"; + + /// + /// Wraps in the bordered panel shared by every field + /// renderer, with the field's title, focus-and-validation-driven border style, + /// and validation error line appended underneath when present. + /// + protected IRenderable RenderPanel(IRenderable content, int width, bool focused) + { + var title = Required ? $"{Label} *" : Label; + var error = ShowErrors ? Validate() : null; + + var borderStyle = error is not null + ? ErrorBorderStyle + : focused + ? FocusedBorderStyle + : DefaultBorderStyle; + + var panel = new Panel(content) + { + Header = new PanelHeader(Markup.Escape(title)), + Border = BoxBorder.Rounded, + BorderStyle = borderStyle + }; + + if (width > 0) + { + panel.Width = width; + } + + if (error is null) + { + return panel; + } + + var errorLine = new Markup($"[{ErrorTextStyle.ToMarkup()}]{Markup.Escape(error)}[/]"); + + return new Rows(panel, errorLine); + } +} + +/// +/// Single-line text edit state shared by and +/// : the current text and cursor position, and the +/// character-level operations a text input supports. +/// +internal sealed class LineEditor +{ + public LineEditor(string text = "") + { + Text = text; + Cursor = text.Length; + } + + /// + /// The current text. + /// + public string Text { get; private set; } + + /// + /// The cursor position, as an index into in the range + /// [0, Text.Length]. + /// + public int Cursor { get; private set; } + + /// + /// Applies to the text and cursor, returning whether the + /// key was a recognized editing operation. + /// + public bool HandleKey(ConsoleKeyInfo info) + { + switch (info.Key) + { + case ConsoleKey.Backspace: + if (Cursor > 0) + { + Text = Text.Remove(Cursor - 1, 1); + Cursor--; + } + + return true; + + case ConsoleKey.Delete: + if (Cursor < Text.Length) + { + Text = Text.Remove(Cursor, 1); + } + + return true; + + case ConsoleKey.LeftArrow: + if (Cursor > 0) + { + Cursor--; + } + + return true; + + case ConsoleKey.RightArrow: + if (Cursor < Text.Length) + { + Cursor++; + } + + return true; + + case ConsoleKey.Home: + Cursor = 0; + return true; + + case ConsoleKey.End: + Cursor = Text.Length; + return true; + + default: + if (IsInsertable(info.KeyChar)) + { + Text = Text.Insert(Cursor, info.KeyChar.ToString()); + Cursor++; + return true; + } + + return false; + } + } + + /// + /// Replaces the text outright, moving the cursor to its end. + /// + public void SetText(string text) + { + Text = text; + Cursor = text.Length; + } + + /// + /// Whether is a printable character that a text + /// input should insert, excluding control characters such as delete. + /// + public static bool IsInsertable(char keyChar) => keyChar >= ' ' && keyChar != '\u007f'; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Form/SelectField.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Form/SelectField.cs new file mode 100644 index 00000000000..812570c68fd --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Form/SelectField.cs @@ -0,0 +1,114 @@ +using Spectre.Console.Rendering; + +namespace ChilliCream.Nitro.CommandLine.Tui.Widgets.Form; + +/// +/// One selectable choice in a . +/// +internal sealed record SelectOption(string Id, string Label); + +/// +/// A radio single-select field over a fixed set of choices, rendered inline on +/// as few lines as fit the field's width, wrapping to further lines only when +/// needed. Left and right move the selection; at the first or last option the +/// key is left unconsumed so focus can move on to another field, leaving up +/// and down free for that field-to-field traversal. +/// +internal sealed class SelectField : FormField +{ + private const string SelectedMarker = "(o)"; + private const string UnselectedMarker = "( )"; + private const string SelectedRowStyle = "aqua"; + private const string OptionSeparator = " "; + private const int MinRenderWidth = 4; + + private readonly IReadOnlyList _options; + + private int _selectedIndex; + + public SelectField( + string id, + string label, + IReadOnlyList options, + bool required = false, + string? initialSelectedId = null, + Func? validator = null) + : base(id, label, required, validator) + { + ArgumentNullException.ThrowIfNull(options); + + if (options.Count == 0) + { + throw new ArgumentException("A select field must have at least one option.", nameof(options)); + } + + _options = options; + + var initialIndex = initialSelectedId is null + ? 0 + : _options.ToList().FindIndex(o => o.Id == initialSelectedId); + + _selectedIndex = Math.Max(0, initialIndex); + } + + public override FormValue GetValue() => new FormValue.Text(_options[_selectedIndex].Id); + + public override bool HandleKey(ConsoleKeyInfo info) + { + switch (info.Key) + { + case ConsoleKey.LeftArrow: + if (_selectedIndex == 0) + { + return false; + } + + _selectedIndex--; + return true; + + case ConsoleKey.RightArrow: + if (_selectedIndex == _options.Count - 1) + { + return false; + } + + _selectedIndex++; + return true; + + default: + return false; + } + } + + public override IRenderable Render(int width, bool focused) + { + var innerWidth = Math.Max(1, width - MinRenderWidth); + var lines = new List>(); + var lineWidth = 0; + + for (var i = 0; i < _options.Count; i++) + { + var isSelected = i == _selectedIndex; + var plainWidth = SelectedMarker.Length + 1 + _options[i].Label.Length; + + if (lines.Count == 0 + || (lineWidth > 0 && lineWidth + OptionSeparator.Length + plainWidth > innerWidth)) + { + lines.Add([]); + lineWidth = 0; + } + + var marker = isSelected ? SelectedMarker : UnselectedMarker; + var option = $"{marker} {Markup.Escape(_options[i].Label)}"; + + lines[^1].Add(focused && isSelected ? $"[{SelectedRowStyle}]{option}[/]" : option); + lineWidth += (lineWidth > 0 ? OptionSeparator.Length : 0) + plainWidth; + } + + var rows = lines + .Select(line => (IRenderable)new Markup(string.Join(OptionSeparator, line))) + .ToList(); + + return RenderPanel(new Rows(rows), width, focused); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Form/TextAreaField.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Form/TextAreaField.cs new file mode 100644 index 00000000000..d69a29534d8 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Form/TextAreaField.cs @@ -0,0 +1,261 @@ +using ChilliCream.Nitro.CommandLine.Tui.Widgets; +using Spectre.Console.Rendering; + +namespace ChilliCream.Nitro.CommandLine.Tui.Widgets.Form; + +/// +/// A multi-line plain-text field: insertion, newlines, backspace, cursor +/// movement across lines, and a vertical scroll window over the visible rows. +/// Up and down move the cursor within the text until it reaches the first or +/// last line, at which point the key is left unconsumed so focus can move on. +/// +internal sealed class TextAreaField : FormField +{ + private const int MinRenderWidth = 4; + + private const string CursorStyle = "black on white"; + + /// + /// The fewest interior lines a text area ever renders, even when its + /// content is empty or shorter than its configured visible-lines count: + /// keeps the field from collapsing to a cramped one-line box. + /// + private const int MinVisibleLines = 3; + + private readonly List _lines; + private readonly Viewport _rowViewport; + private readonly Viewport _columnViewport = new(0, 1); + private readonly int _visibleLines; + + private int _lineIndex; + private int _columnIndex; + + public TextAreaField( + string id, + string label, + bool required = false, + string initialValue = "", + int visibleLines = MinVisibleLines, + Func? validator = null) + : base(id, label, required, validator) + { + _lines = initialValue.Length == 0 ? [""] : [.. initialValue.Split('\n')]; + _visibleLines = Math.Max(MinVisibleLines, visibleLines); + _rowViewport = new Viewport(_lines.Count, _visibleLines); + _columnIndex = _lines[^1].Length; + _lineIndex = _lines.Count - 1; + } + + public override FormValue GetValue() => new FormValue.Text(string.Join('\n', _lines)); + + public override bool HandleKey(ConsoleKeyInfo info) + { + switch (info.Key) + { + case ConsoleKey.Enter: + SplitLine(); + return true; + + case ConsoleKey.Backspace: + DeleteBackward(); + return true; + + case ConsoleKey.Delete: + DeleteForward(); + return true; + + case ConsoleKey.LeftArrow: + MoveLeft(); + return true; + + case ConsoleKey.RightArrow: + MoveRight(); + return true; + + case ConsoleKey.Home: + _columnIndex = 0; + return true; + + case ConsoleKey.End: + _columnIndex = CurrentLine.Length; + return true; + + case ConsoleKey.UpArrow: + if (_lineIndex == 0) + { + return false; + } + + _lineIndex--; + _columnIndex = Math.Min(_columnIndex, CurrentLine.Length); + return true; + + case ConsoleKey.DownArrow: + if (_lineIndex == _lines.Count - 1) + { + return false; + } + + _lineIndex++; + _columnIndex = Math.Min(_columnIndex, CurrentLine.Length); + return true; + + default: + if (LineEditor.IsInsertable(info.KeyChar)) + { + _lines[_lineIndex] = CurrentLine.Insert(_columnIndex, info.KeyChar.ToString()); + _columnIndex++; + return true; + } + + return false; + } + } + + public override IRenderable Render(int width, bool focused) + { + var innerWidth = Math.Max(1, width - MinRenderWidth); + + _rowViewport.Update(_lines.Count, _visibleLines); + _rowViewport.EnsureVisible(_lineIndex); + + var (start, count) = _rowViewport.Slice(); + var isWhollyEmpty = !focused && _lines.Count == 1 && _lines[0].Length == 0; + var rows = new List(); + + for (var i = start; i < start + count; i++) + { + var isCursorRow = focused && i == _lineIndex; + var markup = isCursorRow + ? RenderCursorLine(_lines[i], _columnIndex, innerWidth) + : isWhollyEmpty + ? RenderPlaceholder("empty") + : Markup.Escape(Truncate(_lines[i], innerWidth)); + + rows.Add(new Markup(markup)); + } + + // Pads short content up to the configured visible-lines height so the + // field always renders as a fixed-size box instead of shrinking to fit. + while (rows.Count < _visibleLines) + { + rows.Add(new Markup(" ")); + } + + return RenderPanel(new Rows(rows), width, focused); + } + + private string CurrentLine => _lines[_lineIndex]; + + private void SplitLine() + { + var line = CurrentLine; + var before = line[.._columnIndex]; + var after = line[_columnIndex..]; + + _lines[_lineIndex] = before; + _lines.Insert(_lineIndex + 1, after); + _lineIndex++; + _columnIndex = 0; + } + + private void DeleteBackward() + { + if (_columnIndex > 0) + { + _lines[_lineIndex] = CurrentLine.Remove(_columnIndex - 1, 1); + _columnIndex--; + return; + } + + if (_lineIndex == 0) + { + return; + } + + var mergedColumn = _lines[_lineIndex - 1].Length; + _lines[_lineIndex - 1] += CurrentLine; + _lines.RemoveAt(_lineIndex); + _lineIndex--; + _columnIndex = mergedColumn; + } + + private void DeleteForward() + { + if (_columnIndex < CurrentLine.Length) + { + _lines[_lineIndex] = CurrentLine.Remove(_columnIndex, 1); + return; + } + + if (_lineIndex == _lines.Count - 1) + { + return; + } + + _lines[_lineIndex] += _lines[_lineIndex + 1]; + _lines.RemoveAt(_lineIndex + 1); + } + + private void MoveLeft() + { + if (_columnIndex > 0) + { + _columnIndex--; + return; + } + + if (_lineIndex == 0) + { + return; + } + + _lineIndex--; + _columnIndex = CurrentLine.Length; + } + + private void MoveRight() + { + if (_columnIndex < CurrentLine.Length) + { + _columnIndex++; + return; + } + + if (_lineIndex == _lines.Count - 1) + { + return; + } + + _lineIndex++; + _columnIndex = 0; + } + + private string RenderCursorLine(string line, int cursorColumn, int innerWidth) + { + // +1 so the cursor can sit one column past the last character. + _columnViewport.Update(line.Length + 1, innerWidth); + _columnViewport.EnsureVisible(cursorColumn); + + var (start, count) = _columnViewport.Slice(); + var visibleEnd = Math.Min(line.Length, start + count); + var visibleText = start >= line.Length ? string.Empty : line[start..visibleEnd]; + var visibleCursor = cursorColumn - start; + + var before = Markup.Escape(visibleText[..visibleCursor]); + var atCursor = visibleCursor < visibleText.Length ? visibleText[visibleCursor].ToString() : " "; + var after = visibleCursor < visibleText.Length ? Markup.Escape(visibleText[(visibleCursor + 1)..]) : string.Empty; + + return $"{before}[{CursorStyle}]{Markup.Escape(atCursor)}[/]{after}"; + } + + private static string Truncate(string value, int width) + { + if (width <= 0 || value.Length <= width) + { + return value; + } + + return value[..Math.Max(0, width - 1)] + "โ€ฆ"; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Form/TextField.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Form/TextField.cs new file mode 100644 index 00000000000..40cef50e3ca --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Form/TextField.cs @@ -0,0 +1,68 @@ +using ChilliCream.Nitro.CommandLine.Tui.Widgets; +using Spectre.Console.Rendering; + +namespace ChilliCream.Nitro.CommandLine.Tui.Widgets.Form; + +/// +/// A single-line text field: cursor movement, insertion, deletion, and a +/// horizontal scroll window that keeps the cursor visible when the text is +/// wider than the field. +/// +internal sealed class TextField : FormField +{ + private const string CursorStyle = "black on white"; + private const int MinRenderWidth = 4; + + private readonly LineEditor _editor; + private readonly Viewport _viewport; + + public TextField( + string id, + string label, + bool required = false, + string initialValue = "", + Func? validator = null) + : base(id, label, required, validator) + { + _editor = new LineEditor(initialValue); + + // +1 so the cursor can sit one column past the last character. + _viewport = new Viewport(_editor.Text.Length + 1, 1); + } + + public override FormValue GetValue() => new FormValue.Text(_editor.Text); + + public override bool HandleKey(ConsoleKeyInfo info) => _editor.HandleKey(info); + + public override IRenderable Render(int width, bool focused) + { + var innerWidth = Math.Max(1, width - MinRenderWidth); + + _viewport.Update(_editor.Text.Length + 1, innerWidth); + _viewport.EnsureVisible(_editor.Cursor); + + var (start, count) = _viewport.Slice(); + var visibleEnd = Math.Min(_editor.Text.Length, start + count); + var visibleText = start >= _editor.Text.Length + ? string.Empty + : _editor.Text[start..visibleEnd]; + var visibleCursor = _editor.Cursor - start; + + var content = focused + ? RenderWithCursor(visibleText, visibleCursor) + : visibleText.Length == 0 + ? RenderPlaceholder("empty") + : Markup.Escape(visibleText); + + return RenderPanel(new Markup(content), width, focused); + } + + private static string RenderWithCursor(string visibleText, int cursor) + { + var before = Markup.Escape(visibleText[..cursor]); + var atCursor = cursor < visibleText.Length ? visibleText[cursor].ToString() : " "; + var after = cursor < visibleText.Length ? Markup.Escape(visibleText[(cursor + 1)..]) : string.Empty; + + return $"{before}[{CursorStyle}]{Markup.Escape(atCursor)}[/]{after}"; + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/TaskBadge.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/TaskBadge.cs new file mode 100644 index 00000000000..6da54cbf6a8 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/TaskBadge.cs @@ -0,0 +1,90 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Theming; + +namespace ChilliCream.Nitro.CommandLine.Tui.Widgets; + +/// +/// Renders one task row as a single Spectre markup line: selection prefix, +/// status glyph, type code, priority, id, and title. +/// +internal static class TaskBadge +{ + private const string Ellipsis = "โ€ฆ"; + private const string SelectedPrefix = "> "; + private const string UnselectedPrefix = " "; + + /// + /// Builds the markup line for one task row, truncating the title with an + /// ellipsis so the whole line fits within + /// display columns. A of 0 or less produces + /// an empty line. + /// + public static string Render( + string id, + string title, + string status, + int priority, + string type, + bool selected, + int maxWidth) + { + if (maxWidth <= 0) + { + return string.Empty; + } + + var prefix = selected ? SelectedPrefix : UnselectedPrefix; + var glyph = TaskGlyphs.Status(status); + var typeCode = TaskGlyphs.TypeCode(type); + var priorityText = TaskPriorities.Format(priority); + var priorityStyle = ThemeTokens.GetStyle($"badge.priority.p{priority}").ToMarkup(); + + // Plain-text length of everything but the title, so the title can be + // truncated to make the whole line fit maxWidth. + var fixedPlainLength = prefix.Length + glyph.Length + 1 + + typeCode.Length + 2 + 1 + + priorityText.Length + 1 + + id.Length + 1; + + var titleBudget = Math.Max(0, maxWidth - fixedPlainLength); + var truncatedTitle = Truncate(title, titleBudget); + var escapedTitle = Markup.Escape(truncatedTitle); + + var line = + $"{Markup.Escape(prefix)}{TaskGlyphs.StatusMarkup(status)} " + + $"{TaskGlyphs.TypeCodeMarkup(type)} " + + $"{Stylize(priorityStyle, priorityText)} " + + $"{Markup.Escape(id)} {escapedTitle}"; + + if (selected) + { + var highlightStyle = ThemeTokens.GetStyle("selection.highlight").ToMarkup(); + line = Stylize(highlightStyle, line); + } + + return line; + } + + private static string Stylize(string styleMarkup, string content) => + styleMarkup.Length == 0 ? content : $"[{styleMarkup}]{content}[/]"; + + private static string Truncate(string value, int width) + { + if (width <= 0) + { + return string.Empty; + } + + if (value.Length <= width) + { + return value; + } + + if (width == 1) + { + return Ellipsis; + } + + return string.Concat(value.AsSpan(0, width - 1), Ellipsis); + } +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/TaskGlyphs.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/TaskGlyphs.cs new file mode 100644 index 00000000000..5724429ac20 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/TaskGlyphs.cs @@ -0,0 +1,61 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Theming; + +namespace ChilliCream.Nitro.CommandLine.Tui.Widgets; + +/// +/// Maps task status and type codes to their display glyph, independent of +/// any specific row layout, so the search results list, detail sidebar and +/// dependency sections, and tree explorer render the same glyphs as +/// without composing a full badge line. +/// +internal static class TaskGlyphs +{ + /// + /// The plain-text status glyph: closed, in progress, open, deferred, + /// blocked. Unknown or custom states fall back to the open glyph. + /// + public static string Status(string status) => status switch + { + TaskStates.Closed => "โœ“", + TaskStates.InProgress => "โ—", + TaskStates.Open => "โ—‹", + TaskStates.Deferred => "โธ", + TaskStates.Blocked => "โŠ˜", + _ => "โ—‹" + }; + + /// + /// The status glyph as a Spectre markup fragment, styled per the + /// status.glyph.* theme token for . + /// + public static string StatusMarkup(string status) + => Stylize(ThemeTokens.GetStyle($"status.glyph.{status}").ToMarkup(), Status(status)); + + /// + /// The plain-text bracketed type code (for example B for bug, + /// F for feature). Unknown types fall back to the first letter of + /// , or ? for an empty type. + /// + public static string TypeCode(string type) => type switch + { + TaskTypes.Bug => "B", + TaskTypes.Feature => "F", + TaskTypes.Task => "T", + TaskTypes.Epic => "E", + TaskTypes.Chore => "C", + TaskTypes.Docs => "D", + TaskTypes.Question => "Q", + _ => type.Length > 0 ? type[..1].ToUpperInvariant() : "?" + }; + + /// + /// The bracketed type code as a Spectre markup fragment, styled per the + /// badge.type.* theme token for . + /// + public static string TypeCodeMarkup(string type) + => Stylize(ThemeTokens.GetStyle($"badge.type.{type}").ToMarkup(), $"[[{TypeCode(type)}]]"); + + private static string Stylize(string styleMarkup, string content) => + styleMarkup.Length == 0 ? content : $"[{styleMarkup}]{content}[/]"; +} diff --git a/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Viewport.cs b/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Viewport.cs new file mode 100644 index 00000000000..95b8984dd95 --- /dev/null +++ b/src/Nitro/CommandLine/src/CommandLine/Tui/Widgets/Viewport.cs @@ -0,0 +1,105 @@ +namespace ChilliCream.Nitro.CommandLine.Tui.Widgets; + +/// +/// Tracks the visible window over a list of a given length: the scroll +/// offset, how many rows are visible at once, and which range is currently +/// on screen. Does not render anything itself. +/// +internal sealed class Viewport +{ + public Viewport(int totalCount, int windowHeight) + { + Update(totalCount, windowHeight); + } + + /// + /// The total number of rows in the underlying list. + /// + public int TotalCount { get; private set; } + + /// + /// The number of rows visible at once. + /// + public int WindowHeight { get; private set; } + + /// + /// The index of the first visible row. + /// + public int Offset { get; private set; } + + /// + /// The number of rows above the visible window. + /// + public int HiddenAbove => Offset; + + /// + /// The number of rows below the visible window. + /// + public int HiddenBelow => Math.Max(0, TotalCount - (Offset + WindowHeight)); + + /// + /// Updates the total row count and window height, clamping the offset so + /// it still points at a valid window. + /// + public void Update(int totalCount, int windowHeight) + { + TotalCount = Math.Max(0, totalCount); + WindowHeight = Math.Max(0, windowHeight); + ClampOffset(); + } + + /// + /// Scrolls the window, if needed, so that is + /// visible. Out-of-range indexes are clamped to the list bounds. + /// + public void EnsureVisible(int index) + { + if (TotalCount == 0 || WindowHeight == 0) + { + Offset = 0; + return; + } + + var clampedIndex = Math.Clamp(index, 0, TotalCount - 1); + + if (clampedIndex < Offset) + { + Offset = clampedIndex; + } + else if (clampedIndex > Offset + WindowHeight - 1) + { + Offset = clampedIndex - WindowHeight + 1; + } + + ClampOffset(); + } + + /// + /// Moves the offset by , clamping it to a valid + /// window position. + /// + public void ScrollBy(int delta) + { + Offset += delta; + ClampOffset(); + } + + /// + /// Returns the currently visible range as a start index and row count. + /// + public (int Start, int Count) Slice() + { + if (TotalCount == 0 || WindowHeight == 0) + { + return (0, 0); + } + + return (Offset, Math.Min(WindowHeight, TotalCount - Offset)); + } + + private void ClampOffset() + { + var maxOffset = Math.Max(0, TotalCount - WindowHeight); + Offset = Math.Clamp(Offset, 0, maxOffset); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Smoke.Tests/Nitro.CommandLine.Smoke.Tests.csproj b/src/Nitro/CommandLine/test/CommandLine.Smoke.Tests/Nitro.CommandLine.Smoke.Tests.csproj new file mode 100644 index 00000000000..d6979339a0f --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Smoke.Tests/Nitro.CommandLine.Smoke.Tests.csproj @@ -0,0 +1,18 @@ +๏ปฟ + + + + + ChilliCream.Nitro.CommandLine.Smoke.Tests + ChilliCream.Nitro.CommandLine.Smoke.Tests + + + + + + + + + + + diff --git a/src/Nitro/CommandLine/test/CommandLine.Smoke.Tests/SmokeTests.cs b/src/Nitro/CommandLine/test/CommandLine.Smoke.Tests/SmokeTests.cs new file mode 100644 index 00000000000..36959bc4cef --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Smoke.Tests/SmokeTests.cs @@ -0,0 +1,173 @@ +using System.Runtime.CompilerServices; +using System.Runtime.Versioning; +using CliWrap; +using CliWrap.Buffered; + +namespace ChilliCream.Nitro.CommandLine.Smoke.Tests; + +public class SmokeTests +{ + private static readonly string ProjectPath = ResolveProjectPath(); + private static readonly string TargetFramework = ResolveTargetFramework(); + private static readonly string Configuration = ResolveConfiguration(); + + [Fact] + public async Task Version_Flag_Prints_Version() + { + // act + var result = await RunNitroAsync("--version"); + + // assert + Assert.Equal(0, result.ExitCode); + Assert.False(string.IsNullOrWhiteSpace(result.StandardOutput)); + } + + [Fact] + public async Task Help_Flag_Mentions_Nitro() + { + // act + var result = await RunNitroAsync("--help"); + + // assert + Assert.Equal(0, result.ExitCode); + Assert.Contains("nitro", result.StandardOutput, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Task_Init_Creates_Workspace_In_Fresh_Directory() + { + // arrange + var tempDir = CreateTempDirectory(); + try + { + // act + var result = await RunNitroAsync("agent tasks init", workingDirectory: tempDir); + + // assert + Assert.Equal(0, result.ExitCode); + Assert.Contains("Initialized task workspace", result.StandardOutput); + } + finally + { + DeleteDirectory(tempDir); + } + } + + [Fact] + public async Task Task_List_Succeeds_After_Init() + { + // arrange + var tempDir = CreateTempDirectory(); + try + { + var initResult = await RunNitroAsync("agent tasks init", workingDirectory: tempDir); + Assert.Equal(0, initResult.ExitCode); + + // act + var result = await RunNitroAsync("agent tasks list", workingDirectory: tempDir); + + // assert + Assert.Equal(0, result.ExitCode); + } + finally + { + DeleteDirectory(tempDir); + } + } + + private static async Task RunNitroAsync(string arguments, string? workingDirectory = null) + { + // Run the already-built CLI in the SAME configuration these tests were + // built in. 'dotnet run --no-build' defaults to Debug, so without this + // a Release test run (e.g. CI) would point at a Debug build that does + // not exist and every smoke test would fail with exit code 1. + var args = new[] + { + "run", "--project", ProjectPath, "-c", Configuration, "--framework", TargetFramework, "--no-build", "--" + }.Concat(SplitArguments(arguments)); + + var command = Cli.Wrap("dotnet") + .WithArguments(args) + .WithValidation(CommandResultValidation.None) + .WithEnvironmentVariables(env => env.Set("NITRO_TASK_ACTOR", "smoke-test")); + + if (workingDirectory is not null) + { + command = command.WithWorkingDirectory(workingDirectory); + } + + return await command.ExecuteBufferedAsync(TestContext.Current.CancellationToken); + } + + private static string ResolveConfiguration() + { + var configured = Environment.GetEnvironmentVariable("NITRO_SMOKE_TEST_CONFIGURATION"); + if (!string.IsNullOrWhiteSpace(configured)) + { + return configured.Trim(); + } + +#if DEBUG + return "Debug"; +#else + return "Release"; +#endif + } + + private static string ResolveTargetFramework() + { + var configuredFramework = Environment.GetEnvironmentVariable("NITRO_SMOKE_TEST_TFM"); + if (!string.IsNullOrWhiteSpace(configuredFramework)) + { + return configuredFramework.Trim(); + } + + var frameworkName = + typeof(SmokeTests).Assembly.GetCustomAttributes(typeof(TargetFrameworkAttribute), inherit: false) + .OfType() + .Single() + .FrameworkName; + + const string versionPrefix = ".NETCoreApp,Version=v"; + if (!frameworkName.StartsWith(versionPrefix, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Unsupported target framework '{frameworkName}'."); + } + + return "net" + frameworkName[versionPrefix.Length..]; + } + + private static IEnumerable SplitArguments(string arguments) + { + return arguments.Split(' ', StringSplitOptions.RemoveEmptyEntries); + } + + private static string CreateTempDirectory() + { + var dir = Path.Combine(Path.GetTempPath(), "nitro-smoke-" + Path.GetRandomFileName()); + Directory.CreateDirectory(dir); + return dir; + } + + private static void DeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + { + Directory.Delete(path, recursive: true); + } + } + catch + { + } + } + + private static string ResolveProjectPath([CallerFilePath] string? sourceFilePath = null) + { + var dir = + Path.GetDirectoryName(sourceFilePath) + ?? throw new InvalidOperationException("Unable to resolve smoke test source directory."); + return Path.GetFullPath(Path.Combine(dir, "..", "..", "src", "CommandLine", "Nitro.CommandLine.csproj")); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/CommandTestBase.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/CommandTestBase.cs index a1eb1232bb2..69ecdc7c5b4 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/CommandTestBase.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/CommandTestBase.cs @@ -18,6 +18,7 @@ using ChilliCream.Nitro.CommandLine.Tests.Console; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Time.Testing; using Moq; using Spectre.Console; using Spectre.Console.Testing; @@ -35,6 +36,9 @@ public abstract class CommandTestBase private readonly NitroCommandFixture _fixture; private readonly List _files = []; private readonly Mock _fileSystemMock = new(); + private IFileSystem? _fileSystemOverride; + protected readonly FakeTimeProvider FakeTime = + new(new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); private readonly Mock _environmentVariableProviderMock = new(); private readonly Mock _httpClientFactoryMock = new(); protected readonly Mock SchemasClientMock = new(MockBehavior.Strict); @@ -69,6 +73,15 @@ protected void SetupInteractionMode(InteractionMode mode) _interactionMode = mode; } + /// + /// Replaces the mocked file system with the given implementation, for + /// tests that run commands against real files in a temp directory. + /// + private protected void SetupFileSystem(IFileSystem fileSystem) + { + _fileSystemOverride = fileSystem; + } + protected void SetupNoAuthentication() { _authenticated = false; @@ -234,7 +247,8 @@ private ServiceProvider BuildServices(INitroConsole console) "Workspace from session"))); } - services.Replace(ServiceDescriptor.Singleton(_fileSystemMock.Object)); + services.Replace(ServiceDescriptor.Singleton(_fileSystemOverride ?? _fileSystemMock.Object)); + services.Replace(ServiceDescriptor.Singleton(FakeTime)); services.Replace(ServiceDescriptor.Singleton(_environmentVariableProviderMock.Object)); services.Replace(ServiceDescriptor.Singleton(_httpClientFactoryMock.Object)); services.Replace(ServiceDescriptor.Singleton(_sessionServiceMock.Object)); @@ -412,7 +426,7 @@ protected void VerifyNoWorkspaceSelected() public ValueTask InitializeAsync() => ValueTask.CompletedTask; - public async ValueTask DisposeAsync() + public virtual async ValueTask DisposeAsync() { foreach (var file in _files) { diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionComposeCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionComposeCommandTests.cs index bb33f4cde93..1791810f054 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionComposeCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionComposeCommandTests.cs @@ -705,9 +705,10 @@ public async Task Compose_Valid_Extensions_FromDirectory() var settingsFile = Path.Combine(workDir, "source-schema-1-settings.json"); var extensionsFile = Path.Combine(workDir, "source-schema-1-extensions.graphqls"); - SetupFile(schemaFile, (await File.ReadAllTextAsync( + var schemaText = await File.ReadAllTextAsync( schemaFile, - TestContext.Current.CancellationToken)).TrimEnd()); + TestContext.Current.CancellationToken); + SetupFile(schemaFile, schemaText.TrimEnd()); SetupFile(settingsFile, new MemoryStream(await File.ReadAllBytesAsync( settingsFile, TestContext.Current.CancellationToken))); diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionPublishCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionPublishCommandTests.cs index 7b52378fea1..20528be3986 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionPublishCommandTests.cs +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Fusion/FusionPublishCommandTests.cs @@ -5528,7 +5528,8 @@ private static async Task GetMigratedLegacyArchiveSchemaAsync(MemoryStre var schema = await GetFusionSchemaAsync(archive); - var names = (await archive.GetSourceSchemaNamesAsync()).ToArray(); + var sourceSchemaNames = await archive.GetSourceSchemaNamesAsync(); + var names = sourceSchemaNames.ToArray(); Assert.Equal( new[] { SourceSchemaReviews, SourceSchema }.OrderBy(x => x), names.OrderBy(x => x)); @@ -5556,7 +5557,8 @@ private static async Task GetOverriddenLegacyArchiveSchemaAsync(MemorySt var schema = await GetFusionSchemaAsync(archive); - var names = (await archive.GetSourceSchemaNamesAsync()).ToArray(); + var sourceSchemaNames = await archive.GetSourceSchemaNamesAsync(); + var names = sourceSchemaNames.ToArray(); Assert.Equal(new[] { SourceSchemaReviews }, names); var reviews = await archive.TryGetSourceSchemaConfigurationAsync(SourceSchemaReviews); diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/AddTaskCommentCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/AddTaskCommentCommandTests.cs new file mode 100644 index 00000000000..66ccff86551 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/AddTaskCommentCommandTests.cs @@ -0,0 +1,124 @@ + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class AddTaskCommentCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "comment", "add", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Add a comment to a task. + + Usage: + nitro agent tasks comment add [options] + + Arguments: + The task ID + The comment text + + Options: + --actor The acting identity recorded on the audit log (defaults to NITRO_TASK_ACTOR or the OS user name) + --output The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks comment add "acme-1a2" "Looks good to me." + """); + } + + [Fact] + public async Task AddsComment_Successfully() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "comment", "add", id, "Looks good to me."); + + // assert + result.AssertSuccess($"โœ“ Added comment to '{id}'."); + Assert.Equal("Looks good to me.", await QueryScalarAsync( + $"SELECT text FROM comments WHERE task_id = '{id}'")); + } + + [Fact] + public async Task AddsComment_RecordsActorAsAuthor() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "comment", "add", id, "Reviewed.", "--actor", "alice"); + + // assert + result.AssertSuccess($"โœ“ Added comment to '{id}'."); + Assert.Equal("alice", await QueryScalarAsync( + $"SELECT author FROM comments WHERE task_id = '{id}'")); + } + + [Fact] + public async Task JsonOutput_ReturnsCreatedComment() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "comment", "add", id, "Looks good to me.", "--actor", "alice"); + + // assert + result.AssertSuccess( + $$""" + { + "id": 1, + "taskId": "{{id}}", + "author": "alice", + "text": "Looks good to me.", + "createdAt": "2026-01-01T00:00:00+00:00" + } + """); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public async Task EmptyOrWhitespaceText_ReturnsError(string text) + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "comment", "add", id, text); + + // assert + result.AssertError("The comment text must not be empty."); + } + + [Fact] + public async Task TaskNotFound_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "comment", "add", "acme-999", "Looks good to me."); + + // assert + result.AssertError("Task 'acme-999' does not exist."); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/AddTaskDependencyCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/AddTaskDependencyCommandTests.cs new file mode 100644 index 00000000000..e56f1779d68 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/AddTaskDependencyCommandTests.cs @@ -0,0 +1,195 @@ + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class AddTaskDependencyCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "add", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Add a dependency between two tasks. + + Usage: + nitro agent tasks dep add [options] + + Arguments: + The task ID + The task this dependency points to + + Options: + --type The dependency type (blocks, parent-child, waits-for, related, ...; default blocks) + --actor The acting identity recorded on the audit log (defaults to NITRO_TASK_ACTOR or the OS user name) + --output The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks dep add "acme-1a2" "acme-9z8" + nitro agent tasks dep add "acme-1a2" "acme-9z8" --type waits-for + """); + } + + [Fact] + public async Task DefaultType_AddsBlockingDependency() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + var dependsOnId = await CreateTaskAsync("Write the tokenizer"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "add", id, dependsOnId); + + // assert + result.AssertSuccess($"โœ“ Added blocks dependency: '{id}' -> '{dependsOnId}'."); + Assert.Equal( + "blocks", + await QueryScalarAsync( + "SELECT dependency_type FROM dependencies " + + $"WHERE task_id = '{id}' AND depends_on_id = '{dependsOnId}'")); + } + + [Fact] + public async Task WithType_RecordsGivenTypeAndEvent() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + var dependsOnId = await CreateTaskAsync("Write the tokenizer"); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "dep", "add", id, dependsOnId, "--type", "waits-for"); + + // assert + result.AssertSuccess($"โœ“ Added waits-for dependency: '{id}' -> '{dependsOnId}'."); + Assert.Equal( + $"waits-for:{dependsOnId}", + await QueryScalarAsync( + "SELECT new_value FROM events " + + $"WHERE task_id = '{id}' AND event_type = 'dependency_added'")); + } + + [Fact] + public async Task JsonOutput_ReturnsMinimalDependencyChange() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + var dependsOnId = await CreateTaskAsync("Write the tokenizer"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "dep", "add", id, dependsOnId, "--type", "waits-for"); + + // assert + result.AssertSuccess( + $$""" + { + "id": "{{id}}", + "dependsOnId": "{{dependsOnId}}", + "type": "waits-for", + "cycle": null + } + """); + } + + [Fact] + public async Task JsonOutput_CreatesCycle_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var a = await CreateTaskAsync("Task A"); + var b = await CreateTaskAsync("Task B"); + await ExecuteCommandAsync("agent", "tasks", "dep", "add", a, b); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "add", b, a); + + // assert + result.AssertError($"Adding this dependency would create a cycle: {b} -> {a} -> {b}."); + Assert.Equal( + "0", + await QueryScalarAsync( + "SELECT COUNT(*) FROM dependencies " + + $"WHERE task_id = '{b}' AND depends_on_id = '{a}'")); + } + + [Fact] + public async Task SelfDependency_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "add", id, id); + + // assert + result.AssertError("A task cannot depend on itself."); + } + + [Fact] + public async Task DuplicateDependency_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + var dependsOnId = await CreateTaskAsync("Write the tokenizer"); + await ExecuteCommandAsync("agent", "tasks", "dep", "add", id, dependsOnId); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "add", id, dependsOnId); + + // assert + result.AssertError("Dependency already exists."); + } + + [Fact] + public async Task MissingDependsOnTask_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "add", id, "acme-999"); + + // assert + result.AssertError("Task 'acme-999' does not exist."); + } + + [Fact] + public async Task CreatesCycle_RejectsBeforeCommit() + { + // arrange + await InitWorkspaceAsync(); + var a = await CreateTaskAsync("Task A"); + var b = await CreateTaskAsync("Task B"); + await ExecuteCommandAsync("agent", "tasks", "dep", "add", a, b); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "add", b, a); + + // assert + result.AssertError($"Adding this dependency would create a cycle: {b} -> {a} -> {b}."); + Assert.Equal( + "0", + await QueryScalarAsync( + "SELECT COUNT(*) FROM dependencies " + + $"WHERE task_id = '{b}' AND depends_on_id = '{a}'")); + Assert.Equal( + "1", + await QueryScalarAsync( + "SELECT COUNT(*) FROM dependencies " + + $"WHERE task_id = '{a}' AND depends_on_id = '{b}'")); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/AddTaskLabelCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/AddTaskLabelCommandTests.cs new file mode 100644 index 00000000000..9ad12658e0d --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/AddTaskLabelCommandTests.cs @@ -0,0 +1,159 @@ + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class AddTaskLabelCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "label", "add", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Add one or more labels to a task. + + Usage: + nitro agent tasks label add ... [options] + + Arguments: + The task ID + One or more labels + + Options: + --actor The acting identity recorded on the audit log (defaults to NITRO_TASK_ACTOR or the OS user name) + --output The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks label add "acme-1a2" api parser + """); + } + + [Fact] + public async Task SingleLabel_AddsSuccessfully() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "label", "add", id, "api"); + + // assert + result.AssertSuccess($"โœ“ Added label 'api' to '{id}'."); + Assert.Equal("api", await QueryScalarAsync( + $"SELECT label FROM labels WHERE task_id = '{id}'")); + } + + [Fact] + public async Task MultipleLabels_AddsAllInOneCall() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "label", "add", id, "api", "parser"); + + // assert + result.AssertSuccess( + $""" + โœ“ Added label 'api' to '{id}'. + โœ“ Added label 'parser' to '{id}'. + """); + Assert.Equal("2", await QueryScalarAsync( + $"SELECT COUNT(*) FROM labels WHERE task_id = '{id}'")); + } + + [Fact] + public async Task UppercaseLabelWithWhitespace_IsNormalizedBeforeStorage() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "label", "add", id, " API "); + + // assert + result.AssertSuccess($"โœ“ Added label 'api' to '{id}'."); + Assert.Equal("api", await QueryScalarAsync( + $"SELECT label FROM labels WHERE task_id = '{id}'")); + } + + [Fact] + public async Task ExistingLabel_PrintsAlreadyOnMessage() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + await ExecuteCommandAsync("agent", "tasks", "label", "add", id, "api"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "label", "add", id, "api"); + + // assert + result.AssertSuccess($"Label 'api' is already on '{id}'."); + } + + [Fact] + public async Task JsonOutput_ReturnsMinimalLabelChanges() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "label", "add", id, "api", "parser"); + + // assert + result.AssertSuccess( + $$""" + { + "id": "{{id}}", + "labels": [ + { + "label": "api", + "added": true + }, + { + "label": "parser", + "added": true + } + ] + } + """); + } + + [Fact] + public async Task EmptyLabel_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "label", "add", id, " "); + + // assert + result.AssertError("Labels must be non-empty."); + } + + [Fact] + public async Task TaskNotFound_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "label", "add", "acme-999", "api"); + + // assert + result.AssertError("Task 'acme-999' does not exist."); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/BlockedTaskCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/BlockedTaskCommandTests.cs new file mode 100644 index 00000000000..85200147ca6 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/BlockedTaskCommandTests.cs @@ -0,0 +1,165 @@ +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class BlockedTaskCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "blocked", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + List tasks that are blocked by unfinished dependencies. + + Usage: + nitro agent tasks blocked [options] + + Options: + --output The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks blocked + """); + } + + [Fact] + public async Task NoWorkspace_ReturnsError() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "blocked"); + + // assert + result.AssertError( + """ + No task workspace found. Run `nitro agent tasks init` first. + """); + } + + [Fact] + public async Task NoBlockedTasks_ReturnsEmptyMessage() + { + // arrange + await InitWorkspaceAsync(); + await ExecuteCommandAsync("agent", "tasks", "create", "Standalone task"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "blocked"); + + // assert + result.AssertSuccess( + """ + No blocked tasks. + """); + } + + [Fact] + public async Task BlockedTask_ReturnsRowWithBlockerList() + { + // arrange + await InitWorkspaceAsync(); + var baseId = ExtractTaskId(await ExecuteCommandAsync("agent", "tasks", "create", "Base task")); + var dependentId = ExtractTaskId( + await ExecuteCommandAsync("agent", "tasks", "create", "Dependent task", "--depends-on", baseId)); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "blocked"); + + // assert + result.AssertSuccess( + $""" + {dependentId} P2 task open Dependent task (blocked by: {baseId}:open) + + 1 task(s) + """); + } + + [Fact] + public async Task ClosedBlockedTask_IsExcludedFromBlockedList() + { + // arrange + await InitWorkspaceAsync(); + var baseId = ExtractTaskId(await ExecuteCommandAsync("agent", "tasks", "create", "Base task")); + var dependentId = ExtractTaskId( + await ExecuteCommandAsync("agent", "tasks", "create", "Dependent task", "--depends-on", baseId)); + await ExecuteCommandAsync("agent", "tasks", "close", dependentId); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "blocked"); + + // assert + result.AssertSuccess( + """ + No blocked tasks. + """); + } + + [Fact] + public async Task JsonOutput_ReturnsStructuredListWithBlockers() + { + // arrange + await InitWorkspaceAsync(); + var baseId = ExtractTaskId(await ExecuteCommandAsync("agent", "tasks", "create", "Base task")); + var dependentId = ExtractTaskId( + await ExecuteCommandAsync("agent", "tasks", "create", "Dependent task", "--depends-on", baseId)); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "blocked"); + + // assert + result.AssertSuccess( + $$""" + { + "items": [ + { + "id": "{{dependentId}}", + "priority": 2, + "type": "task", + "status": "open", + "title": "Dependent task", + "blockers": [ + "{{baseId}}:open" + ] + } + ] + } + """); + } + + [Fact] + public async Task JsonOutput_NoBlockedTasks_ReturnsEmptyItems() + { + // arrange + await InitWorkspaceAsync(); + await ExecuteCommandAsync("agent", "tasks", "create", "Standalone task"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "blocked"); + + // assert + result.AssertSuccess( + """ + { + "items": [] + } + """); + } + + /// + /// Pulls the task ID out of a `task create` confirmation line + /// ("Created task 'ID': Title.") so dependent commands can use it + /// without predicting the hash-derived ID ahead of time. + /// + private static string ExtractTaskId(CommandResult result) + { + var start = result.StdOut.IndexOf('\'') + 1; + var end = result.StdOut.IndexOf('\'', start); + return result.StdOut[start..end]; + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/BoardTaskCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/BoardTaskCommandTests.cs new file mode 100644 index 00000000000..236110d0641 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/BoardTaskCommandTests.cs @@ -0,0 +1,54 @@ +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class BoardTaskCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "board", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Open the interactive task board. + + Usage: + nitro agent tasks board [options] + + Options: + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks board + """); + } + + [Fact] + public async Task NonInteractive_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "board"); + + // assert + result.AssertError("agent tasks board requires an interactive terminal."); + } + + [Fact] + public async Task NoWorkspace_ReturnsError() + { + // arrange + SetupInteractionMode(InteractionMode.Interactive); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "board"); + + // assert + result.AssertError("No task workspace found. Run `nitro agent tasks init` first."); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/CloseEligibleTaskEpicCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/CloseEligibleTaskEpicCommandTests.cs new file mode 100644 index 00000000000..d5a454f2e3a --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/CloseEligibleTaskEpicCommandTests.cs @@ -0,0 +1,172 @@ +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class CloseEligibleTaskEpicCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "epic", "close-eligible", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Close every epic whose children are all closed. + + Usage: + nitro agent tasks epic close-eligible [options] + + Options: + --actor The acting identity recorded on the audit log (defaults to NITRO_TASK_ACTOR or the OS user name) + --output The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks epic close-eligible + """); + } + + [Fact] + public async Task NoWorkspace_ReturnsError() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "epic", "close-eligible"); + + // assert + result.AssertError( + """ + No task workspace found. Run `nitro agent tasks init` first. + """); + } + + [Fact] + public async Task NoEligibleEpics_ReturnsEmptyMessage() + { + // arrange + await InitWorkspaceAsync(); + var epicId = await CreateTaskAsync("Ship v5", "--type", "epic"); + await CreateTaskAsync("Design API v5", "--parent", epicId); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "epic", "close-eligible"); + + // assert + result.AssertSuccess( + """ + No eligible epics. + """); + } + + [Fact] + public async Task EligibleEpic_ClosesAndRecordsEvent() + { + // arrange + await InitWorkspaceAsync(); + var epicId = await CreateTaskAsync("Ship v6", "--type", "epic"); + var childId = await CreateTaskAsync("Design API v6", "--parent", epicId); + await ExecuteCommandAsync("agent", "tasks", "close", childId); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "epic", "close-eligible"); + + // assert + result.AssertSuccess($"โœ“ Closed epic '{epicId}'."); + Assert.Equal("closed", await QueryScalarAsync( + $"SELECT status FROM tasks WHERE id = '{epicId}'")); + Assert.Equal("All children are closed.", await QueryScalarAsync( + $"SELECT close_reason FROM tasks WHERE id = '{epicId}'")); + Assert.Equal("closed", await QueryScalarAsync( + $"SELECT new_value FROM events WHERE task_id = '{epicId}' AND event_type = 'closed'")); + } + + [Fact] + public async Task JsonOutput_ReturnsClosedEpics() + { + // arrange + await InitWorkspaceAsync(); + var epicId = await CreateTaskAsync("Ship v10", "--type", "epic"); + var childId = await CreateTaskAsync("Design API v10", "--parent", epicId); + await ExecuteCommandAsync("agent", "tasks", "close", childId); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "epic", "close-eligible"); + + // assert + using var document = System.Text.Json.JsonDocument.Parse(result.StdOut); + var items = document.RootElement.GetProperty("items"); + + Assert.Empty(result.StdErr); + Assert.Equal(0, result.ExitCode); + Assert.Equal(1, items.GetArrayLength()); + Assert.Equal(epicId, items[0].GetProperty("id").GetString()); + Assert.Equal("closed", items[0].GetProperty("status").GetString()); + } + + [Fact] + public async Task JsonOutput_NoEligibleEpics_ReturnsEmptyItems() + { + // arrange + await InitWorkspaceAsync(); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "epic", "close-eligible"); + + // assert + result.AssertSuccess( + """ + { + "items": [] + } + """); + } + + [Fact] + public async Task MultipleEligibleEpics_ClosesAllInAscendingIdOrder() + { + // arrange + await InitWorkspaceAsync(); + var epicAId = await CreateTaskAsync("Ship v7", "--type", "epic"); + var childAId = await CreateTaskAsync("Design v7", "--parent", epicAId); + await ExecuteCommandAsync("agent", "tasks", "close", childAId); + var epicBId = await CreateTaskAsync("Ship v8", "--type", "epic"); + var childBId = await CreateTaskAsync("Design v8", "--parent", epicBId); + await ExecuteCommandAsync("agent", "tasks", "close", childBId); + var (firstId, secondId) = string.CompareOrdinal(epicAId, epicBId) < 0 + ? (epicAId, epicBId) + : (epicBId, epicAId); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "epic", "close-eligible"); + + // assert + result.AssertSuccess( + $""" + โœ“ Closed epic '{firstId}'. + โœ“ Closed epic '{secondId}'. + """); + } + + [Fact] + public async Task AlreadyClosedEpic_IsNotReClosed() + { + // arrange + await InitWorkspaceAsync(); + var epicId = await CreateTaskAsync("Ship v9", "--type", "epic"); + var childId = await CreateTaskAsync("Design API v9", "--parent", epicId); + await ExecuteCommandAsync("agent", "tasks", "close", childId); + await ExecuteCommandAsync("agent", "tasks", "epic", "close-eligible"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "epic", "close-eligible"); + + // assert + result.AssertSuccess( + """ + No eligible epics. + """); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/CloseTaskCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/CloseTaskCommandTests.cs new file mode 100644 index 00000000000..8f3a02348bc --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/CloseTaskCommandTests.cs @@ -0,0 +1,157 @@ + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class CloseTaskCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "close", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Close one or more tasks. + + Usage: + nitro agent tasks close ... [options] + + Arguments: + One or more task IDs + + Options: + --reason The reason recorded for this change + --actor The acting identity recorded on the audit log (defaults to NITRO_TASK_ACTOR or the OS user name) + --output The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks close "app-1a2" + nitro agent tasks close "app-1a2" "app-9z8" --reason "Fixed in v2" + """); + } + + [Fact] + public async Task SingleTask_ClosesSuccessfully() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "close", id); + + // assert + result.AssertSuccess($"โœ“ Closed task '{id}'."); + Assert.Equal("closed", await QueryScalarAsync( + $"SELECT status FROM tasks WHERE id = '{id}'")); + } + + [Fact] + public async Task MultipleTasks_ClosesAllTasks() + { + // arrange + await InitWorkspaceAsync(); + var id1 = await CreateTaskAsync("Fix the parser"); + var id2 = await CreateTaskAsync("Write the docs"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "close", id1, id2); + + // assert + result.AssertSuccess( + $""" + โœ“ Closed task '{id1}'. + โœ“ Closed task '{id2}'. + """); + } + + [Fact] + public async Task WithReason_RecordsCloseReason() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "close", id, "--reason", "Fixed in v2"); + + // assert + result.AssertSuccess($"โœ“ Closed task '{id}'."); + Assert.Equal("Fixed in v2", await QueryScalarAsync( + $"SELECT close_reason FROM tasks WHERE id = '{id}'")); + } + + [Fact] + public async Task AlreadyClosed_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + await ExecuteCommandAsync("agent", "tasks", "close", id); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "close", id); + + // assert + result.AssertError($"Task '{id}' is already closed."); + } + + [Fact] + public async Task TaskNotFound_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "close", "acme-999"); + + // assert + result.AssertError("Task 'acme-999' does not exist."); + } + + [Fact] + public async Task JsonOutput_ReturnsClosedTaskSnapshots() + { + // arrange + await InitWorkspaceAsync(); + var id1 = await CreateTaskAsync("Fix the parser"); + var id2 = await CreateTaskAsync("Write the docs"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "close", id1, id2); + + // assert + using var document = System.Text.Json.JsonDocument.Parse(result.StdOut); + var items = document.RootElement.GetProperty("items"); + + Assert.Empty(result.StdErr); + Assert.Equal(0, result.ExitCode); + Assert.Equal(2, items.GetArrayLength()); + Assert.Equal(id1, items[0].GetProperty("id").GetString()); + Assert.Equal("closed", items[0].GetProperty("status").GetString()); + Assert.Equal(id2, items[1].GetProperty("id").GetString()); + } + + [Fact] + public async Task OneTaskInvalid_RollsBackAllChanges() + { + // arrange + await InitWorkspaceAsync(); + var id1 = await CreateTaskAsync("Fix the parser"); + var id2 = await CreateTaskAsync("Write the docs"); + await ExecuteCommandAsync("agent", "tasks", "close", id2); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "close", id1, id2); + + // assert + result.AssertError($"Task '{id2}' is already closed."); + Assert.Equal("open", await QueryScalarAsync( + $"SELECT status FROM tasks WHERE id = '{id1}'")); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/CountTaskCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/CountTaskCommandTests.cs new file mode 100644 index 00000000000..3c20937174d --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/CountTaskCommandTests.cs @@ -0,0 +1,187 @@ + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class CountTaskCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "count", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Count tasks. + + Usage: + nitro agent tasks count [options] + + Options: + --by Group counts by: status, type, priority, assignee, or label + --output The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks count + nitro agent tasks count --by status + """); + } + + [Fact] + public async Task NoOptions_PrintsBareTotal_ExcludingTombstones() + { + // arrange + await InitWorkspaceAsync(); + await CreateTaskAsync("Fix the parser"); + var closedId = await CreateTaskAsync("Write the docs"); + await ExecuteCommandAsync("agent", "tasks", "close", closedId); + var deletedId = await CreateTaskAsync("Old task"); + await ExecuteCommandAsync("agent", "tasks", "delete", deletedId, "--force"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "count"); + + // assert + result.AssertSuccess("2"); + } + + [Fact] + public async Task ByStatus_GroupsAndSortsByStatus() + { + // arrange + await InitWorkspaceAsync(); + await CreateTaskAsync("Fix the parser"); + var closedId = await CreateTaskAsync("Write the docs"); + await ExecuteCommandAsync("agent", "tasks", "close", closedId); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "count", "--by", "status"); + + // assert + result.AssertSuccess( + """ + closed 1 + open 1 + """); + } + + [Fact] + public async Task ByPriority_FormatsPriorityLabel() + { + // arrange + await InitWorkspaceAsync(); + await CreateTaskAsync("Fix the parser", "--priority", "p1"); + await CreateTaskAsync("Write the docs", "--priority", "p1"); + await CreateTaskAsync("Old task", "--priority", "p3"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "count", "--by", "priority"); + + // assert + result.AssertSuccess( + """ + P1 2 + P3 1 + """); + } + + [Fact] + public async Task ByAssignee_UnassignedFallback() + { + // arrange + await InitWorkspaceAsync(); + await CreateTaskAsync("Fix the parser", "--assignee", "alice"); + await CreateTaskAsync("Write the docs"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "count", "--by", "assignee"); + + // assert + result.AssertSuccess( + """ + alice 1 + unassigned 1 + """); + } + + [Fact] + public async Task ByLabel_TaskCountsUnderEachLabel() + { + // arrange + await InitWorkspaceAsync(); + await CreateTaskAsync("Fix the parser", "--label", "api", "--label", "parser"); + await CreateTaskAsync("Write the docs", "--label", "api"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "count", "--by", "label"); + + // assert + result.AssertSuccess( + """ + api 2 + parser 1 + """); + } + + [Fact] + public async Task JsonOutput_NoOptions_ReturnsStructuredTotal() + { + // arrange + await InitWorkspaceAsync(); + await CreateTaskAsync("Fix the parser"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "count"); + + // assert + result.AssertSuccess( + """ + { + "total": 1 + } + """); + } + + [Fact] + public async Task JsonOutput_ByStatus_ReturnsStructuredList() + { + // arrange + await InitWorkspaceAsync(); + await CreateTaskAsync("Fix the parser"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "count", "--by", "status"); + + // assert + result.AssertSuccess( + """ + { + "items": [ + { + "value": "open", + "count": 1 + } + ] + } + """); + } + + [Fact] + public async Task InvalidBy_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "count", "--by", "bogus"); + + // assert + result.AssertError( + "Invalid --by value 'bogus'. Use status, type, priority, assignee, or label."); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/CreateTaskCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/CreateTaskCommandTests.cs new file mode 100644 index 00000000000..e10541c3827 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/CreateTaskCommandTests.cs @@ -0,0 +1,239 @@ +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class CreateTaskCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "create", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Create a task. + + Usage: + nitro agent tasks create [options] + + Arguments: + <title> The task title + + Options: + --description <description> The task description + --priority <priority> The task priority, 0-4 or p0-p4 (0 = critical, 4 = backlog); list/ready also accept a range like 0-1 or p0-p1 + --type <type> The task type (task, bug, feature, epic, chore, docs, question, or custom) + --assignee <assignee> The assignee + --label <label> A label; can be used multiple times + --due <due> The due date as an ISO 8601 date or timestamp + --defer-until <defer-until> Hide the task from ready work until this ISO 8601 date or timestamp + --estimate <estimate> The estimated effort in minutes + --actor <actor> The acting identity recorded on the audit log (defaults to NITRO_TASK_ACTOR or the OS user name) + --depends-on <depends-on> A dependency as 'id' or 'type:id'; can be used multiple times + --parent <parent> The parent task ID; the new task becomes its child + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks create "Fix the parser" + nitro agent tasks create "Fix the parser" --priority p1 --type bug --depends-on "acme-9z8" + """); + } + + [Fact] + public async Task MinimalArgs_CreatesTask_WithDefaults() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "create", "Fix the parser"); + + // assert + result.AssertSuccess("โœ“ Created task 'acme-n5z': Fix the parser."); + Assert.Equal( + "open|2|task|test-agent", + await QueryScalarAsync( + "SELECT status || '|' || priority || '|' || task_type || '|' || created_by " + + "FROM tasks WHERE id = 'acme-n5z'")); + } + + [Fact] + public async Task JsonOutput_MinimalArgs_ReturnsCreatedTaskSnapshot() + { + // arrange + await InitWorkspaceAsync(); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "create", "Fix the parser"); + + // assert + result.AssertSuccess( + """ + { + "id": "acme-n5z", + "title": "Fix the parser", + "status": "open", + "priority": 2, + "type": "task", + "assignee": null, + "estimatedMinutes": null, + "dueAt": null, + "deferUntil": null, + "createdAt": "2026-01-01T00:00:00+00:00", + "createdBy": "test-agent", + "updatedAt": "2026-01-01T00:00:00+00:00", + "closedAt": null, + "closeReason": null, + "description": "", + "design": "", + "acceptanceCriteria": "", + "notes": "", + "blockers": [] + } + """); + } + + [Fact] + public async Task JsonOutput_WithDependency_IncludesComputedBlockers() + { + // arrange + await InitWorkspaceAsync(); + var blockerId = await CreateTaskAsync("Write the tokenizer tests"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "create", "Fix the parser", "--depends-on", blockerId); + + // assert + using var document = System.Text.Json.JsonDocument.Parse(result.StdOut); + var root = document.RootElement; + + Assert.Empty(result.StdErr); + Assert.Equal(0, result.ExitCode); + Assert.Equal(blockerId, root.GetProperty("blockers")[0].GetString()); + } + + [Fact] + public async Task FullOptions_CreatesChildTask_WithDependencyAndLabels() + { + // arrange + await InitWorkspaceAsync(); + + var blocker = await ExecuteCommandAsync("agent", "tasks", "create", "Write the tokenizer tests"); + blocker.AssertSuccess(); + var parent = await ExecuteCommandAsync("agent", "tasks", "create", "Rewrite the parser"); + parent.AssertSuccess(); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "create", "Fix the parser", + "--description", "Handle edge cases in the tokenizer", + "--priority", "p1", + "--type", "bug", + "--assignee", "alice", + "--label", "api", + "--label", "parser", + "--due", "2026-01-10", + "--defer-until", "2026-01-02", + "--estimate", "90", + "--parent", "acme-29d", + "--depends-on", "acme-38b"); + + // assert + result.AssertSuccess( + """ + โœ“ Created task 'acme-29d.1': Fix the parser. + Blocked by: acme-38b + """); + Assert.Equal( + "open|1|bug|alice|90|Handle edge cases in the tokenizer", + await QueryScalarAsync( + "SELECT status || '|' || priority || '|' || task_type || '|' || assignee || '|' || " + + "estimated_minutes || '|' || description FROM tasks WHERE id = 'acme-29d.1'")); + Assert.Equal( + "api,parser", + await QueryScalarAsync( + "SELECT group_concat(label, ',') FROM " + + "(SELECT label FROM labels WHERE task_id = 'acme-29d.1' ORDER BY label)")); + Assert.Equal( + "acme-29d:parent-child,acme-38b:blocks", + await QueryScalarAsync( + "SELECT group_concat(depends_on_id || ':' || dependency_type, ',') FROM " + + "(SELECT depends_on_id, dependency_type FROM dependencies " + + "WHERE task_id = 'acme-29d.1' ORDER BY depends_on_id)")); + } + + [Fact] + public async Task MissingParent_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "create", "Fix the parser", "--parent", "acme-999"); + + // assert + result.AssertError("Task 'acme-999' does not exist."); + } + + [Fact] + public async Task MissingDependencyTarget_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "create", "Fix the parser", "--depends-on", "acme-999"); + + // assert + result.AssertError("Task 'acme-999' does not exist."); + } + + [Fact] + public async Task InvalidPriority_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "create", "Fix the parser", "--priority", "p9"); + + // assert + result.AssertError("Invalid priority 'p9'. Use 0-4 or p0-p4 (0 = critical, 4 = backlog)."); + } + + [Fact] + public async Task TitleTooLong_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var title = new string('a', 501); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "create", title); + + // assert + result.AssertError("The title must be 1-500 characters."); + } + + [Fact] + public async Task WhitespaceOnlyTitle_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "create", " "); + + // assert + result.AssertError("The title must be 1-500 characters."); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/CyclesTaskDependencyCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/CyclesTaskDependencyCommandTests.cs new file mode 100644 index 00000000000..d6516233a6b --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/CyclesTaskDependencyCommandTests.cs @@ -0,0 +1,164 @@ + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class CyclesTaskDependencyCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "cycles", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Detect cycles among blocking dependencies. + + Usage: + nitro agent tasks dep cycles [options] + + Options: + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks dep cycles + """); + } + + [Fact] + public async Task NoDependencies_PrintsNoCyclesFound() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "cycles"); + + // assert + result.AssertSuccess("No cycles found."); + } + + [Fact] + public async Task TwoNodeBlockingCycle_IsReported() + { + // arrange + await InitWorkspaceAsync(); + var a = await CreateTaskAsync("Task A"); + var b = await CreateTaskAsync("Task B"); + + // task dep add now rejects a cycle before commit, so the edges are + // seeded directly, as if they reached the database another way. + await InsertDependencyAsync(a, b); + await InsertDependencyAsync(b, a); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "cycles"); + + // assert + var smaller = string.CompareOrdinal(a, b) < 0 ? a : b; + var larger = smaller == a ? b : a; + result.AssertSuccess($"{smaller} -> {larger} -> {smaller}"); + } + + [Fact] + public async Task NonBlockingLoop_IsNotReported() + { + // arrange + await InitWorkspaceAsync(); + var a = await CreateTaskAsync("Task A"); + var b = await CreateTaskAsync("Task B"); + await ExecuteCommandAsync("agent", "tasks", "dep", "add", a, b, "--type", "related"); + await ExecuteCommandAsync("agent", "tasks", "dep", "add", b, a, "--type", "related"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "cycles"); + + // assert + result.AssertSuccess("No cycles found."); + } + + [Fact] + public async Task JsonOutput_NoCycles_ReturnsEmptyItems() + { + // arrange + await InitWorkspaceAsync(); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "cycles"); + + // assert + result.AssertSuccess( + """ + { + "items": [] + } + """); + } + + [Fact] + public async Task JsonOutput_TwoNodeBlockingCycle_ReturnsStructuredCycle() + { + // arrange + await InitWorkspaceAsync(); + var a = await CreateTaskAsync("Task A"); + var b = await CreateTaskAsync("Task B"); + + // task dep add now rejects a cycle before commit, so the edges are + // seeded directly, as if they reached the database another way. + await InsertDependencyAsync(a, b); + await InsertDependencyAsync(b, a); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "cycles"); + + // assert + var smaller = string.CompareOrdinal(a, b) < 0 ? a : b; + var larger = smaller == a ? b : a; + result.AssertSuccess( + $$""" + { + "items": [ + { + "tasks": [ + "{{smaller}}", + "{{larger}}" + ] + } + ] + } + """); + } + + [Fact] + public async Task ThreeNodeBlockingCycle_IsReportedOnce() + { + // arrange + await InitWorkspaceAsync(); + var a = await CreateTaskAsync("Task A"); + var b = await CreateTaskAsync("Task B"); + var c = await CreateTaskAsync("Task C"); + + // task dep add now rejects a cycle before commit, so the edges are + // seeded directly, as if they reached the database another way. + await InsertDependencyAsync(a, b); + await InsertDependencyAsync(b, c); + await InsertDependencyAsync(c, a); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "cycles"); + + // assert + // The cycle is the fixed chain a -> b -> c -> a; rotate that circular + // sequence to start at whichever ID sorts smallest, matching how the + // command always starts a cycle at its smallest member. + var cycle = new List<string> { a, b, c }; + var startIndex = cycle.IndexOf(cycle.OrderBy(x => x, StringComparer.Ordinal).First()); + var rotated = cycle.Skip(startIndex).Concat(cycle.Take(startIndex)).ToList(); + result.AssertSuccess(string.Join(" -> ", rotated) + $" -> {rotated[0]}"); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/DeferTaskCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/DeferTaskCommandTests.cs new file mode 100644 index 00000000000..436581632fa --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/DeferTaskCommandTests.cs @@ -0,0 +1,134 @@ + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class DeferTaskCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "defer", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Defer a task until a future date. + + Usage: + nitro agent tasks defer <id> [options] + + Arguments: + <id> The task ID + + Options: + --until <until> (REQUIRED) Hide the task from ready work until this ISO 8601 date or timestamp + --actor <actor> The acting identity recorded on the audit log (defaults to NITRO_TASK_ACTOR or the OS user name) + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks defer "acme-1a2" --until "2026-02-01" + """); + } + + [Fact] + public async Task OpenTask_DefersSuccessfully() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "defer", id, "--until", "2026-02-01"); + + // assert + result.AssertSuccess($"โœ“ Deferred task '{id}' until 2026-02-01 00:00."); + Assert.Equal("deferred", await QueryScalarAsync( + $"SELECT status FROM tasks WHERE id = '{id}'")); + } + + [Fact] + public async Task InProgressTask_DefersSuccessfully() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + await ExecuteCommandAsync("agent", "tasks", "update", id, "--status", "in_progress"); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "defer", id, "--until", "2026-03-15T10:30:00Z"); + + // assert + result.AssertSuccess($"โœ“ Deferred task '{id}' until 2026-03-15 10:30."); + } + + [Fact] + public async Task JsonOutput_ReturnsDeferredTaskSnapshot() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "defer", id, "--until", "2026-02-01"); + + // assert + using var document = System.Text.Json.JsonDocument.Parse(result.StdOut); + var root = document.RootElement; + + Assert.Empty(result.StdErr); + Assert.Equal(0, result.ExitCode); + Assert.Equal(id, root.GetProperty("id").GetString()); + Assert.Equal("deferred", root.GetProperty("status").GetString()); + Assert.Equal( + "2026-02-01T00:00:00+00:00", root.GetProperty("deferUntil").GetString()); + } + + [Fact] + public async Task ClosedTask_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + await ExecuteCommandAsync("agent", "tasks", "close", id); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "defer", id, "--until", "2026-02-01"); + + // assert + result.AssertError("Only open or in-progress tasks can be deferred."); + } + + [Fact] + public async Task TaskNotFound_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "defer", "acme-999", "--until", "2026-02-01"); + + // assert + result.AssertError("Task 'acme-999' does not exist."); + } + + [Fact] + public async Task InvalidUntilDate_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "defer", id, "--until", "not-a-date"); + + // assert + result.AssertError( + "Invalid date 'not-a-date' for '--until'. Use an ISO 8601 date or timestamp."); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/DeleteTaskCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/DeleteTaskCommandTests.cs new file mode 100644 index 00000000000..eddcc03fc38 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/DeleteTaskCommandTests.cs @@ -0,0 +1,164 @@ + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class DeleteTaskCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "delete", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Delete a task. + + Usage: + nitro agent tasks delete <id> [options] + + Arguments: + <id> The task ID + + Options: + --reason <reason> The reason recorded for this change + --actor <actor> The acting identity recorded on the audit log (defaults to NITRO_TASK_ACTOR or the OS user name) + --force Skip confirmation prompts for deletes and overwrites + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks delete "app-1a2" + nitro agent tasks delete "app-1a2" --force + """); + } + + [Fact] + public async Task WithForce_DeletesSuccessfully() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "delete", id, "--force"); + + // assert + result.AssertSuccess($"โœ“ Deleted task '{id}'."); + Assert.Equal("tombstone", await QueryScalarAsync( + $"SELECT status FROM tasks WHERE id = '{id}'")); + } + + [Fact] + public async Task WithReason_RecordsDeleteReason() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "delete", id, "--force", "--reason", "Duplicate of acme-1"); + + // assert + result.AssertSuccess($"โœ“ Deleted task '{id}'."); + Assert.Equal("Duplicate of acme-1", await QueryScalarAsync( + $"SELECT delete_reason FROM tasks WHERE id = '{id}'")); + } + + [Fact] + public async Task JsonOutput_ReturnsDeletedTaskSnapshot() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "delete", id, "--force"); + + // assert + using var document = System.Text.Json.JsonDocument.Parse(result.StdOut); + var root = document.RootElement; + + Assert.Empty(result.StdErr); + Assert.Equal(0, result.ExitCode); + Assert.Equal(id, root.GetProperty("id").GetString()); + Assert.Equal("tombstone", root.GetProperty("status").GetString()); + } + + [Fact] + public async Task WithoutForce_NonInteractive_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "delete", id); + + // assert + result.AssertError("Use --force to delete without confirmation."); + } + + [Fact] + public async Task WithoutForce_Interactive_Confirmed_DeletesSuccessfully() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + SetupInteractionMode(InteractionMode.Interactive); + + var command = StartInteractiveCommand("agent", "tasks", "delete", id); + + // act + command.Confirm(true); + var result = await command.RunToCompletionAsync(TestContext.Current.CancellationToken); + + // assert + result.AssertSuccess( + $""" + ? Delete task '{id}'? [y/n] (y): y + โœ“ Deleted task '{id}'. + """); + } + + [Fact] + public async Task WithoutForce_Interactive_Declined_Aborts() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + SetupInteractionMode(InteractionMode.Interactive); + + var command = StartInteractiveCommand("agent", "tasks", "delete", id); + + // act + command.Confirm(false); + var result = await command.RunToCompletionAsync(TestContext.Current.CancellationToken); + + // assert + result.AssertSuccess( + $""" + ? Delete task '{id}'? [y/n] (y): n + Aborted. + """); + Assert.Equal("open", await QueryScalarAsync( + $"SELECT status FROM tasks WHERE id = '{id}'")); + } + + [Fact] + public async Task TaskNotFound_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "delete", "acme-999", "--force"); + + // assert + result.AssertError("Task 'acme-999' does not exist."); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/DoctorTaskCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/DoctorTaskCommandTests.cs new file mode 100644 index 00000000000..e139fc33131 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/DoctorTaskCommandTests.cs @@ -0,0 +1,234 @@ +using Microsoft.Data.Sqlite; + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class DoctorTaskCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "doctor", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Check the task workspace for integrity problems. + + Usage: + nitro agent tasks doctor [options] + + Options: + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks doctor + """); + } + + [Fact] + public async Task HealthyWorkspace_ReturnsSuccess() + { + // arrange + await InitWorkspaceAsync(); + await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "doctor"); + + // assert + result.AssertSuccess( + $""" + Workspace: {WorkspaceDirectory} + Prefix: acme + Tasks: 1 + + โœ“ SQLite quick_check + โœ“ Dependency cycles + โœ“ Orphan dependencies + โœ“ Orphan labels + โœ“ Orphan comments + โœ“ Tombstoned parent edges + """); + } + + [Fact] + public async Task BlockingCycle_ReportedAndReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var a = await CreateTaskAsync("Task A"); + var b = await CreateTaskAsync("Task B"); + + // task dep add now rejects a cycle before commit, so the edges are + // seeded directly, as if they reached the database another way. + await InsertDependencyAsync(a, b); + await InsertDependencyAsync(b, a); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "doctor"); + + // assert + var smaller = string.CompareOrdinal(a, b) < 0 ? a : b; + var larger = smaller == a ? b : a; + Assert.Equal(1, result.ExitCode); + Assert.Contains("FAIL Dependency cycles:", result.StdOut); + Assert.Contains($"{smaller} -> {larger} -> {smaller}", result.StdOut); + } + + [Fact] + public async Task OrphanDependency_ReportedAndReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var taskId = await CreateTaskAsync("Fix the parser"); + await InsertOrphanDependencyAsync(taskId, "missing-task", TestContext.Current.CancellationToken); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "doctor"); + + // assert + Assert.Equal(1, result.ExitCode); + Assert.Contains("FAIL Orphan dependencies:", result.StdOut); + Assert.Contains($"{taskId} -> missing-task", result.StdOut); + } + + [Fact] + public async Task OrphanLabel_ReportedAndReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var taskId = await CreateTaskAsync("Fix the parser", "--label", "orphan-me"); + await DeleteTaskRowAsync(taskId, TestContext.Current.CancellationToken); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "doctor"); + + // assert + Assert.Equal(1, result.ExitCode); + Assert.Contains("FAIL Orphan labels:", result.StdOut); + Assert.Contains($"{taskId}: 'orphan-me'", result.StdOut); + } + + [Fact] + public async Task OrphanComment_ReportedAndReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var taskId = await CreateTaskAsync("Fix the parser"); + await InsertCommentAsync(taskId, "note", TestContext.Current.CancellationToken); + await DeleteTaskRowAsync(taskId, TestContext.Current.CancellationToken); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "doctor"); + + // assert + Assert.Equal(1, result.ExitCode); + Assert.Contains("FAIL Orphan comments:", result.StdOut); + Assert.Contains($"{taskId}: comment #", result.StdOut); + } + + [Fact] + public async Task TombstonedParent_ReportedAndReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var parentId = await CreateTaskAsync("Parent epic", "--type", "epic"); + var childId = await CreateTaskAsync("Child task", "--parent", parentId); + await ExecuteCommandAsync("agent", "tasks", "delete", parentId, "--force"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "doctor"); + + // assert + Assert.Equal(1, result.ExitCode); + Assert.Contains("FAIL Tombstoned parent edges:", result.StdOut); + Assert.Contains($"{childId} -> {parentId}", result.StdOut); + } + + [Fact] + public async Task JsonOutput_HealthyWorkspace_ReturnsStructuredReport() + { + // arrange + await InitWorkspaceAsync(); + await CreateTaskAsync("Fix the parser"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "doctor"); + + // assert + Assert.Equal(0, result.ExitCode); + using var document = System.Text.Json.JsonDocument.Parse(result.StdOut); + var root = document.RootElement; + Assert.Equal(WorkspaceDirectory, root.GetProperty("workspacePath").GetString()); + Assert.Equal("acme", root.GetProperty("prefix").GetString()); + Assert.Equal(1, root.GetProperty("taskCount").GetInt32()); + Assert.True(root.GetProperty("quickCheckOk").GetBoolean()); + Assert.True(root.GetProperty("healthy").GetBoolean()); + Assert.Empty(root.GetProperty("cycles").EnumerateArray()); + Assert.Empty(root.GetProperty("orphanDependencies").EnumerateArray()); + } + + private async Task InsertOrphanDependencyAsync( + string taskId, string dependsOnId, CancellationToken cancellationToken) + { + await using var connection = + new SqliteConnection($"Data Source={DatabasePath};Pooling=False"); + await connection.OpenAsync(cancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = + "INSERT INTO dependencies (task_id, depends_on_id, dependency_type, created_at) " + + "VALUES (@taskId, @dependsOnId, 'blocks', @createdAt)"; + command.Parameters.AddWithValue("@taskId", taskId); + command.Parameters.AddWithValue("@dependsOnId", dependsOnId); + command.Parameters.AddWithValue("@createdAt", FakeTime.GetUtcNow()); + + await command.ExecuteNonQueryAsync(cancellationToken); + } + + private async Task InsertCommentAsync( + string taskId, string text, CancellationToken cancellationToken) + { + await using var connection = + new SqliteConnection($"Data Source={DatabasePath};Pooling=False"); + await connection.OpenAsync(cancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = + "INSERT INTO comments (task_id, author, text, created_at) " + + "VALUES (@taskId, 'test-agent', @text, @createdAt)"; + command.Parameters.AddWithValue("@taskId", taskId); + command.Parameters.AddWithValue("@text", text); + command.Parameters.AddWithValue("@createdAt", FakeTime.GetUtcNow()); + + await command.ExecuteNonQueryAsync(cancellationToken); + } + + /// <summary> + /// Removes a task row directly, bypassing tombstoning, so its labels and + /// comments become orphans for the doctor checks to find. + /// </summary> + private async Task DeleteTaskRowAsync(string taskId, CancellationToken cancellationToken) + { + await using var connection = + new SqliteConnection($"Data Source={DatabasePath};Pooling=False"); + await connection.OpenAsync(cancellationToken); + + await using (var disableForeignKeys = connection.CreateCommand()) + { + disableForeignKeys.CommandText = "PRAGMA foreign_keys = OFF"; + await disableForeignKeys.ExecuteNonQueryAsync(cancellationToken); + } + + await using var command = connection.CreateCommand(); + command.CommandText = "DELETE FROM tasks WHERE id = @taskId"; + command.Parameters.AddWithValue("@taskId", taskId); + + await command.ExecuteNonQueryAsync(cancellationToken); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/GetTaskConfigCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/GetTaskConfigCommandTests.cs new file mode 100644 index 00000000000..c4c84aa19b2 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/GetTaskConfigCommandTests.cs @@ -0,0 +1,105 @@ + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class GetTaskConfigCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "config", "get", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Get a configuration value. + + Usage: + nitro agent tasks config get <key> [options] + + Arguments: + <key> The configuration key + + Options: + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks config get prefix + """); + } + + [Fact] + public async Task ExistingKey_ReturnsValue() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "config", "get", "prefix"); + + // assert + result.AssertSuccess("acme"); + } + + [Fact] + public async Task AfterSet_ReturnsSetValue() + { + // arrange + await InitWorkspaceAsync(); + await ExecuteCommandAsync("agent", "tasks", "config", "set", "reviewer", "alice"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "config", "get", "reviewer"); + + // assert + result.AssertSuccess("alice"); + } + + [Fact] + public async Task MissingKey_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "config", "get", "nonexistent"); + + // assert + result.AssertError("Configuration key 'nonexistent' is not set."); + } + + [Fact] + public async Task NoWorkspace_ReturnsError() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "config", "get", "prefix"); + + // assert + result.AssertError( + """ + No task workspace found. Run `nitro agent tasks init` first. + """); + } + + [Fact] + public async Task JsonOutput_ExistingKey_ReturnsStructuredValue() + { + // arrange + await InitWorkspaceAsync(); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "config", "get", "prefix"); + + // assert + result.AssertSuccess( + """ + { + "value": "acme" + } + """); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/InitTaskCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/InitTaskCommandTests.cs new file mode 100644 index 00000000000..2ab9939e58e --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/InitTaskCommandTests.cs @@ -0,0 +1,154 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class InitTaskCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "init", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Initialize a task workspace in the current directory. + + Usage: + nitro agent tasks init [options] + + Options: + --prefix <prefix> The task ID prefix (defaults to the current directory name) + --force Reinitialize an existing task workspace + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks init + nitro agent tasks init --prefix "app" + """); + } + + [Fact] + public async Task EmptyDirectory_InitializesWorkspace() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "init"); + + // assert + result.AssertSuccess( + """ + โœ“ Initialized task workspace at '.nitro/tasks'. + โœ“ Task ID prefix set to 'acme'. + """); + Assert.True(File.Exists(DatabasePath)); + Assert.Equal("acme", await QueryScalarAsync( + "SELECT value FROM config WHERE key = 'prefix'")); + var gitIgnoreText = await File.ReadAllTextAsync( + Path.Combine(WorkspaceDirectory, TaskWorkspace.GitIgnoreFileName), + TestContext.Current.CancellationToken); + gitIgnoreText.MatchInlineSnapshot( + """ + # The task database is local state; tasks.jsonl is the source of truth in git. + tasks.db + tasks.db-wal + tasks.db-shm + """); + } + + [Fact] + public async Task JsonOutput_ReturnsWorkspacePathAndPrefix() + { + // arrange + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "init"); + + // assert + using var document = System.Text.Json.JsonDocument.Parse(result.StdOut); + var root = document.RootElement; + + Assert.Empty(result.StdErr); + Assert.Equal(0, result.ExitCode); + Assert.Equal(WorkspaceDirectory, root.GetProperty("path").GetString()); + Assert.Equal("acme", root.GetProperty("prefix").GetString()); + } + + [Fact] + public async Task PrefixOption_NormalizesValue() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "init", "--prefix", "My App!"); + + // assert + result.AssertSuccess( + """ + โœ“ Initialized task workspace at '.nitro/tasks'. + โœ“ Task ID prefix set to 'myapp'. + """); + } + + [Fact] + public async Task AlreadyInitialized_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "init"); + + // assert + result.AssertError( + """ + Already initialized at '.nitro/tasks'. Use --force to reinitialize. + """); + } + + [Fact] + public async Task AlreadyInitialized_Force_ResetsPrefix() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "init", "--force", "--prefix", "core"); + + // assert + result.AssertSuccess( + """ + โœ“ Initialized task workspace at '.nitro/tasks'. + โœ“ Task ID prefix set to 'core'. + """); + Assert.Equal("core", await QueryScalarAsync( + "SELECT value FROM config WHERE key = 'prefix'")); + } + + [Fact] + public async Task AlreadyInitialized_Force_RefreshesStaleGitIgnore() + { + // arrange + await InitWorkspaceAsync(); + var gitIgnorePath = Path.Combine(WorkspaceDirectory, TaskWorkspace.GitIgnoreFileName); + await File.WriteAllTextAsync( + gitIgnorePath, "*\n!.gitignore\n", TestContext.Current.CancellationToken); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "init", "--force"); + + // assert + Assert.Equal(0, result.ExitCode); + var gitIgnoreText = await File.ReadAllTextAsync( + gitIgnorePath, TestContext.Current.CancellationToken); + gitIgnoreText.MatchInlineSnapshot( + """ + # The task database is local state; tasks.jsonl is the source of truth in git. + tasks.db + tasks.db-wal + tasks.db-shm + """); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/LintTaskCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/LintTaskCommandTests.cs new file mode 100644 index 00000000000..414d9b1346c --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/LintTaskCommandTests.cs @@ -0,0 +1,168 @@ +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class LintTaskCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "lint", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Report task quality findings. + + Usage: + nitro agent tasks lint [options] + + Options: + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks lint + """); + } + + [Fact] + public async Task NoFindings_PrintsNoFindings() + { + // arrange + await InitWorkspaceAsync(); + await CreateTaskAsync("Fix the parser", "--description", "Some detail."); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "lint"); + + // assert + result.AssertSuccess("No lint findings."); + } + + [Fact] + public async Task OpenTaskWithEmptyDescription_IsReported() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "lint"); + + // assert + result.AssertSuccess( + $"{id} empty-description: Open task has an empty description."); + } + + [Fact] + public async Task DeferredWithoutDeferUntil_IsReported() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser", "--description", "Some detail."); + var updateResult = await ExecuteCommandAsync("agent", "tasks", "update", id, "--status", "deferred"); + Assert.Equal(0, updateResult.ExitCode); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "lint"); + + // assert + result.AssertSuccess( + $"{id} deferred-without-defer-until: Deferred task has no defer_until."); + } + + [Fact] + public async Task InProgressWithoutAssignee_IsReported() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser", "--description", "Some detail."); + var updateResult = await ExecuteCommandAsync("agent", "tasks", "update", id, "--status", "in_progress"); + Assert.Equal(0, updateResult.ExitCode); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "lint"); + + // assert + result.AssertSuccess( + $"{id} in-progress-without-assignee: In-progress task has no assignee."); + } + + [Fact] + public async Task EpicWithZeroChildren_IsReported() + { + // arrange + await InitWorkspaceAsync(); + var epicId = await CreateTaskAsync( + "Parent epic", "--type", "epic", "--description", "Some detail."); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "lint"); + + // assert + result.AssertSuccess($"{epicId} epic-no-children: Epic has zero children."); + } + + [Fact] + public async Task ClosedEpicWithOpenChildren_IsReported() + { + // arrange + await InitWorkspaceAsync(); + var epicId = await CreateTaskAsync( + "Parent epic", "--type", "epic", "--description", "Some detail."); + await CreateTaskAsync("Child task", "--parent", epicId, "--description", "Some detail."); + var closeResult = await ExecuteCommandAsync("agent", "tasks", "close", epicId); + Assert.Equal(0, closeResult.ExitCode); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "lint"); + + // assert + result.AssertSuccess( + $"{epicId} closed-epic-open-children: Closed epic has open children."); + } + + [Fact] + public async Task TombstonedTasks_AreExcluded() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + var deleteResult = await ExecuteCommandAsync("agent", "tasks", "delete", id, "--force"); + Assert.Equal(0, deleteResult.ExitCode); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "lint"); + + // assert + result.AssertSuccess("No lint findings."); + } + + [Fact] + public async Task JsonOutput_ReturnsStructuredFindings() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "lint"); + + // assert + result.AssertSuccess( + $$""" + { + "items": [ + { + "taskId": "{{id}}", + "rule": "empty-description", + "message": "Open task has an empty description." + } + ] + } + """); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ListTaskCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ListTaskCommandTests.cs new file mode 100644 index 00000000000..4715e9c0715 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ListTaskCommandTests.cs @@ -0,0 +1,300 @@ +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class ListTaskCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "list", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + List tasks. + + Usage: + nitro agent tasks list [options] + + Options: + --status <status> Filter by status; can be used multiple times + --type <type> The task type (task, bug, feature, epic, chore, docs, question, or custom) + --priority <priority> The task priority, 0-4 or p0-p4 (0 = critical, 4 = backlog); list/ready also accept a range like 0-1 or p0-p1 + --assignee <assignee> The assignee + --label <label> A label; can be used multiple times + --limit <limit> The maximum number of tasks to show + --all Include closed and tombstoned tasks + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks list + nitro agent tasks list --status open --status in_progress + nitro agent tasks list --assignee alice --priority p1 + """); + } + + [Fact] + public async Task EmptyWorkspace_PrintsNoTasksFound() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "list"); + + // assert + result.AssertSuccess("No tasks found."); + } + + [Fact] + public async Task List_ExcludesClosedAndTombstoneByDefault() + { + // arrange + await InitWorkspaceAsync(); + + await ExecuteCommandAsync("agent", "tasks", "create", "Bravo task", "--priority", "p0"); + await ExecuteCommandAsync("agent", "tasks", "create", "Alpha task"); + + var closedCreateResult = await ExecuteCommandAsync("agent", "tasks", "create", "Closed task"); + var closedId = ExtractTaskId(closedCreateResult.StdOut); + await ExecuteCommandAsync("agent", "tasks", "close", closedId); + + var deletedCreateResult = await ExecuteCommandAsync("agent", "tasks", "create", "Deleted task"); + var deletedId = ExtractTaskId(deletedCreateResult.StdOut); + await ExecuteCommandAsync("agent", "tasks", "delete", deletedId, "--force"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "list"); + + // assert + result.AssertSuccess( + """ + acme-ml1 P0 task open Bravo task + acme-e88 P2 task open Alpha task + + 2 task(s) + """); + } + + [Fact] + public async Task StatusOption_OverridesDefaultExclusion() + { + // arrange + await InitWorkspaceAsync(); + + await ExecuteCommandAsync("agent", "tasks", "create", "Open task"); + + var closedCreateResult = await ExecuteCommandAsync("agent", "tasks", "create", "Closed task"); + var closedId = ExtractTaskId(closedCreateResult.StdOut); + await ExecuteCommandAsync("agent", "tasks", "close", closedId); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "list", "--status", "closed"); + + // assert + result.AssertSuccess( + """ + acme-5vj P2 task closed Closed task + + 1 task(s) + """); + } + + [Fact] + public async Task AllOption_IncludesClosedAndTombstone() + { + // arrange + await InitWorkspaceAsync(); + + await ExecuteCommandAsync("agent", "tasks", "create", "Open task"); + + var closedCreateResult = await ExecuteCommandAsync("agent", "tasks", "create", "Closed task"); + var closedId = ExtractTaskId(closedCreateResult.StdOut); + await ExecuteCommandAsync("agent", "tasks", "close", closedId); + + var deletedCreateResult = await ExecuteCommandAsync("agent", "tasks", "create", "Deleted task"); + var deletedId = ExtractTaskId(deletedCreateResult.StdOut); + await ExecuteCommandAsync("agent", "tasks", "delete", deletedId, "--force"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "list", "--all"); + + // assert + result.AssertSuccess( + """ + acme-5vj P2 task closed Closed task + acme-liq P2 task open Open task + acme-ulk P2 task tombstone Deleted task + + 3 task(s) + """); + } + + [Fact] + public async Task Filters_NarrowByTypePriorityAssigneeAndLabel() + { + // arrange + await InitWorkspaceAsync(); + + await ExecuteCommandAsync( + "agent", "tasks", "create", "Target task", + "--type", "bug", "--priority", "p1", "--assignee", "alice", + "--label", "api", "--label", "parser"); + await ExecuteCommandAsync( + "agent", "tasks", "create", "Missing label task", + "--type", "bug", "--priority", "p1", "--assignee", "alice", "--label", "api"); + await ExecuteCommandAsync( + "agent", "tasks", "create", "Different type task", + "--type", "feature", "--priority", "p1", "--assignee", "alice", + "--label", "api", "--label", "parser"); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "list", + "--type", "bug", "--priority", "p1", "--assignee", "alice", + "--label", "api", "--label", "parser"); + + // assert + result.AssertSuccess( + """ + acme-tyi P1 bug open Target task + + 1 task(s) + """); + } + + [Fact] + public async Task JsonOutput_ReturnsStructuredList() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "list"); + + // assert + result.AssertSuccess( + $$""" + { + "items": [ + { + "id": "{{id}}", + "priority": 2, + "type": "task", + "status": "open", + "title": "Fix the parser" + } + ] + } + """); + } + + [Fact] + public async Task JsonOutput_EmptyWorkspace_ReturnsEmptyItems() + { + // arrange + await InitWorkspaceAsync(); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "list"); + + // assert + result.AssertSuccess( + """ + { + "items": [] + } + """); + } + + [Fact] + public async Task InvalidPriorityOption_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "list", "--priority", "p9"); + + // assert + result.AssertError( + "Invalid priority 'p9'. Use 0-4 or p0-p4 (0 = critical, 4 = backlog)."); + } + + [Fact] + public async Task PriorityRange_ReturnsTasksWithinBounds() + { + // arrange + await InitWorkspaceAsync(); + + var criticalCreateResult = await ExecuteCommandAsync( + "agent", "tasks", "create", "Critical task", "--priority", "p0"); + var criticalId = ExtractTaskId(criticalCreateResult.StdOut); + var highCreateResult = await ExecuteCommandAsync( + "agent", "tasks", "create", "High task", "--priority", "p1"); + var highId = ExtractTaskId(highCreateResult.StdOut); + await ExecuteCommandAsync("agent", "tasks", "create", "Medium task", "--priority", "p2"); + await ExecuteCommandAsync("agent", "tasks", "create", "Backlog task", "--priority", "p4"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "list", "--priority", "p0-p1"); + + // assert + result.AssertSuccess( + $""" + {criticalId} P0 task open Critical task + {highId} P1 task open High task + + 2 task(s) + """); + } + + [Fact] + public async Task PriorityRange_PlainDigits_ReturnsTasksWithinBounds() + { + // arrange + await InitWorkspaceAsync(); + + var criticalCreateResult = await ExecuteCommandAsync( + "agent", "tasks", "create", "Critical task", "--priority", "p0"); + var criticalId = ExtractTaskId(criticalCreateResult.StdOut); + var highCreateResult = await ExecuteCommandAsync( + "agent", "tasks", "create", "High task", "--priority", "p1"); + var highId = ExtractTaskId(highCreateResult.StdOut); + await ExecuteCommandAsync("agent", "tasks", "create", "Medium task", "--priority", "p2"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "list", "--priority", "0-1"); + + // assert + result.AssertSuccess( + $""" + {criticalId} P0 task open Critical task + {highId} P1 task open High task + + 2 task(s) + """); + } + + [Fact] + public async Task PriorityRange_LowGreaterThanHigh_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "list", "--priority", "3-1"); + + // assert + result.AssertError( + "Invalid priority range '3-1'. The low bound must be <= the high bound."); + } + + private static string ExtractTaskId(string stdout) => stdout.Split('\'')[1]; +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ListTaskCommentCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ListTaskCommentCommandTests.cs new file mode 100644 index 00000000000..a63af76b484 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ListTaskCommentCommandTests.cs @@ -0,0 +1,173 @@ + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class ListTaskCommentCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "comment", "list", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + List a task's comments. + + Usage: + nitro agent tasks comment list <id> [options] + + Arguments: + <id> The task ID + + Options: + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks comment list "acme-1a2" + """); + } + + [Fact] + public async Task NoComments_PrintsNoComments() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "comment", "list", id); + + // assert + result.AssertSuccess("No comments."); + } + + [Fact] + public async Task SingleComment_DisplaysFormattedEntry() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + await ExecuteCommandAsync("agent", "tasks", "comment", "add", id, "Looks good to me."); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "comment", "list", id); + + // assert + result.AssertSuccess( + """ + [1] test-agent 2026-01-01 00:00 + Looks good to me. + """); + } + + [Fact] + public async Task MultipleComments_DisplaysInOrderWithBlankLineBetween() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + await ExecuteCommandAsync("agent", "tasks", "comment", "add", id, "Looks good to me."); + FakeTime.Advance(TimeSpan.FromMinutes(1)); + await ExecuteCommandAsync( + "agent", "tasks", "comment", "add", id, "Thanks, merging.", "--actor", "alice"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "comment", "list", id); + + // assert + result.AssertSuccess( + """ + [1] test-agent 2026-01-01 00:00 + Looks good to me. + + [2] alice 2026-01-01 00:01 + Thanks, merging. + """); + } + + [Fact] + public async Task MultiLineCommentText_IndentsEveryLine() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + await ExecuteCommandAsync("agent", "tasks", "comment", "add", id, "Line one\nLine two"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "comment", "list", id); + + // assert + result.AssertSuccess( + """ + [1] test-agent 2026-01-01 00:00 + Line one + Line two + """); + } + + [Fact] + public async Task TaskNotFound_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "comment", "list", "acme-999"); + + // assert + result.AssertError("Task 'acme-999' does not exist."); + } + + [Fact] + public async Task JsonOutput_ReturnsStructuredComments() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + await ExecuteCommandAsync("agent", "tasks", "comment", "add", id, "Looks good to me."); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "comment", "list", id); + + // assert + result.AssertSuccess( + $$""" + { + "items": [ + { + "id": 1, + "taskId": "{{id}}", + "author": "test-agent", + "text": "Looks good to me.", + "createdAt": "2026-01-01T00:00:00+00:00" + } + ] + } + """); + } + + [Fact] + public async Task JsonOutput_NoComments_ReturnsEmptyItems() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "comment", "list", id); + + // assert + result.AssertSuccess( + """ + { + "items": [] + } + """); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ListTaskConfigCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ListTaskConfigCommandTests.cs new file mode 100644 index 00000000000..2b2d7bafb26 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ListTaskConfigCommandTests.cs @@ -0,0 +1,100 @@ + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class ListTaskConfigCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "config", "list", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + List all configuration values. + + Usage: + nitro agent tasks config list [options] + + Options: + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks config list + """); + } + + [Fact] + public async Task AfterInit_ListsPrefixConfig() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "config", "list"); + + // assert + result.AssertSuccess("prefix = acme"); + } + + [Fact] + public async Task WithAdditionalConfig_ListsAllSortedByKey() + { + // arrange + await InitWorkspaceAsync(); + await ExecuteCommandAsync("agent", "tasks", "config", "set", "zzz", "last"); + await ExecuteCommandAsync("agent", "tasks", "config", "set", "aaa", "first"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "config", "list"); + + // assert + result.AssertSuccess( + """ + aaa = first + prefix = acme + zzz = last + """); + } + + [Fact] + public async Task NoWorkspace_ReturnsError() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "config", "list"); + + // assert + result.AssertError( + """ + No task workspace found. Run `nitro agent tasks init` first. + """); + } + + [Fact] + public async Task JsonOutput_ReturnsStructuredList() + { + // arrange + await InitWorkspaceAsync(); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "config", "list"); + + // assert + result.AssertSuccess( + """ + { + "items": [ + { + "key": "prefix", + "value": "acme" + } + ] + } + """); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ListTaskDependencyCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ListTaskDependencyCommandTests.cs new file mode 100644 index 00000000000..35ed592fd6c --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ListTaskDependencyCommandTests.cs @@ -0,0 +1,151 @@ + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class ListTaskDependencyCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "list", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + List a task's dependencies. + + Usage: + nitro agent tasks dep list <id> [options] + + Arguments: + <id> The task ID + + Options: + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks dep list "acme-1a2" + """); + } + + [Fact] + public async Task NoDependencies_PrintsNoDependencies() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "list", id); + + // assert + result.AssertSuccess("No dependencies."); + } + + [Fact] + public async Task OutgoingDependency_ListsUnderDependenciesSection() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + var dependsOnId = await CreateTaskAsync("Write the tokenizer"); + await ExecuteCommandAsync("agent", "tasks", "dep", "add", id, dependsOnId); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "list", id); + + // assert + result.AssertSuccess( + $""" + Dependencies of {id}: + blocks -> {dependsOnId} (open) Write the tokenizer + """); + } + + [Fact] + public async Task IncomingDependency_ListsUnderDependedOnBySection() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + var dependentId = await CreateTaskAsync("Write the docs"); + await ExecuteCommandAsync("agent", "tasks", "dep", "add", dependentId, id); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "list", id); + + // assert + result.AssertSuccess( + $""" + Depended on by: + {dependentId} (blocks, open) Write the docs + """); + } + + [Fact] + public async Task TaskNotFound_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "list", "acme-999"); + + // assert + result.AssertError("Task 'acme-999' does not exist."); + } + + [Fact] + public async Task JsonOutput_ReturnsStructuredDependenciesAndDependents() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + var dependsOnId = await CreateTaskAsync("Write the tokenizer"); + await ExecuteCommandAsync("agent", "tasks", "dep", "add", id, dependsOnId); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "list", id); + + // assert + result.AssertSuccess( + $$""" + { + "dependencies": [ + { + "type": "blocks", + "dependsOnId": "{{dependsOnId}}", + "status": "open", + "title": "Write the tokenizer" + } + ], + "dependents": [] + } + """); + } + + [Fact] + public async Task JsonOutput_NoDependencies_ReturnsEmptyArrays() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "list", id); + + // assert + result.AssertSuccess( + """ + { + "dependencies": [], + "dependents": [] + } + """); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ListTaskLabelCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ListTaskLabelCommandTests.cs new file mode 100644 index 00000000000..907c5eae6ba --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ListTaskLabelCommandTests.cs @@ -0,0 +1,177 @@ + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class ListTaskLabelCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "label", "list", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + List a task's labels, or every label in use. + + Usage: + nitro agent tasks label list [<id>] [options] + + Arguments: + <id> The task ID + + Options: + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks label list + nitro agent tasks label list "acme-1a2" + """); + } + + [Fact] + public async Task WithId_ListsSortedLabels() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + await ExecuteCommandAsync("agent", "tasks", "label", "add", id, "parser", "api"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "label", "list", id); + + // assert + result.AssertSuccess( + """ + api + parser + """); + } + + [Fact] + public async Task WithIdAndNoLabels_PrintsNoLabels() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "label", "list", id); + + // assert + result.AssertSuccess("No labels."); + } + + [Fact] + public async Task WithoutId_ListsAllLabelsWithCounts() + { + // arrange + await InitWorkspaceAsync(); + var id1 = await CreateTaskAsync("Fix the parser"); + var id2 = await CreateTaskAsync("Write the docs"); + await ExecuteCommandAsync("agent", "tasks", "label", "add", id1, "api", "parser"); + await ExecuteCommandAsync("agent", "tasks", "label", "add", id2, "api"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "label", "list"); + + // assert + result.AssertSuccess( + """ + api 2 + parser 1 + """); + } + + [Fact] + public async Task WithoutIdAndNoLabels_PrintsNoLabels() + { + // arrange + await InitWorkspaceAsync(); + await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "label", "list"); + + // assert + result.AssertSuccess("No labels."); + } + + [Fact] + public async Task TaskNotFound_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "label", "list", "acme-999"); + + // assert + result.AssertError("Task 'acme-999' does not exist."); + } + + [Fact] + public async Task JsonOutput_WithId_ReturnsStructuredLabels() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + await ExecuteCommandAsync("agent", "tasks", "label", "add", id, "parser", "api"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "label", "list", id); + + // assert + result.AssertSuccess( + """ + { + "items": [ + { + "label": "api", + "count": 1 + }, + { + "label": "parser", + "count": 1 + } + ] + } + """); + } + + [Fact] + public async Task JsonOutput_WithoutId_ReturnsStructuredCounts() + { + // arrange + await InitWorkspaceAsync(); + var id1 = await CreateTaskAsync("Fix the parser"); + var id2 = await CreateTaskAsync("Write the docs"); + await ExecuteCommandAsync("agent", "tasks", "label", "add", id1, "api", "parser"); + await ExecuteCommandAsync("agent", "tasks", "label", "add", id2, "api"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "label", "list"); + + // assert + result.AssertSuccess( + """ + { + "items": [ + { + "label": "api", + "count": 2 + }, + { + "label": "parser", + "count": 1 + } + ] + } + """); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/QuickTaskCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/QuickTaskCommandTests.cs new file mode 100644 index 00000000000..8970f3e4edc --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/QuickTaskCommandTests.cs @@ -0,0 +1,166 @@ +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class QuickTaskCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "q", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Quickly create a task and print only its ID. + + Usage: + nitro agent tasks q <title> [options] + + Arguments: + <title> The task title + + Options: + --priority <priority> The task priority, 0-4 or p0-p4 (0 = critical, 4 = backlog); list/ready also accept a range like 0-1 or p0-p1 + --type <type> The task type (task, bug, feature, epic, chore, docs, question, or custom) + --label <label> A label; can be used multiple times + --actor <actor> The acting identity recorded on the audit log (defaults to NITRO_TASK_ACTOR or the OS user name) + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks q "Fix the parser" + nitro agent tasks q "Fix the parser" --priority p1 --type bug --label api + """); + } + + [Fact] + public async Task MinimalArgs_PrintsOnlyTheTaskId() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "q", "Fix the parser"); + + // assert + Assert.Equal(0, result.ExitCode); + Assert.Empty(result.StdErr); + Assert.Equal("acme-n5z", result.StdOut); + Assert.Equal( + "open|2|task|test-agent", + await QueryScalarAsync( + "SELECT status || '|' || priority || '|' || task_type || '|' || created_by " + + "FROM tasks WHERE id = 'acme-n5z'")); + } + + [Fact] + public async Task FullOptions_CreatesTask_WithPriorityTypeAndLabels() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "q", "Fix the parser", + "--priority", "p1", + "--type", "bug", + "--label", "api", + "--label", "parser", + "--actor", "alice"); + + // assert + Assert.Equal(0, result.ExitCode); + Assert.Empty(result.StdErr); + Assert.Equal("acme-n5z", result.StdOut); + Assert.Equal( + "open|1|bug|alice", + await QueryScalarAsync( + "SELECT status || '|' || priority || '|' || task_type || '|' || created_by " + + "FROM tasks WHERE id = 'acme-n5z'")); + Assert.Equal( + "api,parser", + await QueryScalarAsync( + "SELECT group_concat(label, ',') FROM " + + "(SELECT label FROM labels WHERE task_id = 'acme-n5z' ORDER BY label)")); + } + + [Fact] + public async Task JsonOutput_MinimalArgs_ReturnsCreatedTaskSnapshot() + { + // arrange + await InitWorkspaceAsync(); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "q", "Fix the parser"); + + // assert + result.AssertSuccess( + """ + { + "id": "acme-n5z", + "title": "Fix the parser", + "status": "open", + "priority": 2, + "type": "task", + "assignee": null, + "estimatedMinutes": null, + "dueAt": null, + "deferUntil": null, + "createdAt": "2026-01-01T00:00:00+00:00", + "createdBy": "test-agent", + "updatedAt": "2026-01-01T00:00:00+00:00", + "closedAt": null, + "closeReason": null, + "description": "", + "design": "", + "acceptanceCriteria": "", + "notes": "", + "blockers": [] + } + """); + } + + [Fact] + public async Task InvalidPriority_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "q", "Fix the parser", "--priority", "p9"); + + // assert + result.AssertError("Invalid priority 'p9'. Use 0-4 or p0-p4 (0 = critical, 4 = backlog)."); + } + + [Fact] + public async Task TitleTooLong_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var title = new string('a', 501); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "q", title); + + // assert + result.AssertError("The title must be 1-500 characters."); + } + + [Fact] + public async Task WhitespaceOnlyTitle_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "q", " "); + + // assert + result.AssertError("The title must be 1-500 characters."); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ReadyTaskCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ReadyTaskCommandTests.cs new file mode 100644 index 00000000000..b4c381298ed --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ReadyTaskCommandTests.cs @@ -0,0 +1,231 @@ +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class ReadyTaskCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "ready", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + List tasks that are ready to work on. + + Usage: + nitro agent tasks ready [options] + + Options: + --priority <priority> The task priority, 0-4 or p0-p4 (0 = critical, 4 = backlog); list/ready also accept a range like 0-1 or p0-p1 + --assignee <assignee> The assignee + --label <label> A label; can be used multiple times + --limit <limit> The maximum number of tasks to show + --include-deferred Include deferred tasks + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks ready + nitro agent tasks ready --assignee alice --limit 5 + """); + } + + [Fact] + public async Task NoWorkspace_ReturnsError() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "ready"); + + // assert + result.AssertError( + """ + No task workspace found. Run `nitro agent tasks init` first. + """); + } + + [Fact] + public async Task EmptyWorkspace_ReturnsNoReadyTasksMessage() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "ready"); + + // assert + result.AssertSuccess( + """ + No ready tasks. + """); + } + + [Fact] + public async Task OpenTasks_ReturnsRowsSortedByPriorityThenCreatedAt() + { + // arrange + await InitWorkspaceAsync(); + var lowPriorityId = ExtractTaskId( + await ExecuteCommandAsync("agent", "tasks", "create", "Low priority task", "--priority", "3")); + var highPriorityId = ExtractTaskId( + await ExecuteCommandAsync("agent", "tasks", "create", "High priority task", "--priority", "1")); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "ready"); + + // assert + result.AssertSuccess( + $""" + {highPriorityId} P1 task open High priority task + {lowPriorityId} P3 task open Low priority task + + 2 task(s) + """); + } + + [Fact] + public async Task BlockedTask_IsExcludedFromReadyList() + { + // arrange + await InitWorkspaceAsync(); + var baseId = ExtractTaskId(await ExecuteCommandAsync("agent", "tasks", "create", "Base task")); + await ExecuteCommandAsync("agent", "tasks", "create", "Dependent task", "--depends-on", baseId); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "ready"); + + // assert + result.AssertSuccess( + $""" + {baseId} P2 task open Base task + + 1 task(s) + """); + } + + [Fact] + public async Task DeferredTask_ExcludedUnlessIncludeDeferred() + { + // arrange + await InitWorkspaceAsync(); + var deferredId = ExtractTaskId( + await ExecuteCommandAsync( + "agent", "tasks", "create", "Deferred task", "--defer-until", "2026-06-01T00:00:00Z")); + + // act + var defaultResult = await ExecuteCommandAsync("agent", "tasks", "ready"); + var includeDeferredResult = await ExecuteCommandAsync( + "agent", "tasks", "ready", "--include-deferred"); + + // assert + defaultResult.AssertSuccess( + """ + No ready tasks. + """); + includeDeferredResult.AssertSuccess( + $""" + {deferredId} P2 task open Deferred task + + 1 task(s) + """); + } + + [Fact] + public async Task PriorityRange_ReturnsTasksWithinBounds() + { + // arrange + await InitWorkspaceAsync(); + var criticalId = ExtractTaskId( + await ExecuteCommandAsync("agent", "tasks", "create", "Critical task", "--priority", "p0")); + var highId = ExtractTaskId( + await ExecuteCommandAsync("agent", "tasks", "create", "High task", "--priority", "p1")); + await ExecuteCommandAsync("agent", "tasks", "create", "Medium task", "--priority", "p2"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "ready", "--priority", "p0-p1"); + + // assert + result.AssertSuccess( + $""" + {criticalId} P0 task open Critical task + {highId} P1 task open High task + + 2 task(s) + """); + } + + [Fact] + public async Task PriorityRange_LowGreaterThanHigh_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "ready", "--priority", "3-1"); + + // assert + result.AssertError( + "Invalid priority range '3-1'. The low bound must be <= the high bound."); + } + + [Fact] + public async Task JsonOutput_ReturnsStructuredList() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "ready"); + + // assert + result.AssertSuccess( + $$""" + { + "items": [ + { + "id": "{{id}}", + "priority": 2, + "type": "task", + "status": "open", + "title": "Fix the parser" + } + ] + } + """); + } + + [Fact] + public async Task JsonOutput_EmptyWorkspace_ReturnsEmptyItems() + { + // arrange + await InitWorkspaceAsync(); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "ready"); + + // assert + result.AssertSuccess( + """ + { + "items": [] + } + """); + } + + /// <summary> + /// Pulls the task ID out of a `task create` confirmation line + /// ("Created task 'ID': Title.") so dependent commands can use it + /// without predicting the hash-derived ID ahead of time. + /// </summary> + private static string ExtractTaskId(CommandResult result) + { + var start = result.StdOut.IndexOf('\'') + 1; + var end = result.StdOut.IndexOf('\'', start); + return result.StdOut[start..end]; + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/RemoveTaskDependencyCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/RemoveTaskDependencyCommandTests.cs new file mode 100644 index 00000000000..db458d60b35 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/RemoveTaskDependencyCommandTests.cs @@ -0,0 +1,128 @@ + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class RemoveTaskDependencyCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "remove", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Remove a dependency between two tasks. + + Usage: + nitro agent tasks dep remove <id> <depends-on-id> [options] + + Arguments: + <id> The task ID + <depends-on-id> The task this dependency points to + + Options: + --actor <actor> The acting identity recorded on the audit log (defaults to NITRO_TASK_ACTOR or the OS user name) + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks dep remove "acme-1a2" "acme-9z8" + """); + } + + [Fact] + public async Task ExistingDependency_RemovesSuccessfully() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + var dependsOnId = await CreateTaskAsync("Write the tokenizer"); + await ExecuteCommandAsync("agent", "tasks", "dep", "add", id, dependsOnId); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "remove", id, dependsOnId); + + // assert + result.AssertSuccess($"โœ“ Removed dependency: '{id}' -> '{dependsOnId}'."); + Assert.Equal( + "0", + await QueryScalarAsync( + "SELECT COUNT(*) FROM dependencies " + + $"WHERE task_id = '{id}' AND depends_on_id = '{dependsOnId}'")); + } + + [Fact] + public async Task RecordsRemovedEvent_WithTypeAndTarget() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + var dependsOnId = await CreateTaskAsync("Write the tokenizer"); + await ExecuteCommandAsync("agent", "tasks", "dep", "add", id, dependsOnId, "--type", "related"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "remove", id, dependsOnId); + + // assert + result.AssertSuccess($"โœ“ Removed dependency: '{id}' -> '{dependsOnId}'."); + Assert.Equal( + $"related:{dependsOnId}", + await QueryScalarAsync( + "SELECT new_value FROM events " + + $"WHERE task_id = '{id}' AND event_type = 'dependency_removed'")); + } + + [Fact] + public async Task JsonOutput_ReturnsMinimalDependencyChange() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + var dependsOnId = await CreateTaskAsync("Write the tokenizer"); + await ExecuteCommandAsync("agent", "tasks", "dep", "add", id, dependsOnId); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "remove", id, dependsOnId); + + // assert + result.AssertSuccess( + $$""" + { + "id": "{{id}}", + "dependsOnId": "{{dependsOnId}}" + } + """); + } + + [Fact] + public async Task MissingEdge_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + var dependsOnId = await CreateTaskAsync("Write the tokenizer"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "remove", id, dependsOnId); + + // assert + result.AssertError("Dependency does not exist."); + } + + [Fact] + public async Task TaskNotFound_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "remove", "acme-999", "acme-998"); + + // assert + result.AssertError("Task 'acme-999' does not exist."); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/RemoveTaskLabelCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/RemoveTaskLabelCommandTests.cs new file mode 100644 index 00000000000..6d54eca0ed3 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/RemoveTaskLabelCommandTests.cs @@ -0,0 +1,116 @@ + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class RemoveTaskLabelCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "label", "remove", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Remove a label from a task. + + Usage: + nitro agent tasks label remove <id> <label> [options] + + Arguments: + <id> The task ID + <label> The label + + Options: + --actor <actor> The acting identity recorded on the audit log (defaults to NITRO_TASK_ACTOR or the OS user name) + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks label remove "acme-1a2" api + """); + } + + [Fact] + public async Task ExistingLabel_RemovesSuccessfully() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + await ExecuteCommandAsync("agent", "tasks", "label", "add", id, "api"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "label", "remove", id, "api"); + + // assert + result.AssertSuccess($"โœ“ Removed label 'api' from '{id}'."); + Assert.Equal("0", await QueryScalarAsync( + $"SELECT COUNT(*) FROM labels WHERE task_id = '{id}'")); + } + + [Fact] + public async Task UppercaseLabel_MatchesNormalizedStoredLabel() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + await ExecuteCommandAsync("agent", "tasks", "label", "add", id, "api"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "label", "remove", id, "API"); + + // assert + result.AssertSuccess($"โœ“ Removed label 'api' from '{id}'."); + } + + [Fact] + public async Task JsonOutput_ReturnsMinimalLabelChange() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + await ExecuteCommandAsync("agent", "tasks", "label", "add", id, "api"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "label", "remove", id, "api"); + + // assert + result.AssertSuccess( + $$""" + { + "id": "{{id}}", + "label": "api" + } + """); + } + + [Fact] + public async Task LabelNotOnTask_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "label", "remove", id, "api"); + + // assert + result.AssertError($"Label 'api' is not on '{id}'."); + } + + [Fact] + public async Task TaskNotFound_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "label", "remove", "acme-999", "api"); + + // assert + result.AssertError("Task 'acme-999' does not exist."); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ReopenTaskCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ReopenTaskCommandTests.cs new file mode 100644 index 00000000000..85d694437aa --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ReopenTaskCommandTests.cs @@ -0,0 +1,141 @@ + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class ReopenTaskCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "reopen", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Reopen a closed task. + + Usage: + nitro agent tasks reopen <id> [options] + + Arguments: + <id> The task ID + + Options: + --reason <reason> The reason recorded for this change + --actor <actor> The acting identity recorded on the audit log (defaults to NITRO_TASK_ACTOR or the OS user name) + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks reopen "app-1a2" + """); + } + + [Fact] + public async Task ClosedTask_ReopensSuccessfully() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + await ExecuteCommandAsync("agent", "tasks", "close", id, "--reason", "Not needed"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "reopen", id); + + // assert + result.AssertSuccess($"โœ“ Reopened task '{id}'."); + Assert.Equal("open", await QueryScalarAsync( + $"SELECT status FROM tasks WHERE id = '{id}'")); + Assert.Null(await QueryScalarAsync( + $"SELECT closed_at FROM tasks WHERE id = '{id}'")); + Assert.Equal("", await QueryScalarAsync( + $"SELECT close_reason FROM tasks WHERE id = '{id}'")); + } + + [Fact] + public async Task WithReason_RecordsReopenReason() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + await ExecuteCommandAsync("agent", "tasks", "close", id); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "reopen", id, "--reason", "Needed after all"); + + // assert + result.AssertSuccess($"โœ“ Reopened task '{id}'."); + Assert.Equal("Needed after all", await QueryScalarAsync( + $"SELECT comment FROM events WHERE task_id = '{id}' AND event_type = 'reopened'")); + } + + [Fact] + public async Task WithActor_RecordsActorOnEvent() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + await ExecuteCommandAsync("agent", "tasks", "close", id); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "reopen", id, "--actor", "alice"); + + // assert + result.AssertSuccess($"โœ“ Reopened task '{id}'."); + Assert.Equal("alice", await QueryScalarAsync( + $"SELECT actor FROM events WHERE task_id = '{id}' AND event_type = 'reopened'")); + } + + [Fact] + public async Task JsonOutput_ReturnsReopenedTaskSnapshot() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + await ExecuteCommandAsync("agent", "tasks", "close", id); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "reopen", id); + + // assert + using var document = System.Text.Json.JsonDocument.Parse(result.StdOut); + var root = document.RootElement; + + Assert.Empty(result.StdErr); + Assert.Equal(0, result.ExitCode); + Assert.Equal(id, root.GetProperty("id").GetString()); + Assert.Equal("open", root.GetProperty("status").GetString()); + Assert.Equal(System.Text.Json.JsonValueKind.Null, root.GetProperty("closedAt").ValueKind); + } + + [Fact] + public async Task NotClosed_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "reopen", id); + + // assert + result.AssertError($"Task '{id}' is not closed."); + } + + [Fact] + public async Task TaskNotFound_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "reopen", "acme-999"); + + // assert + result.AssertError("Task 'acme-999' does not exist."); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/SearchTaskCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/SearchTaskCommandTests.cs new file mode 100644 index 00000000000..28868b03fa0 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/SearchTaskCommandTests.cs @@ -0,0 +1,236 @@ + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class SearchTaskCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "search", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Search tasks by text. + + Usage: + nitro agent tasks search <text> [options] + + Arguments: + <text> The search text + + Options: + --limit <limit> The maximum number of tasks to show + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks search "parser" + nitro agent tasks search "parser" --limit 5 + """); + } + + [Fact] + public async Task NoMatches_PrintsNoTasksFound() + { + // arrange + await InitWorkspaceAsync(); + await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "search", "nonexistent"); + + // assert + result.AssertSuccess("No tasks found."); + } + + [Fact] + public async Task MatchesTitle_CaseInsensitive() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the Parser"); + await CreateTaskAsync("Write the docs"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "search", "parser"); + + // assert + result.AssertSuccess( + $""" + {id} P2 task open Fix the Parser + + 1 task(s) + """); + } + + [Fact] + public async Task MatchesDescription_ReturnsTask() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync( + "Improve error messages", "--description", "Handle edge cases in the tokenizer"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "search", "tokenizer"); + + // assert + result.AssertSuccess( + $""" + {id} P2 task open Improve error messages + + 1 task(s) + """); + } + + [Fact] + public async Task MatchesComment_ReturnsTask() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Improve error messages"); + await ExecuteCommandAsync( + "agent", "tasks", "comment", "add", id, "Decided to use the tokenizer approach."); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "search", "tokenizer"); + + // assert + result.AssertSuccess( + $""" + {id} P2 task open Improve error messages + + 1 task(s) + """); + } + + [Fact] + public async Task MatchesMultipleComments_ReturnsTaskOnce() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Improve error messages"); + await ExecuteCommandAsync("agent", "tasks", "comment", "add", id, "The tokenizer needs work."); + await ExecuteCommandAsync("agent", "tasks", "comment", "add", id, "Tokenizer fix landed."); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "search", "tokenizer"); + + // assert + result.AssertSuccess( + $""" + {id} P2 task open Improve error messages + + 1 task(s) + """); + } + + [Fact] + public async Task TombstonedTask_ExcludedFromResults() + { + // arrange + await InitWorkspaceAsync(); + var deletedId = await CreateTaskAsync("Widget cleanup"); + await ExecuteCommandAsync("agent", "tasks", "delete", deletedId, "--force"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "search", "widget"); + + // assert + result.AssertSuccess("No tasks found."); + } + + [Fact] + public async Task LimitOption_TruncatesResultsSortedByPriority() + { + // arrange + await InitWorkspaceAsync(); + var high = await CreateTaskAsync("Widget alpha", "--priority", "p0"); + var mid = await CreateTaskAsync("Widget beta", "--priority", "p1"); + await CreateTaskAsync("Widget gamma", "--priority", "p2"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "search", "widget", "--limit", "2"); + + // assert + result.AssertSuccess( + $""" + {high} P0 task open Widget alpha + {mid} P1 task open Widget beta + + 2 task(s) + """); + } + + [Fact] + public async Task JsonOutput_ReturnsStructuredList() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the Parser"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "search", "parser"); + + // assert + result.AssertSuccess( + $$""" + { + "items": [ + { + "id": "{{id}}", + "priority": 2, + "type": "task", + "status": "open", + "title": "Fix the Parser" + } + ] + } + """); + } + + [Fact] + public async Task JsonOutput_NoMatches_ReturnsEmptyItems() + { + // arrange + await InitWorkspaceAsync(); + await CreateTaskAsync("Fix the parser"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "search", "nonexistent"); + + // assert + result.AssertSuccess( + """ + { + "items": [] + } + """); + } + + [Fact] + public async Task LiteralPercent_DoesNotActAsWildcard() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("100% done"); + await CreateTaskAsync("Something else"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "search", "100%"); + + // assert + result.AssertSuccess( + $""" + {id} P2 task open 100% done + + 1 task(s) + """); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/SetTaskConfigCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/SetTaskConfigCommandTests.cs new file mode 100644 index 00000000000..3ca0fb92120 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/SetTaskConfigCommandTests.cs @@ -0,0 +1,113 @@ + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class SetTaskConfigCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "config", "set", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Set a configuration value. + + Usage: + nitro agent tasks config set <key> <value> [options] + + Arguments: + <key> The configuration key + <value> The configuration value + + Options: + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks config set prefix "app" + """); + } + + [Fact] + public async Task NewKey_SetsAndConfirms() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "config", "set", "reviewer", "alice"); + + // assert + result.AssertSuccess("โœ“ Set 'reviewer' to 'alice'."); + Assert.Equal("alice", await QueryScalarAsync( + "SELECT value FROM config WHERE key = 'reviewer'")); + } + + [Fact] + public async Task ExistingKey_OverwritesValue() + { + // arrange + await InitWorkspaceAsync(); + await ExecuteCommandAsync("agent", "tasks", "config", "set", "reviewer", "alice"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "config", "set", "reviewer", "bob"); + + // assert + result.AssertSuccess("โœ“ Set 'reviewer' to 'bob'."); + Assert.Equal("bob", await QueryScalarAsync( + "SELECT value FROM config WHERE key = 'reviewer'")); + } + + [Fact] + public async Task JsonOutput_ReturnsSetKeyAndValue() + { + // arrange + await InitWorkspaceAsync(); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "config", "set", "reviewer", "alice"); + + // assert + result.AssertSuccess( + """ + { + "key": "reviewer", + "value": "alice" + } + """); + } + + [Fact] + public async Task PrefixKey_NormalizesValue() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "config", "set", "prefix", "My App!"); + + // assert + result.AssertSuccess("โœ“ Set 'prefix' to 'myapp'."); + Assert.Equal("myapp", await QueryScalarAsync( + "SELECT value FROM config WHERE key = 'prefix'")); + } + + [Fact] + public async Task NoWorkspace_ReturnsError() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "config", "set", "reviewer", "alice"); + + // assert + result.AssertError( + """ + No task workspace found. Run `nitro agent tasks init` first. + """); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ShowTaskCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ShowTaskCommandTests.cs new file mode 100644 index 00000000000..d56de6e055c --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/ShowTaskCommandTests.cs @@ -0,0 +1,289 @@ +using Microsoft.Data.Sqlite; + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class ShowTaskCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "show", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Show a task's details. + + Usage: + nitro agent tasks show <id> [options] + + Arguments: + <id> The task ID + + Options: + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks show "acme-1a2" + """); + } + + [Fact] + public async Task TaskNotFound_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "show", "acme-999"); + + // assert + result.AssertError("Task 'acme-999' does not exist."); + } + + [Fact] + public async Task MinimalTask_DisplaysHeaderOnly() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "show", id); + + // assert + result.AssertSuccess( + $""" + {id}: Fix the parser + + Status: open Priority: P2 Type: task + Created: 2026-01-01 00:00 by test-agent + Updated: 2026-01-01 00:00 + """); + } + + [Fact] + public async Task TaskWithOptionalFields_DisplaysAllHeaderLinesAndDescription() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync( + "Ship the release", + "--description", "Coordinate the release checklist.", + "--priority", "p1", + "--type", "feature", + "--assignee", "alice", + "--label", "api", + "--label", "parser", + "--due", "2026-01-05T00:00:00Z", + "--defer-until", "2026-01-03T00:00:00Z", + "--estimate", "90"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "show", id); + + // assert + result.AssertSuccess( + $""" + {id}: Ship the release + + Status: open Priority: P1 Type: feature + Assignee: alice + Due: 2026-01-05 00:00 + Deferred until: 2026-01-03 00:00 + Estimate: 90m + Created: 2026-01-01 00:00 by test-agent + Updated: 2026-01-01 00:00 + Labels: api, parser + + Description: + Coordinate the release checklist. + """); + } + + [Fact] + public async Task ClosedTask_DisplaysClosedLineWithReason() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Investigate flaky test"); + var closeResult = await ExecuteCommandAsync( + "agent", "tasks", "close", id, "--reason", "Fixed by retry logic"); + Assert.Equal(0, closeResult.ExitCode); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "show", id); + + // assert + result.AssertSuccess( + $""" + {id}: Investigate flaky test + + Status: closed Priority: P2 Type: task + Created: 2026-01-01 00:00 by test-agent + Updated: 2026-01-01 00:00 + Closed: 2026-01-01 00:00 (Fixed by retry logic) + """); + } + + [Fact] + public async Task TaskWithDependency_DisplaysDependenciesBlocksAndBlockedBy() + { + // arrange + await InitWorkspaceAsync(); + var baseId = await CreateTaskAsync("Fix the parser"); + var dependentId = await CreateTaskAsync("Fix the lexer", "--depends-on", baseId); + + // act + var dependent = await ExecuteCommandAsync("agent", "tasks", "show", dependentId); + var dependency = await ExecuteCommandAsync("agent", "tasks", "show", baseId); + + // assert + dependent.AssertSuccess( + $""" + {dependentId}: Fix the lexer + + Status: open Priority: P2 Type: task + Created: 2026-01-01 00:00 by test-agent + Updated: 2026-01-01 00:00 + Blocked by: {baseId}:open + + Dependencies: + blocks -> {baseId} (open) Fix the parser + """); + dependency.AssertSuccess( + $""" + {baseId}: Fix the parser + + Status: open Priority: P2 Type: task + Created: 2026-01-01 00:00 by test-agent + Updated: 2026-01-01 00:00 + + Blocks: + {dependentId} (open) Fix the lexer + """); + } + + [Fact] + public async Task TaskWithComments_DisplaysComments() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Document the API"); + await InsertCommentAsync(id, "alice", "Looks good to me.", cancellationToken); + await InsertCommentAsync(id, "test-agent", "Thanks, merging.", cancellationToken); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "show", id); + + // assert + result.AssertSuccess( + $""" + {id}: Document the API + + Status: open Priority: P2 Type: task + Created: 2026-01-01 00:00 by test-agent + Updated: 2026-01-01 00:00 + + Comments: + [1] alice 2026-01-01 00:00 + Looks good to me. + + [2] test-agent 2026-01-01 00:00 + Thanks, merging. + """); + } + + [Fact] + public async Task JsonOutput_MinimalTask_ReturnsStructuredDetail() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "show", id); + + // assert + result.AssertSuccess( + $$""" + { + "id": "{{id}}", + "title": "Fix the parser", + "status": "open", + "priority": 2, + "type": "task", + "assignee": null, + "estimatedMinutes": null, + "dueAt": null, + "deferUntil": null, + "createdAt": "2026-01-01T00:00:00+00:00", + "createdBy": "test-agent", + "updatedAt": "2026-01-01T00:00:00+00:00", + "closedAt": null, + "closeReason": null, + "description": "", + "design": "", + "acceptanceCriteria": "", + "notes": "", + "labels": [], + "blockers": [], + "dependencies": [], + "dependents": [], + "comments": [] + } + """); + } + + [Fact] + public async Task JsonOutput_TaskWithDependency_EmbedsDependenciesAndBlockers() + { + // arrange + await InitWorkspaceAsync(); + var baseId = await CreateTaskAsync("Fix the parser"); + var dependentId = await CreateTaskAsync("Fix the lexer", "--depends-on", baseId); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "show", dependentId); + + // assert + using var document = System.Text.Json.JsonDocument.Parse(result.StdOut); + var root = document.RootElement; + + Assert.Empty(result.StdErr); + Assert.Equal(0, result.ExitCode); + Assert.Equal("open", root.GetProperty("blockers")[0].GetString()!.Split(':')[1]); + Assert.Equal(baseId, root.GetProperty("blockers")[0].GetString()!.Split(':')[0]); + Assert.Equal("blocks", root.GetProperty("dependencies")[0].GetProperty("type").GetString()); + Assert.Equal(baseId, root.GetProperty("dependencies")[0].GetProperty("dependsOnId").GetString()); + } + + /// <summary> + /// Inserts a comment directly so the test controls created_at. + /// </summary> + private async Task InsertCommentAsync( + string taskId, string author, string text, CancellationToken cancellationToken) + { + await using var connection = + new SqliteConnection($"Data Source={DatabasePath};Pooling=False"); + await connection.OpenAsync(cancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = + "INSERT INTO comments (task_id, author, text, created_at) " + + "VALUES (@taskId, @author, @text, @createdAt)"; + command.Parameters.AddWithValue("@taskId", taskId); + command.Parameters.AddWithValue("@author", author); + command.Parameters.AddWithValue("@text", text); + command.Parameters.AddWithValue("@createdAt", FakeTime.GetUtcNow()); + + await command.ExecuteNonQueryAsync(cancellationToken); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/StaleTaskCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/StaleTaskCommandTests.cs new file mode 100644 index 00000000000..f50207412a6 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/StaleTaskCommandTests.cs @@ -0,0 +1,175 @@ + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class StaleTaskCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "stale", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + List open tasks that have not been updated recently. + + Usage: + nitro agent tasks stale [options] + + Options: + --days <days> The staleness threshold in days (default 30) + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks stale + nitro agent tasks stale --days 14 + """); + } + + [Fact] + public async Task EmptyWorkspace_PrintsNoStaleTasksMessage() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "stale"); + + // assert + result.AssertSuccess("No stale tasks."); + } + + [Fact] + public async Task DefaultThreshold_ListsTasksNotUpdatedInThirtyDays() + { + // arrange + await InitWorkspaceAsync(); + var oldId = await CreateTaskAsync("Fix the parser"); + FakeTime.Advance(TimeSpan.FromDays(31)); + await CreateTaskAsync("Write the docs"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "stale"); + + // assert + result.AssertSuccess( + $""" + {oldId} P2 task open Fix the parser + + 1 task(s) + """); + } + + [Fact] + public async Task DaysOption_OverridesDefaultThreshold() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + FakeTime.Advance(TimeSpan.FromDays(10)); + + // act + var defaultResult = await ExecuteCommandAsync("agent", "tasks", "stale"); + var customResult = await ExecuteCommandAsync("agent", "tasks", "stale", "--days", "5"); + + // assert + defaultResult.AssertSuccess("No stale tasks."); + customResult.AssertSuccess( + $""" + {id} P2 task open Fix the parser + + 1 task(s) + """); + } + + [Fact] + public async Task ClosedTask_ExcludedEvenWhenOld() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + await ExecuteCommandAsync("agent", "tasks", "close", id); + FakeTime.Advance(TimeSpan.FromDays(60)); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "stale"); + + // assert + result.AssertSuccess("No stale tasks."); + } + + [Fact] + public async Task JsonOutput_ReturnsStructuredList() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + FakeTime.Advance(TimeSpan.FromDays(31)); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "stale"); + + // assert + result.AssertSuccess( + $$""" + { + "items": [ + { + "id": "{{id}}", + "priority": 2, + "type": "task", + "status": "open", + "title": "Fix the parser" + } + ] + } + """); + } + + [Fact] + public async Task JsonOutput_NoStaleTasks_ReturnsEmptyItems() + { + // arrange + await InitWorkspaceAsync(); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "stale"); + + // assert + result.AssertSuccess( + """ + { + "items": [] + } + """); + } + + [Fact] + public async Task MultipleStaleTasks_SortedByUpdatedAtThenId() + { + // arrange + await InitWorkspaceAsync(); + var first = await CreateTaskAsync("Alpha task"); + FakeTime.Advance(TimeSpan.FromDays(1)); + var second = await CreateTaskAsync("Beta task"); + FakeTime.Advance(TimeSpan.FromDays(40)); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "stale"); + + // assert + result.AssertSuccess( + $""" + {first} P2 task open Alpha task + {second} P2 task open Beta task + + 2 task(s) + """); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/StatsTaskCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/StatsTaskCommandTests.cs new file mode 100644 index 00000000000..df63903b231 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/StatsTaskCommandTests.cs @@ -0,0 +1,200 @@ +using Microsoft.Data.Sqlite; + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class StatsTaskCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "stats", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Show summary statistics for the task workspace. + + Usage: + nitro agent tasks stats [options] + + Options: + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks stats + """); + } + + [Fact] + public async Task NoWorkspace_ReturnsError() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "stats"); + + // assert + result.AssertError( + """ + No task workspace found. Run `nitro agent tasks init` first. + """); + } + + [Fact] + public async Task EmptyWorkspace_ReturnsZeroCounts() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "stats"); + + // assert + result.AssertSuccess( + """ + Tasks: 0 + Ready: 0 + Blocked: 0 + Labels: 0 + Comments: 0 + Events: 0 + """); + } + + [Fact] + public async Task MixedStatuses_GroupsByStatusAndCountsReady() + { + // arrange + await InitWorkspaceAsync(); + await CreateTaskAsync("Fix the parser"); + var id2 = await CreateTaskAsync("Write the docs"); + var id3 = await CreateTaskAsync("Deploy service"); + await ExecuteCommandAsync("agent", "tasks", "update", id2, "--status", "in_progress"); + await ExecuteCommandAsync("agent", "tasks", "close", id3); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "stats"); + + // assert + result.AssertSuccess( + """ + Tasks: 3 + open: 1 + in_progress: 1 + closed: 1 + Ready: 1 + Blocked: 0 + Labels: 0 + Comments: 0 + Events: 5 + """); + } + + [Fact] + public async Task BlockedTask_CountsAsBlockedNotReady() + { + // arrange + await InitWorkspaceAsync(); + var baseId = await CreateTaskAsync("Base task"); + await CreateTaskAsync("Dependent task", "--depends-on", baseId); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "stats"); + + // assert + result.AssertSuccess( + """ + Tasks: 2 + open: 2 + Ready: 1 + Blocked: 1 + Labels: 0 + Comments: 0 + Events: 2 + """); + } + + [Fact] + public async Task LabelsAndComments_CountsAcrossTasks() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await InitWorkspaceAsync(); + var id1 = await CreateTaskAsync("Fix the parser", "--label", "api", "--label", "parser"); + await CreateTaskAsync("Write the docs", "--label", "api"); + await InsertCommentAsync(id1, "alice", "Looks good.", cancellationToken); + await InsertCommentAsync(id1, "test-agent", "Thanks.", cancellationToken); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "stats"); + + // assert + result.AssertSuccess( + """ + Tasks: 2 + open: 2 + Ready: 2 + Blocked: 0 + Labels: 2 + Comments: 2 + Events: 2 + """); + } + + [Fact] + public async Task JsonOutput_ReturnsStructuredStats() + { + // arrange + await InitWorkspaceAsync(); + var baseId = await CreateTaskAsync("Base task"); + var dependentId = await CreateTaskAsync("Dependent task", "--depends-on", baseId); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "stats"); + + // assert + result.AssertSuccess( + $$""" + { + "statusCounts": [ + { + "value": "open", + "count": 2 + } + ], + "readyCount": 1, + "blockedTaskStatuses": { + "{{dependentId}}": "open" + }, + "labelCount": 0, + "commentCount": 0, + "eventCount": 2 + } + """); + } + + /// <summary> + /// Inserts a comment directly so the test controls created_at. + /// </summary> + private async Task InsertCommentAsync( + string taskId, string author, string text, CancellationToken cancellationToken) + { + await using var connection = + new SqliteConnection($"Data Source={DatabasePath};Pooling=False"); + await connection.OpenAsync(cancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = + "INSERT INTO comments (task_id, author, text, created_at) " + + "VALUES (@taskId, @author, @text, @createdAt)"; + command.Parameters.AddWithValue("@taskId", taskId); + command.Parameters.AddWithValue("@author", author); + command.Parameters.AddWithValue("@text", text); + command.Parameters.AddWithValue("@createdAt", FakeTime.GetUtcNow()); + + await command.ExecuteNonQueryAsync(cancellationToken); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/StatusTaskEpicCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/StatusTaskEpicCommandTests.cs new file mode 100644 index 00000000000..6590e02b812 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/StatusTaskEpicCommandTests.cs @@ -0,0 +1,161 @@ +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class StatusTaskEpicCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "epic", "status", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Show epics with their child completion counts. + + Usage: + nitro agent tasks epic status [options] + + Options: + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks epic status + """); + } + + [Fact] + public async Task NoWorkspace_ReturnsError() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "epic", "status"); + + // assert + result.AssertError( + """ + No task workspace found. Run `nitro agent tasks init` first. + """); + } + + [Fact] + public async Task NoEpics_ReturnsEmptyMessage() + { + // arrange + await InitWorkspaceAsync(); + await CreateTaskAsync("Standalone task"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "epic", "status"); + + // assert + result.AssertSuccess( + """ + No epics found. + """); + } + + [Fact] + public async Task EpicWithOpenAndClosedChildren_ShowsPartialCompletion() + { + // arrange + await InitWorkspaceAsync(); + var epicId = await CreateTaskAsync("Ship v2", "--type", "epic"); + var child1Id = await CreateTaskAsync("Design API", "--parent", epicId); + await CreateTaskAsync("Implement API", "--parent", epicId); + await ExecuteCommandAsync("agent", "tasks", "close", child1Id); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "epic", "status"); + + // assert + result.AssertSuccess($"{epicId} 1/2 Ship v2"); + } + + [Fact] + public async Task EpicWithAllChildrenClosed_ShowsEligibleSuffix() + { + // arrange + await InitWorkspaceAsync(); + var epicId = await CreateTaskAsync("Ship v3", "--type", "epic"); + var childId = await CreateTaskAsync("Design API v3", "--parent", epicId); + await ExecuteCommandAsync("agent", "tasks", "close", childId); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "epic", "status"); + + // assert + result.AssertSuccess($"{epicId} 1/1 Ship v3 (eligible for close)"); + } + + [Fact] + public async Task EpicAlreadyClosed_SuffixOmitted() + { + // arrange + await InitWorkspaceAsync(); + var epicId = await CreateTaskAsync("Ship v4", "--type", "epic"); + var childId = await CreateTaskAsync("Design API v4", "--parent", epicId); + await ExecuteCommandAsync("agent", "tasks", "close", childId); + await ExecuteCommandAsync("agent", "tasks", "close", epicId); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "epic", "status"); + + // assert + result.AssertSuccess($"{epicId} 1/1 Ship v4"); + } + + [Fact] + public async Task JsonOutput_EpicWithOpenAndClosedChildren_ReturnsStructuredStatus() + { + // arrange + await InitWorkspaceAsync(); + var epicId = await CreateTaskAsync("Ship v2", "--type", "epic"); + var child1Id = await CreateTaskAsync("Design API", "--parent", epicId); + await CreateTaskAsync("Implement API", "--parent", epicId); + await ExecuteCommandAsync("agent", "tasks", "close", child1Id); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "epic", "status"); + + // assert + result.AssertSuccess( + $$""" + { + "items": [ + { + "id": "{{epicId}}", + "title": "Ship v2", + "status": "open", + "total": 2, + "closed": 1, + "isEligibleForClose": false + } + ] + } + """); + } + + [Fact] + public async Task JsonOutput_NoEpics_ReturnsEmptyItems() + { + // arrange + await InitWorkspaceAsync(); + await CreateTaskAsync("Standalone task"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "epic", "status"); + + // assert + result.AssertSuccess( + """ + { + "items": [] + } + """); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/SyncTaskCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/SyncTaskCommandTests.cs new file mode 100644 index 00000000000..9e64ad04076 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/SyncTaskCommandTests.cs @@ -0,0 +1,292 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class SyncTaskCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "sync", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Synchronize the task database with the committed tasks.jsonl file. + + Usage: + nitro agent tasks sync [options] + + Options: + --flush-only Write the task database to tasks.jsonl + --import-only Load tasks.jsonl into the task database + --status Report whether tasks.jsonl and the task database diverge + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks sync --flush-only + nitro agent tasks sync --import-only + nitro agent tasks sync --status + """); + } + + [Fact] + public async Task NoFlags_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "sync"); + + // assert + Assert.Equal(1, result.ExitCode); + Assert.Contains( + "Specify exactly one of '--flush-only', '--import-only', or '--status'.", + result.StdErr); + } + + [Fact] + public async Task MultipleFlags_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "sync", "--flush-only", "--status"); + + // assert + Assert.Equal(1, result.ExitCode); + Assert.Contains( + "Specify exactly one of '--flush-only', '--import-only', or '--status'.", + result.StdErr); + } + + [Fact] + public async Task FlushOnly_NoWorkspace_ReturnsError() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "sync", "--flush-only"); + + // assert + result.AssertError("No task workspace found. Run `nitro agent tasks init` first."); + } + + [Fact] + public async Task ImportOnly_NoWorkspace_ReturnsError() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "sync", "--import-only"); + + // assert + result.AssertError("No task workspace found. Run `nitro agent tasks init` first."); + } + + [Fact] + public async Task ImportOnly_NoJsonl_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "sync", "--import-only"); + + // assert + result.AssertError( + "No 'tasks.jsonl' found at '.nitro/tasks/tasks.jsonl'."); + } + + [Fact] + public async Task FlushOnly_WritesOneLinePerTaskAndConfigEntry() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "sync", "--flush-only"); + + // assert + result.AssertSuccess("โœ“ Flushed 1 task to '.nitro/tasks/tasks.jsonl'."); + + var jsonlPath = TaskWorkspace.GetJsonlPath(WorkspaceDirectory); + Assert.True(File.Exists(jsonlPath)); + + var content = await File.ReadAllTextAsync(jsonlPath, TestContext.Current.CancellationToken); + var lines = content.TrimEnd('\n').Split('\n'); + Assert.Equal(2, lines.Length); + Assert.Contains("\"key\":\"prefix\"", lines[0]); + Assert.Contains($"\"id\":\"{id}\"", lines[1]); + } + + [Fact] + public async Task RoundTrip_FlushDeleteDatabaseImport_ShowOutputIsIdentical() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await InitWorkspaceAsync(); + var parentId = await CreateTaskAsync("Design the schema"); + var childId = await CreateTaskAsync( + "Implement the schema", "--depends-on", parentId); + await ExecuteCommandAsync("agent", "tasks", "label", "add", childId, "backend"); + await ExecuteCommandAsync("agent", "tasks", "comment", "add", childId, "Looks good to me."); + var deletedId = await CreateTaskAsync("Obsolete task"); + await ExecuteCommandAsync("agent", "tasks", "delete", deletedId, "--force"); + + var beforeParent = await ExecuteCommandAsync("agent", "tasks", "show", parentId); + var beforeChild = await ExecuteCommandAsync("agent", "tasks", "show", childId); + + // act + var flushResult = await ExecuteCommandAsync("agent", "tasks", "sync", "--flush-only"); + Assert.Equal(0, flushResult.ExitCode); + + File.Delete(DatabasePath); + File.Delete(DatabasePath + "-wal"); + File.Delete(DatabasePath + "-shm"); + + var importResult = await ExecuteCommandAsync("agent", "tasks", "sync", "--import-only"); + Assert.Equal(0, importResult.ExitCode); + + var afterParent = await ExecuteCommandAsync("agent", "tasks", "show", parentId); + var afterChild = await ExecuteCommandAsync("agent", "tasks", "show", childId); + var deletedStatus = await QueryScalarAsync( + $"SELECT status FROM tasks WHERE id = '{deletedId}'"); + + // assert + Assert.Equal(beforeParent.StdOut, afterParent.StdOut); + Assert.Equal(beforeChild.StdOut, afterChild.StdOut); + Assert.Equal("tombstone", deletedStatus); + } + + [Fact] + public async Task RoundTrip_FlushDeleteDatabaseImport_PrefixSurvives() + { + // arrange + var initResult = await ExecuteCommandAsync("agent", "tasks", "init", "--prefix", "widget"); + Assert.Equal(0, initResult.ExitCode); + var firstId = await CreateTaskAsync("First task"); + Assert.StartsWith("widget-", firstId); + + // act + var flushResult = await ExecuteCommandAsync("agent", "tasks", "sync", "--flush-only"); + Assert.Equal(0, flushResult.ExitCode); + + File.Delete(DatabasePath); + File.Delete(DatabasePath + "-wal"); + File.Delete(DatabasePath + "-shm"); + + var importResult = await ExecuteCommandAsync("agent", "tasks", "sync", "--import-only"); + Assert.Equal(0, importResult.ExitCode); + + var secondId = await CreateTaskAsync("Second task"); + + // assert + Assert.StartsWith("widget-", secondId); + } + + [Fact] + public async Task RoundTrip_FlushDeleteDatabaseImport_NextChildGetsNextId() + { + // arrange + await InitWorkspaceAsync(); + var parentId = await CreateTaskAsync("Design the schema"); + var firstChildId = await CreateTaskAsync( + "Implement the schema", "--parent", parentId); + + // act + var flushResult = await ExecuteCommandAsync("agent", "tasks", "sync", "--flush-only"); + Assert.Equal(0, flushResult.ExitCode); + + File.Delete(DatabasePath); + File.Delete(DatabasePath + "-wal"); + File.Delete(DatabasePath + "-shm"); + + var importResult = await ExecuteCommandAsync("agent", "tasks", "sync", "--import-only"); + Assert.Equal(0, importResult.ExitCode); + + var secondChildId = await CreateTaskAsync( + "Write the tests", "--parent", parentId); + + // assert + Assert.Equal($"{parentId}.1", firstChildId); + Assert.Equal($"{parentId}.2", secondChildId); + } + + [Fact] + public async Task Status_InSync_ReturnsSuccess() + { + // arrange + await InitWorkspaceAsync(); + await CreateTaskAsync("Fix the parser"); + await ExecuteCommandAsync("agent", "tasks", "sync", "--flush-only"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "sync", "--status"); + + // assert + result.AssertSuccess( + "โœ“ 'tasks.jsonl' is in sync with the task database (1 task)."); + } + + [Fact] + public async Task Status_Diverged_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + await ExecuteCommandAsync("agent", "tasks", "sync", "--flush-only"); + await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "sync", "--status"); + + // assert + Assert.Equal(1, result.ExitCode); + Assert.Contains("have diverged", result.StdOut); + } + + [Fact] + public async Task ImportOnly_OlderRecord_DoesNotOverwriteNewerTask() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Original title"); + await ExecuteCommandAsync("agent", "tasks", "sync", "--flush-only"); + + FakeTime.Advance(TimeSpan.FromMinutes(5)); + await ExecuteCommandAsync("agent", "tasks", "update", id, "--title", "Newer title"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "sync", "--import-only"); + + // assert + Assert.Equal(0, result.ExitCode); + Assert.Equal( + "Newer title", + await QueryScalarAsync($"SELECT title FROM tasks WHERE id = '{id}'")); + } + + [Fact] + public async Task ImportOnly_MissingDatabase_CreatesWorkspaceFromJsonl() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + await ExecuteCommandAsync("agent", "tasks", "sync", "--flush-only"); + File.Delete(DatabasePath); + File.Delete(DatabasePath + "-wal"); + File.Delete(DatabasePath + "-shm"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "sync", "--import-only"); + + // assert + Assert.Equal(0, result.ExitCode); + Assert.True(File.Exists(DatabasePath)); + Assert.Equal( + "Fix the parser", + await QueryScalarAsync($"SELECT title FROM tasks WHERE id = '{id}'")); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/TaskStoreTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/TaskStoreTests.cs new file mode 100644 index 00000000000..c06f4b19523 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/TaskStoreTests.cs @@ -0,0 +1,953 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using Microsoft.Data.Sqlite; +using Microsoft.Extensions.Time.Testing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +/// <summary> +/// Exercises the backend-agnostic read surface of <see cref="TaskStore"/> +/// directly against a real SQLite workspace, seeded with raw SQL since the +/// write surface is not implemented yet. Covers the DapperAOT-sensitive +/// paths: TEXT timestamp columns, IN-array expansion, and record +/// materialization. +/// </summary> +public sealed class TaskStoreTests : IAsyncDisposable +{ + private readonly DirectoryInfo _tempRoot; + private readonly string _workingDirectory; + private readonly FakeTimeProvider _timeProvider; + private readonly TaskStore _store; + + public TaskStoreTests() + { + _tempRoot = Directory.CreateTempSubdirectory("nitro-task-store-tests"); + _workingDirectory = Path.Combine(_tempRoot.FullName, "acme"); + Directory.CreateDirectory(_workingDirectory); + + _timeProvider = new FakeTimeProvider( + new DateTimeOffset(2026, 1, 10, 12, 0, 0, TimeSpan.Zero)); + + _store = new TaskStore(new TestFileSystem(_workingDirectory), _timeProvider); + } + + public async ValueTask DisposeAsync() + { + await Task.CompletedTask; + _tempRoot.Delete(recursive: true); + } + + [Fact] + public async Task QueryTasksAsync_DefaultFilter_ExcludesClosedAndTombstone() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 1); + await InsertTaskAsync(connection, "acme-2", status: TaskStates.Closed, priority: 0); + await InsertTaskAsync(connection, "acme-3", status: TaskStates.Tombstone, priority: 0); + + // act + var tasks = await _store.QueryTasksAsync(new TaskFilter(), cancellationToken); + + // assert + var task = Assert.Single(tasks); + Assert.Equal("acme-1", task.Id); + } + + [Fact] + public async Task QueryTasksAsync_IncludeAll_ReturnsEverything() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + await InsertTaskAsync(connection, "acme-2", status: TaskStates.Closed, priority: 1); + + // act + var tasks = await _store.QueryTasksAsync( + new TaskFilter { IncludeAll = true }, cancellationToken); + + // assert + Assert.Equal(["acme-2", "acme-1"], tasks.Select(t => t.Id)); + } + + [Fact] + public async Task QueryTasksAsync_Labels_MatchesAllGivenLabels() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + await InsertTaskAsync(connection, "acme-2", status: TaskStates.Open, priority: 2); + await InsertLabelAsync(connection, "acme-1", "backend"); + await InsertLabelAsync(connection, "acme-1", "urgent"); + await InsertLabelAsync(connection, "acme-2", "backend"); + + // act + var tasks = await _store.QueryTasksAsync( + new TaskFilter { Labels = ["backend", "urgent"] }, cancellationToken); + + // assert + var task = Assert.Single(tasks); + Assert.Equal("acme-1", task.Id); + } + + [Fact] + public async Task QueryTasksAsync_Text_MatchesAcrossTextColumns() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2, title: "Fix the parser"); + await InsertTaskAsync(connection, "acme-2", status: TaskStates.Open, priority: 2, title: "Unrelated"); + + // act + var tasks = await _store.QueryTasksAsync( + new TaskFilter { Text = "parser" }, cancellationToken); + + // assert + var task = Assert.Single(tasks); + Assert.Equal("acme-1", task.Id); + } + + [Fact] + public async Task QueryTasksAsync_ExcludeBlocked_FiltersInMemoryAndAppliesLimitAfterward() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + await InsertTaskAsync(connection, "acme-2", status: TaskStates.Open, priority: 2); + await InsertTaskAsync(connection, "acme-3", status: TaskStates.Open, priority: 2); + await InsertDependencyAsync(connection, "acme-1", "acme-3", TaskDependencyTypes.Blocks); + + // act + var tasks = await _store.QueryTasksAsync( + new TaskFilter { ExcludeBlocked = true }, cancellationToken); + + // assert + Assert.Equal(["acme-2", "acme-3"], tasks.Select(t => t.Id)); + } + + [Fact] + public async Task GetStatsAsync_ComputesReadyAndBlockedCounts() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + await InsertTaskAsync(connection, "acme-2", status: TaskStates.Open, priority: 2); + await InsertTaskAsync(connection, "acme-3", status: TaskStates.Closed, priority: 2); + await InsertDependencyAsync(connection, "acme-1", "acme-2", TaskDependencyTypes.Blocks); + await InsertLabelAsync(connection, "acme-1", "backend"); + await InsertCommentAsync(connection, "acme-1", "note"); + + // act + var stats = await _store.GetStatsAsync(cancellationToken); + + // assert + Assert.Equal(1, stats.ReadyCount); + Assert.Equal(["acme-1"], stats.BlockedTaskStatuses.Keys); + Assert.Equal(1, stats.LabelCount); + Assert.Equal(1, stats.CommentCount); + } + + [Fact] + public async Task CountTasksByAsync_Priority_GroupsAndFormatsAsPCode() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 0); + await InsertTaskAsync(connection, "acme-2", status: TaskStates.Open, priority: 0); + await InsertTaskAsync(connection, "acme-3", status: TaskStates.Open, priority: 1); + + // act + var counts = await _store.CountTasksByAsync(TaskCountDimension.Priority, cancellationToken); + + // assert + Assert.Equal( + [new TaskCount("P0", 2), new TaskCount("P1", 1)], + counts); + } + + [Fact] + public async Task GetEpicStatusesAsync_CountsNonTombstoneChildren() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2, type: TaskTypes.Epic); + await InsertTaskAsync(connection, "acme-1.1", status: TaskStates.Closed, priority: 2); + await InsertTaskAsync(connection, "acme-1.2", status: TaskStates.Open, priority: 2); + await InsertDependencyAsync(connection, "acme-1.1", "acme-1", TaskDependencyTypes.ParentChild); + await InsertDependencyAsync(connection, "acme-1.2", "acme-1", TaskDependencyTypes.ParentChild); + + // act + var epics = await _store.GetEpicStatusesAsync(cancellationToken); + + // assert + var epic = Assert.Single(epics); + Assert.Equal("acme-1", epic.Id); + Assert.Equal(2, epic.Total); + Assert.Equal(1, epic.Closed); + Assert.False(epic.IsEligibleForClose); + } + + [Fact] + public async Task GetDependencyEdgesAsync_ReturnsEveryEdgeWithTimestamp() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + await InsertTaskAsync(connection, "acme-2", status: TaskStates.Open, priority: 2); + await InsertDependencyAsync(connection, "acme-1", "acme-2", TaskDependencyTypes.Blocks); + + // act + var edges = await _store.GetDependencyEdgesAsync(cancellationToken); + + // assert + var edge = Assert.Single(edges); + Assert.Equal("acme-1", edge.TaskId); + Assert.Equal("acme-2", edge.DependsOnId); + Assert.Equal(_timeProvider.GetUtcNow(), edge.CreatedAt); + } + + [Fact] + public async Task GetCommentsAsync_ParsesTimestampAndOrders() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + await InsertCommentAsync(connection, "acme-1", "first"); + await InsertCommentAsync(connection, "acme-1", "second"); + + // act + var comments = await _store.GetCommentsAsync("acme-1", cancellationToken); + + // assert + Assert.Equal(["first", "second"], comments.Select(c => c.Text)); + Assert.All(comments, c => Assert.Equal(_timeProvider.GetUtcNow(), c.CreatedAt)); + } + + [Fact] + public async Task GetDependenciesAndDependentsAsync_JoinTargetStatusAndTitle() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2, title: "Root"); + await InsertTaskAsync(connection, "acme-2", status: TaskStates.Closed, priority: 2, title: "Target"); + await InsertDependencyAsync(connection, "acme-1", "acme-2", TaskDependencyTypes.Blocks); + + // act + var dependencies = await _store.GetDependenciesAsync("acme-1", cancellationToken); + var dependents = await _store.GetDependentsAsync("acme-2", cancellationToken); + + // assert + var dependency = Assert.Single(dependencies); + Assert.Equal("acme-2", dependency.DependsOnId); + Assert.Equal(TaskStates.Closed, dependency.Status); + Assert.Equal("Target", dependency.Title); + + var dependent = Assert.Single(dependents); + Assert.Equal("acme-1", dependent.TaskId); + Assert.Equal(TaskStates.Open, dependent.Status); + Assert.Equal("Root", dependent.Title); + } + + [Fact] + public async Task GetLabelCountsAsync_CountsAcrossNonTombstoneTasks() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + await InsertTaskAsync(connection, "acme-2", status: TaskStates.Tombstone, priority: 2); + await InsertLabelAsync(connection, "acme-1", "backend"); + await InsertLabelAsync(connection, "acme-2", "backend"); + + // act + var counts = await _store.GetLabelCountsAsync(cancellationToken); + + // assert + Assert.Equal([new TaskLabelCount("backend", 1)], counts); + } + + [Fact] + public async Task GetPrefixAndListConfigAsync_ReadWorkspaceConfig() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + await ExecuteAsync( + connection, "INSERT INTO config (key, value) VALUES ('prefix', 'acme')"); + + // act + var prefix = await _store.GetPrefixAsync(cancellationToken); + var entries = await _store.ListConfigAsync(cancellationToken); + + // assert + Assert.Equal("acme", prefix); + Assert.Equal([new TaskConfigEntry("prefix", "acme")], entries); + } + + [Fact] + public async Task CreateTaskAsync_InsertsLabelsDependenciesAndCreatedEvent() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + + var creation = new TaskCreation + { + Title = "New task", + Priority = 1, + Type = TaskTypes.Task, + Labels = ["backend"], + DependsOn = [new TaskDependencyRequest("acme-1", TaskDependencyTypes.Blocks)], + Actor = "tester" + }; + + // act + var result = await _store.CreateTaskAsync(creation, cancellationToken); + + // assert + Assert.Equal(["acme-1"], result.BlockedBy); + + var task = await _store.GetRequiredTaskAsync(result.Id, cancellationToken); + Assert.Equal("New task", task.Title); + Assert.Equal(TaskStates.Open, task.Status); + + var labels = await _store.GetLabelsAsync(result.Id, cancellationToken); + Assert.Equal(["backend"], labels); + + var dependency = Assert.Single(await _store.GetDependenciesAsync(result.Id, cancellationToken)); + Assert.Equal("acme-1", dependency.DependsOnId); + + Assert.Equal([TaskEventTypes.Created], await QueryEventTypesAsync(connection, result.Id)); + } + + [Fact] + public async Task UpdateTaskAsync_AppliesGivenFieldsAndRecordsEvents() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + await InsertTaskAsync( + connection, "acme-1", status: TaskStates.Open, priority: 2, title: "Old title"); + + var update = new TaskUpdate + { + Actor = "tester", + Title = "New title", + TitleGiven = true, + Priority = 0, + PriorityGiven = true, + Assignee = "alice", + AssigneeGiven = true + }; + + // act + var result = await _store.UpdateTaskAsync("acme-1", update, cancellationToken); + + // assert + Assert.Equal(["title"], result.ChangedFields); + + var task = await _store.GetRequiredTaskAsync("acme-1", cancellationToken); + Assert.Equal("New title", task.Title); + Assert.Equal(0, task.Priority); + Assert.Equal("alice", task.Assignee); + + Assert.Equal( + [TaskEventTypes.PriorityChanged, TaskEventTypes.AssigneeChanged, TaskEventTypes.Updated], + await QueryEventTypesAsync(connection, "acme-1")); + } + + [Fact] + public async Task UpdateTaskAsync_SettingClosedStatus_Throws() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + + var update = new TaskUpdate + { + Actor = "tester", + Status = TaskStates.Closed, + StatusGiven = true + }; + + // act & assert + await Assert.ThrowsAsync<ExitException>( + () => _store.UpdateTaskAsync("acme-1", update, cancellationToken)); + } + + [Fact] + public async Task CloseTaskAsync_AllOrNothing_ThrowsWhenAnyIsAlreadyClosed() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + await InsertTaskAsync(connection, "acme-2", status: TaskStates.Closed, priority: 2); + + // act & assert + await Assert.ThrowsAsync<ExitException>( + () => _store.CloseTaskAsync(["acme-1", "acme-2"], "reason", "tester", cancellationToken)); + + var task = await _store.GetRequiredTaskAsync("acme-1", cancellationToken); + Assert.Equal(TaskStates.Open, task.Status); + } + + [Fact] + public async Task CloseTaskAsync_ClosesAndRecordsEvent() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + + // act + var closed = await _store.CloseTaskAsync(["acme-1"], "done", "tester", cancellationToken); + + // assert + Assert.Equal(TaskStates.Closed, Assert.Single(closed).Status); + Assert.Equal([TaskEventTypes.Closed], await QueryEventTypesAsync(connection, "acme-1")); + } + + [Fact] + public async Task ReopenTaskAsync_ThrowsWhenNotClosed() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + + // act & assert + await Assert.ThrowsAsync<ExitException>( + () => _store.ReopenTaskAsync("acme-1", "", "tester", cancellationToken)); + } + + [Fact] + public async Task ReopenTaskAsync_ReopensAndRecordsEvent() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Closed, priority: 2); + + // act + var task = await _store.ReopenTaskAsync("acme-1", "", "tester", cancellationToken); + + // assert + Assert.Equal(TaskStates.Open, task.Status); + Assert.Equal([TaskEventTypes.Reopened], await QueryEventTypesAsync(connection, "acme-1")); + } + + [Fact] + public async Task DeferTaskAsync_ThrowsWhenNotOpenOrInProgress() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Closed, priority: 2); + + // act & assert + await Assert.ThrowsAsync<ExitException>( + () => _store.DeferTaskAsync( + "acme-1", _timeProvider.GetUtcNow().AddDays(1), "tester", cancellationToken)); + } + + [Fact] + public async Task DeferTaskAsync_DefersAndRecordsEvent() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + var until = _timeProvider.GetUtcNow().AddDays(1); + + // act + var task = await _store.DeferTaskAsync("acme-1", until, "tester", cancellationToken); + + // assert + Assert.Equal(TaskStates.Deferred, task.Status); + Assert.Equal(until, task.DeferUntil); + Assert.Equal([TaskEventTypes.Deferred], await QueryEventTypesAsync(connection, "acme-1")); + } + + [Fact] + public async Task UndeferTaskAsync_ThrowsWhenNotDeferred() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + + // act & assert + await Assert.ThrowsAsync<ExitException>( + () => _store.UndeferTaskAsync("acme-1", "tester", cancellationToken)); + } + + [Fact] + public async Task DeleteTaskAsync_TombstonesAndRecordsEvent() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + + // act + var task = await _store.DeleteTaskAsync("acme-1", "no longer needed", "tester", cancellationToken); + + // assert + Assert.Equal(TaskStates.Tombstone, task.Status); + Assert.Equal([TaskEventTypes.Deleted], await QueryEventTypesAsync(connection, "acme-1")); + } + + [Fact] + public async Task CloseEligibleEpicsAsync_ClosesOnlyEpicsWithAllChildrenClosed() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2, type: TaskTypes.Epic); + await InsertTaskAsync(connection, "acme-1.1", status: TaskStates.Closed, priority: 2); + await InsertDependencyAsync(connection, "acme-1.1", "acme-1", TaskDependencyTypes.ParentChild); + await InsertTaskAsync(connection, "acme-2", status: TaskStates.Open, priority: 2, type: TaskTypes.Epic); + await InsertTaskAsync(connection, "acme-2.1", status: TaskStates.Open, priority: 2); + await InsertDependencyAsync(connection, "acme-2.1", "acme-2", TaskDependencyTypes.ParentChild); + + // act + var closed = await _store.CloseEligibleEpicsAsync("tester", cancellationToken); + + // assert + var epic = Assert.Single(closed); + Assert.Equal("acme-1", epic.Id); + Assert.Equal(TaskStates.Closed, epic.Status); + + var task = await _store.GetRequiredTaskAsync("acme-1", cancellationToken); + Assert.Equal(TaskStates.Closed, task.Status); + var untouched = await _store.GetRequiredTaskAsync("acme-2", cancellationToken); + Assert.Equal(TaskStates.Open, untouched.Status); + } + + [Fact] + public async Task AddCommentAsync_InsertsCommentAndBumpsUpdatedAt() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + + // act + var comment = await _store.AddCommentAsync("acme-1", "Looks good.", "tester", cancellationToken); + + // assert + Assert.Equal("Looks good.", comment.Text); + Assert.Equal("tester", comment.Author); + + var comments = await _store.GetCommentsAsync("acme-1", cancellationToken); + Assert.Equal(["Looks good."], comments.Select(c => c.Text)); + Assert.Equal([TaskEventTypes.Commented], await QueryEventTypesAsync(connection, "acme-1")); + } + + [Fact] + public async Task AddCommentAsync_ThrowsOnEmptyText() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + + // act & assert + await Assert.ThrowsAsync<ExitException>( + () => _store.AddCommentAsync("acme-1", " ", "tester", cancellationToken)); + } + + [Fact] + public async Task AddLabelAsync_OnlyBumpsUpdatedAtWhenALabelIsNewlyAdded() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + await InsertLabelAsync(connection, "acme-1", "backend"); + + // act + var results = await _store.AddLabelAsync( + "acme-1", ["backend", "urgent"], "tester", cancellationToken); + + // assert + Assert.Equal( + [new TaskLabelChange("backend", false), new TaskLabelChange("urgent", true)], + results); + Assert.Equal([TaskEventTypes.LabelAdded], await QueryEventTypesAsync(connection, "acme-1")); + } + + [Fact] + public async Task RemoveLabelAsync_ThrowsWhenLabelIsAbsent() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + + // act & assert + await Assert.ThrowsAsync<ExitException>( + () => _store.RemoveLabelAsync("acme-1", "backend", "tester", cancellationToken)); + } + + [Fact] + public async Task RemoveLabelAsync_RemovesAndRecordsEvent() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + await InsertLabelAsync(connection, "acme-1", "backend"); + + // act + await _store.RemoveLabelAsync("acme-1", "backend", "tester", cancellationToken); + + // assert + Assert.Empty(await _store.GetLabelsAsync("acme-1", cancellationToken)); + Assert.Equal([TaskEventTypes.LabelRemoved], await QueryEventTypesAsync(connection, "acme-1")); + } + + [Fact] + public async Task AddDependencyAsync_ThrowsWhenDependencyAlreadyExists() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + await InsertTaskAsync(connection, "acme-2", status: TaskStates.Open, priority: 2); + await InsertDependencyAsync(connection, "acme-1", "acme-2", TaskDependencyTypes.Blocks); + + // act & assert + await Assert.ThrowsAsync<ExitException>( + () => _store.AddDependencyAsync( + "acme-1", "acme-2", TaskDependencyTypes.Blocks, "tester", cancellationToken)); + } + + [Fact] + public async Task AddDependencyAsync_DetectsBlockingCycle_RejectsBeforeCommit() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + await InsertTaskAsync(connection, "acme-2", status: TaskStates.Open, priority: 2); + await InsertDependencyAsync(connection, "acme-2", "acme-1", TaskDependencyTypes.Blocks); + + // act + var exception = await Assert.ThrowsAsync<ExitException>( + () => _store.AddDependencyAsync( + "acme-1", "acme-2", TaskDependencyTypes.Blocks, "tester", cancellationToken)); + + // assert + Assert.Equal( + "Adding this dependency would create a cycle: acme-1 -> acme-2 -> acme-1.", + exception.Message); + Assert.Empty(await _store.GetDependenciesAsync("acme-1", cancellationToken)); + Assert.Empty(await QueryEventTypesAsync(connection, "acme-1")); + } + + [Fact] + public async Task AddDependencyAsync_InsertsAndRecordsEventWhenNoCycle() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + await InsertTaskAsync(connection, "acme-2", status: TaskStates.Open, priority: 2); + + // act + var result = await _store.AddDependencyAsync( + "acme-1", "acme-2", TaskDependencyTypes.Blocks, "tester", cancellationToken); + + // assert + Assert.Null(result.Cycle); + var dependency = Assert.Single(await _store.GetDependenciesAsync("acme-1", cancellationToken)); + Assert.Equal("acme-2", dependency.DependsOnId); + Assert.Equal( + [TaskEventTypes.DependencyAdded], await QueryEventTypesAsync(connection, "acme-1")); + } + + [Fact] + public async Task RemoveDependencyAsync_ThrowsWhenDependencyDoesNotExist() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + + // act & assert + await Assert.ThrowsAsync<ExitException>( + () => _store.RemoveDependencyAsync("acme-1", "acme-2", "tester", cancellationToken)); + } + + [Fact] + public async Task RemoveDependencyAsync_RemovesAndRecordsEvent() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + await InsertTaskAsync(connection, "acme-1", status: TaskStates.Open, priority: 2); + await InsertTaskAsync(connection, "acme-2", status: TaskStates.Open, priority: 2); + await InsertDependencyAsync(connection, "acme-1", "acme-2", TaskDependencyTypes.Blocks); + + // act + await _store.RemoveDependencyAsync("acme-1", "acme-2", "tester", cancellationToken); + + // assert + Assert.Empty(await _store.GetDependenciesAsync("acme-1", cancellationToken)); + Assert.Equal( + [TaskEventTypes.DependencyRemoved], await QueryEventTypesAsync(connection, "acme-1")); + } + + [Fact] + public async Task ImportTasksAsync_CrossCloneCommentIdCollision_KeepsBothComments() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await SeedAsync(cancellationToken); + var now = _timeProvider.GetUtcNow(); + + // Two clones each independently assigned id 1 to a comment on a + // different task; merging their exports produces two records that + // collide on the same comments.id. + var records = new List<TaskSyncRecord> + { + NewTaskSyncRecord( + "acme-1", + now, + comments: [NewTaskSyncComment(1, "from-clone-a", now)]), + NewTaskSyncRecord( + "acme-2", + now, + comments: [NewTaskSyncComment(1, "from-clone-b", now)]) + }; + + // act + var result = await _store.ImportTasksAsync(records, cancellationToken); + + // assert + Assert.Equal(2, result.Applied); + Assert.Equal(0, result.Skipped); + + var commentsOnAcme1 = await _store.GetCommentsAsync("acme-1", cancellationToken); + var commentsOnAcme2 = await _store.GetCommentsAsync("acme-2", cancellationToken); + + Assert.Equal(["from-clone-a"], commentsOnAcme1.Select(c => c.Text)); + Assert.Equal(["from-clone-b"], commentsOnAcme2.Select(c => c.Text)); + } + + [Fact] + public async Task ImportTasksAsync_ReimportingSameRecord_PreservesCommentId() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await SeedAsync(cancellationToken); + var now = _timeProvider.GetUtcNow(); + var record = NewTaskSyncRecord( + "acme-1", + now, + comments: [NewTaskSyncComment(7, "note", now)]); + + await _store.ImportTasksAsync([record], cancellationToken); + + // act: re-importing the exact same record must not be treated as a + // collision against its own previously-imported comment. + var result = await _store.ImportTasksAsync([record], cancellationToken); + + // assert + Assert.Equal(1, result.Applied); + var comments = await _store.GetCommentsAsync("acme-1", cancellationToken); + var comment = Assert.Single(comments); + Assert.Equal("note", comment.Text); + Assert.Equal(7, comment.Id); + } + + private static TaskSyncRecord NewTaskSyncRecord( + string id, + DateTimeOffset now, + IReadOnlyList<TaskSyncComment>? comments = null) => + new() + { + Id = id, + Title = "Task", + Status = TaskStates.Open, + Type = TaskTypes.Task, + CreatedAt = now, + UpdatedAt = now, + Comments = comments ?? [] + }; + + private static TaskSyncComment NewTaskSyncComment(long id, string author, DateTimeOffset now) => + new() + { + Id = id, + Author = author, + Text = author, + CreatedAt = now + }; + + [Fact] + public async Task SetConfigAsync_UpsertsValue() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + await using var connection = await SeedAsync(cancellationToken); + + // act + await _store.SetConfigAsync("prefix", "acme", cancellationToken); + await _store.SetConfigAsync("prefix", "acme2", cancellationToken); + + // assert + Assert.Equal("acme2", await _store.GetConfigAsync("prefix", cancellationToken)); + } + + [Fact] + public async Task InitializeWorkspaceAsync_AppliesSchemaAndSetsPrefix() + { + // arrange + var cancellationToken = TestContext.Current.CancellationToken; + var freshRoot = Path.Combine(_tempRoot.FullName, "fresh"); + Directory.CreateDirectory(freshRoot); + var store = new TaskStore(new TestFileSystem(freshRoot), _timeProvider); + var workspaceDirectory = TaskWorkspace.GetDirectory(freshRoot); + Directory.CreateDirectory(workspaceDirectory); + + // act + await store.InitializeWorkspaceAsync(workspaceDirectory, "fresh", cancellationToken); + + // assert + Assert.Equal("fresh", await store.GetPrefixAsync(cancellationToken)); + } + + /// <summary> + /// Returns the event_type column for every audit-log row on a task, + /// ordered by id, via plain ADO.NET so the test does not need its own + /// Dapper.AOT-compatible call shape. + /// </summary> + private static async Task<List<string>> QueryEventTypesAsync(SqliteConnection connection, string taskId) + { + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT event_type FROM events WHERE task_id = @taskId ORDER BY id"; + command.Parameters.AddWithValue("@taskId", taskId); + + var types = new List<string>(); + await using var reader = await command.ExecuteReaderAsync(); + + while (await reader.ReadAsync()) + { + types.Add(reader.GetString(0)); + } + + return types; + } + + private async Task<SqliteConnection> SeedAsync(CancellationToken cancellationToken) + { + var workspaceDirectory = TaskWorkspace.GetDirectory(_workingDirectory); + Directory.CreateDirectory(workspaceDirectory); + + return await _store.InitializeAsync(workspaceDirectory, cancellationToken); + } + + private Task InsertTaskAsync( + SqliteConnection connection, + string id, + string status, + int priority, + string title = "Task", + string type = TaskTypes.Task) + { + var now = _timeProvider.GetUtcNow(); + + return ExecuteAsync( + connection, + """ + INSERT INTO tasks (id, title, status, priority, task_type, created_at, updated_at) + VALUES (@id, @title, @status, @priority, @type, @now, @now) + """, + ("@id", id), ("@title", title), ("@status", status), + ("@priority", priority), ("@type", type), ("@now", now)); + } + + private Task InsertLabelAsync(SqliteConnection connection, string taskId, string label) + => ExecuteAsync( + connection, + "INSERT INTO labels (task_id, label) VALUES (@taskId, @label)", + ("@taskId", taskId), ("@label", label)); + + private Task InsertDependencyAsync( + SqliteConnection connection, + string taskId, + string dependsOnId, + string type) + { + var now = _timeProvider.GetUtcNow(); + + return ExecuteAsync( + connection, + """ + INSERT INTO dependencies (task_id, depends_on_id, dependency_type, created_at) + VALUES (@taskId, @dependsOnId, @type, @now) + """, + ("@taskId", taskId), ("@dependsOnId", dependsOnId), ("@type", type), ("@now", now)); + } + + private Task InsertCommentAsync(SqliteConnection connection, string taskId, string text) + { + var now = _timeProvider.GetUtcNow(); + + return ExecuteAsync( + connection, + """ + INSERT INTO comments (task_id, author, text, created_at) + VALUES (@taskId, 'test-agent', @text, @now) + """, + ("@taskId", taskId), ("@text", text), ("@now", now)); + } + + /// <summary> + /// Runs a parameterized statement via plain ADO.NET, sidestepping + /// Dapper.AOT's interceptor so the test project does not need its own + /// AOT-compatible call shapes. + /// </summary> + private static async Task ExecuteAsync( + SqliteConnection connection, + string sql, + params (string Name, object Value)[] parameters) + { + await using var command = connection.CreateCommand(); + command.CommandText = sql; + + foreach (var (name, value) in parameters) + { + command.Parameters.AddWithValue(name, value); + } + + await command.ExecuteNonQueryAsync(); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/TasksCommandTestBase.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/TasksCommandTestBase.cs new file mode 100644 index 00000000000..e43bea3ef36 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/TasksCommandTestBase.cs @@ -0,0 +1,106 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using Microsoft.Data.Sqlite; + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +/// <summary> +/// Runs task commands against a real SQLite workspace in a per-test temp +/// directory named "acme", so the derived task ID prefix is deterministic. +/// </summary> +public abstract class TasksCommandTestBase : CommandTestBase +{ + private readonly DirectoryInfo _tempRoot; + + protected TasksCommandTestBase(NitroCommandFixture fixture) : base(fixture) + { + SetupNoAuthentication(); + SetupEnvironmentVariable(TaskActor.EnvironmentVariableName, "test-agent"); + + _tempRoot = Directory.CreateTempSubdirectory("nitro-task-tests"); + WorkingDirectory = Path.Combine(_tempRoot.FullName, "acme"); + Directory.CreateDirectory(WorkingDirectory); + SetupFileSystem(new TestFileSystem(WorkingDirectory)); + } + + protected string WorkingDirectory { get; } + + protected string WorkspaceDirectory + => TaskWorkspace.GetDirectory(WorkingDirectory); + + protected string DatabasePath + => TaskWorkspace.GetDatabasePath(WorkspaceDirectory); + + protected async Task InitWorkspaceAsync() + { + var result = await ExecuteCommandAsync("agent", "tasks", "init"); + Assert.Equal(0, result.ExitCode); + } + + /// <summary> + /// Creates a task via the create command, passing the given arguments + /// after "agent tasks create", and returns the generated task ID. + /// </summary> + protected async Task<string> CreateTaskAsync(params string[] args) + { + var result = await ExecuteCommandAsync(["agent", "tasks", "create", .. args]); + Assert.Equal(0, result.ExitCode); + + var start = result.StdOut.IndexOf('\'') + 1; + var end = result.StdOut.IndexOf('\'', start); + Assert.True(end > start, $"Expected a created task ID in: {result.StdOut}"); + + return result.StdOut[start..end]; + } + + /// <summary> + /// Runs a scalar query against the workspace database and returns the + /// first column of the first row as a string. + /// </summary> + protected async Task<string?> QueryScalarAsync(string sql) + { + var cancellationToken = TestContext.Current.CancellationToken; + + await using var connection = + new SqliteConnection($"Data Source={DatabasePath};Pooling=False"); + await connection.OpenAsync(cancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = sql; + + var result = await command.ExecuteScalarAsync(cancellationToken); + + return result is null or DBNull ? null : result.ToString(); + } + + /// <summary> + /// Inserts a dependency edge directly into the workspace database, + /// bypassing ITaskStore's cycle rejection. Used to seed a cycle that + /// reached the database some other way (a stale sync import, a manual + /// edit) so cycle-detection commands have something to find. + /// </summary> + protected async Task InsertDependencyAsync(string taskId, string dependsOnId, string type = "blocks") + { + var cancellationToken = TestContext.Current.CancellationToken; + + await using var connection = + new SqliteConnection($"Data Source={DatabasePath};Pooling=False"); + await connection.OpenAsync(cancellationToken); + + await using var command = connection.CreateCommand(); + command.CommandText = + "INSERT INTO dependencies (task_id, depends_on_id, dependency_type, created_at) " + + "VALUES (@taskId, @dependsOnId, @type, @now)"; + command.Parameters.AddWithValue("@taskId", taskId); + command.Parameters.AddWithValue("@dependsOnId", dependsOnId); + command.Parameters.AddWithValue("@type", type); + command.Parameters.AddWithValue("@now", DateTimeOffset.UtcNow); + + await command.ExecuteNonQueryAsync(cancellationToken); + } + + public override async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + _tempRoot.Delete(recursive: true); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/TestFileSystem.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/TestFileSystem.cs new file mode 100644 index 00000000000..84bc1215e07 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/TestFileSystem.cs @@ -0,0 +1,47 @@ +using ChilliCream.Nitro.CommandLine.Services; + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +/// <summary> +/// A real file system rooted at a fixed current directory, for tests that run +/// commands against actual files in a temp directory. +/// </summary> +internal sealed class TestFileSystem(string currentDirectory) : IFileSystem +{ + private readonly FileSystem _inner = new(); + + public string GetCurrentDirectory() => currentDirectory; + + public bool FileExists(string path) => _inner.FileExists(path); + + public Stream OpenReadStream(string path) => _inner.OpenReadStream(path); + + public Task<byte[]> ReadAllBytesAsync(string path, CancellationToken ct) + => _inner.ReadAllBytesAsync(path, ct); + + public Task<string> ReadAllTextAsync(string path, CancellationToken ct) + => _inner.ReadAllTextAsync(path, ct); + + public Stream CreateFile(string path) => _inner.CreateFile(path); + + public Task WriteAllTextAsync(string path, string content, CancellationToken ct) + => _inner.WriteAllTextAsync(path, content, ct); + + public void DeleteFile(string path) => _inner.DeleteFile(path); + + public bool DirectoryExists(string path) => _inner.DirectoryExists(path); + + public void CreateDirectory(string path) => _inner.CreateDirectory(path); + + public IEnumerable<string> GetFiles( + string directory, + string pattern, + SearchOption searchOption) + => _inner.GetFiles(directory, pattern, searchOption); + + public IEnumerable<string> GlobMatch( + IEnumerable<string> patterns, + IEnumerable<string>? excludes = null, + string? workingDirectory = null) + => _inner.GlobMatch(patterns, excludes, workingDirectory ?? currentDirectory); +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/TreeTaskDependencyCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/TreeTaskDependencyCommandTests.cs new file mode 100644 index 00000000000..77608355185 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/TreeTaskDependencyCommandTests.cs @@ -0,0 +1,180 @@ + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class TreeTaskDependencyCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "tree", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Show a task's outgoing dependency tree. + + Usage: + nitro agent tasks dep tree <id> [options] + + Arguments: + <id> The task ID + + Options: + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks dep tree "acme-1a2" + """); + } + + [Fact] + public async Task NoDependencies_PrintsRootOnly() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "tree", id); + + // assert + result.AssertSuccess($"{id} (open) Fix the parser"); + } + + [Fact] + public async Task Chain_PrintsNestedIndentation() + { + // arrange + await InitWorkspaceAsync(); + var root = await CreateTaskAsync("Root task"); + var child = await CreateTaskAsync("Child task"); + var grandchild = await CreateTaskAsync("Grandchild task"); + await ExecuteCommandAsync("agent", "tasks", "dep", "add", root, child); + await ExecuteCommandAsync("agent", "tasks", "dep", "add", child, grandchild); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "tree", root); + + // assert + result.AssertSuccess( + $""" + {root} (open) Root task + {child} (blocks, open) Child task + {grandchild} (blocks, open) Grandchild task + """); + } + + [Fact] + public async Task RepeatedNode_MarkedWithAsteriskAndNotExpanded() + { + // arrange + await InitWorkspaceAsync(); + var root = await CreateTaskAsync("Root task"); + var left = await CreateTaskAsync("Left branch"); + var right = await CreateTaskAsync("Right branch"); + var shared = await CreateTaskAsync("Shared dependency"); + await ExecuteCommandAsync("agent", "tasks", "dep", "add", root, left); + await ExecuteCommandAsync("agent", "tasks", "dep", "add", root, right); + await ExecuteCommandAsync("agent", "tasks", "dep", "add", left, shared); + await ExecuteCommandAsync("agent", "tasks", "dep", "add", right, shared); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "tree", root); + + // assert + // Children of the root are sorted by (type, depends-on-id); both edges + // are type "blocks", so the ordinally smaller ID comes first. + var firstIsLeft = string.CompareOrdinal(left, right) < 0; + var first = firstIsLeft ? left : right; + var firstTitle = firstIsLeft ? "Left branch" : "Right branch"; + var second = firstIsLeft ? right : left; + var secondTitle = firstIsLeft ? "Right branch" : "Left branch"; + + result.AssertSuccess( + $""" + {root} (open) Root task + {first} (blocks, open) {firstTitle} + {shared} (blocks, open) Shared dependency + {second} (blocks, open) {secondTitle} + {shared} (blocks, open) Shared dependency * + """); + } + + [Fact] + public async Task TaskNotFound_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "tree", "acme-999"); + + // assert + result.AssertError("Task 'acme-999' does not exist."); + } + + [Fact] + public async Task JsonOutput_Chain_ReturnsNestedTree() + { + // arrange + await InitWorkspaceAsync(); + var root = await CreateTaskAsync("Root task"); + var child = await CreateTaskAsync("Child task"); + await ExecuteCommandAsync("agent", "tasks", "dep", "add", root, child); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "tree", root); + + // assert + result.AssertSuccess( + $$""" + { + "id": "{{root}}", + "type": null, + "status": "open", + "title": "Root task", + "repeated": false, + "children": [ + { + "id": "{{child}}", + "type": "blocks", + "status": "open", + "title": "Child task", + "repeated": false, + "children": [] + } + ] + } + """); + } + + [Fact] + public async Task JsonOutput_NoDependencies_ReturnsRootOnly() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "dep", "tree", id); + + // assert + result.AssertSuccess( + $$""" + { + "id": "{{id}}", + "type": null, + "status": "open", + "title": "Fix the parser", + "repeated": false, + "children": [] + } + """); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/UndeferTaskCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/UndeferTaskCommandTests.cs new file mode 100644 index 00000000000..dafd232f016 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/UndeferTaskCommandTests.cs @@ -0,0 +1,118 @@ + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class UndeferTaskCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "undefer", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Make a deferred task ready again. + + Usage: + nitro agent tasks undefer <id> [options] + + Arguments: + <id> The task ID + + Options: + --actor <actor> The acting identity recorded on the audit log (defaults to NITRO_TASK_ACTOR or the OS user name) + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks undefer "acme-1a2" + """); + } + + [Fact] + public async Task DeferredTask_UndefersSuccessfully() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + await ExecuteCommandAsync("agent", "tasks", "defer", id, "--until", "2026-02-01"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "undefer", id); + + // assert + result.AssertSuccess($"โœ“ Undeferred task '{id}'."); + Assert.Equal("open", await QueryScalarAsync( + $"SELECT status FROM tasks WHERE id = '{id}'")); + Assert.Null(await QueryScalarAsync( + $"SELECT defer_until FROM tasks WHERE id = '{id}'")); + } + + [Fact] + public async Task JsonOutput_ReturnsUndeferredTaskSnapshot() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + await ExecuteCommandAsync("agent", "tasks", "defer", id, "--until", "2026-02-01"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "undefer", id); + + // assert + using var document = System.Text.Json.JsonDocument.Parse(result.StdOut); + var root = document.RootElement; + + Assert.Empty(result.StdErr); + Assert.Equal(0, result.ExitCode); + Assert.Equal(id, root.GetProperty("id").GetString()); + Assert.Equal("open", root.GetProperty("status").GetString()); + Assert.Equal(System.Text.Json.JsonValueKind.Null, root.GetProperty("deferUntil").ValueKind); + } + + [Fact] + public async Task OpenTask_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "undefer", id); + + // assert + result.AssertError($"Task '{id}' is not deferred."); + } + + [Fact] + public async Task ClosedTask_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + await ExecuteCommandAsync("agent", "tasks", "close", id); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "undefer", id); + + // assert + result.AssertError($"Task '{id}' is not deferred."); + } + + [Fact] + public async Task TaskNotFound_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "undefer", "acme-999"); + + // assert + result.AssertError("Task 'acme-999' does not exist."); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/UpdateTaskCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/UpdateTaskCommandTests.cs new file mode 100644 index 00000000000..395a00f2480 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/UpdateTaskCommandTests.cs @@ -0,0 +1,654 @@ +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class UpdateTaskCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "update", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Update one or more tasks' fields. + + Usage: + nitro agent tasks update <ids>... [options] + + Arguments: + <ids> One or more task IDs + + Options: + --title <title> The task title + --description <description> The task description + --status <status> The task status (open, in_progress, blocked, deferred, closed, or custom) + --priority <priority> The task priority, 0-4 or p0-p4 (0 = critical, 4 = backlog); list/ready also accept a range like 0-1 or p0-p1 + --type <type> The task type (task, bug, feature, epic, chore, docs, question, or custom) + --assignee <assignee> The assignee + --notes <notes> The task notes + --design <design> The task design + --acceptance-criteria <acceptance-criteria> The acceptance criteria + --due <due> The due date as an ISO 8601 date or timestamp + --defer-until <defer-until> Hide the task from ready work until this ISO 8601 date or timestamp + --estimate <estimate> The estimated effort in minutes + --add-label <add-label> A label to add; can be used multiple times + --remove-label <remove-label> A label to remove; can be used multiple times + --parent <parent> The parent task ID; the new task becomes its child + --claim Shorthand for --status in_progress --assignee <actor> + --actor <actor> The acting identity recorded on the audit log (defaults to NITRO_TASK_ACTOR or the OS user name) + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks update "app-1a2" --status in_progress + nitro agent tasks update "app-1a2" --priority p1 --assignee alice + nitro agent tasks update "app-1a2" "app-9z8" --priority p1 + nitro agent tasks update "app-1a2" --claim + nitro agent tasks update "app-1a2" --add-label api --remove-label triage + nitro agent tasks update "app-1a2" --parent "app-9z8" + """); + } + + [Fact] + public async Task NoOptionsGiven_ReturnsError() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "update", "acme-1a2"); + + // assert + result.AssertError( + """ + Nothing to update. Pass at least one option. + """); + } + + [Fact] + public async Task TaskNotFound_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "update", "acme-9z9", "--title", "New title"); + + // assert + result.AssertError( + """ + Task 'acme-9z9' does not exist. + """); + } + + [Fact] + public async Task EmptyTitle_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var createResult = await ExecuteCommandAsync("agent", "tasks", "create", "Fix the parser"); + var id = createResult.StdOut.Split('\'')[1]; + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "update", id, "--title", ""); + + // assert + result.AssertError( + """ + The title must be 1-500 characters. + """); + } + + [Fact] + public async Task WhitespaceOnlyTitle_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var createResult = await ExecuteCommandAsync("agent", "tasks", "create", "Fix the parser"); + var id = createResult.StdOut.Split('\'')[1]; + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "update", id, "--title", " "); + + // assert + result.AssertError( + """ + The title must be 1-500 characters. + """); + } + + [Fact] + public async Task SimpleFields_UpdatesTask_RecordsUpdatedEvent() + { + // arrange + await InitWorkspaceAsync(); + var createResult = await ExecuteCommandAsync("agent", "tasks", "create", "Fix the parser"); + var id = createResult.StdOut.Split('\'')[1]; + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "update", id, + "--title", "Fix the parser properly", + "--description", "Investigate the tokenizer", + "--type", "bug", + "--notes", "Talked to Alice", + "--design", "Rewrite the lexer", + "--acceptance-criteria", "All tests green"); + + // assert + result.AssertSuccess( + $""" + โœ“ Updated task '{id}'. + """); + Assert.Equal( + "Fix the parser properly|Investigate the tokenizer|bug|Talked to Alice|" + + "Rewrite the lexer|All tests green", + await QueryScalarAsync( + $""" + SELECT title || '|' || description || '|' || task_type || '|' || notes || '|' + || design || '|' || acceptance_criteria + FROM tasks WHERE id = '{id}' + """)); + Assert.Equal( + "title, description, task_type, notes, design, acceptance_criteria", + await QueryScalarAsync( + $"SELECT comment FROM events WHERE task_id = '{id}' AND event_type = 'updated'")); + } + + [Fact] + public async Task StatusPriorityAssignee_RecordsTypedEvents() + { + // arrange + await InitWorkspaceAsync(); + var createResult = await ExecuteCommandAsync("agent", "tasks", "create", "Fix the parser"); + var id = createResult.StdOut.Split('\'')[1]; + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "update", id, + "--status", "in_progress", + "--priority", "p1", + "--assignee", "alice"); + + // assert + result.AssertSuccess( + $""" + โœ“ Updated task '{id}'. + """); + Assert.Equal( + "status_changed:open->in_progress; priority_changed:P2->P1; assignee_changed:->alice", + await QueryScalarAsync( + $""" + SELECT group_concat(entry, '; ') FROM ( + SELECT event_type || ':' || COALESCE(old_value, '') || '->' + || COALESCE(new_value, '') AS entry + FROM events + WHERE task_id = '{id}' + AND event_type IN ('status_changed', 'priority_changed', 'assignee_changed') + ORDER BY id + ) + """)); + } + + [Fact] + public async Task EmptyAssignee_ClearsAssignee() + { + // arrange + await InitWorkspaceAsync(); + var createResult = await ExecuteCommandAsync( + "agent", "tasks", "create", "Fix the parser", "--assignee", "alice"); + var id = createResult.StdOut.Split('\'')[1]; + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "update", id, "--assignee", ""); + + // assert + result.AssertSuccess( + $""" + โœ“ Updated task '{id}'. + """); + Assert.Equal( + "1", + await QueryScalarAsync($"SELECT assignee IS NULL FROM tasks WHERE id = '{id}'")); + } + + [Fact] + public async Task JsonOutput_ReturnsUpdatedTaskSnapshot() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "update", id, "--title", "Fix the parser properly", "--priority", "p1"); + + // assert + using var document = System.Text.Json.JsonDocument.Parse(result.StdOut); + var items = document.RootElement.GetProperty("items"); + + Assert.Empty(result.StdErr); + Assert.Equal(0, result.ExitCode); + Assert.Equal(1, items.GetArrayLength()); + Assert.Equal(id, items[0].GetProperty("id").GetString()); + Assert.Equal("Fix the parser properly", items[0].GetProperty("title").GetString()); + Assert.Equal(1, items[0].GetProperty("priority").GetInt32()); + } + + [Fact] + public async Task JsonOutput_MultipleTasks_ReturnsAllSnapshots() + { + // arrange + await InitWorkspaceAsync(); + var id1 = await CreateTaskAsync("Fix the parser"); + var id2 = await CreateTaskAsync("Write the docs"); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "update", id1, id2, "--priority", "p1"); + + // assert + using var document = System.Text.Json.JsonDocument.Parse(result.StdOut); + var items = document.RootElement.GetProperty("items"); + + Assert.Empty(result.StdErr); + Assert.Equal(0, result.ExitCode); + Assert.Equal(2, items.GetArrayLength()); + Assert.Equal(id1, items[0].GetProperty("id").GetString()); + Assert.Equal(id2, items[1].GetProperty("id").GetString()); + Assert.Equal(1, items[0].GetProperty("priority").GetInt32()); + Assert.Equal(1, items[1].GetProperty("priority").GetInt32()); + } + + [Fact] + public async Task MultipleTasks_UpdatesAllTasks() + { + // arrange + await InitWorkspaceAsync(); + var id1 = await CreateTaskAsync("Fix the parser"); + var id2 = await CreateTaskAsync("Write the docs"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "update", id1, id2, "--priority", "p1"); + + // assert + result.AssertSuccess( + $""" + โœ“ Updated task '{id1}'. + โœ“ Updated task '{id2}'. + """); + Assert.Equal( + "1|1", + await QueryScalarAsync( + $""" + SELECT group_concat(priority, '|') FROM ( + SELECT priority FROM tasks WHERE id IN ('{id1}', '{id2}') ORDER BY id + ) + """)); + } + + [Fact] + public async Task AtomicFailure_OneBadId_UpdatesNothing() + { + // arrange + await InitWorkspaceAsync(); + var id1 = await CreateTaskAsync("Fix the parser"); + var id2 = await CreateTaskAsync("Write the docs"); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "update", id1, "acme-999", id2, "--priority", "p1"); + + // assert + result.AssertError("Task 'acme-999' does not exist."); + Assert.Equal( + "2|2", + await QueryScalarAsync( + $""" + SELECT group_concat(priority, '|') FROM ( + SELECT priority FROM tasks WHERE id IN ('{id1}', '{id2}') ORDER BY id + ) + """)); + Assert.Equal( + "0", + await QueryScalarAsync( + $""" + SELECT COUNT(*) FROM events + WHERE task_id IN ('{id1}', '{id2}') AND event_type = 'priority_changed' + """)); + } + + [Fact] + public async Task Claim_SetsStatusInProgressAndAssigneeToActor() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "update", id, "--claim"); + + // assert + result.AssertSuccess( + $""" + โœ“ Updated task '{id}'. + """); + Assert.Equal( + "in_progress|test-agent", + await QueryScalarAsync( + $"SELECT status || '|' || assignee FROM tasks WHERE id = '{id}'")); + } + + [Fact] + public async Task Claim_ComposesWithOtherOptions() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "update", id, "--claim", "--priority", "p1"); + + // assert + result.AssertSuccess( + $""" + โœ“ Updated task '{id}'. + """); + Assert.Equal( + "in_progress|test-agent|1", + await QueryScalarAsync( + $"SELECT status || '|' || assignee || '|' || priority FROM tasks WHERE id = '{id}'")); + } + + [Fact] + public async Task Claim_ExplicitAssigneeWins() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "update", id, "--claim", "--assignee", "alice"); + + // assert + result.AssertSuccess( + $""" + โœ“ Updated task '{id}'. + """); + Assert.Equal( + "in_progress|alice", + await QueryScalarAsync( + $"SELECT status || '|' || assignee FROM tasks WHERE id = '{id}'")); + } + + [Fact] + public async Task AddLabelRemoveLabel_UpdatesLabelsAndRecordsEvents() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser", "--label", "triage"); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "update", id, "--add-label", "api", "--remove-label", "triage"); + + // assert + result.AssertSuccess( + $""" + โœ“ Updated task '{id}'. + """); + Assert.Equal( + "api", + await QueryScalarAsync( + $"SELECT group_concat(label, ',') FROM labels WHERE task_id = '{id}'")); + Assert.Equal( + "label_added:api;label_removed:triage", + await QueryScalarAsync( + $""" + SELECT group_concat(entry, ';') FROM ( + SELECT event_type || ':' || COALESCE(new_value, old_value) AS entry + FROM events + WHERE task_id = '{id}' AND event_type IN ('label_added', 'label_removed') + ORDER BY id + ) + """)); + } + + [Fact] + public async Task AddLabelOnly_SatisfiesNothingToUpdateGuard() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "update", id, "--add-label", "api"); + + // assert + result.AssertSuccess( + $""" + โœ“ Updated task '{id}'. + """); + Assert.Equal( + "api", + await QueryScalarAsync( + $"SELECT group_concat(label, ',') FROM labels WHERE task_id = '{id}'")); + } + + [Fact] + public async Task Parent_SetsParentChildEdge() + { + // arrange + await InitWorkspaceAsync(); + var parentId = await CreateTaskAsync("Epic"); + var childId = await CreateTaskAsync("Child"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "update", childId, "--parent", parentId); + + // assert + result.AssertSuccess( + $""" + โœ“ Updated task '{childId}'. + """); + Assert.Equal( + parentId, + await QueryScalarAsync( + $""" + SELECT depends_on_id FROM dependencies + WHERE task_id = '{childId}' AND dependency_type = 'parent-child' + """)); + } + + [Fact] + public async Task Parent_ReplacesExistingParent() + { + // arrange + await InitWorkspaceAsync(); + var oldParentId = await CreateTaskAsync("Old epic"); + var newParentId = await CreateTaskAsync("New epic"); + var childId = await CreateTaskAsync("Child", "--parent", oldParentId); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "update", childId, "--parent", newParentId); + + // assert + result.AssertSuccess( + $""" + โœ“ Updated task '{childId}'. + """); + Assert.Equal( + newParentId, + await QueryScalarAsync( + $""" + SELECT depends_on_id FROM dependencies + WHERE task_id = '{childId}' AND dependency_type = 'parent-child' + """)); + Assert.Equal( + "1", + await QueryScalarAsync( + $""" + SELECT COUNT(*) FROM dependencies + WHERE task_id = '{childId}' AND dependency_type = 'parent-child' + """)); + } + + [Fact] + public async Task EmptyParent_ClearsParent() + { + // arrange + await InitWorkspaceAsync(); + var parentId = await CreateTaskAsync("Epic"); + var childId = await CreateTaskAsync("Child", "--parent", parentId); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "update", childId, "--parent", ""); + + // assert + result.AssertSuccess( + $""" + โœ“ Updated task '{childId}'. + """); + Assert.Equal( + "0", + await QueryScalarAsync( + $""" + SELECT COUNT(*) FROM dependencies + WHERE task_id = '{childId}' AND dependency_type = 'parent-child' + """)); + } + + [Fact] + public async Task Parent_SelfParent_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "update", id, "--parent", id); + + // assert + result.AssertError("A task cannot be its own parent."); + } + + [Fact] + public async Task Parent_UnknownParent_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "update", id, "--parent", "acme-999"); + + // assert + result.AssertError("Task 'acme-999' does not exist."); + } + + [Fact] + public async Task Parent_ExistingDependencyOfAnyTypeForSamePair_ReturnsError() + { + // arrange + await InitWorkspaceAsync(); + var id = await CreateTaskAsync("Fix the parser"); + var otherId = await CreateTaskAsync("Write the tokenizer"); + await ExecuteCommandAsync("agent", "tasks", "dep", "add", id, otherId); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "update", id, "--parent", otherId); + + // assert + result.AssertError("Dependency already exists."); + } + + [Fact] + public async Task Parent_CreatesCycle_RejectsBeforeCommit() + { + // arrange + await InitWorkspaceAsync(); + var a = await CreateTaskAsync("Task A"); + var b = await CreateTaskAsync("Task B"); + await ExecuteCommandAsync("agent", "tasks", "dep", "add", a, b); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "update", b, "--parent", a); + + // assert + result.AssertError($"Setting this parent would create a cycle: {b} -> {a} -> {b}."); + Assert.Equal( + "0", + await QueryScalarAsync( + "SELECT COUNT(*) FROM dependencies " + + $"WHERE task_id = '{b}' AND depends_on_id = '{a}' AND dependency_type = 'parent-child'")); + } + + [Fact] + public async Task MultipleIds_AppliesAddLabelAndParentToEach() + { + // arrange + await InitWorkspaceAsync(); + var parentId = await CreateTaskAsync("Epic"); + var id1 = await CreateTaskAsync("Fix the parser"); + var id2 = await CreateTaskAsync("Write the docs"); + + // act + var result = await ExecuteCommandAsync( + "agent", "tasks", "update", id1, id2, "--add-label", "triage", "--parent", parentId); + + // assert + result.AssertSuccess( + $""" + โœ“ Updated task '{id1}'. + โœ“ Updated task '{id2}'. + """); + Assert.Equal( + "2", + await QueryScalarAsync( + $""" + SELECT COUNT(*) FROM labels + WHERE task_id IN ('{id1}', '{id2}') AND label = 'triage' + """)); + Assert.Equal( + "2", + await QueryScalarAsync( + $""" + SELECT COUNT(*) FROM dependencies + WHERE task_id IN ('{id1}', '{id2}') AND dependency_type = 'parent-child' + AND depends_on_id = '{parentId}' + """)); + } + + [Fact] + public async Task StatusTransitions_AreRejectedForTerminalStates() + { + // arrange + await InitWorkspaceAsync(); + var createResult = await ExecuteCommandAsync("agent", "tasks", "create", "Fix the parser"); + var id = createResult.StdOut.Split('\'')[1]; + var closeResult = await ExecuteCommandAsync("agent", "tasks", "close", id); + Assert.Equal(0, closeResult.ExitCode); + + // act + var closeAttempt = await ExecuteCommandAsync("agent", "tasks", "update", id, "--status", "closed"); + var deleteAttempt = await ExecuteCommandAsync("agent", "tasks", "update", id, "--status", "tombstone"); + var reopenAttempt = await ExecuteCommandAsync("agent", "tasks", "update", id, "--status", "open"); + + // assert + closeAttempt.AssertError( + """ + Use `nitro agent tasks close` to close a task. + """); + deleteAttempt.AssertError( + """ + Use `nitro agent tasks delete` to delete a task. + """); + reopenAttempt.AssertError( + """ + Use `nitro agent tasks reopen` to reopen a task. + """); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/WhereTaskCommandTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/WhereTaskCommandTests.cs new file mode 100644 index 00000000000..07ea55d7821 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Commands/Tasks/WhereTaskCommandTests.cs @@ -0,0 +1,74 @@ + +namespace ChilliCream.Nitro.CommandLine.Tests.Commands.Tasks; + +public sealed class WhereTaskCommandTests(NitroCommandFixture fixture) + : TasksCommandTestBase(fixture) +{ + [Fact] + public async Task Help_ReturnsSuccess() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "where", "--help"); + + // assert + result.AssertHelpOutput( + """ + Description: + Print the absolute path of the current task workspace. + + Usage: + nitro agent tasks where [options] + + Options: + --output <json> The output format (enables non-interactive mode) [env: NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + + Example: + nitro agent tasks where + """); + } + + [Fact] + public async Task WorkspaceExists_PrintsWorkspaceDirectory() + { + // arrange + await InitWorkspaceAsync(); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "where"); + + // assert + Assert.Equal(0, result.ExitCode); + Assert.Empty(result.StdErr); + Assert.Equal(WorkspaceDirectory, result.StdOut); + } + + [Fact] + public async Task NoWorkspace_ReturnsError() + { + // arrange & act + var result = await ExecuteCommandAsync("agent", "tasks", "where"); + + // assert + result.AssertError("No task workspace found. Run `nitro agent tasks init` first."); + } + + [Fact] + public async Task JsonOutput_WorkspaceExists_ReturnsStructuredPath() + { + // arrange + await InitWorkspaceAsync(); + SetupInteractionMode(InteractionMode.JsonOutput); + + // act + var result = await ExecuteCommandAsync("agent", "tasks", "where"); + + // assert + result.AssertSuccess( + $$""" + { + "path": "{{WorkspaceDirectory.Replace("\\", "\\\\")}}" + } + """); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Nitro.CommandLine.Tests.csproj b/src/Nitro/CommandLine/test/CommandLine.Tests/Nitro.CommandLine.Tests.csproj index 19548ebf59b..78da83901ba 100644 --- a/src/Nitro/CommandLine/test/CommandLine.Tests/Nitro.CommandLine.Tests.csproj +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Nitro.CommandLine.Tests.csproj @@ -12,6 +12,7 @@ </ItemGroup> <ItemGroup> + <PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" /> <PackageReference Include="Moq" /> <PackageReference Include="Spectre.Console.Testing" /> </ItemGroup> diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/BoardDataLoaderTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/BoardDataLoaderTests.cs new file mode 100644 index 00000000000..6a430bb5df8 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/BoardDataLoaderTests.cs @@ -0,0 +1,259 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Board; +using Microsoft.Extensions.Time.Testing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Board; + +public sealed class BoardDataLoaderTests +{ + private static readonly DateTimeOffset Now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task LoadColumnAsync_Should_ReturnOnlyBlockedNonTerminalTasks_When_ComputedFilterIsBlocked() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1", status: TaskStates.Open)); + store.Tasks.Add(TaskItemBuilder.Create("a-2", status: TaskStates.Open)); + store.Tasks.Add(TaskItemBuilder.Create("a-3", status: TaskStates.Closed)); + store.Blocked["a-1"] = ["a-9:open"]; + store.Blocked["a-3"] = ["a-9:open"]; + var loader = new BoardDataLoader(store, new FakeTimeProvider(Now)); + var column = new ColumnDefinition { Name = "Blocked", ComputedFilter = ColumnComputedFilter.Blocked }; + + // act + var tasks = await loader.LoadColumnAsync(column, CancellationToken.None); + + // assert + Assert.Equal(["a-1"], tasks.Select(t => t.Id)); + } + + [Fact] + public async Task LoadColumnAsync_Should_IncludeManualStatusBlockedTask_When_NoUnfinishedDependencies() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1", status: TaskStates.Blocked)); + store.Tasks.Add(TaskItemBuilder.Create("a-2", status: TaskStates.Open)); + var loader = new BoardDataLoader(store, new FakeTimeProvider(Now)); + var column = new ColumnDefinition { Name = "Blocked", ComputedFilter = ColumnComputedFilter.Blocked }; + + // act + var tasks = await loader.LoadColumnAsync(column, CancellationToken.None); + + // assert + Assert.Equal(["a-1"], tasks.Select(t => t.Id)); + } + + [Fact] + public async Task LoadColumnAsync_Should_ReturnTaskOnce_When_StatusBlockedAndDependencyComputedBlocked() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1", status: TaskStates.Blocked)); + store.Blocked["a-1"] = ["a-9:open"]; + var loader = new BoardDataLoader(store, new FakeTimeProvider(Now)); + var column = new ColumnDefinition { Name = "Blocked", ComputedFilter = ColumnComputedFilter.Blocked }; + + // act + var tasks = await loader.LoadColumnAsync(column, CancellationToken.None); + + // assert + Assert.Equal(["a-1"], tasks.Select(t => t.Id)); + } + + [Fact] + public async Task LoadColumnAsync_Should_CoverEveryNonTerminalTask_When_LoadingAllDefaultViewColumns() + { + // arrange + var store = new FakeTaskStore(); + var statuses = new[] + { + TaskStates.Open, + TaskStates.InProgress, + TaskStates.Blocked, + TaskStates.Deferred, + TaskStates.Closed, + TaskStates.Tombstone + }; + + foreach (var status in statuses) + { + store.Tasks.Add(TaskItemBuilder.Create($"{status}-nodep", status: status)); + + var dependencyBlockedId = $"{status}-dep"; + store.Tasks.Add(TaskItemBuilder.Create(dependencyBlockedId, status: status)); + store.Blocked[dependencyBlockedId] = ["dep-1:open"]; + } + + var loader = new BoardDataLoader(store, new FakeTimeProvider(Now)); + + // act + var visible = new HashSet<string>(); + foreach (var column in BoardView.Default.Columns) + { + var tasks = await loader.LoadColumnAsync(column, CancellationToken.None); + foreach (var task in tasks) + { + visible.Add(task.Id); + } + } + + // assert + var nonTerminalIds = store.Tasks + .Where(t => !TaskStates.IsTerminal(t.Status)) + .Select(t => t.Id) + .ToList(); + Assert.All(nonTerminalIds, id => Assert.Contains(id, visible)); + } + + [Fact] + public async Task LoadColumnAsync_Should_ReturnDeferredStatusOrFutureDeferUntil_When_ComputedFilterIsDeferred() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1", status: TaskStates.Deferred)); + store.Tasks.Add( + TaskItemBuilder.Create("a-2", status: TaskStates.Open, deferUntil: Now.AddDays(1))); + store.Tasks.Add( + TaskItemBuilder.Create("a-3", status: TaskStates.Open, deferUntil: Now.AddDays(-1))); + store.Tasks.Add(TaskItemBuilder.Create("a-4", status: TaskStates.Open)); + store.Tasks.Add(TaskItemBuilder.Create("a-5", status: TaskStates.Closed)); + var loader = new BoardDataLoader(store, new FakeTimeProvider(Now)); + var column = new ColumnDefinition { Name = "Deferred", ComputedFilter = ColumnComputedFilter.Deferred }; + + // act + var tasks = await loader.LoadColumnAsync(column, CancellationToken.None); + + // assert + Assert.Equal(["a-1", "a-2"], tasks.Select(t => t.Id)); + } + + [Fact] + public async Task LoadColumnAsync_Should_ReturnUnblockedNonDeferredOpenTasks_When_ComputedFilterIsReady() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1", status: TaskStates.Open)); + store.Tasks.Add(TaskItemBuilder.Create("a-2", status: TaskStates.Open)); + store.Tasks.Add(TaskItemBuilder.Create("a-3", status: TaskStates.InProgress)); + store.Blocked["a-2"] = ["a-9:open"]; + var loader = new BoardDataLoader(store, new FakeTimeProvider(Now)); + var column = new ColumnDefinition + { + Name = "Ready", + Statuses = [TaskStates.Open], + ComputedFilter = ColumnComputedFilter.Ready + }; + + // act + var tasks = await loader.LoadColumnAsync(column, CancellationToken.None); + + // assert + Assert.Equal(["a-1"], tasks.Select(t => t.Id)); + } + + [Fact] + public async Task LoadColumnAsync_Should_ReturnOnlyMatchingStatus_When_ColumnDeclaresStatuses() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1", status: TaskStates.InProgress)); + store.Tasks.Add(TaskItemBuilder.Create("a-2", status: TaskStates.Open)); + var loader = new BoardDataLoader(store, new FakeTimeProvider(Now)); + var column = new ColumnDefinition { Name = "In Progress", Statuses = [TaskStates.InProgress] }; + + // act + var tasks = await loader.LoadColumnAsync(column, CancellationToken.None); + + // assert + Assert.Equal(["a-1"], tasks.Select(t => t.Id)); + } + + [Fact] + public async Task LoadColumnAsync_Should_SortRecentFirstAndCap_When_ColumnUsesRecentFirstSortAndLimit() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1", status: TaskStates.Closed, closedAt: Now.AddDays(-3))); + store.Tasks.Add(TaskItemBuilder.Create("a-2", status: TaskStates.Closed, closedAt: Now.AddDays(-1))); + store.Tasks.Add(TaskItemBuilder.Create("a-3", status: TaskStates.Closed, closedAt: Now.AddDays(-2))); + var loader = new BoardDataLoader(store, new FakeTimeProvider(Now)); + var column = new ColumnDefinition + { + Name = "Closed", + Statuses = [TaskStates.Closed], + Sort = BoardColumnSort.RecentFirst, + Limit = 2 + }; + + // act + var tasks = await loader.LoadColumnAsync(column, CancellationToken.None); + + // assert + Assert.Equal(["a-2", "a-3"], tasks.Select(t => t.Id)); + } + + [Fact] + public async Task LoadColumnAsync_Should_SortByPriorityThenCreatedThenId_When_SortIsDefault() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create( + "b-1", priority: TaskPriorities.Medium, createdAt: Now)); + store.Tasks.Add(TaskItemBuilder.Create( + "a-1", priority: TaskPriorities.Medium, createdAt: Now)); + store.Tasks.Add(TaskItemBuilder.Create( + "z-1", priority: TaskPriorities.High, createdAt: Now.AddDays(1))); + var loader = new BoardDataLoader(store, new FakeTimeProvider(Now)); + var column = new ColumnDefinition { Name = "All" }; + + // act + var tasks = await loader.LoadColumnAsync(column, CancellationToken.None); + + // assert + Assert.Equal(["z-1", "a-1", "b-1"], tasks.Select(t => t.Id)); + } + + [Fact] + public async Task LoadColumnAsync_Should_ReturnEmptyList_When_NoTaskMatches() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1", status: TaskStates.Open)); + var loader = new BoardDataLoader(store, new FakeTimeProvider(Now)); + var column = new ColumnDefinition { Name = "In Progress", Statuses = [TaskStates.InProgress] }; + + // act + var tasks = await loader.LoadColumnAsync(column, CancellationToken.None); + + // assert + Assert.Empty(tasks); + } + + [Fact] + public async Task LoadColumnAsync_Should_FilterByTypesAndPriorities_When_ColumnDeclaresThem() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create( + "a-1", priority: TaskPriorities.High, type: TaskTypes.Bug)); + store.Tasks.Add(TaskItemBuilder.Create( + "a-2", priority: TaskPriorities.High, type: TaskTypes.Task)); + store.Tasks.Add(TaskItemBuilder.Create( + "a-3", priority: TaskPriorities.Low, type: TaskTypes.Bug)); + var loader = new BoardDataLoader(store, new FakeTimeProvider(Now)); + var column = new ColumnDefinition + { + Name = "Bugs", + Types = [TaskTypes.Bug], + Priorities = [TaskPriorities.High] + }; + + // act + var tasks = await loader.LoadColumnAsync(column, CancellationToken.None); + + // assert + Assert.Equal(["a-1"], tasks.Select(t => t.Id)); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/BoardDetailModeTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/BoardDetailModeTests.cs new file mode 100644 index 00000000000..7cf9e83e6bb --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/BoardDetailModeTests.cs @@ -0,0 +1,142 @@ +using ChilliCream.Nitro.CommandLine.Tui.Board; +using ChilliCream.Nitro.CommandLine.Tui.Input; +using ChilliCream.Nitro.CommandLine.Tui.Shell; +using Spectre.Console.Testing; +using CursorDirection = ChilliCream.Nitro.CommandLine.Tui.Input.CursorDirection; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Board; + +public sealed class BoardDetailModeTests +{ + private static string RenderToText(BoardDetailMode mode, int width = 80, int height = 24) + { + var console = new TestConsole().Width(width); + console.Write(mode.Render(width, height)); + return console.Output; + } + + [Fact] + public void SelectedTaskId_Should_BeNull_BeforeAnyTaskOpened() + { + // arrange + var store = new FakeTaskStore(); + var mode = new BoardDetailMode(store); + + // assert + Assert.Null(((ITuiMode)mode).SelectedTaskId); + } + + [Fact] + public void OpenOnTask_Should_LoadTaskThroughStore_And_ExposeItAsSelected() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1")); + var mode = new BoardDetailMode(store); + + // act + mode.OpenOnTask("a-1"); + + // assert + Assert.Equal("a-1", ((ITuiMode)mode).SelectedTaskId); + Assert.Contains("a-1", RenderToText(mode)); + } + + [Fact] + public void OpenOnTask_Should_RenderNotFoundMessage_When_TaskDoesNotExist() + { + // arrange + var store = new FakeTaskStore(); + var mode = new BoardDetailMode(store); + + // act + mode.OpenOnTask("missing"); + + // assert + Assert.Contains("not found", RenderToText(mode)); + } + + [Fact] + public void OpenOnTask_Should_ReplaceWhicheverTaskWasPreviouslyLoaded() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1")); + store.Tasks.Add(TaskItemBuilder.Create("a-2")); + var mode = new BoardDetailMode(store); + mode.OpenOnTask("a-1"); + + // act + mode.OpenOnTask("a-2"); + + // assert + Assert.Equal("a-2", ((ITuiMode)mode).SelectedTaskId); + } + + [Fact] + public void Handle_RefreshRequested_Should_ReloadCurrentTaskThroughStore() + { + // arrange: the label is added to the store only after the initial + // load, so it only shows up once RefreshRequested re-fetches. + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1")); + var mode = new BoardDetailMode(store); + mode.OpenOnTask("a-1"); + Assert.DoesNotContain("urgent", RenderToText(mode)); + store.Labels["a-1"] = ["urgent"]; + + // act + var messages = mode.Handle(new TuiMessage.RefreshRequested()); + + // assert + Assert.Empty(messages); + Assert.Contains("urgent", RenderToText(mode)); + } + + [Fact] + public void Handle_RefreshRequested_Should_BeNoOp_When_NoTaskOpened() + { + // arrange + var store = new FakeTaskStore(); + var mode = new BoardDetailMode(store); + + // act + var exception = Record.Exception(() => mode.Handle(new TuiMessage.RefreshRequested())); + + // assert + Assert.Null(exception); + Assert.Null(((ITuiMode)mode).SelectedTaskId); + } + + [Fact] + public void Handle_MoveCursorDown_Should_NotThrow_When_TaskOpened() + => AssertHandleDoesNotThrow(new TuiMessage.MoveCursor(CursorDirection.Down)); + + [Fact] + public void Handle_MoveCursorUp_Should_NotThrow_When_TaskOpened() + => AssertHandleDoesNotThrow(new TuiMessage.MoveCursor(CursorDirection.Up)); + + [Fact] + public void Handle_MoveToEdgeTop_Should_NotThrow_When_TaskOpened() + => AssertHandleDoesNotThrow(new TuiMessage.MoveToEdge(EdgeTarget.Top)); + + [Fact] + public void Handle_MoveToEdgeBottom_Should_NotThrow_When_TaskOpened() + => AssertHandleDoesNotThrow(new TuiMessage.MoveToEdge(EdgeTarget.Bottom)); + + private static void AssertHandleDoesNotThrow(TuiMessage message) + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1")); + var mode = new BoardDetailMode(store); + mode.OpenOnTask("a-1"); + RenderToText(mode, height: 5); + + // act + var exception = Record.Exception(() => mode.Handle(message)); + + // assert + Assert.Null(exception); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/BoardLayoutTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/BoardLayoutTests.cs new file mode 100644 index 00000000000..4f62a0d5faf --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/BoardLayoutTests.cs @@ -0,0 +1,181 @@ +using ChilliCream.Nitro.CommandLine.Tui.Board; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Board; + +public sealed class BoardLayoutTests +{ + [Theory] + [InlineData(200, 5)] + [InlineData(24 * 5, 5)] + public void Decide_Should_ReturnGrid_When_WidthPerColumnMeetsThreshold(int width, int columnCount) + { + // act + var decision = BoardLayout.Decide(width, 20, columnCount, focusedColumnIndex: 0, maximized: false); + + // assert + Assert.Equal(BoardLayoutKind.Grid, decision.Kind); + } + + [Theory] + [InlineData(119, 5)] + [InlineData(23 * 5, 5)] + public void Decide_Should_ReturnStacked_When_WidthPerColumnBelowThreshold(int width, int columnCount) + { + // act + var decision = BoardLayout.Decide(width, 20, columnCount, focusedColumnIndex: 0, maximized: false); + + // assert + Assert.Equal(BoardLayoutKind.Stacked, decision.Kind); + } + + [Fact] + public void Decide_Should_ReturnMaximized_When_MaximizedFlagSet_EvenAtWideWidth() + { + // act + var decision = BoardLayout.Decide(200, 20, 5, focusedColumnIndex: 2, maximized: true); + + // assert + Assert.Equal(BoardLayoutKind.Maximized, decision.Kind); + } + + [Fact] + public void Decide_Should_SumColumnWidthsToTotalWidth_When_Grid() + { + // act + var decision = BoardLayout.Decide(203, 20, 7, focusedColumnIndex: 0, maximized: false); + + // assert + Assert.Equal(203, decision.Columns.Sum(c => c.Width)); + } + + [Fact] + public void Decide_Should_RouteToStacked_When_WidthIsLessThanColumnCount() + { + // act: width below the column count used to make grid distribution + // report widths summing above the requested total; routing to + // stacked instead avoids that entirely. + var decision = BoardLayout.Decide(3, 20, 5, focusedColumnIndex: 0, maximized: false); + + // assert + Assert.Equal(BoardLayoutKind.Stacked, decision.Kind); + } + + [Fact] + public void Decide_Should_GiveOnlyFocusedColumnFullWidth_When_Maximized() + { + // act + var decision = BoardLayout.Decide(100, 20, 4, focusedColumnIndex: 2, maximized: true); + + // assert + Assert.Equal(100, decision.Columns[2].Width); + Assert.True(decision.Columns[2].Expanded); + Assert.Equal(0, decision.Columns[0].Width); + Assert.False(decision.Columns[0].Expanded); + Assert.Equal(0, decision.Columns[1].Width); + Assert.Equal(0, decision.Columns[3].Width); + } + + [Fact] + public void Decide_Should_ExpandEveryColumn_When_StackedWithRoomToShareEqually() + { + // act: 30 rows over 3 columns clears the equal-share minimum + // (3 columns * 5 rows), so every column expands rather than only + // the focused one. + var decision = BoardLayout.Decide(40, 30, 3, focusedColumnIndex: 1, maximized: false); + + // assert + Assert.Equal(BoardLayoutKind.Stacked, decision.Kind); + Assert.All(decision.Columns, c => Assert.True(c.Expanded)); + } + + [Fact] + public void Decide_Should_ShareHeightEqually_When_StackedWithRoomToShareEqually() + { + // act: 3 columns need 2 separator rows, so 32 rows leaves 30 + // distributable rows, which divides evenly by 3. + var decision = BoardLayout.Decide(40, 32, 3, focusedColumnIndex: 1, maximized: false); + + // assert + Assert.All(decision.Columns, c => Assert.Equal(10, c.Height)); + Assert.Equal(30, decision.Columns.Sum(c => c.Height)); + } + + [Fact] + public void Decide_Should_GiveRemainderRowsToFirstColumns_When_StackedHeightDoesNotDivideEvenly() + { + // act: 3 columns need 2 separator rows, leaving 29 distributable rows + // over 3 columns, a remainder of 2. + var decision = BoardLayout.Decide(40, 31, 3, focusedColumnIndex: 0, maximized: false); + + // assert + Assert.Equal(10, decision.Columns[0].Height); + Assert.Equal(10, decision.Columns[1].Height); + Assert.Equal(9, decision.Columns[2].Height); + Assert.Equal(29, decision.Columns.Sum(c => c.Height)); + } + + [Fact] + public void Decide_Should_ExpandFocusedColumnOnly_When_StackedTooShortToShareEqually() + { + // act: 12 rows over 3 columns (4 rows each) is below the 5-row-per + // column minimum an equal share needs to stay usable, so this falls + // back to expanding only the focused column. + var decision = BoardLayout.Decide(40, 12, 3, focusedColumnIndex: 1, maximized: false); + + // assert + Assert.Equal(BoardLayoutKind.Stacked, decision.Kind); + Assert.False(decision.Columns[0].Expanded); + Assert.True(decision.Columns[1].Expanded); + Assert.False(decision.Columns[2].Expanded); + Assert.True(decision.Columns[1].Height > decision.Columns[0].Height); + } + + [Fact] + public void Decide_Should_FallBackDeterministically_When_SeparatorRowsMakeFiveColumnsTooTightAt24Rows() + { + // act: 5 columns need 25 rows to share equally on top of 4 separator + // rows (29 total), so a 24-row terminal falls back to expanding only + // the focused column instead of throwing or producing negative + // heights. + var decision = BoardLayout.Decide(40, 24, 5, focusedColumnIndex: 2, maximized: false); + + // assert + Assert.Equal(BoardLayoutKind.Stacked, decision.Kind); + Assert.True(decision.Columns[2].Expanded); + Assert.All(decision.Columns, c => Assert.True(c.Height >= 1)); + } + + [Fact] + public void Decide_Should_ClampFocusedColumnIndex_When_OutOfRange() + { + // act + var decision = BoardLayout.Decide(100, 20, 3, focusedColumnIndex: 99, maximized: true); + + // assert + Assert.True(decision.Columns[2].Expanded); + } + + [Fact] + public void Decide_Should_ReturnNoColumns_When_ColumnCountIsZero() + { + // act + var decision = BoardLayout.Decide(100, 20, 0, focusedColumnIndex: 0, maximized: false); + + // assert + Assert.Empty(decision.Columns); + } + + [Fact] + public void Decide_Should_NotThrow_And_KeepHeightsAtLeastOne_When_FrameIsVerySmall() + { + // act: a "title plus one row" minimum sane frame. + var exception = Record.Exception(() => + { + var decision = BoardLayout.Decide(5, 1, 4, focusedColumnIndex: 0, maximized: false); + Assert.All(decision.Columns, c => Assert.True(c.Height >= 1)); + }); + + // assert + Assert.Null(exception); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/BoardModeTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/BoardModeTests.cs new file mode 100644 index 00000000000..c6de8011c48 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/BoardModeTests.cs @@ -0,0 +1,593 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Board; +using ChilliCream.Nitro.CommandLine.Tui.Input; +using ChilliCream.Nitro.CommandLine.Tui.Shell; +using Microsoft.Extensions.Time.Testing; +using Spectre.Console.Testing; +using CursorDirection = ChilliCream.Nitro.CommandLine.Tui.Input.CursorDirection; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Board; + +public sealed class BoardModeTests +{ + private static readonly DateTimeOffset Now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private static BoardView TwoColumnView() => new() + { + Name = "Test", + Columns = + [ + new ColumnDefinition { Name = "Open", Statuses = [TaskStates.Open] }, + new ColumnDefinition { Name = "Closed", Statuses = [TaskStates.Closed] } + ] + }; + + private static BoardMode CreateMode(FakeTaskStore store, BoardView? view = null) + => new(new BoardDataLoader(store, new FakeTimeProvider(Now)), view is null ? null : [view]); + + [Fact] + public void FocusColumn_Should_ClampAtFirstColumn_When_MovingLeftPastStart() + { + // arrange + var store = new FakeTaskStore(); + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + + // act + mode.Handle(new TuiMessage.MoveCursor(CursorDirection.Left)); + + // assert + Assert.Equal(0, mode.State.FocusedColumnIndex); + } + + [Fact] + public void FocusColumn_Should_ClampAtLastColumn_When_MovingRightPastEnd() + { + // arrange + var store = new FakeTaskStore(); + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + + // act + mode.Handle(new TuiMessage.MoveCursor(CursorDirection.Right)); + mode.Handle(new TuiMessage.MoveCursor(CursorDirection.Right)); + + // assert + Assert.Equal(1, mode.State.FocusedColumnIndex); + } + + [Fact] + public void MoveSelection_Should_ClampAtLastRow_When_MovingDownPastEnd() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1", status: TaskStates.Open)); + store.Tasks.Add(TaskItemBuilder.Create("a-2", status: TaskStates.Open, createdAt: Now.AddDays(1))); + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + + // act + mode.Handle(new TuiMessage.MoveCursor(CursorDirection.Down)); + mode.Handle(new TuiMessage.MoveCursor(CursorDirection.Down)); + + // assert + Assert.Equal(1, mode.State.Columns[0].SelectedRow); + } + + [Fact] + public void MoveSelectionToEdge_Should_SelectLastRow_When_Bottom() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1", status: TaskStates.Open)); + store.Tasks.Add(TaskItemBuilder.Create("a-2", status: TaskStates.Open, createdAt: Now.AddDays(1))); + store.Tasks.Add(TaskItemBuilder.Create("a-3", status: TaskStates.Open, createdAt: Now.AddDays(2))); + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + + // act + mode.Handle(new TuiMessage.MoveToEdge(EdgeTarget.Bottom)); + + // assert + Assert.Equal(2, mode.State.Columns[0].SelectedRow); + } + + [Fact] + public void MoveSelectionToEdge_Should_SelectFirstRow_When_Top() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1", status: TaskStates.Open)); + store.Tasks.Add(TaskItemBuilder.Create("a-2", status: TaskStates.Open, createdAt: Now.AddDays(1))); + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + mode.Handle(new TuiMessage.MoveToEdge(EdgeTarget.Bottom)); + + // act + mode.Handle(new TuiMessage.MoveToEdge(EdgeTarget.Top)); + + // assert + Assert.Equal(0, mode.State.Columns[0].SelectedRow); + } + + [Fact] + public void Refresh_Should_PreserveSelectedTask_When_ReloadedTasksReorder() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1", status: TaskStates.Open, createdAt: Now)); + store.Tasks.Add(TaskItemBuilder.Create("a-2", status: TaskStates.Open, createdAt: Now.AddDays(1))); + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + mode.Handle(new TuiMessage.MoveToEdge(EdgeTarget.Bottom)); + Assert.Equal("a-2", mode.State.Columns[0].SelectedTaskId); + + // act: a new, earlier-priority task pushes a-2 to a different row on refresh + store.Tasks.Add(TaskItemBuilder.Create( + "a-0", status: TaskStates.Open, priority: TaskPriorities.Critical, createdAt: Now.AddDays(-1))); + mode.Handle(new TuiMessage.RefreshRequested()); + + // assert + Assert.Equal("a-2", mode.State.Columns[0].SelectedTaskId); + Assert.Equal(2, mode.State.Columns[0].SelectedRow); + } + + [Fact] + public void SelectedTaskId_Should_ReturnFocusedColumnsSelectedTask_When_TaskPresent() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1", status: TaskStates.Open)); + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + + // act + var selected = ((ITuiMode)mode).SelectedTaskId; + + // assert + Assert.Equal("a-1", selected); + } + + [Fact] + public void SelectedTaskId_Should_ReturnNull_When_FocusedColumnEmpty() + { + // arrange + var store = new FakeTaskStore(); + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + + // act + var selected = ((ITuiMode)mode).SelectedTaskId; + + // assert + Assert.Null(selected); + } + + [Fact] + public void SelectedTaskId_Should_FollowFocus_When_FocusedColumnChanges() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1", status: TaskStates.Open)); + store.Tasks.Add(TaskItemBuilder.Create("a-2", status: TaskStates.Closed)); + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + + // act + mode.Handle(new TuiMessage.MoveCursor(CursorDirection.Right)); + + // assert + Assert.Equal("a-2", ((ITuiMode)mode).SelectedTaskId); + } + + [Fact] + public void SelectTask_Should_FocusColumnAndSelectRow_When_TaskExists() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1", status: TaskStates.Open)); + store.Tasks.Add(TaskItemBuilder.Create("a-2", status: TaskStates.Closed)); + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + + // act + ((ITuiMode)mode).SelectTask("a-2"); + + // assert + Assert.Equal(1, mode.State.FocusedColumnIndex); + Assert.Equal(0, mode.State.Columns[1].SelectedRow); + Assert.Equal("a-2", ((ITuiMode)mode).SelectedTaskId); + } + + [Fact] + public void SelectTask_Should_LeaveSelectionUnchanged_When_TaskDoesNotExist() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1", status: TaskStates.Open)); + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + + // act + ((ITuiMode)mode).SelectTask("does-not-exist"); + + // assert + Assert.Equal(0, mode.State.FocusedColumnIndex); + Assert.Equal("a-1", ((ITuiMode)mode).SelectedTaskId); + } + + [Fact] + public void OpenSelected_Should_ReturnEmpty() + { + // arrange: TuiShell intercepts OpenSelected for the board before it + // ever reaches BoardMode.Handle, switching to BoardDetailMode itself + // (see TuiShellTests). BoardMode's own handling is a no-op. + var store = new FakeTaskStore(); + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + + // act + var messages = mode.Handle(new TuiMessage.OpenSelected()); + + // assert + Assert.Empty(messages); + } + + [Fact] + public void CopySelectedId_Should_ShowIdInToast_When_TaskSelected() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1", status: TaskStates.Open)); + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + + // act + var messages = mode.Handle(new TuiMessage.CopySelectedId()); + + // assert + var toast = Assert.IsType<TuiMessage.ShowToast>(Assert.Single(messages)); + Assert.Equal("a-1", toast.Text); + } + + [Fact] + public void CopySelectedId_Should_WarnNoTaskSelected_When_ColumnEmpty() + { + // arrange + var store = new FakeTaskStore(); + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + + // act + var messages = mode.Handle(new TuiMessage.CopySelectedId()); + + // assert + var toast = Assert.IsType<TuiMessage.ShowToast>(Assert.Single(messages)); + Assert.Equal(ToastStyle.Warn, toast.Style); + } + + [Fact] + public void CycleView_Should_ReturnNoMessages_When_OnlySingleBuiltInView() + { + // arrange + var store = new FakeTaskStore(); + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + + // act + var messages = mode.Handle(new TuiMessage.CycleView(1)); + + // assert + Assert.Empty(messages); + Assert.Equal("Test", mode.State.View.Name); + } + + [Fact] + public void CycleView_Should_SwitchToNextView_When_MultipleViewsConfigured() + { + // arrange + var store = new FakeTaskStore(); + var secondView = new BoardView { Name = "Second", Columns = TwoColumnView().Columns }; + var mode = new BoardMode( + new BoardDataLoader(store, new FakeTimeProvider(Now)), [TwoColumnView(), secondView]); + mode.OnEnter(); + + // act + mode.Handle(new TuiMessage.CycleView(1)); + + // assert + Assert.Equal("Second", mode.State.View.Name); + } + + [Fact] + public void Render_Should_IncludeColumnTitlesAndTaskBadges() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1", status: TaskStates.Open)); + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + var console = new TestConsole().Width(80).Height(20); + + // act + console.Write(mode.Render(80, 20)); + + // assert + Assert.Contains("Open (1)", console.Output); + Assert.Contains("Closed (0)", console.Output); + Assert.Contains("a-1", console.Output); + } + + [Fact] + public void Render_Should_NotThrow_When_WidthOrHeightIsZero() + { + // arrange + var store = new FakeTaskStore(); + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + + // act + var exception = Record.Exception(() => mode.Render(0, 0)); + + // assert + Assert.Null(exception); + } + + [Fact] + public void Render_Should_ShowMoreBelowIndicator_When_TasksExceedColumnHeight() + { + // arrange + var store = new FakeTaskStore(); + for (var i = 1; i <= 15; i++) + { + store.Tasks.Add(TaskItemBuilder.Create( + $"t-{i:D2}", status: TaskStates.Open, createdAt: Now.AddMinutes(i))); + } + + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + var console = new TestConsole().Width(80).Height(12); + + // act + console.Write(mode.Render(80, 12)); + + // assert + Assert.Contains("more below", console.Output); + Assert.DoesNotContain("t-15", console.Output); + } + + [Fact] + public void Render_Should_ShowMoreAboveIndicator_And_SelectedTask_When_MovedToBottom() + { + // arrange + var store = new FakeTaskStore(); + for (var i = 1; i <= 15; i++) + { + store.Tasks.Add(TaskItemBuilder.Create( + $"t-{i:D2}", status: TaskStates.Open, createdAt: Now.AddMinutes(i))); + } + + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + var console = new TestConsole().Width(80).Height(12); + + // act + mode.Handle(new TuiMessage.MoveToEdge(EdgeTarget.Bottom)); + console.Write(mode.Render(80, 12)); + + // assert + Assert.Contains("more above", console.Output); + Assert.Contains("t-15", console.Output); + } + + [Fact] + public void ToggleMaximize_Should_ShowOnlyFocusedColumn_When_Toggled() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1", status: TaskStates.Open)); + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + var console = new TestConsole().Width(80).Height(20); + + // act + mode.Handle(new TuiMessage.ToggleMaximize()); + console.Write(mode.Render(80, 20)); + + // assert + Assert.Contains("Open - 1/2", console.Output); + Assert.DoesNotContain("Closed", console.Output); + } + + [Fact] + public void ToggleMaximize_Should_ShowNewlyFocusedColumn_When_FocusChangedWhileMaximized() + { + // arrange + var store = new FakeTaskStore(); + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + var console = new TestConsole().Width(80).Height(20); + + // act + mode.Handle(new TuiMessage.ToggleMaximize()); + mode.Handle(new TuiMessage.MoveCursor(CursorDirection.Right)); + console.Write(mode.Render(80, 20)); + + // assert + Assert.Contains("Closed - 2/2", console.Output); + Assert.DoesNotContain("Open -", console.Output); + } + + [Fact] + public void ToggleMaximize_Should_ReturnToGrid_When_ToggledTwice() + { + // arrange + var store = new FakeTaskStore(); + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + var console = new TestConsole().Width(80).Height(20); + + // act + mode.Handle(new TuiMessage.ToggleMaximize()); + mode.Handle(new TuiMessage.ToggleMaximize()); + console.Write(mode.Render(80, 20)); + + // assert + Assert.Contains("Open (0)", console.Output); + Assert.Contains("Closed (0)", console.Output); + } + + [Fact] + public void Render_Should_ReClampViewport_When_ResizedSmaller_After_ScrollingDown() + { + // arrange + var store = new FakeTaskStore(); + for (var i = 1; i <= 30; i++) + { + store.Tasks.Add(TaskItemBuilder.Create( + $"t-{i:D2}", status: TaskStates.Open, createdAt: Now.AddMinutes(i))); + } + + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + var bigConsole = new TestConsole().Width(80).Height(20); + bigConsole.Write(mode.Render(80, 20)); + mode.Handle(new TuiMessage.MoveToEdge(EdgeTarget.Bottom)); + bigConsole.Write(mode.Render(80, 20)); + + // act: shrink the frame drastically after scrolling to the bottom + mode.OnResize(80, 4); + var smallConsole = new TestConsole().Width(80).Height(4); + var exception = Record.Exception(() => smallConsole.Write(mode.Render(80, 4))); + + // assert + Assert.Null(exception); + Assert.Contains("t-30", smallConsole.Output); + } + + [Fact] + public void Render_Should_NotThrow_When_WidthIsBelowColumnCount() + { + // arrange + var store = new FakeTaskStore(); + var view = new BoardView + { + Name = "Many", + Columns = + [ + new ColumnDefinition { Name = "A", Statuses = [TaskStates.Open] }, + new ColumnDefinition { Name = "B", Statuses = [TaskStates.Open] }, + new ColumnDefinition { Name = "C", Statuses = [TaskStates.Open] }, + new ColumnDefinition { Name = "D", Statuses = [TaskStates.Open] }, + new ColumnDefinition { Name = "E", Statuses = [TaskStates.Open] } + ] + }; + var mode = CreateMode(store, view); + mode.OnEnter(); + var console = new TestConsole().Width(3).Height(10); + + // act + var exception = Record.Exception(() => console.Write(mode.Render(3, 10))); + + // assert + Assert.Null(exception); + } + + [Fact] + public void OnEnter_Should_LoadEveryColumnOfDefaultView_ThroughLoader() + { + // arrange: BoardView.Default loaded end-to-end through BoardDataLoader + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("blocked-1", status: TaskStates.Open)); + store.Blocked["blocked-1"] = ["blocker-1"]; + store.Tasks.Add(TaskItemBuilder.Create("deferred-1", status: TaskStates.Deferred)); + store.Tasks.Add(TaskItemBuilder.Create("ready-1", status: TaskStates.Open)); + store.Tasks.Add(TaskItemBuilder.Create("in-progress-1", status: TaskStates.InProgress)); + store.Tasks.Add(TaskItemBuilder.Create( + "closed-1", status: TaskStates.Closed, closedAt: Now.AddDays(-1))); + var mode = CreateMode(store, BoardView.Default); + + // act + mode.OnEnter(); + + // assert + Assert.Equal(["blocked-1"], mode.State.Columns[0].Tasks.Select(t => t.Id)); + Assert.Equal(["deferred-1"], mode.State.Columns[1].Tasks.Select(t => t.Id)); + Assert.Equal(["ready-1"], mode.State.Columns[2].Tasks.Select(t => t.Id)); + Assert.Equal(["in-progress-1"], mode.State.Columns[3].Tasks.Select(t => t.Id)); + Assert.Equal(["closed-1"], mode.State.Columns[4].Tasks.Select(t => t.Id)); + } + + [Fact] + public void Render_Should_FillRequestedHeight_When_Grid() + { + // arrange + var store = new FakeTaskStore(); + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + var console = new TestConsole().Width(80).Height(24); + + // act + console.Write(mode.Render(80, 24)); + + // assert: every column's bottom border reaches the last requested row, + // with no blank gap left below the panels. + var lines = TrimTrailingNewline(console.Output.Split('\n')); + Assert.Equal(24, lines.Length); + Assert.Contains('โ•ฐ', lines[^1]); + } + + [Fact] + public void Render_Should_FillRequestedHeight_When_Maximized() + { + // arrange + var store = new FakeTaskStore(); + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + mode.Handle(new TuiMessage.ToggleMaximize()); + var console = new TestConsole().Width(80).Height(24); + + // act + console.Write(mode.Render(80, 24)); + + // assert + var lines = TrimTrailingNewline(console.Output.Split('\n')); + Assert.Equal(24, lines.Length); + Assert.Contains('โ•ฐ', lines[^1]); + } + + [Fact] + public void Render_Should_FillRequestedHeight_When_Stacked() + { + // arrange: width/columnCount below the stacked threshold forces the + // stacked layout kind, and 24 rows over 2 columns clears the + // equal-share minimum, so both columns render expanded. + var store = new FakeTaskStore(); + var mode = CreateMode(store, TwoColumnView()); + mode.OnEnter(); + var console = new TestConsole().Width(40).Height(24); + + // act + console.Write(mode.Render(40, 24)); + + // assert: the 1 separator row between the 2 columns leaves 23 rows to + // share, an uneven split of 12 and 11, so the first column's bottom + // border sits at row 11, row 12 is the blank separator, and the + // second column's bottom border reaches the last requested row, no + // blank gap left over below it. + var lines = TrimTrailingNewline(console.Output.Split('\n')); + Assert.Equal(24, lines.Length); + Assert.Contains('โ•ฐ', lines[11]); + Assert.Equal(string.Empty, lines[12]); + Assert.Contains('โ•ฐ', lines[^1]); + } + + /// <summary> + /// Spectre appends a trailing line break to some renderables (a bare + /// panel, a stacked rows list) but not others (a grid layout), so + /// splitting console output on '\n' can leave one extra empty entry at + /// the end. Strips it so line counts are comparable across layout kinds. + /// </summary> + private static string[] TrimTrailingNewline(string[] lines) => + lines.Length > 0 && lines[^1].Length == 0 ? lines[..^1] : lines; +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/BoardStateTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/BoardStateTests.cs new file mode 100644 index 00000000000..8b9a6d7169a --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/BoardStateTests.cs @@ -0,0 +1,116 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Board; +using Microsoft.Extensions.Time.Testing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Board; + +public sealed class BoardStateTests +{ + private static readonly DateTimeOffset Now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private static BoardView TwoColumnView() => new() + { + Name = "Test", + Columns = + [ + new ColumnDefinition { Name = "Open", Statuses = [TaskStates.Open] }, + new ColumnDefinition { Name = "Closed", Statuses = [TaskStates.Closed] } + ] + }; + + [Fact] + public async Task RefreshAsync_Should_LoadEveryColumn() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1", status: TaskStates.Open)); + store.Tasks.Add(TaskItemBuilder.Create("a-2", status: TaskStates.Closed)); + var state = new BoardState(TwoColumnView(), new BoardDataLoader(store, new FakeTimeProvider(Now))); + + // act + await state.RefreshAsync(CancellationToken.None); + + // assert + Assert.Equal(["a-1"], state.Columns[0].Tasks.Select(t => t.Id)); + Assert.Equal(["a-2"], state.Columns[1].Tasks.Select(t => t.Id)); + } + + [Fact] + public async Task RefreshAsync_Should_KeepSelectedRowOnSameTask_When_TaskStillPresentAfterReorder() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1", status: TaskStates.Open, createdAt: Now)); + store.Tasks.Add(TaskItemBuilder.Create("a-2", status: TaskStates.Open, createdAt: Now.AddDays(1))); + var state = new BoardState(TwoColumnView(), new BoardDataLoader(store, new FakeTimeProvider(Now))); + await state.RefreshAsync(CancellationToken.None); + state.Columns[0].SelectedRow = 1; // a-2, the later-created task + + // act: a new, earlier-priority task pushes a-2 to a different row on refresh + store.Tasks.Add(TaskItemBuilder.Create( + "a-0", status: TaskStates.Open, priority: TaskPriorities.Critical, createdAt: Now.AddDays(-1))); + await state.RefreshAsync(CancellationToken.None); + + // assert + Assert.Equal(["a-0", "a-1", "a-2"], state.Columns[0].Tasks.Select(t => t.Id)); + Assert.Equal(2, state.Columns[0].SelectedRow); + Assert.Equal("a-2", state.Columns[0].SelectedTaskId); + } + + [Fact] + public async Task RefreshAsync_Should_ClampSelectedRow_When_SelectedTaskNoLongerPresent() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1", status: TaskStates.Open)); + store.Tasks.Add(TaskItemBuilder.Create("a-2", status: TaskStates.Open, createdAt: Now.AddDays(1))); + var state = new BoardState(TwoColumnView(), new BoardDataLoader(store, new FakeTimeProvider(Now))); + await state.RefreshAsync(CancellationToken.None); + state.Columns[0].SelectedRow = 1; // a-2 + + // act: a-2 closes and drops out of the Open column + store.Tasks.RemoveAt(1); + await state.RefreshAsync(CancellationToken.None); + + // assert + Assert.Equal(["a-1"], state.Columns[0].Tasks.Select(t => t.Id)); + Assert.Equal(0, state.Columns[0].SelectedRow); + } + + [Fact] + public async Task RefreshAsync_Should_KeepSelectedRowAtZero_When_ColumnBecomesEmpty() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a-1", status: TaskStates.Open)); + var state = new BoardState(TwoColumnView(), new BoardDataLoader(store, new FakeTimeProvider(Now))); + await state.RefreshAsync(CancellationToken.None); + + // act + store.Tasks.Clear(); + await state.RefreshAsync(CancellationToken.None); + + // assert + Assert.Empty(state.Columns[0].Tasks); + Assert.Equal(0, state.Columns[0].SelectedRow); + Assert.Null(state.Columns[0].SelectedTaskId); + } + + [Theory] + [InlineData(-1, 0)] + [InlineData(0, 0)] + [InlineData(1, 1)] + [InlineData(5, 1)] + public void FocusColumn_Should_ClampToValidColumnRange(int requested, int expected) + { + // arrange + var store = new FakeTaskStore(); + var state = new BoardState(TwoColumnView(), new BoardDataLoader(store, new FakeTimeProvider(Now))); + + // act + state.FocusColumn(requested); + + // assert + Assert.Equal(expected, state.FocusedColumnIndex); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/BoardViewTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/BoardViewTests.cs new file mode 100644 index 00000000000..0eb21fc79bb --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/BoardViewTests.cs @@ -0,0 +1,34 @@ +using ChilliCream.Nitro.CommandLine.Tui.Board; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Board; + +public sealed class BoardViewTests +{ + [Fact] + public void Default_Should_DeclareBlockedDeferredReadyInProgressAndClosedColumns_InOrder() + { + // act + var columns = BoardView.Default.Columns.Select(c => c.Name); + + // assert + Assert.Equal(["Blocked", "Deferred", "Ready", "In Progress", "Closed"], columns); + } + + [Fact] + public void Default_Should_AssignDistinctBorderTokenPerColumn() + { + // act + var borderTokens = BoardView.Default.Columns.Select(c => c.BorderToken); + + // assert + Assert.Equal( + [ + "board.column.status.blocked", + "board.column.status.deferred", + "board.column.status.ready", + "board.column.status.inprogress", + "board.column.status.closed" + ], + borderTokens); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/ColumnDefinitionTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/ColumnDefinitionTests.cs new file mode 100644 index 00000000000..bda6d1f128b --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/ColumnDefinitionTests.cs @@ -0,0 +1,68 @@ +using ChilliCream.Nitro.CommandLine.Tui.Board; +using ChilliCream.Nitro.CommandLine.Tui.Theming; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Board; + +public sealed class ColumnDefinitionTests +{ + [Fact] + public void ResolveBorderStyle_Should_UseNeutralBorderToken_When_NoBorderTokenAssigned_And_NotFocused() + { + // arrange + var definition = new ColumnDefinition { Name = "Backlog" }; + + // act + var style = definition.ResolveBorderStyle(focused: false); + + // assert + Assert.Equal(ThemeTokens.GetStyle("board.column.border"), style); + } + + [Fact] + public void ResolveBorderStyle_Should_UseNeutralFocusedBorderToken_When_NoBorderTokenAssigned_And_Focused() + { + // arrange + var definition = new ColumnDefinition { Name = "Backlog" }; + + // act + var style = definition.ResolveBorderStyle(focused: true); + + // assert + Assert.Equal(ThemeTokens.GetStyle("board.column.border.focused"), style); + } + + [Fact] + public void ResolveBorderStyle_Should_UseOwnAccentToken_When_BorderTokenAssigned_And_NotFocused() + { + // arrange + var definition = new ColumnDefinition + { + Name = "Blocked", + BorderToken = "board.column.status.blocked" + }; + + // act + var style = definition.ResolveBorderStyle(focused: false); + + // assert + Assert.Equal(ThemeTokens.GetStyle("board.column.status.blocked"), style); + } + + [Fact] + public void ResolveBorderStyle_Should_UseOwnFocusedAccentToken_When_BorderTokenAssigned_And_Focused() + { + // arrange + var definition = new ColumnDefinition + { + Name = "Blocked", + BorderToken = "board.column.status.blocked" + }; + + // act + var style = definition.ResolveBorderStyle(focused: true); + + // assert + Assert.Equal(ThemeTokens.GetStyle("board.column.status.blocked.focused"), style); + Assert.NotEqual(ThemeTokens.GetStyle("board.column.border.focused"), style); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/FakeTaskStore.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/FakeTaskStore.cs new file mode 100644 index 00000000000..a3a15a7d653 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/FakeTaskStore.cs @@ -0,0 +1,231 @@ +using System.Data.Common; +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using Microsoft.Data.Sqlite; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Board; + +/// <summary> +/// An in-memory <see cref="ITaskStore"/> exercising the query surface the +/// board model consumes (<see cref="QueryTasksAsync"/> and +/// <see cref="ComputeBlockedAsync(CancellationToken)"/>), plus the task +/// detail surface <see cref="ChilliCream.Nitro.CommandLine.Tui.Board.BoardDetailMode"/> +/// consumes (task by id, labels, dependencies, blocks, comments). Every +/// other member throws <see cref="NotSupportedException"/>. +/// </summary> +internal sealed class FakeTaskStore : ITaskStore +{ + public List<TaskItem> Tasks { get; } = []; + + public Dictionary<string, IReadOnlyList<string>> Blocked { get; } = []; + + public Dictionary<string, string[]> Labels { get; } = []; + + public Task<IReadOnlyList<TaskItem>> QueryTasksAsync( + TaskFilter filter, + CancellationToken cancellationToken) + { + IEnumerable<TaskItem> query = Tasks; + + if (filter.Statuses is { Length: > 0 } statuses) + { + query = query.Where(t => statuses.Contains(t.Status)); + } + else if (!filter.IncludeAll) + { + query = query.Where(t => !TaskStates.IsTerminal(t.Status)); + } + + if (filter.ExcludeTombstones) + { + query = query.Where(t => t.Status != TaskStates.Tombstone); + } + + if (filter.Type is { } type) + { + query = query.Where(t => t.Type == type); + } + + if (filter.Priority is { } priority) + { + query = query.Where(t => t.Priority == priority); + } + + if (filter.Unassigned) + { + query = query.Where(t => string.IsNullOrEmpty(t.Assignee)); + } + else if (filter.Assignee is { } assignee) + { + query = query.Where(t => t.Assignee == assignee); + } + + if (filter.Labels is { Length: > 0 } labels) + { + query = query.Where(t => labels.All(label => + Labels.TryGetValue(t.Id, out var taskLabels) && taskLabels.Contains(label))); + } + + if (filter.DeferredVisibleAt is { } visibleAt) + { + query = query.Where(t => t.DeferUntil is null || t.DeferUntil <= visibleAt); + } + + if (filter.ExcludeBlocked) + { + query = query.Where(t => !Blocked.ContainsKey(t.Id)); + } + + var result = query.ToList(); + + if (filter.Limit is { } limit) + { + result = result.Take(limit).ToList(); + } + + return Task.FromResult<IReadOnlyList<TaskItem>>(result); + } + + public Task<IReadOnlyDictionary<string, IReadOnlyList<string>>> ComputeBlockedAsync( + CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyDictionary<string, IReadOnlyList<string>>>(Blocked); + + public Task<TaskItem?> GetTaskAsync(string id, CancellationToken cancellationToken) + => Task.FromResult(Tasks.FirstOrDefault(t => t.Id == id)); + + public Task<IReadOnlyList<string>> GetLabelsAsync(string taskId, CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyList<string>>(Labels.TryGetValue(taskId, out var labels) ? labels : []); + + public Task<IReadOnlyList<TaskComment>> GetCommentsAsync(string taskId, CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyList<TaskComment>>([]); + + public Task<IReadOnlyList<TaskDependencyDetail>> GetDependenciesAsync(string taskId, CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyList<TaskDependencyDetail>>([]); + + public Task<IReadOnlyList<TaskDependentDetail>> GetDependentsAsync(string taskId, CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyList<TaskDependentDetail>>([]); + + public Task<SqliteConnection> InitializeAsync(string workspaceDirectory, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<SqliteConnection> ConnectAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public string? FindWorkspaceDirectory() + => throw new NotSupportedException(); + + public Task<string?> GetConfigAsync( + SqliteConnection connection, string key, CancellationToken cancellationToken, DbTransaction? transaction = null) + => throw new NotSupportedException(); + + public Task SetConfigAsync( + SqliteConnection connection, string key, string value, CancellationToken cancellationToken, DbTransaction? transaction = null) + => throw new NotSupportedException(); + + public Task<string> GetPrefixAsync( + SqliteConnection connection, CancellationToken cancellationToken, DbTransaction? transaction = null) + => throw new NotSupportedException(); + + public Task<string> CreateTaskIdAsync( + SqliteConnection connection, string? parentId, string seed, CancellationToken cancellationToken, DbTransaction? transaction = null) + => throw new NotSupportedException(); + + public Task RecordEventAsync( + SqliteConnection connection, TaskEvent taskEvent, CancellationToken cancellationToken, DbTransaction? transaction = null) + => throw new NotSupportedException(); + + public Task<TaskItem?> GetTaskAsync( + SqliteConnection connection, string id, CancellationToken cancellationToken, DbTransaction? transaction = null) + => throw new NotSupportedException(); + + public Task<TaskItem> GetRequiredTaskAsync( + SqliteConnection connection, string id, CancellationToken cancellationToken, DbTransaction? transaction = null) + => throw new NotSupportedException(); + + public Task<IReadOnlyDictionary<string, IReadOnlyList<string>>> ComputeBlockedAsync( + SqliteConnection connection, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem> GetRequiredTaskAsync(string id, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskLabelCount>> GetLabelCountsAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskDependency>> GetDependencyEdgesAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskEpicStatus>> GetEpicStatusesAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<int> CountTasksAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskCount>> CountTasksByAsync(TaskCountDimension dimension, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskStats> GetStatsAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<string?> GetConfigAsync(string key, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task SetConfigAsync(string key, string value, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskConfigEntry>> ListConfigAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<string> GetPrefixAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task InitializeWorkspaceAsync(string workspaceDirectory, string prefix, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskCreationResult> CreateTaskAsync(TaskCreation creation, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskUpdateResult> UpdateTaskAsync(string id, TaskUpdate update, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskItem>> CloseTaskAsync(IReadOnlyList<string> ids, string reason, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem> ReopenTaskAsync(string id, string reason, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem> DeferTaskAsync(string id, DateTimeOffset until, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem> UndeferTaskAsync(string id, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem> DeleteTaskAsync(string id, string reason, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskEpicStatus>> CloseEligibleEpicsAsync(string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskComment> AddCommentAsync(string id, string text, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskLabelChange>> AddLabelAsync(string id, IReadOnlyList<string> labels, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task RemoveLabelAsync(string id, string label, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskDependencyAddResult> AddDependencyAsync(string id, string dependsOnId, string type, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task RemoveDependencyAsync(string id, string dependsOnId, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskSyncRecord>> ExportTasksAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskImportResult> ImportTasksAsync(IReadOnlyList<TaskSyncRecord> records, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task EnsureWorkspaceAsync(string workspaceDirectory, CancellationToken cancellationToken) + => throw new NotSupportedException(); +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/TaskItemBuilder.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/TaskItemBuilder.cs new file mode 100644 index 00000000000..a9ed226c6d7 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Board/TaskItemBuilder.cs @@ -0,0 +1,38 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Board; + +/// <summary> +/// Builds <see cref="TaskItem"/> instances with sensible defaults for board +/// model tests. +/// </summary> +internal static class TaskItemBuilder +{ + public static TaskItem Create( + string id, + string status = TaskStates.Open, + int priority = TaskPriorities.Medium, + string type = TaskTypes.Task, + string? assignee = null, + DateTimeOffset? createdAt = null, + DateTimeOffset? updatedAt = null, + DateTimeOffset? closedAt = null, + DateTimeOffset? deferUntil = null) + { + var created = createdAt ?? DateTimeOffset.UnixEpoch; + + return new TaskItem + { + Id = id, + Title = id, + Status = status, + Priority = priority, + Type = type, + Assignee = assignee, + CreatedAt = created, + UpdatedAt = updatedAt ?? created, + ClosedAt = closedAt, + DeferUntil = deferUntil + }; + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/FakeTaskStore.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/FakeTaskStore.cs new file mode 100644 index 00000000000..1eac0109fcf --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/FakeTaskStore.cs @@ -0,0 +1,137 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Details; + +/// <summary> +/// An in-memory <see cref="ITaskStore"/> exercising only the read surface +/// the task detail view consumes (task by id, labels, dependencies both +/// directions, comments, blocked set). Every other member throws +/// <see cref="NotSupportedException"/>: the detail view never calls them. +/// </summary> +internal sealed class FakeTaskStore : ITaskStore +{ + public Dictionary<string, TaskItem> Tasks { get; } = []; + + public Dictionary<string, string[]> Labels { get; } = []; + + public Dictionary<string, List<TaskDependencyDetail>> Dependencies { get; } = []; + + public Dictionary<string, List<TaskDependentDetail>> Dependents { get; } = []; + + public Dictionary<string, List<TaskComment>> Comments { get; } = []; + + public Dictionary<string, IReadOnlyList<string>> Blocked { get; } = []; + + public Task<TaskItem?> GetTaskAsync(string id, CancellationToken cancellationToken) + => Task.FromResult(Tasks.GetValueOrDefault(id)); + + public Task<IReadOnlyList<string>> GetLabelsAsync(string taskId, CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyList<string>>(Labels.GetValueOrDefault(taskId) ?? []); + + public Task<IReadOnlyList<TaskDependencyDetail>> GetDependenciesAsync( + string taskId, CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyList<TaskDependencyDetail>>( + Dependencies.GetValueOrDefault(taskId) ?? []); + + public Task<IReadOnlyList<TaskDependentDetail>> GetDependentsAsync( + string taskId, CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyList<TaskDependentDetail>>( + Dependents.GetValueOrDefault(taskId) ?? []); + + public Task<IReadOnlyList<TaskComment>> GetCommentsAsync(string taskId, CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyList<TaskComment>>(Comments.GetValueOrDefault(taskId) ?? []); + + public Task<IReadOnlyDictionary<string, IReadOnlyList<string>>> ComputeBlockedAsync( + CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyDictionary<string, IReadOnlyList<string>>>(Blocked); + + public string? FindWorkspaceDirectory() + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskItem>> QueryTasksAsync(TaskFilter filter, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem> GetRequiredTaskAsync(string id, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskLabelCount>> GetLabelCountsAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskDependency>> GetDependencyEdgesAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskEpicStatus>> GetEpicStatusesAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<int> CountTasksAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskCount>> CountTasksByAsync(TaskCountDimension dimension, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskStats> GetStatsAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<string?> GetConfigAsync(string key, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task SetConfigAsync(string key, string value, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskConfigEntry>> ListConfigAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<string> GetPrefixAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task InitializeWorkspaceAsync(string workspaceDirectory, string prefix, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskCreationResult> CreateTaskAsync(TaskCreation creation, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskUpdateResult> UpdateTaskAsync(string id, TaskUpdate update, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskItem>> CloseTaskAsync(IReadOnlyList<string> ids, string reason, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem> ReopenTaskAsync(string id, string reason, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem> DeferTaskAsync(string id, DateTimeOffset until, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem> UndeferTaskAsync(string id, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem> DeleteTaskAsync(string id, string reason, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskEpicStatus>> CloseEligibleEpicsAsync(string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskComment> AddCommentAsync(string id, string text, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskLabelChange>> AddLabelAsync(string id, IReadOnlyList<string> labels, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task RemoveLabelAsync(string id, string label, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskDependencyAddResult> AddDependencyAsync(string id, string dependsOnId, string type, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task RemoveDependencyAsync(string id, string dependsOnId, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskSyncRecord>> ExportTasksAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskImportResult> ImportTasksAsync(IReadOnlyList<TaskSyncRecord> records, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task EnsureWorkspaceAsync(string workspaceDirectory, CancellationToken cancellationToken) + => throw new NotSupportedException(); +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/TaskDetailBodyTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/TaskDetailBodyTests.cs new file mode 100644 index 00000000000..a16dc67ec68 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/TaskDetailBodyTests.cs @@ -0,0 +1,198 @@ +using System.Text.RegularExpressions; +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Details; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Details; + +public sealed partial class TaskDetailBodyTests +{ + [Fact] + public async Task Build_Should_ReturnEmpty_When_NoTaskIsLoaded() + { + // arrange + var model = new TaskDetailModel(new FakeTaskStore()); + await model.LoadAsync("missing", CancellationToken.None); + + // act + var lines = TaskDetailBody.Build(model, 40, focused: true); + + // assert + Assert.Empty(lines); + } + + [Fact] + public async Task Build_Should_OmitEmptySections_And_KeepOrder() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["t-1"] = TaskItemBuilder.Create( + "t-1", + description: "desc", + notes: "note"); + var model = new TaskDetailModel(store); + await model.LoadAsync("t-1", CancellationToken.None); + + // act + var lines = TaskDetailBody.Build(model, 40, focused: true) + .Select(l => StripMarkupTags(l.Content)) + .ToList(); + + // assert: Design, Acceptance criteria, Dependencies, Blocks, Comments are all + // empty and must not appear; each present section renders as a 3-line box + // (top border, content, bottom border), with a blank line separating them. + Assert.Equal( + [ + BoxTop("Description", 40), + BoxContent("desc", 40), + BoxBottom(40), + "", + BoxTop("Notes", 40), + BoxContent("note", 40), + BoxBottom(40) + ], + lines); + } + + [Fact] + public async Task Build_Should_IncludeAllSections_InOrder_When_AllArePresent() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["t-1"] = TaskItemBuilder.Create( + "t-1", + description: "d", + design: "de", + acceptanceCriteria: "ac", + notes: "n"); + store.Dependencies["t-1"] = + [ + new TaskDependencyDetail { Type = TaskDependencyTypes.Blocks, DependsOnId = "t-2", Status = TaskStates.Open, Title = "Dep" } + ]; + store.Dependents["t-1"] = + [ + new TaskDependentDetail { Type = TaskDependencyTypes.Related, TaskId = "t-3", Status = TaskStates.Open, Title = "Blocked" } + ]; + store.Comments["t-1"] = + [ + new TaskComment { TaskId = "t-1", Author = "alice", Text = "c", CreatedAt = DateTimeOffset.UnixEpoch } + ]; + var model = new TaskDetailModel(store); + await model.LoadAsync("t-1", CancellationToken.None); + + var listStyleHeaders = new HashSet<string>(["Dependencies", "Blocks", "Comments"]); + + // act: Description, Design, Acceptance criteria, and Notes render as boxes, + // so their names are extracted from the box's top border; Dependencies, + // Blocks, and Comments stay a plain header line. + var headers = TaskDetailBody.Build(model, 40, focused: true) + .Select(l => StripMarkupTags(l.Content)) + .Where(l => l.StartsWith("โ•ญโ”€", StringComparison.Ordinal) || listStyleHeaders.Contains(l)) + .Select(l => l.StartsWith("โ•ญโ”€", StringComparison.Ordinal) ? BoxTitle(l) : l) + .ToList(); + + // assert + Assert.Equal( + ["Description", "Design", "Acceptance criteria", "Notes", "Dependencies", "Blocks", "Comments"], + headers); + } + + [Fact] + public async Task Build_Should_MarkSectionHeaders_AsMarkup() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["t-1"] = TaskItemBuilder.Create("t-1", description: "desc"); + var model = new TaskDetailModel(store); + await model.LoadAsync("t-1", CancellationToken.None); + + // act + var lines = TaskDetailBody.Build(model, 40, focused: true); + + // assert: the box's top and bottom borders are styled markup carrying the + // section name; the content row is plain, unescaped text. + Assert.Equal(BoxTop("Description", 40), StripMarkupTags(lines[0].Content)); + Assert.True(lines[0].IsMarkup); + Assert.Equal(new TaskDetailBodyLine(BoxContent("desc", 40), false), lines[1]); + Assert.Equal(BoxBottom(40), StripMarkupTags(lines[2].Content)); + Assert.True(lines[2].IsMarkup); + } + + [Fact] + public async Task Build_Should_MarkDependencyAndBlocksRows_AsMarkup() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["t-1"] = TaskItemBuilder.Create("t-1"); + store.Dependencies["t-1"] = + [ + new TaskDependencyDetail { Type = TaskDependencyTypes.Blocks, DependsOnId = "t-2", Status = TaskStates.Open, Title = "Dep" } + ]; + var model = new TaskDetailModel(store); + await model.LoadAsync("t-1", CancellationToken.None); + + // act + var lines = TaskDetailBody.Build(model, 40, focused: true).ToList(); + + // assert: the styled "Dependencies" header line is followed by a blank + // separator, then the markup row. + var headerIndex = lines.FindIndex(l => StripMarkupTags(l.Content) == "Dependencies"); + var blankLine = lines[headerIndex + 1]; + var rowLine = lines[headerIndex + 2]; + + Assert.True(lines[headerIndex].IsMarkup); + Assert.Equal(new TaskDetailBodyLine(string.Empty, false), blankLine); + Assert.True(rowLine.IsMarkup); + } + + [Fact] + public async Task Build_Should_MarkSelectedRow_AsSelected_When_Focused() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["t-1"] = TaskItemBuilder.Create("t-1"); + store.Dependencies["t-1"] = + [ + new TaskDependencyDetail { Type = TaskDependencyTypes.Blocks, DependsOnId = "t-2", Status = TaskStates.Open, Title = "Dep" } + ]; + var model = new TaskDetailModel(store); + await model.LoadAsync("t-1", CancellationToken.None); + + // act + var focusedLines = TaskDetailBody.Build(model, 40, focused: true); + var unfocusedLines = TaskDetailBody.Build(model, 40, focused: false); + + // assert: the sole dependency row is selected only while focused. + Assert.Contains(focusedLines, l => l.IsSelectedRow); + Assert.DoesNotContain(unfocusedLines, l => l.IsSelectedRow); + } + + private static string StripMarkupTags(string line) => MarkupTagPattern().Replace(line, ""); + + /// <summary> + /// The unstyled top border line <see cref="TaskDetailSectionBox"/> renders + /// for <paramref name="title"/> at <paramref name="width"/> columns. + /// </summary> + private static string BoxTop(string title, int width) + => $"โ•ญโ”€{title}{new string('โ”€', Math.Max(0, width - 3 - title.Length))}โ•ฎ"; + + /// <summary> + /// The content row <see cref="TaskDetailSectionBox"/> renders for one + /// already-wrapped <paramref name="line"/> at <paramref name="width"/> + /// columns. + /// </summary> + private static string BoxContent(string line, int width) => $"โ”‚ {line.PadRight(width - 4)} โ”‚"; + + /// <summary> + /// The unstyled bottom border line <see cref="TaskDetailSectionBox"/> + /// renders at <paramref name="width"/> columns. + /// </summary> + private static string BoxBottom(int width) => $"โ•ฐ{new string('โ”€', Math.Max(0, width - 2))}โ•ฏ"; + + /// <summary> + /// Recovers a section box's title from its unstyled top border line. + /// </summary> + private static string BoxTitle(string topBorderLine) => topBorderLine[2..^1].TrimEnd('โ”€'); + + [GeneratedRegex(@"\[[^\]]*\]")] + private static partial Regex MarkupTagPattern(); +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/TaskDetailModelTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/TaskDetailModelTests.cs new file mode 100644 index 00000000000..f47a10e5f4c --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/TaskDetailModelTests.cs @@ -0,0 +1,253 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Details; +using ChilliCream.Nitro.CommandLine.Tui.Input; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Details; + +public sealed class TaskDetailModelTests +{ + [Fact] + public void Constructor_Should_Throw_When_StoreIsNull() + { + // act & assert + Assert.Throws<ArgumentNullException>(() => new TaskDetailModel(null!)); + } + + [Fact] + public async Task LoadAsync_Should_PopulateTaskAndRelatedData_When_TaskExists() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["t-1"] = TaskItemBuilder.Create("t-1", title: "First task"); + store.Labels["t-1"] = ["backend", "urgent"]; + store.Comments["t-1"] = [new TaskComment + { + TaskId = "t-1", Author = "alice", Text = "hi", CreatedAt = DateTimeOffset.UnixEpoch + }]; + store.Blocked["t-1"] = ["t-0:not closed"]; + var model = new TaskDetailModel(store); + + // act + var loaded = await model.LoadAsync("t-1", CancellationToken.None); + + // assert + Assert.True(loaded); + Assert.Equal("t-1", model.CurrentTaskId); + Assert.Equal("First task", model.Task!.Title); + Assert.Equal(["backend", "urgent"], model.Labels); + Assert.Single(model.Comments); + Assert.Equal(["t-0:not closed"], model.BlockedBy); + } + + [Fact] + public async Task LoadAsync_Should_ClearState_When_TaskDoesNotExist() + { + // arrange + var store = new FakeTaskStore(); + var model = new TaskDetailModel(store); + + // act + var loaded = await model.LoadAsync("missing", CancellationToken.None); + + // assert + Assert.False(loaded); + Assert.Null(model.Task); + Assert.Equal("missing", model.CurrentTaskId); + Assert.Empty(model.Rows); + } + + [Fact] + public async Task LoadAsync_Should_BuildRows_DependenciesThenBlocks() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["t-1"] = TaskItemBuilder.Create("t-1"); + store.Dependencies["t-1"] = + [ + new TaskDependencyDetail { Type = TaskDependencyTypes.Blocks, DependsOnId = "t-2", Status = TaskStates.Open, Title = "Dep" } + ]; + store.Dependents["t-1"] = + [ + new TaskDependentDetail { Type = TaskDependencyTypes.Related, TaskId = "t-3", Status = TaskStates.Open, Title = "Blocked task" } + ]; + var model = new TaskDetailModel(store); + + // act + await model.LoadAsync("t-1", CancellationToken.None); + + // assert + Assert.Equal(2, model.Rows.Count); + Assert.Equal(TaskDetailRowKind.Dependency, model.Rows[0].Kind); + Assert.Equal("t-2", model.Rows[0].TargetId); + Assert.True(model.Rows[0].IsBlocking); + Assert.Equal(TaskDetailRowKind.Blocks, model.Rows[1].Kind); + Assert.Equal("t-3", model.Rows[1].TargetId); + Assert.False(model.Rows[1].IsBlocking); + } + + [Fact] + public async Task MoveRowCursor_Should_ClampToRowBounds() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["t-1"] = TaskItemBuilder.Create("t-1"); + store.Dependencies["t-1"] = + [ + new TaskDependencyDetail { Type = TaskDependencyTypes.Related, DependsOnId = "t-2", Status = TaskStates.Open, Title = "A" }, + new TaskDependencyDetail { Type = TaskDependencyTypes.Related, DependsOnId = "t-3", Status = TaskStates.Open, Title = "B" } + ]; + var model = new TaskDetailModel(store); + await model.LoadAsync("t-1", CancellationToken.None); + + // act & assert + Assert.Equal(0, model.SelectedRowIndex); + + model.MoveRowCursor(CursorDirection.Up); + Assert.Equal(0, model.SelectedRowIndex); + + model.MoveRowCursor(CursorDirection.Down); + Assert.Equal(1, model.SelectedRowIndex); + + model.MoveRowCursor(CursorDirection.Down); + Assert.Equal(1, model.SelectedRowIndex); + + model.MoveRowCursor(CursorDirection.Up); + Assert.Equal(0, model.SelectedRowIndex); + } + + [Fact] + public async Task OpenSelectedRowAsync_Should_NavigateAndPushBackStack_When_TargetExists() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["t-1"] = TaskItemBuilder.Create("t-1", title: "Origin"); + store.Tasks["t-2"] = TaskItemBuilder.Create("t-2", title: "Target"); + store.Dependencies["t-1"] = + [ + new TaskDependencyDetail { Type = TaskDependencyTypes.Related, DependsOnId = "t-2", Status = TaskStates.Open, Title = "Target" } + ]; + var model = new TaskDetailModel(store); + await model.LoadAsync("t-1", CancellationToken.None); + + // act + var navigated = await model.OpenSelectedRowAsync(CancellationToken.None); + + // assert + Assert.True(navigated); + Assert.Equal("t-2", model.Task!.Id); + Assert.True(model.CanGoBack); + } + + [Fact] + public async Task OpenSelectedRowAsync_Should_NotNavigate_When_TargetNoLongerExists() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["t-1"] = TaskItemBuilder.Create("t-1"); + store.Dependencies["t-1"] = + [ + new TaskDependencyDetail { Type = TaskDependencyTypes.Related, DependsOnId = "t-2", Status = null, Title = null } + ]; + var model = new TaskDetailModel(store); + await model.LoadAsync("t-1", CancellationToken.None); + + // act + var navigated = await model.OpenSelectedRowAsync(CancellationToken.None); + + // assert + Assert.False(navigated); + Assert.Equal("t-1", model.Task!.Id); + Assert.False(model.CanGoBack); + } + + [Fact] + public async Task OpenSelectedRowAsync_Should_ReturnFalse_When_NoRowsExist() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["t-1"] = TaskItemBuilder.Create("t-1"); + var model = new TaskDetailModel(store); + await model.LoadAsync("t-1", CancellationToken.None); + + // act + var navigated = await model.OpenSelectedRowAsync(CancellationToken.None); + + // assert + Assert.False(navigated); + } + + [Fact] + public async Task GoBackAsync_Should_PopStackAndReloadPreviousTask() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["t-1"] = TaskItemBuilder.Create("t-1", title: "Origin"); + store.Tasks["t-2"] = TaskItemBuilder.Create("t-2", title: "Target"); + store.Dependencies["t-1"] = + [ + new TaskDependencyDetail { Type = TaskDependencyTypes.Related, DependsOnId = "t-2", Status = TaskStates.Open, Title = "Target" } + ]; + var model = new TaskDetailModel(store); + await model.LoadAsync("t-1", CancellationToken.None); + await model.OpenSelectedRowAsync(CancellationToken.None); + Assert.Equal("t-2", model.Task!.Id); + + // act + var wentBack = await model.GoBackAsync(CancellationToken.None); + + // assert + Assert.True(wentBack); + Assert.Equal("t-1", model.Task!.Id); + Assert.False(model.CanGoBack); + } + + [Fact] + public async Task GoBackAsync_Should_ReturnFalse_When_StackIsEmpty() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["t-1"] = TaskItemBuilder.Create("t-1"); + var model = new TaskDetailModel(store); + await model.LoadAsync("t-1", CancellationToken.None); + + // act + var wentBack = await model.GoBackAsync(CancellationToken.None); + + // assert + Assert.False(wentBack); + Assert.Equal("t-1", model.Task!.Id); + } + + [Fact] + public async Task NavigationStack_Should_SupportMultipleHops() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["t-1"] = TaskItemBuilder.Create("t-1"); + store.Tasks["t-2"] = TaskItemBuilder.Create("t-2"); + store.Tasks["t-3"] = TaskItemBuilder.Create("t-3"); + store.Dependencies["t-1"] = + [ + new TaskDependencyDetail { Type = TaskDependencyTypes.Related, DependsOnId = "t-2", Status = TaskStates.Open, Title = "t2" } + ]; + store.Dependencies["t-2"] = + [ + new TaskDependencyDetail { Type = TaskDependencyTypes.Related, DependsOnId = "t-3", Status = TaskStates.Open, Title = "t3" } + ]; + var model = new TaskDetailModel(store); + await model.LoadAsync("t-1", CancellationToken.None); + + // act + await model.OpenSelectedRowAsync(CancellationToken.None); + await model.OpenSelectedRowAsync(CancellationToken.None); + Assert.Equal("t-3", model.Task!.Id); + + // assert: pops back through both hops in reverse order + await model.GoBackAsync(CancellationToken.None); + Assert.Equal("t-2", model.Task!.Id); + + await model.GoBackAsync(CancellationToken.None); + Assert.Equal("t-1", model.Task!.Id); + Assert.False(model.CanGoBack); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/TaskDetailSectionBoxTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/TaskDetailSectionBoxTests.cs new file mode 100644 index 00000000000..0f7beb7ad4b --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/TaskDetailSectionBoxTests.cs @@ -0,0 +1,110 @@ +using System.Text.RegularExpressions; +using ChilliCream.Nitro.CommandLine.Tui.Details; +using Spectre.Console; +using Spectre.Console.Testing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Details; + +public sealed partial class TaskDetailSectionBoxTests +{ + [Fact] + public void Render_Should_ReturnEmpty_When_TextIsEmpty() + { + // act + var lines = TaskDetailSectionBox.Render("Notes", "", 20); + + // assert + Assert.Empty(lines); + } + + [Fact] + public void Render_Should_ProduceRoundedBoxWithTitleAndPadding() + { + // act + var lines = TaskDetailSectionBox.Render("Notes", "hi", 10); + + // assert: a rounded border carries the title, one padded content row + // holds the text, and the border closes with a matching bottom row. + Assert.Equal( + [ + new TaskDetailBodyLine("โ•ญโ”€Notesโ”€โ”€โ•ฎ", IsMarkup: true), + new TaskDetailBodyLine("โ”‚ hi โ”‚", IsMarkup: false), + new TaskDetailBodyLine("โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ", IsMarkup: true) + ], + StripStyle(lines)); + } + + [Fact] + public void Render_Should_WrapContent_AtInteriorWidth_NotBoxWidth() + { + // act: at a 10-column box, the 4-column border-and-padding chrome + // leaves a 6-column interior, so "alpha beta gamma" wraps to three + // rows, not the two it would take if wrapped at the full box width. + var lines = TaskDetailSectionBox.Render("Notes", "alpha beta gamma", 10); + + // assert + Assert.Equal( + [ + "โ•ญโ”€Notesโ”€โ”€โ•ฎ", + "โ”‚ alpha โ”‚", + "โ”‚ beta โ”‚", + "โ”‚ gamma โ”‚", + "โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ" + ], + StripStyle(lines).Select(l => l.Content)); + } + + [Fact] + public void Render_Should_ProduceOneContentRow_ForShortNonEmptyText() + { + // act + var lines = TaskDetailSectionBox.Render("Notes", "hi", 40); + + // assert: top border, one content row, bottom border. + Assert.Equal(3, lines.Count); + } + + [Fact] + public void Render_Should_NotThrow_When_TitleIsLongerThanWidth() + { + // act + var exception = Record.Exception(() => TaskDetailSectionBox.Render("A very long section title", "text", 5)); + + // assert + Assert.Null(exception); + } + + [Fact] + public void Render_Should_NotThrow_When_WidthIsNarrowerThanChrome() + { + // act + var exception = Record.Exception(() => TaskDetailSectionBox.Render("Notes", "text", 1)); + + // assert + Assert.Null(exception); + } + + [Fact] + public void Render_Should_PreserveBracketsInContent_When_RenderedThroughSpectre() + { + // arrange: the content row is plain, unescaped text; Spectre.Console + // escapes it at render time the same way it escapes any other + // plain-text body line. + var lines = TaskDetailSectionBox.Render("Notes", "[greeting]", 20); + var console = new TestConsole().Width(20); + + // act + console.Write(new Markup(Markup.Escape(lines[1].Content))); + + // assert + Assert.Contains("[greeting]", console.Output); + } + + private static IReadOnlyList<TaskDetailBodyLine> StripStyle(IReadOnlyList<TaskDetailBodyLine> lines) + => lines.Select(l => l with { Content = StripMarkupTags(l.Content) }).ToList(); + + private static string StripMarkupTags(string line) => MarkupTagPattern().Replace(line, ""); + + [GeneratedRegex(@"\[[^\]]*\]")] + private static partial Regex MarkupTagPattern(); +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/TaskDetailSectionsTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/TaskDetailSectionsTests.cs new file mode 100644 index 00000000000..a60be43a89d --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/TaskDetailSectionsTests.cs @@ -0,0 +1,125 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Details; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Details; + +public sealed class TaskDetailSectionsTests +{ + [Fact] + public void BuildTextSection_Should_ReturnEmpty_When_TextIsEmpty() + { + // act + var lines = TaskDetailSections.BuildTextSection("Description:", "", 20); + + // assert + Assert.Empty(lines); + } + + [Fact] + public void BuildTextSection_Should_IncludeHeader_Then_WrappedLines() + { + // act + var lines = TaskDetailSections.BuildTextSection("Description:", "one two three four", 11); + + // assert + Assert.Equal(["Description:", "one two", "three four"], lines); + } + + [Fact] + public void BuildTextSection_Should_PreserveParagraphBreaks() + { + // act + var lines = TaskDetailSections.BuildTextSection("Notes:", "first\nsecond", 20); + + // assert + Assert.Equal(["Notes:", "first", "second"], lines); + } + + [Fact] + public void WrapLine_Should_ReturnLineUnwrapped_When_WidthIsZeroOrLess() + { + // act + var lines = TaskDetailSections.WrapLine("one two three", 0); + + // assert + Assert.Equal(["one two three"], lines); + } + + [Fact] + public void WrapLine_Should_ReturnOneEmptyLine_When_LineIsEmpty() + { + // act + var lines = TaskDetailSections.WrapLine("", 10); + + // assert + Assert.Equal([""], lines); + } + + [Fact] + public void WrapLine_Should_HardBreak_When_WordIsLongerThanWidth() + { + // act + var lines = TaskDetailSections.WrapLine("abcdefghij", 4); + + // assert + Assert.Equal(["abcd", "efgh", "ij"], lines); + } + + [Fact] + public void WrapLine_Should_FitWordsExactlyToWidth() + { + // act + var lines = TaskDetailSections.WrapLine("ab cd", 5); + + // assert + Assert.Equal(["ab cd"], lines); + } + + [Fact] + public void BuildCommentsSection_Should_ReturnEmpty_When_NoComments() + { + // act + var lines = TaskDetailSections.BuildCommentsSection([], 20); + + // assert + Assert.Empty(lines); + } + + [Fact] + public void BuildCommentsSection_Should_RenderAuthorTimestampAndWrappedText_WithBlankLineBetween() + { + // arrange + var comments = new List<TaskComment> + { + new() + { + TaskId = "t-1", + Author = "alice", + Text = "hello world", + CreatedAt = DateTimeOffset.UnixEpoch + }, + new() + { + TaskId = "t-1", + Author = "bob", + Text = "hi", + CreatedAt = DateTimeOffset.UnixEpoch + } + }; + + // act + var lines = TaskDetailSections.BuildCommentsSection(comments, 20); + + // assert + Assert.Equal( + [ + "Comments", + $"alice - {TaskDates.Format(DateTimeOffset.UnixEpoch)}", + "hello world", + "", + $"bob - {TaskDates.Format(DateTimeOffset.UnixEpoch)}", + "hi" + ], + lines); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/TaskDetailSidebarTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/TaskDetailSidebarTests.cs new file mode 100644 index 00000000000..3d7ff4a77d7 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/TaskDetailSidebarTests.cs @@ -0,0 +1,100 @@ +using System.Text.RegularExpressions; +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Details; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Details; + +public sealed partial class TaskDetailSidebarTests +{ + [Fact] + public void Build_Should_OmitOptionalFields_When_NotSet() + { + // arrange + var task = TaskItemBuilder.Create("t-1", createdBy: "alice"); + + // act + var lines = TaskDetailSidebar.Build(task, [], []).Select(StripMarkupTags); + + // assert: only the always-present status/priority/type/created/updated + // lines remain, none of the optional fields; the state and dates + // groups are separated by a blank line. + Assert.Equal( + [ + "โ—‹ open", + "Priority: P2", + "Type: task", + "", + $"Created: {TaskDates.Format(task.CreatedAt)} by alice", + $"Updated: {TaskDates.Format(task.UpdatedAt)}" + ], + lines); + } + + [Fact] + public void Build_Should_IncludeAllOptionalFields_When_Set() + { + // arrange + var task = TaskItemBuilder.Create( + "t-1", + status: TaskStates.Closed, + assignee: "bob", + estimatedMinutes: 30, + dueAt: DateTimeOffset.UnixEpoch, + deferUntil: DateTimeOffset.UnixEpoch, + createdBy: "alice", + closedAt: DateTimeOffset.UnixEpoch, + closeReason: "done"); + + // act + var lines = TaskDetailSidebar.Build(task, ["backend"], ["t-0:not closed"]).Select(StripMarkupTags); + + // assert: state, dates/people, labels, and blocked-by are separated + // by a blank line. + Assert.Equal( + [ + "โœ“ closed", + "Priority: P2", + "Type: task", + "", + "Assignee: bob", + $"Due: {TaskDates.Format(task.DueAt!.Value)}", + $"Deferred until: {TaskDates.Format(task.DeferUntil!.Value)}", + "Estimate: 30m", + $"Created: {TaskDates.Format(task.CreatedAt)} by alice", + $"Updated: {TaskDates.Format(task.UpdatedAt)}", + $"Closed: {TaskDates.Format(task.ClosedAt!.Value)} (done)", + "", + "Labels: backend", + "", + "Blocked by: t-0:not closed" + ], + lines); + } + + [Fact] + public void Build_Should_NotShowClosedLine_When_StatusIsNotClosed() + { + // arrange + var task = TaskItemBuilder.Create("t-1", status: TaskStates.Open, closedAt: DateTimeOffset.UnixEpoch); + + // act + var lines = TaskDetailSidebar.Build(task, [], []).Select(StripMarkupTags); + + // assert + Assert.Equal( + [ + "โ—‹ open", + "Priority: P2", + "Type: task", + "", + $"Created: {TaskDates.Format(task.CreatedAt)} by ", + $"Updated: {TaskDates.Format(task.UpdatedAt)}" + ], + lines); + } + + private static string StripMarkupTags(string line) => MarkupTagPattern().Replace(line, ""); + + [GeneratedRegex(@"\[[^\]]*\]")] + private static partial Regex MarkupTagPattern(); +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/TaskDetailViewTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/TaskDetailViewTests.cs new file mode 100644 index 00000000000..67b05012a1e --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/TaskDetailViewTests.cs @@ -0,0 +1,262 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Details; +using ChilliCream.Nitro.CommandLine.Tui.Input; +using Spectre.Console.Testing; +using CursorDirection = ChilliCream.Nitro.CommandLine.Tui.Input.CursorDirection; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Details; + +public sealed class TaskDetailViewTests +{ + [Fact] + public void Render_Should_NotThrow_When_WidthIsZeroOrLess() + { + // arrange + var view = new TaskDetailView(new TaskDetailModel(new FakeTaskStore())); + var console = new TestConsole().Width(10); + + // act + var exception = Record.Exception(() => console.Write(view.Render(0, 10, focused: true))); + + // assert + Assert.Null(exception); + } + + [Fact] + public async Task Render_Should_FillTheFrame_When_SideBySide() + { + // arrange: 110 columns is at or above the 100-column stacking threshold. + var view = await CreateViewAsync(); + + // act + var text = RenderToText(view, width: 110, height: 12); + + // assert + Assert.Equal(12, CountRows(text)); + } + + [Fact] + public async Task Render_Should_FillTheFrame_When_Stacked() + { + // arrange: 80 columns is below the 100-column stacking threshold. + var view = await CreateViewAsync(); + + // act + var text = RenderToText(view, width: 80, height: 16); + + // assert + Assert.Equal(16, CountRows(text)); + } + + [Fact] + public async Task Render_Should_ShowMoreBelowIndicator_When_BodyOverflowsHeight() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["t-1"] = TaskItemBuilder.Create( + "t-1", notes: string.Join('\n', Enumerable.Range(1, 30).Select(i => $"line {i}"))); + var view = await CreateViewAsync(store, "t-1"); + + // act + var text = RenderToText(view, width: 110, height: 10); + + // assert + Assert.Contains("more below", text); + } + + [Fact] + public async Task Render_Should_ShowMoreAboveIndicator_After_ScrollingDown() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["t-1"] = TaskItemBuilder.Create( + "t-1", notes: string.Join('\n', Enumerable.Range(1, 30).Select(i => $"line {i}"))); + var view = await CreateViewAsync(store, "t-1"); + + // act: an initial render establishes the viewport's window height and + // total line count, which scrolling depends on. + RenderToText(view, width: 110, height: 10); + + for (var i = 0; i < 10; i++) + { + view.ScrollDown(); + } + + var text = RenderToText(view, width: 110, height: 10); + + // assert + Assert.Contains("more above", text); + } + + [Fact] + public async Task Render_Should_KeepSelectedRow_Visible_When_CursorMovesPastTheViewport() + { + // arrange: ten dependency rows in a body panel too short to show them all + // at once. + var store = new FakeTaskStore(); + store.Tasks["t-1"] = TaskItemBuilder.Create("t-1"); + store.Dependencies["t-1"] = Enumerable.Range(0, 10) + .Select(i => new TaskDependencyDetail + { + Type = TaskDependencyTypes.Related, + DependsOnId = $"d-{i}", + Status = TaskStates.Open, + Title = $"Dep {i}" + }) + .ToList(); + var model = await LoadModelAsync(store, "t-1"); + var view = new TaskDetailView(model); + + // act: move the row cursor down to the last row. + for (var i = 0; i < 9; i++) + { + model.MoveRowCursor(CursorDirection.Down); + } + + var text = RenderToText(view, width: 110, height: 10, focused: true); + + // assert: the now-selected last row scrolled into view, and the + // viewport reports rows hidden above it. + Assert.Contains("d-9", text); + Assert.Contains("more above", text); + } + + [Fact] + public async Task Render_Should_KeepScrollOffset_When_TaskHasDependencyRows_And_BodyScrolledToBottom() + { + // arrange: a task with a dependency row (which claims a fixed, + // unmoving selected-row line index) and enough notes and comment + // lines that the body overflows a short viewport, with content after + // the dependency row so scrolling past it is observable. + var store = new FakeTaskStore(); + store.Tasks["t-1"] = TaskItemBuilder.Create( + "t-1", notes: string.Join('\n', Enumerable.Range(1, 30).Select(i => $"line {i}"))); + store.Dependencies["t-1"] = + [ + new TaskDependencyDetail + { + Type = TaskDependencyTypes.Related, + DependsOnId = "d-0", + Status = TaskStates.Open, + Title = "Dep 0" + } + ]; + store.Comments["t-1"] = Enumerable.Range(1, 7) + .Select(i => new TaskComment + { + Id = i, + TaskId = "t-1", + Author = "user", + Text = $"comment {i}", + CreatedAt = DateTimeOffset.UnixEpoch + }) + .ToList(); + var view = await CreateViewAsync(store, "t-1"); + RenderToText(view, width: 110, height: 10); + + // act: scroll all the way to the bottom, past the dependency row's + // fixed line index, then render again. + view.ScrollToBottom(); + var text = RenderToText(view, width: 110, height: 10); + + // assert: the bottom of the body stays in view instead of snapping + // back up to the dependency row. + Assert.Contains("comment 5", text); + Assert.Contains("more above", text); + } + + [Fact] + public async Task Render_Should_NotClipLastSidebarGroup_When_StackedAndAFieldWraps() + { + // arrange: a stacked-layout width narrow enough that "Created: + // 2026-01-01 09:00 by e2e-agent" wraps to two rows in the sidebar + // panel, with a "Blocked by" group after it whose fixed panel Height + // must grow to account for that wrap instead of clipping the group. + var store = new FakeTaskStore(); + store.Tasks["t-1"] = TaskItemBuilder.Create( + "t-1", createdAt: DateTimeOffset.UnixEpoch, createdBy: "e2e-agent"); + store.Blocked["t-1"] = ["some-other-task"]; + var view = await CreateViewAsync(store, "t-1"); + + // act + var text = RenderToText(view, width: 40, height: 20); + + // assert + Assert.Contains("Blocked by", text); + } + + [Fact] + public async Task Render_Should_ShowIdAndTitle_InBodyHeader() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["t-1"] = TaskItemBuilder.Create("t-1", title: "Sample task", description: "desc"); + var view = await CreateViewAsync(store, "t-1"); + + // act + var text = RenderToText(view, width: 110, height: 10); + + // assert + Assert.Contains("t-1", text); + Assert.Contains("Sample task", text); + } + + [Fact] + public async Task Render_Should_ShowNoDetailsMessage_When_TaskHasNoSections() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["t-1"] = TaskItemBuilder.Create("t-1"); + var view = await CreateViewAsync(store, "t-1"); + + // act + var text = RenderToText(view, width: 110, height: 10); + + // assert + Assert.Contains("No details.", text); + } + + [Fact] + public async Task Render_Should_ShowNotFoundMessage_When_TaskIsMissing() + { + // arrange + var model = new TaskDetailModel(new FakeTaskStore()); + await model.LoadAsync("missing", CancellationToken.None); + var view = new TaskDetailView(model); + + // act + var text = RenderToText(view, width: 110, height: 10); + + // assert + Assert.Contains("not found", text); + } + + private static async Task<TaskDetailView> CreateViewAsync(FakeTaskStore? store = null, string taskId = "t-1") + { + store ??= new FakeTaskStore(); + + if (!store.Tasks.ContainsKey(taskId)) + { + store.Tasks[taskId] = TaskItemBuilder.Create(taskId, description: "desc"); + } + + var model = await LoadModelAsync(store, taskId); + return new TaskDetailView(model); + } + + private static async Task<TaskDetailModel> LoadModelAsync(FakeTaskStore store, string taskId) + { + var model = new TaskDetailModel(store); + await model.LoadAsync(taskId, CancellationToken.None); + return model; + } + + private static string RenderToText(TaskDetailView view, int width, int height, bool focused = true) + { + var console = new TestConsole().Width(width).Height(height); + console.Write(view.Render(width, height, focused)); + return console.Output; + } + + private static int CountRows(string text) => text.Split('\n', StringSplitOptions.RemoveEmptyEntries).Length; +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/TaskItemBuilder.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/TaskItemBuilder.cs new file mode 100644 index 00000000000..5927ce6b735 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Details/TaskItemBuilder.cs @@ -0,0 +1,55 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Details; + +/// <summary> +/// Builds <see cref="TaskItem"/> instances with sensible defaults for task +/// detail tests. +/// </summary> +internal static class TaskItemBuilder +{ + public static TaskItem Create( + string id, + string title = "", + string status = TaskStates.Open, + int priority = TaskPriorities.Medium, + string type = TaskTypes.Task, + string? assignee = null, + string description = "", + string design = "", + string acceptanceCriteria = "", + string notes = "", + int? estimatedMinutes = null, + DateTimeOffset? dueAt = null, + DateTimeOffset? deferUntil = null, + DateTimeOffset? createdAt = null, + string createdBy = "", + DateTimeOffset? updatedAt = null, + DateTimeOffset? closedAt = null, + string closeReason = "") + { + var created = createdAt ?? DateTimeOffset.UnixEpoch; + + return new TaskItem + { + Id = id, + Title = title.Length == 0 ? id : title, + Status = status, + Priority = priority, + Type = type, + Assignee = assignee, + Description = description, + Design = design, + AcceptanceCriteria = acceptanceCriteria, + Notes = notes, + EstimatedMinutes = estimatedMinutes, + DueAt = dueAt, + DeferUntil = deferUntil, + CreatedAt = created, + CreatedBy = createdBy, + UpdatedAt = updatedAt ?? created, + ClosedAt = closedAt, + CloseReason = closeReason + }; + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/ConfirmDialogTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/ConfirmDialogTests.cs new file mode 100644 index 00000000000..a53833ec4a8 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/ConfirmDialogTests.cs @@ -0,0 +1,138 @@ +using ChilliCream.Nitro.CommandLine.Tui.Editing; +using ChilliCream.Nitro.CommandLine.Tui.Widgets.Form; +using Spectre.Console.Testing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Editing; + +public sealed class ConfirmDialogTests +{ + private static ConsoleKeyInfo Key(ConsoleKey key) => new('\0', key, false, false, false); + + private static ConsoleKeyInfo Char(char c) => new(c, ConsoleKey.NoName, false, false, false); + + private static ConfirmDialog CreateDialog() => new("Close task 'a1': \"Title\"?", "Close"); + + [Fact] + public void HandleKey_Should_ReturnCancelled_When_EscapePressedAtField() + { + // arrange + var dialog = CreateDialog(); + + // act + var result = dialog.HandleKey(Key(ConsoleKey.Escape)); + + // assert + Assert.IsType<ConfirmDialogResult.Cancelled>(result); + } + + [Fact] + public void HandleKey_Should_ReturnConfirmedWithEmptyReason_When_EnterPressedAtFieldWithNoText() + { + // arrange + var dialog = CreateDialog(); + + // act + var result = dialog.HandleKey(Key(ConsoleKey.Enter)); + + // assert + var confirmed = Assert.IsType<ConfirmDialogResult.Confirmed>(result); + Assert.Equal("", confirmed.Reason); + } + + [Fact] + public void HandleKey_Should_ReturnConfirmedWithTypedReason_When_TextEnteredThenEnter() + { + // arrange + var dialog = CreateDialog(); + dialog.HandleKey(Char('n')); + dialog.HandleKey(Char('o')); + + // act + var result = dialog.HandleKey(Key(ConsoleKey.Enter)); + + // assert + var confirmed = Assert.IsType<ConfirmDialogResult.Confirmed>(result); + Assert.Equal("no", confirmed.Reason); + } + + [Fact] + public void HandleKey_Should_ReturnNull_When_TabMovesFocusToButtons() + { + // arrange + var dialog = CreateDialog(); + + // act + var result = dialog.HandleKey(Key(ConsoleKey.Tab)); + + // assert + Assert.Null(result); + } + + [Fact] + public void HandleKey_Should_ReturnConfirmed_When_EnterActivatesDefaultButtonAfterTab() + { + // arrange: the confirm button is selected by default among the buttons. + var dialog = CreateDialog(); + dialog.HandleKey(Key(ConsoleKey.Tab)); + + // act + var result = dialog.HandleKey(Key(ConsoleKey.Enter)); + + // assert + var confirmed = Assert.IsType<ConfirmDialogResult.Confirmed>(result); + Assert.Equal("", confirmed.Reason); + } + + [Fact] + public void HandleKey_Should_ReturnCancelled_When_CancelButtonActivated() + { + // arrange + var dialog = CreateDialog(); + dialog.HandleKey(Key(ConsoleKey.Tab)); + dialog.HandleKey(Key(ConsoleKey.RightArrow)); + + // act + var result = dialog.HandleKey(Key(ConsoleKey.Enter)); + + // assert + Assert.IsType<ConfirmDialogResult.Cancelled>(result); + } + + [Fact] + public void HandleKey_Should_ReturnCancelled_When_EscapePressedAtButtons() + { + // arrange + var dialog = CreateDialog(); + dialog.HandleKey(Key(ConsoleKey.Tab)); + + // act + var result = dialog.HandleKey(Key(ConsoleKey.Escape)); + + // assert + Assert.IsType<ConfirmDialogResult.Cancelled>(result); + } + + [Fact] + public void Render_Should_IncludeMessage() + { + // arrange + var dialog = CreateDialog(); + var console = new TestConsole().Width(60).Height(10); + + // act + console.Write(dialog.Render(60, 10)); + + // assert + Assert.Contains("Close task", console.Output); + } + + [Fact] + public void Constructor_Should_Throw_When_MessageEmpty() + { + // act + var exception = Record.Exception(() => new ConfirmDialog("", "Close")); + + // assert + Assert.IsType<ArgumentException>(exception); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/FakeTaskStore.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/FakeTaskStore.cs new file mode 100644 index 00000000000..9a46d3a1acd --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/FakeTaskStore.cs @@ -0,0 +1,240 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Editing; + +/// <summary> +/// An in-memory <see cref="ITaskStore"/> exercising only the write surface +/// <see cref="TaskLifecycleActions"/>, <see cref="TaskEditorForm"/>, and +/// <see cref="TaskCreateForm"/> consume (close, reopen, delete, update, add +/// label, remove label, create). Every other member throws +/// <see cref="NotSupportedException"/>. +/// </summary> +internal sealed class FakeTaskStore : ITaskStore +{ + /// <summary> + /// When set, the next write call throws this instead of returning a + /// result. + /// </summary> + public ExitException? ThrowOnWrite { get; set; } + + public IReadOnlyList<string>? ClosedIds { get; private set; } + + public string? ClosedReason { get; private set; } + + public string? ReopenedId { get; private set; } + + public string? ReopenedReason { get; private set; } + + public string? DeletedId { get; private set; } + + public string? DeletedReason { get; private set; } + + public string? Actor { get; private set; } + + public TaskItem ResultTask { get; set; } = null!; + + public TaskCreation? CreationReceived { get; private set; } + + public TaskCreationResult CreationResult { get; set; } = new() { Id = "a1" }; + + public string? UpdatedId { get; private set; } + + public TaskUpdate? UpdateReceived { get; private set; } + + public TaskUpdateResult UpdateResult { get; set; } = new(); + + public string? AddLabelId { get; private set; } + + public IReadOnlyList<string>? AddedLabels { get; private set; } + + public string? RemoveLabelId { get; private set; } + + public List<string> RemovedLabels { get; } = []; + + public Task<IReadOnlyList<TaskItem>> CloseTaskAsync( + IReadOnlyList<string> ids, string reason, string actor, CancellationToken cancellationToken) + { + ClosedIds = ids; + ClosedReason = reason; + Actor = actor; + + if (ThrowOnWrite is { } exception) + { + throw exception; + } + + return Task.FromResult<IReadOnlyList<TaskItem>>([ResultTask]); + } + + public Task<TaskItem> ReopenTaskAsync( + string id, string reason, string actor, CancellationToken cancellationToken) + { + ReopenedId = id; + ReopenedReason = reason; + Actor = actor; + + if (ThrowOnWrite is { } exception) + { + throw exception; + } + + return Task.FromResult(ResultTask); + } + + public Task<TaskItem> DeleteTaskAsync( + string id, string reason, string actor, CancellationToken cancellationToken) + { + DeletedId = id; + DeletedReason = reason; + Actor = actor; + + if (ThrowOnWrite is { } exception) + { + throw exception; + } + + return Task.FromResult(ResultTask); + } + + public string? FindWorkspaceDirectory() => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskItem>> QueryTasksAsync(TaskFilter filter, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem?> GetTaskAsync(string id, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem> GetRequiredTaskAsync(string id, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<string>> GetLabelsAsync(string taskId, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskLabelCount>> GetLabelCountsAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskComment>> GetCommentsAsync(string taskId, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskDependencyDetail>> GetDependenciesAsync(string taskId, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskDependentDetail>> GetDependentsAsync(string taskId, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskDependency>> GetDependencyEdgesAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyDictionary<string, IReadOnlyList<string>>> ComputeBlockedAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskEpicStatus>> GetEpicStatusesAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<int> CountTasksAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskCount>> CountTasksByAsync(TaskCountDimension dimension, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskStats> GetStatsAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<string?> GetConfigAsync(string key, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task SetConfigAsync(string key, string value, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskConfigEntry>> ListConfigAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<string> GetPrefixAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task InitializeWorkspaceAsync(string workspaceDirectory, string prefix, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskCreationResult> CreateTaskAsync(TaskCreation creation, CancellationToken cancellationToken) + { + CreationReceived = creation; + Actor = creation.Actor; + + if (ThrowOnWrite is { } exception) + { + throw exception; + } + + return Task.FromResult(CreationResult); + } + + public Task<TaskUpdateResult> UpdateTaskAsync(string id, TaskUpdate update, CancellationToken cancellationToken) + { + UpdatedId = id; + UpdateReceived = update; + Actor = update.Actor; + + if (ThrowOnWrite is { } exception) + { + throw exception; + } + + return Task.FromResult(UpdateResult); + } + + public Task<TaskItem> DeferTaskAsync(string id, DateTimeOffset until, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem> UndeferTaskAsync(string id, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskEpicStatus>> CloseEligibleEpicsAsync(string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskComment> AddCommentAsync(string id, string text, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskLabelChange>> AddLabelAsync(string id, IReadOnlyList<string> labels, string actor, CancellationToken cancellationToken) + { + AddLabelId = id; + AddedLabels = labels; + Actor = actor; + + if (ThrowOnWrite is { } exception) + { + throw exception; + } + + return Task.FromResult<IReadOnlyList<TaskLabelChange>>( + [.. labels.Select(label => new TaskLabelChange(label, Added: true))]); + } + + public Task RemoveLabelAsync(string id, string label, string actor, CancellationToken cancellationToken) + { + RemoveLabelId = id; + RemovedLabels.Add(label); + Actor = actor; + + if (ThrowOnWrite is { } exception) + { + throw exception; + } + + return Task.CompletedTask; + } + + public Task<TaskDependencyAddResult> AddDependencyAsync(string id, string dependsOnId, string type, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task RemoveDependencyAsync(string id, string dependsOnId, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskSyncRecord>> ExportTasksAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskImportResult> ImportTasksAsync(IReadOnlyList<TaskSyncRecord> records, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task EnsureWorkspaceAsync(string workspaceDirectory, CancellationToken cancellationToken) + => throw new NotSupportedException(); +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/PriorityPickerTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/PriorityPickerTests.cs new file mode 100644 index 00000000000..6d69eccedda --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/PriorityPickerTests.cs @@ -0,0 +1,59 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Editing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Editing; + +public sealed class PriorityPickerTests +{ + [Fact] + public void Create_Should_PreSelectTasksCurrentPriority() + { + // arrange + var task = TaskItemBuilder.Create("a1", priority: TaskPriorities.High); + + // act + var picker = PriorityPicker.Create(task); + var applied = Assert.IsType<QuickPickerResult.Applied>( + picker.HandleKey(new ConsoleKeyInfo('\0', ConsoleKey.Enter, false, false, false))); + + // assert + Assert.Equal("1", applied.SelectedId); + } + + [Fact] + public async Task ApplyAsync_Should_CallUpdateTaskAsync_WithPriorityGiven() + { + // arrange + var task = TaskItemBuilder.Create("a1", priority: TaskPriorities.Medium); + var store = new FakeTaskStore { UpdateResult = new TaskUpdateResult { ChangedFields = ["priority"] } }; + + // act + var outcome = await PriorityPicker.ApplyAsync( + store, task, TaskPriorities.Critical, "me", CancellationToken.None); + + // assert + Assert.Equal("a1", store.UpdatedId); + Assert.True(store.UpdateReceived!.PriorityGiven); + Assert.Equal(TaskPriorities.Critical, store.UpdateReceived.Priority); + Assert.False(store.UpdateReceived.StatusGiven); + Assert.Equal("me", store.Actor); + var succeeded = Assert.IsType<TaskEditorOutcome.Succeeded>(outcome); + Assert.Contains("P0", succeeded.ToastText); + } + + [Fact] + public async Task ApplyAsync_Should_ReturnFailed_When_StoreThrows() + { + // arrange + var task = TaskItemBuilder.Create("a1"); + var store = new FakeTaskStore { ThrowOnWrite = new ExitException("not found") }; + + // act + var outcome = await PriorityPicker.ApplyAsync( + store, task, TaskPriorities.Low, "me", CancellationToken.None); + + // assert + var failed = Assert.IsType<TaskEditorOutcome.Failed>(outcome); + Assert.Equal("not found", failed.ToastText); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/QuickPickerTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/QuickPickerTests.cs new file mode 100644 index 00000000000..0be366fddbb --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/QuickPickerTests.cs @@ -0,0 +1,172 @@ +using ChilliCream.Nitro.CommandLine.Tui.Editing; +using Spectre.Console.Testing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Editing; + +public sealed class QuickPickerTests +{ + private static ConsoleKeyInfo Key(ConsoleKey key) => new('\0', key, false, false, false); + + private static QuickPicker CreatePicker(string? initialSelectedId = null) => new( + "Status", + [ + new QuickPickerOption("open", "Open"), + new QuickPickerOption("in_progress", "In Progress"), + new QuickPickerOption("blocked", "Blocked") + ], + initialSelectedId); + + [Fact] + public void HandleKey_Should_ApplyFirstOption_When_EnterPressedWithNoMovement() + { + // arrange + var picker = CreatePicker(); + + // act + var result = picker.HandleKey(Key(ConsoleKey.Enter)); + + // assert + var applied = Assert.IsType<QuickPickerResult.Applied>(result); + Assert.Equal("open", applied.SelectedId); + } + + [Fact] + public void HandleKey_Should_ApplyPreSelectedOption_When_InitialSelectedIdGiven() + { + // arrange + var picker = CreatePicker(initialSelectedId: "blocked"); + + // act + var result = picker.HandleKey(Key(ConsoleKey.Enter)); + + // assert + var applied = Assert.IsType<QuickPickerResult.Applied>(result); + Assert.Equal("blocked", applied.SelectedId); + } + + [Fact] + public void HandleKey_Should_MoveSelectionDown_When_JOrDownArrowPressed() + { + // arrange + var picker = CreatePicker(); + picker.HandleKey(Key(ConsoleKey.J)); + + // act + var result = picker.HandleKey(Key(ConsoleKey.Enter)); + + // assert + var applied = Assert.IsType<QuickPickerResult.Applied>(result); + Assert.Equal("in_progress", applied.SelectedId); + } + + [Fact] + public void HandleKey_Should_StopAtLastOption_When_MovingDownPastTheEnd() + { + // arrange + var picker = CreatePicker(); + picker.HandleKey(Key(ConsoleKey.DownArrow)); + picker.HandleKey(Key(ConsoleKey.DownArrow)); + picker.HandleKey(Key(ConsoleKey.DownArrow)); + + // act + var result = picker.HandleKey(Key(ConsoleKey.Enter)); + + // assert + var applied = Assert.IsType<QuickPickerResult.Applied>(result); + Assert.Equal("blocked", applied.SelectedId); + } + + [Fact] + public void HandleKey_Should_MoveSelectionUp_When_KOrUpArrowPressed() + { + // arrange + var picker = CreatePicker(initialSelectedId: "blocked"); + picker.HandleKey(Key(ConsoleKey.K)); + + // act + var result = picker.HandleKey(Key(ConsoleKey.Enter)); + + // assert + var applied = Assert.IsType<QuickPickerResult.Applied>(result); + Assert.Equal("in_progress", applied.SelectedId); + } + + [Fact] + public void HandleKey_Should_StopAtFirstOption_When_MovingUpPastTheStart() + { + // arrange + var picker = CreatePicker(); + + // act + picker.HandleKey(Key(ConsoleKey.UpArrow)); + var result = picker.HandleKey(Key(ConsoleKey.Enter)); + + // assert + var applied = Assert.IsType<QuickPickerResult.Applied>(result); + Assert.Equal("open", applied.SelectedId); + } + + [Fact] + public void HandleKey_Should_ReturnCancelled_When_EscapePressed() + { + // arrange + var picker = CreatePicker(); + + // act + var result = picker.HandleKey(Key(ConsoleKey.Escape)); + + // assert + Assert.IsType<QuickPickerResult.Cancelled>(result); + } + + [Fact] + public void HandleKey_Should_ReturnNull_When_MovementKeyPressed() + { + // arrange + var picker = CreatePicker(); + + // act + var result = picker.HandleKey(Key(ConsoleKey.J)); + + // assert + Assert.Null(result); + } + + [Fact] + public void Render_Should_IncludeTitleAndOptionLabels() + { + // arrange + var picker = CreatePicker(); + var console = new TestConsole().Width(60).Height(10); + + // act + console.Write(picker.Render(60, 10)); + + // assert + Assert.Contains("Status", console.Output); + Assert.Contains("Open", console.Output); + Assert.Contains("In Progress", console.Output); + Assert.Contains("Blocked", console.Output); + } + + [Fact] + public void Constructor_Should_Throw_When_TitleEmpty() + { + // act + var exception = Record.Exception( + () => new QuickPicker("", [new QuickPickerOption("a", "A")])); + + // assert + Assert.IsType<ArgumentException>(exception); + } + + [Fact] + public void Constructor_Should_Throw_When_OptionsEmpty() + { + // act + var exception = Record.Exception(() => new QuickPicker("Status", [])); + + // assert + Assert.IsType<ArgumentException>(exception); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/StatusPickerTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/StatusPickerTests.cs new file mode 100644 index 00000000000..08710917f74 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/StatusPickerTests.cs @@ -0,0 +1,59 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Editing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Editing; + +public sealed class StatusPickerTests +{ + [Fact] + public void Create_Should_PreSelectTasksCurrentStatus() + { + // arrange + var task = TaskItemBuilder.Create("a1", status: TaskStates.Blocked); + + // act + var picker = StatusPicker.Create(task); + var applied = Assert.IsType<QuickPickerResult.Applied>( + picker.HandleKey(new ConsoleKeyInfo('\0', ConsoleKey.Enter, false, false, false))); + + // assert + Assert.Equal(TaskStates.Blocked, applied.SelectedId); + } + + [Fact] + public async Task ApplyAsync_Should_CallUpdateTaskAsync_WithStatusGiven() + { + // arrange + var task = TaskItemBuilder.Create("a1", status: TaskStates.Open); + var store = new FakeTaskStore { UpdateResult = new TaskUpdateResult { ChangedFields = ["status"] } }; + + // act + var outcome = await StatusPicker.ApplyAsync( + store, task, TaskStates.InProgress, "me", CancellationToken.None); + + // assert + Assert.Equal("a1", store.UpdatedId); + Assert.True(store.UpdateReceived!.StatusGiven); + Assert.Equal(TaskStates.InProgress, store.UpdateReceived.Status); + Assert.False(store.UpdateReceived.TitleGiven); + Assert.Equal("me", store.Actor); + var succeeded = Assert.IsType<TaskEditorOutcome.Succeeded>(outcome); + Assert.Contains("in_progress", succeeded.ToastText); + } + + [Fact] + public async Task ApplyAsync_Should_ReturnFailed_When_StoreThrows() + { + // arrange + var task = TaskItemBuilder.Create("a1", status: TaskStates.Closed); + var store = new FakeTaskStore { ThrowOnWrite = new ExitException("Use `nitro agent tasks reopen` to reopen a task.") }; + + // act + var outcome = await StatusPicker.ApplyAsync( + store, task, TaskStates.Open, "me", CancellationToken.None); + + // assert + var failed = Assert.IsType<TaskEditorOutcome.Failed>(outcome); + Assert.Equal("Use `nitro agent tasks reopen` to reopen a task.", failed.ToastText); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/TaskCreateFormTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/TaskCreateFormTests.cs new file mode 100644 index 00000000000..9430e980e7c --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/TaskCreateFormTests.cs @@ -0,0 +1,348 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Editing; +using ChilliCream.Nitro.CommandLine.Tui.Input; +using ChilliCream.Nitro.CommandLine.Tui.Widgets.Form; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Editing; + +public sealed class TaskCreateFormTests +{ + private static ConsoleKeyInfo Key(char c) => new(c, ConsoleKey.NoName, false, false, false); + + private static ConsoleKeyInfo Key(ConsoleKey key) => new('\0', key, false, false, false); + + private static ConsoleKeyInfo CtrlKey(ConsoleKey key) => new('\0', key, false, false, true); + + private static void Type(TaskCreateForm form, string text) + { + foreach (var c in text) + { + form.HandleKey(Key(c)); + } + } + + private static void TabTo(TaskCreateForm form, int times) + { + for (var i = 0; i < times; i++) + { + form.HandleKey(Key(ConsoleKey.Tab)); + } + } + + private static FormResult.Submitted Save(TaskCreateForm form) + { + // Tab from wherever focus currently sits until it reaches the button + // row (FocusedField null), then activate the default Create button. + while (form.FocusedField is not null) + { + form.HandleKey(Key(ConsoleKey.Tab)); + } + + return Assert.IsType<FormResult.Submitted>(form.HandleKey(Key(ConsoleKey.Enter))); + } + + [Fact] + public void Constructor_Should_PresetType_ToGivenTypePreset() + { + // arrange + var form = new TaskCreateForm(TaskTypes.Epic); + + // act + TabTo(form, 1); + + // assert + Assert.Equal(new FormValue.Text(TaskTypes.Epic), form.FocusedField?.GetValue()); + } + + [Fact] + public void Constructor_Should_DefaultPriority_ToMedium() + { + // arrange + var form = new TaskCreateForm(TaskTypes.Task); + + // act + TabTo(form, 2); + + // assert + Assert.Equal(new FormValue.Text("2"), form.FocusedField?.GetValue()); + } + + [Fact] + public void Constructor_Should_LeaveLabelsAndDescription_Empty() + { + // arrange + var form = new TaskCreateForm(TaskTypes.Task); + + // act + TabTo(form, 3); + + // assert + Assert.Equal(new FormValue.List([]), form.FocusedField?.GetValue()); + + TabTo(form, 1); + Assert.Equal(new FormValue.Text(""), form.FocusedField?.GetValue()); + } + + [Fact] + public void IsDirty_Should_BeFalse_Initially() + { + // arrange + var form = new TaskCreateForm(TaskTypes.Task); + + // assert + Assert.False(form.IsDirty); + } + + [Fact] + public void IsDirty_Should_BeTrue_When_TitleEdited() + { + // arrange + var form = new TaskCreateForm(TaskTypes.Task); + + // act + form.HandleKey(Key('!')); + + // assert + Assert.True(form.IsDirty); + } + + [Fact] + public void HandleKey_Should_ReturnNull_When_EnterOnCreateWithEmptyTitle() + { + // arrange + var form = new TaskCreateForm(TaskTypes.Task); + TabTo(form, 5); + + // act + var result = form.HandleKey(Key(ConsoleKey.Enter)); + + // assert: the form stays open because the required title is empty. + Assert.Null(result); + } + + [Fact] + public void HandleKey_Should_ReturnNull_When_EnterOnCreateWithWhitespaceOnlyTitle() + { + // arrange + var form = new TaskCreateForm(TaskTypes.Task); + Type(form, " "); + TabTo(form, 5); + + // act + var result = form.HandleKey(Key(ConsoleKey.Enter)); + + // assert + Assert.Null(result); + } + + [Fact] + public void HandleKey_Should_ReturnCancelled_When_Escape() + { + // arrange + var form = new TaskCreateForm(TaskTypes.Task); + + // act + var result = form.HandleKey(Key(ConsoleKey.Escape)); + + // assert + Assert.IsType<FormResult.Cancelled>(result); + } + + [Fact] + public void HandleKey_Should_Create_When_CtrlEnterFromTitleField() + { + // arrange: focus never leaves the title field, no Tab to Create. + var form = new TaskCreateForm(TaskTypes.Task); + Type(form, "New task"); + + // act + var result = form.HandleKey(CtrlKey(ConsoleKey.Enter)); + + // assert + var submitted = Assert.IsType<FormResult.Submitted>(result); + Assert.Equal(new FormValue.Text("New task"), submitted.Values[TaskCreateForm.TitleFieldId]); + } + + [Fact] + public void HandleKey_Should_Create_When_CtrlSFromTitleField() + { + // arrange: Ctrl+S is the fallback save chord for terminals that + // deliver Ctrl+Enter identically to a plain Enter. + var form = new TaskCreateForm(TaskTypes.Task); + Type(form, "New task"); + + // act + var result = form.HandleKey(CtrlKey(ConsoleKey.S)); + + // assert + var submitted = Assert.IsType<FormResult.Submitted>(result); + Assert.Equal(new FormValue.Text("New task"), submitted.Values[TaskCreateForm.TitleFieldId]); + } + + [Fact] + public void HandleKey_Should_NotClose_When_CtrlEnterWithEmptyTitle() + { + // arrange + var form = new TaskCreateForm(TaskTypes.Task); + + // act + var result = form.HandleKey(CtrlKey(ConsoleKey.Enter)); + + // assert: the form stays open and focus moves to the invalid title + // field instead of closing. + Assert.Null(result); + Assert.Equal(TaskCreateForm.TitleFieldId, form.FocusedField?.Id); + } + + [Fact] + public async Task SubmitAsync_Should_CreateTopLevelTask_When_NoParentId() + { + // arrange + var form = new TaskCreateForm(TaskTypes.Task); + Type(form, "Fix the parser"); + var submitted = Save(form); + var store = new FakeTaskStore { CreationResult = new TaskCreationResult { Id = "a1" } }; + + // act + var outcome = await form.SubmitAsync(store, submitted.Values, "me", CancellationToken.None); + + // assert + var creation = store.CreationReceived!; + Assert.Equal("Fix the parser", creation.Title); + Assert.Equal(TaskTypes.Task, creation.Type); + Assert.Equal(TaskPriorities.Medium, creation.Priority); + Assert.Null(creation.ParentId); + Assert.Equal("me", creation.Actor); + var succeeded = Assert.IsType<TaskCreateOutcome.Succeeded>(outcome); + Assert.Equal("a1", succeeded.TaskId); + Assert.Equal("Created task 'a1'.", succeeded.ToastText); + } + + [Fact] + public async Task SubmitAsync_Should_PassParentId_Through_ToTheStore() + { + // arrange: a task created as the child of the currently selected task. + var form = new TaskCreateForm(TaskTypes.Task, parentId: "app-1a2"); + Type(form, "Sub task"); + var submitted = Save(form); + var store = new FakeTaskStore { CreationResult = new TaskCreationResult { Id = "app-1a2.3" } }; + + // act + var outcome = await form.SubmitAsync(store, submitted.Values, "me", CancellationToken.None); + + // assert + Assert.Equal("app-1a2", store.CreationReceived!.ParentId); + var succeeded = Assert.IsType<TaskCreateOutcome.Succeeded>(outcome); + Assert.Equal("app-1a2.3", succeeded.TaskId); + } + + [Fact] + public void Constructor_Should_DefaultParentField_ToChild_When_ParentIdGiven() + { + // arrange + var form = new TaskCreateForm(TaskTypes.Task, parentId: "app-1a2"); + + // act + TabTo(form, 2); + + // assert + Assert.Equal(new FormValue.Text(TaskCreateForm.ChildParentOptionId), form.FocusedField?.GetValue()); + } + + [Fact] + public async Task SubmitAsync_Should_CreateTopLevelTask_When_ParentFieldSwitchedToNoParent() + { + // arrange: the board always has a selection once a column is + // populated, so this is the only gesture that lets a root task be + // created while a row is selected. + var form = new TaskCreateForm(TaskTypes.Task, parentId: "app-1a2"); + Type(form, "Root task"); + TabTo(form, 2); + form.HandleKey(Key(ConsoleKey.RightArrow)); + var submitted = Save(form); + var store = new FakeTaskStore { CreationResult = new TaskCreationResult { Id = "a2" } }; + + // act + var outcome = await form.SubmitAsync(store, submitted.Values, "me", CancellationToken.None); + + // assert + Assert.Null(store.CreationReceived!.ParentId); + var succeeded = Assert.IsType<TaskCreateOutcome.Succeeded>(outcome); + Assert.Equal("a2", succeeded.TaskId); + } + + [Fact] + public void IsDirty_Should_BeTrue_When_ParentFieldSwitchedToNoParent() + { + // arrange + var form = new TaskCreateForm(TaskTypes.Task, parentId: "app-1a2"); + TabTo(form, 2); + + // act + form.HandleKey(Key(ConsoleKey.RightArrow)); + + // assert + Assert.True(form.IsDirty); + } + + [Fact] + public async Task SubmitAsync_Should_CreateEpic_When_BuiltWithEpicPreset() + { + // arrange + var form = new TaskCreateForm(TaskTypes.Epic); + Type(form, "Search overhaul"); + var submitted = Save(form); + var store = new FakeTaskStore(); + + // act + await form.SubmitAsync(store, submitted.Values, "me", CancellationToken.None); + + // assert + Assert.Equal(TaskTypes.Epic, store.CreationReceived!.Type); + } + + [Fact] + public async Task SubmitAsync_Should_ReturnFailed_When_StoreThrows() + { + // arrange + var form = new TaskCreateForm(TaskTypes.Task); + Type(form, "Title"); + var submitted = Save(form); + var store = new FakeTaskStore { ThrowOnWrite = new ExitException("rejected") }; + + // act + var outcome = await form.SubmitAsync(store, submitted.Values, "me", CancellationToken.None); + + // assert + var failed = Assert.IsType<TaskCreateOutcome.Failed>(outcome); + Assert.Equal("rejected", failed.ToastText); + } + + [Fact] + public void ToShowToast_Should_UseSuccessStyle_ForSucceeded() + { + // arrange + var outcome = new TaskCreateOutcome.Succeeded("a1", "Created task 'a1'."); + + // act + var toast = outcome.ToShowToast(); + + // assert + Assert.Equal("Created task 'a1'.", toast.Text); + Assert.Equal(ToastStyle.Success, toast.Style); + } + + [Fact] + public void ToShowToast_Should_UseErrorStyle_ForFailed() + { + // arrange + var outcome = new TaskCreateOutcome.Failed("rejected"); + + // act + var toast = outcome.ToShowToast(); + + // assert + Assert.Equal("rejected", toast.Text); + Assert.Equal(ToastStyle.Error, toast.Style); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/TaskEditorFormTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/TaskEditorFormTests.cs new file mode 100644 index 00000000000..7fc5d6eafc3 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/TaskEditorFormTests.cs @@ -0,0 +1,396 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Editing; +using ChilliCream.Nitro.CommandLine.Tui.Input; +using ChilliCream.Nitro.CommandLine.Tui.Widgets.Form; +using Spectre.Console.Testing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Editing; + +public sealed class TaskEditorFormTests +{ + private static ConsoleKeyInfo Key(char c) => new(c, ConsoleKey.NoName, false, false, false); + + private static ConsoleKeyInfo Key(ConsoleKey key) => new('\0', key, false, false, false); + + private static ConsoleKeyInfo CtrlKey(ConsoleKey key) => new('\0', key, false, false, true); + + private static void Type(TaskEditorForm form, string text) + { + foreach (var c in text) + { + form.HandleKey(Key(c)); + } + } + + private static void TabTo(TaskEditorForm form, int times) + { + for (var i = 0; i < times; i++) + { + form.HandleKey(Key(ConsoleKey.Tab)); + } + } + + private static FormResult.Submitted Save(TaskEditorForm form) + { + // Tab from wherever focus currently sits until it reaches the button + // row (FocusedField null), then activate the default Save button. + while (form.FocusedField is not null) + { + form.HandleKey(Key(ConsoleKey.Tab)); + } + + return Assert.IsType<FormResult.Submitted>(form.HandleKey(Key(ConsoleKey.Enter))); + } + + [Fact] + public void Constructor_Should_PrepopulateEveryField_FromTaskAndLabels() + { + // arrange + var task = TaskItemBuilder.Create( + "a1", + "Title", + TaskStates.InProgress, + priority: 1, + type: TaskTypes.Bug, + description: "desc", + notes: "notes"); + + // act + var form = new TaskEditorForm(task, ["alpha", "beta"]); + + // assert + Assert.Equal(new FormValue.Text("Title"), form.FocusedField?.GetValue()); + + TabTo(form, 1); + Assert.Equal(new FormValue.Text(TaskStates.InProgress), form.FocusedField?.GetValue()); + + TabTo(form, 1); + Assert.Equal(new FormValue.Text("1"), form.FocusedField?.GetValue()); + + TabTo(form, 1); + Assert.Equal(new FormValue.Text(TaskTypes.Bug), form.FocusedField?.GetValue()); + + TabTo(form, 1); + Assert.Equal(new FormValue.List(["alpha", "beta"]), form.FocusedField?.GetValue()); + + TabTo(form, 1); + Assert.Equal(new FormValue.Text("desc"), form.FocusedField?.GetValue()); + + TabTo(form, 1); + Assert.Equal(new FormValue.Text("notes"), form.FocusedField?.GetValue()); + } + + [Fact] + public void Constructor_Should_NormalizeCrlf_InDescriptionAndNotes() + { + // arrange + var task = TaskItemBuilder.Create( + "a1", description: "line1\r\nline2", notes: "n1\r\nn2"); + + // act + var form = new TaskEditorForm(task, []); + + // assert + TabTo(form, 5); + Assert.Equal(new FormValue.Text("line1\nline2"), form.FocusedField?.GetValue()); + + TabTo(form, 1); + Assert.Equal(new FormValue.Text("n1\nn2"), form.FocusedField?.GetValue()); + } + + [Fact] + public void Constructor_Should_RoundTripCustomStatus_When_NotAmongWellKnownOptions() + { + // arrange: a closed task opened for editing keeps its own status + // selected instead of silently defaulting to "open". + var task = TaskItemBuilder.Create("a1", status: TaskStates.Closed); + + // act + var form = new TaskEditorForm(task, []); + + // assert + TabTo(form, 1); + Assert.Equal(new FormValue.Text(TaskStates.Closed), form.FocusedField?.GetValue()); + Assert.False(form.IsDirty); + } + + [Fact] + public void IsDirty_Should_BeFalse_Initially() + { + // arrange + var task = TaskItemBuilder.Create("a1", "Title"); + + // act + var form = new TaskEditorForm(task, ["x"]); + + // assert + Assert.False(form.IsDirty); + } + + [Fact] + public void IsDirty_Should_BeTrue_When_TitleEdited() + { + // arrange + var task = TaskItemBuilder.Create("a1", "Title"); + var form = new TaskEditorForm(task, []); + + // act + form.HandleKey(Key('!')); + + // assert + Assert.True(form.IsDirty); + } + + [Fact] + public void HandleKey_Should_ReturnNull_When_EnterOnSaveWithEmptyTitle() + { + // arrange + var task = TaskItemBuilder.Create("a1", "T"); + var form = new TaskEditorForm(task, []); + form.HandleKey(Key(ConsoleKey.Backspace)); + TabTo(form, 7); + + // act + var result = form.HandleKey(Key(ConsoleKey.Enter)); + + // assert: the form stays open because the required title is empty. + Assert.Null(result); + } + + [Fact] + public void HandleKey_Should_ReturnNull_When_EnterOnSaveWithWhitespaceOnlyTitle() + { + // arrange + var task = TaskItemBuilder.Create("a1", "T"); + var form = new TaskEditorForm(task, []); + form.HandleKey(Key(ConsoleKey.Backspace)); + Type(form, " "); + TabTo(form, 7); + + // act + var result = form.HandleKey(Key(ConsoleKey.Enter)); + + // assert: the form stays open because a whitespace-only title is + // rejected, matching TaskCreateForm's title validator. + Assert.Null(result); + } + + [Fact] + public void HandleKey_Should_Submit_When_CtrlEnterFromTitleField() + { + // arrange: focus never leaves the title field, no Tab to Save. + var task = TaskItemBuilder.Create("a1", "Title"); + var form = new TaskEditorForm(task, []); + Type(form, "!"); + + // act + var result = form.HandleKey(CtrlKey(ConsoleKey.Enter)); + + // assert + var submitted = Assert.IsType<FormResult.Submitted>(result); + Assert.Equal(new FormValue.Text("Title!"), submitted.Values[TaskEditorForm.TitleFieldId]); + } + + [Fact] + public void HandleKey_Should_Submit_When_CtrlSFromTitleField() + { + // arrange: Ctrl+S is the fallback save chord for terminals that + // deliver Ctrl+Enter identically to a plain Enter. + var task = TaskItemBuilder.Create("a1", "Title"); + var form = new TaskEditorForm(task, []); + Type(form, "!"); + + // act + var result = form.HandleKey(CtrlKey(ConsoleKey.S)); + + // assert + var submitted = Assert.IsType<FormResult.Submitted>(result); + Assert.Equal(new FormValue.Text("Title!"), submitted.Values[TaskEditorForm.TitleFieldId]); + } + + [Fact] + public void HandleKey_Should_NotClose_When_CtrlEnterWithEmptyTitle() + { + // arrange + var task = TaskItemBuilder.Create("a1", "T"); + var form = new TaskEditorForm(task, []); + form.HandleKey(Key(ConsoleKey.Backspace)); + + // act + var result = form.HandleKey(CtrlKey(ConsoleKey.Enter)); + + // assert: the form stays open and focus moves to the invalid title + // field instead of closing. + Assert.Null(result); + Assert.Equal(TaskEditorForm.TitleFieldId, form.FocusedField?.Id); + } + + [Fact] + public void HandleKey_Should_ReturnCancelled_When_Escape() + { + // arrange + var task = TaskItemBuilder.Create("a1", "Title"); + var form = new TaskEditorForm(task, []); + + // act + var result = form.HandleKey(Key(ConsoleKey.Escape)); + + // assert + Assert.IsType<FormResult.Cancelled>(result); + } + + [Fact] + public async Task SubmitAsync_Should_WriteOnlyTitle_When_OnlyTitleChanged() + { + // arrange + var task = TaskItemBuilder.Create( + "a1", "Title", TaskStates.Open, priority: 2, type: TaskTypes.Task, description: "d", notes: "n"); + var form = new TaskEditorForm(task, ["x"]); + Type(form, "!"); + var submitted = Save(form); + var store = new FakeTaskStore { UpdateResult = new TaskUpdateResult { ChangedFields = ["title"] } }; + + // act + var outcome = await form.SubmitAsync(store, submitted.Values, "me", CancellationToken.None); + + // assert + Assert.Equal("a1", store.UpdatedId); + var update = store.UpdateReceived!; + Assert.True(update.TitleGiven); + Assert.Equal("Title!", update.Title); + Assert.False(update.StatusGiven); + Assert.False(update.PriorityGiven); + Assert.False(update.TypeGiven); + Assert.False(update.DescriptionGiven); + Assert.False(update.NotesGiven); + Assert.Null(store.AddedLabels); + Assert.Empty(store.RemovedLabels); + var succeeded = Assert.IsType<TaskEditorOutcome.Succeeded>(outcome); + Assert.Equal(["title"], succeeded.ChangedFields); + Assert.Equal("Updated task 'a1'.", succeeded.ToastText); + } + + [Fact] + public async Task SubmitAsync_Should_WriteNothing_When_NoFieldChanged() + { + // arrange + var task = TaskItemBuilder.Create("a1", "Title"); + var form = new TaskEditorForm(task, ["x"]); + var submitted = Save(form); + var store = new FakeTaskStore(); + + // act + var outcome = await form.SubmitAsync(store, submitted.Values, "me", CancellationToken.None); + + // assert + Assert.Null(store.UpdatedId); + Assert.Null(store.AddedLabels); + Assert.Empty(store.RemovedLabels); + var succeeded = Assert.IsType<TaskEditorOutcome.Succeeded>(outcome); + Assert.Empty(succeeded.ChangedFields); + Assert.Equal("No changes to task 'a1'.", succeeded.ToastText); + } + + [Fact] + public async Task SubmitAsync_Should_AddAndRemoveLabels_When_LabelsEdited() + { + // arrange: remove "b", add "c", keep "a". + var task = TaskItemBuilder.Create("a1", "Title"); + var form = new TaskEditorForm(task, ["a", "b"]); + TabTo(form, 4); + form.HandleKey(Key(ConsoleKey.DownArrow)); + form.HandleKey(Key('d')); + form.HandleKey(Key('a')); + Type(form, "c"); + form.HandleKey(Key(ConsoleKey.Enter)); + var submitted = Save(form); + var store = new FakeTaskStore(); + + // act + var outcome = await form.SubmitAsync(store, submitted.Values, "me", CancellationToken.None); + + // assert + Assert.Null(store.UpdatedId); + Assert.Equal(["c"], store.AddedLabels); + Assert.Equal(["b"], store.RemovedLabels); + var succeeded = Assert.IsType<TaskEditorOutcome.Succeeded>(outcome); + Assert.Equal(["labels"], succeeded.ChangedFields); + } + + [Fact] + public async Task SubmitAsync_Should_ReturnFailed_When_StoreThrows() + { + // arrange + var task = TaskItemBuilder.Create("a1", "Title"); + var form = new TaskEditorForm(task, []); + Type(form, "!"); + var submitted = Save(form); + var store = new FakeTaskStore { ThrowOnWrite = new ExitException("rejected") }; + + // act + var outcome = await form.SubmitAsync(store, submitted.Values, "me", CancellationToken.None); + + // assert + var failed = Assert.IsType<TaskEditorOutcome.Failed>(outcome); + Assert.Equal("rejected", failed.ToastText); + } + + [Fact] + public void ToShowToast_Should_UseSuccessStyle_ForSucceeded() + { + // arrange + var outcome = new TaskEditorOutcome.Succeeded([], "No changes to task 'a1'."); + + // act + var toast = outcome.ToShowToast(); + + // assert + Assert.Equal("No changes to task 'a1'.", toast.Text); + Assert.Equal(ToastStyle.Success, toast.Style); + } + + [Fact] + public void ToShowToast_Should_UseErrorStyle_ForFailed() + { + // arrange + var outcome = new TaskEditorOutcome.Failed("rejected"); + + // act + var toast = outcome.ToShowToast(); + + // assert + Assert.Equal("rejected", toast.Text); + Assert.Equal(ToastStyle.Error, toast.Style); + } + + [Fact] + public void HandleKey_Should_ReachEveryFieldAndSave_When_FrameIsMinimumViableSize() + { + // arrange: the 80x24 frame (23 content rows once the status row is + // reserved) the UX spec requires to stay fully operable, with the + // default seven-field set. + var task = TaskItemBuilder.Create("a1", "Title"); + var form = new TaskEditorForm(task, ["alpha"]); + var console = new TestConsole().Width(80).Height(23); + var fieldLabels = new[] { "Title", "Status", "Priority", "Type", "Labels", "Description", "Notes" }; + + // act & assert: every field is reachable by Tab and, once focused, + // scrolled fully into view. + foreach (var label in fieldLabels) + { + console.Write(form.Render(80, 23)); + Assert.Contains(label, console.Output); + form.HandleKey(Key(ConsoleKey.Tab)); + } + + // act: the button row is the next and final stop. + console.Write(form.Render(80, 23)); + Assert.Contains("Save", console.Output); + + // act + var result = form.HandleKey(Key(ConsoleKey.Enter)); + + // assert: save works from the fully-scrolled button row. + Assert.IsType<FormResult.Submitted>(result); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/TaskItemBuilder.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/TaskItemBuilder.cs new file mode 100644 index 00000000000..d266d96fc6d --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/TaskItemBuilder.cs @@ -0,0 +1,31 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Editing; + +/// <summary> +/// Builds <see cref="TaskItem"/> instances with sensible defaults for task +/// lifecycle action tests. +/// </summary> +internal static class TaskItemBuilder +{ + public static TaskItem Create( + string id, + string title = "", + string status = TaskStates.Open, + int priority = TaskPriorities.Medium, + string type = TaskTypes.Task, + string description = "", + string notes = "") + => new() + { + Id = id, + Title = title.Length == 0 ? id : title, + Status = status, + Priority = priority, + Type = type, + Description = description, + Notes = notes, + CreatedAt = DateTimeOffset.UnixEpoch, + UpdatedAt = DateTimeOffset.UnixEpoch + }; +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/TaskLifecycleActionsTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/TaskLifecycleActionsTests.cs new file mode 100644 index 00000000000..29a8af4ab0a --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Editing/TaskLifecycleActionsTests.cs @@ -0,0 +1,169 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Editing; +using ChilliCream.Nitro.CommandLine.Tui.Input; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Editing; + +public sealed class TaskLifecycleActionsTests +{ + [Fact] + public async Task CloseAsync_Should_CallStoreWithReason_And_ReturnSucceeded() + { + // arrange + var task = TaskItemBuilder.Create("a1", "Title"); + var store = new FakeTaskStore { ResultTask = TaskItemBuilder.Create("a1", "Title", TaskStates.Closed) }; + + // act + var outcome = await TaskLifecycleActions.CloseAsync(store, task, "done", "me", CancellationToken.None); + + // assert + Assert.Equal(["a1"], store.ClosedIds); + Assert.Equal("done", store.ClosedReason); + var succeeded = Assert.IsType<TaskLifecycleOutcome.Succeeded>(outcome); + Assert.Equal(TaskLifecycleAction.Close, succeeded.Action); + Assert.False(succeeded.ShouldPopDetail); + } + + [Fact] + public async Task CloseAsync_Should_ReturnFailed_When_StoreThrows() + { + // arrange + var task = TaskItemBuilder.Create("a1", "Title"); + var store = new FakeTaskStore { ThrowOnWrite = new ExitException("already closed") }; + + // act + var outcome = await TaskLifecycleActions.CloseAsync(store, task, "", "me", CancellationToken.None); + + // assert + var failed = Assert.IsType<TaskLifecycleOutcome.Failed>(outcome); + Assert.Equal(TaskLifecycleAction.Close, failed.Action); + Assert.Equal("already closed", failed.ToastText); + } + + [Fact] + public async Task ReopenAsync_Should_ReturnFailedWithoutCallingStore_When_TaskNotClosed() + { + // arrange + var task = TaskItemBuilder.Create("a1", "Title", TaskStates.Open); + var store = new FakeTaskStore(); + + // act + var outcome = await TaskLifecycleActions.ReopenAsync(store, task, "", "me", CancellationToken.None); + + // assert + var failed = Assert.IsType<TaskLifecycleOutcome.Failed>(outcome); + Assert.Equal(TaskLifecycleAction.Reopen, failed.Action); + Assert.Null(store.ReopenedId); + } + + [Fact] + public async Task ReopenAsync_Should_CallStoreWithReason_And_ReturnSucceeded_When_TaskClosed() + { + // arrange + var task = TaskItemBuilder.Create("a1", "Title", TaskStates.Closed); + var store = new FakeTaskStore { ResultTask = TaskItemBuilder.Create("a1", "Title", TaskStates.Open) }; + + // act + var outcome = await TaskLifecycleActions.ReopenAsync(store, task, "reconsidered", "me", CancellationToken.None); + + // assert + Assert.Equal("a1", store.ReopenedId); + Assert.Equal("reconsidered", store.ReopenedReason); + var succeeded = Assert.IsType<TaskLifecycleOutcome.Succeeded>(outcome); + Assert.Equal(TaskLifecycleAction.Reopen, succeeded.Action); + Assert.False(succeeded.ShouldPopDetail); + } + + [Fact] + public async Task ReopenAsync_Should_ReturnFailed_When_StoreThrows() + { + // arrange + var task = TaskItemBuilder.Create("a1", "Title", TaskStates.Closed); + var store = new FakeTaskStore { ThrowOnWrite = new ExitException("not closed") }; + + // act + var outcome = await TaskLifecycleActions.ReopenAsync(store, task, "", "me", CancellationToken.None); + + // assert + var failed = Assert.IsType<TaskLifecycleOutcome.Failed>(outcome); + Assert.Equal("not closed", failed.ToastText); + } + + [Fact] + public async Task DeleteAsync_Should_CallStoreWithReason_And_ReturnSucceeded_WithDetailPop() + { + // arrange + var task = TaskItemBuilder.Create("a1", "Title"); + var store = new FakeTaskStore { ResultTask = TaskItemBuilder.Create("a1", "Title", TaskStates.Tombstone) }; + + // act + var outcome = await TaskLifecycleActions.DeleteAsync(store, task, "obsolete", "me", CancellationToken.None); + + // assert + Assert.Equal("a1", store.DeletedId); + Assert.Equal("obsolete", store.DeletedReason); + var succeeded = Assert.IsType<TaskLifecycleOutcome.Succeeded>(outcome); + Assert.Equal(TaskLifecycleAction.Delete, succeeded.Action); + Assert.True(succeeded.ShouldPopDetail); + } + + [Fact] + public async Task DeleteAsync_Should_ReturnFailed_When_StoreThrows() + { + // arrange + var task = TaskItemBuilder.Create("a1", "Title"); + var store = new FakeTaskStore { ThrowOnWrite = new ExitException("not found") }; + + // act + var outcome = await TaskLifecycleActions.DeleteAsync(store, task, "", "me", CancellationToken.None); + + // assert + var failed = Assert.IsType<TaskLifecycleOutcome.Failed>(outcome); + Assert.Equal("not found", failed.ToastText); + } + + [Theory] + [InlineData(TaskStates.Open, false)] + [InlineData(TaskStates.InProgress, false)] + [InlineData(TaskStates.Closed, true)] + public void CanReopen_Should_ReturnTrue_OnlyWhenClosed(string status, bool expected) + { + // arrange + var task = TaskItemBuilder.Create("a1", "Title", status); + + // act + var canReopen = TaskLifecycleActions.CanReopen(task); + + // assert + Assert.Equal(expected, canReopen); + } + + [Fact] + public void ToShowToast_Should_UseSuccessStyle_ForSucceeded() + { + // arrange + var outcome = new TaskLifecycleOutcome.Succeeded( + TaskLifecycleAction.Close, TaskItemBuilder.Create("a1"), "Closed task 'a1'."); + + // act + var toast = outcome.ToShowToast(); + + // assert + Assert.Equal("Closed task 'a1'.", toast.Text); + Assert.Equal(ToastStyle.Success, toast.Style); + } + + [Fact] + public void ToShowToast_Should_UseErrorStyle_ForFailed() + { + // arrange + var outcome = new TaskLifecycleOutcome.Failed(TaskLifecycleAction.Delete, "not found"); + + // act + var toast = outcome.ToShowToast(); + + // assert + Assert.Equal("not found", toast.Text); + Assert.Equal(ToastStyle.Error, toast.Style); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Input/KeyDispatcherTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Input/KeyDispatcherTests.cs new file mode 100644 index 00000000000..9eefd5c9c0e --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Input/KeyDispatcherTests.cs @@ -0,0 +1,93 @@ +using ChilliCream.Nitro.CommandLine.Tui.Input; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Input; + +public sealed class KeyDispatcherTests +{ + private static ConsoleKeyInfo KeyInfo(char keyChar, ConsoleKey key, ConsoleModifiers modifiers = ConsoleModifiers.None) => + new( + keyChar, + key, + modifiers.HasFlag(ConsoleModifiers.Shift), + modifiers.HasFlag(ConsoleModifiers.Alt), + modifiers.HasFlag(ConsoleModifiers.Control)); + + [Fact] + public void Dispatch_Should_PreferModeTable_When_KeyBoundInBothTables() + { + // arrange + var globalMap = new KeyMap( + [new KeyBinding(new KeyChord(ConsoleKey.Q, ConsoleModifiers.None, 'q'), () => new TuiMessage.QuitRequested())]); + var modeMap = new KeyMap( + [new KeyBinding(new KeyChord(ConsoleKey.Q, ConsoleModifiers.None, 'q'), () => new TuiMessage.RefreshRequested())]); + var dispatcher = new KeyDispatcher(globalMap); + + // act + var message = dispatcher.Dispatch(KeyInfo('q', ConsoleKey.Q), modeMap); + + // assert + Assert.IsType<TuiMessage.RefreshRequested>(message); + } + + [Fact] + public void Dispatch_Should_FallBackToGlobalTable_When_KeyUnboundInModeTable() + { + // arrange + var globalMap = new KeyMap( + [new KeyBinding(new KeyChord(ConsoleKey.Q, ConsoleModifiers.None, 'q'), () => new TuiMessage.QuitRequested())]); + var modeMap = new KeyMap( + [new KeyBinding(new KeyChord(ConsoleKey.J, ConsoleModifiers.None, 'j'), () => new TuiMessage.MoveCursor(CursorDirection.Down))]); + var dispatcher = new KeyDispatcher(globalMap); + + // act + var message = dispatcher.Dispatch(KeyInfo('q', ConsoleKey.Q), modeMap); + + // assert + Assert.IsType<TuiMessage.QuitRequested>(message); + } + + [Fact] + public void Dispatch_Should_UseGlobalTable_When_ModeTableIsNull() + { + // arrange + var globalMap = new KeyMap( + [new KeyBinding(new KeyChord(ConsoleKey.Q, ConsoleModifiers.None, 'q'), () => new TuiMessage.QuitRequested())]); + var dispatcher = new KeyDispatcher(globalMap); + + // act + var message = dispatcher.Dispatch(KeyInfo('q', ConsoleKey.Q), modeKeyMap: null); + + // assert + Assert.IsType<TuiMessage.QuitRequested>(message); + } + + [Fact] + public void Dispatch_Should_ReturnNull_When_KeyUnboundInBothTables() + { + // arrange + var globalMap = new KeyMap( + [new KeyBinding(new KeyChord(ConsoleKey.Q, ConsoleModifiers.None, 'q'), () => new TuiMessage.QuitRequested())]); + var modeMap = new KeyMap( + [new KeyBinding(new KeyChord(ConsoleKey.J, ConsoleModifiers.None, 'j'), () => new TuiMessage.MoveCursor(CursorDirection.Down))]); + var dispatcher = new KeyDispatcher(globalMap); + + // act + var message = dispatcher.Dispatch(KeyInfo('x', ConsoleKey.X), modeMap); + + // assert + Assert.Null(message); + } + + [Fact] + public void Dispatch_Should_ResolveDefaultGlobalKeys() + { + // arrange + var dispatcher = new KeyDispatcher(KeyMap.CreateDefaultGlobal()); + + // act + var message = dispatcher.Dispatch(KeyInfo('j', ConsoleKey.J), modeKeyMap: null); + + // assert + Assert.Equal(CursorDirection.Down, Assert.IsType<TuiMessage.MoveCursor>(message).Direction); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Input/KeyMapTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Input/KeyMapTests.cs new file mode 100644 index 00000000000..2eed2f211e2 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Input/KeyMapTests.cs @@ -0,0 +1,457 @@ +using ChilliCream.Nitro.CommandLine.Tui.Input; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Input; + +public sealed class KeyMapTests +{ + [Fact] + public void TryResolve_Should_ReturnMessage_When_ChordIsBound() + { + // arrange + var chord = new KeyChord(ConsoleKey.A, ConsoleModifiers.None, 'a'); + var keyMap = new KeyMap([new KeyBinding(chord, () => new TuiMessage.RefreshRequested())]); + + // act + var resolved = keyMap.TryResolve(chord, out var message); + + // assert + Assert.True(resolved); + Assert.IsType<TuiMessage.RefreshRequested>(message); + } + + [Fact] + public void TryResolve_Should_ReturnFalse_When_ChordIsUnbound() + { + // arrange + var bound = new KeyChord(ConsoleKey.A, ConsoleModifiers.None, 'a'); + var unbound = new KeyChord(ConsoleKey.B, ConsoleModifiers.None, 'b'); + var keyMap = new KeyMap([new KeyBinding(bound, () => new TuiMessage.RefreshRequested())]); + + // act + var resolved = keyMap.TryResolve(unbound, out var message); + + // assert + Assert.False(resolved); + Assert.Null(message); + } + + [Fact] + public void TryResolve_Should_DistinguishChords_By_Modifiers() + { + // arrange + var lower = new KeyChord(ConsoleKey.G, ConsoleModifiers.None, 'g'); + var upper = new KeyChord(ConsoleKey.G, ConsoleModifiers.Shift, 'G'); + var keyMap = new KeyMap( + [ + new KeyBinding(lower, () => new TuiMessage.MoveToEdge(EdgeTarget.Top)), + new KeyBinding(upper, () => new TuiMessage.MoveToEdge(EdgeTarget.Bottom)) + ]); + + // act + keyMap.TryResolve(lower, out var lowerMessage); + keyMap.TryResolve(upper, out var upperMessage); + + // assert + Assert.Equal(EdgeTarget.Top, Assert.IsType<TuiMessage.MoveToEdge>(lowerMessage).Edge); + Assert.Equal(EdgeTarget.Bottom, Assert.IsType<TuiMessage.MoveToEdge>(upperMessage).Edge); + } + + [Fact] + public void CreateDefaultGlobal_Should_MapJAndDownArrow_ToMoveCursorDown() + { + AssertMovesCursor(new KeyChord(ConsoleKey.J, ConsoleModifiers.None, 'j'), CursorDirection.Down); + AssertMovesCursor(new KeyChord(ConsoleKey.DownArrow, ConsoleModifiers.None, '\0'), CursorDirection.Down); + } + + [Fact] + public void CreateDefaultGlobal_Should_MapKAndUpArrow_ToMoveCursorUp() + { + AssertMovesCursor(new KeyChord(ConsoleKey.K, ConsoleModifiers.None, 'k'), CursorDirection.Up); + AssertMovesCursor(new KeyChord(ConsoleKey.UpArrow, ConsoleModifiers.None, '\0'), CursorDirection.Up); + } + + [Fact] + public void CreateDefaultGlobal_Should_MapHAndLeftArrow_ToMoveCursorLeft() + { + AssertMovesCursor(new KeyChord(ConsoleKey.H, ConsoleModifiers.None, 'h'), CursorDirection.Left); + AssertMovesCursor(new KeyChord(ConsoleKey.LeftArrow, ConsoleModifiers.None, '\0'), CursorDirection.Left); + } + + [Fact] + public void CreateDefaultGlobal_Should_MapLAndRightArrow_ToMoveCursorRight() + { + AssertMovesCursor(new KeyChord(ConsoleKey.L, ConsoleModifiers.None, 'l'), CursorDirection.Right); + AssertMovesCursor(new KeyChord(ConsoleKey.RightArrow, ConsoleModifiers.None, '\0'), CursorDirection.Right); + } + + [Fact] + public void CreateDefaultGlobal_Should_MapG_ToMoveToTop() + { + AssertMovesToEdge(new KeyChord(ConsoleKey.G, ConsoleModifiers.None, 'g'), EdgeTarget.Top); + } + + [Fact] + public void CreateDefaultGlobal_Should_MapShiftG_ToMoveToBottom() + { + AssertMovesToEdge(new KeyChord(ConsoleKey.G, ConsoleModifiers.Shift, 'G'), EdgeTarget.Bottom); + } + + private static void AssertMovesCursor(KeyChord chord, CursorDirection expectedDirection) + { + // arrange + var keyMap = KeyMap.CreateDefaultGlobal(); + + // act + var resolved = keyMap.TryResolve(chord, out var message); + + // assert + Assert.True(resolved); + Assert.Equal(expectedDirection, Assert.IsType<TuiMessage.MoveCursor>(message).Direction); + } + + private static void AssertMovesToEdge(KeyChord chord, EdgeTarget expectedEdge) + { + // arrange + var keyMap = KeyMap.CreateDefaultGlobal(); + + // act + var resolved = keyMap.TryResolve(chord, out var message); + + // assert + Assert.True(resolved); + Assert.Equal(expectedEdge, Assert.IsType<TuiMessage.MoveToEdge>(message).Edge); + } + + [Theory] + [InlineData(ConsoleKey.Q, ConsoleModifiers.None, 'q')] + [InlineData(ConsoleKey.C, ConsoleModifiers.Control, '\u0003')] + public void CreateDefaultGlobal_Should_MapQuitKeys_ToQuitRequested( + ConsoleKey key, + ConsoleModifiers modifiers, + char keyChar) + { + // arrange + var keyMap = KeyMap.CreateDefaultGlobal(); + + // act + var resolved = keyMap.TryResolve(new KeyChord(key, modifiers, keyChar), out var message); + + // assert + Assert.True(resolved); + Assert.IsType<TuiMessage.QuitRequested>(message); + } + + [Theory] + [InlineData(ConsoleKey.N, ConsoleModifiers.Control, '\u000e', 1)] + [InlineData(ConsoleKey.P, ConsoleModifiers.Control, '\u0010', -1)] + public void CreateDefaultGlobal_Should_MapCycleViewKeys_ToCycleView( + ConsoleKey key, + ConsoleModifiers modifiers, + char keyChar, + int expectedDelta) + { + // arrange + var keyMap = KeyMap.CreateDefaultGlobal(); + + // act + var resolved = keyMap.TryResolve(new KeyChord(key, modifiers, keyChar), out var message); + + // assert + Assert.True(resolved); + Assert.Equal(expectedDelta, Assert.IsType<TuiMessage.CycleView>(message).Delta); + } + + [Fact] + public void CreateDefaultGlobal_Should_MapEnter_ToOpenSelected() + { + // arrange + var keyMap = KeyMap.CreateDefaultGlobal(); + + // act + var resolved = keyMap.TryResolve( + new KeyChord(ConsoleKey.Enter, ConsoleModifiers.None, '\r'), + out var message); + + // assert + Assert.True(resolved); + Assert.IsType<TuiMessage.OpenSelected>(message); + } + + [Fact] + public void CreateDefaultGlobal_Should_MapR_ToRefreshRequested() + { + // arrange + var keyMap = KeyMap.CreateDefaultGlobal(); + + // act + var resolved = keyMap.TryResolve( + new KeyChord(ConsoleKey.R, ConsoleModifiers.None, 'r'), + out var message); + + // assert + Assert.True(resolved); + Assert.IsType<TuiMessage.RefreshRequested>(message); + } + + [Fact] + public void CreateDefaultGlobal_Should_MapY_ToCopySelectedId() + { + // arrange + var keyMap = KeyMap.CreateDefaultGlobal(); + + // act + var resolved = keyMap.TryResolve( + new KeyChord(ConsoleKey.Y, ConsoleModifiers.None, 'y'), + out var message); + + // assert + Assert.True(resolved); + Assert.IsType<TuiMessage.CopySelectedId>(message); + } + + [Fact] + public void CreateDefaultGlobal_Should_MapZ_ToToggleMaximize() + { + // arrange + var keyMap = KeyMap.CreateDefaultGlobal(); + + // act + var resolved = keyMap.TryResolve( + new KeyChord(ConsoleKey.Z, ConsoleModifiers.None, 'z'), + out var message); + + // assert + Assert.True(resolved); + Assert.IsType<TuiMessage.ToggleMaximize>(message); + } + + [Fact] + public void CreateDefaultGlobal_Should_MapS_ToStatusPickerRequested() + { + // arrange + var keyMap = KeyMap.CreateDefaultGlobal(); + + // act + var resolved = keyMap.TryResolve( + new KeyChord(ConsoleKey.S, ConsoleModifiers.None, 's'), + out var message); + + // assert + Assert.True(resolved); + Assert.IsType<TuiMessage.StatusPickerRequested>(message); + } + + [Fact] + public void CreateDefaultGlobal_Should_MapC_ToCreateTaskRequested() + { + // arrange + var keyMap = KeyMap.CreateDefaultGlobal(); + + // act + var resolved = keyMap.TryResolve( + new KeyChord(ConsoleKey.C, ConsoleModifiers.None, 'c'), + out var message); + + // assert + Assert.True(resolved); + Assert.IsType<TuiMessage.CreateTaskRequested>(message); + } + + [Fact] + public void CreateDefaultGlobal_Should_MapShiftC_ToCreateEpicRequested() + { + // arrange + var keyMap = KeyMap.CreateDefaultGlobal(); + + // act + var resolved = keyMap.TryResolve( + new KeyChord(ConsoleKey.C, ConsoleModifiers.Shift, 'C'), + out var message); + + // assert + Assert.True(resolved); + Assert.IsType<TuiMessage.CreateEpicRequested>(message); + } + + [Fact] + public void CreateDefaultGlobal_Should_MapP_ToPriorityPickerRequested() + { + // arrange + var keyMap = KeyMap.CreateDefaultGlobal(); + + // act + var resolved = keyMap.TryResolve( + new KeyChord(ConsoleKey.P, ConsoleModifiers.None, 'p'), + out var message); + + // assert + Assert.True(resolved); + Assert.IsType<TuiMessage.PriorityPickerRequested>(message); + } + + [Fact] + public void CreateDefaultGlobal_Should_ReturnFalse_When_KeyIsUnbound() + { + // arrange + var keyMap = KeyMap.CreateDefaultGlobal(); + + // act + var resolved = keyMap.TryResolve( + new KeyChord(ConsoleKey.F1, ConsoleModifiers.None, '\0'), + out var message); + + // assert + Assert.False(resolved); + Assert.Null(message); + } + + [Fact] + public void CreateDefaultGlobal_Should_MapOem2_ToFocusSearchRequested() + { + // arrange + var keyMap = KeyMap.CreateDefaultGlobal(); + + // act + var resolved = keyMap.TryResolve( + new KeyChord(ConsoleKey.Oem2, ConsoleModifiers.None, '/'), + out var message); + + // assert + Assert.True(resolved); + Assert.IsType<TuiMessage.FocusSearchRequested>(message); + } + + [Fact] + public void CreateDefaultGlobal_Should_MapDivide_ToFocusSearchRequested() + { + // arrange: some terminals report ConsoleKey.Divide (not Oem2) for a typed '/'. + var keyMap = KeyMap.CreateDefaultGlobal(); + + // act + var resolved = keyMap.TryResolve( + new KeyChord(ConsoleKey.Divide, ConsoleModifiers.None, '/'), + out var message); + + // assert + Assert.True(resolved); + Assert.IsType<TuiMessage.FocusSearchRequested>(message); + } + + [Fact] + public void TryResolve_Should_FallBackToKeyChar_When_ConsoleKeyDiffersButCharAndModifiersMatch() + { + // arrange + var chord = new KeyChord(ConsoleKey.Oem2, ConsoleModifiers.None, '/'); + var keyMap = new KeyMap([new KeyBinding(chord, () => new TuiMessage.FocusSearchRequested())]); + var otherKeyReportingSameChar = new KeyChord(ConsoleKey.Divide, ConsoleModifiers.None, '/'); + + // act + var resolved = keyMap.TryResolve(otherKeyReportingSameChar, out var message); + + // assert + Assert.True(resolved); + Assert.IsType<TuiMessage.FocusSearchRequested>(message); + } + + [Fact] + public void TryResolve_Should_NotFallBackToKeyChar_When_ModifiersDiffer() + { + // arrange + var chord = new KeyChord(ConsoleKey.Oem2, ConsoleModifiers.None, '/'); + var keyMap = new KeyMap([new KeyBinding(chord, () => new TuiMessage.FocusSearchRequested())]); + var sameCharDifferentModifiers = new KeyChord(ConsoleKey.Divide, ConsoleModifiers.Shift, '/'); + + // act + var resolved = keyMap.TryResolve(sameCharDifferentModifiers, out var message); + + // assert + Assert.False(resolved); + Assert.Null(message); + } + + [Fact] + public void Hints_Should_BeEmpty_When_NoBindingCarriesAHint() + { + // arrange + var keyMap = new KeyMap([new KeyBinding(new KeyChord(ConsoleKey.A, ConsoleModifiers.None, 'a'), () => new TuiMessage.RefreshRequested())]); + + // assert + Assert.Empty(keyMap.Hints); + } + + [Fact] + public void Hints_Should_OmitBindings_Without_AHint() + { + // arrange: only the second binding carries a hint. + var keyMap = new KeyMap( + [ + new KeyBinding(new KeyChord(ConsoleKey.A, ConsoleModifiers.None, 'a'), () => new TuiMessage.RefreshRequested()), + new KeyBinding( + new KeyChord(ConsoleKey.B, ConsoleModifiers.None, 'b'), + () => new TuiMessage.RefreshRequested(), + new KeyHint("b", "refresh")) + ]); + + // assert + Assert.Equal([new KeyHint("b", "refresh")], keyMap.Hints); + } + + [Fact] + public void Hints_Should_PreserveConstructorOrder() + { + // arrange + var keyMap = new KeyMap( + [ + new KeyBinding( + new KeyChord(ConsoleKey.A, ConsoleModifiers.None, 'a'), + () => new TuiMessage.RefreshRequested(), + new KeyHint("a", "first")), + new KeyBinding( + new KeyChord(ConsoleKey.B, ConsoleModifiers.None, 'b'), + () => new TuiMessage.RefreshRequested(), + new KeyHint("b", "second")) + ]); + + // assert + Assert.Equal([new KeyHint("a", "first"), new KeyHint("b", "second")], keyMap.Hints); + } + + [Fact] + public void CreateDefaultGlobal_Should_EndItsHints_With_Quit() + { + // arrange + var keyMap = KeyMap.CreateDefaultGlobal(); + + // assert + Assert.NotEmpty(keyMap.Hints); + Assert.Equal(new KeyHint("q", "quit"), keyMap.Hints[^1]); + } + + [Fact] + public void CreateDefaultGlobal_Should_Hint_TheFourDirectionKeys_AsOneMoveEntry() + { + // arrange + var keyMap = KeyMap.CreateDefaultGlobal(); + + // assert: j/k/h/l and their arrow-key equivalents collapse into a + // single "move" hint rather than one entry per key. + Assert.Single(keyMap.Hints, h => h.Action == "move"); + } + + [Fact] + public void TryResolve_Should_NotFallBackToKeyChar_When_CharIsControlChar() + { + // arrange: Ctrl+C reports KeyChar '\u0003', which is a control char and must not + // fall back onto an unrelated binding that happens to share the char. + var chord = new KeyChord(ConsoleKey.C, ConsoleModifiers.Control, '\u0003'); + var keyMap = new KeyMap([new KeyBinding(chord, () => new TuiMessage.QuitRequested())]); + var differentKeySameControlChar = new KeyChord(ConsoleKey.D3, ConsoleModifiers.Control, '\u0003'); + + // act + var resolved = keyMap.TryResolve(differentKeySameControlChar, out var message); + + // assert + Assert.False(resolved); + Assert.Null(message); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Runtime/TaskDbWatcherTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Runtime/TaskDbWatcherTests.cs new file mode 100644 index 00000000000..ce37cc59e5e --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Runtime/TaskDbWatcherTests.cs @@ -0,0 +1,175 @@ +using System.Threading.Channels; +using ChilliCream.Nitro.CommandLine.Tui.Runtime; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Runtime; + +public sealed class TaskDbWatcherTests : IDisposable +{ + private static readonly TimeSpan Debounce = TimeSpan.FromMilliseconds(50); + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5); + + private readonly string _directory = + Directory.CreateDirectory(Path.Combine(Path.GetTempPath(), "task-db-watcher-tests-" + Guid.NewGuid())).FullName; + + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } + + [Fact] + public async Task RunAsync_Should_PublishDataChangedEvent_When_DatabaseFileWritten() + { + // arrange + var testToken = TestContext.Current.CancellationToken; + var databasePath = Path.Combine(_directory, "tasks.db"); + File.WriteAllText(databasePath, "initial"); + var watcher = new TaskDbWatcher(databasePath, Debounce); + var channel = Channel.CreateUnbounded<TuiEvent>(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(testToken); + + // act + var runTask = watcher.RunAsync(channel.Writer, cts.Token); + await Task.Delay(Debounce, testToken); + File.WriteAllText(databasePath, "changed"); + var received = await ReadOneAsync(channel.Reader, testToken); + cts.Cancel(); + await runTask; + + // assert + Assert.IsType<TuiEvent.DataChangedEvent>(received); + } + + [Fact] + public async Task RunAsync_Should_PublishDataChangedEvent_When_WalSiblingWritten() + { + // arrange + var testToken = TestContext.Current.CancellationToken; + var databasePath = Path.Combine(_directory, "tasks.db"); + File.WriteAllText(databasePath, "initial"); + var watcher = new TaskDbWatcher(databasePath, Debounce); + var channel = Channel.CreateUnbounded<TuiEvent>(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(testToken); + + // act + var runTask = watcher.RunAsync(channel.Writer, cts.Token); + await Task.Delay(Debounce, testToken); + File.WriteAllText(databasePath + "-wal", "wal-bytes"); + var received = await ReadOneAsync(channel.Reader, testToken); + cts.Cancel(); + await runTask; + + // assert + Assert.IsType<TuiEvent.DataChangedEvent>(received); + } + + [Fact] + public async Task RunAsync_Should_CoalesceBurstOfWrites_IntoSingleEvent() + { + // arrange + var testToken = TestContext.Current.CancellationToken; + var databasePath = Path.Combine(_directory, "tasks.db"); + File.WriteAllText(databasePath, "initial"); + var watcher = new TaskDbWatcher(databasePath, Debounce); + var channel = Channel.CreateUnbounded<TuiEvent>(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(testToken); + + // act + var runTask = watcher.RunAsync(channel.Writer, cts.Token); + await Task.Delay(Debounce, testToken); + + // A burst of writes to the db and its WAL/SHM siblings within one debounce + // window resets the same timer rather than each scheduling its own event. + for (var i = 0; i < 5; i++) + { + File.WriteAllText(databasePath, "changed-" + i); + File.WriteAllText(databasePath + "-wal", "wal-" + i); + await Task.Delay(Debounce / 5, testToken); + } + + var first = await ReadOneAsync(channel.Reader, testToken); + + // No further event should follow once the burst settles. + await Task.Delay(Debounce * 4, testToken); + cts.Cancel(); + await runTask; + + // assert + Assert.IsType<TuiEvent.DataChangedEvent>(first); + Assert.False(channel.Reader.TryRead(out _)); + } + + [Fact] + public async Task RunAsync_Should_IgnoreUnrelatedFile_InSameDirectory() + { + // arrange + var testToken = TestContext.Current.CancellationToken; + var databasePath = Path.Combine(_directory, "tasks.db"); + File.WriteAllText(databasePath, "initial"); + var watcher = new TaskDbWatcher(databasePath, Debounce); + var channel = Channel.CreateUnbounded<TuiEvent>(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(testToken); + + // act + var runTask = watcher.RunAsync(channel.Writer, cts.Token); + await Task.Delay(Debounce, testToken); + File.WriteAllText(Path.Combine(_directory, "tasks.jsonl"), "unrelated"); + await Task.Delay(Debounce * 4, testToken); + cts.Cancel(); + await runTask; + + // assert + Assert.False(channel.Reader.TryRead(out _)); + } + + [Fact] + public async Task RunAsync_Should_CompleteWithoutThrowing_When_ParentDirectoryMissing() + { + // arrange + var testToken = TestContext.Current.CancellationToken; + var databasePath = Path.Combine(_directory, "does-not-exist", "tasks.db"); + var watcher = new TaskDbWatcher(databasePath, Debounce); + var channel = Channel.CreateUnbounded<TuiEvent>(); + + // act + var runTask = watcher.RunAsync(channel.Writer, testToken); + var completed = await Task.WhenAny(runTask, Task.Delay(TestTimeout, testToken)); + + // assert: the watcher returns promptly on its own rather than only + // when the timeout delay wins the race, since it never starts a + // watch loop to wait on. + Assert.Same(runTask, completed); + await runTask; + } + + [Fact] + public async Task RunAsync_Should_StopAndComplete_When_Cancelled() + { + // arrange + var testToken = TestContext.Current.CancellationToken; + var databasePath = Path.Combine(_directory, "tasks.db"); + File.WriteAllText(databasePath, "initial"); + var watcher = new TaskDbWatcher(databasePath, Debounce); + var channel = Channel.CreateUnbounded<TuiEvent>(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(testToken); + + // act + var runTask = watcher.RunAsync(channel.Writer, cts.Token); + await Task.Delay(Debounce, testToken); + cts.Cancel(); + var completed = await Task.WhenAny(runTask, Task.Delay(TestTimeout, testToken)); + + // assert + Assert.Same(runTask, completed); + await runTask; + } + + private static async Task<TuiEvent> ReadOneAsync(ChannelReader<TuiEvent> reader, CancellationToken cancellationToken) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TestTimeout); + return await reader.ReadAsync(cts.Token); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Runtime/TerminalSessionTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Runtime/TerminalSessionTests.cs new file mode 100644 index 00000000000..b1651efd59b --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Runtime/TerminalSessionTests.cs @@ -0,0 +1,50 @@ +using ChilliCream.Nitro.CommandLine.Tui.Runtime; +using Spectre.Console.Testing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Runtime; + +public sealed class TerminalSessionTests +{ + [Fact] + public void Constructor_Should_EnterAlternateScreen_And_HideCursor() + { + // arrange + var console = new TestConsole { EmitAnsiSequences = true }; + + // act + using var session = new TerminalSession(console); + + // assert + Assert.Contains("[?1049h", console.Output); + } + + [Fact] + public void Dispose_Should_ExitAlternateScreen_And_ShowCursor() + { + // arrange + var console = new TestConsole { EmitAnsiSequences = true }; + var session = new TerminalSession(console); + + // act + session.Dispose(); + + // assert + Assert.Contains("[?1049l", console.Output); + } + + [Fact] + public void Dispose_Should_BeIdempotent_When_CalledMoreThanOnce() + { + // arrange + var console = new TestConsole { EmitAnsiSequences = true }; + var session = new TerminalSession(console); + session.Dispose(); + var outputAfterFirstDispose = console.Output; + + // act + session.Dispose(); + + // assert + Assert.Equal(outputAfterFirstDispose, console.Output); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Runtime/TuiApplicationTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Runtime/TuiApplicationTests.cs new file mode 100644 index 00000000000..d757d7dbedc --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Runtime/TuiApplicationTests.cs @@ -0,0 +1,247 @@ +using System.Collections.Concurrent; +using System.Threading.Channels; +using ChilliCream.Nitro.CommandLine.Tui.Runtime; +using Spectre.Console; +using Spectre.Console.Rendering; +using Spectre.Console.Testing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Runtime; + +public sealed class TuiApplicationTests +{ + private static readonly TimeSpan TickInterval = TimeSpan.FromMilliseconds(10); + private static readonly TimeSpan KeyPollInterval = TimeSpan.FromMilliseconds(5); + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5); + + [Fact] + public async Task RunAsync_Should_MergeKeyAndTickEvents_IntoRootHandler() + { + // arrange + var testToken = TestContext.Current.CancellationToken; + var console = new TestConsole(); + console.Input.PushKey(ConsoleKey.A); + var app = new TuiApplication(console, TickInterval, KeyPollInterval); + var received = new ConcurrentQueue<TuiEvent>(); + var sawBoth = new TaskCompletionSource(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(testToken); + + bool Handler(TuiEvent tuiEvent) + { + received.Enqueue(tuiEvent); + if (received.OfType<TuiEvent.KeyEvent>().Any() && received.OfType<TuiEvent.TickEvent>().Any()) + { + sawBoth.TrySetResult(); + } + + return false; + } + + // act + var runTask = app.RunAsync(Handler, () => new Text("frame"), cts.Token); + await Task.WhenAny(sawBoth.Task, Task.Delay(TestTimeout, testToken)); + cts.Cancel(); + await runTask; + + // assert + Assert.Contains(received, e => e is TuiEvent.KeyEvent { Info.Key: ConsoleKey.A }); + Assert.Contains(received, e => e is TuiEvent.TickEvent); + } + + [Fact] + public async Task RunAsync_Should_EmitResizeEvent_When_ConsoleWindowSizeChangesOnTick() + { + // arrange + var testToken = TestContext.Current.CancellationToken; + var console = new TestConsole(); + var app = new TuiApplication(console, TickInterval, KeyPollInterval); + var received = new ConcurrentQueue<TuiEvent>(); + var sawResize = new TaskCompletionSource(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(testToken); + + bool Handler(TuiEvent tuiEvent) + { + received.Enqueue(tuiEvent); + if (tuiEvent is TuiEvent.ResizeEvent) + { + sawResize.TrySetResult(); + } + + return false; + } + + // act + var runTask = app.RunAsync(Handler, () => new Text("frame"), cts.Token); + await Task.Delay(TickInterval * 3, testToken); + console.Profile.Width = 120; + console.Profile.Height = 40; + await Task.WhenAny(sawResize.Task, Task.Delay(TestTimeout, testToken)); + cts.Cancel(); + await runTask; + + // assert + var resize = Assert.Single(received.OfType<TuiEvent.ResizeEvent>()); + Assert.Equal(120, resize.Width); + Assert.Equal(40, resize.Height); + } + + [Fact] + public async Task RunAsync_Should_NotRepaint_When_HandlerReportsFrameNotDirty() + { + // arrange + var testToken = TestContext.Current.CancellationToken; + var console = new TestConsole(); + var app = new TuiApplication(console, TickInterval, KeyPollInterval); + var rendererCallCount = 0; + var handlerCallCount = 0; + var handlerInvoked = new TaskCompletionSource(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(testToken); + + IRenderable Renderer() + { + Interlocked.Increment(ref rendererCallCount); + return new Text("frame"); + } + + bool Handler(TuiEvent tuiEvent) + { + Interlocked.Increment(ref handlerCallCount); + handlerInvoked.TrySetResult(); + return false; + } + + // act + var runTask = app.RunAsync(Handler, Renderer, cts.Token); + await Task.WhenAny(handlerInvoked.Task, Task.Delay(TestTimeout, testToken)); + await Task.Delay(TickInterval * 3, testToken); + cts.Cancel(); + await runTask; + + // assert + Assert.True(handlerCallCount > 0); + Assert.Equal(1, rendererCallCount); + } + + [Fact] + public async Task RunAsync_Should_RestoreTerminal_When_CancellationRequested() + { + // arrange + var testToken = TestContext.Current.CancellationToken; + var console = new TestConsole { EmitAnsiSequences = true }; + var app = new TuiApplication(console, TickInterval, KeyPollInterval); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(testToken); + + // act + var runTask = app.RunAsync(_ => false, () => new Text("frame"), cts.Token); + await Task.Delay(TickInterval * 3, testToken); + cts.Cancel(); + var completed = await Task.WhenAny(runTask, Task.Delay(TestTimeout, testToken)); + + // assert + Assert.Same(runTask, completed); + await runTask; + Assert.Contains("[?1049h", console.Output); + Assert.Contains("[?1049l", console.Output); + } + + [Fact] + public async Task RunAsync_Should_PaintInitialFrame_BeforeAnyDirtyEvent() + { + // arrange + var testToken = TestContext.Current.CancellationToken; + var console = new TestConsole { EmitAnsiSequences = true }; + var app = new TuiApplication(console, TickInterval, KeyPollInterval); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(testToken); + + // act + // The handler never reports the frame as dirty, so the initial frame can only + // reach the console output via the Live display's own startup paint. + var runTask = app.RunAsync(_ => false, () => new Text("initial-frame-marker"), cts.Token); + await Task.Delay(TickInterval * 5, testToken); + cts.Cancel(); + await runTask; + + // assert + Assert.Contains("initial-frame-marker", console.Output); + } + + [Fact] + public async Task RunAsync_Should_MergeAdditionalEventSource_IntoRootHandler() + { + // arrange + var testToken = TestContext.Current.CancellationToken; + var console = new TestConsole(); + var app = new TuiApplication(console, TickInterval, KeyPollInterval); + var received = new ConcurrentQueue<TuiEvent>(); + var sawDataChanged = new TaskCompletionSource(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(testToken); + + bool Handler(TuiEvent tuiEvent) + { + received.Enqueue(tuiEvent); + if (tuiEvent is TuiEvent.DataChangedEvent) + { + sawDataChanged.TrySetResult(); + } + + return false; + } + + async Task Source(ChannelWriter<TuiEvent> writer, CancellationToken sourceToken) + { + try + { + await Task.Delay(TickInterval, sourceToken); + writer.TryWrite(new TuiEvent.DataChangedEvent()); + await Task.Delay(Timeout.InfiniteTimeSpan, sourceToken); + } + catch (OperationCanceledException) + { + // Expected on shutdown. + } + } + + // act + var runTask = app.RunAsync(Handler, () => new Text("frame"), cts.Token, [Source]); + await Task.WhenAny(sawDataChanged.Task, Task.Delay(TestTimeout, testToken)); + cts.Cancel(); + var completed = await Task.WhenAny(runTask, Task.Delay(TestTimeout, testToken)); + + // assert + Assert.Contains(received, e => e is TuiEvent.DataChangedEvent); + Assert.Same(runTask, completed); + await runTask; + } + + [Fact] + public async Task RunAsync_Should_StopKeyReader_When_HandlerThrows() + { + // arrange + var testToken = TestContext.Current.CancellationToken; + var console = new TestConsole(); + var app = new TuiApplication(console, TickInterval, KeyPollInterval); + var handlerInvoked = new TaskCompletionSource(); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(testToken); + + bool Handler(TuiEvent tuiEvent) + { + handlerInvoked.TrySetResult(); + throw new InvalidOperationException("boom"); + } + + // act + var runTask = app.RunAsync(Handler, () => new Text("frame"), cts.Token); + await Task.WhenAny(handlerInvoked.Task, Task.Delay(TestTimeout, testToken)); + + var exception = await Assert.ThrowsAsync<InvalidOperationException>(() => runTask); + + // Give the key-reader loop several poll intervals worth of time to consume + // the key below if it were still running. + await Task.Delay(KeyPollInterval * 10, testToken); + console.Input.PushKey(ConsoleKey.A); + await Task.Delay(KeyPollInterval * 10, testToken); + + // assert + Assert.Equal("boom", exception.Message); + Assert.True(console.Input.IsKeyAvailable()); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Search/FakeTaskStore.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Search/FakeTaskStore.cs new file mode 100644 index 00000000000..2cbf1b2b469 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Search/FakeTaskStore.cs @@ -0,0 +1,218 @@ +using System.Data.Common; +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using Microsoft.Data.Sqlite; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Search; + +/// <summary> +/// An in-memory <see cref="ITaskStore"/> exercising the surface search mode +/// consumes: the query surface (<see cref="QueryTasksAsync"/>) and the read +/// surface its embedded detail pane consumes (task by id, labels, comments, +/// dependencies both directions, blocked set). Every other member throws +/// <see cref="NotSupportedException"/>: search mode never calls them. +/// </summary> +internal sealed class FakeTaskStore : ITaskStore +{ + public List<TaskItem> Tasks { get; } = []; + + /// <summary> + /// The filter passed to the most recent <see cref="QueryTasksAsync"/> + /// call, or null when the store has not been queried yet. + /// </summary> + public TaskFilter? LastFilter { get; private set; } + + /// <summary> + /// The number of times <see cref="QueryTasksAsync"/> has been called. + /// </summary> + public int QueryCount { get; private set; } + + public Task<IReadOnlyList<TaskItem>> QueryTasksAsync( + TaskFilter filter, + CancellationToken cancellationToken) + { + LastFilter = filter; + QueryCount++; + + IEnumerable<TaskItem> query = Tasks; + + if (filter.Statuses is { Length: > 0 } statuses) + { + query = query.Where(t => statuses.Contains(t.Status)); + } + else if (!filter.IncludeAll) + { + query = query.Where(t => t.Status != TaskStates.Tombstone && t.Status != TaskStates.Closed); + } + + if (filter.ExcludeTombstones) + { + query = query.Where(t => t.Status != TaskStates.Tombstone); + } + + if (filter.Type is { } type) + { + query = query.Where(t => t.Type == type); + } + + if (filter.Priority is { } priority) + { + query = query.Where(t => t.Priority == priority); + } + + if (filter.Assignee is { } assignee) + { + query = query.Where(t => t.Assignee == assignee); + } + + if (filter.Text is { Length: > 0 } text) + { + query = query.Where(t => t.Title.Contains(text, StringComparison.OrdinalIgnoreCase)); + } + + return Task.FromResult<IReadOnlyList<TaskItem>>(query.ToList()); + } + + public Task<SqliteConnection> InitializeAsync(string workspaceDirectory, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<SqliteConnection> ConnectAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public string? FindWorkspaceDirectory() + => throw new NotSupportedException(); + + public Task<string?> GetConfigAsync( + SqliteConnection connection, string key, CancellationToken cancellationToken, DbTransaction? transaction = null) + => throw new NotSupportedException(); + + public Task SetConfigAsync( + SqliteConnection connection, string key, string value, CancellationToken cancellationToken, DbTransaction? transaction = null) + => throw new NotSupportedException(); + + public Task<string> GetPrefixAsync( + SqliteConnection connection, CancellationToken cancellationToken, DbTransaction? transaction = null) + => throw new NotSupportedException(); + + public Task<string> CreateTaskIdAsync( + SqliteConnection connection, string? parentId, string seed, CancellationToken cancellationToken, DbTransaction? transaction = null) + => throw new NotSupportedException(); + + public Task RecordEventAsync( + SqliteConnection connection, TaskEvent taskEvent, CancellationToken cancellationToken, DbTransaction? transaction = null) + => throw new NotSupportedException(); + + public Task<TaskItem?> GetTaskAsync( + SqliteConnection connection, string id, CancellationToken cancellationToken, DbTransaction? transaction = null) + => throw new NotSupportedException(); + + public Task<TaskItem> GetRequiredTaskAsync( + SqliteConnection connection, string id, CancellationToken cancellationToken, DbTransaction? transaction = null) + => throw new NotSupportedException(); + + public Task<IReadOnlyDictionary<string, IReadOnlyList<string>>> ComputeBlockedAsync( + SqliteConnection connection, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem?> GetTaskAsync(string id, CancellationToken cancellationToken) + => Task.FromResult(Tasks.FirstOrDefault(t => t.Id == id)); + + public Task<TaskItem> GetRequiredTaskAsync(string id, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<string>> GetLabelsAsync(string taskId, CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyList<string>>([]); + + public Task<IReadOnlyList<TaskLabelCount>> GetLabelCountsAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskComment>> GetCommentsAsync(string taskId, CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyList<TaskComment>>([]); + + public Task<IReadOnlyList<TaskDependencyDetail>> GetDependenciesAsync(string taskId, CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyList<TaskDependencyDetail>>([]); + + public Task<IReadOnlyList<TaskDependentDetail>> GetDependentsAsync(string taskId, CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyList<TaskDependentDetail>>([]); + + public Task<IReadOnlyList<TaskDependency>> GetDependencyEdgesAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyDictionary<string, IReadOnlyList<string>>> ComputeBlockedAsync(CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyDictionary<string, IReadOnlyList<string>>>( + new Dictionary<string, IReadOnlyList<string>>()); + + public Task<IReadOnlyList<TaskEpicStatus>> GetEpicStatusesAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<int> CountTasksAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskCount>> CountTasksByAsync(TaskCountDimension dimension, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskStats> GetStatsAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<string?> GetConfigAsync(string key, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task SetConfigAsync(string key, string value, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskConfigEntry>> ListConfigAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<string> GetPrefixAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task InitializeWorkspaceAsync(string workspaceDirectory, string prefix, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskCreationResult> CreateTaskAsync(TaskCreation creation, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskUpdateResult> UpdateTaskAsync(string id, TaskUpdate update, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskItem>> CloseTaskAsync(IReadOnlyList<string> ids, string reason, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem> ReopenTaskAsync(string id, string reason, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem> DeferTaskAsync(string id, DateTimeOffset until, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem> UndeferTaskAsync(string id, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem> DeleteTaskAsync(string id, string reason, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskEpicStatus>> CloseEligibleEpicsAsync(string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskComment> AddCommentAsync(string id, string text, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskLabelChange>> AddLabelAsync(string id, IReadOnlyList<string> labels, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task RemoveLabelAsync(string id, string label, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskDependencyAddResult> AddDependencyAsync(string id, string dependsOnId, string type, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task RemoveDependencyAsync(string id, string dependsOnId, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskSyncRecord>> ExportTasksAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskImportResult> ImportTasksAsync(IReadOnlyList<TaskSyncRecord> records, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task EnsureWorkspaceAsync(string workspaceDirectory, CancellationToken cancellationToken) + => throw new NotSupportedException(); +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Search/SearchModeTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Search/SearchModeTests.cs new file mode 100644 index 00000000000..8d0af2e044f --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Search/SearchModeTests.cs @@ -0,0 +1,352 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Input; +using ChilliCream.Nitro.CommandLine.Tui.Search; +using Spectre.Console.Testing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Search; + +public sealed class SearchModeTests +{ + private static readonly DateTimeOffset Now = DateTimeOffset.UnixEpoch; + + [Fact] + public void Constructor_Should_Throw_When_StoreIsNull() + { + // act & assert + Assert.Throws<ArgumentNullException>(() => new SearchMode(null!)); + } + + [Fact] + public void Focus_Should_BeInput_When_ModeIsNew() + { + // arrange + var mode = new SearchMode(new FakeTaskStore()); + + // assert + Assert.Equal(SearchFocus.Input, mode.Focus); + } + + [Fact] + public async Task TickAsync_Should_LoadAllNonTombstoneTasks_When_QueryIsEmpty() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("t-1")); + store.Tasks.Add(TaskItemBuilder.Create("t-2")); + store.Tasks.Add(TaskItemBuilder.Create("t-3", status: TaskStates.Closed)); + store.Tasks.Add(TaskItemBuilder.Create("t-4", status: TaskStates.Tombstone)); + var mode = new SearchMode(store); + + // act + mode.OnEnter(); + var ran = await mode.TickAsync(Now, CancellationToken.None); + + // assert + Assert.True(ran); + Assert.Equal(["t-1", "t-2", "t-3"], mode.Results.Select(t => t.Id)); + Assert.Equal("t-1", mode.SelectedTaskId); + } + + [Fact] + public async Task HandleQueryKey_Should_ScheduleDebouncedQuery_When_TextIsValid() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("t-1")); + var mode = new SearchMode(store); + mode.OnEnter(); + await mode.TickAsync(Now, CancellationToken.None); + Assert.Equal(1, store.QueryCount); + + // act + TypeChar(mode, 'p', Now); + TypeChar(mode, '0', Now); + + var beforeDue = await mode.TickAsync(Now + SearchMode.DebounceWindow - TimeSpan.FromMilliseconds(1), CancellationToken.None); + Assert.False(beforeDue); + Assert.Equal(1, store.QueryCount); + + var afterDue = await mode.TickAsync(Now + SearchMode.DebounceWindow, CancellationToken.None); + + // assert + Assert.True(afterDue); + Assert.Equal(2, store.QueryCount); + Assert.Equal(0, store.LastFilter!.Priority); + Assert.Null(mode.ParseError); + } + + [Fact] + public void HandleQueryKey_Should_SetParseError_When_TextIsInvalid() + { + // arrange + var mode = new SearchMode(new FakeTaskStore()); + + // act + TypeText(mode, "priority:9", Now); + + // assert + Assert.NotNull(mode.ParseError); + Assert.Equal("priority:9", mode.QueryText); + } + + [Fact] + public async Task TickAsync_Should_NotQuery_When_PendingTextIsInvalid() + { + // arrange + var store = new FakeTaskStore(); + var mode = new SearchMode(store); + mode.OnEnter(); + await mode.TickAsync(Now, CancellationToken.None); + var queriesAfterEntry = store.QueryCount; + + // act + TypeText(mode, "priority:9", Now); + var ran = await mode.TickAsync(Now + SearchMode.DebounceWindow, CancellationToken.None); + + // assert + Assert.False(ran); + Assert.Equal(queriesAfterEntry, store.QueryCount); + } + + [Fact] + public void HandleQueryKey_Should_HaveNoEffect_When_FocusIsNotInput() + { + // arrange + var mode = new SearchMode(new FakeTaskStore()); + mode.Handle(new TuiMessage.OpenSelected()); + Assert.Equal(SearchFocus.List, mode.Focus); + + // act + TypeChar(mode, 'x', Now); + + // assert + Assert.Equal("", mode.QueryText); + } + + [Fact] + public void Handle_Should_WalkFocusForward_When_OpenSelectedReceived() + { + // arrange + var mode = new SearchMode(new FakeTaskStore()); + Assert.Equal(SearchFocus.Input, mode.Focus); + + // act & assert: Input -> List + mode.Handle(new TuiMessage.OpenSelected()); + Assert.Equal(SearchFocus.List, mode.Focus); + + // act & assert: List -> Detail + mode.Handle(new TuiMessage.OpenSelected()); + Assert.Equal(SearchFocus.Detail, mode.Focus); + + // act & assert: Detail stays put + mode.Handle(new TuiMessage.OpenSelected()); + Assert.Equal(SearchFocus.Detail, mode.Focus); + } + + [Fact] + public void Handle_Should_WalkFocusBack_When_MoveCursorLeftReceived() + { + // arrange + var mode = new SearchMode(new FakeTaskStore()); + mode.Handle(new TuiMessage.OpenSelected()); + mode.Handle(new TuiMessage.OpenSelected()); + Assert.Equal(SearchFocus.Detail, mode.Focus); + + // act & assert: Detail -> List + mode.Handle(new TuiMessage.MoveCursor(CursorDirection.Left)); + Assert.Equal(SearchFocus.List, mode.Focus); + + // act & assert: List -> Input + mode.Handle(new TuiMessage.MoveCursor(CursorDirection.Left)); + Assert.Equal(SearchFocus.Input, mode.Focus); + + // act & assert: Input stays put + mode.Handle(new TuiMessage.MoveCursor(CursorDirection.Left)); + Assert.Equal(SearchFocus.Input, mode.Focus); + } + + [Fact] + public void Handle_Should_FocusDetail_When_MoveCursorRightReceivedOnList() + { + // arrange + var mode = new SearchMode(new FakeTaskStore()); + mode.Handle(new TuiMessage.OpenSelected()); + Assert.Equal(SearchFocus.List, mode.Focus); + + // act + mode.Handle(new TuiMessage.MoveCursor(CursorDirection.Right)); + + // assert + Assert.Equal(SearchFocus.Detail, mode.Focus); + } + + [Fact] + public async Task Handle_Should_MoveSelection_When_FocusIsListAndCursorMoves() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("t-1")); + store.Tasks.Add(TaskItemBuilder.Create("t-2")); + store.Tasks.Add(TaskItemBuilder.Create("t-3")); + var mode = new SearchMode(store); + mode.OnEnter(); + await mode.TickAsync(Now, CancellationToken.None); + mode.Handle(new TuiMessage.OpenSelected()); + Assert.Equal(SearchFocus.List, mode.Focus); + Assert.Equal("t-1", mode.SelectedTaskId); + + // act & assert + mode.Handle(new TuiMessage.MoveCursor(CursorDirection.Down)); + Assert.Equal("t-2", mode.SelectedTaskId); + + mode.Handle(new TuiMessage.MoveCursor(CursorDirection.Down)); + Assert.Equal("t-3", mode.SelectedTaskId); + + mode.Handle(new TuiMessage.MoveCursor(CursorDirection.Up)); + Assert.Equal("t-2", mode.SelectedTaskId); + } + + [Fact] + public async Task Handle_Should_NotMoveSelection_When_FocusIsNotList() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("t-1")); + store.Tasks.Add(TaskItemBuilder.Create("t-2")); + var mode = new SearchMode(store); + mode.OnEnter(); + await mode.TickAsync(Now, CancellationToken.None); + Assert.Equal(SearchFocus.Input, mode.Focus); + + // act + mode.Handle(new TuiMessage.MoveCursor(CursorDirection.Down)); + + // assert + Assert.Equal("t-1", mode.SelectedTaskId); + } + + [Fact] + public async Task Handle_Should_MoveSelectionToEdge_When_FocusIsList() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("t-1")); + store.Tasks.Add(TaskItemBuilder.Create("t-2")); + store.Tasks.Add(TaskItemBuilder.Create("t-3")); + var mode = new SearchMode(store); + mode.OnEnter(); + await mode.TickAsync(Now, CancellationToken.None); + mode.Handle(new TuiMessage.OpenSelected()); + + // act & assert + mode.Handle(new TuiMessage.MoveToEdge(EdgeTarget.Bottom)); + Assert.Equal("t-3", mode.SelectedTaskId); + + mode.Handle(new TuiMessage.MoveToEdge(EdgeTarget.Top)); + Assert.Equal("t-1", mode.SelectedTaskId); + } + + [Fact] + public async Task Handle_Should_RerunLastAppliedQuery_When_RefreshRequestedReceived() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("t-1")); + var mode = new SearchMode(store); + mode.OnEnter(); + await mode.TickAsync(Now, CancellationToken.None); + Assert.Equal(1, store.QueryCount); + + // act + mode.Handle(new TuiMessage.RefreshRequested()); + var ran = await mode.TickAsync(Now, CancellationToken.None); + + // assert + Assert.True(ran); + Assert.Equal(2, store.QueryCount); + } + + [Fact] + public void KeyMap_Should_MirrorEscapeToMoveLeft_And_TabToOpenSelected() + { + // arrange + var mode = new SearchMode(new FakeTaskStore()); + + // act + var escapeResolved = mode.KeyMap!.TryResolve( + new KeyChord(ConsoleKey.Escape, ConsoleModifiers.None, '\u001b'), out var escapeMessage); + var tabResolved = mode.KeyMap!.TryResolve( + new KeyChord(ConsoleKey.Tab, ConsoleModifiers.None, '\t'), out var tabMessage); + + // assert + Assert.True(escapeResolved); + Assert.Equal(new TuiMessage.MoveCursor(CursorDirection.Left), escapeMessage); + Assert.True(tabResolved); + Assert.Equal(new TuiMessage.OpenSelected(), tabMessage); + } + + [Fact] + public void Handle_Should_EmitBack_When_MoveCursorLeftReceivedAtInputFocus() + { + // arrange + var mode = new SearchMode(new FakeTaskStore()); + Assert.Equal(SearchFocus.Input, mode.Focus); + + // act + var followUps = mode.Handle(new TuiMessage.MoveCursor(CursorDirection.Left)); + + // assert + Assert.Equal([new TuiMessage.Back()], followUps); + Assert.Equal(SearchFocus.Input, mode.Focus); + } + + [Fact] + public void Handle_Should_NotEmitBack_When_MoveCursorLeftReceivedAtListFocus() + { + // arrange + var mode = new SearchMode(new FakeTaskStore()); + mode.Handle(new TuiMessage.OpenSelected()); + Assert.Equal(SearchFocus.List, mode.Focus); + + // act + var followUps = mode.Handle(new TuiMessage.MoveCursor(CursorDirection.Left)); + + // assert + Assert.Empty(followUps); + Assert.Equal(SearchFocus.Input, mode.Focus); + } + + [Fact] + public async Task Render_Should_ShowSelectedTaskDetail_Instead_Of_Placeholder() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("t-1", "My task title")); + var mode = new SearchMode(store); + mode.OnEnter(); + await mode.TickAsync(Now, CancellationToken.None); + mode.Handle(new TuiMessage.OpenSelected()); + mode.Handle(new TuiMessage.OpenSelected()); + Assert.Equal(SearchFocus.Detail, mode.Focus); + + // act + var console = new TestConsole().Width(100); + console.Write(mode.Render(100, 24)); + + // assert + var text = console.Output; + Assert.DoesNotContain("detail view pending", text); + Assert.Contains("My task title", text); + } + + private static void TypeChar(SearchMode mode, char c, DateTimeOffset now) + => mode.HandleQueryKey(new ConsoleKeyInfo(c, ConsoleKey.NoName, false, false, false), now); + + private static void TypeText(SearchMode mode, string text, DateTimeOffset now) + { + foreach (var c in text) + { + TypeChar(mode, c, now); + } + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Search/TaskItemBuilder.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Search/TaskItemBuilder.cs new file mode 100644 index 00000000000..42ad972143c --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Search/TaskItemBuilder.cs @@ -0,0 +1,35 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Search; + +/// <summary> +/// Builds <see cref="TaskItem"/> instances with sensible defaults for search +/// mode tests. +/// </summary> +internal static class TaskItemBuilder +{ + public static TaskItem Create( + string id, + string title = "", + string status = TaskStates.Open, + int priority = TaskPriorities.Medium, + string type = TaskTypes.Task, + string? assignee = null, + DateTimeOffset? createdAt = null, + DateTimeOffset? updatedAt = null) + { + var created = createdAt ?? DateTimeOffset.UnixEpoch; + + return new TaskItem + { + Id = id, + Title = title.Length == 0 ? id : title, + Status = status, + Priority = priority, + Type = type, + Assignee = assignee, + CreatedAt = created, + UpdatedAt = updatedAt ?? created + }; + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Search/TaskQueryParserTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Search/TaskQueryParserTests.cs new file mode 100644 index 00000000000..19d0cc1a740 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Search/TaskQueryParserTests.cs @@ -0,0 +1,361 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Search; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Search; + +public sealed class TaskQueryParserTests +{ + [Fact] + public void TryParse_Should_ReturnEmptyQuery_When_InputIsBlank() + { + // act + var success = TaskQueryParser.TryParse(" ", out var query, out _); + + // assert + Assert.True(success); + Assert.Null(query.Text); + Assert.Null(query.Statuses); + Assert.Null(query.Labels); + Assert.Null(query.Type); + Assert.Null(query.Priority); + Assert.Null(query.Assignee); + Assert.False(query.Ready); + Assert.False(query.Blocked); + Assert.False(query.Stale); + } + + [Fact] + public void TryParse_Should_JoinBareWords_When_MultipleGiven() + { + // act + var success = TaskQueryParser.TryParse("fix the parser", out var query, out _); + + // assert + Assert.True(success); + Assert.Equal("fix the parser", query.Text); + } + + [Fact] + public void TryParse_Should_KeepEmbeddedSpaces_When_ValueIsQuoted() + { + // act + var success = TaskQueryParser.TryParse("\"fix the parser\"", out var query, out _); + + // assert + Assert.True(success); + Assert.Equal("fix the parser", query.Text); + } + + [Fact] + public void TryParse_Should_KeepEmbeddedSpaces_When_KeyValueIsQuoted() + { + // act + var success = TaskQueryParser.TryParse("assignee:\"jane doe\"", out var query, out _); + + // assert + Assert.True(success); + Assert.Equal("jane doe", query.Assignee); + } + + [Theory] + [InlineData("status:open", "open")] + [InlineData("status:Open", "open")] + [InlineData("status:in-progress", "in_progress")] + public void TryParse_Should_NormalizeStatus(string input, string expected) + { + // act + var success = TaskQueryParser.TryParse(input, out var query, out _); + + // assert + Assert.True(success); + Assert.Equal([expected], query.Statuses); + } + + [Fact] + public void TryParse_Should_AccumulateStatuses_When_RepeatedTokensGiven() + { + // act + var success = TaskQueryParser.TryParse( + "status:open status:in_progress", out var query, out _); + + // assert + Assert.True(success); + Assert.Equal(["open", "in_progress"], query.Statuses); + } + + [Fact] + public void TryParse_Should_AccumulateLabels_When_RepeatedTokensGiven() + { + // act + var success = TaskQueryParser.TryParse( + "label:Backend label:Frontend", out var query, out _); + + // assert + Assert.True(success); + Assert.Equal(["backend", "frontend"], query.Labels); + } + + [Fact] + public void TryParse_Should_NormalizeType() + { + // act + var success = TaskQueryParser.TryParse("type:BUG", out var query, out _); + + // assert + Assert.True(success); + Assert.Equal(TaskTypes.Bug, query.Type); + } + + [Fact] + public void TryParse_Should_TakeLastValue_When_TypeGivenTwice() + { + // act + var success = TaskQueryParser.TryParse("type:bug type:feature", out var query, out _); + + // assert + Assert.True(success); + Assert.Equal(TaskTypes.Feature, query.Type); + } + + [Theory] + [InlineData("p0", 0)] + [InlineData("P4", 4)] + public void TryParse_Should_SetPriority_When_BareKeywordGiven(string input, int expected) + { + // act + var success = TaskQueryParser.TryParse(input, out var query, out _); + + // assert + Assert.True(success); + Assert.Equal(expected, query.Priority); + } + + [Theory] + [InlineData("priority:2", 2)] + [InlineData("priority:p3", 3)] + [InlineData("priority:P1", 1)] + public void TryParse_Should_SetPriority_When_KeyValueGiven(string input, int expected) + { + // act + var success = TaskQueryParser.TryParse(input, out var query, out _); + + // assert + Assert.True(success); + Assert.Equal(expected, query.Priority); + } + + [Fact] + public void TryParse_Should_TreatOutOfRangeBarePriority_AsFreeText() + { + // act + var success = TaskQueryParser.TryParse("p9", out var query, out _); + + // assert + Assert.True(success); + Assert.Null(query.Priority); + Assert.Equal("p9", query.Text); + } + + [Fact] + public void TryParse_Should_FailWithPosition_When_PriorityValueIsInvalid() + { + // act + var success = TaskQueryParser.TryParse("open priority:9", out _, out var error); + + // assert + Assert.False(success); + Assert.Equal(5, error.Position); + Assert.Contains("Invalid priority '9'", error.Message); + } + + [Fact] + public void TryParse_Should_SetAssignee() + { + // act + var success = TaskQueryParser.TryParse("assignee:alice", out var query, out _); + + // assert + Assert.True(success); + Assert.Equal("alice", query.Assignee); + } + + [Theory] + [InlineData("ready")] + [InlineData("READY")] + public void TryParse_Should_SetReady_When_BareKeywordGiven(string input) + { + // act + var success = TaskQueryParser.TryParse(input, out var query, out _); + + // assert + Assert.True(success); + Assert.True(query.Ready); + } + + [Fact] + public void TryParse_Should_SetBlocked_When_BareKeywordGiven() + { + // act + var success = TaskQueryParser.TryParse("blocked", out var query, out _); + + // assert + Assert.True(success); + Assert.True(query.Blocked); + } + + [Fact] + public void TryParse_Should_SetStale_When_BareKeywordGiven() + { + // act + var success = TaskQueryParser.TryParse("stale", out var query, out _); + + // assert + Assert.True(success); + Assert.True(query.Stale); + } + + [Fact] + public void TryParse_Should_TreatQuotedKeyword_AsFreeText() + { + // act + var success = TaskQueryParser.TryParse("\"ready\"", out var query, out _); + + // assert + Assert.True(success); + Assert.False(query.Ready); + Assert.Equal("ready", query.Text); + } + + [Fact] + public void TryParse_Should_CombineFreeTextAndFilters_When_Mixed() + { + // act + var success = TaskQueryParser.TryParse( + "parser bug status:open p1 label:tui assignee:alice ready", + out var query, + out _); + + // assert + Assert.True(success); + Assert.Equal("parser bug", query.Text); + Assert.Equal(["open"], query.Statuses); + Assert.Equal(1, query.Priority); + Assert.Equal(["tui"], query.Labels); + Assert.Equal("alice", query.Assignee); + Assert.True(query.Ready); + } + + [Fact] + public void TryParse_Should_FailWithPosition_When_KeyIsUnknown() + { + // act + var success = TaskQueryParser.TryParse("open owner:alice", out var query, out var error); + + // assert + Assert.False(success); + Assert.Equal(TaskQuery.Empty, query); + Assert.Equal(5, error.Position); + Assert.Equal("Unknown key 'owner:'.", error.Message); + } + + [Fact] + public void TryParse_Should_TreatQuotedUnknownKeyLookingToken_AsFreeText() + { + // act + var success = TaskQueryParser.TryParse("\"owner:alice\"", out var query, out _); + + // assert + Assert.True(success); + Assert.Equal("owner:alice", query.Text); + } + + [Fact] + public void ToFilter_Should_MapPlainFields_When_NoKeywordsGiven() + { + // arrange + TaskQueryParser.TryParse( + "parser status:open type:bug p1 label:tui assignee:alice", + out var query, + out _); + var now = new DateTimeOffset(2026, 8, 18, 0, 0, 0, TimeSpan.Zero); + + // act + var filter = query.ToFilter(now); + + // assert + Assert.Equal("parser", filter.Text); + Assert.Equal(["open"], filter.Statuses!); + Assert.Equal(TaskTypes.Bug, filter.Type); + Assert.Equal(1, filter.Priority); + Assert.Equal(["tui"], filter.Labels!); + Assert.Equal("alice", filter.Assignee); + Assert.False(filter.ExcludeBlocked); + Assert.Null(filter.UpdatedBefore); + Assert.Null(filter.DeferredVisibleAt); + Assert.Equal(TaskOrdering.PriorityCreatedId, filter.Ordering); + } + + [Fact] + public void ToFilter_Should_ApplyReadyDefaults_When_ReadyKeywordGiven() + { + // arrange + TaskQueryParser.TryParse("ready", out var query, out _); + var now = new DateTimeOffset(2026, 8, 18, 0, 0, 0, TimeSpan.Zero); + + // act + var filter = query.ToFilter(now); + + // assert + Assert.Equal([TaskStates.Open], filter.Statuses!); + Assert.True(filter.ExcludeBlocked); + Assert.Equal(now, filter.DeferredVisibleAt); + Assert.Equal(TaskOrdering.ReadyPick, filter.Ordering); + } + + [Fact] + public void ToFilter_Should_KeepExplicitStatuses_When_ReadyKeywordAlsoGiven() + { + // arrange + TaskQueryParser.TryParse("ready status:deferred", out var query, out _); + var now = new DateTimeOffset(2026, 8, 18, 0, 0, 0, TimeSpan.Zero); + + // act + var filter = query.ToFilter(now); + + // assert + Assert.Equal([TaskStates.Deferred], filter.Statuses!); + } + + [Fact] + public void ToFilter_Should_ApplyStaleDefaults_When_StaleKeywordGiven() + { + // arrange + TaskQueryParser.TryParse("stale", out var query, out _); + var now = new DateTimeOffset(2026, 8, 18, 0, 0, 0, TimeSpan.Zero); + + // act + var filter = query.ToFilter(now); + + // assert + Assert.Equal([TaskStates.Open, TaskStates.InProgress], filter.Statuses!); + Assert.Equal(now - TaskQuery.DefaultStaleWindow, filter.UpdatedBefore); + Assert.Equal(TaskOrdering.UpdatedAtAscending, filter.Ordering); + } + + [Fact] + public void ToFilter_Should_NotRepresentBlocked_Because_TaskFilterHasNoMatchingField() + { + // arrange + TaskQueryParser.TryParse("blocked", out var query, out _); + var now = new DateTimeOffset(2026, 8, 18, 0, 0, 0, TimeSpan.Zero); + + // act + var filter = query.ToFilter(now); + + // assert + Assert.True(query.Blocked); + Assert.Null(filter.Statuses); + Assert.False(filter.ExcludeBlocked); + Assert.Equal(TaskOrdering.PriorityCreatedId, filter.Ordering); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Shell/ConfirmDialogTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Shell/ConfirmDialogTests.cs new file mode 100644 index 00000000000..d4082c4a312 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Shell/ConfirmDialogTests.cs @@ -0,0 +1,77 @@ +using ChilliCream.Nitro.CommandLine.Tui.Input; +using ChilliCream.Nitro.CommandLine.Tui.Shell; +using Spectre.Console.Testing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Shell; + +public sealed class ConfirmDialogTests +{ + private static TuiMessage? Resolve(KeyMap map, ConsoleKey key, char keyChar, ConsoleModifiers modifiers = ConsoleModifiers.None) => + map.TryResolve(new KeyChord(key, modifiers, keyChar), out var message) ? message : null; + + [Fact] + public void KeyMap_Should_ResolveY_ToConfirmQuit() + { + // arrange + var dialog = new ConfirmDialog("Quit? (y/n)"); + + // act + var message = Resolve(dialog.KeyMap, ConsoleKey.Y, 'y'); + + // assert + Assert.IsType<TuiMessage.ConfirmQuit>(message); + } + + [Fact] + public void KeyMap_Should_ResolveN_ToCancelQuit() + { + // arrange + var dialog = new ConfirmDialog("Quit? (y/n)"); + + // act + var message = Resolve(dialog.KeyMap, ConsoleKey.N, 'n'); + + // assert + Assert.IsType<TuiMessage.CancelQuit>(message); + } + + [Fact] + public void KeyMap_Should_ResolveEscape_ToCancelQuit() + { + // arrange + var dialog = new ConfirmDialog("Quit? (y/n)"); + + // act + var message = Resolve(dialog.KeyMap, ConsoleKey.Escape, ''); + + // assert + Assert.IsType<TuiMessage.CancelQuit>(message); + } + + [Fact] + public void KeyMap_Should_ReturnNull_When_KeyUnbound() + { + // arrange + var dialog = new ConfirmDialog("Quit? (y/n)"); + + // act + var message = Resolve(dialog.KeyMap, ConsoleKey.Q, 'q'); + + // assert + Assert.Null(message); + } + + [Fact] + public void Render_Should_IncludeMessage() + { + // arrange + var dialog = new ConfirmDialog("Quit? (y/n)"); + var console = new TestConsole().Width(40).Height(10); + + // act + console.Write(dialog.Render(40, 10)); + + // assert + Assert.Contains("Quit? (y/n)", console.Output); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Shell/FakeTaskStore.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Shell/FakeTaskStore.cs new file mode 100644 index 00000000000..ce71ca38636 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Shell/FakeTaskStore.cs @@ -0,0 +1,220 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Shell; + +/// <summary> +/// An in-memory <see cref="ITaskStore"/> exercising the surface +/// <see cref="TuiShell"/>'s cross-mode gestures consume (task by id, labels, +/// close, reopen, delete, update, create), plus the task detail surface +/// <c>BoardDetailMode</c> consumes (dependencies, blocks, comments, computed +/// blocked-by), which return empty. Every other member throws +/// <see cref="NotSupportedException"/>. +/// </summary> +internal sealed class FakeTaskStore : ITaskStore +{ + /// <summary> + /// When set, the next write call throws this instead of returning a + /// result. + /// </summary> + public ExitException? ThrowOnWrite { get; set; } + + public Dictionary<string, TaskItem> Tasks { get; } = []; + + public Dictionary<string, string[]> Labels { get; } = []; + + public IReadOnlyList<string>? ClosedIds { get; private set; } + + public string? ReopenedId { get; private set; } + + public string? DeletedId { get; private set; } + + public string? UpdatedId { get; private set; } + + public TaskUpdate? UpdateReceived { get; private set; } + + public string? Actor { get; private set; } + + public TaskCreation? CreationReceived { get; private set; } + + public TaskCreationResult CreationResult { get; set; } = new() { Id = "a1" }; + + public Task<IReadOnlyList<TaskItem>> QueryTasksAsync(TaskFilter filter, CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyList<TaskItem>>([.. Tasks.Values]); + + public Task<IReadOnlyList<TaskDependency>> GetDependencyEdgesAsync(CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyList<TaskDependency>>([]); + + public Task<TaskItem?> GetTaskAsync(string id, CancellationToken cancellationToken) + => Task.FromResult(Tasks.GetValueOrDefault(id)); + + public Task<IReadOnlyList<string>> GetLabelsAsync(string taskId, CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyList<string>>(Labels.GetValueOrDefault(taskId) ?? []); + + public Task<IReadOnlyList<TaskItem>> CloseTaskAsync( + IReadOnlyList<string> ids, string reason, string actor, CancellationToken cancellationToken) + { + ClosedIds = ids; + Actor = actor; + var task = Tasks[ids[0]]; + task.Status = TaskStates.Closed; + return Task.FromResult<IReadOnlyList<TaskItem>>([task]); + } + + public Task<TaskItem> ReopenTaskAsync(string id, string reason, string actor, CancellationToken cancellationToken) + { + ReopenedId = id; + Actor = actor; + var task = Tasks[id]; + task.Status = TaskStates.Open; + return Task.FromResult(task); + } + + public Task<TaskItem> DeleteTaskAsync(string id, string reason, string actor, CancellationToken cancellationToken) + { + DeletedId = id; + Actor = actor; + var task = Tasks[id]; + task.Status = TaskStates.Tombstone; + return Task.FromResult(task); + } + + public Task<TaskUpdateResult> UpdateTaskAsync(string id, TaskUpdate update, CancellationToken cancellationToken) + { + UpdatedId = id; + UpdateReceived = update; + Actor = update.Actor; + + if (ThrowOnWrite is { } exception) + { + throw exception; + } + + var changedFields = new List<string>(); + if (update.TitleGiven) + { + changedFields.Add("title"); + } + + if (update.StatusGiven) + { + changedFields.Add("status"); + } + + if (update.PriorityGiven) + { + changedFields.Add("priority"); + } + + if (update.TypeGiven) + { + changedFields.Add("type"); + } + + if (update.DescriptionGiven) + { + changedFields.Add("description"); + } + + if (update.NotesGiven) + { + changedFields.Add("notes"); + } + + return Task.FromResult(new TaskUpdateResult { ChangedFields = changedFields }); + } + + public Task<IReadOnlyList<TaskLabelChange>> AddLabelAsync( + string id, IReadOnlyList<string> labels, string actor, CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyList<TaskLabelChange>>( + [.. labels.Select(label => new TaskLabelChange(label, Added: true))]); + + public Task RemoveLabelAsync(string id, string label, string actor, CancellationToken cancellationToken) + => Task.CompletedTask; + + public string? FindWorkspaceDirectory() => throw new NotSupportedException(); + + public Task<TaskItem> GetRequiredTaskAsync(string id, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskLabelCount>> GetLabelCountsAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskComment>> GetCommentsAsync(string taskId, CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyList<TaskComment>>([]); + + public Task<IReadOnlyList<TaskDependencyDetail>> GetDependenciesAsync(string taskId, CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyList<TaskDependencyDetail>>([]); + + public Task<IReadOnlyList<TaskDependentDetail>> GetDependentsAsync(string taskId, CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyList<TaskDependentDetail>>([]); + + public Task<IReadOnlyDictionary<string, IReadOnlyList<string>>> ComputeBlockedAsync(CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyDictionary<string, IReadOnlyList<string>>>(new Dictionary<string, IReadOnlyList<string>>()); + + public Task<IReadOnlyList<TaskEpicStatus>> GetEpicStatusesAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<int> CountTasksAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskCount>> CountTasksByAsync(TaskCountDimension dimension, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskStats> GetStatsAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<string?> GetConfigAsync(string key, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task SetConfigAsync(string key, string value, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskConfigEntry>> ListConfigAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<string> GetPrefixAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task InitializeWorkspaceAsync(string workspaceDirectory, string prefix, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskCreationResult> CreateTaskAsync(TaskCreation creation, CancellationToken cancellationToken) + { + CreationReceived = creation; + Actor = creation.Actor; + + if (ThrowOnWrite is { } exception) + { + throw exception; + } + + return Task.FromResult(CreationResult); + } + + public Task<TaskItem> DeferTaskAsync(string id, DateTimeOffset until, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem> UndeferTaskAsync(string id, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskEpicStatus>> CloseEligibleEpicsAsync(string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskComment> AddCommentAsync(string id, string text, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskDependencyAddResult> AddDependencyAsync(string id, string dependsOnId, string type, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task RemoveDependencyAsync(string id, string dependsOnId, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskSyncRecord>> ExportTasksAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskImportResult> ImportTasksAsync(IReadOnlyList<TaskSyncRecord> records, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task EnsureWorkspaceAsync(string workspaceDirectory, CancellationToken cancellationToken) + => throw new NotSupportedException(); +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Shell/FakeTuiMode.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Shell/FakeTuiMode.cs new file mode 100644 index 00000000000..7bb48767bc1 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Shell/FakeTuiMode.cs @@ -0,0 +1,49 @@ +using ChilliCream.Nitro.CommandLine.Tui.Input; +using ChilliCream.Nitro.CommandLine.Tui.Shell; +using Spectre.Console; +using Spectre.Console.Rendering; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Shell; + +/// <summary> +/// A minimal <see cref="ITuiMode"/> test double that records every call it receives +/// and lets the test script the follow-up messages returned from <see cref="Handle"/>. +/// </summary> +internal sealed class FakeTuiMode : ITuiMode +{ + public KeyMap? KeyMap { get; set; } + + public bool EnterCalled { get; private set; } + + public List<(int Width, int Height)> ResizeCalls { get; } = []; + + public List<TuiMessage> HandledMessages { get; } = []; + + public List<(int Width, int Height)> RenderCalls { get; } = []; + + public Func<TuiMessage, IReadOnlyList<TuiMessage>> HandleResult { get; set; } = static _ => []; + + public string RenderText { get; set; } = "mode"; + + public string? SelectedTaskId { get; set; } + + public List<string> SelectTaskCalls { get; } = []; + + public void OnEnter() => EnterCalled = true; + + public void SelectTask(string taskId) => SelectTaskCalls.Add(taskId); + + public void OnResize(int width, int height) => ResizeCalls.Add((width, height)); + + public IReadOnlyList<TuiMessage> Handle(TuiMessage message) + { + HandledMessages.Add(message); + return HandleResult(message); + } + + public IRenderable Render(int width, int height) + { + RenderCalls.Add((width, height)); + return new Markup(RenderText); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Shell/TaskItemBuilder.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Shell/TaskItemBuilder.cs new file mode 100644 index 00000000000..5adef1f923b --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Shell/TaskItemBuilder.cs @@ -0,0 +1,20 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Shell; + +/// <summary> +/// Builds <see cref="TaskItem"/> instances with sensible defaults for +/// <see cref="TuiShell"/> wiring tests. +/// </summary> +internal static class TaskItemBuilder +{ + public static TaskItem Create(string id, string title = "", string status = TaskStates.Open) + => new() + { + Id = id, + Title = title.Length == 0 ? id : title, + Status = status, + CreatedAt = DateTimeOffset.UnixEpoch, + UpdatedAt = DateTimeOffset.UnixEpoch + }; +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Shell/ToasterTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Shell/ToasterTests.cs new file mode 100644 index 00000000000..d5f3fd350df --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Shell/ToasterTests.cs @@ -0,0 +1,163 @@ +using ChilliCream.Nitro.CommandLine.Tui.Input; +using ChilliCream.Nitro.CommandLine.Tui.Shell; +using Spectre.Console.Testing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Shell; + +public sealed class ToasterTests +{ + private static readonly DateTimeOffset Start = DateTimeOffset.UnixEpoch; + + private static string RenderToText(Toaster toaster) + { + var renderable = toaster.Render(); + if (renderable is null) + { + return string.Empty; + } + + var console = new TestConsole().Width(80); + console.Write(renderable); + return console.Output; + } + + [Fact] + public void Render_Should_ReturnNull_When_NoToastQueued() + { + // arrange + var toaster = new Toaster(); + + // act + var renderable = toaster.Render(); + + // assert + Assert.Null(renderable); + } + + [Fact] + public void Enqueue_Should_ShowImmediately_When_NoCurrentToast() + { + // arrange + var toaster = new Toaster(); + + // act + toaster.Enqueue("saved", ToastStyle.Success, Start); + + // assert + Assert.Contains("saved", RenderToText(toaster)); + } + + [Fact] + public void Enqueue_Should_QueueSecondToast_When_ToastAlreadyShowing() + { + // arrange + var toaster = new Toaster(); + toaster.Enqueue("first", ToastStyle.Info, Start); + + // act + toaster.Enqueue("second", ToastStyle.Info, Start); + + // assert + var text = RenderToText(toaster); + Assert.Contains("first", text); + Assert.DoesNotContain("second", text); + } + + [Fact] + public void Tick_Should_ReturnFalse_When_DurationNotElapsed() + { + // arrange + var toaster = new Toaster(); + toaster.Enqueue("saved", ToastStyle.Success, Start); + + // act + var dirty = toaster.Tick(Start + TimeSpan.FromSeconds(1)); + + // assert + Assert.False(dirty); + Assert.Contains("saved", RenderToText(toaster)); + } + + [Fact] + public void Tick_Should_AdvanceToQueuedToast_When_DurationElapsed() + { + // arrange + var toaster = new Toaster(); + toaster.Enqueue("first", ToastStyle.Info, Start); + toaster.Enqueue("second", ToastStyle.Info, Start); + + // act + var dirty = toaster.Tick(Start + Toaster.Duration); + + // assert + Assert.True(dirty); + var text = RenderToText(toaster); + Assert.Contains("second", text); + Assert.DoesNotContain("first", text); + } + + [Fact] + public void Tick_Should_ClearCurrentToast_When_DurationElapsedAndNoneQueued() + { + // arrange + var toaster = new Toaster(); + toaster.Enqueue("saved", ToastStyle.Success, Start); + + // act + var dirty = toaster.Tick(Start + Toaster.Duration); + + // assert + Assert.True(dirty); + Assert.Null(toaster.Render()); + } + + [Fact] + public void Tick_Should_ReturnFalse_When_NoToastShowing() + { + // arrange + var toaster = new Toaster(); + + // act + var dirty = toaster.Tick(Start); + + // assert + Assert.False(dirty); + } + + [Fact] + public void Render_Should_IncludeStyleIcon_When_Success() + { + AssertStyleIcon(ToastStyle.Success, "โœ”"); + } + + [Fact] + public void Render_Should_IncludeStyleIcon_When_Warn() + { + AssertStyleIcon(ToastStyle.Warn, "!"); + } + + [Fact] + public void Render_Should_IncludeStyleIcon_When_Error() + { + AssertStyleIcon(ToastStyle.Error, "โœ˜"); + } + + [Fact] + public void Render_Should_IncludeStyleIcon_When_Info() + { + AssertStyleIcon(ToastStyle.Info, "i"); + } + + private static void AssertStyleIcon(ToastStyle style, string icon) + { + // arrange + var toaster = new Toaster(); + toaster.Enqueue("message", style, Start); + + // act + var text = RenderToText(toaster); + + // assert + Assert.Contains(icon, text); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Shell/TuiShellTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Shell/TuiShellTests.cs new file mode 100644 index 00000000000..b2c3252d357 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Shell/TuiShellTests.cs @@ -0,0 +1,1244 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Board; +using ChilliCream.Nitro.CommandLine.Tui.Input; +using ChilliCream.Nitro.CommandLine.Tui.Runtime; +using ChilliCream.Nitro.CommandLine.Tui.Search; +using ChilliCream.Nitro.CommandLine.Tui.Shell; +using ChilliCream.Nitro.CommandLine.Tui.Tree; +using Spectre.Console.Testing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Shell; + +public sealed class TuiShellTests +{ + private static ConsoleKeyInfo KeyInfo(char keyChar, ConsoleKey key, ConsoleModifiers modifiers = ConsoleModifiers.None) => + new( + keyChar, + key, + modifiers.HasFlag(ConsoleModifiers.Shift), + modifiers.HasFlag(ConsoleModifiers.Alt), + modifiers.HasFlag(ConsoleModifiers.Control)); + + private static TuiShell CreateShell(FakeTuiMode mode, int width = 80, int height = 24) => + new(new KeyDispatcher(KeyMap.CreateDefaultGlobal()), mode, width, height); + + private static TuiShell CreateShellWithModes( + ITuiMode initialMode, + FakeTaskStore store, + out SearchMode searchMode, + out DependencyTreeView treeView) + { + searchMode = new SearchMode(store); + treeView = new DependencyTreeView(store, rootId: ""); + + return new TuiShell( + new KeyDispatcher(KeyMap.CreateDefaultGlobal()), + initialMode, + 80, + 24, + searchMode, + treeView, + store, + actor: "tester"); + } + + private static string RenderToText(TuiShell shell) + { + var console = new TestConsole().Width(80); + console.Write(shell.Render()); + return console.Output; + } + + private static string RenderToText(TuiShell shell, int width) + { + var console = new TestConsole().Width(width); + console.Write(shell.Render()); + return console.Output; + } + + [Fact] + public void Constructor_Should_CallOnEnter_OnActiveMode() + { + // arrange + var mode = new FakeTuiMode(); + + // act + CreateShell(mode); + + // assert + Assert.True(mode.EnterCalled); + } + + [Fact] + public void Handle_Should_OpenConfirmDialog_When_QuitRequested() + { + // arrange + var shell = CreateShell(new FakeTuiMode()); + + // act + var dirty = shell.Handle(new TuiEvent.KeyEvent(KeyInfo('q', ConsoleKey.Q))); + + // assert + Assert.True(dirty); + Assert.Contains("Quit?", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_RaiseQuitConfirmed_When_YPressedWhileConfirmActive() + { + // arrange + var shell = CreateShell(new FakeTuiMode()); + var confirmed = false; + shell.QuitConfirmed += () => confirmed = true; + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('q', ConsoleKey.Q))); + + // act + var dirty = shell.Handle(new TuiEvent.KeyEvent(KeyInfo('y', ConsoleKey.Y))); + + // assert + Assert.True(dirty); + Assert.True(confirmed); + } + + [Fact] + public void Handle_Should_CloseConfirmDialogWithoutQuitting_When_NPressedWhileConfirmActive() + { + // arrange + var mode = new FakeTuiMode { RenderText = "board" }; + var shell = CreateShell(mode); + var confirmed = false; + shell.QuitConfirmed += () => confirmed = true; + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('q', ConsoleKey.Q))); + + // act + var dirty = shell.Handle(new TuiEvent.KeyEvent(KeyInfo('n', ConsoleKey.N))); + + // assert + Assert.True(dirty); + Assert.False(confirmed); + var text = RenderToText(shell); + Assert.DoesNotContain("Quit?", text); + Assert.Contains("board", text); + } + + [Fact] + public void Handle_Should_PrioritizeConfirmDialogKeys_OverGlobalCopyBinding_When_ConfirmActive() + { + // The global table binds 'y' to CopySelectedId; while the confirm dialog is + // active it must win that binding instead of falling through to global. + var mode = new FakeTuiMode(); + var shell = CreateShell(mode); + var confirmed = false; + shell.QuitConfirmed += () => confirmed = true; + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('q', ConsoleKey.Q))); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('y', ConsoleKey.Y))); + + // assert + Assert.True(confirmed); + Assert.DoesNotContain(mode.HandledMessages, m => m is TuiMessage.CopySelectedId); + } + + [Fact] + public void Handle_Should_SwallowUnboundKey_When_QuitConfirmIsActive() + { + // The quit confirmation is fully modal: a key unresolved by its own + // key map (y/n/Esc) must not fall through to the active mode or the + // global table, so no overlay opens and no mode change happens. + var mode = new FakeTuiMode { SelectedTaskId = "a" }; + var shell = CreateShell(mode); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('q', ConsoleKey.Q))); + + // act + var dirty = shell.Handle(new TuiEvent.KeyEvent(KeyInfo('e', ConsoleKey.E))); + + // assert + Assert.False(dirty); + Assert.Empty(mode.HandledMessages); + var text = RenderToText(shell); + Assert.Contains("Quit?", text); + } + + [Fact] + public void Handle_Should_NotDelegateToActiveMode_When_EnterPressedWhileQuitConfirmIsActive() + { + // Enter (OpenSelected) must not reach the active mode while the quit + // confirmation is open, so it cannot switch the mode underneath the + // dialog. + var mode = new FakeTuiMode { SelectedTaskId = "a", RenderText = "board" }; + var shell = CreateShell(mode); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('q', ConsoleKey.Q))); + + // act + var dirty = shell.Handle(new TuiEvent.KeyEvent(new ConsoleKeyInfo('\r', ConsoleKey.Enter, false, false, false))); + + // assert + Assert.False(dirty); + Assert.Empty(mode.HandledMessages); + var text = RenderToText(shell); + Assert.Contains("Quit?", text); + Assert.DoesNotContain("board", text); + } + + [Fact] + public void Handle_Should_ForwardRefreshRequested_ToActiveMode() + { + // arrange + var mode = new FakeTuiMode(); + var shell = CreateShell(mode); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('r', ConsoleKey.R))); + + // assert + Assert.Contains(mode.HandledMessages, m => m is TuiMessage.RefreshRequested); + } + + [Fact] + public void Handle_Should_ForwardDataChangedEvent_ToActiveModeAsRefreshRequested() + { + // arrange + var mode = new FakeTuiMode(); + var shell = CreateShell(mode); + + // act + var dirty = shell.Handle(new TuiEvent.DataChangedEvent()); + + // assert + Assert.True(dirty); + Assert.Contains(mode.HandledMessages, m => m is TuiMessage.RefreshRequested); + } + + [Fact] + public void Handle_Should_DelegateUnhandledMessage_ToActiveMode() + { + // arrange + var mode = new FakeTuiMode(); + var shell = CreateShell(mode); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('j', ConsoleKey.J))); + + // assert + var moveCursor = Assert.IsType<TuiMessage.MoveCursor>(Assert.Single(mode.HandledMessages)); + Assert.Equal(CursorDirection.Down, moveCursor.Direction); + } + + [Fact] + public void Handle_Should_DispatchFollowUpMessages_FromActiveMode() + { + // arrange + var mode = new FakeTuiMode + { + HandleResult = _ => [new TuiMessage.ShowToast("saved", ToastStyle.Success)] + }; + var shell = CreateShell(mode); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('r', ConsoleKey.R))); + + // assert + Assert.Contains("saved", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_ExpireToast_When_TickIsFarEnoughInTheFuture() + { + // arrange + var mode = new FakeTuiMode + { + HandleResult = _ => [new TuiMessage.ShowToast("saved", ToastStyle.Success)] + }; + var shell = CreateShell(mode); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('r', ConsoleKey.R))); + Assert.Contains("saved", RenderToText(shell)); + + // act + var dirty = shell.Handle(new TuiEvent.TickEvent(DateTimeOffset.UtcNow.AddSeconds(4))); + + // assert + Assert.True(dirty); + Assert.DoesNotContain("saved", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_ReturnFalse_When_TickWithNoActiveToast() + { + // arrange + var shell = CreateShell(new FakeTuiMode()); + + // act + var dirty = shell.Handle(new TuiEvent.TickEvent(DateTimeOffset.UtcNow)); + + // assert + Assert.False(dirty); + } + + [Fact] + public void Handle_Should_ResizeActiveMode_ReservingStatusRow_When_ResizeEvent() + { + // arrange + var mode = new FakeTuiMode(); + var shell = CreateShell(mode, width: 80, height: 24); + + // act + var dirty = shell.Handle(new TuiEvent.ResizeEvent(100, 30)); + + // assert + Assert.True(dirty); + Assert.Equal((100, 29), Assert.Single(mode.ResizeCalls)); + } + + [Fact] + public void Render_Should_PassContentHeight_ReservingStatusRow_ToActiveMode() + { + // arrange + var mode = new FakeTuiMode(); + var shell = CreateShell(mode, width: 80, height: 24); + + // act + shell.Render(); + + // assert + Assert.Equal((80, 23), Assert.Single(mode.RenderCalls)); + } + + [Fact] + public void Handle_Should_SwitchToSearchAndFocusInput_When_SlashPressed() + { + // arrange + var store = new FakeTaskStore(); + var shell = CreateShellWithModes(new FakeTuiMode(), store, out var search, out _); + + // act + var dirty = shell.Handle(new TuiEvent.KeyEvent(KeyInfo('/', ConsoleKey.Oem2))); + + // assert + Assert.True(dirty); + Assert.Equal(SearchFocus.Input, search.Focus); + Assert.Contains("Results", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_PopBackToPreviousMode_When_EscapePressedAfterSwitchingModes() + { + // arrange + var store = new FakeTaskStore(); + var initialMode = new FakeTuiMode { RenderText = "board" }; + var shell = CreateShellWithModes(initialMode, store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('/', ConsoleKey.Oem2))); + Assert.Contains("Results", RenderToText(shell)); + + // act + var dirty = shell.Handle(new TuiEvent.KeyEvent(KeyInfo('', ConsoleKey.Escape))); + + // assert + Assert.True(dirty); + Assert.Contains("board", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_ShowToast_When_TreeRequestedWithNoSelection() + { + // arrange + var store = new FakeTaskStore(); + var initialMode = new FakeTuiMode(); + var shell = CreateShellWithModes(initialMode, store, out _, out _); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('t', ConsoleKey.T))); + + // assert + Assert.Contains("No task selected.", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_SwitchToTreeRootedOnSelection_When_TreeRequested() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["a"] = TaskItemBuilder.Create("a"); + var initialMode = new FakeTuiMode { SelectedTaskId = "a" }; + var shell = CreateShellWithModes(initialMode, store, out _, out var tree); + + // act + var dirty = shell.Handle(new TuiEvent.KeyEvent(KeyInfo('t', ConsoleKey.T))); + + // assert + Assert.True(dirty); + Assert.Equal("a", tree.RootId); + } + + [Fact] + public void Handle_Should_ShowToast_When_EnterPressedOnBoardWithNoSelection() + { + // arrange: an empty board has no focused-column selection. + var store = new FakeTaskStore(); + var view = new BoardView + { + Name = "Test", + Columns = [new ColumnDefinition { Name = "Open", Statuses = [TaskStates.Open] }] + }; + var board = new BoardMode(new BoardDataLoader(store, TimeProvider.System), [view]); + var shell = CreateShellWithModes(board, store, out _, out _); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\r', ConsoleKey.Enter))); + + // assert + Assert.Contains("No task selected.", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_OpenTaskDetail_When_EnterPressedOnBoardSelection() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["a-1"] = TaskItemBuilder.Create("a-1", "Board task"); + var view = new BoardView + { + Name = "Test", + Columns = [new ColumnDefinition { Name = "Open", Statuses = [TaskStates.Open] }] + }; + var board = new BoardMode(new BoardDataLoader(store, TimeProvider.System), [view]); + var shell = CreateShellWithModes(board, store, out _, out _); + + // act + var dirty = shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\r', ConsoleKey.Enter))); + + // assert + var rendered = RenderToText(shell); + Assert.True(dirty); + Assert.Contains("Board task", rendered); + Assert.DoesNotContain("Detail view not available yet.", rendered); + } + + [Fact] + public void Handle_Should_ReturnToBoardWithSelectionPreserved_When_EscapePressedAfterOpeningDetailFromBoard() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["a-1"] = TaskItemBuilder.Create("a-1", "Board task"); + var view = new BoardView + { + Name = "Test", + Columns = [new ColumnDefinition { Name = "Open", Statuses = [TaskStates.Open] }] + }; + var board = new BoardMode(new BoardDataLoader(store, TimeProvider.System), [view]); + var shell = CreateShellWithModes(board, store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\r', ConsoleKey.Enter))); + Assert.Contains("Board task", RenderToText(shell)); + + // act + var dirty = shell.Handle(new TuiEvent.KeyEvent(KeyInfo('', ConsoleKey.Escape))); + + // assert + Assert.True(dirty); + Assert.Equal("a-1", board.State.Columns[0].SelectedTaskId); + Assert.DoesNotContain("Detail view not available yet.", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_ShowToast_When_EditRequestedWithNoSelection() + { + // arrange + var store = new FakeTaskStore(); + var shell = CreateShellWithModes(new FakeTuiMode(), store, out _, out _); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('e', ConsoleKey.E))); + + // assert + Assert.Contains("No task selected.", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_OpenEditorAndWriteChangedTitle_When_EditRequestedThenSaved() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["a"] = TaskItemBuilder.Create("a", "Old title"); + var initialMode = new FakeTuiMode { SelectedTaskId = "a" }; + var shell = CreateShellWithModes(initialMode, store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('e', ConsoleKey.E))); + Assert.Contains("Edit Task", RenderToText(shell)); + + // act: append a character to the focused title field, then tab to the + // button row (7 fields: title/status/priority/type/labels/description/ + // notes) and activate the default-selected Save button. + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('!', ConsoleKey.NoName))); + + for (var i = 0; i < 7; i++) + { + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\t', ConsoleKey.Tab))); + } + + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\r', ConsoleKey.Enter))); + + // assert + Assert.Equal("a", store.UpdatedId); + Assert.Equal("Old title!", store.UpdateReceived!.Title); + Assert.Equal("tester", store.Actor); + Assert.Contains("Updated task", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_KeepEditorFormOpenWithValuesAndShowError_When_StoreRejectsSave() + { + // arrange + var store = new FakeTaskStore { ThrowOnWrite = new ExitException("rejected") }; + store.Tasks["a"] = TaskItemBuilder.Create("a", "Old title"); + var initialMode = new FakeTuiMode { SelectedTaskId = "a" }; + var shell = CreateShellWithModes(initialMode, store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('e', ConsoleKey.E))); + + // act: append a character to the focused title field, tab to the + // button row, and activate the default-selected Save button. + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('!', ConsoleKey.NoName))); + + for (var i = 0; i < 7; i++) + { + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\t', ConsoleKey.Tab))); + } + + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\r', ConsoleKey.Enter))); + + // assert: the form is still open, and the store's error is shown as a + // toast rather than silently discarded. + var rendered = RenderToText(shell); + Assert.Contains("Edit Task", rendered); + Assert.Contains("rejected", rendered); + + // act: one more Tab wraps focus from the button row back to the title + // field, scrolling it into view. + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\t', ConsoleKey.Tab))); + + // assert: the edited value survived the rejected save. + Assert.Contains("Old title!", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_OpenEditorFromBoardSelection_When_BoardModeIsActive() + { + // arrange: a real BoardMode (not FakeTuiMode) supplies SelectedTaskId + // from its focused column's selected row. + var store = new FakeTaskStore(); + store.Tasks["a-1"] = TaskItemBuilder.Create("a-1", "Board task"); + var view = new BoardView + { + Name = "Test", + Columns = [new ColumnDefinition { Name = "Open", Statuses = [TaskStates.Open] }] + }; + var board = new BoardMode(new BoardDataLoader(store, TimeProvider.System), [view]); + var shell = CreateShellWithModes(board, store, out _, out _); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('e', ConsoleKey.E))); + + // assert + var rendered = RenderToText(shell); + Assert.Contains("Edit Task", rendered); + Assert.DoesNotContain("No task selected.", rendered); + } + + [Fact] + public void Handle_Should_CreateChildTaskFromBoardSelection_When_BoardModeIsActive() + { + // arrange: create-as-child reads the active mode's SelectedTaskId as + // the new task's parent id. + var store = new FakeTaskStore(); + store.Tasks["a-1"] = TaskItemBuilder.Create("a-1", "Board task"); + var view = new BoardView + { + Name = "Test", + Columns = [new ColumnDefinition { Name = "Open", Statuses = [TaskStates.Open] }] + }; + var board = new BoardMode(new BoardDataLoader(store, TimeProvider.System), [view]); + var shell = CreateShellWithModes(board, store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('c', ConsoleKey.C))); + + // act: fill the required title field, tab past the parent field + // (left on its default "child" option) to the button row, submit. + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('!', ConsoleKey.NoName))); + + for (var i = 0; i < 6; i++) + { + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\t', ConsoleKey.Tab))); + } + + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\r', ConsoleKey.Enter))); + + // assert + Assert.Equal("a-1", store.CreationReceived!.ParentId); + } + + [Fact] + public void Handle_Should_CloseEditorWithoutWriting_When_EscapePressedWhileEditing() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["a"] = TaskItemBuilder.Create("a"); + var initialMode = new FakeTuiMode { SelectedTaskId = "a" }; + var shell = CreateShellWithModes(initialMode, store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('e', ConsoleKey.E))); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('', ConsoleKey.Escape))); + + // assert + Assert.Null(store.UpdatedId); + Assert.DoesNotContain("Edit Task", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_OpenDiscardConfirmation_When_EscapePressedWhileEditingDirtyEditorForm() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["a"] = TaskItemBuilder.Create("a", "Title"); + var initialMode = new FakeTuiMode { SelectedTaskId = "a" }; + var shell = CreateShellWithModes(initialMode, store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('e', ConsoleKey.E))); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('!', ConsoleKey.NoName))); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('', ConsoleKey.Escape))); + + // assert: the editor stays open behind the confirmation, unwritten. + Assert.Null(store.UpdatedId); + Assert.Contains("Discard unsaved changes?", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_ReturnToEditorFormWithValuesIntact_When_DiscardCancelled() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["a"] = TaskItemBuilder.Create("a", "Title"); + var initialMode = new FakeTuiMode { SelectedTaskId = "a" }; + var shell = CreateShellWithModes(initialMode, store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('e', ConsoleKey.E))); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('!', ConsoleKey.NoName))); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('', ConsoleKey.Escape))); + + // act: Escape on the discard confirmation cancels it, not the editor. + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('', ConsoleKey.Escape))); + + // assert + var text = RenderToText(shell); + Assert.DoesNotContain("Discard unsaved changes?", text); + Assert.Contains("Edit Task", text); + } + + [Fact] + public void Handle_Should_CloseEditorWithoutWriting_When_DiscardConfirmed() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["a"] = TaskItemBuilder.Create("a", "Title"); + var initialMode = new FakeTuiMode { SelectedTaskId = "a" }; + var shell = CreateShellWithModes(initialMode, store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('e', ConsoleKey.E))); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('!', ConsoleKey.NoName))); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('', ConsoleKey.Escape))); + + // act: Enter on the discard confirmation's focused reason field confirms it. + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\r', ConsoleKey.Enter))); + + // assert + Assert.Null(store.UpdatedId); + var text = RenderToText(shell); + Assert.DoesNotContain("Discard unsaved changes?", text); + Assert.DoesNotContain("Edit Task", text); + } + + [Fact] + public void Handle_Should_OpenDiscardConfirmation_When_EscapePressedWhileCreatingDirtyCreateForm() + { + // arrange + var store = new FakeTaskStore(); + var shell = CreateShellWithModes(new FakeTuiMode(), store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('c', ConsoleKey.C))); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('!', ConsoleKey.NoName))); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('', ConsoleKey.Escape))); + + // assert: the create form stays open behind the confirmation. + Assert.Null(store.CreationReceived); + Assert.Contains("Discard unsaved changes?", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_CloseCreateFormWithoutCreating_When_DiscardConfirmed() + { + // arrange + var store = new FakeTaskStore(); + var shell = CreateShellWithModes(new FakeTuiMode(), store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('c', ConsoleKey.C))); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('!', ConsoleKey.NoName))); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('', ConsoleKey.Escape))); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\r', ConsoleKey.Enter))); + + // assert + Assert.Null(store.CreationReceived); + var text = RenderToText(shell); + Assert.DoesNotContain("Discard unsaved changes?", text); + Assert.DoesNotContain("Create Task", text); + } + + [Fact] + public void Handle_Should_CloseTask_When_CloseOrReopenRequestedOnOpenTask() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["a"] = TaskItemBuilder.Create("a", status: TaskStates.Open); + var initialMode = new FakeTuiMode { SelectedTaskId = "a" }; + var shell = CreateShellWithModes(initialMode, store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('x', ConsoleKey.X))); + Assert.Contains("Close task", RenderToText(shell)); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\r', ConsoleKey.Enter))); + + // assert + Assert.Equal(["a"], store.ClosedIds); + Assert.Equal("tester", store.Actor); + Assert.Contains("Closed task", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_ReopenTask_When_CloseOrReopenRequestedOnClosedTask() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["a"] = TaskItemBuilder.Create("a", status: TaskStates.Closed); + var initialMode = new FakeTuiMode { SelectedTaskId = "a" }; + var shell = CreateShellWithModes(initialMode, store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('x', ConsoleKey.X))); + Assert.Contains("Reopen task", RenderToText(shell)); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\r', ConsoleKey.Enter))); + + // assert + Assert.Equal("a", store.ReopenedId); + Assert.Contains("Reopened task", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_DeleteTask_When_DeleteRequested() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["a"] = TaskItemBuilder.Create("a"); + var initialMode = new FakeTuiMode { SelectedTaskId = "a" }; + var shell = CreateShellWithModes(initialMode, store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('X', ConsoleKey.X, ConsoleModifiers.Shift))); + Assert.Contains("Delete task", RenderToText(shell)); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\r', ConsoleKey.Enter))); + + // assert + Assert.Equal("a", store.DeletedId); + Assert.Contains("Deleted task", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_ShowToast_When_StatusPickerRequestedWithNoSelection() + { + // arrange + var store = new FakeTaskStore(); + var shell = CreateShellWithModes(new FakeTuiMode(), store, out _, out _); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('s', ConsoleKey.S))); + + // assert + Assert.Contains("No task selected.", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_OpenStatusPickerAndWriteSelectedStatus_When_StatusPickerRequestedThenApplied() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["a"] = TaskItemBuilder.Create("a", status: TaskStates.Open); + var initialMode = new FakeTuiMode { SelectedTaskId = "a" }; + var shell = CreateShellWithModes(initialMode, store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('s', ConsoleKey.S))); + Assert.Contains("Status", RenderToText(shell)); + + // act: move down once to In Progress, then apply. + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('j', ConsoleKey.J))); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\r', ConsoleKey.Enter))); + + // assert + Assert.Equal("a", store.UpdatedId); + Assert.True(store.UpdateReceived!.StatusGiven); + Assert.Equal(TaskStates.InProgress, store.UpdateReceived.Status); + Assert.Equal("tester", store.Actor); + Assert.Contains("Status set to", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_CancelStatusPickerWithoutWriting_When_EscapePressedWhilePicking() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["a"] = TaskItemBuilder.Create("a", status: TaskStates.Open); + var initialMode = new FakeTuiMode { SelectedTaskId = "a" }; + var shell = CreateShellWithModes(initialMode, store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('s', ConsoleKey.S))); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('', ConsoleKey.Escape))); + + // assert + Assert.Null(store.UpdatedId); + Assert.DoesNotContain("Status", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_OpenCloseConfirmation_When_ClosedPickedOnStatusPicker() + { + // arrange: picking Closed on the status picker is not a bare status + // write, it routes through the same close confirmation flow as x. + var store = new FakeTaskStore(); + store.Tasks["a"] = TaskItemBuilder.Create("a", status: TaskStates.Open); + var initialMode = new FakeTuiMode { SelectedTaskId = "a" }; + var shell = CreateShellWithModes(initialMode, store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('s', ConsoleKey.S))); + + // act: move down to Closed (Open, In Progress, Blocked, Deferred, Closed). + for (var i = 0; i < 4; i++) + { + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('j', ConsoleKey.J))); + } + + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\r', ConsoleKey.Enter))); + + // assert + Assert.Null(store.UpdatedId); + Assert.Contains("Close task", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_ShowToast_When_PriorityPickerRequestedWithNoSelection() + { + // arrange + var store = new FakeTaskStore(); + var shell = CreateShellWithModes(new FakeTuiMode(), store, out _, out _); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('p', ConsoleKey.P))); + + // assert + Assert.Contains("No task selected.", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_OpenPriorityPickerAndWriteSelectedPriority_When_PriorityPickerRequestedThenApplied() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["a"] = TaskItemBuilder.Create("a"); + var initialMode = new FakeTuiMode { SelectedTaskId = "a" }; + var shell = CreateShellWithModes(initialMode, store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('p', ConsoleKey.P))); + Assert.Contains("Priority", RenderToText(shell)); + + // act: move up once to P1, then apply. + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('k', ConsoleKey.K))); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\r', ConsoleKey.Enter))); + + // assert + Assert.Equal("a", store.UpdatedId); + Assert.True(store.UpdateReceived!.PriorityGiven); + Assert.Equal(1, store.UpdateReceived.Priority); + Assert.Contains("Priority set to", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_OpenCreateFormAndRouteSubsequentKeys_ToForm_When_CreateTaskRequested() + { + // arrange + var store = new FakeTaskStore(); + var mode = new FakeTuiMode(); + var shell = CreateShellWithModes(mode, store, out _, out _); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('c', ConsoleKey.C))); + Assert.Contains("Create Task", RenderToText(shell)); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('j', ConsoleKey.J))); + + // assert: the 'j' that would otherwise move the active mode's cursor + // was absorbed by the modal create form instead. + Assert.Empty(mode.HandledMessages); + } + + [Fact] + public void Handle_Should_PresetEpicType_When_CreateEpicRequestedThenSubmitted() + { + // arrange + var store = new FakeTaskStore(); + var shell = CreateShellWithModes(new FakeTuiMode(), store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('C', ConsoleKey.C, ConsoleModifiers.Shift))); + Assert.Contains("Create Epic", RenderToText(shell)); + + // act: type a title, tab to the button row (5 fields: title/type/ + // priority/labels/description), then activate the default Create button. + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('!', ConsoleKey.NoName))); + + for (var i = 0; i < 5; i++) + { + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\t', ConsoleKey.Tab))); + } + + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\r', ConsoleKey.Enter))); + + // assert + Assert.Equal(TaskTypes.Epic, store.CreationReceived!.Type); + } + + [Fact] + public void Handle_Should_PassSelectedTaskIdAsParentId_When_CreateFormSubmittedWithSelection() + { + // arrange + var store = new FakeTaskStore(); + var initialMode = new FakeTuiMode { SelectedTaskId = "a" }; + var shell = CreateShellWithModes(initialMode, store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('c', ConsoleKey.C))); + + // act: a selection adds a parent field (title/type/parent/priority/ + // labels/description), left on its default "child" option. + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('!', ConsoleKey.NoName))); + + for (var i = 0; i < 6; i++) + { + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\t', ConsoleKey.Tab))); + } + + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\r', ConsoleKey.Enter))); + + // assert + Assert.Equal("a", store.CreationReceived!.ParentId); + } + + [Fact] + public void Handle_Should_CreateTopLevelTask_When_ParentFieldSwitchedToNoParentWithSelection() + { + // arrange: TryOpenCreateForm always passes the active mode's + // SelectedTaskId as parent, and a populated board column always has + // a selection, so switching the parent field is the only board + // gesture that creates a root task while a row is selected. + var store = new FakeTaskStore(); + var initialMode = new FakeTuiMode { SelectedTaskId = "a" }; + var shell = CreateShellWithModes(initialMode, store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('c', ConsoleKey.C))); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('!', ConsoleKey.NoName))); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\t', ConsoleKey.Tab))); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\t', ConsoleKey.Tab))); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\0', ConsoleKey.RightArrow))); + + for (var i = 0; i < 4; i++) + { + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\t', ConsoleKey.Tab))); + } + + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\r', ConsoleKey.Enter))); + + // assert + Assert.Null(store.CreationReceived!.ParentId); + } + + [Fact] + public void Handle_Should_CreateTopLevelTaskWithNoToast_When_CreateFormSubmittedWithNoSelection() + { + // arrange: creating never requires a selection, so a null + // SelectedTaskId must not trigger the "No task selected." toast that + // gates edit, lifecycle, and the pickers. + var store = new FakeTaskStore(); + var shell = CreateShellWithModes(new FakeTuiMode(), store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('c', ConsoleKey.C))); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('!', ConsoleKey.NoName))); + + for (var i = 0; i < 5; i++) + { + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\t', ConsoleKey.Tab))); + } + + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\r', ConsoleKey.Enter))); + + // assert + Assert.Null(store.CreationReceived!.ParentId); + Assert.DoesNotContain("No task selected.", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_ToastRefreshModeAndSelectCreatedTask_When_CreateFormSubmittedSuccessfully() + { + // arrange + var store = new FakeTaskStore { CreationResult = new TaskCreationResult { Id = "a2" } }; + var mode = new FakeTuiMode(); + var shell = CreateShellWithModes(mode, store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('c', ConsoleKey.C))); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('!', ConsoleKey.NoName))); + + for (var i = 0; i < 5; i++) + { + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\t', ConsoleKey.Tab))); + } + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\r', ConsoleKey.Enter))); + + // assert + Assert.Contains("Created task 'a2'.", RenderToText(shell)); + Assert.Contains(mode.HandledMessages, m => m is TuiMessage.RefreshRequested); + Assert.Equal(["a2"], mode.SelectTaskCalls); + } + + [Fact] + public void Handle_Should_KeepCreateFormOpenWithValuesAndShowError_When_StoreRejectsCreate() + { + // arrange + var store = new FakeTaskStore { ThrowOnWrite = new ExitException("rejected") }; + var shell = CreateShellWithModes(new FakeTuiMode(), store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('c', ConsoleKey.C))); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('!', ConsoleKey.NoName))); + + for (var i = 0; i < 5; i++) + { + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\t', ConsoleKey.Tab))); + } + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\r', ConsoleKey.Enter))); + + // assert: the form is still open with the entered title, and the + // store's error is shown as a toast rather than silently discarded. + Assert.Equal("!", store.CreationReceived!.Title); + var rendered = RenderToText(shell); + Assert.Contains("Create Task", rendered); + Assert.Contains("rejected", rendered); + } + + [Fact] + public void Handle_Should_CloseCreateFormWithoutCreating_When_EscapePressedWhileCreating() + { + // arrange + var store = new FakeTaskStore(); + var shell = CreateShellWithModes(new FakeTuiMode(), store, out _, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('c', ConsoleKey.C))); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('', ConsoleKey.Escape))); + + // assert + Assert.Null(store.CreationReceived); + Assert.DoesNotContain("Create Task", RenderToText(shell)); + } + + [Fact] + public void Handle_Should_RouteRawKeyToSearchQueryInput_When_SearchModeHasInputFocus() + { + // arrange + var store = new FakeTaskStore(); + var shell = CreateShellWithModes(new FakeTuiMode(), store, out var search, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('/', ConsoleKey.Oem2))); + + // act: 'j' would move a list cursor globally, but with the query + // input focused it must be typed instead. + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('j', ConsoleKey.J))); + + // assert + Assert.Equal("j", search.QueryText); + } + + [Fact] + public void Render_Should_ShowGlobalFooterHints_When_NoOverlayOrToastIsActive() + { + // arrange + var shell = CreateShell(new FakeTuiMode()); + + // act + var text = RenderToText(shell); + + // assert: the curated global hint set fits an 80-column footer + // untruncated. + Assert.Contains("move", text); + Assert.Contains("open", text); + Assert.Contains("refresh", text); + Assert.Contains("copy id", text); + Assert.Contains("zoom", text); + Assert.Contains("edit", text); + Assert.Contains("back", text); + Assert.Contains("quit", text); + Assert.DoesNotContain("โ€ฆ", text); + } + + [Fact] + public void Render_Should_ReplaceFooterWithToast_When_ToastIsActive() + { + // arrange + var mode = new FakeTuiMode + { + HandleResult = _ => [new TuiMessage.ShowToast("saved", ToastStyle.Success)] + }; + var shell = CreateShell(mode); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('r', ConsoleKey.R))); + var text = RenderToText(shell); + + // assert: the toast is showing, so the footer's own hints are not. + Assert.Contains("saved", text); + Assert.DoesNotContain("quit", text); + } + + [Fact] + public void Render_Should_ShowFooterAgain_When_ToastExpires() + { + // arrange + var mode = new FakeTuiMode + { + HandleResult = _ => [new TuiMessage.ShowToast("saved", ToastStyle.Success)] + }; + var shell = CreateShell(mode); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('r', ConsoleKey.R))); + + // act + shell.Handle(new TuiEvent.TickEvent(DateTimeOffset.UtcNow.AddSeconds(4))); + var text = RenderToText(shell); + + // assert + Assert.DoesNotContain("saved", text); + Assert.Contains("quit", text); + } + + [Fact] + public void Render_Should_ShowOnlyQuitDialogHints_NoGlobalHints_When_QuitConfirmIsActive() + { + // arrange + var shell = CreateShell(new FakeTuiMode()); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('q', ConsoleKey.Q))); + + // act + var text = RenderToText(shell); + + // assert: the quit dialog swallows every key itself, so the global + // hints (which would not actually work) are not shown alongside it. + Assert.Contains("confirm", text); + Assert.Contains("cancel", text); + Assert.DoesNotContain("move", text); + } + + [Fact] + public void Render_Should_ShowOnlyFormHints_NoGlobalHints_When_TaskEditorFormIsActive() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["a"] = TaskItemBuilder.Create("a"); + var initialMode = new FakeTuiMode { SelectedTaskId = "a" }; + var shell = CreateShellWithModes(initialMode, store, out _, out _); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('e', ConsoleKey.E))); + var text = RenderToText(shell); + + // assert: the editor form swallows every key itself, so the global + // hints (which would not actually work) are not shown alongside it. + Assert.Contains("next field", text); + Assert.Contains("save", text); + Assert.Contains("cancel", text); + Assert.DoesNotContain("quit", text); + } + + [Fact] + public void Render_Should_ShowOnlyPickerHints_NoGlobalHints_When_QuickPickerIsActive() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks["a"] = TaskItemBuilder.Create("a", status: TaskStates.Open); + var initialMode = new FakeTuiMode { SelectedTaskId = "a" }; + var shell = CreateShellWithModes(initialMode, store, out _, out _); + + // act + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('s', ConsoleKey.S))); + var text = RenderToText(shell); + + // assert + Assert.Contains("select", text); + Assert.Contains("apply", text); + Assert.Contains("cancel", text); + Assert.DoesNotContain("quit", text); + } + + [Fact] + public void Render_Should_ShowOnlySearchInputHints_When_QueryInputHasFocus() + { + // arrange + var store = new FakeTaskStore(); + var shell = CreateShellWithModes(new FakeTuiMode(), store, out _, out _); + + // act: '/' switches to search mode with the query input focused. + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('/', ConsoleKey.Oem2))); + var text = RenderToText(shell); + + // assert: only the hints the query input actually honors are shown + // (typing, esc back, tab open, enter open); every other key, + // including the global table's hjkl/r/y/z/e/q, is swallowed into + // the query instead, so those hints must not appear. + Assert.Contains("type search esc back tab open enter open", text); + Assert.DoesNotContain("move", text); + Assert.DoesNotContain("refresh", text); + Assert.DoesNotContain("copy id", text); + Assert.DoesNotContain("zoom", text); + Assert.DoesNotContain("edit", text); + Assert.DoesNotContain("quit", text); + } + + [Fact] + public void Render_Should_ShowContextAndGlobalHints_When_SearchFocusLeavesInput() + { + // arrange + var store = new FakeTaskStore(); + var shell = CreateShellWithModes(new FakeTuiMode(), store, out var search, out _); + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('/', ConsoleKey.Oem2))); + + // act: Tab opens the selected result, moving focus from Input to + // List, so the query is no longer swallowing every key. + shell.Handle(new TuiEvent.KeyEvent(KeyInfo('\t', ConsoleKey.Tab))); + var text = RenderToText(shell); + + // assert + Assert.Equal(SearchFocus.List, search.Focus); + Assert.DoesNotContain("type search", text); + Assert.Contains("move", text); + } + + [Fact] + public void Render_Should_TruncateFooterWithEllipsis_When_WidthCannotFitEveryHint() + { + // arrange + var shell = CreateShell(new FakeTuiMode(), width: 15); + + // act + var text = RenderToText(shell, width: 15); + + // assert: the first (and narrowest-fitting) hint survives, later + // ones are dropped behind a trailing ellipsis. + Assert.Contains("move", text); + Assert.Contains("โ€ฆ", text); + Assert.DoesNotContain("quit", text); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Tree/DependencyTreeViewTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Tree/DependencyTreeViewTests.cs new file mode 100644 index 00000000000..ab375388453 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Tree/DependencyTreeViewTests.cs @@ -0,0 +1,467 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Input; +using ChilliCream.Nitro.CommandLine.Tui.Tree; +using Spectre.Console; +using Spectre.Console.Testing; +using CursorDirection = ChilliCream.Nitro.CommandLine.Tui.Input.CursorDirection; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Tree; + +public sealed class DependencyTreeViewTests +{ + [Fact] + public void Constructor_Should_Throw_When_StoreIsNull() + { + // act & assert + Assert.Throws<ArgumentNullException>(() => new DependencyTreeView(null!, "a")); + } + + [Fact] + public void Constructor_Should_Throw_When_RootIdIsNull() + { + // act & assert + Assert.Throws<ArgumentNullException>(() => new DependencyTreeView(new FakeTaskStore(), null!)); + } + + [Fact] + public void OnEnter_Should_LoadOnlyRoot_When_NoEdgesExist() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a")); + var view = new DependencyTreeView(store, "a"); + + // act + view.OnEnter(); + + // assert + Assert.Equal(["a"], view.Rows.Select(r => r.TaskId)); + Assert.Equal("a", view.SelectedTaskId); + } + + [Fact] + public void OnEnter_Should_LoadDependencyChildren_When_EdgesExist() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a")); + store.Tasks.Add(TaskItemBuilder.Create("b")); + store.Edges.Add(Edge("a", "b")); + var view = new DependencyTreeView(store, "a"); + + // act + view.OnEnter(); + + // assert + Assert.Equal(["a", "b"], view.Rows.Select(r => r.TaskId)); + } + + [Fact] + public void SelectedTaskId_Should_BeNull_When_NotYetEntered() + { + // arrange + var view = new DependencyTreeView(new FakeTaskStore(), "a"); + + // assert + Assert.Null(view.SelectedTaskId); + } + + [Fact] + public void Handle_MoveCursorDown_Should_AdvanceSelection() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a")); + store.Tasks.Add(TaskItemBuilder.Create("b")); + store.Tasks.Add(TaskItemBuilder.Create("c")); + store.Edges.Add(Edge("a", "b")); + store.Edges.Add(Edge("a", "c")); + var view = new DependencyTreeView(store, "a"); + view.OnEnter(); + Assert.Equal("a", view.SelectedTaskId); + + // act & assert + view.Handle(new TuiMessage.MoveCursor(CursorDirection.Down)); + Assert.Equal("b", view.SelectedTaskId); + + view.Handle(new TuiMessage.MoveCursor(CursorDirection.Down)); + Assert.Equal("c", view.SelectedTaskId); + } + + [Fact] + public void Handle_MoveCursorUp_Should_ClampAtFirstRow() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a")); + var view = new DependencyTreeView(store, "a"); + view.OnEnter(); + + // act + view.Handle(new TuiMessage.MoveCursor(CursorDirection.Up)); + + // assert + Assert.Equal("a", view.SelectedTaskId); + } + + [Fact] + public void Handle_MoveToEdge_Should_JumpToTopAndBottom() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a")); + store.Tasks.Add(TaskItemBuilder.Create("b")); + store.Tasks.Add(TaskItemBuilder.Create("c")); + store.Edges.Add(Edge("a", "b")); + store.Edges.Add(Edge("a", "c")); + var view = new DependencyTreeView(store, "a"); + view.OnEnter(); + + // act & assert + view.Handle(new TuiMessage.MoveToEdge(EdgeTarget.Bottom)); + Assert.Equal("c", view.SelectedTaskId); + + view.Handle(new TuiMessage.MoveToEdge(EdgeTarget.Top)); + Assert.Equal("a", view.SelectedTaskId); + } + + [Fact] + public void Handle_OpenSelected_Should_RefocusOnCursorNode_And_PushBreadcrumb() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a")); + store.Tasks.Add(TaskItemBuilder.Create("b")); + store.Tasks.Add(TaskItemBuilder.Create("c")); + store.Edges.Add(Edge("a", "b")); + store.Edges.Add(Edge("b", "c")); + var view = new DependencyTreeView(store, "a"); + view.OnEnter(); + view.Handle(new TuiMessage.MoveCursor(CursorDirection.Down)); + Assert.Equal("b", view.SelectedTaskId); + + // act + view.Handle(new TuiMessage.OpenSelected()); + + // assert + Assert.Equal("b", view.RootId); + Assert.True(view.CanNavigateBack); + Assert.Equal(["b", "c"], view.Rows.Select(r => r.TaskId)); + } + + [Fact] + public void Refocus_Should_HaveNoEffect_When_CursorIsOnRoot() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a")); + var view = new DependencyTreeView(store, "a"); + view.OnEnter(); + + // act + view.Refocus(); + + // assert + Assert.Equal("a", view.RootId); + Assert.False(view.CanNavigateBack); + } + + [Fact] + public void NavigateBack_Should_PopBreadcrumb_And_RestorePreviousRoot() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a")); + store.Tasks.Add(TaskItemBuilder.Create("b")); + store.Edges.Add(Edge("a", "b")); + var view = new DependencyTreeView(store, "a"); + view.OnEnter(); + view.Handle(new TuiMessage.MoveCursor(CursorDirection.Down)); + view.Refocus(); + Assert.Equal("b", view.RootId); + + // act + var popped = view.NavigateBack(); + + // assert + Assert.True(popped); + Assert.Equal("a", view.RootId); + Assert.False(view.CanNavigateBack); + } + + [Fact] + public void NavigateBack_Should_ReturnFalse_When_BreadcrumbsAreEmpty() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a")); + var view = new DependencyTreeView(store, "a"); + view.OnEnter(); + + // act + var popped = view.NavigateBack(); + + // assert + Assert.False(popped); + Assert.Equal("a", view.RootId); + } + + [Fact] + public void EnterOnTask_Should_ResetBreadcrumbsAndCursor() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a")); + store.Tasks.Add(TaskItemBuilder.Create("b")); + store.Tasks.Add(TaskItemBuilder.Create("z")); + store.Edges.Add(Edge("a", "b")); + var view = new DependencyTreeView(store, "a"); + view.OnEnter(); + view.Handle(new TuiMessage.MoveCursor(CursorDirection.Down)); + view.Refocus(); + Assert.True(view.CanNavigateBack); + + // act + view.EnterOnTask("z"); + + // assert + Assert.Equal("z", view.RootId); + Assert.False(view.CanNavigateBack); + Assert.Equal("z", view.SelectedTaskId); + } + + [Fact] + public void ToggleEdgeMode_Should_SwitchBetweenBlockingAndParentChild_WithoutRefetching() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a")); + store.Tasks.Add(TaskItemBuilder.Create("b")); + store.Tasks.Add(TaskItemBuilder.Create("c")); + store.Edges.Add(Edge("a", "b", TaskDependencyTypes.ParentChild)); + store.Edges.Add(Edge("a", "c", TaskDependencyTypes.Blocks)); + var view = new DependencyTreeView(store, "a"); + view.OnEnter(); + Assert.Equal(TreeEdgeMode.Blocking, view.EdgeMode); + Assert.Equal(["a", "b", "c"], view.Rows.Select(r => r.TaskId)); + var queriesAfterEnter = store.EdgeQueryCount; + + // act + view.ToggleEdgeMode(); + + // assert + Assert.Equal(TreeEdgeMode.ParentChild, view.EdgeMode); + Assert.Equal(["a", "b"], view.Rows.Select(r => r.TaskId)); + Assert.Equal(queriesAfterEnter, store.EdgeQueryCount); + } + + [Fact] + public void ToggleDirection_Should_SwitchBetweenUpAndDown_WithoutRefetching() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a")); + store.Tasks.Add(TaskItemBuilder.Create("b")); + store.Tasks.Add(TaskItemBuilder.Create("c")); + store.Edges.Add(Edge("a", "b")); + store.Edges.Add(Edge("c", "a")); + var view = new DependencyTreeView(store, "a"); + view.OnEnter(); + Assert.Equal(TreeDirection.Up, view.Direction); + Assert.Equal(["a", "b"], view.Rows.Select(r => r.TaskId)); + var queriesAfterEnter = store.EdgeQueryCount; + + // act + view.ToggleDirection(); + + // assert + Assert.Equal(TreeDirection.Down, view.Direction); + Assert.Equal(["a", "c"], view.Rows.Select(r => r.TaskId)); + Assert.Equal(queriesAfterEnter, store.EdgeQueryCount); + } + + [Fact] + public void Handle_RefreshRequested_Should_ReloadFromStore() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a")); + var view = new DependencyTreeView(store, "a"); + view.OnEnter(); + var queriesAfterEnter = store.EdgeQueryCount; + + // act + view.Handle(new TuiMessage.RefreshRequested()); + + // assert + Assert.Equal(queriesAfterEnter + 1, store.EdgeQueryCount); + } + + [Fact] + public void KeyMap_Should_ResolveF_To_OpenSelected() + { + // arrange + var view = new DependencyTreeView(new FakeTaskStore(), "a"); + + // act + var resolved = view.KeyMap!.TryResolve( + new KeyChord(ConsoleKey.F, ConsoleModifiers.None, 'f'), out var message); + + // assert + Assert.True(resolved); + Assert.Equal(new TuiMessage.OpenSelected(), message); + } + + [Fact] + public void KeyMap_Should_ResolveM_D_U_To_TreeGestures() + { + // arrange + var view = new DependencyTreeView(new FakeTaskStore(), "a"); + + // act & assert + Assert.True(view.KeyMap!.TryResolve(new KeyChord(ConsoleKey.M, ConsoleModifiers.None, 'm'), out var m)); + Assert.Equal(new TuiMessage.ToggleTreeEdgeMode(), m); + + Assert.True(view.KeyMap!.TryResolve(new KeyChord(ConsoleKey.D, ConsoleModifiers.None, 'd'), out var d)); + Assert.Equal(new TuiMessage.ToggleTreeDirection(), d); + + Assert.True(view.KeyMap!.TryResolve(new KeyChord(ConsoleKey.U, ConsoleModifiers.None, 'u'), out var u)); + Assert.Equal(new TuiMessage.NavigateTreeBack(), u); + } + + [Fact] + public void Handle_ToggleTreeEdgeMode_Should_ToggleEdgeMode() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a")); + store.Tasks.Add(TaskItemBuilder.Create("b")); + store.Edges.Add(Edge("a", "b", TaskDependencyTypes.ParentChild)); + var view = new DependencyTreeView(store, "a"); + view.OnEnter(); + + // act + view.Handle(new TuiMessage.ToggleTreeEdgeMode()); + + // assert + Assert.Equal(TreeEdgeMode.ParentChild, view.EdgeMode); + } + + [Fact] + public void Handle_ToggleTreeDirection_Should_ToggleDirection() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a")); + var view = new DependencyTreeView(store, "a"); + view.OnEnter(); + + // act + view.Handle(new TuiMessage.ToggleTreeDirection()); + + // assert + Assert.Equal(TreeDirection.Down, view.Direction); + } + + [Fact] + public void Handle_NavigateTreeBack_Should_PopBreadcrumb() + { + // arrange + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("a")); + store.Tasks.Add(TaskItemBuilder.Create("b")); + store.Edges.Add(Edge("a", "b")); + var view = new DependencyTreeView(store, "a"); + view.OnEnter(); + view.Handle(new TuiMessage.MoveCursor(CursorDirection.Down)); + view.Refocus(); + Assert.Equal("b", view.RootId); + + // act + view.Handle(new TuiMessage.NavigateTreeBack()); + + // assert + Assert.Equal("a", view.RootId); + } + + [Theory] + [InlineData(64, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa > z ยท blocking ยท depends on")] + [InlineData(44, "aaaaaaaaโ€ฆaaa > z ยท blocking ยท depends on")] + [InlineData(24, "aaaโ€ฆ> z ยท depends on")] + [InlineData(10, "aaaโ€ฆ z")] + public void Render_Should_DegradeBreadcrumbHeader_When_PanelIsNarrow(int width, string expectedHeader) + { + // arrange: a two-id breadcrumb (a pushed root plus the current root) + // wide enough on its own to overflow the panel at the narrower + // widths below. The sole visible row (the tree has no edges once + // refocused on "z") carries a long title so the panel's own, + // content-driven width never becomes the binding constraint on the + // header budget at any of the widths under test, matching the + // pre-refactor header sizing exactly. + var longRootId = new string('a', 30); + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create(longRootId)); + store.Tasks.Add(TaskItemBuilder.Create("z", title: new string('x', 60))); + store.Edges.Add(Edge(longRootId, "z")); + var view = new DependencyTreeView(store, longRootId); + view.OnEnter(); + view.Handle(new TuiMessage.MoveCursor(CursorDirection.Down)); + view.Refocus(); + Assert.Equal("z", view.RootId); + + // act + var panel = Assert.IsType<Panel>(view.Render(width, 10)); + var console = new TestConsole().Width(120); + console.Write(panel); + + // assert: the header degrades deterministically (middle-truncating + // the id chain, then dropping the edge mode, then the direction) + // instead of being cut wherever Spectre's own panel-header ellipsis + // lands. Asserted against the rendered top border line rather than + // panel.Header.Text, since Spectre may still re-truncate a header + // that does not fit the panel's actual (content-driven) width. + Assert.Contains(expectedHeader, console.Lines[0]); + } + + [Fact] + public void Render_Should_TruncateHeaderToPanelWidth_When_ContentRowsAreNarrowerThanHeader() + { + // arrange: a two-id breadcrumb (53 chars + // fully expanded: "acme-epic1 > acme-epic1.1 ยท blocking ยท depended + // on by"), but the only visible row after refocusing is the short, + // untitled "acme-epic1.1" node itself, so the panel's actual + // (content-driven) width is only 36 columns, well under the full + // header. The header must degrade to fit that panel width via this + // view's own deterministic middle-truncation, not via Spectre's + // PanelHeader ellipsis, which would otherwise cut it mid-word before + // " by". + var store = new FakeTaskStore(); + store.Tasks.Add(TaskItemBuilder.Create("acme-epic1")); + store.Tasks.Add(TaskItemBuilder.Create("acme-epic1.1")); + store.Edges.Add(Edge("acme-epic1", "acme-epic1.1")); + var view = new DependencyTreeView(store, "acme-epic1"); + view.OnEnter(); + view.Handle(new TuiMessage.MoveCursor(CursorDirection.Down)); + view.Refocus(); + Assert.Equal("acme-epic1.1", view.RootId); + view.Handle(new TuiMessage.ToggleTreeDirection()); + Assert.Equal(TreeDirection.Down, view.Direction); + + // act + var panel = Assert.IsType<Panel>(view.Render(95, 10)); + var console = new TestConsole().Width(120); + console.Write(panel); + + // assert + Assert.Contains("acmeโ€ฆ1.1 ยท blocking ยท depended on by", console.Lines[0]); + } + + private static TaskDependency Edge(string taskId, string dependsOnId, string type = TaskDependencyTypes.Blocks) + => new() + { + TaskId = taskId, + DependsOnId = dependsOnId, + Type = type, + CreatedAt = DateTimeOffset.UnixEpoch + }; +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Tree/FakeTaskStore.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Tree/FakeTaskStore.cs new file mode 100644 index 00000000000..5b214b575c4 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Tree/FakeTaskStore.cs @@ -0,0 +1,137 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Tree; + +/// <summary> +/// An in-memory <see cref="ITaskStore"/> exercising only the graph-read +/// surface the dependency tree explorer consumes +/// (<see cref="QueryTasksAsync"/>, <see cref="GetDependencyEdgesAsync"/>). +/// Every other member throws <see cref="NotSupportedException"/>: the tree +/// explorer never calls them. +/// </summary> +internal sealed class FakeTaskStore : ITaskStore +{ + public List<TaskItem> Tasks { get; } = []; + + public List<TaskDependency> Edges { get; } = []; + + /// <summary> + /// The number of times <see cref="GetDependencyEdgesAsync"/> has been + /// called. + /// </summary> + public int EdgeQueryCount { get; private set; } + + public Task<IReadOnlyList<TaskItem>> QueryTasksAsync( + TaskFilter filter, + CancellationToken cancellationToken) + => Task.FromResult<IReadOnlyList<TaskItem>>(Tasks.ToList()); + + public Task<IReadOnlyList<TaskDependency>> GetDependencyEdgesAsync( + CancellationToken cancellationToken) + { + EdgeQueryCount++; + return Task.FromResult<IReadOnlyList<TaskDependency>>(Edges.ToList()); + } + + public string? FindWorkspaceDirectory() + => throw new NotSupportedException(); + + public Task<TaskItem?> GetTaskAsync(string id, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem> GetRequiredTaskAsync(string id, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<string>> GetLabelsAsync(string taskId, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskLabelCount>> GetLabelCountsAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskComment>> GetCommentsAsync(string taskId, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskDependencyDetail>> GetDependenciesAsync(string taskId, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskDependentDetail>> GetDependentsAsync(string taskId, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyDictionary<string, IReadOnlyList<string>>> ComputeBlockedAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskEpicStatus>> GetEpicStatusesAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<int> CountTasksAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskCount>> CountTasksByAsync(TaskCountDimension dimension, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskStats> GetStatsAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<string?> GetConfigAsync(string key, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task SetConfigAsync(string key, string value, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskConfigEntry>> ListConfigAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<string> GetPrefixAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task InitializeWorkspaceAsync(string workspaceDirectory, string prefix, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskCreationResult> CreateTaskAsync(TaskCreation creation, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskUpdateResult> UpdateTaskAsync(string id, TaskUpdate update, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskItem>> CloseTaskAsync(IReadOnlyList<string> ids, string reason, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem> ReopenTaskAsync(string id, string reason, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem> DeferTaskAsync(string id, DateTimeOffset until, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem> UndeferTaskAsync(string id, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskItem> DeleteTaskAsync(string id, string reason, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskEpicStatus>> CloseEligibleEpicsAsync(string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskComment> AddCommentAsync(string id, string text, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskLabelChange>> AddLabelAsync(string id, IReadOnlyList<string> labels, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task RemoveLabelAsync(string id, string label, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskDependencyAddResult> AddDependencyAsync(string id, string dependsOnId, string type, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task RemoveDependencyAsync(string id, string dependsOnId, string actor, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<IReadOnlyList<TaskSyncRecord>> ExportTasksAsync(CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task<TaskImportResult> ImportTasksAsync(IReadOnlyList<TaskSyncRecord> records, CancellationToken cancellationToken) + => throw new NotSupportedException(); + + public Task EnsureWorkspaceAsync(string workspaceDirectory, CancellationToken cancellationToken) + => throw new NotSupportedException(); +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Tree/TaskItemBuilder.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Tree/TaskItemBuilder.cs new file mode 100644 index 00000000000..9eb9f247b72 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Tree/TaskItemBuilder.cs @@ -0,0 +1,29 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Tree; + +/// <summary> +/// Builds <see cref="TaskItem"/> instances with sensible defaults for +/// dependency tree tests. +/// </summary> +internal static class TaskItemBuilder +{ + public static TaskItem Create( + string id, + string title = "", + string status = TaskStates.Open, + int priority = TaskPriorities.Medium, + string type = TaskTypes.Task) + { + return new TaskItem + { + Id = id, + Title = title.Length == 0 ? id : title, + Status = status, + Priority = priority, + Type = type, + CreatedAt = DateTimeOffset.UnixEpoch, + UpdatedAt = DateTimeOffset.UnixEpoch + }; + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Tree/TreeBuilderTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Tree/TreeBuilderTests.cs new file mode 100644 index 00000000000..14f48a4c6c8 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Tree/TreeBuilderTests.cs @@ -0,0 +1,252 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Tree; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Tree; + +public sealed class TreeBuilderTests +{ + [Fact] + public void Build_Should_ReturnOnlyRoot_When_NoEdges() + { + // arrange & act + var rows = TreeBuilder.Build("a", [], TreeEdgeMode.Blocking, TreeDirection.Up); + + // assert + var row = Assert.Single(rows); + Assert.Equal("a", row.TaskId); + Assert.Equal(0, row.Depth); + Assert.False(row.IsCycle); + } + + [Fact] + public void Build_Should_WalkTowardDependencies_When_DirectionIsUp() + { + // arrange + // a depends on b and c + var edges = new List<TaskDependency> + { + Edge("a", "b"), + Edge("a", "c") + }; + + // act + var rows = TreeBuilder.Build("a", edges, TreeEdgeMode.Blocking, TreeDirection.Up); + + // assert + Assert.Equal(["a", "b", "c"], rows.Select(r => r.TaskId)); + Assert.Equal([0, 1, 1], rows.Select(r => r.Depth)); + } + + [Fact] + public void Build_Should_WalkTowardDependents_When_DirectionIsDown() + { + // arrange + // b and c depend on a + var edges = new List<TaskDependency> + { + Edge("b", "a"), + Edge("c", "a") + }; + + // act + var rows = TreeBuilder.Build("a", edges, TreeEdgeMode.Blocking, TreeDirection.Down); + + // assert + Assert.Equal(["a", "b", "c"], rows.Select(r => r.TaskId)); + } + + [Fact] + public void Build_Should_OrderChildrenOrdinally() + { + // arrange + var edges = new List<TaskDependency> + { + Edge("a", "z"), + Edge("a", "m"), + Edge("a", "b") + }; + + // act + var rows = TreeBuilder.Build("a", edges, TreeEdgeMode.Blocking, TreeDirection.Up); + + // assert + Assert.Equal(["a", "b", "m", "z"], rows.Select(r => r.TaskId)); + } + + [Fact] + public void Build_Should_OnlyFollowParentChildEdges_When_EdgeModeIsParentChild() + { + // arrange + var edges = new List<TaskDependency> + { + Edge("a", "b", TaskDependencyTypes.ParentChild), + Edge("a", "c", TaskDependencyTypes.Blocks) + }; + + // act + var rows = TreeBuilder.Build("a", edges, TreeEdgeMode.ParentChild, TreeDirection.Up); + + // assert + Assert.Equal(["a", "b"], rows.Select(r => r.TaskId)); + } + + [Fact] + public void Build_Should_FollowEveryBlockingType_When_EdgeModeIsBlocking() + { + // arrange + var edges = new List<TaskDependency> + { + Edge("a", "b", TaskDependencyTypes.Blocks), + Edge("a", "c", TaskDependencyTypes.ParentChild), + Edge("a", "d", TaskDependencyTypes.WaitsFor), + Edge("a", "e", TaskDependencyTypes.Related) + }; + + // act + var rows = TreeBuilder.Build("a", edges, TreeEdgeMode.Blocking, TreeDirection.Up); + + // assert + Assert.Equal(["a", "b", "c", "d"], rows.Select(r => r.TaskId)); + } + + [Fact] + public void Build_Should_StopAtRequestedDepth() + { + // arrange + // a -> b -> c -> d + var edges = new List<TaskDependency> + { + Edge("a", "b"), + Edge("b", "c"), + Edge("c", "d") + }; + + // act + var rows = TreeBuilder.Build("a", edges, TreeEdgeMode.Blocking, TreeDirection.Up, maxDepth: 1); + + // assert + Assert.Equal(["a", "b"], rows.Select(r => r.TaskId)); + } + + [Fact] + public void Build_Should_ClampRequestedDepth_To_HardMaxDepth() + { + // arrange: a chain longer than the hard cap + var edges = new List<TaskDependency>(); + const int chainLength = TreeBuilder.HardMaxDepth + 5; + + for (var i = 0; i < chainLength; i++) + { + edges.Add(Edge($"t{i}", $"t{i + 1}")); + } + + // act + var rows = TreeBuilder.Build("t0", edges, TreeEdgeMode.Blocking, TreeDirection.Up, maxDepth: 999); + + // assert + Assert.Equal(TreeBuilder.HardMaxDepth + 1, rows.Count); + Assert.Equal(TreeBuilder.HardMaxDepth, rows[^1].Depth); + } + + [Fact] + public void Build_Should_MarkSecondOccurrence_As_Cycle_When_PathsConverge() + { + // arrange: diamond, a -> b -> d, a -> c -> d + var edges = new List<TaskDependency> + { + Edge("a", "b"), + Edge("a", "c"), + Edge("b", "d"), + Edge("c", "d") + }; + + // act + var rows = TreeBuilder.Build("a", edges, TreeEdgeMode.Blocking, TreeDirection.Up); + + // assert + Assert.Equal(["a", "b", "d", "c", "d"], rows.Select(r => r.TaskId)); + Assert.False(rows[2].IsCycle); + Assert.True(rows[4].IsCycle); + } + + [Fact] + public void Build_Should_NotExpandBeyond_CycleRow() + { + // arrange: d has its own child e, but d is only reached the second + // time as a cycle row, so e must never appear. + var edges = new List<TaskDependency> + { + Edge("a", "b"), + Edge("a", "c"), + Edge("b", "d"), + Edge("c", "d"), + Edge("d", "e") + }; + + // act + var rows = TreeBuilder.Build("a", edges, TreeEdgeMode.Blocking, TreeDirection.Up); + + // assert + Assert.Equal(["a", "b", "d", "e", "c", "d"], rows.Select(r => r.TaskId)); + Assert.Equal(1, rows.Count(r => r.TaskId == "e")); + Assert.True(rows[5].IsCycle); + } + + [Fact] + public void Build_Should_MarkAncestor_As_Cycle_When_EdgeLoopsBack() + { + // arrange: a -> b -> a + var edges = new List<TaskDependency> + { + Edge("a", "b"), + Edge("b", "a") + }; + + // act + var rows = TreeBuilder.Build("a", edges, TreeEdgeMode.Blocking, TreeDirection.Up); + + // assert + Assert.Equal(["a", "b", "a"], rows.Select(r => r.TaskId)); + Assert.False(rows[0].IsCycle); + Assert.True(rows[2].IsCycle); + } + + [Fact] + public void Build_Should_SetConnectorState_ForSiblingsAndGrandchildren() + { + // arrange: a -> b -> d, a -> c + var edges = new List<TaskDependency> + { + Edge("a", "b"), + Edge("a", "c"), + Edge("b", "d") + }; + + // act + var rows = TreeBuilder.Build("a", edges, TreeEdgeMode.Blocking, TreeDirection.Up); + + // assert + Assert.Equal(["a", "b", "d", "c"], rows.Select(r => r.TaskId)); + + var b = rows[1]; + Assert.False(b.IsLastChild); + Assert.Empty(b.AncestorIsLastChild); + + var d = rows[2]; + Assert.True(d.IsLastChild); + Assert.Equal([false], d.AncestorIsLastChild); + + var c = rows[3]; + Assert.True(c.IsLastChild); + Assert.Empty(c.AncestorIsLastChild); + } + + private static TaskDependency Edge(string taskId, string dependsOnId, string type = TaskDependencyTypes.Blocks) + => new() + { + TaskId = taskId, + DependsOnId = dependsOnId, + Type = type, + CreatedAt = DateTimeOffset.UnixEpoch + }; +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Tree/TreeNodeRowTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Tree/TreeNodeRowTests.cs new file mode 100644 index 00000000000..e1585f75876 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Tree/TreeNodeRowTests.cs @@ -0,0 +1,102 @@ +using ChilliCream.Nitro.CommandLine.Tui.Tree; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Tree; + +public sealed class TreeNodeRowTests +{ + [Fact] + public void BuildConnector_Should_ReturnEmpty_When_DepthIsZero() + { + // arrange + var row = new TreeNodeRow { TaskId = "root", Depth = 0 }; + + // act + var connector = row.BuildConnector(); + + // assert + Assert.Equal("", connector); + } + + [Fact] + public void BuildConnector_Should_UseBranchGlyph_When_DepthOneAndNotLastChild() + { + // arrange + var row = new TreeNodeRow { TaskId = "a", Depth = 1, IsLastChild = false }; + + // act + var connector = row.BuildConnector(); + + // assert + Assert.Equal("โ”œโ”€ ", connector); + } + + [Fact] + public void BuildConnector_Should_UseCornerGlyph_When_DepthOneAndLastChild() + { + // arrange + var row = new TreeNodeRow { TaskId = "a", Depth = 1, IsLastChild = true }; + + // act + var connector = row.BuildConnector(); + + // assert + Assert.Equal("โ””โ”€ ", connector); + } + + [Fact] + public void BuildConnector_Should_DrawContinuationBar_When_AncestorHasMoreSiblings() + { + // arrange + var row = new TreeNodeRow + { + TaskId = "a", + Depth = 2, + IsLastChild = true, + AncestorIsLastChild = [false] + }; + + // act + var connector = row.BuildConnector(); + + // assert + Assert.Equal("โ”‚ โ””โ”€ ", connector); + } + + [Fact] + public void BuildConnector_Should_DrawBlankSegment_When_AncestorIsLastChild() + { + // arrange + var row = new TreeNodeRow + { + TaskId = "a", + Depth = 2, + IsLastChild = false, + AncestorIsLastChild = [true] + }; + + // act + var connector = row.BuildConnector(); + + // assert + Assert.Equal(" โ”œโ”€ ", connector); + } + + [Fact] + public void BuildConnector_Should_ChainMultipleAncestorSegments_When_DepthIsThree() + { + // arrange + var row = new TreeNodeRow + { + TaskId = "a", + Depth = 3, + IsLastChild = true, + AncestorIsLastChild = [true, false] + }; + + // act + var connector = row.BuildConnector(); + + // assert + Assert.Equal(" โ”‚ โ””โ”€ ", connector); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/ColumnPaneTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/ColumnPaneTests.cs new file mode 100644 index 00000000000..e57408a9e07 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/ColumnPaneTests.cs @@ -0,0 +1,78 @@ +using ChilliCream.Nitro.CommandLine.Tui.Theming; +using ChilliCream.Nitro.CommandLine.Tui.Widgets; +using Spectre.Console; +using Spectre.Console.Testing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Widgets; + +public sealed class ColumnPaneTests +{ + [Fact] + public void Render_Should_TitlePanelWithNameAndCount() + { + // act + var panel = ColumnPane.Render("Backlog", 3, ["one", "two"], focused: false); + + // assert + Assert.Equal("Backlog (3)", panel.Header?.Text); + } + + [Fact] + public void Render_Should_UseRoundedBorder() + { + // act + var panel = ColumnPane.Render("Backlog", 0, [], focused: false); + + // assert + Assert.Equal(BoxBorder.Rounded, panel.Border); + } + + [Fact] + public void Render_Should_UseFocusedBorderStyle_When_Focused() + { + // act + var panel = ColumnPane.Render("Backlog", 0, [], focused: true); + + // assert + Assert.Equal(ThemeTokens.GetStyle("board.column.border.focused"), panel.BorderStyle); + } + + [Fact] + public void Render_Should_UseUnfocusedBorderStyle_When_NotFocused() + { + // act + var panel = ColumnPane.Render("Backlog", 0, [], focused: false); + + // assert + Assert.Equal(ThemeTokens.GetStyle("board.column.border"), panel.BorderStyle); + } + + [Fact] + public void Render_Should_RenderContentLines() + { + // arrange + var console = new TestConsole().Width(40); + var panel = ColumnPane.Render("Backlog", 2, ["first row", "second row"], focused: false); + + // act + console.Write(panel); + + // assert + Assert.Contains("first row", console.Output); + Assert.Contains("second row", console.Output); + } + + [Fact] + public void Render_Should_NotThrow_When_NoLines() + { + // arrange + var console = new TestConsole().Width(40); + var panel = ColumnPane.Render("Backlog", 0, [], focused: false); + + // act + var exception = Record.Exception(() => console.Write(panel)); + + // assert + Assert.Null(exception); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/Form/EditableListFieldTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/Form/EditableListFieldTests.cs new file mode 100644 index 00000000000..beb3f052fdb --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/Form/EditableListFieldTests.cs @@ -0,0 +1,184 @@ +using ChilliCream.Nitro.CommandLine.Tui.Widgets.Form; +using Spectre.Console.Testing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Widgets.Form; + +public sealed class EditableListFieldTests +{ + private static ConsoleKeyInfo Key(char c) => new(c, ConsoleKey.NoName, false, false, false); + + private static ConsoleKeyInfo Key(ConsoleKey key) => new('\0', key, false, false, false); + + [Fact] + public void GetValue_Should_ReturnInitialValues() + { + // arrange + var field = new EditableListField("labels", "Labels", initialValues: ["bug", "urgent"]); + + // act + var value = field.GetValue(); + + // assert + Assert.Equal(new FormValue.List(["bug", "urgent"]), value); + } + + [Fact] + public void HandleKey_Should_AddEntry_When_A() + { + // arrange + var field = new EditableListField("labels", "Labels", initialValues: ["bug"]); + + // act + field.HandleKey(Key('a')); + field.HandleKey(Key('x')); + field.HandleKey(Key(ConsoleKey.Enter)); + + // assert + Assert.Equal(new FormValue.List(["bug", "x"]), field.GetValue()); + } + + [Fact] + public void HandleKey_Should_RemoveSelectedEntry_When_D() + { + // arrange + var field = new EditableListField("labels", "Labels", initialValues: ["bug", "urgent"]); + + // act + field.HandleKey(Key('d')); + + // assert + Assert.Equal(new FormValue.List(["urgent"]), field.GetValue()); + } + + [Fact] + public void HandleKey_Should_EditSelectedEntry_When_Enter() + { + // arrange + var field = new EditableListField("labels", "Labels", initialValues: ["bug"]); + + // act + field.HandleKey(Key(ConsoleKey.Enter)); + field.HandleKey(Key(ConsoleKey.End)); + field.HandleKey(Key('!')); + field.HandleKey(Key(ConsoleKey.Enter)); + + // assert + Assert.Equal(new FormValue.List(["bug!"]), field.GetValue()); + } + + [Fact] + public void HandleKey_Should_RevertEdit_When_Escape() + { + // arrange + var field = new EditableListField("labels", "Labels", initialValues: ["bug"]); + field.HandleKey(Key(ConsoleKey.Enter)); + field.HandleKey(Key(ConsoleKey.End)); + field.HandleKey(Key('!')); + + // act + var handled = field.HandleKey(Key(ConsoleKey.Escape)); + + // assert: Escape is consumed by the in-progress edit, not the form. + Assert.True(handled); + Assert.Equal(new FormValue.List(["bug"]), field.GetValue()); + } + + [Fact] + public void HandleKey_Should_ReturnFalse_When_EscapeWhileBrowsing() + { + // arrange + var field = new EditableListField("labels", "Labels", initialValues: ["bug"]); + + // act + var handled = field.HandleKey(Key(ConsoleKey.Escape)); + + // assert: browsing (not editing) leaves Escape for the form to cancel. + Assert.False(handled); + } + + [Fact] + public void HandleKey_Should_RemoveNewEntry_When_EscapeCancelsIt() + { + // arrange + var field = new EditableListField("labels", "Labels", initialValues: ["bug"]); + field.HandleKey(Key('a')); + field.HandleKey(Key('x')); + + // act + field.HandleKey(Key(ConsoleKey.Escape)); + + // assert + Assert.Equal(new FormValue.List(["bug"]), field.GetValue()); + } + + [Fact] + public void HandleKey_Should_ReturnFalse_When_UpArrowOnFirstEntry() + { + // arrange + var field = new EditableListField("labels", "Labels", initialValues: ["bug", "urgent"]); + + // act + var handled = field.HandleKey(Key(ConsoleKey.UpArrow)); + + // assert + Assert.False(handled); + } + + [Fact] + public void HandleKey_Should_ReturnFalse_When_DownArrowOnLastEntry() + { + // arrange + var field = new EditableListField("labels", "Labels", initialValues: ["bug", "urgent"]); + field.HandleKey(Key(ConsoleKey.DownArrow)); + + // act + var handled = field.HandleKey(Key(ConsoleKey.DownArrow)); + + // assert + Assert.False(handled); + } + + [Fact] + public void Render_Should_NotThrow_When_Empty() + { + // arrange + var field = new EditableListField("labels", "Labels"); + var console = new TestConsole().Width(60); + + // act + var exception = Record.Exception(() => console.Write(field.Render(40, focused: true))); + + // assert + Assert.Null(exception); + } + + [Fact] + public void Render_Should_ListEntries() + { + // arrange + var field = new EditableListField("labels", "Labels", initialValues: ["bug", "urgent"]); + var console = new TestConsole().Width(60); + + // act + console.Write(field.Render(40, focused: false)); + + // assert + Assert.Contains("bug", console.Output); + Assert.Contains("urgent", console.Output); + } + + [Fact] + public void Render_Should_ShowPlaceholder_When_Empty() + { + // arrange + var field = new EditableListField("labels", "Labels"); + var console = new TestConsole().Width(60); + + // act + console.Write(field.Render(40, focused: false)); + + // assert: an interior line is present instead of a collapsed border pair. + Assert.Contains("no labels - type to add", console.Output); + Assert.Equal(3, console.Output.TrimEnd('\n').Split('\n').Length); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/Form/FormButtonsTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/Form/FormButtonsTests.cs new file mode 100644 index 00000000000..e60011ceccd --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/Form/FormButtonsTests.cs @@ -0,0 +1,92 @@ +using ChilliCream.Nitro.CommandLine.Tui.Widgets.Form; +using Spectre.Console.Testing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Widgets.Form; + +public sealed class FormButtonsTests +{ + private static ConsoleKeyInfo Key(ConsoleKey key) => new('\0', key, false, false, false); + + private static FormButtons CreateButtons() => new( + [ + new FormButtonSpec("save", "Save", ButtonKind.Primary), + new FormButtonSpec("cancel", "Cancel", ButtonKind.Secondary) + ]); + + [Fact] + public void Constructor_Should_Throw_When_NoButtons() + { + // act + var exception = Record.Exception(() => new FormButtons([])); + + // assert + Assert.IsType<ArgumentException>(exception); + } + + [Fact] + public void SelectedId_Should_BeFirstButton_Initially() + { + // arrange + var buttons = CreateButtons(); + + // assert + Assert.Equal("save", buttons.SelectedId); + Assert.Equal(ButtonKind.Primary, buttons.SelectedKind); + } + + [Fact] + public void HandleKey_Should_MoveSelection_When_RightArrow() + { + // arrange + var buttons = CreateButtons(); + + // act + var handled = buttons.HandleKey(Key(ConsoleKey.RightArrow)); + + // assert + Assert.True(handled); + Assert.Equal("cancel", buttons.SelectedId); + } + + [Fact] + public void HandleKey_Should_ReturnFalse_When_RightArrowOnLastButton() + { + // arrange + var buttons = CreateButtons(); + buttons.HandleKey(Key(ConsoleKey.RightArrow)); + + // act + var handled = buttons.HandleKey(Key(ConsoleKey.RightArrow)); + + // assert + Assert.False(handled); + } + + [Fact] + public void HandleKey_Should_ReturnFalse_When_LeftArrowOnFirstButton() + { + // arrange + var buttons = CreateButtons(); + + // act + var handled = buttons.HandleKey(Key(ConsoleKey.LeftArrow)); + + // assert + Assert.False(handled); + } + + [Fact] + public void Render_Should_IncludeButtonLabels() + { + // arrange + var buttons = CreateButtons(); + var console = new TestConsole().Width(60); + + // act + console.Write(buttons.Render(focused: true)); + + // assert + Assert.Contains("Save", console.Output); + Assert.Contains("Cancel", console.Output); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/Form/FormTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/Form/FormTests.cs new file mode 100644 index 00000000000..df0374feda1 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/Form/FormTests.cs @@ -0,0 +1,583 @@ +using ChilliCream.Nitro.CommandLine.Tui.Widgets.Form; +using Spectre.Console.Testing; +using FormUnderTest = ChilliCream.Nitro.CommandLine.Tui.Widgets.Form.Form; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Widgets.Form; + +public sealed class FormTests +{ + private static ConsoleKeyInfo Key(char c) => new(c, ConsoleKey.NoName, false, false, false); + + private static ConsoleKeyInfo Key(ConsoleKey key, bool shift = false) => new('\0', key, shift, false, false); + + private static ConsoleKeyInfo CtrlKey(ConsoleKey key) => new('\0', key, false, false, true); + + private static FormUnderTest CreateForm( + Func<FormValue, string?>? titleValidator = null, + IReadOnlyList<FormButtonSpec>? buttons = null) + { + var fields = new FormField[] + { + new TextField("title", "Title", validator: titleValidator), + new SelectField( + "status", + "Status", + [new SelectOption("open", "Open"), new SelectOption("closed", "Closed")]) + }; + + var buttonRow = new FormButtons(buttons ?? [ + new FormButtonSpec("save", "Save", ButtonKind.Primary), + new FormButtonSpec("cancel", "Cancel", ButtonKind.Secondary) + ]); + + return new FormUnderTest("Edit Task", fields, buttonRow); + } + + [Fact] + public void Constructor_Should_Throw_When_NoFields() + { + // arrange + var buttons = new FormButtons([new FormButtonSpec("save", "Save", ButtonKind.Primary)]); + + // act + var exception = Record.Exception(() => new FormUnderTest("Edit Task", [], buttons)); + + // assert + Assert.IsType<ArgumentException>(exception); + } + + [Fact] + public void FocusedField_Should_BeFirstField_Initially() + { + // arrange + var form = CreateForm(); + + // assert + Assert.Equal("title", form.FocusedField?.Id); + } + + [Fact] + public void HandleKey_Should_MoveFocusToNextField_When_Tab() + { + // arrange + var form = CreateForm(); + + // act + var result = form.HandleKey(Key(ConsoleKey.Tab)); + + // assert + Assert.Null(result); + Assert.Equal("status", form.FocusedField?.Id); + } + + [Fact] + public void HandleKey_Should_MoveFocusToButtonRow_When_TabPastLastField() + { + // arrange + var form = CreateForm(); + form.HandleKey(Key(ConsoleKey.Tab)); + + // act + form.HandleKey(Key(ConsoleKey.Tab)); + + // assert + Assert.Null(form.FocusedField); + } + + [Fact] + public void HandleKey_Should_WrapFocusToFirstField_When_TabPastButtonRow() + { + // arrange + var form = CreateForm(); + form.HandleKey(Key(ConsoleKey.Tab)); + form.HandleKey(Key(ConsoleKey.Tab)); + + // act + form.HandleKey(Key(ConsoleKey.Tab)); + + // assert + Assert.Equal("title", form.FocusedField?.Id); + } + + [Fact] + public void HandleKey_Should_MoveFocusBackward_When_ShiftTab() + { + // arrange + var form = CreateForm(); + form.HandleKey(Key(ConsoleKey.Tab)); + + // act + form.HandleKey(Key(ConsoleKey.Tab, shift: true)); + + // assert + Assert.Equal("title", form.FocusedField?.Id); + } + + [Fact] + public void HandleKey_Should_TraverseFocus_When_DownArrowOnTextField() + { + // arrange: TextField never consumes vertical arrows itself. + var form = CreateForm(); + + // act + form.HandleKey(Key(ConsoleKey.DownArrow)); + + // assert + Assert.Equal("status", form.FocusedField?.Id); + } + + [Fact] + public void HandleKey_Should_TraverseFocus_When_DownArrowOnSelectField() + { + // arrange: the select field itself moves with left and right, so down + // arrow is left for the form to interpret as field-to-field traversal. + var form = CreateForm(); + form.HandleKey(Key(ConsoleKey.Tab)); + + // act + form.HandleKey(Key(ConsoleKey.DownArrow)); + + // assert + Assert.Null(form.FocusedField); + } + + [Fact] + public void HandleKey_Should_NotTraverseFocus_When_RightArrowChangesSelectFieldValue() + { + // arrange + var form = CreateForm(); + form.HandleKey(Key(ConsoleKey.Tab)); + + // act: the select field consumes right arrow to change its own selection. + form.HandleKey(Key(ConsoleKey.RightArrow)); + + // assert + Assert.Equal("status", form.FocusedField?.Id); + } + + [Fact] + public void HandleKey_Should_TypeIntoFocusedField_When_CharacterKey() + { + // arrange + var form = CreateForm(); + + // act + form.HandleKey(Key('h')); + form.HandleKey(Key('i')); + + // assert + Assert.Equal(new FormValue.Text("hi"), form.FocusedField?.GetValue()); + } + + [Fact] + public void HandleKey_Should_ReturnCancelled_When_EscapeWhileFieldFocused() + { + // arrange + var form = CreateForm(); + + // act + var result = form.HandleKey(Key(ConsoleKey.Escape)); + + // assert + Assert.IsType<FormResult.Cancelled>(result); + } + + [Fact] + public void HandleKey_Should_ReturnCancelled_When_EscapeWhileButtonRowFocused() + { + // arrange + var form = CreateForm(); + form.HandleKey(Key(ConsoleKey.Tab)); + form.HandleKey(Key(ConsoleKey.Tab)); + + // act + var result = form.HandleKey(Key(ConsoleKey.Escape)); + + // assert + Assert.IsType<FormResult.Cancelled>(result); + } + + [Fact] + public void HandleKey_Should_ReturnNull_When_EnterWhileFieldFocused() + { + // arrange: Enter only activates a button, so it must not submit while + // focus is still on a field. + var form = CreateForm(); + + // act + var result = form.HandleKey(Key(ConsoleKey.Enter)); + + // assert + Assert.Null(result); + } + + [Fact] + public void HandleKey_Should_Submit_When_EnterOnPrimaryButtonAndValid() + { + // arrange + var form = CreateForm(); + form.HandleKey(Key('h')); + form.HandleKey(Key(ConsoleKey.Tab)); + form.HandleKey(Key(ConsoleKey.Tab)); + + // act + var result = form.HandleKey(Key(ConsoleKey.Enter)); + + // assert + var submitted = Assert.IsType<FormResult.Submitted>(result); + Assert.Equal(new FormValue.Text("h"), submitted.Values["title"]); + Assert.Equal(new FormValue.Text("open"), submitted.Values["status"]); + } + + [Fact] + public void HandleKey_Should_BlockSubmit_When_ValidatorFails() + { + // arrange + var form = CreateForm(titleValidator: value => + value is FormValue.Text { Value.Length: 0 } ? "Title is required." : null); + form.HandleKey(Key(ConsoleKey.Tab)); + form.HandleKey(Key(ConsoleKey.Tab)); + + // act + var result = form.HandleKey(Key(ConsoleKey.Enter)); + + // assert + Assert.Null(result); + } + + [Fact] + public void HandleKey_Should_Submit_When_CtrlEnterWhileFieldFocused() + { + // arrange: focus stays on the first field, no Tab to the button row. + var form = CreateForm(); + form.HandleKey(Key('h')); + + // act + var result = form.HandleKey(CtrlKey(ConsoleKey.Enter)); + + // assert + var submitted = Assert.IsType<FormResult.Submitted>(result); + Assert.Equal(new FormValue.Text("h"), submitted.Values["title"]); + } + + [Fact] + public void HandleKey_Should_Submit_When_CtrlSWhileFieldFocused() + { + // arrange: Ctrl+S is the fallback chord for terminals that deliver + // Ctrl+Enter identically to a plain Enter. + var form = CreateForm(); + form.HandleKey(Key('h')); + + // act + var result = form.HandleKey(CtrlKey(ConsoleKey.S)); + + // assert + var submitted = Assert.IsType<FormResult.Submitted>(result); + Assert.Equal(new FormValue.Text("h"), submitted.Values["title"]); + } + + [Fact] + public void HandleKey_Should_NotInsertNewline_When_CtrlEnterInTextAreaField() + { + // arrange: a plain Enter in a text area inserts a newline, but the + // save chord must take priority over the field's own key handling. + var fields = new FormField[] { new TextAreaField("notes", "Notes") }; + var buttons = new FormButtons([new FormButtonSpec("save", "Save", ButtonKind.Primary)]); + var form = new FormUnderTest("Edit Task", fields, buttons); + form.HandleKey(Key('h')); + + // act + var result = form.HandleKey(CtrlKey(ConsoleKey.Enter)); + + // assert + var submitted = Assert.IsType<FormResult.Submitted>(result); + Assert.Equal(new FormValue.Text("h"), submitted.Values["notes"]); + } + + [Fact] + public void HandleKey_Should_NotSubmit_When_PlainEnterInTextAreaField() + { + // arrange: confirms the save chord is Ctrl-gated, not a change to + // the text area's own plain-Enter newline behavior. + var fields = new FormField[] { new TextAreaField("notes", "Notes") }; + var buttons = new FormButtons([new FormButtonSpec("save", "Save", ButtonKind.Primary)]); + var form = new FormUnderTest("Edit Task", fields, buttons); + + // act + var result = form.HandleKey(Key(ConsoleKey.Enter)); + + // assert + Assert.Null(result); + Assert.Equal(new FormValue.Text("\n"), form.FocusedField?.GetValue()); + } + + [Fact] + public void HandleKey_Should_FocusFirstInvalidField_When_CtrlEnterAndValidatorFails() + { + // arrange: focus sits on the status field, away from the invalid title. + var form = CreateForm(titleValidator: value => + value is FormValue.Text { Value.Length: 0 } ? "Title is required." : null); + form.HandleKey(Key(ConsoleKey.Tab)); + + // act + var result = form.HandleKey(CtrlKey(ConsoleKey.Enter)); + + // assert + Assert.Null(result); + Assert.Equal("title", form.FocusedField?.Id); + } + + [Fact] + public void HandleKey_Should_IgnoreSelectedButton_When_CtrlEnterOnSecondaryButton() + { + // arrange: focus sits on Cancel, but the save chord always targets + // the primary action, not whichever button is currently selected. + var form = CreateForm(); + form.HandleKey(Key('h')); + form.HandleKey(Key(ConsoleKey.Tab)); + form.HandleKey(Key(ConsoleKey.Tab)); + form.HandleKey(Key(ConsoleKey.RightArrow)); + + // act + var result = form.HandleKey(CtrlKey(ConsoleKey.Enter)); + + // assert + var submitted = Assert.IsType<FormResult.Submitted>(result); + Assert.Equal(new FormValue.Text("h"), submitted.Values["title"]); + } + + [Fact] + public void HandleKey_Should_ReturnButtonActivated_When_EnterOnSecondaryButton() + { + // arrange + var form = CreateForm(); + form.HandleKey(Key(ConsoleKey.Tab)); + form.HandleKey(Key(ConsoleKey.Tab)); + form.HandleKey(Key(ConsoleKey.RightArrow)); + + // act + var result = form.HandleKey(Key(ConsoleKey.Enter)); + + // assert + var activated = Assert.IsType<FormResult.ButtonActivated>(result); + Assert.Equal("cancel", activated.ButtonId); + } + + [Fact] + public void HandleKey_Should_NotRequireValidation_When_SecondaryButtonActivated() + { + // arrange + var form = CreateForm(titleValidator: _ => "always invalid"); + form.HandleKey(Key(ConsoleKey.Tab)); + form.HandleKey(Key(ConsoleKey.Tab)); + form.HandleKey(Key(ConsoleKey.RightArrow)); + + // act + var result = form.HandleKey(Key(ConsoleKey.Enter)); + + // assert + Assert.IsType<FormResult.ButtonActivated>(result); + } + + [Fact] + public void Render_Should_NotShowValidationError_Initially_When_RequiredFieldEmpty() + { + // arrange + var form = CreateForm(titleValidator: _ => "Title is required."); + var console = new TestConsole().Width(80).Height(20); + + // act + console.Write(form.Render(80, 20)); + + // assert: the field has neither been touched nor has a submit been + // attempted, so the error stays hidden. + Assert.DoesNotContain("Title is required.", console.Output); + } + + [Fact] + public void Render_Should_ShowValidationError_When_FieldTouched() + { + // arrange + var form = CreateForm(titleValidator: _ => "always invalid"); + var console = new TestConsole().Width(80).Height(20); + + // act: typing into the title field touches it even though the + // validator still fails afterwards. + form.HandleKey(Key('h')); + console.Write(form.Render(80, 20)); + + // assert + Assert.Contains("always invalid", console.Output); + } + + [Fact] + public void Render_Should_ShowValidationError_When_SubmitAttempted_Even_When_FieldUntouched() + { + // arrange: never type into the title field, only tab past it. + var form = CreateForm(titleValidator: _ => "Title is required."); + var console = new TestConsole().Width(80).Height(20); + form.HandleKey(Key(ConsoleKey.Tab)); + form.HandleKey(Key(ConsoleKey.Tab)); + + // act + form.HandleKey(Key(ConsoleKey.Enter)); + console.Write(form.Render(80, 20)); + + // assert + Assert.Contains("Title is required.", console.Output); + } + + [Fact] + public void Render_Should_IncludeTitleAndFieldLabels() + { + // arrange + var form = CreateForm(); + var console = new TestConsole().Width(80).Height(20); + + // act + console.Write(form.Render(80, 20)); + + // assert + Assert.Contains("Edit Task", console.Output); + Assert.Contains("Title", console.Output); + Assert.Contains("Status", console.Output); + Assert.Contains("Save", console.Output); + } + + [Fact] + public void Render_Should_CapContentWidth_When_FrameIsWiderThanEightyColumns() + { + // arrange + var form = CreateForm(); + var console = new TestConsole().Width(160).Height(20); + + // act + console.Write(form.Render(160, 20)); + + // assert: the outer panel border is centered and never wider than 80 + // columns, however wide the frame around it is. + var borderLine = console.Output + .Split('\n') + .First(line => line.Contains("Edit Task")); + var leading = borderLine.Length - borderLine.TrimStart(' ').Length; + var panelWidth = borderLine.Trim().Length; + + Assert.True(panelWidth <= 80); + Assert.Equal(leading, 160 - leading - panelWidth); + } + + [Fact] + public void Render_Should_SeparateFieldsFromButtons_With_BlankLine() + { + // arrange + var form = CreateForm(); + var console = new TestConsole().Width(80).Height(20); + + // act + console.Write(form.Render(80, 20)); + + // assert: the line directly above the button row carries no field + // content, only the panel's side borders. + var lines = console.Output.Split('\n'); + var buttonLineIndex = Array.FindIndex(lines, line => line.Contains("Save")); + var lineAboveButtons = lines[buttonLineIndex - 1]; + + Assert.DoesNotContain("Status", lineAboveButtons); + Assert.DoesNotContain("(o)", lineAboveButtons); + } + + private static FormUnderTest CreateTallForm(int fieldCount) + { + var fields = new List<FormField>(); + + for (var i = 0; i < fieldCount; i++) + { + fields.Add(new TextAreaField($"field{i}", $"Field {i}")); + } + + var buttonRow = new FormButtons([ + new FormButtonSpec("save", "Save", ButtonKind.Primary), + new FormButtonSpec("cancel", "Cancel", ButtonKind.Secondary) + ]); + + return new FormUnderTest("Edit Task", fields, buttonRow); + } + + [Fact] + public void Render_Should_NeverExceedFrameHeight_When_FieldsTallerThanFrame() + { + // arrange: 7 text areas (5 rows each = 35) far exceed a 23-row frame. + var form = CreateTallForm(7); + var console = new TestConsole().Width(80).Height(30); + + // act + console.Write(form.Render(80, 23)); + + // assert + var lineCount = console.Output.Split('\n').Length; + Assert.True(lineCount <= 23, $"expected at most 23 lines, got {lineCount}."); + } + + [Fact] + public void Render_Should_ShowFocusedField_When_ScrolledPastTheFirstScreen() + { + // arrange: the same oversized form, focus moved to the last field. + var form = CreateTallForm(7); + var console = new TestConsole().Width(80).Height(30); + + for (var i = 0; i < 6; i++) + { + form.HandleKey(Key(ConsoleKey.Tab)); + } + + // act + console.Write(form.Render(80, 23)); + + // assert: the focused field's label is visible even though it is the + // seventh of seven fields and the frame cannot show them all at once. + Assert.Contains("Field 6", console.Output); + } + + [Fact] + public void Render_Should_KeepButtonRowVisible_When_FieldsDoNotAllFit() + { + // arrange + var form = CreateTallForm(7); + var console = new TestConsole().Width(80).Height(30); + + // act: focus stays on the first field, far from the button row. + console.Write(form.Render(80, 23)); + + // assert: the button row is pinned, not scrolled out of view. + Assert.Contains("Save", console.Output); + } + + [Fact] + public void Render_Should_ShowTooSmallNotice_When_FrameBelowHardFloor() + { + // arrange + var form = CreateForm(); + var console = new TestConsole().Width(10).Height(5); + + // act + console.Write(form.Render(10, 5)); + + // assert: the narrow frame wraps the notice onto several lines, so + // only a fragment that survives wrapping is checked. + Assert.Contains("too small", console.Output); + } + + [Fact] + public void Render_Should_NotShowTooSmallNotice_When_FrameAtMinimumViableSize() + { + // arrange: the frame that must remain fully operable per spec. + var form = CreateForm(); + var console = new TestConsole().Width(80).Height(23); + + // act + console.Write(form.Render(80, 23)); + + // assert + Assert.DoesNotContain("Terminal too small", console.Output); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/Form/SelectFieldTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/Form/SelectFieldTests.cs new file mode 100644 index 00000000000..df120c69118 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/Form/SelectFieldTests.cs @@ -0,0 +1,153 @@ +using ChilliCream.Nitro.CommandLine.Tui.Widgets.Form; +using Spectre.Console.Testing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Widgets.Form; + +public sealed class SelectFieldTests +{ + private static ConsoleKeyInfo Key(ConsoleKey key) => new('\0', key, false, false, false); + + private static SelectField CreateField(string? initialSelectedId = null) => new( + "status", + "Status", + [new SelectOption("open", "Open"), new SelectOption("closed", "Closed"), new SelectOption("blocked", "Blocked")], + initialSelectedId: initialSelectedId); + + [Fact] + public void GetValue_Should_ReturnFirstOption_When_NoInitialSelection() + { + // arrange + var field = CreateField(); + + // act + var value = field.GetValue(); + + // assert + Assert.Equal(new FormValue.Text("open"), value); + } + + [Fact] + public void GetValue_Should_ReturnInitialSelection_When_Given() + { + // arrange + var field = CreateField(initialSelectedId: "blocked"); + + // act + var value = field.GetValue(); + + // assert + Assert.Equal(new FormValue.Text("blocked"), value); + } + + [Fact] + public void HandleKey_Should_MoveSelectionForward_When_RightArrow() + { + // arrange + var field = CreateField(); + + // act + var handled = field.HandleKey(Key(ConsoleKey.RightArrow)); + + // assert + Assert.True(handled); + Assert.Equal(new FormValue.Text("closed"), field.GetValue()); + } + + [Fact] + public void HandleKey_Should_ReturnFalse_When_RightArrowOnLastOption() + { + // arrange + var field = CreateField(initialSelectedId: "blocked"); + + // act + var handled = field.HandleKey(Key(ConsoleKey.RightArrow)); + + // assert + Assert.False(handled); + Assert.Equal(new FormValue.Text("blocked"), field.GetValue()); + } + + [Fact] + public void HandleKey_Should_ReturnFalse_When_LeftArrowOnFirstOption() + { + // arrange + var field = CreateField(); + + // act + var handled = field.HandleKey(Key(ConsoleKey.LeftArrow)); + + // assert + Assert.False(handled); + } + + [Theory] + [InlineData(ConsoleKey.UpArrow)] + [InlineData(ConsoleKey.DownArrow)] + public void HandleKey_Should_ReturnFalse_When_VerticalArrow(ConsoleKey key) + { + // arrange: up and down are left for the hosting form to interpret as + // field-to-field traversal, since the options move with left and right. + var field = CreateField(); + + // act + var handled = field.HandleKey(Key(key)); + + // assert + Assert.False(handled); + Assert.Equal(new FormValue.Text("open"), field.GetValue()); + } + + [Fact] + public void Render_Should_MarkSelectedOption() + { + // arrange + var field = CreateField(initialSelectedId: "closed"); + var console = new TestConsole().Width(60); + + // act + console.Write(field.Render(40, focused: false)); + + // assert + Assert.Contains("(o) Closed", console.Output); + Assert.Contains("( ) Open", console.Output); + } + + [Fact] + public void Render_Should_RenderOptionsOnOneLine_When_TheyFitTheWidth() + { + // arrange + var field = CreateField(); + var console = new TestConsole().Width(60); + + // act + console.Write(field.Render(40, focused: false)); + + // assert: all three options share one interior row. + var interiorLines = console.Output + .Split('\n') + .Where(line => line.Contains("(o)") || line.Contains("( )")) + .ToArray(); + Assert.Single(interiorLines); + Assert.Contains("Open", interiorLines[0]); + Assert.Contains("Closed", interiorLines[0]); + Assert.Contains("Blocked", interiorLines[0]); + } + + [Fact] + public void Render_Should_WrapOptionsToNextLine_When_TheyDoNotFitTheWidth() + { + // arrange: a field width far too narrow for three options on one line. + var field = CreateField(); + var console = new TestConsole().Width(30); + + // act + console.Write(field.Render(14, focused: false)); + + // assert + var interiorLines = console.Output + .Split('\n') + .Where(line => line.Contains("(o)") || line.Contains("( )")) + .ToArray(); + Assert.True(interiorLines.Length > 1); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/Form/TextAreaFieldTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/Form/TextAreaFieldTests.cs new file mode 100644 index 00000000000..9a941860502 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/Form/TextAreaFieldTests.cs @@ -0,0 +1,156 @@ +using ChilliCream.Nitro.CommandLine.Tui.Widgets.Form; +using Spectre.Console.Testing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Widgets.Form; + +public sealed class TextAreaFieldTests +{ + private static ConsoleKeyInfo Key(char c) => new(c, ConsoleKey.NoName, false, false, false); + + private static ConsoleKeyInfo Key(ConsoleKey key) => new('\0', key, false, false, false); + + [Fact] + public void HandleKey_Should_InsertCharacter_When_Typed() + { + // arrange + var field = new TextAreaField("notes", "Notes"); + + // act + field.HandleKey(Key('h')); + field.HandleKey(Key('i')); + + // assert + Assert.Equal(new FormValue.Text("hi"), field.GetValue()); + } + + [Fact] + public void HandleKey_Should_SplitLine_When_Enter() + { + // arrange + var field = new TextAreaField("notes", "Notes", initialValue: "ab"); + field.HandleKey(Key(ConsoleKey.Home)); + field.HandleKey(Key(ConsoleKey.RightArrow)); + + // act + field.HandleKey(Key(ConsoleKey.Enter)); + + // assert + Assert.Equal(new FormValue.Text("a\nb"), field.GetValue()); + } + + [Fact] + public void HandleKey_Should_MergeLines_When_BackspaceAtLineStart() + { + // arrange + var field = new TextAreaField("notes", "Notes", initialValue: "a\nb"); + field.HandleKey(Key(ConsoleKey.Home)); + + // act + field.HandleKey(Key(ConsoleKey.Backspace)); + + // assert + Assert.Equal(new FormValue.Text("ab"), field.GetValue()); + } + + [Fact] + public void HandleKey_Should_MoveToPreviousLine_When_UpArrowNotOnFirstLine() + { + // arrange: the cursor starts at the end of "b" (column 1), so moving up + // clamps to the same column on "a" (also column 1, its end). + var field = new TextAreaField("notes", "Notes", initialValue: "a\nb"); + + // act + var handled = field.HandleKey(Key(ConsoleKey.UpArrow)); + field.HandleKey(Key('x')); + + // assert + Assert.True(handled); + Assert.Equal(new FormValue.Text("ax\nb"), field.GetValue()); + } + + [Fact] + public void HandleKey_Should_ReturnFalse_When_UpArrowOnFirstLine() + { + // arrange + var field = new TextAreaField("notes", "Notes", initialValue: "a\nb"); + field.HandleKey(Key(ConsoleKey.UpArrow)); + + // act + var handled = field.HandleKey(Key(ConsoleKey.UpArrow)); + + // assert + Assert.False(handled); + } + + [Fact] + public void HandleKey_Should_ReturnFalse_When_DownArrowOnLastLine() + { + // arrange + var field = new TextAreaField("notes", "Notes", initialValue: "a\nb"); + + // act + var handled = field.HandleKey(Key(ConsoleKey.DownArrow)); + + // assert + Assert.False(handled); + } + + [Fact] + public void Render_Should_ScrollVertically_When_MoreLinesThanVisible() + { + // arrange + var field = new TextAreaField("notes", "Notes", initialValue: "1\n2\n3\n4\n5", visibleLines: 2); + field.HandleKey(Key(ConsoleKey.End)); + var console = new TestConsole().Width(60); + + // act + console.Write(field.Render(40, focused: true)); + + // assert: the cursor is on the last line, so the first lines scrolled off. + Assert.DoesNotContain("1", console.Output); + Assert.Contains("5", console.Output); + } + + [Fact] + public void Render_Should_NotThrow_When_WidthIsZero() + { + // arrange + var field = new TextAreaField("notes", "Notes", initialValue: "a\nb"); + var console = new TestConsole().Width(60); + + // act + var exception = Record.Exception(() => console.Write(field.Render(0, focused: true))); + + // assert + Assert.Null(exception); + } + + [Fact] + public void Render_Should_ShowPlaceholder_When_EmptyAndUnfocused() + { + // arrange + var field = new TextAreaField("notes", "Notes"); + var console = new TestConsole().Width(60); + + // act + console.Write(field.Render(40, focused: false)); + + // assert: border (2 lines) plus at least 3 interior lines. + Assert.Contains("empty", console.Output); + Assert.True(console.Output.TrimEnd('\n').Split('\n').Length >= 5); + } + + [Fact] + public void Render_Should_PadToAtLeastThreeInteriorLines_When_ContentIsShort() + { + // arrange: one line of real content, well under the minimum height. + var field = new TextAreaField("notes", "Notes", initialValue: "hi"); + var console = new TestConsole().Width(60); + + // act + console.Write(field.Render(40, focused: false)); + + // assert + Assert.True(console.Output.TrimEnd('\n').Split('\n').Length >= 5); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/Form/TextFieldTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/Form/TextFieldTests.cs new file mode 100644 index 00000000000..20605eda946 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/Form/TextFieldTests.cs @@ -0,0 +1,231 @@ +using ChilliCream.Nitro.CommandLine.Tui.Widgets.Form; +using Spectre.Console.Testing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Widgets.Form; + +public sealed class TextFieldTests +{ + private static ConsoleKeyInfo Key(char c, ConsoleKey key = ConsoleKey.NoName) => + new(c, key, shift: false, alt: false, control: false); + + private static ConsoleKeyInfo Key(ConsoleKey key) => new('\0', key, false, false, false); + + [Fact] + public void HandleKey_Should_InsertCharacter_When_Typed() + { + // arrange + var field = new TextField("title", "Title"); + + // act + field.HandleKey(Key('h')); + field.HandleKey(Key('i')); + + // assert + Assert.Equal(new FormValue.Text("hi"), field.GetValue()); + } + + [Fact] + public void HandleKey_Should_InsertAtCursor_When_CursorMovedLeft() + { + // arrange + var field = new TextField("title", "Title", initialValue: "ac"); + field.HandleKey(Key(ConsoleKey.LeftArrow)); + + // act + field.HandleKey(Key('b')); + + // assert + Assert.Equal(new FormValue.Text("abc"), field.GetValue()); + } + + [Fact] + public void HandleKey_Should_RemovePriorCharacter_When_Backspace() + { + // arrange + var field = new TextField("title", "Title", initialValue: "abc"); + + // act + field.HandleKey(Key(ConsoleKey.Backspace)); + + // assert + Assert.Equal(new FormValue.Text("ab"), field.GetValue()); + } + + [Fact] + public void HandleKey_Should_RemoveFollowingCharacter_When_Delete() + { + // arrange + var field = new TextField("title", "Title", initialValue: "abc"); + field.HandleKey(Key(ConsoleKey.Home)); + + // act + field.HandleKey(Key(ConsoleKey.Delete)); + + // assert + Assert.Equal(new FormValue.Text("bc"), field.GetValue()); + } + + [Fact] + public void HandleKey_Should_ReturnFalse_When_UpArrow() + { + // arrange + var field = new TextField("title", "Title", initialValue: "abc"); + + // act + var handled = field.HandleKey(Key(ConsoleKey.UpArrow)); + + // assert + Assert.False(handled); + } + + [Fact] + public void HandleKey_Should_ReturnFalse_When_DownArrow() + { + // arrange + var field = new TextField("title", "Title", initialValue: "abc"); + + // act + var handled = field.HandleKey(Key(ConsoleKey.DownArrow)); + + // assert + Assert.False(handled); + } + + [Fact] + public void Render_Should_ShowRequiredMarker_When_Required() + { + // arrange + var field = new TextField("title", "Title", required: true); + var console = new TestConsole().Width(60); + + // act + console.Write(field.Render(40, focused: false)); + + // assert + Assert.Contains("Title *", console.Output); + } + + [Fact] + public void Render_Should_NotShowValidationError_When_ShowErrorsIsFalse() + { + // arrange + var field = new TextField("title", "Title", validator: _ => "Title is required."); + var console = new TestConsole().Width(60); + + // act: ShowErrors defaults to false until the hosting Form sets it. + console.Write(field.Render(40, focused: false)); + + // assert + Assert.DoesNotContain("Title is required.", console.Output); + } + + [Fact] + public void Render_Should_ShowValidationError_When_ShowErrorsIsTrue() + { + // arrange + var field = new TextField("title", "Title", validator: _ => "Title is required.") + { + ShowErrors = true + }; + var console = new TestConsole().Width(60); + + // act + console.Write(field.Render(40, focused: false)); + + // assert + Assert.Contains("Title is required.", console.Output); + } + + [Fact] + public void Render_Should_ScrollHorizontally_When_TextOverflowsWidth() + { + // arrange + var field = new TextField("title", "Title", initialValue: "0123456789abcdefghij"); + field.HandleKey(Key(ConsoleKey.End)); + var console = new TestConsole().Width(60); + + // act + console.Write(field.Render(14, focused: true)); + + // assert: the cursor sits at the end, so the earliest characters have + // scrolled out of view. + Assert.DoesNotContain("0123456789", console.Output); + Assert.Contains("j", console.Output); + } + + [Fact] + public void Render_Should_NotThrow_When_WidthIsZero() + { + // arrange + var field = new TextField("title", "Title", initialValue: "abc"); + var console = new TestConsole().Width(60); + + // act + var exception = Record.Exception(() => console.Write(field.Render(0, focused: true))); + + // assert + Assert.Null(exception); + } + + [Fact] + public void Validate_Should_ReturnError_When_ValidatorFails() + { + // arrange + var field = new TextField( + "title", + "Title", + validator: value => value is FormValue.Text { Value.Length: 0 } ? "Title is required." : null); + + // act + var error = field.Validate(); + + // assert + Assert.Equal("Title is required.", error); + } + + [Fact] + public void Validate_Should_ReturnNull_When_ValidatorPasses() + { + // arrange + var field = new TextField( + "title", + "Title", + initialValue: "hi", + validator: value => value is FormValue.Text { Value.Length: 0 } ? "Title is required." : null); + + // act + var error = field.Validate(); + + // assert + Assert.Null(error); + } + + [Fact] + public void Render_Should_ShowPlaceholder_When_EmptyAndUnfocused() + { + // arrange + var field = new TextField("title", "Title"); + var console = new TestConsole().Width(60); + + // act + console.Write(field.Render(40, focused: false)); + + // assert: an interior line is present instead of a collapsed border pair. + Assert.Contains("empty", console.Output); + Assert.Equal(3, console.Output.TrimEnd('\n').Split('\n').Length); + } + + [Fact] + public void Render_Should_NotShowPlaceholder_When_EmptyAndFocused() + { + // arrange: an active cursor already gives the field an interior line. + var field = new TextField("title", "Title"); + var console = new TestConsole().Width(60); + + // act + console.Write(field.Render(40, focused: true)); + + // assert + Assert.DoesNotContain("empty", console.Output); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/TaskBadgeTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/TaskBadgeTests.cs new file mode 100644 index 00000000000..b85976a5774 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/TaskBadgeTests.cs @@ -0,0 +1,249 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Widgets; +using Spectre.Console; +using Spectre.Console.Testing; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Widgets; + +public sealed class TaskBadgeTests +{ + [Fact] + public void Render_Should_BuildBadgeLine_When_Unselected() + { + // act + var line = TaskBadge.Render( + id: "T-1", + title: "Fix bug", + status: TaskStates.Open, + priority: TaskPriorities.Medium, + type: TaskTypes.Task, + selected: false, + maxWidth: 80); + + // assert + line.MatchInlineSnapshot( + " [grey70]โ—‹[/] [grey70][[T]][/] [yellow]P2[/] T-1 Fix bug"); + } + + [Fact] + public void Render_Should_PrefixAndHighlight_When_Selected() + { + // act + var line = TaskBadge.Render( + id: "T-1", + title: "Fix bug", + status: TaskStates.Open, + priority: TaskPriorities.Medium, + type: TaskTypes.Task, + selected: true, + maxWidth: 80); + + // assert + line.MatchInlineSnapshot( + "[default on grey35]> [grey70]โ—‹[/] [grey70][[T]][/] [yellow]P2[/] T-1 Fix bug[/]"); + } + + [Theory] + [InlineData(TaskStates.Closed, "โœ“")] + [InlineData(TaskStates.InProgress, "โ—")] + [InlineData(TaskStates.Open, "โ—‹")] + [InlineData(TaskStates.Deferred, "โธ")] + [InlineData(TaskStates.Blocked, "โŠ˜")] + public void Render_Should_UseGlyph_ForEachKnownStatus(string status, string expectedGlyph) + { + // act + var line = TaskBadge.Render( + id: "T-1", + title: "Title", + status: status, + priority: TaskPriorities.Medium, + type: TaskTypes.Task, + selected: false, + maxWidth: 80); + + // assert + Assert.Contains(expectedGlyph, line); + } + + [Theory] + [InlineData(TaskTypes.Bug, "B")] + [InlineData(TaskTypes.Feature, "F")] + [InlineData(TaskTypes.Task, "T")] + [InlineData(TaskTypes.Epic, "E")] + [InlineData(TaskTypes.Chore, "C")] + [InlineData(TaskTypes.Docs, "D")] + [InlineData(TaskTypes.Question, "Q")] + public void Render_Should_UseTypeCode_ForEachKnownType(string type, string expectedCode) + { + // act + var line = TaskBadge.Render( + id: "T-1", + title: "Title", + status: TaskStates.Open, + priority: TaskPriorities.Medium, + type: type, + selected: false, + maxWidth: 80); + + // assert + Assert.Contains($"[[{expectedCode}]]", line); + } + + [Fact] + public void Render_Should_FallBackToFirstLetter_When_TypeUnknown() + { + // act + var line = TaskBadge.Render( + id: "T-1", + title: "Title", + status: TaskStates.Open, + priority: TaskPriorities.Medium, + type: "design", + selected: false, + maxWidth: 80); + + // assert + Assert.Contains("[[D]]", line); + } + + [Fact] + public void Render_Should_TruncateTitleWithEllipsis_When_LineExceedsMaxWidth() + { + // act + var line = TaskBadge.Render( + id: "T-1", + title: "Fix bug and more stuff", + status: TaskStates.Open, + priority: TaskPriorities.Medium, + type: TaskTypes.Task, + selected: false, + maxWidth: 20); + + // assert + line.MatchInlineSnapshot( + " [grey70]โ—‹[/] [grey70][[T]][/] [yellow]P2[/] T-1 Fix โ€ฆ"); + } + + [Fact] + public void Render_Should_EscapeTruncatedTitle_When_TruncationCutsThroughBracketChar() + { + // act + var line = TaskBadge.Render( + id: "T-1", + title: "[URGENT] fix this", + status: TaskStates.Open, + priority: TaskPriorities.Medium, + type: TaskTypes.Task, + selected: false, + maxWidth: 18); + + // assert + line.MatchInlineSnapshot( + " [grey70]โ—‹[/] [grey70][[T]][/] [yellow]P2[/] T-1 [[Uโ€ฆ"); + } + + [Fact] + public void Render_Should_ReturnEmpty_When_MaxWidthIsZero() + { + // act + var line = TaskBadge.Render( + id: "T-1", + title: "Title", + status: TaskStates.Open, + priority: TaskPriorities.Medium, + type: TaskTypes.Task, + selected: false, + maxWidth: 0); + + // assert + Assert.Equal(string.Empty, line); + } + + [Fact] + public void Render_Should_RenderAsValidMarkup_When_TypeIsKnownAndSelected() + { + // arrange + var console = new TestConsole().Width(80); + var line = TaskBadge.Render( + id: "T-1", + title: "Fix bug", + status: TaskStates.Open, + priority: TaskPriorities.Medium, + type: TaskTypes.Task, + selected: true, + maxWidth: 80); + + // act + var exception = Record.Exception(() => console.Write(new Markup(line))); + + // assert + Assert.Null(exception); + Assert.Contains("Fix bug", console.Output); + } + + [Fact] + public void Render_Should_RenderAsValidMarkup_When_TypeIsUnknown() + { + // arrange + var console = new TestConsole().Width(80); + var line = TaskBadge.Render( + id: "T-1", + title: "Title", + status: TaskStates.Open, + priority: TaskPriorities.Medium, + type: "design", + selected: false, + maxWidth: 80); + + // act + var exception = Record.Exception(() => console.Write(new Markup(line))); + + // assert + Assert.Null(exception); + Assert.Contains("D", console.Output); + } + + [Fact] + public void Render_Should_RenderAsValidMarkup_When_StatusIsTombstone() + { + // arrange + var console = new TestConsole().Width(80); + var line = TaskBadge.Render( + id: "T-1", + title: "Title", + status: TaskStates.Tombstone, + priority: TaskPriorities.Medium, + type: TaskTypes.Task, + selected: false, + maxWidth: 80); + + // act + var exception = Record.Exception(() => console.Write(new Markup(line))); + + // assert + Assert.Null(exception); + Assert.Contains("Title", console.Output); + } + + [Fact] + public void Render_Should_RenderAsValidMarkup_When_PriorityIsOutOfRange() + { + // arrange + var console = new TestConsole().Width(80); + var line = TaskBadge.Render( + id: "T-1", + title: "Title", + status: TaskStates.Open, + priority: 7, + type: TaskTypes.Task, + selected: false, + maxWidth: 80); + + // act + var exception = Record.Exception(() => console.Write(new Markup(line))); + + // assert + Assert.Null(exception); + Assert.Contains("P7", console.Output); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/TaskGlyphsTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/TaskGlyphsTests.cs new file mode 100644 index 00000000000..8159ad6da47 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/TaskGlyphsTests.cs @@ -0,0 +1,109 @@ +using ChilliCream.Nitro.CommandLine.Services.Tasks; +using ChilliCream.Nitro.CommandLine.Tui.Widgets; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Widgets; + +public sealed class TaskGlyphsTests +{ + [Theory] + [InlineData(TaskStates.Closed, "โœ“")] + [InlineData(TaskStates.InProgress, "โ—")] + [InlineData(TaskStates.Open, "โ—‹")] + [InlineData(TaskStates.Deferred, "โธ")] + [InlineData(TaskStates.Blocked, "โŠ˜")] + public void Status_Should_ReturnGlyph_ForEachKnownStatus(string status, string expectedGlyph) + { + // act + var glyph = TaskGlyphs.Status(status); + + // assert + Assert.Equal(expectedGlyph, glyph); + } + + [Fact] + public void Status_Should_FallBackToOpenGlyph_When_StatusUnknown() + { + // act + var glyph = TaskGlyphs.Status("custom_state"); + + // assert + Assert.Equal("โ—‹", glyph); + } + + [Fact] + public void StatusMarkup_Should_WrapGlyphInThemeStyle() + { + // act + var markup = TaskGlyphs.StatusMarkup(TaskStates.Closed); + + // assert + markup.MatchInlineSnapshot("[green]โœ“[/]"); + } + + [Fact] + public void StatusMarkup_Should_ReturnUnwrappedGlyph_When_StatusHasNoStyleToken() + { + // act + var markup = TaskGlyphs.StatusMarkup("custom_state"); + + // assert + Assert.Equal("โ—‹", markup); + } + + [Theory] + [InlineData(TaskTypes.Bug, "B")] + [InlineData(TaskTypes.Feature, "F")] + [InlineData(TaskTypes.Task, "T")] + [InlineData(TaskTypes.Epic, "E")] + [InlineData(TaskTypes.Chore, "C")] + [InlineData(TaskTypes.Docs, "D")] + [InlineData(TaskTypes.Question, "Q")] + public void TypeCode_Should_ReturnCode_ForEachKnownType(string type, string expectedCode) + { + // act + var code = TaskGlyphs.TypeCode(type); + + // assert + Assert.Equal(expectedCode, code); + } + + [Fact] + public void TypeCode_Should_FallBackToFirstLetter_When_TypeUnknown() + { + // act + var code = TaskGlyphs.TypeCode("design"); + + // assert + Assert.Equal("D", code); + } + + [Fact] + public void TypeCode_Should_ReturnQuestionMark_When_TypeIsEmpty() + { + // act + var code = TaskGlyphs.TypeCode(string.Empty); + + // assert + Assert.Equal("?", code); + } + + [Fact] + public void TypeCodeMarkup_Should_WrapBracketedCodeInThemeStyle() + { + // act + var markup = TaskGlyphs.TypeCodeMarkup(TaskTypes.Bug); + + // assert + markup.MatchInlineSnapshot("[red3][[B]][/]"); + } + + [Fact] + public void TypeCodeMarkup_Should_ReturnUnwrappedCode_When_TypeHasNoStyleToken() + { + // act + var markup = TaskGlyphs.TypeCodeMarkup("design"); + + // assert + Assert.Equal("[[D]]", markup); + } +} diff --git a/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/ViewportTests.cs b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/ViewportTests.cs new file mode 100644 index 00000000000..d7c08a1afe2 --- /dev/null +++ b/src/Nitro/CommandLine/test/CommandLine.Tests/Tui/Widgets/ViewportTests.cs @@ -0,0 +1,177 @@ +using ChilliCream.Nitro.CommandLine.Tui.Widgets; + +namespace ChilliCream.Nitro.CommandLine.Tests.Tui.Widgets; + +public sealed class ViewportTests +{ + [Fact] + public void Slice_Should_ReturnEmptyRange_When_Empty() + { + // arrange + var viewport = new Viewport(totalCount: 0, windowHeight: 5); + + // act + var (start, count) = viewport.Slice(); + + // assert + Assert.Equal(0, start); + Assert.Equal(0, count); + Assert.Equal(0, viewport.HiddenAbove); + Assert.Equal(0, viewport.HiddenBelow); + } + + [Fact] + public void Slice_Should_ReturnWholeList_When_WindowLargerThanContent() + { + // arrange + var viewport = new Viewport(totalCount: 3, windowHeight: 10); + + // act + var (start, count) = viewport.Slice(); + + // assert + Assert.Equal(0, start); + Assert.Equal(3, count); + Assert.Equal(0, viewport.HiddenAbove); + Assert.Equal(0, viewport.HiddenBelow); + } + + [Fact] + public void EnsureVisible_Should_ClampToLastIndex_When_IndexBeyondEnd() + { + // arrange + var viewport = new Viewport(totalCount: 5, windowHeight: 2); + + // act + viewport.EnsureVisible(99); + var (start, count) = viewport.Slice(); + + // assert + Assert.Equal(3, start); + Assert.Equal(2, count); + Assert.Equal(3, viewport.HiddenAbove); + Assert.Equal(0, viewport.HiddenBelow); + } + + [Fact] + public void EnsureVisible_Should_ClampToFirstIndex_When_IndexBeforeStart() + { + // arrange + var viewport = new Viewport(totalCount: 5, windowHeight: 2); + viewport.EnsureVisible(4); + + // act + viewport.EnsureVisible(-10); + var (start, count) = viewport.Slice(); + + // assert + Assert.Equal(0, start); + Assert.Equal(2, count); + } + + [Fact] + public void EnsureVisible_Should_ScrollDown_When_IndexBelowWindow() + { + // arrange + var viewport = new Viewport(totalCount: 10, windowHeight: 3); + + // act + viewport.EnsureVisible(5); + var (start, count) = viewport.Slice(); + + // assert + Assert.Equal(3, start); + Assert.Equal(3, count); + Assert.Equal(3, viewport.HiddenAbove); + Assert.Equal(4, viewport.HiddenBelow); + } + + [Fact] + public void EnsureVisible_Should_ScrollUp_When_IndexAboveWindow() + { + // arrange + var viewport = new Viewport(totalCount: 10, windowHeight: 3); + viewport.EnsureVisible(9); + + // act + viewport.EnsureVisible(1); + var (start, _) = viewport.Slice(); + + // assert + Assert.Equal(1, start); + } + + [Fact] + public void Update_Should_ClampOffset_When_ContentShrinks() + { + // arrange + var viewport = new Viewport(totalCount: 20, windowHeight: 5); + viewport.EnsureVisible(19); + + // act + viewport.Update(totalCount: 3, windowHeight: 5); + var (start, count) = viewport.Slice(); + + // assert + Assert.Equal(0, start); + Assert.Equal(3, count); + } + + [Fact] + public void EnsureVisible_Should_NotThrow_When_WindowHeightIsZero() + { + // arrange + var viewport = new Viewport(totalCount: 5, windowHeight: 0); + + // act + viewport.EnsureVisible(2); + var (start, count) = viewport.Slice(); + + // assert + Assert.Equal(0, start); + Assert.Equal(0, count); + } + + [Fact] + public void ScrollBy_Should_MoveOffset_When_DeltaWithinRange() + { + // arrange + var viewport = new Viewport(totalCount: 20, windowHeight: 5); + viewport.EnsureVisible(10); + + // act + viewport.ScrollBy(2); + var (start, _) = viewport.Slice(); + + // assert + Assert.Equal(8, start); + } + + [Fact] + public void ScrollBy_Should_ClampAtZero_When_DeltaScrollsPastStart() + { + // arrange + var viewport = new Viewport(totalCount: 20, windowHeight: 5); + + // act + viewport.ScrollBy(-100); + var (start, _) = viewport.Slice(); + + // assert + Assert.Equal(0, start); + } + + [Fact] + public void ScrollBy_Should_ClampAtMaxOffset_When_DeltaScrollsPastEnd() + { + // arrange + var viewport = new Viewport(totalCount: 20, windowHeight: 5); + + // act + viewport.ScrollBy(100); + var (start, _) = viewport.Slice(); + + // assert + Assert.Equal(15, start); + } +} diff --git a/src/Nitro/CommandLine/test/e2e/.gitignore b/src/Nitro/CommandLine/test/e2e/.gitignore new file mode 100644 index 00000000000..bb0c3a193d4 --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/.gitignore @@ -0,0 +1,4 @@ +# Published binary (self-contained, regenerated by run.sh) +bin/ +# Raw VHS recordings + extracted frame + report (regenerated by run.sh) +out/ diff --git a/src/Nitro/CommandLine/test/e2e/README.md b/src/Nitro/CommandLine/test/e2e/README.md new file mode 100644 index 00000000000..1273632cb45 --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/README.md @@ -0,0 +1,110 @@ +# End-to-end terminal recordings (VHS) + +This directory drives the **real `nitro` binary through a real PTY**, one +[VHS](https://github.com/charmbracelet/vhs) tape per command flow, and produces +two artifacts per flow from a single recording: + +| Artifact | Purpose | +| --- | --- | +| `<flow>-flow.gif` | Animated demo | +| `<flow>-flow.golden.txt` | Final-frame text snapshot, diffed in CI as an integration test | + +The same recording is both the demo *and* the assertion: one tape, one run. + +## Why a recording + +Nothing else in this repository exercises `Program.cs`, real DI wiring, real +`System.CommandLine` parsing, or real terminal rendering of the published +binary. This tier does, through a real PTY. It is the slow, high-confidence +layer: a handful of representative flows, not exhaustive. + +## The flows + +| Flow | Drives | Validates | +| --- | --- | --- | +| `help` | `nitro agent tasks list --help` | the pipeline itself: publish, record, extract, diff, end to end | + +This is a trivial smoke flow that proves the pipeline itself, independently of +the fixture-backed `nitro agent tasks` flows. It asks for help on `list`, a +leaf subcommand, since its help output is the more informative pipeline check +than the `agent`/`tasks` group commands' own help. Task commands need no +auth, they never call the Nitro backend. + +Keep `MARKERS`/`ALL_FLOWS` in [`run.sh`](run.sh) in sync with the tape set as +flows are added. + +## How it works + +``` +<flow>-flow.tape --> VHS container (ttyd + ffmpeg) --> <flow>-flow.gif + \-> <flow>-flow.txt --> extract-frame.sh --> diff vs golden +``` + +1. **`<flow>-flow.tape`** is a VHS script. A hidden setup block puts the + published binary on `PATH`, pins `HOME=/tmp/home` (hermetic), and works in a + throwaway `/tmp/work`. Recorded steps gate on `Wait+Screen /.../` sentinels, + so the recording syncs on **state** rather than wall-clock timing. +2. **`run.sh`** publishes a self-contained `linux-x64` binary once, then for + each flow mounts the repo read-only into the pinned VHS container and + records. +3. **`extract-frame.sh`** reduces VHS's multi-frame `.txt` capture to the final + completed frame, keyed on a per-flow marker (e.g. `List tasks.`). +4. An optional per-flow `SCRUBS` entry in `run.sh` (a `sed -E` expression) can + normalize non-deterministic content, such as wall-clock-seeded task IDs + (`acme-[a-z0-9]+`) or dates, before the frame is compared with or written as + the golden. Empty by default; a future task flow that creates state live + would add its own entry. +5. Each frame is diffed against `<flow>-flow.golden.txt`. + +## Running it + +```bash +./run.sh # record + verify EVERY flow (what CI does) +./run.sh help # record + verify only the named flow(s) +./run.sh --update # accept new output: refresh all goldens + GIFs +./run.sh --update help # refresh a single flow +REBUILD=1 ./run.sh # force re-publish of the binary first +NITRO_E2E_AOT=1 ./run.sh # publish with PublishAot=true instead (slow, matches the shipped binary) +``` + +`run.sh` exits non-zero if any flow's frame differs from its golden (**FAIL**) +or has no golden yet (**NEW**), and collects the changed/new GIFs, frames, and +diffs under `out/report/` for CI. Exit codes: `0` all PASS, `1` any FAIL/NEW +(or, under `--update`, any recording failure), `2` usage error, `3` docker +missing. + +Requirements: `docker` + the .NET SDK. Nothing else, `ttyd`, `ffmpeg`, and the +fonts are baked into the pinned container. + +## What makes it deterministic + +Two independent runs produce a **byte-identical** final frame. The levers: + +- **Assert on text, never on the GIF.** GIF bytes go through ffmpeg/gifski and + are not stable across versions/platforms. The character grid (`.txt`) is. +- **Final frame only.** Intermediate frames vary with timing; the end state + does not. +- **Pinned VHS image** (by digest), a new VHS release can't silently reflow + output. +- **Fixed geometry / theme / `CursorBlink false`** in every tape. +- **Hermetic inputs**: a fixed `/tmp/work` cwd and a pinned `$HOME` where it + appears in output, so every path is constant. +- **Per-flow `SCRUBS`** normalize anything still non-deterministic (task IDs + seeded from wall-clock time, dates) before the diff. + +If `nitro` legitimately changes its output, the diff fails, that's the test +working. Re-run with `--update <flow>` and commit the new golden + GIF. + +## Files + +| File | | +| --- | --- | +| `<flow>-flow.tape` | The VHS script for a flow (source of truth) | +| `<flow>-flow.golden.txt` | Committed final-frame snapshot | +| `<flow>-flow.gif` | Committed demo (regenerate with `run.sh --update <flow>`) | +| `run.sh` | Publish, record each flow, extract, verify/update + build report | +| `extract-frame.sh` | VHS `.txt` -> final frame (per-flow marker) | +| `bin/`, `out/` | gitignored: published binary, raw recordings, report | + +Linux `linux-x64` only, no Windows/macOS support, matching the upstream skillz +e2e tier this pipeline was ported from. diff --git a/src/Nitro/CommandLine/test/e2e/board-flow.gif b/src/Nitro/CommandLine/test/e2e/board-flow.gif new file mode 100644 index 00000000000..61d5b720d76 Binary files /dev/null and b/src/Nitro/CommandLine/test/e2e/board-flow.gif differ diff --git a/src/Nitro/CommandLine/test/e2e/board-flow.golden.txt b/src/Nitro/CommandLine/test/e2e/board-flow.golden.txt new file mode 100644 index 00000000000..f5d9168f3b9 --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/board-flow.golden.txt @@ -0,0 +1,35 @@ +โ•ญโ”€Blocked (3)โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ โ—‹ [B] P0 acme-a1b Checkout crashes on Safari โ”‚ +โ”‚ โ—‹ [E] P1 acme-epic1 Launch v2 pricing page โ”‚ +โ”‚ โ—‹ [T] P2 acme-epic1.2 Wire up billing API โ”‚ +โ”‚ โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + +โ•ญโ”€Deferred (1)โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ โธ [C] P4 acme-e5f Rotate staging API keys โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + +โ•ญโ”€Ready (0)โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + +โ•ญโ”€In Progress (1)โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ > โ— [T] P2 acme-epic1.1 Draft new pricing copy โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + +โ•ญโ”€Closed (1)โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ โœ“ [F] P3 acme-c3d Add dark mode toggle โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +hjkl move enter open r refresh y copy id z zoom e edit esc back q quit diff --git a/src/Nitro/CommandLine/test/e2e/board-flow.tape b/src/Nitro/CommandLine/test/e2e/board-flow.tape new file mode 100644 index 00000000000..47e25dd0690 --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/board-flow.tape @@ -0,0 +1,91 @@ +# End-to-end recording of `nitro agent tasks board`, keystroke-driven, against the +# fixture workspace. +# +# The board TUI (raw-key event loop, full-screen alternate-screen rendering) +# cannot be exercised by the in-process test tier; this is the VHS tier's job. +# Drives column focus (Left/Right) and row selection (Up/Down) across all +# five of the fixture's columns (see fixtures/seed.sql and BoardView.Default), +# syncing every transition on a Wait+Screen sentinel anchored on the +# selection prefix ("> ") next to a literal card title, or (for the empty +# "Ready" column) the focused panel's own box-drawing top border, never on +# Sleep. Fixed geometry (Width/Height below) is the stability lever for this +# full-screen layout: changing it requires `--update` of every board-flow* +# golden. +# +# Column membership below is computed by BoardDataLoader/TaskStore +# ComputeBlockedAsync against fixtures/seed.sql, not just each task's own +# `status`: acme-epic1 is an open epic with a non-terminal child +# (acme-epic1.2), so it lands in "Blocked" (not "Ready"); acme-epic1.2 is +# open but blocked by the still-in_progress acme-epic1.1, so it is also in +# "Blocked"; acme-a1b is blocked by acme-epic1.2. That leaves the "Ready" +# column empty for this fixture. +# Produces board-flow.gif + board-flow.txt. + +Output board-flow.gif +Output board-flow.txt + +Set Shell bash +Set FontSize 18 +Set Width 1100 +Set Height 850 +Set Padding 14 +Set TypingSpeed 50ms +Set CursorBlink false + +# --- setup (hidden) --- +Hide +Type `export PS1="$ "` Enter +Type `export PATH=/src/src/Nitro/CommandLine/test/e2e/bin:$PATH` Enter +Type `export HOME=/tmp/home` Enter +Type `export NITRO_TASK_ACTOR=e2e-agent` Enter +Type `mkdir -p "$HOME"` Enter +Type `rm -rf /tmp/work && mkdir -p /tmp/work && cp -r /vhs/fixture/acme /tmp/work/acme && cd /tmp/work/acme` Enter +Type `clear` Enter +Show + +# --- the demo --- +Type `nitro agent tasks board` Enter + +# Initial frame: focus starts on "Blocked" (index 0, 3 cards), selection on +# its first row (acme-a1b, P0, sorted ahead of acme-epic1 P1 and +# acme-epic1.2 P2). +Wait+Screen@30s /> โ—‹ \[B\] P0 acme-a1b Checkout crashes on Safari/ + +# Down: move selection onto the column's second row (acme-epic1, an epic +# blocked by its own open children). +Down +Wait+Screen@30s /> โ—‹ \[E\] P1 acme-epic1 Launch v2 pricing page/ + +# Down: onto the third row (acme-epic1.2). +Down +Wait+Screen@30s /> โ—‹ \[T\] P2 acme-epic1\.2 Wire up billing API/ + +# Up: back onto the second row (acme-epic1). +Up +Wait+Screen@30s /> โ—‹ \[E\] P1 acme-epic1 Launch v2 pricing page/ + +# Right: focus "Deferred" (index 1), whose only card is acme-e5f. +Right +Wait+Screen@30s /> โธ \[C\] P4 acme-e5f Rotate staging API keys/ + +# Right: focus "Ready" (index 2). Empty for this fixture (both open tasks +# are blocked), so sync on the focused panel's own top border instead of a +# card row. +Right +Wait+Screen@30s /โ•ญโ”€Ready \(0\)โ”€/ + +# Right: focus "In Progress" (index 3), whose only card is acme-epic1.1. +Right +Wait+Screen@30s /> โ— \[T\] P2 acme-epic1\.1 Draft new pricing copy/ + +# Right: focus "Closed" (index 4), whose only card is acme-c3d. +Right +Wait+Screen@30s /> โœ“ \[F\] P3 acme-c3d Add dark mode toggle/ + +# Left: back to "In Progress". Final state for the golden. +Left +Wait+Screen@30s /> โ— \[T\] P2 acme-epic1\.1 Draft new pricing copy/ + +Sleep 1500ms +Screenshot board-flow-final.png +Sleep 500ms diff --git a/src/Nitro/CommandLine/test/e2e/board-maximize-flow.gif b/src/Nitro/CommandLine/test/e2e/board-maximize-flow.gif new file mode 100644 index 00000000000..0df373074a9 Binary files /dev/null and b/src/Nitro/CommandLine/test/e2e/board-maximize-flow.gif differ diff --git a/src/Nitro/CommandLine/test/e2e/board-maximize-flow.golden.txt b/src/Nitro/CommandLine/test/e2e/board-maximize-flow.golden.txt new file mode 100644 index 00000000000..8af4c551271 --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/board-maximize-flow.golden.txt @@ -0,0 +1,35 @@ +โ•ญโ”€Blocked (3)โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ โ—‹ [B] P0 acme-a1b Checkout crashes on Safari โ”‚ +โ”‚ โ—‹ [E] P1 acme-epic1 Launch v2 pricing page โ”‚ +โ”‚ โ—‹ [T] P2 acme-epic1.2 Wire up billing API โ”‚ +โ”‚ โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + +โ•ญโ”€Deferred (1)โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ โธ [C] P4 acme-e5f Rotate staging API keys โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + +โ•ญโ”€Ready (0)โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + +โ•ญโ”€In Progress (1)โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ โ— [T] P2 acme-epic1.1 Draft new pricing copy โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + +โ•ญโ”€Closed (1)โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ > โœ“ [F] P3 acme-c3d Add dark mode toggle โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ”‚ โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +hjkl move enter open r refresh y copy id z zoom e edit esc back q quit diff --git a/src/Nitro/CommandLine/test/e2e/board-maximize-flow.tape b/src/Nitro/CommandLine/test/e2e/board-maximize-flow.tape new file mode 100644 index 00000000000..5bc555f8024 --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/board-maximize-flow.tape @@ -0,0 +1,75 @@ +# End-to-end recording of `nitro agent tasks board`'s maximize toggle ('z'), +# keystroke-driven, against the fixture workspace. +# +# Exercises BoardLayoutKind.Maximized: 'z' collapses the grid to the focused +# column at full width with a "<index>/<count>" header suffix (rendered as +# "<Name> - <index>/<count> (<count>)" by BoardMode.RenderMaximized + +# ColumnPane.Render), Right moves which column is maximized, and a second +# 'z' returns to the five-column grid. Every transition syncs on a +# Wait+Screen sentinel anchored on a literal column header (with its +# fixture task count) or card row from fixtures/seed.sql, never on Sleep. +# Fixed geometry (Width/Height below) is the stability lever for this +# full-screen layout: changing it requires `--update` of every board-flow* +# golden. +# Produces board-maximize-flow.gif + board-maximize-flow.txt. + +Output board-maximize-flow.gif +Output board-maximize-flow.txt + +Set Shell bash +Set FontSize 18 +Set Width 1100 +Set Height 850 +Set Padding 14 +Set TypingSpeed 50ms +Set CursorBlink false + +# --- setup (hidden) --- +Hide +Type `export PS1="$ "` Enter +Type `export PATH=/src/src/Nitro/CommandLine/test/e2e/bin:$PATH` Enter +Type `export HOME=/tmp/home` Enter +Type `export NITRO_TASK_ACTOR=e2e-agent` Enter +Type `mkdir -p "$HOME"` Enter +Type `rm -rf /tmp/work && mkdir -p /tmp/work && cp -r /vhs/fixture/acme /tmp/work/acme && cd /tmp/work/acme` Enter +Type `clear` Enter +Show + +# --- the demo --- +Type `nitro agent tasks board` Enter + +# Initial frame: five-column grid, focus on "Blocked" (index 0), selection +# on its first row (acme-a1b). +Wait+Screen@30s /> โ—‹ \[B\] P0 acme-a1b Checkout crashes on Safari/ + +# z: maximize the focused column ("Blocked", 1 of 5, 3 cards). +Type "z" +Wait+Screen@30s /Blocked - 1\/5 \(3\)/ + +# Right: the maximized column follows focus to "Deferred" (2 of 5, 1 card). +Right +Wait+Screen@30s /Deferred - 2\/5 \(1\)/ + +# Right: "Ready" (3 of 5, 0 cards: both open tasks in this fixture are +# blocked, see board-flow.tape's header). +Right +Wait+Screen@30s /Ready - 3\/5 \(0\)/ + +# Right: "In Progress" (4 of 5, 1 card). +Right +Wait+Screen@30s /In Progress - 4\/5 \(1\)/ + +# Right: "Closed" (5 of 5, 1 card). +Right +Wait+Screen@30s /Closed - 5\/5 \(1\)/ + +# z: restore the five-column grid, still focused on "Closed". Final state +# for the golden: the neighboring "In Progress (1)" column header is visible +# again, which only happens outside Maximized layout (Maximized shows one +# column). +Type "z" +Wait+Screen@30s /In Progress \(1\)/ + +Sleep 1500ms +Screenshot board-maximize-flow-final.png +Sleep 500ms diff --git a/src/Nitro/CommandLine/test/e2e/close-reopen-flow.gif b/src/Nitro/CommandLine/test/e2e/close-reopen-flow.gif new file mode 100644 index 00000000000..78f56a2df26 Binary files /dev/null and b/src/Nitro/CommandLine/test/e2e/close-reopen-flow.gif differ diff --git a/src/Nitro/CommandLine/test/e2e/close-reopen-flow.golden.txt b/src/Nitro/CommandLine/test/e2e/close-reopen-flow.golden.txt new file mode 100644 index 00000000000..0f6274ba40e --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/close-reopen-flow.golden.txt @@ -0,0 +1,5 @@ +$ nitro agent tasks close acme-a1b --reason "Won't fix, unreproducible" +โœ“ Closed task 'acme-a1b'. +$ nitro agent tasks reopen acme-a1b +โœ“ Reopened task 'acme-a1b'. +$ diff --git a/src/Nitro/CommandLine/test/e2e/close-reopen-flow.tape b/src/Nitro/CommandLine/test/e2e/close-reopen-flow.tape new file mode 100644 index 00000000000..51cca7ea7b6 --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/close-reopen-flow.tape @@ -0,0 +1,43 @@ +# End-to-end recording of `nitro agent tasks close` then `nitro agent tasks reopen` against +# the fixture workspace. +# +# Exercises the close/reopen write paths on a fixture task that starts open +# (fixtures/seed.sql), round-tripping its status back to open. Neither command +# prints a timestamp, so the output stays deterministic without a SCRUBS +# entry. Needs no auth: task commands do not call the Nitro backend. +# Produces close-reopen-flow.gif + close-reopen-flow.txt. + +Output close-reopen-flow.gif +Output close-reopen-flow.txt + +Set Shell bash +Set FontSize 18 +Set Width 1100 +Set Height 850 +Set Padding 14 +Set TypingSpeed 50ms +Set CursorBlink false + +# --- setup (hidden) --- +Hide +Type `export PS1="$ "` Enter +Type `export PATH=/src/src/Nitro/CommandLine/test/e2e/bin:$PATH` Enter +Type `export HOME=/tmp/home` Enter +Type `export NITRO_TASK_ACTOR=e2e-agent` Enter +Type `mkdir -p "$HOME"` Enter +Type `rm -rf /tmp/work && mkdir -p /tmp/work && cp -r /vhs/fixture/acme /tmp/work/acme && cd /tmp/work/acme` Enter +Type `clear` Enter +Show + +# --- the demo --- +Type `nitro agent tasks close acme-a1b --reason "Won't fix, unreproducible"` Enter + +Wait+Screen@30s /Closed task 'acme-a1b'/ +Sleep 500ms + +Type `nitro agent tasks reopen acme-a1b` Enter + +Wait+Screen@30s /Reopened task 'acme-a1b'/ +Sleep 1500ms +Screenshot close-reopen-flow-final.png +Sleep 500ms diff --git a/src/Nitro/CommandLine/test/e2e/create-flow.gif b/src/Nitro/CommandLine/test/e2e/create-flow.gif new file mode 100644 index 00000000000..6c1cf5529b8 Binary files /dev/null and b/src/Nitro/CommandLine/test/e2e/create-flow.gif differ diff --git a/src/Nitro/CommandLine/test/e2e/create-flow.golden.txt b/src/Nitro/CommandLine/test/e2e/create-flow.golden.txt new file mode 100644 index 00000000000..aeeb8188341 --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/create-flow.golden.txt @@ -0,0 +1,3 @@ +$ nitro agent tasks create "Write onboarding docs" +โœ“ Created task 'acme-XXX': Write onboarding docs. +$ diff --git a/src/Nitro/CommandLine/test/e2e/create-flow.tape b/src/Nitro/CommandLine/test/e2e/create-flow.tape new file mode 100644 index 00000000000..ce0c8e34d01 --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/create-flow.tape @@ -0,0 +1,38 @@ +# End-to-end recording of `nitro agent tasks create` against the fixture workspace. +# +# Exercises task creation with no dependencies/parent, the simplest write +# path. The minted ID is seeded from the task title and wall-clock time (see +# TaskStore.CreateTaskIdAsync), so it is non-deterministic across recordings; +# run.sh's `create` SCRUBS entry normalizes it before diffing. Needs no auth: +# task commands do not call the Nitro backend. +# Produces create-flow.gif + create-flow.txt. + +Output create-flow.gif +Output create-flow.txt + +Set Shell bash +Set FontSize 18 +Set Width 1100 +Set Height 850 +Set Padding 14 +Set TypingSpeed 50ms +Set CursorBlink false + +# --- setup (hidden) --- +Hide +Type `export PS1="$ "` Enter +Type `export PATH=/src/src/Nitro/CommandLine/test/e2e/bin:$PATH` Enter +Type `export HOME=/tmp/home` Enter +Type `export NITRO_TASK_ACTOR=e2e-agent` Enter +Type `mkdir -p "$HOME"` Enter +Type `rm -rf /tmp/work && mkdir -p /tmp/work && cp -r /vhs/fixture/acme /tmp/work/acme && cd /tmp/work/acme` Enter +Type `clear` Enter +Show + +# --- the demo --- +Type `nitro agent tasks create "Write onboarding docs"` Enter + +Wait+Screen@30s /Created task '/ +Sleep 1500ms +Screenshot create-flow-final.png +Sleep 500ms diff --git a/src/Nitro/CommandLine/test/e2e/dep-tree-flow.gif b/src/Nitro/CommandLine/test/e2e/dep-tree-flow.gif new file mode 100644 index 00000000000..7ab586eeec8 Binary files /dev/null and b/src/Nitro/CommandLine/test/e2e/dep-tree-flow.gif differ diff --git a/src/Nitro/CommandLine/test/e2e/dep-tree-flow.golden.txt b/src/Nitro/CommandLine/test/e2e/dep-tree-flow.golden.txt new file mode 100644 index 00000000000..a3f58c4c047 --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/dep-tree-flow.golden.txt @@ -0,0 +1,6 @@ +$ nitro agent tasks dep tree acme-epic1.2 +acme-epic1.2 (open) Wire up billing API + acme-epic1.1 (blocks, in_progress) Draft new pricing copy + acme-epic1 (parent-child, open) Launch v2 pricing page + acme-epic1 (parent-child, open) Launch v2 pricing page * +$ diff --git a/src/Nitro/CommandLine/test/e2e/dep-tree-flow.tape b/src/Nitro/CommandLine/test/e2e/dep-tree-flow.tape new file mode 100644 index 00000000000..71800ee6afd --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/dep-tree-flow.tape @@ -0,0 +1,40 @@ +# End-to-end recording of `nitro agent tasks dep tree` against the fixture +# workspace. +# +# `dep tree` walks a task's *outgoing* dependencies (what it depends on), not +# an epic's children, so it is run against acme-epic1.2 (fixtures/seed.sql), +# which has both a `parent-child` edge to acme-epic1 and a `blocks` edge to +# acme-epic1.1: enough to render a populated, deterministic tree with a +# repeated node (marked `*`). Needs no auth: task commands do not call the +# Nitro backend. +# Produces dep-tree-flow.gif + dep-tree-flow.txt. + +Output dep-tree-flow.gif +Output dep-tree-flow.txt + +Set Shell bash +Set FontSize 18 +Set Width 1100 +Set Height 850 +Set Padding 14 +Set TypingSpeed 50ms +Set CursorBlink false + +# --- setup (hidden) --- +Hide +Type `export PS1="$ "` Enter +Type `export PATH=/src/src/Nitro/CommandLine/test/e2e/bin:$PATH` Enter +Type `export HOME=/tmp/home` Enter +Type `export NITRO_TASK_ACTOR=e2e-agent` Enter +Type `mkdir -p "$HOME"` Enter +Type `rm -rf /tmp/work && mkdir -p /tmp/work && cp -r /vhs/fixture/acme /tmp/work/acme && cd /tmp/work/acme` Enter +Type `clear` Enter +Show + +# --- the demo --- +Type `nitro agent tasks dep tree acme-epic1.2` Enter + +Wait+Screen@30s /acme-epic1\.1/ +Sleep 1500ms +Screenshot dep-tree-flow-final.png +Sleep 500ms diff --git a/src/Nitro/CommandLine/test/e2e/detail-flow.gif b/src/Nitro/CommandLine/test/e2e/detail-flow.gif new file mode 100644 index 00000000000..3fc36401fea Binary files /dev/null and b/src/Nitro/CommandLine/test/e2e/detail-flow.gif differ diff --git a/src/Nitro/CommandLine/test/e2e/detail-flow.golden.txt b/src/Nitro/CommandLine/test/e2e/detail-flow.golden.txt new file mode 100644 index 00000000000..67d6477108e --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/detail-flow.golden.txt @@ -0,0 +1,35 @@ +โ•ญโ”€acme-epic1 ยท blocking ยท depended on byโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ +โ”‚ โ—‹ [E] P1 acme-epic1 Launch v2 pricing page โ”‚ +โ”‚ โ”œโ”€ > โ— [T] P2 acme-epic1.1 Draft new pricing copy โ”‚ +โ”‚ โ”‚ โ””โ”€ โ—‹ [T] P2 acme-epic1.2 Wire up billing API (cycle) โ”‚ +โ”‚ โ””โ”€ โ—‹ [T] P2 acme-epic1.2 Wire up billing API โ”‚ +โ”‚ โ””โ”€ โ—‹ [B] P0 acme-a1b Checkout crashes on Safari โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + + + + + + + + + + + + + + + + + + + + + + + + + + + +f follow m edge mode d direction u back hjkl move enter open r refresh y copy id โ€ฆ diff --git a/src/Nitro/CommandLine/test/e2e/detail-flow.tape b/src/Nitro/CommandLine/test/e2e/detail-flow.tape new file mode 100644 index 00000000000..a674da84bfc --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/detail-flow.tape @@ -0,0 +1,122 @@ +# End-to-end recording of the tasks TUI's task detail view and dependency +# tree explorer against the fixture workspace. +# +# There is no dedicated interactive "detail" entry command (BoardTaskCommand +# is the sole TuiShell construction site, see search-flow.tape's header); +# the detail view and the tree explorer are both reached through search +# mode, same as search-flow.tape: '/' into search, Tab Tab from the query +# input to the detail pane. From there this flow exercises what +# search-flow.tape does not: the detail body's dependency section, entering +# the dependency tree explorer ('t', TuiMessage.OpenTreeRequested), toggling +# its direction ('d') to reveal the seeded acme-epic1 children, drilling +# into one of them (Enter, DependencyTreeView.Refocus, pushing a +# breadcrumb), and navigating back up the breadcrumb stack ('u', +# TuiMessage.NavigateTreeBack) -- the tree explorer's own back gesture, not +# Escape/TuiMessage.Back (which would exit the tree entirely, back to +# search). +# +# acme-epic1 is the root: fixtures/seed.sql gives it two parent-child +# children (acme-epic1.1, acme-epic1.2) and no outgoing dependencies of its +# own, so its detail body's "Blocks:" section (TaskDetailModel.Rows pairs +# outgoing Dependencies with incoming Blocks; a parent-child edge counts as +# incoming here, see TaskDetailModel.BuildRows/GetDependentsAsync) lists +# exactly those two children, and it is also the fixture task whose Created +# and Updated sidebar lines both land on 2026-01-01 (it was created and +# never touched again), the deterministic marker this flow's frame +# extraction keys on. +# +# The dependency tree explorer defaults to TreeEdgeMode.Blocking (which +# includes parent-child edges, TaskDependencyTypes.IsBlocking) and +# TreeDirection.Up ("depends on"); acme-epic1 has nothing to depend on, so +# the tree opens on a lone root row until 'd' toggles direction to "depended +# on by", which is when its children appear. +# +# Fixed geometry (Width/Height below) is the stability lever for this +# full-screen layout, same as board-flow.tape/search-flow.tape: changing it +# requires `--update` of every board-flow*/search-flow*/detail-flow* golden. +# Produces detail-flow.gif + detail-flow.txt. + +Output detail-flow.gif +Output detail-flow.txt + +Set Shell bash +Set FontSize 18 +Set Width 1100 +Set Height 850 +Set Padding 14 +Set TypingSpeed 50ms +Set CursorBlink false + +# --- setup (hidden) --- +Hide +Type `export PS1="$ "` Enter +Type `export PATH=/src/src/Nitro/CommandLine/test/e2e/bin:$PATH` Enter +Type `export HOME=/tmp/home` Enter +Type `export NITRO_TASK_ACTOR=e2e-agent` Enter +Type `mkdir -p "$HOME"` Enter +Type `rm -rf /tmp/work && mkdir -p /tmp/work && cp -r /vhs/fixture/acme /tmp/work/acme && cd /tmp/work/acme` Enter +Type `clear` Enter +Show + +# --- the demo --- +Type `nitro agent tasks board` Enter + +# Board's initial frame (same sentinel as board-flow.tape). +Wait+Screen@30s /> โ—‹ \[B\] P0 acme-a1b Checkout crashes on Safari/ + +# '/' into search mode; the pending empty query resolves to all six fixture +# tasks on the next tick, ordered by priority then created_at, selection on +# the first row (acme-a1b). +Type "/" +Wait+Screen@30s /6 task\(s\)/ + +# Tab into the results list, then Down once: acme-a1b (P0) -> acme-epic1 +# (P1), the next row by priority. +Tab +Down +# The Results panel wraps this row's trailing "page" word onto its own line +# at this fixed geometry, so the wait anchors on the row's text up to the +# wrap point rather than the full title (see search-flow.tape). +Wait+Screen@30s /> โ—‹ \[E\] P1 acme-epic1 Launch v2 pricing/ + +# Tab again: List -> Detail focus, landing on acme-epic1's detail pane. Its +# body has no Description/Design/Acceptance criteria/Notes/Comments (all +# empty in the fixture), so the "Blocks:" section -- its two parent-child +# children -- is visible without scrolling. Detail focus puts the selection +# cursor on the first Blocks row; at this fixed geometry the body panel is +# narrow enough that the row's target id/title wrap onto their own line +# (see search-flow.tape), so the wait anchors on the unwrapped prefix + +# dependency type. +Tab +Wait+Screen@30s /> โ— parent-child <-/ + +# 't' opens the dependency tree explorer on the selected task (acme-epic1). +# Default Up direction ("depends on") shows only the root: acme-epic1 has no +# outgoing dependency edges. 'd' toggles to Down ("depended on by"), +# revealing its two seeded children. +Type "t" +Type "d" +Wait+Screen@30s /acme-epic1 ยท blocking ยท depended on by/ + +# Down selects the first child (acme-epic1.1); Enter refocuses the tree on +# it (DependencyTreeView.Refocus), pushing acme-epic1 onto the breadcrumb +# stack. Its own Down tree (still Blocking/Down) shows acme-epic1.2, which +# blocks-depends-on acme-epic1.1 per fixtures/seed.sql, and transitively +# acme-a1b beneath it. The panel header carries the breadcrumb, but at this +# fixed geometry the two-id breadcrumb text overflows the header and gets +# ellipsis-truncated by Spectre's PanelHeader before it reaches " by", so the +# wait anchors on the unwrapped, untruncated root row instead. +Down +Enter +Wait+Screen@30s /> โ— \[T\] P2 acme-epic1\.1 Draft new pricing copy/ + +# 'u' pops the breadcrumb stack (DependencyTreeView.NavigateBack), back to +# the tree rooted on acme-epic1 with its two children again -- the tree +# explorer's own back gesture, distinct from Escape/exiting to search. +# Final state for the golden. +Type "u" +Wait+Screen@30s /acme-epic1 ยท blocking ยท depended on by/ + +Sleep 1500ms +Screenshot detail-flow-final.png +Sleep 500ms diff --git a/src/Nitro/CommandLine/test/e2e/error-flow.gif b/src/Nitro/CommandLine/test/e2e/error-flow.gif new file mode 100644 index 00000000000..8eb25b43f37 Binary files /dev/null and b/src/Nitro/CommandLine/test/e2e/error-flow.gif differ diff --git a/src/Nitro/CommandLine/test/e2e/error-flow.golden.txt b/src/Nitro/CommandLine/test/e2e/error-flow.golden.txt new file mode 100644 index 00000000000..b286d554822 --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/error-flow.golden.txt @@ -0,0 +1,4 @@ +$ nitro agent tasks show acme-nope; echo "[nitro exit: $?]" +Task 'acme-nope' does not exist. +[nitro exit: 1] +$ diff --git a/src/Nitro/CommandLine/test/e2e/error-flow.tape b/src/Nitro/CommandLine/test/e2e/error-flow.tape new file mode 100644 index 00000000000..8363ad5cb34 --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/error-flow.tape @@ -0,0 +1,40 @@ +# End-to-end recording of a failing `nitro agent tasks` command against the fixture +# workspace. +# +# Exercises the non-zero-exit UX for a task ID that does not exist +# (TaskStore.GetRequiredTaskAsync -> ExitException, caught by +# SetActionWithExceptionHandling, exit code 1). The exit code is echoed right +# after the command, mirroring the skillz "[skills exit: 1]" trick, since VHS +# only asserts on rendered screen text, never on a process exit code. Needs no +# auth: task commands do not call the Nitro backend. +# Produces error-flow.gif + error-flow.txt. + +Output error-flow.gif +Output error-flow.txt + +Set Shell bash +Set FontSize 18 +Set Width 1100 +Set Height 850 +Set Padding 14 +Set TypingSpeed 50ms +Set CursorBlink false + +# --- setup (hidden) --- +Hide +Type `export PS1="$ "` Enter +Type `export PATH=/src/src/Nitro/CommandLine/test/e2e/bin:$PATH` Enter +Type `export HOME=/tmp/home` Enter +Type `export NITRO_TASK_ACTOR=e2e-agent` Enter +Type `mkdir -p "$HOME"` Enter +Type `rm -rf /tmp/work && mkdir -p /tmp/work && cp -r /vhs/fixture/acme /tmp/work/acme && cd /tmp/work/acme` Enter +Type `clear` Enter +Show + +# --- the demo --- +Type `nitro agent tasks show acme-nope; echo "[nitro exit: $?]"` Enter + +Wait+Screen@30s /\[nitro exit: 1\]/ +Sleep 1500ms +Screenshot error-flow-final.png +Sleep 500ms diff --git a/src/Nitro/CommandLine/test/e2e/extract-frame.sh b/src/Nitro/CommandLine/test/e2e/extract-frame.sh new file mode 100755 index 00000000000..883314682b1 --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/extract-frame.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# Extract the final completed-state frame from a VHS .txt capture. +# +# extract-frame.sh <capture.txt> [marker] +# +# `Output foo.txt` in a tape concatenates every rendered frame, separated by a +# horizontal rule (a line of box-drawing dashes). For a stable golden we keep +# only the last frame that reached the flow's success marker, with trailing +# whitespace and surrounding blank lines trimmed. +# +# `marker` is a literal substring the completed frame must contain (e.g. "Done!" +# for an install, "Successfully removed" for a removal). When omitted/empty the +# last non-blank frame is used. +# +# Exit non-zero (instead of silently falling back) when the capture is missing or +# empty, or when a non-empty marker is never seen. This makes a broken recording +# or a wrong/typo'd marker fail loudly rather than (a) passing on the wrong frame +# or (b) committing an empty golden via `run.sh --update`. +set -euo pipefail + +TXT="${1:-/dev/stdin}" +MARKER="${2:-}" + +if [[ "$TXT" != "/dev/stdin" && ! -s "$TXT" ]]; then + echo "extract-frame: capture is missing or empty: $TXT" >&2 + exit 1 +fi + +awk -v marker="$MARKER" ' + function store() { + if (buf ~ /[^[:space:]]/) { + anylast = buf # last non-blank frame (fallback) + if (marker == "" || index(buf, marker) > 0) { + last = buf # last frame matching the marker + seen = 1 + } + } + buf = "" + } + /^โ”€โ”€โ”€โ”€/ { store(); next } + { line = $0; sub(/[ \t\r]+$/, "", line); buf = buf line "\n" } + END { + store() + # A required marker that never appeared means the flow did not reach its + # success state (or the marker is wrong): fail rather than emit a stale frame. + if (marker != "" && !seen) { + print "extract-frame: success marker never appeared: " marker > "/dev/stderr" + exit 3 + } + out = (last != "" ? last : anylast) + sub(/^\n+/, "", out) + sub(/\n+$/, "\n", out) + printf "%s", out + } +' "$TXT" diff --git a/src/Nitro/CommandLine/test/e2e/fixtures/README.md b/src/Nitro/CommandLine/test/e2e/fixtures/README.md new file mode 100644 index 00000000000..e671848d810 --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/fixtures/README.md @@ -0,0 +1,77 @@ +# Fixture task workspace + +`seed.sql` is a deterministic dataset for the `nitro agent tasks` e2e tapes. IDs, +timestamps, and actors are all hardcoded so a recording that reads this data +is byte-stable across runs, wall-clock time, and machines. + +## How run.sh uses it + +Before recording any flow, `run.sh` prepares `out/fixture/acme/` on the host +(not inside the VHS container): + +1. `rm -rf` + `mkdir -p out/fixture/acme` for a clean slate. +2. Run the freshly published `bin/nitro agent tasks init` inside it, with + `NITRO_TASK_ACTOR=e2e-agent` set. This creates the real `.nitro/tasks/` + schema (via `TaskStore.InitializeAsync`/`TaskStoreSchema`) and sets the + `acme` task ID prefix (via `TaskWorkspace.NormalizePrefix`, derived from the + `acme` directory name, matching `TasksCommandTestBase`). +3. Apply `seed.sql` with the `sqlite3` CLI against + `out/fixture/acme/.nitro/tasks/tasks.db`. +4. Guard: run `bin/nitro agent tasks list` inside `out/fixture/acme` and grep for + `acme-epic1`. If the marker is missing, schema drift is failing fast here, + with a pointer back to this file, instead of surfacing later as a + confusing golden diff inside a tape's `Hide` block. + +A tape only ever `cp -r`s the prepared `out/fixture/acme` directory into its +own throwaway `/tmp/work`; no task-mutating command inside a tape's `Hide` +block is relied on to produce IDs, so recordings stay independent of the +wall-clock-seeded ID scheme in `CreateTaskCommand`/`TaskStore.CreateIdSuffix`. + +## The dataset + +One epic with two children, a few standalone tasks spanning statuses, +priorities, and types, a blocking dependency, a label, and a comment: + +| ID | Type | Status | Priority | Notes | +| --- | --- | --- | --- | --- | +| `acme-epic1` | epic | open | P1 | parent of the two tasks below | +| `acme-epic1.1` | task | in_progress | P2 | assignee `alice`, label `content` | +| `acme-epic1.2` | task | open | P2 | blocked on `acme-epic1.1`, has a comment from `bob` | +| `acme-a1b` | bug | open | P0 | blocked on `acme-epic1.2` | +| `acme-c3d` | feature | closed | P3 | closed with a reason | +| `acme-e5f` | chore | deferred | P4 | deferred to `2026-02-01` | + +Every `NOT NULL` column in `TaskStoreSchema.Create` is populated on every +inserted row (empty string / matching default where a command would leave it +unset). `events` rows mirror what each corresponding command would have +written, even though no task command besides `task stats` (`COUNT(*)`) reads +that table today. + +All timestamps are UTC, fixed on `2026-01-01` (or shortly after, to give +`updated_at`/dependency ordering a realistic spread), written in the exact +text format `Microsoft.Data.Sqlite` persists for a `DateTimeOffset`: +`yyyy-MM-dd HH:mm:ss.fffffff+00:00`. That format keeps `idx_tasks_updated_at` +lexicographic ordering correct, the same reason `TaskDates.Parse` requires an +explicit offset. + +`child_counters` seeds `('acme-epic1', 2)` so a tape that creates a further +child of `acme-epic1` mints `acme-epic1.3`, continuing on from the two +children already in the fixture instead of colliding with them. + +## Regenerating after a schema change + +`seed.sql` is a plain list of `INSERT` statements against +`TaskStoreSchema.Create`; there is no code generator. After changing the +schema (a new column, a new `NOT NULL` constraint, a renamed table): + +1. Bump `TaskStoreSchema.CurrentVersion` as usual for the production change. +2. Update every `INSERT` in `seed.sql` to match the new column list. For a new + `NOT NULL` column, add a value to every affected `INSERT` (an empty + string/`NULL` per the column's own default, unless the fixture should + exercise the new column specifically). +3. Re-run `./run.sh help` (or any flow). The prepare-fixture step reapplies + `seed.sql` from scratch every run, so a missed column surfaces immediately + as a `sqlite3` constraint error, and the `task list` guard catches a + renamed table or column before any tape records against stale data. +4. To inspect the seeded data directly: + `sqlite3 out/fixture/acme/.nitro/tasks/tasks.db ".dump"`. diff --git a/src/Nitro/CommandLine/test/e2e/fixtures/seed.sql b/src/Nitro/CommandLine/test/e2e/fixtures/seed.sql new file mode 100644 index 00000000000..09ac5880ba6 --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/fixtures/seed.sql @@ -0,0 +1,139 @@ +-- Deterministic task workspace fixture for e2e tapes. +-- +-- Applied by run.sh with the sqlite3 CLI against a workspace database that +-- `nitro agent tasks init` already created (so the schema, PRAGMA user_version, and +-- the `config` prefix row come from the real binary, not from this file). +-- Every ID, timestamp, and actor here is hardcoded so recordings that read +-- this data are byte-stable across runs. See README.md in this directory for +-- the dataset shape and how to regenerate this file after a schema change. +-- +-- All timestamps use the exact text format TaskStore/Microsoft.Data.Sqlite +-- writes for a DateTimeOffset ("yyyy-MM-dd HH:mm:ss.fffffff+00:00"), UTC, on a +-- fixed date (2026-01-01) so idx_tasks_updated_at ordering matches what a +-- real run would have produced. + +BEGIN TRANSACTION; + +-- tasks ---------------------------------------------------------------- +-- acme-epic1: an epic with two children (acme-epic1.1, acme-epic1.2). +INSERT INTO tasks ( + id, title, description, design, acceptance_criteria, notes, + status, priority, task_type, assignee, estimated_minutes, due_at, defer_until, + created_at, created_by, updated_at, closed_at, close_reason, deleted_at, delete_reason +) VALUES ( + 'acme-epic1', 'Launch v2 pricing page', '', '', '', '', + 'open', 1, 'epic', NULL, NULL, NULL, NULL, + '2026-01-01 09:00:00.0000000+00:00', 'e2e-agent', + '2026-01-01 09:00:00.0000000+00:00', NULL, '', NULL, '' +); + +INSERT INTO tasks ( + id, title, description, design, acceptance_criteria, notes, + status, priority, task_type, assignee, estimated_minutes, due_at, defer_until, + created_at, created_by, updated_at, closed_at, close_reason, deleted_at, delete_reason +) VALUES ( + 'acme-epic1.1', 'Draft new pricing copy', '', '', '', '', + 'in_progress', 2, 'task', 'alice', NULL, NULL, NULL, + '2026-01-01 09:05:00.0000000+00:00', 'e2e-agent', + '2026-01-02 10:00:00.0000000+00:00', NULL, '', NULL, '' +); + +INSERT INTO tasks ( + id, title, description, design, acceptance_criteria, notes, + status, priority, task_type, assignee, estimated_minutes, due_at, defer_until, + created_at, created_by, updated_at, closed_at, close_reason, deleted_at, delete_reason +) VALUES ( + 'acme-epic1.2', 'Wire up billing API', '', '', '', '', + 'open', 2, 'task', NULL, NULL, NULL, NULL, + '2026-01-01 09:10:00.0000000+00:00', 'e2e-agent', + '2026-01-03 12:00:00.0000000+00:00', NULL, '', NULL, '' +); + +-- acme-a1b: standalone bug, blocked on acme-epic1.2 via a `blocks` edge below. +INSERT INTO tasks ( + id, title, description, design, acceptance_criteria, notes, + status, priority, task_type, assignee, estimated_minutes, due_at, defer_until, + created_at, created_by, updated_at, closed_at, close_reason, deleted_at, delete_reason +) VALUES ( + 'acme-a1b', 'Checkout crashes on Safari', '', '', '', '', + 'open', 0, 'bug', NULL, NULL, NULL, NULL, + '2026-01-02 08:00:00.0000000+00:00', 'e2e-agent', + '2026-01-02 08:00:00.0000000+00:00', NULL, '', NULL, '' +); + +-- acme-c3d: standalone feature, already closed. +INSERT INTO tasks ( + id, title, description, design, acceptance_criteria, notes, + status, priority, task_type, assignee, estimated_minutes, due_at, defer_until, + created_at, created_by, updated_at, closed_at, close_reason, deleted_at, delete_reason +) VALUES ( + 'acme-c3d', 'Add dark mode toggle', '', '', '', '', + 'closed', 3, 'feature', NULL, NULL, NULL, NULL, + '2026-01-01 07:00:00.0000000+00:00', 'e2e-agent', + '2026-01-04 15:00:00.0000000+00:00', '2026-01-04 15:00:00.0000000+00:00', 'Shipped in v1.9', + NULL, '' +); + +-- acme-e5f: standalone chore, deferred. +INSERT INTO tasks ( + id, title, description, design, acceptance_criteria, notes, + status, priority, task_type, assignee, estimated_minutes, due_at, defer_until, + created_at, created_by, updated_at, closed_at, close_reason, deleted_at, delete_reason +) VALUES ( + 'acme-e5f', 'Rotate staging API keys', '', '', '', '', + 'deferred', 4, 'chore', NULL, NULL, NULL, '2026-02-01 00:00:00.0000000+00:00', + '2026-01-01 07:30:00.0000000+00:00', 'e2e-agent', + '2026-01-05 09:00:00.0000000+00:00', NULL, '', NULL, '' +); + +-- dependencies ----------------------------------------------------------- +-- Parent-child edges, as `task create --parent` would have recorded them. +INSERT INTO dependencies (task_id, depends_on_id, dependency_type, created_at, created_by) +VALUES ('acme-epic1.1', 'acme-epic1', 'parent-child', '2026-01-01 09:05:00.0000000+00:00', 'e2e-agent'); + +INSERT INTO dependencies (task_id, depends_on_id, dependency_type, created_at, created_by) +VALUES ('acme-epic1.2', 'acme-epic1', 'parent-child', '2026-01-01 09:10:00.0000000+00:00', 'e2e-agent'); + +-- A real blocking edge, so `task show acme-epic1.2` lists a dependency and +-- `task show acme-epic1.1` lists a block, independent of the parent-child pair. +INSERT INTO dependencies (task_id, depends_on_id, dependency_type, created_at, created_by) +VALUES ('acme-epic1.2', 'acme-epic1.1', 'blocks', '2026-01-01 09:10:00.0000000+00:00', 'e2e-agent'); + +-- acme-a1b is open but not actionable: it is blocked on the still-open +-- acme-epic1.2, so `task show acme-a1b` renders a "Blocked by" line. +INSERT INTO dependencies (task_id, depends_on_id, dependency_type, created_at, created_by) +VALUES ('acme-a1b', 'acme-epic1.2', 'blocks', '2026-01-02 08:00:00.0000000+00:00', 'e2e-agent'); + +-- labels ------------------------------------------------------------------- +INSERT INTO labels (task_id, label) VALUES ('acme-epic1.1', 'content'); + +-- comments ------------------------------------------------------------------- +INSERT INTO comments (task_id, author, text, created_at) +VALUES ('acme-epic1.2', 'bob', 'Needs design review before starting.', '2026-01-03 12:00:00.0000000+00:00'); + +-- events ------------------------------------------------------------------- +-- Not read by any task command besides `task stats` (COUNT(*)); included so +-- the audit log looks like real command output would have produced. +INSERT INTO events (task_id, event_type, actor, old_value, new_value, comment, created_at) VALUES + ('acme-epic1', 'created', 'e2e-agent', NULL, NULL, NULL, '2026-01-01 09:00:00.0000000+00:00'), + ('acme-epic1.1', 'created', 'e2e-agent', NULL, NULL, NULL, '2026-01-01 09:05:00.0000000+00:00'), + ('acme-epic1.1', 'dependency_added', 'e2e-agent', NULL, 'parent-child:acme-epic1', NULL, '2026-01-01 09:05:00.0000000+00:00'), + ('acme-epic1.2', 'created', 'e2e-agent', NULL, NULL, NULL, '2026-01-01 09:10:00.0000000+00:00'), + ('acme-epic1.2', 'dependency_added', 'e2e-agent', NULL, 'parent-child:acme-epic1', NULL, '2026-01-01 09:10:00.0000000+00:00'), + ('acme-epic1.2', 'dependency_added', 'e2e-agent', NULL, 'blocks:acme-epic1.1', NULL, '2026-01-01 09:10:00.0000000+00:00'), + ('acme-a1b', 'created', 'e2e-agent', NULL, NULL, NULL, '2026-01-02 08:00:00.0000000+00:00'), + ('acme-a1b', 'dependency_added', 'e2e-agent', NULL, 'blocks:acme-epic1.2', NULL, '2026-01-02 08:00:00.0000000+00:00'), + ('acme-c3d', 'created', 'e2e-agent', NULL, NULL, NULL, '2026-01-01 07:00:00.0000000+00:00'), + ('acme-c3d', 'closed', 'e2e-agent', NULL, NULL, 'Shipped in v1.9', '2026-01-04 15:00:00.0000000+00:00'), + ('acme-e5f', 'created', 'e2e-agent', NULL, NULL, NULL, '2026-01-01 07:30:00.0000000+00:00'), + ('acme-e5f', 'deferred', 'e2e-agent', NULL, '2026-02-01 00:00:00.0000000+00:00', NULL, '2026-01-05 09:00:00.0000000+00:00'), + ('acme-epic1.1', 'label_added', 'e2e-agent', NULL, 'content', NULL, '2026-01-02 09:00:00.0000000+00:00'), + ('acme-epic1.1', 'status_changed', 'e2e-agent', 'open', 'in_progress', NULL, '2026-01-02 10:00:00.0000000+00:00'), + ('acme-epic1.2', 'commented', 'bob', NULL, NULL, NULL, '2026-01-03 12:00:00.0000000+00:00'); + +-- child_counters ------------------------------------------------------------- +-- Two children already allocated under acme-epic1 (epic1.1, epic1.2); the +-- next `task create --parent acme-epic1` would mint acme-epic1.3. +INSERT INTO child_counters (parent_id, last_child) VALUES ('acme-epic1', 2); + +COMMIT; diff --git a/src/Nitro/CommandLine/test/e2e/help-flow.gif b/src/Nitro/CommandLine/test/e2e/help-flow.gif new file mode 100644 index 00000000000..b32e35cab1d Binary files /dev/null and b/src/Nitro/CommandLine/test/e2e/help-flow.gif differ diff --git a/src/Nitro/CommandLine/test/e2e/help-flow.golden.txt b/src/Nitro/CommandLine/test/e2e/help-flow.golden.txt new file mode 100644 index 00000000000..2e92b9c0351 --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/help-flow.golden.txt @@ -0,0 +1,27 @@ +$ nitro agent tasks list --help +Description: + List tasks. + +Usage: + nitro agent tasks list [options] + +Options: + --status <status> Filter by status; can be used multiple times + --type <type> The task type (task, bug, feature, epic, chore, docs, question, or + custom) + --priority <priority> The task priority, 0-4 or p0-p4 (0 = critical, 4 = backlog); + list/ready also accept a range like 0-1 or p0-p1 + --assignee <assignee> The assignee + --label <label> A label; can be used multiple times + --limit <limit> The maximum number of tasks to show + --all Include closed and tombstoned tasks + --output <json> The output format (enables non-interactive mode) [env: + NITRO_OUTPUT_FORMAT] + -?, -h, --help Show help and usage information + +Example: + nitro agent tasks list + nitro agent tasks list --status open --status in_progress + nitro agent tasks list --assignee alice --priority p1 + +$ diff --git a/src/Nitro/CommandLine/test/e2e/help-flow.tape b/src/Nitro/CommandLine/test/e2e/help-flow.tape new file mode 100644 index 00000000000..1ea0374fc41 --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/help-flow.tape @@ -0,0 +1,39 @@ +# End-to-end recording of `nitro agent tasks list --help`. +# +# Trivial pipeline smoke flow: proves publish -> record -> extract -> diff works +# end to end (real Program.cs, real DI wiring, real System.CommandLine parsing, +# real terminal rendering of the published binary) independently of the +# fixture-backed agent tasks flows. This flow asks for help on `list`, a leaf +# subcommand, since its help output is the more informative pipeline check +# than the `agent`/`tasks` group commands' own help. Needs no auth: task +# commands do not call the Nitro backend. +# Produces help-flow.gif + help-flow.txt. + +Output help-flow.gif +Output help-flow.txt + +Set Shell bash +Set FontSize 18 +Set Width 1100 +Set Height 850 +Set Padding 14 +Set TypingSpeed 50ms +Set CursorBlink false + +# --- setup (hidden) --- +Hide +Type `export PS1="$ "` Enter +Type `export PATH=/src/src/Nitro/CommandLine/test/e2e/bin:$PATH` Enter +Type `export HOME=/tmp/home` Enter +Type `mkdir -p "$HOME"` Enter +Type `cd /tmp && rm -rf work && mkdir work && cd work` Enter +Type `clear` Enter +Show + +# --- the demo --- +Type `nitro agent tasks list --help` Enter + +Wait+Screen@30s /List tasks\./ +Sleep 1500ms +Screenshot help-flow-final.png +Sleep 500ms diff --git a/src/Nitro/CommandLine/test/e2e/init-flow.gif b/src/Nitro/CommandLine/test/e2e/init-flow.gif new file mode 100644 index 00000000000..38a20c3062d Binary files /dev/null and b/src/Nitro/CommandLine/test/e2e/init-flow.gif differ diff --git a/src/Nitro/CommandLine/test/e2e/init-flow.golden.txt b/src/Nitro/CommandLine/test/e2e/init-flow.golden.txt new file mode 100644 index 00000000000..7f8d7a01aab --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/init-flow.golden.txt @@ -0,0 +1,4 @@ +$ nitro agent tasks init +โœ“ Initialized task workspace at '.nitro/tasks'. +โœ“ Task ID prefix set to 'acme'. +$ diff --git a/src/Nitro/CommandLine/test/e2e/init-flow.tape b/src/Nitro/CommandLine/test/e2e/init-flow.tape new file mode 100644 index 00000000000..9dc99288d5d --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/init-flow.tape @@ -0,0 +1,38 @@ +# End-to-end recording of `nitro agent tasks init` in a fresh directory. +# +# Exercises the write path that creates the on-disk `.nitro/tasks/` schema and +# derives the task ID prefix from the current directory name, ahead of the +# fixture-backed flows below (which reuse a pre-seeded workspace instead of +# running `init` live). Needs no auth: task commands do not call the Nitro +# backend. +# Produces init-flow.gif + init-flow.txt. + +Output init-flow.gif +Output init-flow.txt + +Set Shell bash +Set FontSize 18 +Set Width 1100 +Set Height 850 +Set Padding 14 +Set TypingSpeed 50ms +Set CursorBlink false + +# --- setup (hidden) --- +Hide +Type `export PS1="$ "` Enter +Type `export PATH=/src/src/Nitro/CommandLine/test/e2e/bin:$PATH` Enter +Type `export HOME=/tmp/home` Enter +Type `export NITRO_TASK_ACTOR=e2e-agent` Enter +Type `mkdir -p "$HOME"` Enter +Type `rm -rf /tmp/work && mkdir -p /tmp/work/acme && cd /tmp/work/acme` Enter +Type `clear` Enter +Show + +# --- the demo --- +Type `nitro agent tasks init` Enter + +Wait+Screen@30s /Initialized task workspace/ +Sleep 1500ms +Screenshot init-flow-final.png +Sleep 500ms diff --git a/src/Nitro/CommandLine/test/e2e/list-flow.gif b/src/Nitro/CommandLine/test/e2e/list-flow.gif new file mode 100644 index 00000000000..cc704503e65 Binary files /dev/null and b/src/Nitro/CommandLine/test/e2e/list-flow.gif differ diff --git a/src/Nitro/CommandLine/test/e2e/list-flow.golden.txt b/src/Nitro/CommandLine/test/e2e/list-flow.golden.txt new file mode 100644 index 00000000000..e1d5af21361 --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/list-flow.golden.txt @@ -0,0 +1,9 @@ +$ nitro agent tasks list +acme-a1b P0 bug open Checkout crashes on Safari +acme-epic1 P1 epic open Launch v2 pricing page +acme-epic1.1 P2 task in_progress Draft new pricing copy +acme-epic1.2 P2 task open Wire up billing API +acme-e5f P4 chore deferred Rotate staging API keys + +5 task(s) +$ diff --git a/src/Nitro/CommandLine/test/e2e/list-flow.tape b/src/Nitro/CommandLine/test/e2e/list-flow.tape new file mode 100644 index 00000000000..75b012e93d7 --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/list-flow.tape @@ -0,0 +1,37 @@ +# End-to-end recording of `nitro agent tasks list` against the fixture workspace. +# +# Exercises the default (open, non-tombstone) listing over a real +# `.nitro/tasks/tasks.db`, ordered by priority/created_at/id, against the +# deterministic dataset in fixtures/seed.sql. Needs no auth: task commands do +# not call the Nitro backend. +# Produces list-flow.gif + list-flow.txt. + +Output list-flow.gif +Output list-flow.txt + +Set Shell bash +Set FontSize 18 +Set Width 1100 +Set Height 850 +Set Padding 14 +Set TypingSpeed 50ms +Set CursorBlink false + +# --- setup (hidden) --- +Hide +Type `export PS1="$ "` Enter +Type `export PATH=/src/src/Nitro/CommandLine/test/e2e/bin:$PATH` Enter +Type `export HOME=/tmp/home` Enter +Type `export NITRO_TASK_ACTOR=e2e-agent` Enter +Type `mkdir -p "$HOME"` Enter +Type `rm -rf /tmp/work && mkdir -p /tmp/work && cp -r /vhs/fixture/acme /tmp/work/acme && cd /tmp/work/acme` Enter +Type `clear` Enter +Show + +# --- the demo --- +Type `nitro agent tasks list` Enter + +Wait+Screen@30s /acme-e5f/ +Sleep 1500ms +Screenshot list-flow-final.png +Sleep 500ms diff --git a/src/Nitro/CommandLine/test/e2e/run.sh b/src/Nitro/CommandLine/test/e2e/run.sh new file mode 100755 index 00000000000..8a649cea848 --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/run.sh @@ -0,0 +1,265 @@ +#!/usr/bin/env bash +# Record the nitro CLI flows with VHS and verify each against its golden frame. +# +# ./run.sh record every flow, diff each final frame vs its golden (CI / test) +# ./run.sh --update record every flow, overwrite goldens + committed GIFs (accept output) +# ./run.sh remove update record only the named flows (any subset of the list below) +# ./run.sh --update init update a single flow +# +# Each flow <name> is driven by test/e2e/<name>-flow.tape and checked against +# test/e2e/<name>-flow.golden.txt. One VHS run per flow produces both the demo +# GIF (<name>-flow.gif) and the text capture that is reduced to a deterministic +# final frame and diffed. +# +# Inputs (the repo) are mounted read-only into the pinned VHS container; the +# container bundles ttyd + ffmpeg + fonts so nothing needs installing on the host. +# The published binary and raw recordings land in gitignored dirs (bin/, out/). +# +# Results for changed/new flows are collected under out/report/ for CI to upload +# as artifacts and surface in a PR comment: +# out/report/status.tsv <flow>\t<PASS|FAIL|NEW> (every recorded flow) +# out/report/changed.txt one <flow> per line (FAIL or NEW only) +# out/report/<flow>-flow.gif / .frame.txt / .golden.txt / .diff +# +# Exit code: 0 if every recorded flow PASSed; 1 if any FAILed or was NEW (no +# golden yet); 2 on usage/setup error; 3 if docker is unavailable. --update +# exits 0 when every targeted flow recorded successfully, or 1 if any recording +# failed (its golden is left unchanged). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../../../.." && pwd)" +OUT_DIR="$SCRIPT_DIR/out" +BIN_DIR="$SCRIPT_DIR/bin" +REPORT_DIR="$OUT_DIR/report" + +# Pinned by digest so a new VHS release can't silently shift the rendered output +# (and thus the goldens). Bump deliberately, then re-run with --update. Tag: v0.11.0. +VHS_IMAGE="${VHS_IMAGE:-ghcr.io/charmbracelet/vhs@sha256:9d5fc3dc0c160b0fb1d2212baff07e6bdf3fa9438c504a3237484567302fcf93}" + +# Every flow, in record order, with the literal marker the completed final frame +# must contain (see extract-frame.sh). Keep this list and ALL_FLOWS in sync. +# +# `help` is a trivial pipeline smoke flow: it proves publish -> record -> extract +# -> diff works end to end before fixture-backed task flows land. The rest run +# against the deterministic fixture prepared below (out/fixture/acme). +declare -A MARKERS=( + [help]="List tasks." + [init]="Initialized task workspace" + [list]="acme-e5f" + [show]="Draft new pricing copy" + [create]="Created task '" + [close-reopen]="Reopened task 'acme-a1b'." + [dep-tree]="acme-epic1.1" + [error]="[nitro exit: 1]" + [board]="> โ— [T] P2 acme-epic1.1 Draft new pricing copy" + [board-maximize]="In Progress (1)" + [search]="> โ— parent-child <-" + [detail]="acme-epic1 ยท blocking ยท depended on by" +) +ALL_FLOWS=(help init list show create close-reopen dep-tree error board board-maximize search detail) + +# Per-flow sed expressions (extended regex, `sed -E`) applied to the extracted +# frame before it is compared with (or written as) the golden. Empty by default. +# +# `create` mints its task ID from the title and wall-clock time +# (TaskStore.CreateTaskIdAsync), so it is non-deterministic across recordings; +# normalize it to a fixed placeholder before diffing. The expression is a +# single literal substitution (no alternation/backreferences) so it cannot +# fail on a well-formed frame. +declare -A SCRUBS=( + [create]='s/acme-[a-z0-9.]+/acme-XXX/g' +) + +# --- args: optional --update plus an optional subset of flow names ------------ +UPDATE=0 +FLOWS=() +for arg in "$@"; do + case "$arg" in + --update) UPDATE=1 ;; + -*) echo "unknown option: $arg" >&2; exit 2 ;; + *) + if [[ -z "${MARKERS[$arg]+x}" ]]; then + echo "unknown flow: $arg (known: ${ALL_FLOWS[*]})" >&2 + exit 2 + fi + FLOWS+=("$arg") + ;; + esac +done +[[ ${#FLOWS[@]} -eq 0 ]] && FLOWS=("${ALL_FLOWS[@]}") + +if ! command -v docker >/dev/null 2>&1; then + echo "docker is required to run the VHS recordings" >&2 + exit 3 +fi + +# 1. Publish a self-contained linux-x64 binary (glibc, matches the Debian-based +# VHS image). Skip if already present unless REBUILD=1 or --update, an update +# must never bake a stale binary's output into the committed goldens. +# +# The csproj sets UseAppHost=false (it is packed as a dotnet tool), so a +# non-AOT e2e publish must override UseAppHost=true to get a native `nitro` +# executable rather than only `nitro.dll`. Task commands need no auth, so no +# Nitro*ClientId secret properties are passed; the BuildSecrets constants +# default to empty. +# +# NITRO_E2E_AOT=1 publishes with PublishAot=true instead, matching the +# shipped binary. This is slow, so it is off by default. +if [[ ! -x "$BIN_DIR/nitro" || "${REBUILD:-0}" == "1" || "$UPDATE" == "1" ]]; then + echo "==> publishing nitro (linux-x64, self-contained)" + aot_flag=false + [[ "${NITRO_E2E_AOT:-0}" == "1" ]] && aot_flag=true + dotnet publish "$REPO_ROOT/src/Nitro/CommandLine/src/CommandLine" \ + -c Release -f net11.0 -r linux-x64 -o "$BIN_DIR" --self-contained \ + -p:TargetFrameworks=net11.0 -p:RuntimeIdentifiers=linux-x64 \ + -p:PublishAot=$aot_flag -p:UseAppHost=true >/dev/null +fi + +mkdir -p "$OUT_DIR" +rm -rf "$REPORT_DIR" +mkdir -p "$REPORT_DIR" +: > "$REPORT_DIR/status.tsv" +: > "$REPORT_DIR/changed.txt" + +# 1b. Prepare a deterministic fixture task workspace (fixed IDs, timestamps, +# actors: see fixtures/README.md) that tapes `cp -r` into their own +# throwaway /tmp/work rather than seeding state live inside a Hide block. +# Rebuilt unconditionally on every run (not cached like the binary) so +# schema drift between TaskStoreSchema and fixtures/seed.sql fails fast +# here, with a message pointing at fixtures/README.md, instead of turning +# into a confusing golden diff once a task flow tape lands. +if ! command -v sqlite3 >/dev/null 2>&1; then + echo "sqlite3 is required to prepare the fixture workspace" >&2 + exit 3 +fi + +FIXTURE_DIR="$OUT_DIR/fixture/acme" +# Matches TaskWorkspace.RootDirectoryName/TasksDirectoryName/DatabaseFileName. +FIXTURE_DB="$FIXTURE_DIR/.nitro/tasks/tasks.db" +FIXTURE_MARKER="acme-epic1" + +echo "==> preparing fixture workspace (out/fixture/acme)" +rm -rf "$FIXTURE_DIR" +mkdir -p "$FIXTURE_DIR" +if ! ( cd "$FIXTURE_DIR" && NITRO_TASK_ACTOR=e2e-agent "$BIN_DIR/nitro" agent tasks init >/dev/null ); then + echo "==> fixture prepare FAILED: 'nitro agent tasks init' did not succeed" >&2 + exit 2 +fi +if ! sqlite3 "$FIXTURE_DB" < "$SCRIPT_DIR/fixtures/seed.sql"; then + echo "==> fixture prepare FAILED: seed.sql did not apply cleanly to $FIXTURE_DB" >&2 + echo " the task schema likely drifted from fixtures/seed.sql; see fixtures/README.md" >&2 + exit 2 +fi +if ! ( cd "$FIXTURE_DIR" && "$BIN_DIR/nitro" agent tasks list ) | grep -q "$FIXTURE_MARKER"; then + echo "==> fixture guard FAILED: 'nitro agent tasks list' did not show '$FIXTURE_MARKER'" >&2 + echo " the task schema likely drifted from fixtures/seed.sql; see fixtures/README.md" >&2 + exit 2 +fi +echo " fixture ready, guard passed" + +overall=0 +declare -a SUMMARY=() + +for flow in "${FLOWS[@]}"; do + tape="$SCRIPT_DIR/${flow}-flow.tape" + golden="$SCRIPT_DIR/${flow}-flow.golden.txt" + frame="$OUT_DIR/${flow}-flow.frame.txt" + + if [[ ! -f "$tape" ]]; then + echo "==> $flow: missing tape ($tape)" >&2 + exit 2 + fi + + # 2. Record. The tape writes <flow>-flow.gif + <flow>-flow.txt into OUT_DIR + # (mounted as the VHS workdir at /vhs). A single flow's failure (docker/VHS + # error, or a Wait sentinel that times out) must NOT abort the batch under + # `set -e`: capture it, record the flow as FAIL, and carry on to the rest. + echo "==> recording ${flow}-flow" + rec_ok=1 + if ! docker run --rm --shm-size=512m \ + -v "$REPO_ROOT":/src:ro \ + -v "$OUT_DIR":/vhs \ + "$VHS_IMAGE" "/src/src/Nitro/CommandLine/test/e2e/${flow}-flow.tape"; then + rec_ok=0 + echo " recording FAILED (docker/VHS error)" >&2 + fi + + # 3. Reduce the multi-frame capture to the deterministic final frame. + # extract-frame.sh exits non-zero if the capture is missing/empty or the + # success marker never appeared; treat that as a failed recording too. + extract_ok=0 + if [[ "$rec_ok" == "1" ]] \ + && bash "$SCRIPT_DIR/extract-frame.sh" "$OUT_DIR/${flow}-flow.txt" "${MARKERS[$flow]}" > "$frame"; then + extract_ok=1 + else + : > "$frame" + echo " frame extraction FAILED (empty capture or missing marker '${MARKERS[$flow]}')" >&2 + fi + recorded_ok=0 + if [[ "$extract_ok" == "1" && -s "$frame" ]]; then + recorded_ok=1 + fi + + # 3b. Normalize non-deterministic content (task IDs, dates) before compare/update. + if [[ "$recorded_ok" == "1" && -n "${SCRUBS[$flow]:-}" ]]; then + sed -E -i "${SCRUBS[$flow]}" "$frame" + fi + + # 4. Update or verify. + if [[ "$UPDATE" == "1" ]]; then + if [[ "$recorded_ok" == "1" ]]; then + cp "$frame" "$golden" + cp "$OUT_DIR/${flow}-flow.gif" "$SCRIPT_DIR/${flow}-flow.gif" + echo " updated golden + GIF" + SUMMARY+=("$flow UPDATED") + else + overall=1 + echo " NOT updated, recording failed, golden left unchanged" >&2 + SUMMARY+=("$flow FAILED") + fi + continue + fi + + if [[ "$recorded_ok" != "1" ]]; then + status=FAIL + elif [[ ! -f "$golden" ]]; then + status=NEW + elif diff -u "$golden" "$frame" > "$REPORT_DIR/${flow}-flow.diff" 2>&1; then + status=PASS + rm -f "$REPORT_DIR/${flow}-flow.diff" + else + status=FAIL + fi + + printf '%s\t%s\n' "$flow" "$status" >> "$REPORT_DIR/status.tsv" + SUMMARY+=("$flow $status") + + if [[ "$status" != "PASS" ]]; then + overall=1 + echo "$flow" >> "$REPORT_DIR/changed.txt" + cp -f "$OUT_DIR/${flow}-flow.gif" "$REPORT_DIR/${flow}-flow.gif" 2>/dev/null || true + cp -f "$frame" "$REPORT_DIR/${flow}-flow.frame.txt" 2>/dev/null || true + [[ -f "$golden" ]] && cp -f "$golden" "$REPORT_DIR/${flow}-flow.golden.txt" + echo " $status (recorded; see report)" + else + echo " PASS" + fi +done + +echo +echo "==> summary" +for line in "${SUMMARY[@]}"; do + echo " $line" +done + +if [[ "$UPDATE" == "1" ]]; then + exit "$overall" +fi + +if [[ "$overall" -ne 0 ]]; then + echo "==> FAIL: $(wc -l < "$REPORT_DIR/changed.txt" | tr -d ' ') flow(s) changed or new (re-run with --update to accept)" >&2 +else + echo "==> PASS: all ${#FLOWS[@]} flow(s) match their golden" +fi +exit "$overall" diff --git a/src/Nitro/CommandLine/test/e2e/search-flow.gif b/src/Nitro/CommandLine/test/e2e/search-flow.gif new file mode 100644 index 00000000000..50abff4f55c Binary files /dev/null and b/src/Nitro/CommandLine/test/e2e/search-flow.gif differ diff --git a/src/Nitro/CommandLine/test/e2e/search-flow.golden.txt b/src/Nitro/CommandLine/test/e2e/search-flow.golden.txt new file mode 100644 index 00000000000..1d9dae6145a --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/search-flow.golden.txt @@ -0,0 +1,35 @@ +> status:open pricing +โ•ญโ”€Resultsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ•ญโ”€acme-epic1 Launch v2 pricing pageโ”€โ”€โ•ฎ +โ”‚ โ—‹ [E] P1 acme-epic1 Launch v2 pricing โ”‚โ”‚ Blocks โ”‚ +โ”‚ page โ”‚โ”‚ โ”‚ +โ”‚ 1 task(s) โ”‚โ”‚ > โ— parent-child <- acme-epic1.1 โ€ฆ โ”‚ +โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏโ”‚ โ—‹ parent-child <- acme-epic1.2 โ€ฆ โ”‚ + โ”‚ โ”‚ + โ”‚ โ”‚ + โ”‚ โ”‚ + โ”‚ โ”‚ + โ”‚ โ”‚ + โ”‚ โ”‚ + โ”‚ โ”‚ + โ”‚ โ”‚ + โ”‚ โ”‚ + โ”‚ โ”‚ + โ”‚ โ”‚ + โ”‚ โ”‚ + โ”‚ โ”‚ + โ”‚ โ”‚ + โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ + โ•ญโ”€Detailsโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ + โ”‚ โ—‹ open โ”‚ + โ”‚ Priority: P1 โ”‚ + โ”‚ Type: epic โ”‚ + โ”‚ โ”‚ + โ”‚ Created: 2026-01-01 09:00 by โ”‚ + โ”‚ e2e-agent โ”‚ + โ”‚ Updated: 2026-01-01 09:00 โ”‚ + โ”‚ โ”‚ + โ”‚ Blocked by: โ”‚ + โ”‚ acme-epic1.1:child-open, โ”‚ + โ”‚ acme-epic1.2:child-open โ”‚ + โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ +esc back tab open hjkl move enter open r refresh y copy id z zoom e edit q quit diff --git a/src/Nitro/CommandLine/test/e2e/search-flow.tape b/src/Nitro/CommandLine/test/e2e/search-flow.tape new file mode 100644 index 00000000000..95cc9dc49b6 --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/search-flow.tape @@ -0,0 +1,94 @@ +# End-to-end recording of the tasks TUI's search mode against the fixture +# workspace. +# +# Search mode is reached from the board TUI via the '/' +# gesture (TuiMessage.FocusSearchRequested, KeyMap.CreateDefaultGlobal): it +# is the only entry point, there is no dedicated interactive search command +# (`nitro agent tasks search` is a separate, non-interactive text-search command +# that never touches Tui/Search/SearchMode.cs). Drives the query input's +# live-filtering (TaskQueryParser/TaskQuery.ToFilter against +# fixtures/seed.sql) through one incremental narrowing step, then Tab +# through Input -> List -> Detail focus (SearchMode.HandleOpenSelected) to +# land on the selected task's detail pane, syncing every transition on a +# Wait+Screen sentinel anchored on fixture task titles, ids, or the live +# result count, never on Sleep. +# +# `status:open` alone matches three fixture tasks (acme-a1b P0, acme-epic1 +# P1, acme-epic1.2 P2: the three open tasks per fixtures/seed.sql), ordered +# by priority. Extending it to `status:open pricing` narrows the free-text +# `Text` filter (title/description/design/acceptance-criteria/notes, +# LIKE-matched, see TaskStore's BuildWhereClause) down to exactly one: +# acme-epic1's title is the only one of the three containing "pricing" +# ("Launch v2 pricing page"; acme-epic1.2 is "Wire up billing API", acme-a1b +# is "Checkout crashes on Safari"). +# +# Fixed geometry (Width/Height below) is the stability lever for this +# full-screen layout, same as board-flow.tape: changing it requires +# `--update` of every board-flow*/search-flow*/detail-flow* golden. +# Produces search-flow.gif + search-flow.txt. + +Output search-flow.gif +Output search-flow.txt + +Set Shell bash +Set FontSize 18 +Set Width 1100 +Set Height 850 +Set Padding 14 +Set TypingSpeed 50ms +Set CursorBlink false + +# --- setup (hidden) --- +Hide +Type `export PS1="$ "` Enter +Type `export PATH=/src/src/Nitro/CommandLine/test/e2e/bin:$PATH` Enter +Type `export HOME=/tmp/home` Enter +Type `export NITRO_TASK_ACTOR=e2e-agent` Enter +Type `mkdir -p "$HOME"` Enter +Type `rm -rf /tmp/work && mkdir -p /tmp/work && cp -r /vhs/fixture/acme /tmp/work/acme && cd /tmp/work/acme` Enter +Type `clear` Enter +Show + +# --- the demo --- +Type `nitro agent tasks board` Enter + +# Board's initial frame (same sentinel as board-flow.tape): focus starts on +# "Blocked", selection on its first row, acme-a1b. +Wait+Screen@30s /> โ—‹ \[B\] P0 acme-a1b Checkout crashes on Safari/ + +# '/' jumps into search mode (TuiShell.HandleMessage FocusSearchRequested), +# focus on the query input. The pending query from mode entry (TaskQuery.Empty) +# resolves on the next tick to all six fixture tasks, ordered by priority +# then created_at, selection on the first row (acme-a1b). +Type "/" +Wait+Screen@30s /6 task\(s\)/ + +# Incremental step 1: "status:open" narrows to three fixture hits. +Type `status:open` +Wait+Screen@30s /3 task\(s\)/ + +# Incremental step 2: extending the query with a free-text word narrows to +# exactly one hit. +Type ` pricing` +# Focus is still on the query input (no Tab yet), so the Results list has no +# selection cursor to anchor on (SearchResultsList only renders "> " when the +# list itself is focused); the narrowing to exactly one hit is what this step +# exercises, so anchor on the result count, same as the previous step. +Wait+Screen@30s /1 task\(s\)/ + +# Tab moves focus Input -> List (SearchMode's own Tab binding mirrors +# Enter/OpenSelected); a second Tab moves List -> Detail, landing on the +# selected task's detail pane. Detail focus puts the selection cursor on the +# body's first Blocks row (TaskDetailBody.BuildRowSection, selected when +# focused && row.Index == SelectedRowIndex); at this fixed geometry the body +# panel is narrow enough that the row's target id/title wrap onto their own +# line (TaskDetailRowRenderer's single logical line gets soft-wrapped by +# Spectre), so the wait anchors on the unwrapped prefix + dependency type. +# Final state for the golden. +Tab +Tab +Wait+Screen@30s /> โ— parent-child <-/ + +Sleep 1500ms +Screenshot search-flow-final.png +Sleep 500ms diff --git a/src/Nitro/CommandLine/test/e2e/show-flow.gif b/src/Nitro/CommandLine/test/e2e/show-flow.gif new file mode 100644 index 00000000000..913c0b7fb65 Binary files /dev/null and b/src/Nitro/CommandLine/test/e2e/show-flow.gif differ diff --git a/src/Nitro/CommandLine/test/e2e/show-flow.golden.txt b/src/Nitro/CommandLine/test/e2e/show-flow.golden.txt new file mode 100644 index 00000000000..a7a2573103b --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/show-flow.golden.txt @@ -0,0 +1,14 @@ +$ nitro agent tasks show acme-epic1.1 +acme-epic1.1: Draft new pricing copy + +Status: in_progress Priority: P2 Type: task +Assignee: alice +Created: 2026-01-01 09:05 by e2e-agent +Updated: 2026-01-02 10:00 +Labels: content + +Dependencies: + parent-child -> acme-epic1 (open) Launch v2 pricing page +Blocks: + acme-epic1.2 (open) Wire up billing API +$ diff --git a/src/Nitro/CommandLine/test/e2e/show-flow.tape b/src/Nitro/CommandLine/test/e2e/show-flow.tape new file mode 100644 index 00000000000..30fa34f40fa --- /dev/null +++ b/src/Nitro/CommandLine/test/e2e/show-flow.tape @@ -0,0 +1,38 @@ +# End-to-end recording of `nitro agent tasks show` against the fixture workspace. +# +# Exercises the detail render: header, dependencies, and blocks sections, over +# a task with both an outgoing parent-child dependency and an incoming block +# (fixtures/seed.sql). Created/Updated render deterministically because the +# fixture's timestamps are hardcoded. Needs no auth: task commands do not +# call the Nitro backend. +# Produces show-flow.gif + show-flow.txt. + +Output show-flow.gif +Output show-flow.txt + +Set Shell bash +Set FontSize 18 +Set Width 1100 +Set Height 850 +Set Padding 14 +Set TypingSpeed 50ms +Set CursorBlink false + +# --- setup (hidden) --- +Hide +Type `export PS1="$ "` Enter +Type `export PATH=/src/src/Nitro/CommandLine/test/e2e/bin:$PATH` Enter +Type `export HOME=/tmp/home` Enter +Type `export NITRO_TASK_ACTOR=e2e-agent` Enter +Type `mkdir -p "$HOME"` Enter +Type `rm -rf /tmp/work && mkdir -p /tmp/work && cp -r /vhs/fixture/acme /tmp/work/acme && cd /tmp/work/acme` Enter +Type `clear` Enter +Show + +# --- the demo --- +Type `nitro agent tasks show acme-epic1.1` Enter + +Wait+Screen@30s /Draft new pricing copy/ +Sleep 1500ms +Screenshot show-flow-final.png +Sleep 500ms