Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
05356d8
Refactor Publication: replace DateTimeOffset? DefaultedAt with bool I…
claude Mar 3, 2026
ce0aa43
Rename "default publication" to "main publication" for consistency
claude Mar 25, 2026
d0d7e62
Add Publication.IsMain migration; bump dotnet-ef to EF Core 10
myieye Jun 8, 2026
4d2cc7d
Enforce single main-publication invariant and converge merged duplicates
myieye Jun 8, 2026
0f182c8
Extract renamed "main publication" i18n string
myieye Jun 8, 2026
8441ead
Fix viewer type/lint errors from the IsMain refactor
myieye Jun 8, 2026
405a71d
Remove redundant IsMain stripping when syncing publications to FwData
myieye Jun 8, 2026
38958d8
Fix FW Lite tests for the auto-added main publication
myieye Jun 8, 2026
b821804
Fix dry-run sync failure when adding the main publication
myieye Jun 8, 2026
f44b818
Regenerate FW Lite verified snapshots for Publication.IsMain
myieye Jun 8, 2026
ae4d736
Make main-publication auto-add opt-in; converge duplicate mains by de…
myieye Jun 17, 2026
347d2c8
Clarify FwLite conformance-test guidance; trim i18n-completeness agen…
myieye Jun 17, 2026
bf7eb5a
Use the validation wrapper in MiniLcm API tests
myieye Jun 18, 2026
8d16e7f
Refine IsMain: single-main guard in the validation wrapper; require C…
myieye Jun 18, 2026
cb5ee3c
Validate the publication before/after update in the wrapper
myieye Jun 18, 2026
e38cb86
Rename createEntryOptions helper to satisfy the naming-convention lint
myieye Jun 18, 2026
5b22963
Preserve dated sena-3 snapshot; capture IsMain format as new dated file
myieye Jun 18, 2026
4ed9776
Split sena-3 snapshot test into a latest round-trip target and dated …
myieye Jun 18, 2026
75abfd4
Re-extract i18n to drop the orphaned "main publication is always incl…
myieye Jun 18, 2026
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
39 changes: 10 additions & 29 deletions .claude/agents/i18n-completeness.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: i18n-completeness
description: Verify the i18n extraction workflow completed end-to-end when new user-visible strings are added in frontend/viewer/**. Extraction freshness, .po file consistency, context comments per I18N_CONTEXT_GUIDE.md. Distinct from viewer-watcher's parser-awareness check.
description: Verify the i18n extraction workflow completed end-to-end when new user-visible strings are added in frontend/viewer/**. Extraction freshness and .po file consistency. Distinct from viewer-watcher's parser-awareness check.
tools: Bash, Read, Grep, Glob
model: sonnet
---
Expand All @@ -19,9 +19,11 @@ detected; nothing to verify" and stop.

## Baseline

Read `frontend/viewer/I18N_CONTEXT_GUIDE.md` for the context-comment
expectations. `frontend/viewer/AGENTS.md` §i18n has the extraction
workflow.
`frontend/viewer/AGENTS.md` §i18n has the extraction workflow.

Translator-context (`#.`) comments are **out of scope** — they're owned
by the `crowdin-merge` skill (via the `i18n-context-writer` agent). Do
not check for, suggest, or flag them, even informationally.

## Standards

Expand All @@ -38,26 +40,7 @@ For each new `$t\`...\`` or `msg\`...\`` introduced by the diff:
Grep `frontend/viewer/src/locales/en.po` for each new string's
msgid form.

### B. Context comments

`I18N_CONTEXT_GUIDE.md` requires context comments on strings that:
- Differ between Classic and Lite views.
- Are ambiguous in isolation ("Open" — a verb? a noun?).
- Contain user-supplied variables.
- Could be confused with similar terms elsewhere.

For each new string, ask: does it need a context comment? If yes and
absent → 💭 nit; suggest one.

Context-comment syntax in source:
```typescript
// i18n: <context>
$t`Open`
```

Or in `msg\`...\`` calls via the `context` option.

### C. Cross-locale state
### B. Cross-locale state

When extraction runs, it adds the new msgid to all `.po` files (as
untranslated). Spot-check:
Expand All @@ -69,14 +52,14 @@ untranslated). Spot-check:
Missing msgid in non-source locales → 💭 nit (Lingui usually syncs them
on next extract; might be a partial run).

### D. Don't translate placeholder strings
### C. Don't translate placeholder strings

If a string only appears in dev / test code (Storybook stories,
`*.test.ts`, dev-only debug routes) → flag as 💭 nit: *"this string is
extracted but only used in dev — wrap in `// i18n-ignore` or scope to
non-extracted context."*

### E. Pluralization & ICU
### D. Pluralization & ICU

Plural forms use Lingui's plural component / API:
```typescript
Expand All @@ -86,7 +69,7 @@ $t`${count, plural, one {# entry} other {# entries}}`
A new user-visible count without pluralization handling → ⚠️ important
*"will read 'You have 1 entries' for count=1"*.

### F. Don't compose translated strings at runtime
### E. Don't compose translated strings at runtime

Even if a translatable phrase looks like literals at extraction time,
concatenating them at runtime breaks grammar in other languages:
Expand All @@ -109,12 +92,10 @@ const label = $t`${noun} modified by ${author}`;
- `src/locales/en.po` — check each new msgid is present.
- `src/locales/*.po` — spot-check other locales were extracted
too.
- `// i18n:` comment density vs new-string density.

## Severity quick map

- New strings without extraction run → ⚠️ important.
- Missing context comment on ambiguous string → 💭 nit.
- Missing plural form on count-bearing string → ⚠️ important.
- Runtime-composed translated string → ⚠️ important.
- Dev-only string extracted → 💭 nit.
Expand Down
2 changes: 1 addition & 1 deletion .config/dotnet-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "9.0.16",
"version": "10.0.8",
"commands": [
"dotnet-ef"
],
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ Key documentation for this project:
### Testing

- ✅ **DO run unit tests via the CLI**, filtered to the tests relevant to your changes (e.g. `dotnet test backend/FwLite/FwLiteShared.Tests --filter "FullyQualifiedName~MyTestClass"`). Verify tests you wrote or changed actually pass before handing work back. Never run whole suites just to "see if anything broke".
- ✅ **`MiniLcm.Tests`, `LcmCrdt.Tests`, `FwDataMiniLcmBridge.Tests` need no infrastructure** — these are the unit/conformance tests for the two `IMiniLcmApi` implementations; run them filtered to your change like any unit test (this is where a `*TestsBase` change should be verified, including its FwData side). The FwData/LCM-backed selections load a real project (a handful of tests = seconds, a whole class = a minute or two), so keep the filter tight; don't reach for the whole class or project.
- ✅ **FwLite integration tests** (e.g. `FwLiteProjectSync.Tests`) need no infrastructure but are slow. Run a **targeted selection** (specific tests, not necessarily whole classes) when you touched critical sync code **and believe the work is finished** — not on every iteration. Waiting on tests burns time; be deliberate about which runs buy real signal.
- ✅ **`backend/Testing` contains unit tests too** — only tests marked `Category=Integration|FlakyIntegration|RequiresDb` (and the `Testing.Browser` namespace) need infrastructure. Its unit tests are fine to run: `task test:unit -- <filter>` excludes those categories for you.
- ✅ **FwLite viewer Playwright tests MAY be run** — they're cheap: `task playwright-test-standalone -- <test-name-filter>` (from `frontend/viewer/`) auto-starts the vite dev server with the in-browser demo project; no lexbox stack, chromium only. Always filter to relevant tests; details in `frontend/viewer/AGENTS.md`.
Expand Down
25 changes: 23 additions & 2 deletions backend/FwLite/FwDataMiniLcmBridge/Api/FwDataMiniLcmApi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -364,12 +364,25 @@ private Publication FromLcmPossibility(ICmPossibility lcmPossibility)
var possibility = new Publication
{
Id = lcmPossibility.Guid,
Name = FromLcmMultiString(lcmPossibility.Name)
Name = FromLcmMultiString(lcmPossibility.Name),
IsMain = lcmPossibility.IsProtected
};

return possibility;
}

// The Main Dictionary is the publication FieldWorks protects from deletion (IsProtected). LibLCM treats anything
// other than exactly one protected publication as an error; we mirror that for >1 but tolerate 0 so a project
// without a Main Dictionary can still create entries (it just won't auto-add one).
private ICmPossibility? FindMainPublication()
{
var protectedPublications = Publications.PossibilitiesOS.Where(pub => pub.IsProtected).ToArray();
if (protectedPublications.Length > 1)
throw new InvalidOperationException(
$"Expected at most one protected (main) publication but found {protectedPublications.Length}.");
return protectedPublications.FirstOrDefault();
}

public Task<Publication> UpdatePublication(Guid id, UpdateObjectInput<Publication> update)
{
var lcmPublication = GetLcmPublication(id);
Expand Down Expand Up @@ -992,8 +1005,16 @@ public Task<int> GetEntryIndex(Guid entryId, string? query = null, IndexQueryOpt

public async Task<Entry> CreateEntry(Entry entry, CreateEntryOptions? options = null)
{
options ??= CreateEntryOptions.Everything;
options ??= CreateEntryOptions.AsIs;
entry.Id = entry.Id == default ? Guid.NewGuid() : entry.Id;
if (options.AutoAddMainPublication)
{
var mainPublication = FindMainPublication();
if (mainPublication is not null && entry.PublishIn.All(pub => pub.Id != mainPublication.Guid))
{
entry.PublishIn.Add(FromLcmPossibility(mainPublication));
}
}
try
{
UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("Create Entry",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ public UpdatePublicationProxy(ICmPossibility lcmPublication, FwDataMiniLcmApi le
_lexboxLcmApi = lexboxLcmApi;
}

public override bool IsMain
{
get => _lcmPublication.IsProtected;
set { } // IsMain is mapped from IsProtected in FW; don't write back
}

public override MultiString Name
{
get => new UpdateMultiStringProxy(_lcmPublication.Name, _lexboxLcmApi);
Expand Down
20 changes: 13 additions & 7 deletions backend/FwLite/FwLiteProjectSync.Tests/EntrySyncTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using MiniLcm.Exceptions;
using MiniLcm.Models;
using MiniLcm.SyncHelpers;
using MiniLcm.Tests;
using MiniLcm.Tests.AutoFakerHelpers;
using Soenneker.Utils.AutoBogus;

Expand Down Expand Up @@ -53,7 +54,10 @@ public abstract class EntrySyncTestsBase(ExtraWritingSystemsSyncFixture fixture)
{
public Task InitializeAsync()
{
Api = GetApi(_fixture);
BaseApi = GetApi(_fixture);
// Mirror production sync (CrdtFwdataProjectSyncService): validation only, no normalization,
// because the data is already normalized on both sides.
Api = TestMiniLcmWrappers.CreateValidationFactory().Create(BaseApi);
return Task.CompletedTask;
}

Expand All @@ -66,6 +70,7 @@ public Task DisposeAsync()

private readonly SyncFixture _fixture = fixture;
protected IMiniLcmApi Api = null!;
protected IMiniLcmApi BaseApi = null!;

private static readonly AutoFaker AutoFaker = new(AutoFakerDefault.MakeConfig(
ExtraWritingSystemsSyncFixture.VernacularWritingSystems));
Expand Down Expand Up @@ -99,12 +104,10 @@ public enum ApiType
public async Task CanSyncRandomEntries(ApiType? roundTripApiType)
{
// arrange
var currentApiType = Api switch
var currentApiType = BaseApi switch
{
FwDataMiniLcmApi => ApiType.FwData,
CrdtMiniLcmApi => ApiType.Crdt,
// This works now, because we're not currently wrapping Api,
// but if we ever do, then we want this to throw, so we know we need to detect the api differently.
_ => throw new InvalidOperationException("Unknown API type")
};

Expand Down Expand Up @@ -188,18 +191,21 @@ public async Task CanSyncRandomEntries(ApiType? roundTripApiType)
// We expect the final result to be equivalent to this "raw"/untouched, requested state.
var expected = after.Copy();

// Don't auto-add the main publication when staging data: the sync path under test (EntrySync) never
// does, and a round-tripped main wouldn't exist on the other API, breaking the subsequent create.
var noAutoMain = new CreateEntryOptions(AutoAddMainPublication: false);
if (roundTripApi is not null)
{
// round-tripping ensures we're dealing with realistic data
// (e.g. in fwdata ComplexFormComponents do not have an Id)
before = await roundTripApi.CreateEntry(before);
before = await roundTripApi.CreateEntry(before, noAutoMain);
await roundTripApi.DeleteEntry(before.Id);
after = await roundTripApi.CreateEntry(after);
after = await roundTripApi.CreateEntry(after, noAutoMain);
await roundTripApi.DeleteEntry(after.Id);
}

// before should not be round-tripped here. That's handled above.
await Api.CreateEntry(before);
await Api.CreateEntry(before, noAutoMain);

// act
await EntrySync.SyncFull(before, after, Api);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,21 @@ public ProjectSnapshotSerializationTests()
.GetRequiredService<ProjectSnapshotService>();
}

// The mutable round-trip target. Dated snapshots are immutable deserialize-only inputs;
// this one tracks the current serialization format and is regenerated when it changes.
private const string LatestSnapshotFileName = "sena-3_snapshot.latest.verified.txt";

public static IEnumerable<object[]> GetSena3SnapshotNames()
{
return GetSena3SnapshotPaths()
return GetDatedSena3SnapshotPaths()
.Select(file => new object[] { Path.GetFileName(file) });
}

private static IEnumerable<string> GetSena3SnapshotPaths()
private static IEnumerable<string> GetDatedSena3SnapshotPaths()
{
var snapshotsDir = RelativePath("Snapshots");
return Directory.GetFiles(snapshotsDir, "sena-3_snapshot.*.verified.txt");
return Directory.GetFiles(snapshotsDir, "sena-3_snapshot.*.verified.txt")
.Where(file => Path.GetFileName(file) != LatestSnapshotFileName);
}

[Theory]
Expand Down Expand Up @@ -58,15 +63,13 @@ public async Task AssertSena3Snapshots(string sourceSnapshotName)
public async Task LatestSena3SnapshotRoundTrips()
{
// arrange
var latestSnapshotPath = GetSena3SnapshotPaths()
.OrderDescending()
.First();
var latestSnapshotPath = RelativePath(Path.Combine("Snapshots", LatestSnapshotFileName));

// act
var roundTrippedJson = await GetRoundTrippedIndentedSnapshot(latestSnapshotPath);

// assert
var verifyName = Path.GetFileName(latestSnapshotPath).Replace(".verified.txt", "");
var verifyName = LatestSnapshotFileName.Replace(".verified.txt", "");
await Verify(roundTrippedJson)
.UseStrictJson()
.UseDirectory("Snapshots")
Expand Down
23 changes: 23 additions & 0 deletions backend/FwLite/FwLiteProjectSync.Tests/PublicationSyncTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
using FwLiteProjectSync.Tests.Fixtures;
using MiniLcm.Models;
using MiniLcm.SyncHelpers;

namespace FwLiteProjectSync.Tests;

public class PublicationSyncTests(SyncFixture fixture) : IClassFixture<SyncFixture>
{
[Fact]
public async Task DryRunSync_AddingANewMainPublication_DoesNotPromoteOrThrow()
{
// A main that's new to the collection is created with IsMain set, so it must not also be promoted via
// UpdatePublication — during a dry run that publication doesn't exist yet, so the update would throw NotFound.
var dryRunApi = new DryRunMiniLcmApi(fixture.CrdtApi);
var newMain = new Publication { Id = Guid.NewGuid(), Name = { { "en", "Main" } }, IsMain = true };

var act = () => PublicationSync.Sync([], [newMain], dryRunApi);

await act.Should().NotThrowAsync();
dryRunApi.DryRunRecords.Should().ContainSingle(r => r.Method == nameof(DryRunMiniLcmApi.CreatePublication));
dryRunApi.DryRunRecords.Should().NotContain(r => r.Method == nameof(DryRunMiniLcmApi.UpdatePublication));
}
}
Loading
Loading