hammer-editor/integrationTests
Adam Brown 1b312bc3b2
Allow a project to set the language it's written in (#838)
* Add per-project language setting (#754)

An optional BCP-47 language on ProjectData, picked from a searchable
list of all platform locales in project settings. New projects default
to the device locale; the Alice example project is en-US.

Spell check is gated per project: when the project language does not
leniently match the dictionary locale, the dictionary is withheld
(ProjectSpellCheckRepository) and project settings explain why.

The public story page emits <html lang> and JSON-LD inLanguage from the
declared language, and EPUB export prefers it over the device locale.
The hasher contributes zero bytes when unset so existing sync hashes
stay stable.

* Fix review findings in the project-language feature

createProject now only seeds the default language for genuinely new
projects (seedDefaultLanguage), so account sync materializes server
projects with the never-synced baseline intact, and the seed is
language-only so it cannot gate spell check against a same-language
dictionary. The hasher's language block gets a -1 marker plus length
prefix so it can never collide with a tags block, and the initial
write goes through the shared saveStoredProjectData path.

The Locale type now retains the script subtag, keeping zh-Hans/zh-Hant
style locales distinct in the picker. The picker's clear row is pinned
above the list so it survives an empty search, watchSpellCheckAllowed
delivers on the main dispatcher, and the public story page hashes the
stored project-data hash into its validator instead of parsing the
blob per request, applying the language override after withDefaults so
chrome links keep the viewer's locale.

* Enforce single-owner persisted formats

The tags write in PromoteIdeaUseCase rewrote project_data.toml from
scratch, erasing the language seed createProject had just written: the
exact hazard of a second inline writer. It now read-modify-writes
through the datasource's scope-less helpers, and ProjectsListComponent's
hand-rolled reader delegates to a new blocking readStoredProjectData.

The rule is written down (ARCHITECTURE.md hard constraint 7, CLAUDE.md)
and enforced by PersistedFormatOwnershipTest, which fails the build when
raw TOML I/O appears outside a Datasource file. Migrators are exempt by
role; the two remaining legacy offenders are allowlisted as a burn-down
that can only shrink.

* Burn down the last raw TOML I/O outside datasources

ProjectStatisticsCacheReader now delegates to a scope-less
readProjectStatistics helper in StatisticsDatasource, and the example
project's fabricated activity log goes through writeDeviceLog in
WritingActivityDatasource, which also becomes the single owner of the
.activity path convention.

With no offenders left, PersistedFormatOwnershipTest drops its
burn-down allowlist entirely: only Datasource files and migrators may
touch persisted TOML formats from here on.

* Pass seedDefaultLanguage in the Android instrumented-test harness

* Pass seedDefaultLanguage in the round-trip sync HeadlessClient
2026-08-03 22:22:00 -07:00
..
src/jvmTest/kotlin/com/darkrockstudios/apps/hammer/integration Allow a project to set the language it's written in (#838) 2026-08-03 22:22:00 -07:00
build.gradle.kts Bind e2e test server to an ephemeral port to fix flakiness (#749) 2026-07-17 14:27:51 -07:00
README.md Fix fresh-sync re-download and project-data conflict; add resync stability matrix (#704) 2026-06-30 08:12:31 -07:00

Sync integration tests

End-to-end sync tests that run the real client sync engine against a real server. Each test spins up an in-process Jetty server (RoundTripTestBase) and drives one or more fully-wired clients (HeadlessClient) through it. Unlike the server-side :server e2e tests, which drive the HTTP API by hand, these exercise the client's store / hash / conflict logic — the layer where divergence bugs actually live.

The model-free oracle

The hard part of testing sync is knowing the expected end state. We mostly avoid computing it, and instead assert two properties that any correct sync must satisfy — both on RoundTripTestBase:

  • assertConverged(projectName, vararg clients) — every client holds exactly the entity set the server holds, hash for hash. Doesn't care what the entities are, only that the sides agree.
  • assertResyncSilent(client) — an immediate extra sync moves nothing over the wire. Built on tapWire(), an HttpSend interceptor that records real traffic (download_entity 200 vs 304, upload_entity). Any client/server hash divergence surfaces here as a re-download or re-upload.

tapWire() is the workhorse: assert what actually crossed the wire, not what the client claims it did. The null-timestamp re-download bug was a 200 where a 304 belonged.

The three regimes

Sync output is a function of (client baseline, client ops, server ops). New tests should slot into one of these:

Regime Setup Oracle
First-time client empty, server has entities every entity pulled; client converges to server
No-change nothing changed since last sync assertResyncSilent — zero wire transfer
Mixed creates / edits / deletes on one or both sides assertConverged + assertResyncSilent; conflicts only where both sides touched the same entity

Coverage map

First-time

  • ServerDownloadsEntityTest — server-only scene lands on a clean client
  • TwoDeviceSyncTest (a second device downloads…) — a second device adopts an existing project

No-change

  • ResyncStabilityMatrixTest — every entity type × edge-case field values, server-originated, resync silent
  • UploadResyncStabilityMatrixTest — same matrix for client-created entities
  • ResyncDownloadsNothingTest — all types in one project; first sync pulls + heals, second is silent
  • SyncHashStabilityTest, EditResyncNoConflictTest, ResyncBaselineScenariosTest, EntityTypeResyncMatrixTest, SyncedHashBackfillTest — targeted baseline / hash-agreement cases

Mixed

  • MixedSyncFuzzTest — seeded property test: random create/edit/delete/rename across all types, converge + silent
  • TwoDeviceSyncTest (independent edits…) — two devices, disjoint edits, converge
  • IndependentEditsTest, ClientDeletionTest, ClientUploadsEntityTest, ServerOriginatedEntitiesTest — specific transitions
  • ConflictPickClientTest, ConflictPickServerTest — the conflict sub-case (both sides touch one entity)
  • SyncFuzzTest — single-entity edit/rename fuzz (legacy; MixedSyncFuzzTest is the broader net)

Adding tests

  • A new scenario: extend RoundTripTestBase, drive HeadlessClients, and finish with assertConverged / assertResyncSilent rather than hand-rolled state checks.
  • Two devices on one project: secondDeviceFor(primary, localName). The primary must have synced once. A second device that creates entities after adopting should re-open its editor (initializeSceneEditor()) first, mirroring a real session re-deriving its next id.
  • More fuzz coverage: add seeds to MixedSyncFuzzTest.SEEDS (a failure prints the seed + iteration to replay).
  • Scripted "other device" changes: seedServerEntity / mutateServerEntity / seedServerEntityDeletion (+ bump last_id) set server state directly — more faithful than a second real client for server-originated changes, since it sidesteps shared client-side id allocation.