diff --git a/AGENTS.md b/AGENTS.md index 51001799d..126489390 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,7 +112,7 @@ The core types are: All character offsets (`charStart`, `charEnd`, `charIndex`) are zero-based UTF-16 code-unit indices — plain JavaScript string indices, not code points and not grapheme clusters — with end offsets exclusive. -Key invariants: `Segment.baselineText.slice(charStart, charEnd) === Token.surfaceText`; at most one linked analysis per token/segment may have `status: 'approved'`. Token and phrase payloads are shared by every occurrence analyzed identically, so a phrase occurrence is identified by `PhraseAnalysisLink.id`, never by its `analysisId`. `MultiString` values are keyed by BCP 47 tags. `TokenSnapshot.surfaceText` detects drift when baseline text changes. Every project and draft record carries a `modelVersion`; every write stamps `CURRENT_MODEL_VERSION`, and a record stamped higher than that is refused (`assertSupportedModelVersion`) rather than read or overwritten. +Key invariants: `Segment.baselineText.slice(charStart, charEnd) === Token.surfaceText`; at most one linked analysis per token/segment may have `status: 'approved'`. Token and phrase payloads are shared by every occurrence analyzed identically, so a link is identified by its own `id`, never by its `analysisId`. Picking one of a token's or segment's links, or one of several identical payloads, goes through [pick-rules.ts](src/utils/pick-rules.ts), never list order. `MultiString` values are keyed by BCP 47 tags. `TokenSnapshot.surfaceText` detects drift when baseline text changes. Every project and draft record carries a `modelVersion`; every write stamps `CURRENT_MODEL_VERSION`, and a record stamped higher than that is refused (`assertSupportedModelVersion`) rather than read or overwritten. ### TypeScript path aliases diff --git a/perf/dataset/generate.ts b/perf/dataset/generate.ts index 9d6c2ffc5..74a7a19d5 100644 --- a/perf/dataset/generate.ts +++ b/perf/dataset/generate.ts @@ -69,6 +69,9 @@ export function generateAnalysis( const segmentAnalyses: SegmentAnalysis[] = []; const segmentAnalysisLinks: SegmentAnalysisLink[] = []; + // Its own stream, so link ids never shift the draws a tier's records come from. + const linkIds = createRng(seed, 'link-ids'); + let clock = 0; const nextStamp = (rng: Rng): string => { clock += intBetween(rng, 1, 90_000); @@ -119,6 +122,7 @@ export function generateAnalysis( const payload = tokenPayload(surfaceText, token.writingSystem, sense, rng); const stamp = nextStamp(rng); tokenAnalysisLinks.push({ + id: uuid(linkIds), analysisId: payload.id, createdAt: stamp, updatedAt: stamp, @@ -168,6 +172,7 @@ export function generateAnalysis( }, }); segmentAnalysisLinks.push({ + id: uuid(linkIds), analysisId: id, segmentId: segment.id, createdAt: stamp, diff --git a/src/__tests__/components/AnalysisCatalogPanel.test.tsx b/src/__tests__/components/AnalysisCatalogPanel.test.tsx index 7ad4da514..b234358af 100644 --- a/src/__tests__/components/AnalysisCatalogPanel.test.tsx +++ b/src/__tests__/components/AnalysisCatalogPanel.test.tsx @@ -61,7 +61,13 @@ function link( status: AssignmentStatus = 'approved', surfaceText = 'ἀρχῇ', ): TokenAnalysisLink { - return { ...FIXTURE_STAMPS, analysisId, status, token: { tokenRef, surfaceText } }; + return { + id: `${tokenRef}:${analysisId}:${status}`, + ...FIXTURE_STAMPS, + analysisId, + status, + token: { tokenRef, surfaceText }, + }; } /** The token ref {@link FocusRequestProbe} last claimed, or `undefined` when it claimed nothing. */ diff --git a/src/__tests__/components/AnalysisStore.test.tsx b/src/__tests__/components/AnalysisStore.test.tsx index ceef889b6..2065d6e48 100644 --- a/src/__tests__/components/AnalysisStore.test.tsx +++ b/src/__tests__/components/AnalysisStore.test.tsx @@ -74,6 +74,7 @@ function makeAnalysisWithGloss( gloss: { und: gloss }, }; const link: TokenAnalysisLink = { + id: 'tl-1', ...FIXTURE_STAMPS, analysisId: ta.id, status: 'approved', @@ -106,12 +107,14 @@ function makeSharedAnalysis(gloss = 'a', surfaceText = 'word'): TextAnalysis { tokenAnalyses: [ta], tokenAnalysisLinks: [ { + id: 'tl-2', ...FIXTURE_STAMPS, analysisId: ta.id, status: 'approved', token: { tokenRef: 'tok-1', surfaceText }, }, { + id: 'tl-3', ...FIXTURE_STAMPS, analysisId: ta.id, status: 'approved', @@ -207,6 +210,7 @@ describe('useGloss', () => { gloss: { en: 'hi' }, }; const link: TokenAnalysisLink = { + id: 'tl-4', ...FIXTURE_STAMPS, analysisId: 'ta-1', status: 'suggested', @@ -272,6 +276,7 @@ describe('useGloss', () => { gloss: { fr: 'bonjour' }, }; const link: TokenAnalysisLink = { + id: 'tl-5', ...FIXTURE_STAMPS, analysisId: 'ta-1', status: 'approved', @@ -563,6 +568,7 @@ describe('useGlossDispatch', () => { gloss: { en: 'old' }, }; const suggestedLink: TokenAnalysisLink = { + id: 'tl-6', ...FIXTURE_STAMPS, analysisId: 'suggested-1', status: 'suggested', @@ -1010,7 +1016,7 @@ const SEGMENT_ANALYSIS_WITH_TRANSLATION: TextAnalysis = { }, ], segmentAnalysisLinks: [ - { ...FIXTURE_STAMPS, analysisId: 'sa-1', status: 'approved', segmentId: 'seg-1' }, + { id: 'sl-1', ...FIXTURE_STAMPS, analysisId: 'sa-1', status: 'approved', segmentId: 'seg-1' }, ], tokenAnalyses: [], tokenAnalysisLinks: [], @@ -1119,7 +1125,9 @@ function withSegmentTranslation(segmentId: string, status: AssignmentStatus): Te segmentAnalyses: [ { ...FIXTURE_STAMPS, id: 'sa-1', surfaceText: 'old text', freeTranslation: { und: 'old' } }, ], - segmentAnalysisLinks: [{ ...FIXTURE_STAMPS, analysisId: 'sa-1', status, segmentId }], + segmentAnalysisLinks: [ + { id: 'sl-2', ...FIXTURE_STAMPS, analysisId: 'sa-1', status, segmentId }, + ], }; } @@ -1257,6 +1265,7 @@ describe('useMorphemes', () => { ], }; const link: TokenAnalysisLink = { + id: 'tl-7', ...FIXTURE_STAMPS, analysisId: 'ta-1', status: 'approved', @@ -1385,6 +1394,7 @@ describe('useConfirmMorphemesDispatch', () => { ], tokenAnalysisLinks: [ { + id: 'tl-8', ...FIXTURE_STAMPS, analysisId: 'ta-1', status: 'approved', @@ -1435,6 +1445,7 @@ describe('useMorphemeDeleteDispatch', () => { ], }; const link: TokenAnalysisLink = { + id: 'tl-9', ...FIXTURE_STAMPS, analysisId: 'ta-1', status: 'approved', @@ -1493,6 +1504,7 @@ describe('useMorphemeGlossDispatch', () => { ], }; const link: TokenAnalysisLink = { + id: 'tl-10', ...FIXTURE_STAMPS, analysisId: 'ta-1', status: 'approved', @@ -1533,12 +1545,14 @@ describe('useMorphemeGlossDispatch', () => { ], tokenAnalysisLinks: [ { + id: 'tl-11', ...FIXTURE_STAMPS, analysisId: 'shared-analysis', status: 'approved', token: { tokenRef: 'tok-1', surfaceText: 'word' }, }, { + id: 'tl-12', ...FIXTURE_STAMPS, analysisId: 'shared-analysis', status: 'approved', @@ -1891,6 +1905,7 @@ describe('useApproveAnalysisDispatch', () => { function approvedLink(analysisId: string, tokenRef: string): TokenAnalysisLink { return { + id: `${tokenRef}:${analysisId}`, ...FIXTURE_STAMPS, analysisId, token: { tokenRef, surfaceText: 'ἀρχῇ' }, diff --git a/src/__tests__/components/ConcordancePanel.test.tsx b/src/__tests__/components/ConcordancePanel.test.tsx index f116273a9..8620f69ed 100644 --- a/src/__tests__/components/ConcordancePanel.test.tsx +++ b/src/__tests__/components/ConcordancePanel.test.tsx @@ -78,6 +78,7 @@ function makeText(overrides: Partial = {}): SourceText { /** Builds an approved link from `tokenRef` to the analysis. */ function link(analysisId: string, tokenRef: string): TokenAnalysisLink { return { + id: `${tokenRef}:${analysisId}`, ...FIXTURE_STAMPS, analysisId, status: 'approved', diff --git a/src/__tests__/components/ConcordanceRowView.test.tsx b/src/__tests__/components/ConcordanceRowView.test.tsx index c69319bdd..587ba9dc7 100644 --- a/src/__tests__/components/ConcordanceRowView.test.tsx +++ b/src/__tests__/components/ConcordanceRowView.test.tsx @@ -23,6 +23,7 @@ const localizedStrings = Object.fromEntries(CONCORDANCE_ROW_STRING_KEYS.map((k) /** Builds an approved link from `tokenRef` to the analysis. */ function link(analysisId: string, tokenRef: string): TokenAnalysisLink { return { + id: `${tokenRef}:${analysisId}`, ...FIXTURE_STAMPS, analysisId, status: 'approved', @@ -49,7 +50,8 @@ type RowOptions = Partial<{ function renderRow(options: RowOptions = {}) { const verses = options.verses ?? [{ sid: 'GEN 1:3', text: 'Let there be light' }]; const entries = buildConcordanceEntries([indexBook(makeVerseBook(verses))], new Collator('en')); - const approvedByToken = approvedAnalysisByToken(options.links ?? []); + const analysesById = new Map((options.analyses ?? []).map((a) => [a.id, a])); + const approvedByToken = approvedAnalysisByToken(options.links ?? [], analysesById); const row = deriveConcordanceRows(entries, approvedByToken, options.currentBook ?? 'GEN').find( (r) => r.entry.form === 'light', ); @@ -58,7 +60,7 @@ function renderRow(options: RowOptions = {}) { withTooltipProvider(