diff --git a/AGENTS.md b/AGENTS.md index 76021a292..fd1197177 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,9 @@ The WebView root component is assigned to `globalThis.webViewComponent` (not exp Drafts are loaded and saved through [AnalysisRepository](src/types/analysis-repository.ts), the seam a later storage backend implements. [DraftRepository](src/services/draftRepository.ts) implements it over the draft envelope and its per-book shards, and rewrites only the books that changed. It imports nothing from PAPI and reaches storage only through a `KeyValueStorage`. That lets the perf harness run it in Node, straight over the storage files ([perf/storage.ts](perf/storage.ts)). -Saved projects are listed through [ProjectSummaries](src/services/projectSummaries.ts): one record holding every project's summary, rewritten on each project write so listing reads no analysis. It is PAPI-free in the same way. +[The conformance suite](src/__tests__/analysis-repository-conformance.ts) defines the seam: every `AnalysisRepository` implementation must pass it, and [draftRepository.conformance.test.ts](src/__tests__/services/draftRepository.conformance.test.ts) runs it against `DraftRepository`. A behavior every implementation owes goes in the suite; one particular to an implementation, such as which shards a save rewrites, goes in that implementation's own tests. A later backend runs the suite by passing `describeAnalysisRepositoryConformance` a factory that returns a fresh `ConformanceStore` for each test. + +Saved projects are listed through [ProjectSummaries](src/services/projectSummaries.ts): one record holding every project's summary, rewritten on each project write so listing reads no analysis. Like `DraftRepository`, it is PAPI-free. ### Components diff --git a/src/__tests__/analysis-repository-conformance.ts b/src/__tests__/analysis-repository-conformance.ts new file mode 100644 index 000000000..d708b5ee3 --- /dev/null +++ b/src/__tests__/analysis-repository-conformance.ts @@ -0,0 +1,685 @@ +/// + +import type { DraftProject, TextAnalysis } from 'interlinearizer'; +import type { AnalysisRepository, DraftExport } from '../types/analysis-repository'; +import { emptyAnalysis, emptyDraft } from '../types/empty-factories'; +import { CURRENT_MODEL_VERSION } from '../types/model-version'; +import { FIXTURE_STAMPS, makePhraseLink } from './test-helpers'; + +/** One empty store an {@link AnalysisRepository} implementation keeps drafts in. */ +export interface ConformanceStore { + /** + * Opens a repository for `sourceProjectId`. Every repository this store opens reaches the same + * stored drafts, as one opened after a restart would. + */ + open(sourceProjectId: string): Promise; + + /** Stores a draft for `sourceProjectId` as a build with a newer model version would write it. */ + storeNewerBuildDraft(sourceProjectId: string): Promise; + + /** + * Leaves the stored analysis of one book of the draft for `sourceProjectId` unreadable, corrupt + * or lost outright, with the rest of the draft as it stands. + */ + damageBook(sourceProjectId: string, bookCode: string, damage: 'corrupt' | 'lost'): Promise; + + /** + * Leaves the stored draft for `sourceProjectId` well-formed but unusable, so no book in it can be + * vouched for. + */ + damageDraft(sourceProjectId: string): Promise; + + /** + * Makes the cleanup each save of the draft for `sourceProjectId` does after its content lands + * fail, leaving storage that {@link ConformanceStore.holdsLeftovers} reports, until the returned + * function is called. + */ + failCleanup(sourceProjectId: string): () => void; + + /** Whether storage holds anything for `sourceProjectId` that its stored draft no longer needs. */ + holdsLeftovers(sourceProjectId: string): Promise; +} + +const SOURCE = 'source-project'; + +function tokenLink(id: string, analysisId: string, tokenRef: string, surfaceText: string) { + return { + id, + analysisId, + ...FIXTURE_STAMPS, + status: 'approved' as const, + token: { tokenRef, surfaceText }, + }; +} + +/** + * Every record kind and every optional field, spread over GEN and EXO. One payload is linked from + * both books and one from neither. Arrays inside records are out of sorted order, so a repository + * that sorts them fails. + */ +function fullAnalysis(): TextAnalysis { + return { + segmentAnalyses: [ + { + id: 'sa-exo', + ...FIXTURE_STAMPS, + surfaceText: 'And these', + confidence: 'high', + producer: 'human', + sourceUser: 'consultant', + freeTranslation: { en: 'And these are the names.' }, + literalTranslation: { en: 'And these the names.' }, + }, + ], + segmentAnalysisLinks: [ + { + id: 'sl-exo', + analysisId: 'sa-exo', + ...FIXTURE_STAMPS, + status: 'approved', + confidence: 'high', + segmentId: 'EXO 1:1', + }, + ], + tokenAnalyses: [ + { id: 'ta-and', ...FIXTURE_STAMPS, surfaceText: 'and', gloss: { en: 'and', fr: 'et' } }, + { + id: 'ta-beginning', + ...FIXTURE_STAMPS, + surfaceText: 'beginning', + confidence: 'medium', + producer: 'pt9-import', + sourceUser: 'translator', + morphemes: [ + { + id: 'm-2', + form: 'begin', + writingSystem: 'en', + entryRef: { authority: 'fwlite', projectId: 'lex', entryId: 'e-begin' }, + senseRef: { authority: 'fwlite', senseId: 's-begin' }, + allomorphRef: { authority: 'fwlite', allomorphId: 'a-begin' }, + grammarRef: { authority: 'fwlite', msaId: 'msa-verb' }, + gloss: { en: 'start' }, + }, + { id: 'm-1', form: '-ning', writingSystem: 'en' }, + ], + morphemesStale: true, + pos: 'noun', + features: { case: 'nominative', number: 'singular' }, + gloss: { en: 'start' }, + glossSenseRef: { authority: 'fwlite', senseId: 's-start' }, + }, + { id: 'ta-unlinked', ...FIXTURE_STAMPS, surfaceText: 'spare', gloss: { en: 'spare' } }, + ], + tokenAnalysisLinks: [ + tokenLink('tl-gen-and', 'ta-and', 'GEN 1:1:0', 'and'), + tokenLink('tl-exo-and', 'ta-and', 'EXO 1:1:0', 'and'), + { + ...tokenLink('tl-gen-beginning', 'ta-beginning', 'GEN 1:1:9', 'beginning'), + status: 'suggested', + confidence: 'low', + }, + ], + phraseAnalyses: [ + { + id: 'pa-in-the', + ...FIXTURE_STAMPS, + surfaceText: 'in the', + confidence: 'guess', + producer: 'eflomal', + sourceUser: 'reviewer', + gloss: { en: 'in' }, + senseRef: { authority: 'fwlite', senseId: 's-in' }, + }, + ], + phraseAnalysisLinks: [ + { + ...makePhraseLink('pa-in-the', ['GEN 1:1:4', 'GEN 1:1:2'], ['the', 'in']), + confidence: 'guess', + }, + ], + }; +} + +/** A draft setting every optional field, around {@link fullAnalysis}. */ +function fullDraft(): DraftProject { + return { + sourceProjectId: SOURCE, + modelVersion: CURRENT_MODEL_VERSION, + analysisLanguages: ['fr', 'en'], + targetProjectId: 'target-project', + suggestedName: 'Genesis draft', + suggestedDescription: 'A draft of Genesis and Exodus.', + analysis: fullAnalysis(), + dirty: true, + segmentation: { + removedVerseStarts: ['GEN 1:2'], + addedStarts: [{ tokenRef: 'GEN 1:1:4', surfaceText: 'the' }], + }, + }; +} + +/** + * {@link fullDraft} cut to EXO's records, with the draft's optional fields dropped and its other + * metadata changed, so a save that keeps any of the stored draft shows. + */ +function exodusOnlyDraft(): DraftProject { + const { analysis } = fullDraft(); + return { + sourceProjectId: SOURCE, + modelVersion: CURRENT_MODEL_VERSION, + analysisLanguages: ['en'], + analysis: { + ...emptyAnalysis(), + segmentAnalyses: analysis.segmentAnalyses, + segmentAnalysisLinks: analysis.segmentAnalysisLinks, + tokenAnalyses: analysis.tokenAnalyses.filter(({ id }) => id === 'ta-and'), + tokenAnalysisLinks: analysis.tokenAnalysisLinks.filter(({ id }) => id === 'tl-exo-and'), + }, + dirty: false, + }; +} + +/** {@link fullDraft} without GEN's records, as it loads once GEN's stored analysis is unreadable. */ +function fullDraftWithoutGenesis(): DraftProject { + const draft = fullDraft(); + const { analysis } = draft; + return { + ...draft, + analysis: { + ...analysis, + tokenAnalyses: analysis.tokenAnalyses.filter(({ id }) => id !== 'ta-beginning'), + tokenAnalysisLinks: analysis.tokenAnalysisLinks.filter(({ id }) => id === 'tl-exo-and'), + phraseAnalyses: [], + phraseAnalysisLinks: [], + }, + }; +} + +/** + * The draft with each record list sorted by id. Lists of records promise no order, but the arrays + * inside a record keep theirs. + */ +function inIdOrder(draft: DraftProject): DraftProject { + const byId = (records: readonly T[]): T[] => + [...records].sort((a, b) => a.id.localeCompare(b.id)); + const { analysis } = draft; + return { + ...draft, + analysis: { + ...analysis, + segmentAnalyses: byId(analysis.segmentAnalyses), + segmentAnalysisLinks: byId(analysis.segmentAnalysisLinks), + tokenAnalyses: byId(analysis.tokenAnalyses), + tokenAnalysisLinks: byId(analysis.tokenAnalysisLinks), + phraseAnalyses: byId(analysis.phraseAnalyses), + phraseAnalysisLinks: byId(analysis.phraseAnalysisLinks), + }, + }; +} + +/** + * Changes `draft` in place at each level of nesting, so a repository still holding part of it + * shows. + */ +function changeInPlace(draft: DraftProject): void { + const { analysis, segmentation } = draft; + draft.analysisLanguages.push('de'); + [ + ...analysis.segmentAnalyses, + ...analysis.segmentAnalysisLinks, + ...analysis.tokenAnalyses, + ...analysis.tokenAnalysisLinks, + ...analysis.phraseAnalyses, + ...analysis.phraseAnalysisLinks, + ].forEach((record) => { + record.updatedAt = '2026-02-02T00:00:00.000Z'; + }); + analysis.segmentAnalyses.forEach((payload) => { + if (payload.freeTranslation) payload.freeTranslation.en = 'changed'; + }); + analysis.tokenAnalyses.forEach((payload) => { + if (payload.gloss) payload.gloss.en = 'changed'; + payload.morphemes?.forEach((morpheme) => { + if (morpheme.gloss) morpheme.gloss.en = 'changed'; + }); + }); + analysis.phraseAnalyses.forEach((payload) => { + if (payload.gloss) payload.gloss.en = 'changed'; + }); + analysis.tokenAnalysisLinks.forEach((link) => { + link.token.surfaceText = 'changed'; + }); + analysis.phraseAnalysisLinks.forEach((link) => { + link.tokens.forEach((token) => { + token.surfaceText = 'changed'; + }); + }); + segmentation?.removedVerseStarts.push('EXO 1:2'); + segmentation?.addedStarts.forEach((start) => { + start.surfaceText = 'changed'; + }); +} + +/** Expects `value` to survive a JSON round trip unchanged and to hold no `null`. */ +function expectJsonPlain(value: unknown): void { + expect(JSON.parse(JSON.stringify(value))).toStrictEqual(value); + expect(JSON.stringify(value)).not.toMatch(/\bnull\b/); +} + +/** + * Expects `exported` to name no unreadable book, and returns its draft with record lists in id + * order. + */ +function readableDraft(exported: DraftExport): DraftProject { + expect(exported.unreadableBooks).toStrictEqual([]); + return inIdOrder(exported.draft); +} + +/** + * Loads the draft through `repository`, expecting every book readable, with record lists in id + * order. + */ +async function load(repository: AnalysisRepository): Promise { + return readableDraft(await repository.exportAll()); +} + +/** + * Loads the draft through a newly opened repository, expecting every book readable, with record + * lists in id order. + */ +async function reload(store: ConformanceStore, sourceProjectId = SOURCE): Promise { + return load(await store.open(sourceProjectId)); +} + +/** Loads the draft for copying through a newly opened repository, with record lists in id order. */ +async function reloadForCopy(store: ConformanceStore): Promise { + const { draft, unreadableBooks } = await (await store.open(SOURCE)).exportForCopy(); + return { draft: inIdOrder(draft), unreadableBooks }; +} + +/** A repository that has loaded {@link fullDraft} with GEN's stored analysis unreadable. */ +async function loadedWithUnreadableGenesis(store: ConformanceStore): Promise { + await (await store.open(SOURCE)).replaceAll(fullDraft()); + await store.damageBook(SOURCE, 'GEN', 'corrupt'); + const repository = await store.open(SOURCE); + await repository.exportAll(); + return repository; +} + +/** + * Declares the tests every {@link AnalysisRepository} implementation must pass, titled with the + * implementation's name. Each test runs against a fresh store from `createStore`. + */ +export function describeAnalysisRepositoryConformance( + implementationName: string, + createStore: () => ConformanceStore, +): void { + describe(`${implementationName} conforms to AnalysisRepository`, () => { + let store: ConformanceStore; + + beforeEach(() => { + store = createStore(); + }); + + describe('exportAll', () => { + it('loads an empty draft for its source project when none is saved', async () => { + await expect(reload(store)).resolves.toStrictEqual(emptyDraft(SOURCE)); + }); + + it('returns a saved draft as JSON-plain values, omitting absent optional fields rather than nulling them', async () => { + const minimal: DraftProject = { + ...emptyDraft(SOURCE), + analysis: { + ...emptyAnalysis(), + tokenAnalyses: [{ id: 'ta-and', ...FIXTURE_STAMPS, surfaceText: 'and' }], + tokenAnalysisLinks: [tokenLink('tl-and', 'ta-and', 'GEN 1:1:0', 'and')], + }, + }; + await (await store.open(SOURCE)).replaceAll(minimal); + + expectJsonPlain(await (await store.open(SOURCE)).exportAll()); + await expect(reload(store)).resolves.toStrictEqual(minimal); + }); + + it('returns a never-saved draft as JSON-plain values', async () => { + expectJsonPlain(await (await store.open(SOURCE)).exportAll()); + }); + + it('returns a draft the caller may change without changing what is stored', async () => { + const repository = await store.open(SOURCE); + await repository.replaceAll(fullDraft()); + + changeInPlace((await repository.exportAll()).draft); + + await expect(load(repository)).resolves.toStrictEqual(inIdOrder(fullDraft())); + }); + + it('refuses a draft written by a newer build', async () => { + await store.storeNewerBuildDraft(SOURCE); + + await expect((await store.open(SOURCE)).exportAll()).rejects.toThrow(); + }); + + it.each(['corrupt', 'lost'] as const)( + 'names a book whose stored analysis is %s, and loads the rest', + async (damage) => { + await (await store.open(SOURCE)).replaceAll(fullDraft()); + await store.damageBook(SOURCE, 'GEN', damage); + + const { draft, unreadableBooks } = await (await store.open(SOURCE)).exportAll(); + + expect(unreadableBooks).toStrictEqual(['GEN']); + expect(inIdOrder(draft)).toStrictEqual(inIdOrder(fullDraftWithoutGenesis())); + }, + ); + + it('loads an unusable draft as an empty one', async () => { + await (await store.open(SOURCE)).replaceAll(fullDraft()); + await store.damageDraft(SOURCE); + + await expect(reload(store)).resolves.toStrictEqual(emptyDraft(SOURCE)); + }); + }); + + describe('exportForCopy', () => { + it('exports a saved draft whole, naming no book', async () => { + await (await store.open(SOURCE)).replaceAll(fullDraft()); + + await expect(reloadForCopy(store)).resolves.toStrictEqual({ + draft: inIdOrder(fullDraft()), + unreadableBooks: [], + }); + }); + + it('exports an empty draft for its source project when none is saved', async () => { + await expect(reloadForCopy(store)).resolves.toStrictEqual({ + draft: emptyDraft(SOURCE), + unreadableBooks: [], + }); + }); + + it.each(['corrupt', 'lost'] as const)( + 'names a book whose stored analysis is %s, and exports the rest', + async (damage) => { + await (await store.open(SOURCE)).replaceAll(fullDraft()); + await store.damageBook(SOURCE, 'GEN', damage); + + await expect(reloadForCopy(store)).resolves.toStrictEqual({ + draft: inIdOrder(fullDraftWithoutGenesis()), + unreadableBooks: ['GEN'], + }); + }, + ); + + it('refuses an unusable draft rather than exporting it empty', async () => { + await (await store.open(SOURCE)).replaceAll(fullDraft()); + await store.damageDraft(SOURCE); + + await expect((await store.open(SOURCE)).exportForCopy()).rejects.toThrow(); + }); + + it('refuses a draft written by a newer build', async () => { + await store.storeNewerBuildDraft(SOURCE); + + await expect((await store.open(SOURCE)).exportForCopy()).rejects.toThrow(); + }); + }); + + describe('replaceAll', () => { + it('saves every record kind and optional field for the same repository to load', async () => { + const repository = await store.open(SOURCE); + await repository.replaceAll(fullDraft()); + + await expect(load(repository)).resolves.toStrictEqual(inIdOrder(fullDraft())); + }); + + it('saves a draft for a newly opened repository to load', async () => { + await (await store.open(SOURCE)).replaceAll(fullDraft()); + + await expect(reload(store)).resolves.toStrictEqual(inIdOrder(fullDraft())); + }); + + it('keeps a payload linked from several books as one record', async () => { + await (await store.open(SOURCE)).replaceAll(fullDraft()); + + const { analysis } = await reload(store); + + expect(analysis.tokenAnalyses.filter(({ id }) => id === 'ta-and')).toHaveLength(1); + }); + + it('saves an edit to a payload linked from several books', async () => { + const repository = await store.open(SOURCE); + await repository.replaceAll(fullDraft()); + const { draft } = await repository.exportAll(); + draft.analysis.tokenAnalyses = draft.analysis.tokenAnalyses.map((payload) => + payload.id === 'ta-and' ? { ...payload, gloss: { en: 'and then' } } : payload, + ); + + await repository.replaceAll(draft); + + await expect(reload(store)).resolves.toStrictEqual(inIdOrder(draft)); + }); + + it('stamps what it saves with the current model version', async () => { + await ( + await store.open(SOURCE) + ).replaceAll({ ...fullDraft(), modelVersion: CURRENT_MODEL_VERSION - 1 }); + + const { modelVersion } = await reload(store); + + expect(modelVersion).toBe(CURRENT_MODEL_VERSION); + }); + + it('replaces the stored draft whole, dropping records the saved draft lacks', async () => { + const repository = await store.open(SOURCE); + await repository.replaceAll(fullDraft()); + await repository.exportAll(); + + await repository.replaceAll(exodusOnlyDraft()); + + await expect(reload(store)).resolves.toStrictEqual(inIdOrder(exodusOnlyDraft())); + }); + + it('drops records the saved draft lacks even when this repository never loaded them', async () => { + await (await store.open(SOURCE)).replaceAll(fullDraft()); + + await (await store.open(SOURCE)).replaceAll(exodusOnlyDraft()); + + await expect(reload(store)).resolves.toStrictEqual(inIdOrder(exodusOnlyDraft())); + }); + + it('saves in full after reloading a draft another repository replaced', async () => { + const repository = await store.open(SOURCE); + await repository.replaceAll(fullDraft()); + await (await store.open(SOURCE)).replaceAll(exodusOnlyDraft()); + await expect(load(repository)).resolves.toStrictEqual(inIdOrder(exodusOnlyDraft())); + + await repository.replaceAll(fullDraft()); + + await expect(reload(store)).resolves.toStrictEqual(inIdOrder(fullDraft())); + }); + + it('saves an emptied analysis as empty', async () => { + const repository = await store.open(SOURCE); + await repository.replaceAll(fullDraft()); + + await repository.replaceAll({ ...fullDraft(), analysis: emptyAnalysis() }); + + await expect(reload(store)).resolves.toStrictEqual({ + ...fullDraft(), + analysis: emptyAnalysis(), + }); + }); + + it('restores records a later save brings back', async () => { + const repository = await store.open(SOURCE); + await repository.replaceAll(fullDraft()); + await repository.replaceAll(exodusOnlyDraft()); + + await repository.replaceAll(fullDraft()); + + await expect(reload(store)).resolves.toStrictEqual(inIdOrder(fullDraft())); + }); + + it('stores what it was given, unaffected by later changes to that draft', async () => { + const repository = await store.open(SOURCE); + const draft = fullDraft(); + await repository.replaceAll(draft); + + changeInPlace(draft); + + await expect(load(repository)).resolves.toStrictEqual(inIdOrder(fullDraft())); + await expect(reload(store)).resolves.toStrictEqual(inIdOrder(fullDraft())); + }); + + it('stores in-place changes to a saved draft when that draft is saved again', async () => { + const repository = await store.open(SOURCE); + const draft = fullDraft(); + await repository.replaceAll(draft); + changeInPlace(draft); + + await repository.replaceAll(draft); + + await expect(reload(store)).resolves.toStrictEqual(inIdOrder(draft)); + }); + + it('keeps the drafts of different source projects apart', async () => { + const other = { ...exodusOnlyDraft(), sourceProjectId: 'other-project' }; + await (await store.open(SOURCE)).replaceAll(fullDraft()); + + await (await store.open('other-project')).replaceAll(other); + + await expect(reload(store)).resolves.toStrictEqual(inIdOrder(fullDraft())); + await expect(reload(store, 'other-project')).resolves.toStrictEqual(inIdOrder(other)); + }); + + it('leaves a draft written by a newer build in place, refusing to overwrite it', async () => { + await store.storeNewerBuildDraft(SOURCE); + + await expect((await store.open(SOURCE)).replaceAll(fullDraft())).rejects.toThrow(); + + await expect((await store.open(SOURCE)).exportAll()).rejects.toThrow(); + }); + + it('resolves once the draft has landed even if cleanup after it fails, and a later save finishes that cleanup', async () => { + await (await store.open(SOURCE)).replaceAll(fullDraft()); + const restore = store.failCleanup(SOURCE); + + await expect( + (await store.open(SOURCE)).replaceAll(exodusOnlyDraft()), + ).resolves.toStrictEqual([]); + await expect(store.holdsLeftovers(SOURCE)).resolves.toBe(true); + + restore(); + await expect(reload(store)).resolves.toStrictEqual(inIdOrder(exodusOnlyDraft())); + await (await store.open(SOURCE)).replaceAll(exodusOnlyDraft()); + await expect(store.holdsLeftovers(SOURCE)).resolves.toBe(false); + }); + }); + + describe('replaceAll after loading a book that could not be read', () => { + it('keeps the book as stored when the draft carries nothing for it, leaving out no book', async () => { + const repository = await loadedWithUnreadableGenesis(store); + + await expect(repository.replaceAll(exodusOnlyDraft())).resolves.toStrictEqual([]); + + await expect(reloadForCopy(store)).resolves.toStrictEqual({ + draft: inIdOrder(exodusOnlyDraft()), + unreadableBooks: ['GEN'], + }); + }); + + it('resolves with the book, keeping it as stored, when the draft carries analysis for it', async () => { + const repository = await loadedWithUnreadableGenesis(store); + const withoutSegments = (draft: DraftProject): DraftProject => ({ + ...draft, + analysis: { ...draft.analysis, segmentAnalyses: [], segmentAnalysisLinks: [] }, + }); + + await expect(repository.replaceAll(withoutSegments(fullDraft()))).resolves.toStrictEqual([ + 'GEN', + ]); + + await expect(reloadForCopy(store)).resolves.toStrictEqual({ + draft: inIdOrder(withoutSegments(fullDraftWithoutGenesis())), + unreadableBooks: ['GEN'], + }); + }); + + it('writes the book like any other once told to discard what could not be read', async () => { + const repository = await loadedWithUnreadableGenesis(store); + + await expect( + repository.replaceAll(fullDraft(), { discardUnreadable: true }), + ).resolves.toStrictEqual([]); + + await expect(reload(store)).resolves.toStrictEqual(inIdOrder(fullDraft())); + }); + + it('wipes the book the draft carries nothing for once told to discard what could not be read', async () => { + const repository = await loadedWithUnreadableGenesis(store); + + await repository.replaceAll(exodusOnlyDraft(), { discardUnreadable: true }); + + await expect(reload(store)).resolves.toStrictEqual(inIdOrder(exodusOnlyDraft())); + await expect(store.holdsLeftovers(SOURCE)).resolves.toBe(false); + }); + }); + + describe('markClean', () => { + it('records the stored draft as clean, leaving its content as it stands', async () => { + await (await store.open(SOURCE)).replaceAll(fullDraft()); + + await (await store.open(SOURCE)).markClean(); + + await expect(reload(store)).resolves.toStrictEqual( + inIdOrder({ ...fullDraft(), dirty: false }), + ); + }); + + it('keeps a book that could not be read as stored', async () => { + await (await store.open(SOURCE)).replaceAll(fullDraft()); + await store.damageBook(SOURCE, 'GEN', 'corrupt'); + + await (await store.open(SOURCE)).markClean(); + + await expect(reloadForCopy(store)).resolves.toStrictEqual({ + draft: inIdOrder({ ...fullDraftWithoutGenesis(), dirty: false }), + unreadableBooks: ['GEN'], + }); + }); + + it('leaves an already-clean draft as it stands', async () => { + await (await store.open(SOURCE)).replaceAll(exodusOnlyDraft()); + + await (await store.open(SOURCE)).markClean(); + + await expect(reload(store)).resolves.toStrictEqual(inIdOrder(exodusOnlyDraft())); + }); + + it('resolves when no draft is saved, which still loads as empty', async () => { + await (await store.open(SOURCE)).markClean(); + + await expect(reload(store)).resolves.toStrictEqual(emptyDraft(SOURCE)); + }); + + it('refuses a draft written by a newer build, leaving it in place', async () => { + await store.storeNewerBuildDraft(SOURCE); + + await expect((await store.open(SOURCE)).markClean()).rejects.toThrow(); + + await expect((await store.open(SOURCE)).exportAll()).rejects.toThrow(); + }); + + it('refuses an unusable draft, leaving it in place', async () => { + await (await store.open(SOURCE)).replaceAll(fullDraft()); + await store.damageDraft(SOURCE); + + await expect((await store.open(SOURCE)).markClean()).rejects.toThrow(); + + await expect((await store.open(SOURCE)).exportForCopy()).rejects.toThrow(); + }); + }); + }); +} diff --git a/src/__tests__/services/draftRepository.conformance.test.ts b/src/__tests__/services/draftRepository.conformance.test.ts new file mode 100644 index 000000000..7a707768f --- /dev/null +++ b/src/__tests__/services/draftRepository.conformance.test.ts @@ -0,0 +1,84 @@ +/// + +import { DraftRepository } from '../../services/draftRepository'; +import { draftAnalysisKey, draftKey } from '../../services/storageKeys'; +import { emptyDraft } from '../../types/empty-factories'; +import { CURRENT_MODEL_VERSION } from '../../types/model-version'; +import { describeAnalysisRepositoryConformance } from '../analysis-repository-conformance'; +import { memoryStorage, silentLogger } from '../test-helpers'; + +const logger = silentLogger(); + +/** Whether the current test damaged storage or failed its cleanup, which a repository may log. */ +let warningsExpected = false; + +describeAnalysisRepositoryConformance('DraftRepository', () => { + const { storage: memory, records } = memoryStorage(); + warningsExpected = false; + /** The key of the draft whose cleanup fails; unset while cleanup succeeds. */ + let failingDraftKey: string | undefined; + let landed = false; + /** + * Refuses writes and deletions of the failing draft's other records from the time a save of it + * lands until the draft is next read, which is how every call on it starts. + */ + const refuseAfterLanding = async (key: string, operation: () => Promise) => { + if (landed && key.startsWith(`${failingDraftKey}:`)) throw new Error('cleanup refused'); + await operation(); + if (key === failingDraftKey) landed = true; + }; + const storage = { + read: async (key: string) => { + if (key === failingDraftKey) landed = false; + return memory.read(key); + }, + write: (key: string, data: string) => refuseAfterLanding(key, () => memory.write(key, data)), + delete: (key: string) => refuseAfterLanding(key, () => memory.delete(key)), + }; + return { + open: async (sourceProjectId) => new DraftRepository(storage, sourceProjectId, logger), + storeNewerBuildDraft: (sourceProjectId) => + memory.write( + draftKey(sourceProjectId), + JSON.stringify({ ...emptyDraft(sourceProjectId), modelVersion: CURRENT_MODEL_VERSION + 1 }), + ), + damageBook: async (sourceProjectId, bookCode, damage) => { + warningsExpected = true; + const key = draftAnalysisKey(sourceProjectId, bookCode); + if (damage === 'lost') records.delete(key); + else records.set(key, 'not json'); + }, + damageDraft: async (sourceProjectId) => { + warningsExpected = true; + records.set(draftKey(sourceProjectId), JSON.stringify({ dirty: true })); + }, + failCleanup: (sourceProjectId) => { + warningsExpected = true; + failingDraftKey = draftKey(sourceProjectId); + return () => { + failingDraftKey = undefined; + landed = false; + }; + }, + holdsLeftovers: async (sourceProjectId) => { + const stored: { analysisBooks?: string[] } = JSON.parse( + records.get(draftKey(sourceProjectId)) ?? '{}', + ); + const needed = new Set([ + draftKey(sourceProjectId), + ...(stored.analysisBooks ?? []).map((bookCode) => + draftAnalysisKey(sourceProjectId, bookCode), + ), + ]); + return [...records.keys()].some( + (key) => key.startsWith(`${draftKey(sourceProjectId)}:`) && !needed.has(key), + ); + }, + }; +}); + +// A conforming save and load of well-formed drafts has nothing to warn about, so a warning there +// means the repository found fault with records it wrote itself. +afterEach(() => { + if (!warningsExpected) expect(logger.warn).not.toHaveBeenCalled(); +}); diff --git a/src/__tests__/services/draftRepository.test.ts b/src/__tests__/services/draftRepository.test.ts index 9915fd9d6..bf889a1b7 100644 --- a/src/__tests__/services/draftRepository.test.ts +++ b/src/__tests__/services/draftRepository.test.ts @@ -3,31 +3,9 @@ import type { DraftProject, TextAnalysis } from 'interlinearizer'; import { DraftRepository } from '../../services/draftRepository'; import { draftAnalysisKey, draftKey } from '../../services/storageKeys'; -import type { KeyValueStorage, WarningLogger } from '../../types/analysis-repository'; import { emptyAnalysis, emptyDraft } from '../../types/empty-factories'; import { CURRENT_MODEL_VERSION } from '../../types/model-version'; -import { FIXTURE_STAMPS, makePhraseLink } from '../test-helpers'; - -/** Storage held in a map, recording every write in order. */ -function memoryStorage(initial: Record = {}) { - const records = new Map(Object.entries(initial)); - const writes: string[] = []; - const storage: KeyValueStorage = { - read: async (key) => records.get(key), - write: async (key, data) => { - writes.push(key); - records.set(key, data); - }, - delete: async (key) => { - records.delete(key); - }, - }; - return { storage, records, writes }; -} - -function silentLogger(): WarningLogger & { warn: jest.Mock } { - return { warn: jest.fn() }; -} +import { FIXTURE_STAMPS, makePhraseLink, memoryStorage, silentLogger } from '../test-helpers'; /** An analysis with a token payload shared by GEN and EXO, a phrase in GEN and a segment in EXO. */ function twoBookAnalysis(): TextAnalysis { @@ -63,25 +41,6 @@ function twoBookDraft(): DraftProject { } describe('DraftRepository', () => { - it('loads an empty draft for its source project when none is stored', async () => { - const { storage } = memoryStorage(); - - await expect(new DraftRepository(storage, 'src', silentLogger()).exportAll()).resolves.toEqual({ - draft: emptyDraft('src'), - unreadableBooks: [], - }); - }); - - it('loads in a new instance what another saved', async () => { - const { storage } = memoryStorage(); - await new DraftRepository(storage, 'src', silentLogger()).replaceAll(twoBookDraft()); - - await expect(new DraftRepository(storage, 'src', silentLogger()).exportAll()).resolves.toEqual({ - draft: twoBookDraft(), - unreadableBooks: [], - }); - }); - it('loads a payload shared by several books once', async () => { const { storage, records } = memoryStorage(); await new DraftRepository(storage, 'src', silentLogger()).replaceAll(twoBookDraft()); @@ -142,68 +101,7 @@ describe('DraftRepository', () => { ); }); - it('names a book whose shard is unusable, and loads the rest', async () => { - const { storage, records } = memoryStorage(); - await new DraftRepository(storage, 'src', silentLogger()).replaceAll(twoBookDraft()); - records.set(draftAnalysisKey('src', 'GEN'), 'not json'); - - const { draft, unreadableBooks } = await new DraftRepository( - storage, - 'src', - silentLogger(), - ).exportAll(); - - expect(unreadableBooks).toEqual(['GEN']); - expect(draft.analysis.tokenAnalysisLinks.map(({ id }) => id)).toEqual(['tl-EXO']); - }); - describe('exportForCopy', () => { - it('exports a readable draft whole, naming no book', async () => { - const { storage } = memoryStorage(); - await new DraftRepository(storage, 'src', silentLogger()).replaceAll(twoBookDraft()); - - await expect( - new DraftRepository(storage, 'src', silentLogger()).exportForCopy(), - ).resolves.toEqual({ draft: twoBookDraft(), unreadableBooks: [] }); - }); - - it('names a book whose shard is unusable, and exports the rest', async () => { - const { storage, records } = memoryStorage(); - await new DraftRepository(storage, 'src', silentLogger()).replaceAll(twoBookDraft()); - records.set(draftAnalysisKey('src', 'GEN'), 'not json'); - - const { draft, unreadableBooks } = await new DraftRepository( - storage, - 'src', - silentLogger(), - ).exportForCopy(); - - expect(unreadableBooks).toEqual(['GEN']); - expect(draft.analysis.tokenAnalysisLinks.map(({ id }) => id)).toEqual(['tl-EXO']); - }); - - it('names a book whose shard the manifest lists but storage lacks', async () => { - const { storage, records } = memoryStorage(); - await new DraftRepository(storage, 'src', silentLogger()).replaceAll(twoBookDraft()); - records.delete(draftAnalysisKey('src', 'EXO')); - - const { unreadableBooks } = await new DraftRepository( - storage, - 'src', - silentLogger(), - ).exportForCopy(); - - expect(unreadableBooks).toEqual(['EXO']); - }); - - it('exports a fresh empty draft when none is stored', async () => { - const { storage } = memoryStorage(); - - await expect( - new DraftRepository(storage, 'src', silentLogger()).exportForCopy(), - ).resolves.toEqual({ draft: emptyDraft('src'), unreadableBooks: [] }); - }); - it('exports an inline draft whole, naming no book', async () => { const inline = twoBookDraft(); const { storage } = memoryStorage({ [draftKey('src')]: JSON.stringify(inline) }); @@ -320,14 +218,6 @@ describe('DraftRepository', () => { expect(records.get(draftAnalysisKey('src', 'GEN'))).toBe('not json'); }); - it('leaves out no book when the draft carries nothing for an unreadable one', async () => { - const { repository, draft } = await loadedWithUnreadableGen(); - - await expect(repository.replaceAll({ ...draft, analysis: emptyAnalysis() })).resolves.toEqual( - [], - ); - }); - it('leaves out the analysis a draft carries for an unreadable book, keeping its shard', async () => { const { repository, records } = await loadedWithUnreadableGen(); @@ -336,29 +226,6 @@ describe('DraftRepository', () => { expect(records.get(draftAnalysisKey('src', 'GEN'))).toBe('not json'); }); - it('saves the rest of a draft carrying analysis for an unreadable book', async () => { - const { repository, storage } = await loadedWithUnreadableGen(); - const draft = twoBookDraft(); - - await repository.replaceAll({ - ...draft, - analysis: { ...draft.analysis, segmentAnalyses: [], segmentAnalysisLinks: [] }, - }); - - const reloaded = await new DraftRepository(storage, 'src', silentLogger()).exportForCopy(); - expect(reloaded.unreadableBooks).toEqual(['GEN']); - expect(reloaded.draft.analysis.segmentAnalysisLinks).toEqual([]); - expect(reloaded.draft.analysis.tokenAnalysisLinks.map(({ id }) => id)).toEqual(['tl-EXO']); - }); - - it('writes over an unreadable book once the draft is started over', async () => { - const { repository, records } = await loadedWithUnreadableGen(); - - await repository.replaceAll(twoBookDraft(), { discardUnreadable: true }); - - expect(records.get(draftAnalysisKey('src', 'GEN'))).not.toBe('not json'); - }); - it('wipes an unreadable book the draft carries nothing for once the draft is started over', async () => { const { repository, draft, records } = await loadedWithUnreadableGen(); diff --git a/src/__tests__/test-helpers.ts b/src/__tests__/test-helpers.ts index 6a003cd86..745cb1093 100644 --- a/src/__tests__/test-helpers.ts +++ b/src/__tests__/test-helpers.ts @@ -11,9 +11,10 @@ import { } from '../components/PhraseStripContext'; import type { SegmentPages } from '../hooks/useSegmentPages'; import { summarizeProject } from '../services/projectSummaries'; +import type { KeyValueStorage, WarningLogger } from '../types/analysis-repository'; import { emptyAnalysis } from '../types/empty-factories'; -import { CURRENT_MODEL_VERSION } from '../types/model-version'; import type { InterlinearProjectSummary } from '../types/interlinear-project-summary'; +import { CURRENT_MODEL_VERSION } from '../types/model-version'; import type { OutlineSegment } from '../types/segment-pages'; /** Minimal execution token-shaped object for tests (structural match for ExecutionToken). */ @@ -546,3 +547,30 @@ export function getMockedSendCommand(papiModule: unknown): jest.Mock { export function getMockedWaitForNetworkObject(papiModule: unknown): jest.Mock { return getMockedPapiFn(papiModule, ['networkObjectStatus', 'waitForNetworkObject']); } + +/** + * Storage held in a map seeded from `initial`. + * + * @returns The storage, the map backing it, and the keys written so far, in write order, with + * repeats. + */ +export function memoryStorage(initial: Record = {}) { + const records = new Map(Object.entries(initial)); + const writes: string[] = []; + const storage: KeyValueStorage = { + read: async (key) => records.get(key), + write: async (key, data) => { + writes.push(key); + records.set(key, data); + }, + delete: async (key) => { + records.delete(key); + }, + }; + return { storage, records, writes }; +} + +/** A logger whose warnings a test can inspect instead of printing. */ +export function silentLogger(): WarningLogger & { warn: jest.Mock } { + return { warn: jest.fn() }; +} diff --git a/src/__tests__/utils/analysis-violations.test.ts b/src/__tests__/utils/analysis-violations.test.ts index ca63095c9..b960fa11c 100644 --- a/src/__tests__/utils/analysis-violations.test.ts +++ b/src/__tests__/utils/analysis-violations.test.ts @@ -1,14 +1,9 @@ /// import type { TextAnalysis, TokenAnalysisLink } from 'interlinearizer'; -import type { WarningLogger } from '../../types/analysis-repository'; import { emptyAnalysis } from '../../types/empty-factories'; import { reportAnalysisViolations } from '../../utils/analysis-violations'; -import { FIXTURE_STAMPS } from '../test-helpers'; - -function silentLogger(): WarningLogger & { warn: jest.Mock } { - return { warn: jest.fn() }; -} +import { FIXTURE_STAMPS, silentLogger } from '../test-helpers'; function linkTo(analysisId: string, tokenRef: string, id = `tl-${tokenRef}`): TokenAnalysisLink { return { diff --git a/src/types/analysis-repository.ts b/src/types/analysis-repository.ts index c345bb68c..ea40cca45 100644 --- a/src/types/analysis-repository.ts +++ b/src/types/analysis-repository.ts @@ -53,11 +53,17 @@ export type DraftExport = { /** * Reads and writes one source project's draft. Every call is async and takes and returns only * JSON-plain values, so an implementation may sit behind a network boundary or another language. + * Once a call settles, the caller may change a draft it passed in or got back without changing what + * is stored. Lists of records come back in no promised order. + * + * Calls for one draft must not overlap, whether on one repository or several. After one repository + * saves a draft, any other that had already loaded or saved it must load it again before its next + * save. */ export interface AnalysisRepository { /** - * Loads the whole stored draft. Stored content that cannot be read loads as empty, so one damaged - * record never hides the rest. + * Loads the whole stored draft, or an empty one for the source project when none is stored. + * Stored content that cannot be read loads as empty, so one damaged record never hides the rest. * * @throws {SyntaxError} If the draft's stored value contains invalid JSON. * @throws {Error} If the stored draft was written by a newer build. @@ -66,7 +72,8 @@ export interface AnalysisRepository { exportAll(): Promise; /** - * Loads the whole stored draft for copying into a project. + * Loads the whole stored draft for copying into a project, or an empty one for the source project + * when none is stored. * * @throws {SyntaxError} If the draft's stored value contains invalid JSON. * @throws {Error} If the stored draft was written by a newer build. @@ -76,10 +83,13 @@ export interface AnalysisRepository { exportForCopy(): Promise; /** - * Saves the whole draft, replacing the stored one. Content that lands counts as saved even when - * cleanup after it fails. A book whose stored analysis could not be read is kept as stored unless - * `options` discards it: a draft that loaded it empty never wipes it, and analysis the draft - * carries for it never stands in for what could not be read. + * Saves the whole draft, replacing the stored one, stamped with this build's model version. Once + * the save's cleanup succeeds, nothing the saved draft no longer needs stays stored, apart from + * the stored analysis a save keeps for unreadable books. Content that lands counts as saved even + * when that cleanup fails; a later save, from any repository, finishes the cleanup. A book whose + * stored analysis could not be read is kept as stored unless `options` discards it: a draft that + * loaded it empty never wipes it, and analysis the draft carries for it never stands in for what + * could not be read. * * @returns The books whose analysis in the draft was left out for that reason; empty when the * draft was saved whole.