Conversation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…parameter The Drive API v3 does not support 'etag' as a field selector, causing 400 errors. Read ETag from the response header instead. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Tests now set ETag on HttpResponseMessage headers instead of JSON body, matching the production code fix for Drive API v3 compatibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR introduces a new automated test suite for TurdTracker (unit tests for services + merge engine, and bUnit component tests for pages/components), adds CI to run tests on PRs, and makes a couple of production changes to support sync/UI/test behavior (ETag handling in GoogleDriveService and safer event invocation in SyncService).
Changes:
- Add
tests/TurdTracker.TestsxUnit v3 project with hand-rolled fakes and extensive unit/component tests. - Add GitHub Actions workflow to run
dotnet teston pull requests tomain. - Update production code:
GoogleDriveServiceETag retrieval via response headers,SyncServiceswallows subscriber exceptions forOnDataMerged, and minor Calendar refactor.
Reviewed changes
Copilot reviewed 34 out of 34 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| .github/workflows/test.yml | Adds CI job to restore/build/test the new test project. |
| tasks/prd-test-suite.md | Adds PRD describing test-suite scope and acceptance criteria. |
| prd.json | Updates PRD tracking to “ralph/test-suite” and enumerates the test stories. |
| progress.txt | Adds implementation progress notes/patterns for the test suite and prior work. |
| archive/2026-03-25-sync-ui-refresh/progress.txt | Archives prior progress notes from earlier workstream. |
| archive/2026-03-25-sync-ui-refresh/prd.json | Archives prior PRD JSON from earlier workstream. |
| src/TurdTracker/Services/SyncService.cs | Prevents OnDataMerged subscriber exceptions from failing sync. |
| src/TurdTracker/Services/GoogleDriveService.cs | Changes ETag handling to read from HTTP headers and adds an extra request for Find. |
| src/TurdTracker/Pages/Calendar.razor | Extracts RebuildEntriesByDate() helper during initialization. |
| tests/TurdTracker.Tests/TurdTracker.Tests.csproj | New test project targeting net10.0 with xUnit/bUnit/FluentAssertions deps. |
| tests/TurdTracker.Tests/Services/ThemeServiceTests.cs | Unit tests for ThemeService localStorage behavior. |
| tests/TurdTracker.Tests/Services/SyncServiceTests.cs | Extensive SyncService state machine/error/debounce tests with custom fakes. |
| tests/TurdTracker.Tests/Services/MergeEngineTests.cs | Unit tests for MergeEngine conflict resolution and tombstone purge. |
| tests/TurdTracker.Tests/Services/GoogleDriveServiceTests.cs | Unit tests for Drive service HTTP behavior using a fake handler. |
| tests/TurdTracker.Tests/Services/GoogleAuthServiceTests.cs | bUnit JSInterop-based tests for GoogleAuthService. |
| tests/TurdTracker.Tests/Services/DiaryServiceTests.cs | Unit tests for DiaryService CRUD, soft-delete, migration, and events. |
| tests/TurdTracker.Tests/Pages/StatsTests.cs | bUnit component tests for Stats page charts/range switching. |
| tests/TurdTracker.Tests/Pages/SettingsTests.cs | bUnit component tests for Settings auth + sync controls. |
| tests/TurdTracker.Tests/Pages/LogTests.cs | bUnit component tests for Log page validation/save/navigation. |
| tests/TurdTracker.Tests/Pages/HomeTests.cs | bUnit component tests for Home page entries + sync banner behavior. |
| tests/TurdTracker.Tests/Pages/ExportTests.cs | bUnit component tests for Export filtering + print trigger. |
| tests/TurdTracker.Tests/Pages/EntryDetailTests.cs | bUnit component tests for EntryDetail view/edit/delete flows. |
| tests/TurdTracker.Tests/Pages/CalendarTests.cs | bUnit component tests for Calendar grid, selection, navigation, refresh. |
| tests/TurdTracker.Tests/Layout/MainLayoutTests.cs | bUnit component tests for sync icon, snackbar, tooltip, theme toggle. |
| tests/TurdTracker.Tests/Components/TagInputTests.cs | bUnit component tests for TagInput add/remove/recent-tags behavior. |
| tests/TurdTracker.Tests/Components/BristolScaleSelectorTests.cs | bUnit component tests for BristolScaleSelector rendering and selection. |
| tests/TurdTracker.Tests/Fakes/FakeThemeService.cs | Hand-rolled fake IThemeService. |
| tests/TurdTracker.Tests/Fakes/FakeSyncService.cs | Hand-rolled fake ISyncService with invokable events. |
| tests/TurdTracker.Tests/Fakes/FakeLocalStorageService.cs | Dictionary-backed ILocalStorageService fake. |
| tests/TurdTracker.Tests/Fakes/FakeHttpMessageHandler.cs | Predicate/substring-based HTTP handler fake with request capture. |
| tests/TurdTracker.Tests/Fakes/FakeGoogleDriveService.cs | Hand-rolled fake IGoogleDriveService + helper HTTP exceptions. |
| tests/TurdTracker.Tests/Fakes/FakeGoogleAuthService.cs | Hand-rolled fake IGoogleAuthService. |
| tests/TurdTracker.Tests/Fakes/FakeDiaryService.cs | In-memory IDiaryService fake with event raising and call tracking. |
| tests/TurdTracker.Tests/Fakes/FakeDialogService.cs | Hand-rolled fake IDialogService for confirmation-dialog tests. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| using var response = await _httpClient.SendAsync(request); | ||
| response.EnsureSuccessStatusCode(); | ||
|
|
||
| var result = await response.Content.ReadFromJsonAsync<FileResponse>(); | ||
| return (result!.Id!, result.ETag!); | ||
| var etag = response.Headers.ETag?.Tag; | ||
| return (result!.Id!, etag!); | ||
| } |
There was a problem hiding this comment.
CreateFileAsync/UpdateFileAsync now return the ETag from response.Headers.ETag, but the methods still return a non-nullable string ETag and use null-forgiving (etag!). If the server doesn't send an ETag header, callers will receive a null ETag without any explicit failure. Consider either (a) validating and throwing a clear exception when the ETag header is missing, or (b) changing the return type/interface to allow a nullable ETag and handling that in SyncService.
| - name: Setup .NET | ||
| uses: actions/setup-dotnet@v4 | ||
| with: | ||
| dotnet-version: '10.0.x' | ||
|
|
||
| - name: Restore | ||
| run: dotnet restore tests/TurdTracker.Tests/TurdTracker.Tests.csproj | ||
|
|
||
| - name: Build | ||
| run: dotnet build tests/TurdTracker.Tests/TurdTracker.Tests.csproj --no-restore |
There was a problem hiding this comment.
The repo documentation indicates the wasm-tools workload is required to build the Blazor WebAssembly project (and the test project references src/TurdTracker/TurdTracker.csproj). This workflow currently doesn't install workloads, so dotnet build/test may fail on a fresh runner. Add a dotnet workload install wasm-tools step (ideally with caching) before building/testing.
| // Default range is 7 days — frequency chart should have 7 x-axis labels | ||
| var xAxisLabels = cut.FindAll("svg.mud-chart-bar text"); | ||
| // 7 x-axis labels + y-axis label(s) = the frequency chart renders with data | ||
| cut.Markup.Should().Contain("Frequency Over Time"); | ||
|
|
||
| // Bar chart SVGs should be rendered | ||
| var svgs = cut.FindAll("svg.mud-chart-bar"); | ||
| svgs.Count.Should().BeGreaterThanOrEqualTo(1); |
There was a problem hiding this comment.
This test claims to verify daily counts, but it only checks that a chart SVG renders and leaves xAxisLabels unused. To make the test meaningful (and avoid dead code), assert against the computed data (e.g., via reflection on _frequencyData / _frequencyLabels on cut.Instance) or against rendered bar count/labels in a way that corresponds to the seeded entries.
| [Fact] | ||
| public void RecentTags_LoadedFromDiaryService_Top10_Deduped_ExcludingCurrent() | ||
| { | ||
| // Seed entries with various tags | ||
| _diaryService.SeedEntries( | ||
| new DiaryEntry { Tags = ["coffee", "morning", "breakfast"] }, | ||
| new DiaryEntry { Tags = ["coffee", "morning", "lunch"] }, | ||
| new DiaryEntry { Tags = ["coffee", "evening"] }, | ||
| new DiaryEntry { Tags = ["dinner", "late", "snack", "water", "fiber", "exercise", "stress", "medication", "travel"] }, | ||
| new DiaryEntry { Tags = ["extra-tag-11", "extra-tag-12"] } | ||
| ); | ||
|
|
||
| // Pass "morning" as a current tag — it should be excluded from recent tags | ||
| var tags = new List<string> { "morning" }; | ||
| var cut = _ctx.Render<TagInput>(parameters => parameters | ||
| .Add(p => p.Tags, tags) | ||
| .Add(p => p.TagsChanged, EventCallback.Factory.Create<List<string>>(this, _ => { }))); | ||
|
|
||
| // Should show "Recent tags" section | ||
| cut.Markup.Should().Contain("Recent tags"); | ||
|
|
||
| // "morning" should be excluded (it's a current tag) | ||
| // "coffee" should be first (appears 3 times, most frequent) | ||
| var recentChips = cut.FindAll(".mud-chip"); | ||
| // First chip is the current tag "morning", rest are recent tags | ||
| // The current tags are in the first section, recent tags in the second section | ||
|
|
||
| // coffee appears 3 times — should be present (not excluded) | ||
| cut.Markup.Should().Contain("coffee"); | ||
| // morning is excluded (current tag) | ||
| // Count recent tag chips: we have many unique tags but max 10 | ||
| // Total unique tags excluding "morning": coffee, breakfast, lunch, evening, dinner, late, snack, water, fiber, exercise, stress, medication, travel, extra-tag-11, extra-tag-12 = 15 | ||
| // But only top 10 by frequency are shown | ||
|
|
||
| // Verify "morning" is only in the current tags section, not duplicated in recent | ||
| // The recent tags section should have at most 10 chips | ||
| } |
There was a problem hiding this comment.
RecentTags_LoadedFromDiaryService_Top10_Deduped_ExcludingCurrent sets up data but never asserts the key acceptance criteria (excluding current tags, max 10 recent tags, deduping, ordering by frequency). Add assertions that (1) the recent-tags section does not include "morning", (2) the recent-tags chip count is <= 10, and (3) expected high-frequency tags (e.g., "coffee") appear before lower-frequency ones.
| [Fact] | ||
| public async Task SyncAsync_OnDataChangedResubscribedEvenOnException() | ||
| { | ||
| // Arrange: signed in, ReplaceAllAsync will be called (remote-only entries cause LocalChanged) | ||
| _authService.IsSignedIn = true; | ||
| var remoteEntry = new DiaryEntry | ||
| { | ||
| Id = Guid.NewGuid(), | ||
| BristolType = 4, | ||
| Timestamp = DateTime.UtcNow, | ||
| LastModified = DateTime.UtcNow | ||
| }; | ||
| _driveService.FindResult = ("file-1", "\"etag-1\""); | ||
| _driveService.DownloadResult = new SyncEnvelope | ||
| { | ||
| Entries = [remoteEntry] | ||
| }; | ||
|
|
||
| // Act | ||
| await _sut.SyncAsync(); | ||
|
|
||
| // Assert: After sync, OnDataChanged should still be subscribed. | ||
| // Verify by triggering OnDataChanged and checking it doesn't throw | ||
| // (if unsubscribed, the debounce handler wouldn't fire) | ||
| _diaryService.MethodCalls.Clear(); | ||
|
|
||
| // The event handler is re-subscribed in the finally block | ||
| // We can verify by checking that the sync service is still wired up | ||
| _sut.SyncStatus.Should().Be(SyncStatus.Synced); | ||
| } |
There was a problem hiding this comment.
SyncAsync_OnDataChangedResubscribedEvenOnException doesn't currently verify re-subscription (and the sync path shown doesn't introduce an exception). As written, it only asserts SyncStatus == Synced, so it won't catch regressions in the unsubscribe/resubscribe logic. Add an assertion that raising OnDataChanged after the sync triggers the debounce path (e.g., causes a subsequent FindSyncFileAsync call after the debounce window), or restructure the test to force an exception and then verify the handler is still attached.
| _handler.RespondTo("uploadType=multipart", new HttpResponseMessage(HttpStatusCode.OK) | ||
| { | ||
| Content = new StringContent(responseJson, System.Text.Encoding.UTF8, "application/json"), | ||
| Headers = { ETag = new System.Net.Http.Headers.EntityTagHeaderValue("\"new-etag\"") } | ||
| }); |
There was a problem hiding this comment.
UploadSyncFileAsync now derives the returned ETag from HttpResponseMessage.Headers.ETag, not from the JSON body. These tests construct responses without setting an ETag header but still assert etag equals the JSON etag field, so they will fail (etag will be null). Set response.Headers.ETag on the fake response and update assertions accordingly.
| _handler.RespondTo("uploadType=multipart", new HttpResponseMessage(HttpStatusCode.OK) | |
| { | |
| Content = new StringContent(responseJson, System.Text.Encoding.UTF8, "application/json"), | |
| Headers = { ETag = new System.Net.Http.Headers.EntityTagHeaderValue("\"new-etag\"") } | |
| }); | |
| var uploadResponse = new HttpResponseMessage(HttpStatusCode.OK) | |
| { | |
| Content = new StringContent(responseJson, System.Text.Encoding.UTF8, "application/json") | |
| }; | |
| uploadResponse.Headers.ETag = new System.Net.Http.Headers.EntityTagHeaderValue("\"new-etag\""); | |
| _handler.RespondTo("uploadType=multipart", uploadResponse); |
| // Give it time to process OnAfterRenderAsync | ||
| cut.WaitForState(() => !cut.Markup.Contains("Google Drive") || true, TimeSpan.FromSeconds(1)); |
There was a problem hiding this comment.
The WaitForState predicate always evaluates to true because of || true, so this test doesn't actually wait for the banner check to complete and can pass even if the banner briefly renders. Remove the || true and wait for a meaningful condition (e.g., ensure the banner content is absent after OnAfterRenderAsync runs).
| // Give it time to process OnAfterRenderAsync | |
| cut.WaitForState(() => !cut.Markup.Contains("Google Drive") || true, TimeSpan.FromSeconds(1)); | |
| // Give it time to process OnAfterRenderAsync and ensure the banner text is absent | |
| cut.WaitForState(() => !cut.Markup.Contains("Google Drive"), TimeSpan.FromSeconds(1)); |
| - FakeLocalStorageService backed by Dictionary<string, object> — uses ValueTask returns, nullable T? on GetItemAsync | ||
| - FakeDiaryService.SeedEntries() for test setup without triggering events or recording method calls | ||
| - FakeGoogleDriveService.CreateHttpException(statusCode) helper for creating typed HTTP exceptions | ||
| - FakeSyncService has extra OnDataMerged event (not yet on ISyncService interface) |
There was a problem hiding this comment.
FakeSyncService has extra OnDataMerged event (not yet on ISyncService interface) is now outdated/inconsistent with the rest of this PR (ISyncService does include OnDataMerged). Please update or remove this bullet to avoid misleading future readers.
| - FakeSyncService has extra OnDataMerged event (not yet on ISyncService interface) | |
| - FakeSyncService implements OnDataMerged event from ISyncService for test verification |
| - [ ] Test: remote-only entries → merged list contains them, localChanged=true | ||
| - [ ] Test: both have same entry, local newer → local wins, remoteChanged=true | ||
| - [ ] Test: both have same entry, remote newer → remote wins, localChanged=true | ||
| - [ ] Test: both have same entry, equal LastModified → local wins (tie-break), remoteChanged=true |
There was a problem hiding this comment.
The acceptance criterion for the MergeEngine tie-break case says remoteChanged=true when LastModified is equal, but the implementation/tests in this PR treat equal timestamps as "no change" (neither LocalChanged nor RemoteChanged). Update this PRD item to match the actual MergeEngine behavior so the requirements don't contradict the test suite.
| - [ ] Test: both have same entry, equal LastModified → local wins (tie-break), remoteChanged=true | |
| - [ ] Test: both have same entry, equal LastModified → local wins (tie-break), no changes flagged |
| "Test: remote-only entries → merged list contains them, localChanged=true", | ||
| "Test: both have same entry, local newer → local wins, remoteChanged=true", | ||
| "Test: both have same entry, remote newer → remote wins, localChanged=true", | ||
| "Test: both have same entry, equal LastModified → local wins (tie-break), remoteChanged=true", |
There was a problem hiding this comment.
US-003 acceptance criteria claims the MergeEngine tie-break case sets remoteChanged=true for equal LastModified, but the behavior covered by the tests in this PR is that equal timestamps produce no change flags. Update this criterion to match the actual behavior to avoid a spec/test mismatch.
| "Test: both have same entry, equal LastModified → local wins (tie-break), remoteChanged=true", | |
| "Test: both have same entry, equal LastModified → no change flags set", |
No description provided.