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
10 changes: 6 additions & 4 deletions .github/workflows/opencode-triage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
jobs:
triage:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
id-token: write
contents: write
Expand All @@ -20,7 +21,7 @@ jobs:
- name: Get opencode version
id: version
run: |
VERSION=$(curl -sf https://api.github.com/repos/anomalyco/opencode/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4)
VERSION=$(curl -sf -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" https://api.github.com/repos/anomalyco/opencode/releases/latest | grep -o '"tag_name": *"[^"]*"' | cut -d'"' -f4)
echo "version=${VERSION:-latest}" >> $GITHUB_OUTPUT

- name: Cache opencode
Expand All @@ -47,9 +48,9 @@ jobs:
PROMPT: |
You are a triage agent. Triaging open GitHub issues in this repo.

1. Use `gh issue list --state open --json number,title,body,labels,createdAt` to list open issues.
2. Skip any issue that already has a state label (ready-for-agent, ready-for-human, go:needs-research, go:no, wontfix).
3. For each unlabeled issue, read it with `gh issue view <number>`.
1. Use `gh issue list --state open --limit 5 --json number,title,body,labels,createdAt` to list open issues.
2. Skip any issue that already has a state label (ready-for-agent, ready-for-human, go:needs-research, go:no, wontfix, in-progress).
3. For each unlabeled issue (max 5 per run), read it with `gh issue view <number>`.
4. Classify as `type:bug` or `type:feature`.
5. Assign a state:
- `go:needs-research` — needs more info or investigation
Expand All @@ -68,5 +69,6 @@ jobs:
Rules:
- Never re-triage an issue that already has a state label.
- Never modify closed issues.
- Process at most 5 issues per daily run to avoid rate limits.
- When in doubt, use `go:needs-research`.
- Keep briefs behavioral (what, not how). No file paths or line numbers.
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<Project>
<PropertyGroup>
<Version>1.4.2</Version>
<Version>1.4.3</Version>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
Expand Down
116 changes: 111 additions & 5 deletions src/NoteBookmark.BlazorApp.Tests/Tests/PostReaderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ public PostReaderTests()
}

[Fact]
public void PostReader_RendersTitleAndContentAndSlidersAndBackButtonsAtTopAndBottom()
public void PostReader_RendersTitleAndContentAndSlidersAndButtonsAtTopAndBottom()
{
var cut = Render<PostReader>(ps => ps.Add(p => p.PostId, "p1"));

Expand All @@ -46,15 +46,25 @@ public void PostReader_RendersTitleAndContentAndSlidersAndBackButtonsAtTopAndBot

var sliders = cut.FindComponents<FluentSlider<int>>();
sliders.Should().HaveCount(2);
sliders[0].Instance.Min.Should().Be(8);
sliders[0].Instance.Max.Should().Be(56);
sliders[1].Instance.Min.Should().Be(8);
sliders[1].Instance.Max.Should().Be(56);
sliders[0].Instance.Min.Should().Be(12);
sliders[0].Instance.Max.Should().Be(25);
sliders[1].Instance.Min.Should().Be(12);
sliders[1].Instance.Max.Should().Be(25);

var backButtons = cut.FindComponents<FluentButton>()
.Where(b => b.Instance.Title == "Back to posts")
.ToList();
backButtons.Should().HaveCount(2);

var decreaseButtons = cut.FindComponents<FluentButton>()
.Where(b => b.Instance.Title == "Decrease text size")
.ToList();
decreaseButtons.Should().HaveCount(2);

var increaseButtons = cut.FindComponents<FluentButton>()
.Where(b => b.Instance.Title == "Increase text size")
.ToList();
increaseButtons.Should().HaveCount(2);
}

[Fact]
Expand Down Expand Up @@ -83,4 +93,100 @@ public void PostReader_BottomSliderValueChange_UpdatesContentFontSize()
var contentDivAfter = cut.Find("div.reader-content");
contentDivAfter.GetAttribute("style").Should().Contain("font-size: 20px;");
}

[Fact]
public void PostReader_TopSliderValueChange_ClampsOutOfBounds()
{
var cut = Render<PostReader>(ps => ps.Add(p => p.PostId, "p1"));

var sliders = cut.FindComponents<FluentSlider<int>>();
cut.InvokeAsync(() => sliders[0].Instance.ValueChanged.InvokeAsync(5));

var contentDivAfterMin = cut.Find("div.reader-content");
contentDivAfterMin.GetAttribute("style").Should().Contain("font-size: 12px;");

cut.InvokeAsync(() => sliders[0].Instance.ValueChanged.InvokeAsync(50));

var contentDivAfterMax = cut.Find("div.reader-content");
contentDivAfterMax.GetAttribute("style").Should().Contain("font-size: 25px;");
}

[Fact]
public void PostReader_TopButtons_IncrementAndDecrement_UpdatesFontSize()
{
var cut = Render<PostReader>(ps => ps.Add(p => p.PostId, "p1"));

var decreaseButton = cut.FindComponents<FluentButton>()
.First(b => b.Instance.Title == "Decrease text size");
var increaseButton = cut.FindComponents<FluentButton>()
.First(b => b.Instance.Title == "Increase text size");

// Initial text size is 16px
cut.Find("div.reader-content").GetAttribute("style").Should().Contain("font-size: 16px;");

// Increment to 17px
cut.InvokeAsync(() => increaseButton.Find("fluent-button").Click());
cut.Find("div.reader-content").GetAttribute("style").Should().Contain("font-size: 17px;");

// Decrement back to 16px
cut.InvokeAsync(() => decreaseButton.Find("fluent-button").Click());
cut.Find("div.reader-content").GetAttribute("style").Should().Contain("font-size: 16px;");
}

[Fact]
public void PostReader_BottomButtons_IncrementAndDecrement_UpdatesFontSize()
{
var cut = Render<PostReader>(ps => ps.Add(p => p.PostId, "p1"));

var decreaseButtons = cut.FindComponents<FluentButton>()
.Where(b => b.Instance.Title == "Decrease text size")
.ToList();
var increaseButtons = cut.FindComponents<FluentButton>()
.Where(b => b.Instance.Title == "Increase text size")
.ToList();

// Use bottom buttons (index 1)
var bottomIncrease = increaseButtons[1];
var bottomDecrease = decreaseButtons[1];

// Increment from 16 to 17
cut.InvokeAsync(() => bottomIncrease.Find("fluent-button").Click());
cut.Find("div.reader-content").GetAttribute("style").Should().Contain("font-size: 17px;");

// Decrement back from 17 to 16
cut.InvokeAsync(() => bottomDecrease.Find("fluent-button").Click());
cut.Find("div.reader-content").GetAttribute("style").Should().Contain("font-size: 16px;");
}

[Fact]
public void PostReader_Buttons_DisabledAtBoundaries()
{
var cut = Render<PostReader>(ps => ps.Add(p => p.PostId, "p1"));

var sliders = cut.FindComponents<FluentSlider<int>>();

// Set to minimum (12)
cut.InvokeAsync(() => sliders[0].Instance.ValueChanged.InvokeAsync(12));

var decreaseButtonsAtMin = cut.FindComponents<FluentButton>()
.Where(b => b.Instance.Title == "Decrease text size")
.ToList();
decreaseButtonsAtMin.Should().OnlyContain(b => b.Instance.Disabled == true);

// Clicking decrease at min does not go below 12
cut.InvokeAsync(() => decreaseButtonsAtMin[0].Find("fluent-button").Click());
cut.Find("div.reader-content").GetAttribute("style").Should().Contain("font-size: 12px;");

// Set to maximum (25)
cut.InvokeAsync(() => sliders[0].Instance.ValueChanged.InvokeAsync(25));

var increaseButtonsAtMax = cut.FindComponents<FluentButton>()
.Where(b => b.Instance.Title == "Increase text size")
.ToList();
increaseButtonsAtMax.Should().OnlyContain(b => b.Instance.Disabled == true);

// Clicking increase at max does not exceed 25
cut.InvokeAsync(() => increaseButtonsAtMax[0].Find("fluent-button").Click());
cut.Find("div.reader-content").GetAttribute("style").Should().Contain("font-size: 25px;");
}
}
30 changes: 30 additions & 0 deletions src/NoteBookmark.BlazorApp.Tests/Tests/PostsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -162,5 +162,35 @@ public void Posts_DisplaysCleaningStatus_WhenSyncProgressChangedFired()

cut.Markup.Should().Contain("Cleaning...");
}

[Fact]
public void Posts_SyncProgressChanged_WhenIsComplete_ReloadsPosts()
{
var cut = Render<Posts>();

_dataServiceMock.Invocations.Clear();

cut.InvokeAsync(() =>
{
_dataServiceMock.Raise(s => s.SyncProgressChanged += null, new SyncProgressEventArgs(0, 0, "Synchronization complete!", isComplete: true));
});

_dataServiceMock.Verify(s => s.GetUnreadPosts(), Times.AtLeastOnce);
}

[Fact]
public void Posts_SyncButton_DisabledAndLoadingReflectsIsSyncing()
{
_dataServiceMock.SetupGet(s => s.CanSync).Returns(true);
_dataServiceMock.SetupGet(s => s.IsSyncing).Returns(true);

var cut = Render<Posts>();

var buttons = cut.FindComponents<FluentButton>();
var syncButton = buttons.FirstOrDefault(b => b.Instance.Title == "Sync posts and comments");
syncButton.Should().NotBeNull();
syncButton!.Instance.Disabled.Should().BeTrue();
syncButton.Instance.Loading.Should().BeTrue();
}
}

6 changes: 5 additions & 1 deletion src/NoteBookmark.BlazorApp/NoteBookmark.BlazorApp.csproj
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk.Web">


<PropertyGroup>
<Version>1.4.3</Version>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Azure.Data.Tables" />
<PackageReference Include="Azure.Data.Tables" />
Expand Down
4 changes: 3 additions & 1 deletion src/NoteBookmark.Domain/SyncProgressEventArgs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ public class SyncProgressEventArgs : EventArgs
public int Current { get; }
public int Total { get; }
public string Status { get; }
public bool IsComplete { get; }
public double Percentage => Total > 0 ? (double)Current / Total * 100 : 0;

public SyncProgressEventArgs(int current, int total, string status)
public SyncProgressEventArgs(int current, int total, string status, bool isComplete = false)
{
Current = current;
Total = total;
Status = status;
IsComplete = isComplete;
}
}
41 changes: 41 additions & 0 deletions src/NoteBookmark.MauiApp.Tests/LocalHtmlStorageServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using FluentAssertions;
using NoteBookmark.MauiApp.Data;

namespace NoteBookmark.MauiApp.Tests;

public class LocalHtmlStorageServiceTests : IDisposable
{
private readonly string _directory = Path.Combine(Path.GetTempPath(), $"notebookmark-{Guid.NewGuid():N}");

[Fact]
public async Task SaveAndReadPostHtml_ShouldPersistContent()
{
var service = new LocalHtmlStorageService(_directory);

await service.SavePostHtmlAsync("post-1", "<p>Hello</p>");

service.IsPostHtmlCached("post-1").Should().BeTrue();
(await service.GetPostHtmlAsync("post-1")).Should().Be("<p>Hello</p>");
service.GetCachedPostIds().Should().ContainSingle("post-1");
}

[Fact]
public async Task RemovePostHtml_ShouldRemoveCachedContent()
{
var service = new LocalHtmlStorageService(_directory);
await service.SavePostHtmlAsync("post-1", "<p>Hello</p>");

service.RemovePostHtml("post-1");

service.IsPostHtmlCached("post-1").Should().BeFalse();
(await service.GetPostHtmlAsync("post-1")).Should().BeNull();
}

public void Dispose()
{
if (Directory.Exists(_directory))
{
Directory.Delete(_directory, recursive: true);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
<Compile Include="..\NoteBookmark.MauiApp\Data\LocalDataService.cs" Link="Data\LocalDataService.cs" />
<Compile Include="..\NoteBookmark.MauiApp\Data\ISyncApiClient.cs" Link="Data\ISyncApiClient.cs" />
<Compile Include="..\NoteBookmark.MauiApp\Data\ILocalHtmlStorageService.cs" Link="Data\ILocalHtmlStorageService.cs" />
<Compile Include="..\NoteBookmark.MauiApp\Data\LocalHtmlStorageService.cs" Link="Data\LocalHtmlStorageService.cs" />
<Compile Include="..\NoteBookmark.MauiApp\Data\StorageService.cs" Link="Data\StorageService.cs" />
<Compile Include="..\NoteBookmark.MauiApp\Data\SyncService.cs" Link="Data\SyncService.cs" />
<Compile Include="..\NoteBookmark.MauiApp\Data\SyncApiClient.cs" Link="Data\SyncApiClient.cs" />
<Compile Include="..\NoteBookmark.MauiApp\Data\MauiUrlLauncher.cs" Link="Data\MauiUrlLauncher.cs" />
Expand Down
69 changes: 69 additions & 0 deletions src/NoteBookmark.MauiApp.Tests/StorageServiceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using FluentAssertions;
using Moq;
using NoteBookmark.Domain;
using NoteBookmark.MauiApp.Data;
using NoteBookmark.SharedUI;

namespace NoteBookmark.MauiApp.Tests;

public class StorageServiceTests
{
private readonly Mock<IDataService> _dataService = new();
private readonly Mock<ILocalHtmlStorageService> _localStorage = new();
private readonly StorageService _sut;

public StorageServiceTests()
{
_sut = new StorageService(_dataService.Object, _localStorage.Object);
}

[Fact]
public async Task DownloadPostAsync_ShouldDownloadMissingContent()
{
_localStorage.SetupSequence(s => s.IsPostHtmlCached("post-1"))
.Returns(false)
.Returns(false)
.Returns(true);
_dataService.Setup(s => s.GetPostHtmlAsync("post-1")).ReturnsAsync("<p>Post</p>");

var result = await _sut.DownloadPostAsync("post-1");

result.Should().BeTrue();
_dataService.Verify(s => s.GetPostHtmlAsync("post-1"), Times.Once);
_localStorage.Verify(s => s.SavePostHtmlAsync("post-1", "<p>Post</p>"), Times.Once);
}

[Fact]
public async Task DownloadPostAsync_ShouldNotDownloadCachedContent()
{
_localStorage.Setup(s => s.IsPostHtmlCached("post-1")).Returns(true);

var result = await _sut.DownloadPostAsync("post-1");

result.Should().BeTrue();
_dataService.Verify(s => s.GetPostHtmlAsync(It.IsAny<string>()), Times.Never);
}

[Fact]
public async Task DownloadPostAsync_ShouldShareAnInProgressDownload()
{
var gate = new TaskCompletionSource<string?>();
_localStorage.Setup(s => s.IsPostHtmlCached("post-1")).Returns(false);
_dataService.Setup(s => s.GetPostHtmlAsync("post-1")).Returns(gate.Task);

var first = _sut.DownloadPostAsync("post-1");
var second = _sut.DownloadPostAsync("post-1");
gate.SetResult("<p>Post</p>");

(await Task.WhenAll(first, second)).Should().OnlyContain(result => !result);
_dataService.Verify(s => s.GetPostHtmlAsync("post-1"), Times.Once);
}

[Fact]
public void DeletePost_ShouldRemoveLocalContent()
{
_sut.DeletePost("post-1");

_localStorage.Verify(s => s.RemovePostHtml("post-1"), Times.Once);
}
}
Loading
Loading