Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ config.json
.env
.env.*
!.env.example
bin/
obj/
.claude/
.azure
*.user
Expand Down
35 changes: 35 additions & 0 deletions BlogWriter.Tests/SessionListSelectionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
using Xunit;

namespace BlogWriter.Tests;

public sealed class SessionListSelectionTests
{
[Theory]
[InlineData("1", 1)]
[InlineData(" 2 ", 2)]
public void TryResolve_UsesOneBasedDisplayedPosition(string input, int expectedIndex)
{
IReadOnlyList<BlogSessionSummary> sessions = [CreateSummary("one"), CreateSummary("two")];

Assert.True(SessionListSelection.TryResolve(input, sessions, out BlogSessionSummary? selected));
Assert.Equal(sessions[expectedIndex - 1].Id, selected!.Id);
}

[Theory]
[InlineData("")]
[InlineData("0")]
[InlineData("-1")]
[InlineData("1.5")]
[InlineData("text")]
[InlineData("3")]
public void TryResolve_RejectsInvalidOrOutOfRangeInput(string input)
{
IReadOnlyList<BlogSessionSummary> sessions = [CreateSummary("one"), CreateSummary("two")];

Assert.False(SessionListSelection.TryResolve(input, sessions, out BlogSessionSummary? selected));
Assert.Null(selected);
}

private static BlogSessionSummary CreateSummary(string task) =>
new(Guid.NewGuid().ToString("N"), task, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow);
}
45 changes: 44 additions & 1 deletion BlogWriter.Web.Tests/BlogWorkspaceServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,47 @@ public async Task SubmitInitialAsync_PublishesCompletedDraftAndReview()
Assert.Equal(1, sessions.StartCalls);
}

[Fact]
public async Task LaunchSelectionAsync_RestoresPromptContextAndStartsExactlyOnce()
{
var sessions = new StubSessionService
{
Summaries = [CreateSummary("saved")],
SessionToLoad = ListLauncherTestHelpers.Session("saved topic", "tighten the ending"),
};
var workspace = new BlogWorkspaceService(sessions, TimeSpan.FromMilliseconds(25));
workspace.State.Draft = "old draft";
workspace.State.Review = "old review";
workspace.State.InitialPrompt = "old prompt";
workspace.State.RevisionPrompt = "old revision";

await workspace.ListAsync(true);
await workspace.LaunchSelectionAsync("1");

Assert.Equal(1, sessions.StartCalls);
Assert.Equal("saved topic", sessions.LastStartPrompt);
Assert.Equal("tighten the ending", workspace.State.RevisionPrompt);
Assert.Equal(WorkspaceMode.Draft, workspace.State.Mode);
Assert.Empty(workspace.State.SelectionError ?? "");
}

[Theory]
[InlineData("")]
[InlineData("0")]
[InlineData("9")]
public async Task LaunchSelectionAsync_InvalidInputDoesNotLoadOrStart(string input)
{
var sessions = new StubSessionService { Summaries = [CreateSummary("saved")] };
var workspace = new BlogWorkspaceService(sessions, TimeSpan.FromMilliseconds(25));
await workspace.ListAsync(false);

await workspace.LaunchSelectionAsync(input);

Assert.Equal(0, sessions.LoadCalls);
Assert.Equal(0, sessions.StartCalls);
Assert.NotNull(workspace.State.SelectionError);
}

[Fact]
public async Task SubmitInitialAsync_AppendsLifecycleAndReviewerOutput()
{
Expand Down Expand Up @@ -101,7 +142,7 @@ public async Task DuplicateReviewerOutputIsRenderedOnce()
await workspace.SubmitInitialAsync();
sessions.LastOutput!.Report(BlogWorkspaceOutputTestHelpers.Review("review", "initial-review"));

Assert.Equal(1, workspace.State.Review.Split("\n\n", StringSplitOptions.None).Length);
Assert.Single(workspace.State.Review.Split("\n\n", StringSplitOptions.None));
}

[Fact]
Expand Down Expand Up @@ -398,6 +439,7 @@ private static BlogSession CreateSession(
private sealed class StubSessionService : IBlogWriterSessionService
{
public int StartCalls { get; private set; }
public string? LastStartPrompt { get; private set; }
public int LoadCalls { get; private set; }
public WordRange? LastStartRange { get; private set; }
public WordRange? LastRevisionRange { get; private set; }
Expand All @@ -410,6 +452,7 @@ private sealed class StubSessionService : IBlogWriterSessionService
public Task<BlogSession> StartAsync(string prompt, int minWords = ResearchState.DefaultMinWords, int maxWords = ResearchState.DefaultMaxWords, CancellationToken cancellationToken = default, IProgress<WorkflowOutputUpdate>? output = null)
{
StartCalls++;
LastStartPrompt = prompt;
LastOutput = output;
output?.Report(WorkflowOutputUpdate.Create(
WorkflowOutputKind.ReviewerFeedback,
Expand Down
36 changes: 36 additions & 0 deletions BlogWriter.Web.Tests/CommandBarTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using Bunit;
using BlogWriter.Web.Components;

namespace BlogWriter.Web.Tests;

public sealed class CommandBarTests : BunitContext
{
public CommandBarTests() => JSInterop.Mode = JSRuntimeMode.Loose;

[Fact]
public void CommandBar_ShowsInlineThreeDigitSelectorAndHelp()
{
IRenderedComponent<CommandBar> cut = Render<CommandBar>(parameters => parameters
.Add(component => component.SelectionVisible, true)
.Add(component => component.SelectionInput, "12")
.Add(component => component.ReviseEnabled, true));

Assert.Equal("12", cut.Find("#command-session-number").GetAttribute("value"));
Assert.Equal("3", cut.Find("#command-session-number").GetAttribute("maxlength"));
Assert.Equal("Help", cut.Find("button[data-command='help']").GetAttribute("aria-label"));
Assert.DoesNotContain("session-selector", cut.Markup);
}

[Fact]
public void CommandBar_SelectionChangeInvokesCallback()
{
string? value = null;
IRenderedComponent<CommandBar> cut = Render<CommandBar>(parameters => parameters
.Add(component => component.SelectionVisible, true)
.Add(component => component.SelectionChanged, (string selected) => value = selected));

cut.Find("#command-session-number").Change("2");

Assert.Equal("2", value);
}
}
14 changes: 14 additions & 0 deletions BlogWriter.Web.Tests/HelpDialogTestHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Bunit;
using Microsoft.JSInterop;

namespace BlogWriter.Web.Tests;

internal static class HelpDialogTestHelpers
{
public static void AllowDialogOpen(BunitContext context) =>
context.JSInterop.SetupVoid("blogWriterDialog.show", _ => true);

public static void FailClipboardCopy(BunitContext context) =>
context.JSInterop.SetupVoid("navigator.clipboard.writeText", _ => true)
.SetException(new JSException("Clipboard permission denied."));
}
14 changes: 7 additions & 7 deletions BlogWriter.Web.Tests/HomePageTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public sealed class HomePageTests : BunitContext
public HomePageTests() => JSInterop.Mode = JSRuntimeMode.Loose;

[Fact]
public void Home_RendersWritingWorkspaceAndFourCommands()
public void Home_RendersWritingWorkspaceAndFiveCommands()
{
BlogWorkspaceService workspace = RegisterWorkspace();

Expand All @@ -21,7 +21,7 @@ public void Home_RendersWritingWorkspaceAndFourCommands()
Assert.NotNull(cut.Find("#revision-prompt"));
Assert.NotNull(cut.Find("[aria-labelledby='draft-heading']"));
Assert.NotNull(cut.Find("[aria-labelledby='review-heading']"));
Assert.Equal(["New", "List", "Revise", "Quit"],
Assert.Equal(["New", "List", "Revise", "Quit", "?"],
cut.FindAll(".command-bar button").Select(button => button.TextContent.Trim()).ToArray());
Assert.True(cut.Find("button[data-command='revise']").HasAttribute("disabled"));
Assert.False(workspace.State.IsSelectionVisible);
Expand Down Expand Up @@ -53,8 +53,9 @@ public void Home_ShowsNumberInputAndEnablesReviseForNonEmptyList()
cut.Find("button[data-command='list']").Click();
cut.WaitForAssertion(() =>
{
Assert.NotNull(cut.Find("#session-number"));
Assert.NotNull(cut.Find("#command-session-number"));
Assert.False(cut.Find("button[data-command='revise']").HasAttribute("disabled"));
Assert.False(cut.Find("#revision-prompt").HasAttribute("disabled"));
Assert.Contains("[1]", cut.Find(".session-list").TextContent);
});
}
Expand Down Expand Up @@ -88,6 +89,7 @@ public void Home_KeepsNewListAndQuitEnabledWhileReviseIsConditional()
Assert.False(cut.Find("button[data-command='list']").HasAttribute("disabled"));
Assert.False(cut.Find("button[data-command='quit']").HasAttribute("disabled"));
Assert.True(cut.Find("button[data-command='revise']").HasAttribute("disabled"));
Assert.True(cut.Find("#revision-prompt").HasAttribute("disabled"));
}

[Fact]
Expand Down Expand Up @@ -151,11 +153,9 @@ public void Home_ValidSelectionLoadsSavedSession()
]);
IRenderedComponent<Home> cut = Render<Home>();
cut.Find("button[data-command='list']").Click();
cut.WaitForElement("#session-number").Input("1");
cut.WaitForElement("#command-session-number").Change("1");

cut.Find("button[data-command='revise']").Click();

cut.WaitForAssertion(() => Assert.Equal("loaded", workspace.State.Draft));
cut.WaitForAssertion(() => Assert.Equal("draft", workspace.State.Draft));
}

[Fact]
Expand Down
26 changes: 26 additions & 0 deletions BlogWriter.Web.Tests/ListLauncherTestHelpers.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
namespace BlogWriter.Web.Tests;

internal static class ListLauncherTestHelpers
{
public static BlogSessionSummary Summary(string task) =>
new(Guid.NewGuid().ToString("N"), task, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow);

public static BlogSession Session(
string mainTask,
string currentSubTask = "",
string draft = "draft",
string review = "review") => new()
{
Id = Guid.NewGuid().ToString("N"),
OwnerId = "owner",
CreatedAt = DateTimeOffset.UtcNow,
UpdatedAt = DateTimeOffset.UtcNow,
State = new ResearchState
{
MainTask = mainTask,
CurrentSubTask = currentSubTask,
Draft = draft,
ReviewNotes = review,
},
};
}
45 changes: 45 additions & 0 deletions BlogWriter.Web.Tests/RunCommandDialogTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
using Bunit;
using BlogWriter.Web.Components;

namespace BlogWriter.Web.Tests;

public sealed class RunCommandDialogTests : BunitContext
{
public RunCommandDialogTests() => JSInterop.Mode = JSRuntimeMode.Loose;

[Fact]
public void Dialog_ShowsExactRunCommandAndAccessibleModal()
{
HelpDialogTestHelpers.AllowDialogOpen(this);
IRenderedComponent<RunCommandDialog> cut = Render<RunCommandDialog>();

Assert.Equal(RunCommandDialog.CommandText, cut.Find(".run-command-text").TextContent);
Assert.Equal("dialog", cut.Find("dialog").GetAttribute("role"));
Assert.Equal("true", cut.Find("dialog").GetAttribute("aria-modal"));
Assert.Equal("Copy", cut.Find(".dialog-actions button").TextContent.Trim());
}

[Fact]
public void Dialog_CopyReportsSuccess()
{
HelpDialogTestHelpers.AllowDialogOpen(this);
IRenderedComponent<RunCommandDialog> cut = Render<RunCommandDialog>();

cut.Find(".dialog-actions button").Click();

Assert.Contains("Command copied", cut.Markup);
}

[Fact]
public void Dialog_CopyFailureKeepsCommandVisibleAndReportsFailure()
{
HelpDialogTestHelpers.AllowDialogOpen(this);
HelpDialogTestHelpers.FailClipboardCopy(this);
IRenderedComponent<RunCommandDialog> cut = Render<RunCommandDialog>();

cut.Find(".dialog-actions button").Click();

Assert.Contains(RunCommandDialog.CommandText, cut.Markup);
Assert.Contains("Copying was not available", cut.Markup);
}
}
14 changes: 14 additions & 0 deletions BlogWriter.Web.Tests/WorkflowLogTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,18 @@ public void WorkflowLog_RendersOrderedTextEntries()
Assert.Equal(["Progress: started", "Failure: <failed>"], cut.FindAll("li").Select(item => item.TextContent).ToArray());
Assert.Contains("&lt;failed&gt;", cut.Markup);
}

[Fact]
public void WorkflowLog_RetainsOlderEntriesForScrolling()
{
IReadOnlyList<WorkflowLogEntry> entries = Enumerable.Range(1, 5)
.Select(index => new WorkflowLogEntry($"entry {index}", WorkflowOutputOutcome.Progress))
.ToList();

IRenderedComponent<WorkflowLog> cut = Render<WorkflowLog>(parameters => parameters
.Add(component => component.Entries, entries));

Assert.Equal(5, cut.FindAll("li").Count);
Assert.Contains("workflow-log", cut.Find("section").ClassList);
}
}
Loading
Loading