diff --git a/src/__tests__/main.test.ts b/src/__tests__/main.test.ts index f2dd1475e..e1573e405 100644 --- a/src/__tests__/main.test.ts +++ b/src/__tests__/main.test.ts @@ -19,9 +19,13 @@ import { DraftRepository } from '../services/draftRepository'; import * as hostPerf from '../services/hostPerf'; import * as projectStorage from '../services/projectStorage'; import * as pt9ImportService from '../services/pt9ImportService'; -import { type AnalysisRepository, UnreadableBooksError } from '../types/analysis-repository'; +import { + type IndexDifference, + type KeyValueStorage, + type RepositoryCapabilities, + UnreadableBooksError, +} from '../types/analysis-repository'; import { emptyAnalysis, emptyDraft } from '../types/empty-factories'; -import { scopeRecordOf } from '../utils/change-set'; import { toProjectSummary } from '../types/interlinear-project-summary'; import { createTestActivationContext, @@ -515,6 +519,9 @@ describe('main', () => { 'interlinearizer.checkIndexes', 'interlinearizer.writeGloss', 'interlinearizer.approveAnalysisForToken', + 'interlinearizer.writeMorphemes', + 'interlinearizer.deleteMorphemes', + 'interlinearizer.writeMorphemeGloss', 'interlinearizer.discardStaleAnalysis', 'interlinearizer.reapplyStaleAnalysis', 'interlinearizer.keepStaleFreeTranslation', @@ -1955,31 +1962,67 @@ describe('main', () => { }); describe('session commands', () => { - /** A repository handing out `draft`, as storage would open one. */ - function stubRepository(draft: DraftProject, readOnly = false): AnalysisRepository { - const noLinks = { tokenAnalysisLinks: [], phraseAnalysisLinks: [], segmentAnalysisLinks: [] }; - return { - capabilities: { readOnly }, - exportAll: async () => ({ draft, unreadableBooks: [] }), - exportForCopy: async () => ({ draft, unreadableBooks: [] }), - replaceAll: async () => [], - markClean: async () => {}, - apply: async (changes) => ({ inverse: changes }), - getTokenAnalyses: async () => [], - getPhraseAnalyses: async () => [], - getSegmentAnalyses: async () => [], - getPhraseAnalysisLinks: async () => [], - findLinksByToken: async () => noLinks, - findLinksBySegment: async () => [], - findLinksToAnalyses: async () => noLinks, - findLinksInBooks: async () => noLinks, - findTokenAnalysesByForm: async () => [], - findPhraseAnalysesByForm: async () => [], - checkIndexes: async () => [ - { index: 'tokenAnalyses', key: 'ta-1', missing: ['ta-1'], extra: [] }, - ], - getScope: async () => scopeRecordOf(draft), - }; + /** A draft repository in memory whose indexes always report one difference. */ + class StubRepository extends DraftRepository { + override readonly capabilities: RepositoryCapabilities; + + constructor(storage: KeyValueStorage, sourceProjectId: string, readOnly: boolean) { + super(storage, sourceProjectId, silentLogger()); + this.capabilities = { readOnly }; + } + + override async checkIndexes(): Promise { + return [{ index: 'tokenAnalyses', key: 'ta-1', missing: ['ta-1'], extra: [] }]; + } + } + + /** A repository holding `draft`, as storage would open one. */ + async function stubRepository(draft: DraftProject, readOnly = false): Promise { + const { storage } = memoryStorage(); + await new DraftRepository(storage, draft.sourceProjectId, silentLogger()).replaceAll(draft); + return new StubRepository(storage, draft.sourceProjectId, readOnly); + } + + /** "cats" on GEN 1:1:0, approved with a breakdown and nothing else. */ + const CATS_BREAKDOWN: Partial = { + tokenAnalyses: [ + { + id: 'ta-cats', + ...FIXTURE_STAMPS, + surfaceText: 'cats', + morphemes: [ + { id: 'm-cat', form: 'cat', writingSystem: 'en' }, + { id: 'm-s', form: '-s', writingSystem: 'en' }, + ], + }, + ], + tokenAnalysisLinks: [ + { + id: 'tl-cats', + analysisId: 'ta-cats', + ...FIXTURE_STAMPS, + status: 'approved', + token: { tokenRef: 'GEN 1:1:0', surfaceText: 'cats' }, + }, + ], + }; + + /** + * Gives sessions a draft of `src-project` to open. The draft holds `analysis` and + * `segmentation`, with English as its analysis language. + */ + async function seedDraft( + analysis: Partial, + segmentation?: SegmentationDelta, + ): Promise { + jest.mocked(projectStorage.openDraftRepository).mockReturnValue( + await stubRepository({ + ...emptyDraft('src-project'), + analysisLanguages: ['en'], + analysis: { ...emptyAnalysis(), ...analysis }, + ...(segmentation && { segmentation }), + }), + ); } /** Activates the extension and returns the session commands' handlers. */ @@ -2024,6 +2067,26 @@ describe('main', () => { analysisId: string, ) => Promise >('interlinearizer.approveAnalysisForToken'), + writeMorphemes: handler< + ( + sessionId: string, + tokenRef: string, + surfaceText: string, + forms: string[], + writingSystem: string, + ) => Promise + >('interlinearizer.writeMorphemes'), + deleteMorphemes: handler<(sessionId: string, tokenRef: string) => Promise>( + 'interlinearizer.deleteMorphemes', + ), + writeMorphemeGloss: handler< + ( + sessionId: string, + tokenRef: string, + morphemeId: string, + value: string, + ) => Promise + >('interlinearizer.writeMorphemeGloss'), discardStaleAnalysis: handler< (sessionId: string, analysisId: string, tokenRef: string) => Promise >('interlinearizer.discardStaleAnalysis'), @@ -2094,25 +2157,6 @@ describe('main', () => { }; } - /** Stores a draft of `src-project` holding `analysis` and `segmentation`, for sessions to open. */ - async function storeDraftHolding( - analysis: Partial, - segmentation?: SegmentationDelta, - ): Promise { - const repository = new DraftRepository( - memoryStorage().storage, - 'src-project', - silentLogger(), - ); - await repository.replaceAll({ - ...emptyDraft('src-project'), - analysisLanguages: ['en'], - analysis: { ...emptyAnalysis(), ...analysis }, - ...(segmentation && { segmentation }), - }); - jest.mocked(projectStorage.openDraftRepository).mockReturnValue(repository); - } - const staleToken = { tokenAnalyses: [{ id: 'ta-in', ...FIXTURE_STAMPS, surfaceText: 'In', gloss: { en: 'at' } }], tokenAnalysisLinks: [ @@ -2147,12 +2191,8 @@ describe('main', () => { { sid: 'GEN 1:2', text: 'And the earth' }, ]); - beforeEach(() => { - jest - .mocked(projectStorage.openDraftRepository) - .mockReturnValue( - stubRepository({ ...emptyDraft('src-project'), analysisLanguages: ['en'] }), - ); + beforeEach(async () => { + await seedDraft({}); __mockSettingsGet.mockResolvedValue(['en']); jest.mocked(bookService.getVerseBook).mockResolvedValue(VERSE_BOOK); jest.mocked(bookService.getBookSegments).mockResolvedValue(VERSE_BOOK.segments); @@ -2187,7 +2227,7 @@ describe('main', () => { ]); jest .mocked(projectStorage.openPt9ImportRepository) - .mockReturnValue(stubRepository(emptyDraft('src-project'), true)); + .mockReturnValue(await stubRepository(emptyDraft('src-project'), true)); const { openSession } = await activateSessionCommands(); const opened = JSON.parse(await openSession('src-project', 'import-id')); @@ -2206,7 +2246,7 @@ describe('main', () => { it('reads the interface languages for a draft that lists no analysis language', async () => { jest .mocked(projectStorage.openDraftRepository) - .mockReturnValue(stubRepository(emptyDraft('src-project'))); + .mockReturnValue(await stubRepository(emptyDraft('src-project'))); __mockSettingsGet.mockResolvedValue(['fr']); const { openSession } = await activateSessionCommands(); @@ -2359,16 +2399,96 @@ describe('main', () => { }); it('approves an analysis for a token, replying with the changes', async () => { + await seedDraft({ + tokenAnalyses: [{ id: 'ta-in', ...FIXTURE_STAMPS, surfaceText: 'In', gloss: { en: 'at' } }], + }); const { openSession, approveAnalysisForToken } = await activateSessionCommands(); const { sessionId } = JSON.parse(await openSession('src-project')); - await expect( - approveAnalysisForToken(sessionId, 'GEN 1:1:0', 'In', 'ta-unknown').then(JSON.parse), - ).resolves.toStrictEqual({ intent: 'approveAnalysisForToken' }); + const changes = JSON.parse( + await approveAnalysisForToken(sessionId, 'GEN 1:1:0', 'In', 'ta-in'), + ); + + expect(changes).toMatchObject({ + intent: 'approveAnalysisForToken', + tokenAnalysisLinks: { + puts: [ + { + analysisId: 'ta-in', + status: 'approved', + token: { tokenRef: 'GEN 1:1:0', surfaceText: 'In' }, + }, + ], + deletes: [], + }, + }); + }); + + it("writes a token's morpheme breakdown, replying with the changes", async () => { + const { openSession, writeMorphemes } = await activateSessionCommands(); + const { sessionId } = JSON.parse(await openSession('src-project')); + + const changes = JSON.parse( + await writeMorphemes(sessionId, 'GEN 1:1:0', 'cats', ['cat', '-s'], 'en'), + ); + + expect(changes).toMatchObject({ + intent: 'writeMorphemes', + tokenAnalyses: { + puts: [ + { + surfaceText: 'cats', + morphemes: [ + { id: expect.any(String), form: 'cat', writingSystem: 'en' }, + { id: expect.any(String), form: '-s', writingSystem: 'en' }, + ], + }, + ], + deletes: [], + }, + tokenAnalysisLinks: { + puts: [{ status: 'approved', token: { tokenRef: 'GEN 1:1:0', surfaceText: 'cats' } }], + deletes: [], + }, + }); + }); + + it("removes a token's morpheme breakdown, replying with the changes", async () => { + await seedDraft(CATS_BREAKDOWN); + const { openSession, deleteMorphemes } = await activateSessionCommands(); + const { sessionId } = JSON.parse(await openSession('src-project')); + + const changes = JSON.parse(await deleteMorphemes(sessionId, 'GEN 1:1:0')); + + expect(changes).toMatchObject({ + intent: 'deleteMorphemes', + tokenAnalyses: { puts: [], deletes: ['ta-cats'] }, + tokenAnalysisLinks: { puts: [], deletes: ['tl-cats'] }, + }); + }); + + it("glosses a morpheme in the session's language, replying with the changes", async () => { + await seedDraft(CATS_BREAKDOWN); + const { openSession, setAnalysisLanguage, writeMorphemeGloss } = + await activateSessionCommands(); + const { sessionId } = JSON.parse(await openSession('src-project')); + await setAnalysisLanguage(sessionId, 'fr'); + + const changes = JSON.parse(await writeMorphemeGloss(sessionId, 'GEN 1:1:0', 'm-cat', 'chat')); + + expect(changes).toMatchObject({ + intent: 'writeMorphemeGloss', + tokenAnalyses: { + puts: [ + { id: 'ta-cats', morphemes: [{ id: 'm-cat', gloss: { fr: 'chat' } }, { id: 'm-s' }] }, + ], + deletes: [], + }, + }); }); it('discards the place an analysis went stale at, replying with the changes', async () => { - await storeDraftHolding(staleToken); + await seedDraft(staleToken); const { openSession, discardStaleAnalysis } = await activateSessionCommands(); const { sessionId } = JSON.parse(await openSession('src-project')); @@ -2382,7 +2502,7 @@ describe('main', () => { }); it('reapplies a stale analysis on a token, replying with the changes', async () => { - await storeDraftHolding(staleToken); + await seedDraft(staleToken); const { openSession, reapplyStaleAnalysis } = await activateSessionCommands(); const { sessionId } = JSON.parse(await openSession('src-project')); @@ -2406,7 +2526,7 @@ describe('main', () => { }); it('keeps a stale free translation for a segment, replying with the changes', async () => { - await storeDraftHolding(staleTranslation); + await seedDraft(staleTranslation); const { openSession, keepStaleFreeTranslation } = await activateSessionCommands(); const { sessionId } = JSON.parse(await openSession('src-project')); @@ -2425,7 +2545,7 @@ describe('main', () => { }); it('discards a stale free translation, replying with the changes', async () => { - await storeDraftHolding(staleTranslation); + await seedDraft(staleTranslation); const { openSession, discardStaleFreeTranslation } = await activateSessionCommands(); const { sessionId } = JSON.parse(await openSession('src-project')); @@ -2463,10 +2583,7 @@ describe('main', () => { }); it("checks a phrase against the segments the draft's boundaries in its book make", async () => { - await storeDraftHolding( - {}, - { removedVerseStarts: ['GEN 1:2:0', 'EXO 1:2:0'], addedStarts: [] }, - ); + await seedDraft({}, { removedVerseStarts: ['GEN 1:2:0', 'EXO 1:2:0'], addedStarts: [] }); const { openSession, createPhrase } = await activateSessionCommands(); const { sessionId } = JSON.parse(await openSession('src-project')); @@ -2497,7 +2614,7 @@ describe('main', () => { ['mergePhrases', (c: SessionCommands, id: string) => c.mergePhrases(id, 'pl-1', godAnd)], ['approvePhrase', (c: SessionCommands, id: string) => c.approvePhrase(id, 'pl-1')], ])('refuses %s over tokens in two segments, logging the error', async (intent, command) => { - await storeDraftHolding(suggested(godAnd)); + await seedDraft(suggested(godAnd)); const commands = await activateSessionCommands(); const { sessionId } = JSON.parse(await commands.openSession('src-project')); @@ -2520,7 +2637,7 @@ describe('main', () => { }); it('approves a suggested phrase within one segment, replying with the changes', async () => { - await storeDraftHolding(suggested(inThe)); + await seedDraft(suggested(inThe)); const { openSession, approvePhrase } = await activateSessionCommands(); const { sessionId } = JSON.parse(await openSession('src-project')); @@ -2531,7 +2648,7 @@ describe('main', () => { }); it('leaves an approved phrase approved without reading the text', async () => { - await storeDraftHolding({ + await seedDraft({ ...suggested(godAnd), phraseAnalysisLinks: [ { id: 'pl-1', analysisId: 'pa-1', ...FIXTURE_STAMPS, status: 'approved', tokens: godAnd }, @@ -2547,7 +2664,7 @@ describe('main', () => { }); it('removes a phrase given no tokens without reading the text', async () => { - await storeDraftHolding(suggested(godAnd)); + await seedDraft(suggested(godAnd)); const { openSession, updatePhrase } = await activateSessionCommands(); const { sessionId } = JSON.parse(await openSession('src-project')); @@ -2559,7 +2676,7 @@ describe('main', () => { }); it("writes a phrase's gloss in the session's language, replying with the changes", async () => { - await storeDraftHolding({ + await seedDraft({ phraseAnalyses: [{ id: 'pa-1', ...FIXTURE_STAMPS, surfaceText: 'In the' }], phraseAnalysisLinks: [ { id: 'pl-1', analysisId: 'pa-1', ...FIXTURE_STAMPS, status: 'approved', tokens: inThe }, @@ -2580,7 +2697,7 @@ describe('main', () => { }); it('merges a token into a phrase when PAPI names no absorbed phrase, replying with the changes', async () => { - await storeDraftHolding({ + await seedDraft({ phraseAnalyses: [{ id: 'pa-1', ...FIXTURE_STAMPS, surfaceText: 'In the' }], phraseAnalysisLinks: [ { id: 'pl-1', analysisId: 'pa-1', ...FIXTURE_STAMPS, status: 'approved', tokens: inThe }, @@ -2611,7 +2728,7 @@ describe('main', () => { { tokenRef: 'GEN 1:1:7', surfaceText: 'beginning' }, { tokenRef: 'GEN 1:2:0', surfaceText: 'And' }, ]; - await storeDraftHolding({ + await seedDraft({ phraseAnalyses: [{ id: 'pa-1', ...FIXTURE_STAMPS, surfaceText: 'In the beginning And' }], phraseAnalysisLinks: [ { @@ -2738,7 +2855,7 @@ describe('main', () => { (c: SessionCommands, id: string) => c.mergePhrases(id, 'pl-2', [inThe[1], ...beginningGod]), ], ])('refuses %s over a token in another approved phrase', async (_intent, command) => { - await storeDraftHolding({ + await seedDraft({ phraseAnalyses: [ { id: 'pa-1', ...FIXTURE_STAMPS, surfaceText: 'In the' }, { id: 'pa-2', ...FIXTURE_STAMPS, surfaceText: 'beginning God' }, @@ -2829,7 +2946,7 @@ describe('main', () => { { tokenRef: 'GEN 1:1:7', surfaceText: 'beginning' }, ], }; - await storeDraftHolding({ + await seedDraft({ phraseAnalyses: [{ id: 'pa-1', ...FIXTURE_STAMPS, surfaceText: 'In the beginning' }], phraseAnalysisLinks: [phrase], }); @@ -2846,7 +2963,7 @@ describe('main', () => { }); it("stales the free translation of a segment it splits, in the split's edit", async () => { - await storeDraftHolding({ + await seedDraft({ segmentAnalyses: [ { id: 'sa-1', @@ -2897,7 +3014,7 @@ describe('main', () => { ])( 'refuses %s in a book the project has no text for, logging the error', async (intent, command) => { - await storeDraftHolding(suggested(exodus)); + await seedDraft(suggested(exodus)); jest.mocked(bookService.getVerseBook).mockResolvedValue(undefined); jest.mocked(bookService.getBookSegments).mockResolvedValue(undefined); const commands = await activateSessionCommands(); diff --git a/src/__tests__/services/commands/freeTranslationCommands.parity.test.ts b/src/__tests__/services/commands/freeTranslationCommands.parity.test.ts index 944777c59..3ff0a75b6 100644 --- a/src/__tests__/services/commands/freeTranslationCommands.parity.test.ts +++ b/src/__tests__/services/commands/freeTranslationCommands.parity.test.ts @@ -5,10 +5,8 @@ import { writeSegmentFreeTranslation as writeCommand } from '../../../services/c import { writeSegmentFreeTranslation } from '../../../store/analysisSlice'; import { emptyAnalysis } from '../../../types/empty-factories'; import { FIXTURE_STAMPS } from '../../test-helpers'; -import { runParity } from './parity'; +import { ids, LATER, NOW, type ParityCase, runParity } from './parity'; -const NOW = '2026-06-01T00:00:00.000Z'; -const LATER = { createdAt: '2026-03-01T00:00:00.000Z', updatedAt: '2026-03-01T00:00:00.000Z' }; const NEW = { linkId: 'new-link', analysisId: 'new-analysis' }; const SEGMENT = 'GEN 1:1'; @@ -34,8 +32,6 @@ function analysisOf( return { ...emptyAnalysis(), segmentAnalyses, segmentAnalysisLinks }; } -const ids = (records: readonly { id: string }[]) => records.map(({ id }) => id).sort(); - const translationOf = (store: TextAnalysis, id: string) => store.segmentAnalyses.find((sa) => sa.id === id); @@ -44,15 +40,8 @@ const linkOf = (store: TextAnalysis, id: string) => type WriteInput = { value: string; adoptStaleAnalysisId?: string }; -type ParityCase = { - analysis: TextAnalysis; - input: WriteInput; - /** What the reducer's result must show, so the case exercises the path it names. */ - expectStore: (store: TextAnalysis) => void; -}; - describe('writeSegmentFreeTranslation parity with the reducer', () => { - const cases: [string, ParityCase][] = [ + const cases: [string, ParityCase][] = [ [ 'makes an approved translation for a segment with none', { diff --git a/src/__tests__/services/commands/morphemeCommands.parity.test.ts b/src/__tests__/services/commands/morphemeCommands.parity.test.ts new file mode 100644 index 000000000..f09cc7dc7 --- /dev/null +++ b/src/__tests__/services/commands/morphemeCommands.parity.test.ts @@ -0,0 +1,873 @@ +/// + +import type { MorphemeAnalysis } from 'interlinearizer'; +import { + deleteMorphemes as deleteMorphemesCommand, + writeMorphemeGloss as writeMorphemeGlossCommand, + writeMorphemes as writeMorphemesCommand, +} from '../../../services/commands/morphemeCommands'; +import { deleteMorphemes, writeMorphemeGloss, writeMorphemes } from '../../../store/analysisSlice'; +import { emptyAnalysis } from '../../../types/empty-factories'; +import { FIXTURE_STAMPS } from '../../test-helpers'; +import { + analysisOf, + EARLIER, + expectParity, + ids, + LATER, + link, + NOW, + OTHER_TOKEN, + type ParityCase, + payload, + runParity, + TOKEN, +} from './parity'; + +function morpheme(id: string, form: string, fields: Partial = {}) { + return { id, form, writingSystem: 'en', ...fields }; +} + +/** A breakdown as the WebView prepares one: a fresh id per form. */ +const forms = (...list: string[]) => list.map((form, i) => ({ id: `new-m-${i}`, form })); +const CAT_S = [morpheme('m-cat', 'cat'), morpheme('m-s', '-s')]; + +type MorphemesInput = { + surfaceText?: string; + writingSystem?: string; + morphemes: { id: string; form: string }[]; +}; + +describe('writeMorphemes parity with the reducer', () => { + const cases: [string, ParityCase][] = [ + [ + 'creates a payload and an approved link for an unanalyzed token', + { + analysis: emptyAnalysis(), + input: { morphemes: forms('cat', '-s') }, + expectStore: (store) => { + expect(store.tokenAnalyses).toStrictEqual([ + payload('new-analysis', 'cats', { + createdAt: NOW, + updatedAt: NOW, + morphemes: [morpheme('new-m-0', 'cat'), morpheme('new-m-1', '-s')], + }), + ]); + expect(ids(store.tokenAnalysisLinks)).toStrictEqual(['new-link']); + }, + }, + ], + [ + 'shares a stored payload whose breakdown matches, whatever its morpheme ids', + { + analysis: analysisOf( + [payload('ta-cats', 'cats', { morphemes: CAT_S })], + [link('tl-other', 'ta-cats', OTHER_TOKEN, 'cats')], + ), + input: { morphemes: forms('cat', '-s') }, + expectStore: (store) => { + expect(ids(store.tokenAnalyses)).toStrictEqual(['ta-cats']); + expect(store.tokenAnalysisLinks.find(({ id }) => id === 'new-link')?.analysisId).toBe( + 'ta-cats', + ); + }, + }, + ], + [ + "approves the token's held link to the matching payload rather than adding one", + { + analysis: analysisOf( + [payload('ta-cats', 'cats', { morphemes: CAT_S })], + [link('tl-held', 'ta-cats', TOKEN, 'cats', { status: 'suggested', confidence: 'low' })], + ), + input: { morphemes: forms('cat', '-s') }, + expectStore: (store) => { + expect(store.tokenAnalysisLinks).toStrictEqual([ + expect.objectContaining({ id: 'tl-held', status: 'approved', confidence: 'low' }), + ]); + }, + }, + ], + [ + 'adds a breakdown to an unshared payload holding none', + { + analysis: analysisOf( + [payload('ta-cats', 'cats', { gloss: { en: 'cats' } })], + [link('tl-cats', 'ta-cats', TOKEN, 'cats')], + ), + input: { morphemes: forms('cat', '-s') }, + expectStore: (store) => { + expect(store.tokenAnalyses).toStrictEqual([ + payload('ta-cats', 'cats', { + updatedAt: NOW, + gloss: { en: 'cats' }, + morphemes: [morpheme('new-m-0', 'cat'), morpheme('new-m-1', '-s')], + }), + ]); + }, + }, + ], + [ + 're-splits an unshared payload in place, keeping unchanged morphemes whole', + { + analysis: analysisOf( + [ + payload('ta-cats', 'cats', { + morphemes: [ + morpheme('m-cat', 'cat', { + gloss: { en: 'feline' }, + senseRef: { authority: 'fwlite', senseId: 's-cat' }, + }), + morpheme('m-s', '-s', { gloss: { en: 'PL' } }), + ], + morphemesStale: true, + }), + ], + [link('tl-cats', 'ta-cats', TOKEN, 'cats')], + ), + input: { surfaceText: 'cats,', morphemes: forms('cat', 's') }, + expectStore: (store) => { + expect(store.tokenAnalyses).toStrictEqual([ + payload('ta-cats', 'cats,', { + updatedAt: NOW, + morphemes: [ + morpheme('m-cat', 'cat', { + gloss: { en: 'feline' }, + senseRef: { authority: 'fwlite', senseId: 's-cat' }, + }), + morpheme('new-m-1', 's'), + ], + }), + ]); + expect(store.tokenAnalysisLinks[0].token.surfaceText).toBe('cats,'); + }, + }, + ], + [ + 'keeps a distinct old morpheme per repeated form', + { + analysis: analysisOf( + [ + payload('ta-baba', 'baba', { + morphemes: [ + morpheme('m-1', 'ba', { gloss: { en: 'first' } }), + morpheme('m-2', 'ba', { gloss: { en: 'second' } }), + ], + }), + ], + [link('tl-baba', 'ta-baba', TOKEN, 'baba')], + ), + input: { surfaceText: 'baba', morphemes: forms('ba', 'ba', 'ba') }, + expectStore: (store) => { + expect(store.tokenAnalyses[0].morphemes?.map(({ id }) => id)).toStrictEqual([ + 'm-1', + 'm-2', + 'new-m-2', + ]); + }, + }, + ], + [ + 'stamps the writing system on kept morphemes too', + { + analysis: analysisOf( + [ + payload('ta-cats', 'cats', { + morphemes: [morpheme('m-cat', 'cat', { gloss: { en: 'feline' } })], + }), + ], + [link('tl-cats', 'ta-cats', TOKEN, 'cats')], + ), + input: { writingSystem: 'en-Latn', morphemes: forms('cat', '-s') }, + expectStore: (store) => { + expect(store.tokenAnalyses[0].morphemes).toStrictEqual([ + morpheme('m-cat', 'cat', { writingSystem: 'en-Latn', gloss: { en: 'feline' } }), + morpheme('new-m-1', '-s', { writingSystem: 'en-Latn' }), + ]); + }, + }, + ], + [ + 'forks a payload other tokens share, re-splitting only the fork', + { + analysis: analysisOf( + [payload('ta-cats', 'cats', { morphemes: CAT_S })], + [ + link('tl-cats', 'ta-cats', TOKEN, 'cats'), + link('tl-other', 'ta-cats', OTHER_TOKEN, 'cats'), + ], + ), + input: { morphemes: forms('ca', 'ts') }, + expectStore: (store) => { + expect(ids(store.tokenAnalyses)).toStrictEqual(['new-analysis', 'ta-cats']); + expect(store.tokenAnalyses.find(({ id }) => id === 'ta-cats')?.morphemes).toStrictEqual( + CAT_S, + ); + }, + }, + ], + [ + 'folds a fork back onto the shared payload when the re-split leaves it unchanged', + { + analysis: analysisOf( + [payload('ta-cats', 'cats', { morphemes: CAT_S })], + [ + link('tl-cats', 'ta-cats', TOKEN, 'cats'), + link('tl-other', 'ta-cats', OTHER_TOKEN, 'cats'), + ], + ), + input: { morphemes: forms('cat', '-s') }, + expectStore: (store) => { + expect(ids(store.tokenAnalyses)).toStrictEqual(['ta-cats']); + expect(store.tokenAnalysisLinks.every(({ analysisId }) => analysisId === 'ta-cats')).toBe( + true, + ); + }, + }, + ], + [ + "folds a re-split matching another payload into it, clearing the survivor's stale mark", + { + analysis: analysisOf( + [ + payload('ta-split', 'cats', { + morphemes: [morpheme('m-ca', 'ca'), morpheme('m-ts', 'ts')], + }), + payload('ta-cats', 'Cats', { ...EARLIER, morphemes: CAT_S, morphemesStale: true }), + ], + [ + link('tl-split', 'ta-split', TOKEN, 'cats'), + link('tl-other', 'ta-cats', OTHER_TOKEN, 'cats'), + ], + ), + input: { morphemes: forms('cat', '-s') }, + expectStore: (store) => { + expect(ids(store.tokenAnalyses)).toStrictEqual(['ta-cats']); + expect(store.tokenAnalyses[0].morphemesStale).toBeUndefined(); + expect(store.tokenAnalysisLinks.every(({ analysisId }) => analysisId === 'ta-cats')).toBe( + true, + ); + }, + }, + ], + [ + 'leaves a token linking both folded payloads one link, dated by the earlier', + { + analysis: analysisOf( + [ + payload('ta-split', 'cats', { + morphemes: [morpheme('m-ca', 'ca'), morpheme('m-ts', 'ts')], + }), + payload('ta-cats', 'cats', { ...EARLIER, morphemes: CAT_S }), + ], + [ + link('tl-split', 'ta-split', TOKEN, 'cats', LATER), + link('tl-offered', 'ta-cats', TOKEN, 'cats', { ...EARLIER, status: 'suggested' }), + ], + ), + input: { morphemes: forms('cat', '-s') }, + expectStore: (store) => { + expect(store.tokenAnalysisLinks).toStrictEqual([ + expect.objectContaining({ + id: 'tl-split', + status: 'approved', + createdAt: EARLIER.createdAt, + }), + ]); + }, + }, + ], + [ + 'edits the payload an approval tie picks among several approved links', + { + analysis: analysisOf( + [ + payload('ta-old', 'cats', { morphemes: [morpheme('m-old', 'cats')] }), + payload('ta-new', 'cats', { morphemes: CAT_S }), + ], + [ + link('tl-old', 'ta-old', TOKEN, 'cats', EARLIER), + link('tl-new', 'ta-new', TOKEN, 'cats', LATER), + ], + ), + input: { morphemes: forms('ca', 'ts') }, + expectStore: (store) => { + expect( + store.tokenAnalyses.find(({ id }) => id === 'ta-new')?.morphemes?.map((m) => m.form), + ).toStrictEqual(['ca', 'ts']); + expect(store.tokenAnalyses.find(({ id }) => id === 'ta-old')?.morphemes).toStrictEqual([ + morpheme('m-old', 'cats'), + ]); + }, + }, + ], + [ + 'leaves an empty breakdown where no forms are given', + { + analysis: analysisOf( + [payload('ta-cats', 'cats', { gloss: { en: 'cats' }, morphemes: CAT_S })], + [link('tl-cats', 'ta-cats', TOKEN, 'cats')], + ), + input: { morphemes: [] }, + expectStore: (store) => { + expect(store.tokenAnalyses[0].morphemes).toStrictEqual([]); + }, + }, + ], + [ + 'removes an approved link whose payload is gone before writing', + { + analysis: analysisOf([], [link('tl-orphan', 'ta-missing', TOKEN, 'cats')]), + input: { morphemes: forms('cat', '-s') }, + expectStore: (store) => { + expect(ids(store.tokenAnalysisLinks)).toStrictEqual(['new-link']); + }, + }, + ], + ]; + + it.each(cases)('%s', async (_name, { analysis, input, expectStore }) => { + const surfaceText = input.surfaceText ?? 'cats'; + const writingSystem = input.writingSystem ?? 'en'; + const outcome = await runParity( + analysis, + 'en', + { + type: writeMorphemes.type, + payload: { + tokenRef: TOKEN, + surfaceText, + writingSystem, + analysisId: 'new-analysis', + linkId: 'new-link', + morphemes: input.morphemes, + now: NOW, + }, + }, + (buffer) => + writeMorphemesCommand(buffer, { + tokenRef: TOKEN, + surfaceText, + writingSystem, + analysisId: 'new-analysis', + linkId: 'new-link', + morphemes: input.morphemes, + now: NOW, + }), + ); + + expectParity(outcome, expectStore); + }); +}); + +type GlossInput = { morphemeId?: string; value: string }; + +describe('writeMorphemeGloss parity with the reducer', () => { + const cases: [string, ParityCase][] = [ + [ + 'glosses an unglossed morpheme on an unshared payload, leaving its stale mark', + { + analysis: analysisOf( + [payload('ta-cats', 'cats', { morphemes: CAT_S, morphemesStale: true })], + [link('tl-cats', 'ta-cats', TOKEN, 'cats')], + ), + input: { value: 'feline' }, + expectStore: (store) => { + expect(store.tokenAnalyses).toStrictEqual([ + payload('ta-cats', 'cats', { + updatedAt: NOW, + morphemes: [ + morpheme('m-cat', 'cat', { gloss: { en: 'feline' } }), + morpheme('m-s', '-s'), + ], + morphemesStale: true, + }), + ]); + expect(store.tokenAnalysisLinks[0].updatedAt).toBe(NOW); + }, + }, + ], + [ + 'rewrites the gloss in one language, keeping the others', + { + analysis: analysisOf( + [ + payload('ta-cats', 'cats', { + morphemes: [morpheme('m-cat', 'cat', { gloss: { en: 'feline', fr: 'chat' } })], + }), + ], + [link('tl-cats', 'ta-cats', TOKEN, 'cats')], + ), + input: { value: 'cat' }, + expectStore: (store) => { + expect(store.tokenAnalyses[0].morphemes?.[0].gloss).toStrictEqual({ + en: 'cat', + fr: 'chat', + }); + }, + }, + ], + [ + 'clears the gloss, dropping it once its only entry goes', + { + analysis: analysisOf( + [ + payload('ta-cats', 'cats', { + morphemes: [morpheme('m-cat', 'cat', { gloss: { en: 'feline' } })], + }), + ], + [link('tl-cats', 'ta-cats', TOKEN, 'cats')], + ), + input: { value: ' ' }, + expectStore: (store) => { + expect(store.tokenAnalyses).toStrictEqual([ + payload('ta-cats', 'cats', { updatedAt: NOW, morphemes: [morpheme('m-cat', 'cat')] }), + ]); + }, + }, + ], + [ + 'clears one language, keeping the others', + { + analysis: analysisOf( + [ + payload('ta-cats', 'cats', { + morphemes: [morpheme('m-cat', 'cat', { gloss: { en: 'feline', fr: 'chat' } })], + }), + ], + [link('tl-cats', 'ta-cats', TOKEN, 'cats')], + ), + input: { value: '' }, + expectStore: (store) => { + expect(store.tokenAnalyses[0].morphemes?.[0].gloss).toStrictEqual({ fr: 'chat' }); + }, + }, + ], + [ + 'clears the gloss and keeps the sense ref', + { + analysis: analysisOf( + [ + payload('ta-cats', 'cats', { + morphemes: [ + morpheme('m-cat', 'cat', { + gloss: { en: 'feline' }, + senseRef: { authority: 'fwlite', senseId: 's-cat' }, + }), + ], + }), + ], + [link('tl-cats', 'ta-cats', TOKEN, 'cats')], + ), + input: { value: '' }, + expectStore: (store) => { + expect(store.tokenAnalyses[0].morphemes).toStrictEqual([ + morpheme('m-cat', 'cat', { senseRef: { authority: 'fwlite', senseId: 's-cat' } }), + ]); + }, + }, + ], + [ + 'stamps the payload and link even when a blank value finds no gloss to clear', + { + analysis: analysisOf( + [payload('ta-cats', 'cats', { morphemes: CAT_S })], + [link('tl-cats', 'ta-cats', TOKEN, 'cats')], + ), + input: { value: '' }, + expectStore: (store) => { + expect(store.tokenAnalyses[0]).toMatchObject({ updatedAt: NOW, morphemes: CAT_S }); + expect(store.tokenAnalysisLinks[0].updatedAt).toBe(NOW); + }, + }, + ], + [ + 'forks a payload other tokens share, glossing only the fork', + { + analysis: analysisOf( + [payload('ta-cats', 'cats', { morphemes: CAT_S })], + [ + link('tl-cats', 'ta-cats', TOKEN, 'cats'), + link('tl-other', 'ta-cats', OTHER_TOKEN, 'cats'), + ], + ), + input: { value: 'feline' }, + expectStore: (store) => { + expect(ids(store.tokenAnalyses)).toStrictEqual(['new-analysis', 'ta-cats']); + expect( + store.tokenAnalyses.find(({ id }) => id === 'new-analysis')?.morphemes?.[0].gloss, + ).toStrictEqual({ en: 'feline' }); + expect(store.tokenAnalyses.find(({ id }) => id === 'ta-cats')?.morphemes).toStrictEqual( + CAT_S, + ); + }, + }, + ], + [ + "folds a gloss matching another payload into it, clearing the survivor's stale mark", + { + analysis: analysisOf( + [ + payload('ta-bare', 'cats', { morphemes: CAT_S }), + payload('ta-glossed', 'cats', { + ...EARLIER, + morphemes: [ + morpheme('m-cat-2', 'cat', { gloss: { en: 'feline' } }), + morpheme('m-s-2', '-s'), + ], + morphemesStale: true, + }), + ], + [ + link('tl-bare', 'ta-bare', TOKEN, 'cats'), + link('tl-other', 'ta-glossed', OTHER_TOKEN, 'cats'), + ], + ), + input: { value: 'feline' }, + expectStore: (store) => { + expect(ids(store.tokenAnalyses)).toStrictEqual(['ta-glossed']); + expect(store.tokenAnalyses[0].morphemesStale).toBeUndefined(); + expect( + store.tokenAnalysisLinks.every(({ analysisId }) => analysisId === 'ta-glossed'), + ).toBe(true); + }, + }, + ], + [ + 'folds a clear matching another payload into it', + { + analysis: analysisOf( + [ + payload('ta-glossed', 'cats', { + morphemes: [ + morpheme('m-cat', 'cat', { gloss: { en: 'feline' } }), + morpheme('m-s', '-s'), + ], + }), + payload('ta-bare', 'cats', { + ...EARLIER, + morphemes: [morpheme('m-cat-2', 'cat'), morpheme('m-s-2', '-s')], + }), + ], + [ + link('tl-glossed', 'ta-glossed', TOKEN, 'cats'), + link('tl-other', 'ta-bare', OTHER_TOKEN, 'cats'), + ], + ), + input: { value: '' }, + expectStore: (store) => { + expect(ids(store.tokenAnalyses)).toStrictEqual(['ta-bare']); + expect(store.tokenAnalysisLinks.every(({ analysisId }) => analysisId === 'ta-bare')).toBe( + true, + ); + }, + }, + ], + [ + 'stages nothing for a token with no approved analysis', + { + analysis: analysisOf( + [payload('ta-cats', 'cats', { morphemes: CAT_S })], + [link('tl-other', 'ta-cats', OTHER_TOKEN, 'cats')], + ), + input: { value: 'feline' }, + expectStore: (store) => { + expect(store.tokenAnalyses[0].updatedAt).toBe(FIXTURE_STAMPS.updatedAt); + }, + }, + ], + [ + 'stages nothing for a morpheme the breakdown lacks', + { + analysis: analysisOf( + [payload('ta-cats', 'cats', { morphemes: CAT_S })], + [link('tl-cats', 'ta-cats', TOKEN, 'cats')], + ), + input: { morphemeId: 'm-missing', value: 'feline' }, + expectStore: (store) => { + expect(store.tokenAnalyses[0].updatedAt).toBe(FIXTURE_STAMPS.updatedAt); + }, + }, + ], + [ + 'stages nothing for a payload with no breakdown', + { + analysis: analysisOf( + [payload('ta-cats', 'cats', { gloss: { en: 'cats' } })], + [link('tl-cats', 'ta-cats', TOKEN, 'cats')], + ), + input: { value: 'feline' }, + expectStore: (store) => { + expect(store.tokenAnalyses[0].updatedAt).toBe(FIXTURE_STAMPS.updatedAt); + }, + }, + ], + [ + 'removes an approved link whose payload is gone, and nothing else', + { + analysis: analysisOf([], [link('tl-orphan', 'ta-missing', TOKEN, 'cats')]), + input: { value: 'feline' }, + expectStore: (store) => { + expect(store).toStrictEqual(emptyAnalysis()); + }, + }, + ], + ]; + + it.each(cases)('%s', async (_name, { analysis, input, expectStore }) => { + const morphemeId = input.morphemeId ?? 'm-cat'; + const outcome = await runParity( + analysis, + 'en', + { + type: writeMorphemeGloss.type, + payload: { tokenRef: TOKEN, morphemeId, value: input.value, id: 'new-analysis', now: NOW }, + }, + (buffer) => + writeMorphemeGlossCommand(buffer, { + tokenRef: TOKEN, + morphemeId, + value: input.value, + analysisLanguage: 'en', + analysisId: 'new-analysis', + now: NOW, + }), + ); + + expectParity(outcome, expectStore); + }); +}); + +describe('deleteMorphemes parity with the reducer', () => { + const cases: [string, Omit, 'input'>][] = [ + [ + 'removes the breakdown, keeping a payload with a gloss', + { + analysis: analysisOf( + [ + payload('ta-cats', 'cats', { + gloss: { en: 'cats' }, + morphemes: CAT_S, + morphemesStale: true, + }), + ], + [link('tl-cats', 'ta-cats', TOKEN, 'cats')], + ), + expectStore: (store) => { + expect(store.tokenAnalyses).toStrictEqual([ + payload('ta-cats', 'cats', { updatedAt: NOW, gloss: { en: 'cats' } }), + ]); + expect(store.tokenAnalysisLinks[0].updatedAt).toBe(NOW); + }, + }, + ], + [ + 'removes the payload and its link when the breakdown was its only content', + { + analysis: analysisOf( + [payload('ta-cats', 'cats', { morphemes: CAT_S })], + [link('tl-cats', 'ta-cats', TOKEN, 'cats')], + ), + expectStore: (store) => { + expect(store).toStrictEqual(emptyAnalysis()); + }, + }, + ], + [ + 'removes an empty breakdown', + { + analysis: analysisOf( + [payload('ta-cats', 'cats', { gloss: { en: 'cats' }, morphemes: [] })], + [link('tl-cats', 'ta-cats', TOKEN, 'cats')], + ), + expectStore: (store) => { + expect(store.tokenAnalyses).toStrictEqual([ + payload('ta-cats', 'cats', { updatedAt: NOW, gloss: { en: 'cats' } }), + ]); + }, + }, + ], + [ + "keeps an emptied payload another of the token's links still names", + { + analysis: analysisOf( + [payload('ta-cats', 'cats', { morphemes: CAT_S })], + [ + link('tl-cats', 'ta-cats', TOKEN, 'cats'), + link('tl-rejected', 'ta-cats', TOKEN, 'cats', { status: 'rejected' }), + ], + ), + expectStore: (store) => { + expect(store.tokenAnalyses).toStrictEqual([ + payload('ta-cats', 'cats', { updatedAt: NOW }), + ]); + expect(ids(store.tokenAnalysisLinks)).toStrictEqual(['tl-rejected']); + }, + }, + ], + [ + 'removes the payload and its link when its gloss object is empty', + { + analysis: analysisOf( + [payload('ta-cats', 'cats', { gloss: {}, morphemes: CAT_S })], + [link('tl-cats', 'ta-cats', TOKEN, 'cats')], + ), + expectStore: (store) => { + expect(store).toStrictEqual(emptyAnalysis()); + }, + }, + ], + [ + 'keeps a payload holding a part of speech', + { + analysis: analysisOf( + [payload('ta-cats', 'cats', { morphemes: CAT_S, pos: 'n' })], + [link('tl-cats', 'ta-cats', TOKEN, 'cats')], + ), + expectStore: (store) => { + expect(store.tokenAnalyses).toStrictEqual([ + payload('ta-cats', 'cats', { updatedAt: NOW, pos: 'n' }), + ]); + }, + }, + ], + [ + 'keeps a payload holding features', + { + analysis: analysisOf( + [payload('ta-cats', 'cats', { morphemes: CAT_S, features: { Number: 'Pl' } })], + [link('tl-cats', 'ta-cats', TOKEN, 'cats')], + ), + expectStore: (store) => { + expect(store.tokenAnalyses).toStrictEqual([ + payload('ta-cats', 'cats', { updatedAt: NOW, features: { Number: 'Pl' } }), + ]); + }, + }, + ], + [ + 'keeps a payload holding a sense reference', + { + analysis: analysisOf( + [ + payload('ta-cats', 'cats', { + morphemes: CAT_S, + glossSenseRef: { authority: 'fwlite', senseId: 's-cat' }, + }), + ], + [link('tl-cats', 'ta-cats', TOKEN, 'cats')], + ), + expectStore: (store) => { + expect(store.tokenAnalyses).toStrictEqual([ + payload('ta-cats', 'cats', { + updatedAt: NOW, + glossSenseRef: { authority: 'fwlite', senseId: 's-cat' }, + }), + ]); + }, + }, + ], + [ + "forks a payload other tokens share, removing only the fork's breakdown", + { + analysis: analysisOf( + [payload('ta-cats', 'cats', { gloss: { en: 'cats' }, morphemes: CAT_S })], + [ + link('tl-cats', 'ta-cats', TOKEN, 'cats'), + link('tl-other', 'ta-cats', OTHER_TOKEN, 'cats'), + ], + ), + expectStore: (store) => { + expect(ids(store.tokenAnalyses)).toStrictEqual(['new-analysis', 'ta-cats']); + expect(store.tokenAnalyses.find(({ id }) => id === 'ta-cats')?.morphemes).toStrictEqual( + CAT_S, + ); + }, + }, + ], + [ + "drops only this token's link from a shared payload the removal would empty", + { + analysis: analysisOf( + [payload('ta-cats', 'cats', { morphemes: CAT_S })], + [ + link('tl-cats', 'ta-cats', TOKEN, 'cats'), + link('tl-other', 'ta-cats', OTHER_TOKEN, 'cats'), + ], + ), + expectStore: (store) => { + expect(store.tokenAnalyses).toStrictEqual([ + payload('ta-cats', 'cats', { morphemes: CAT_S }), + ]); + expect(ids(store.tokenAnalysisLinks)).toStrictEqual(['tl-other']); + }, + }, + ], + [ + 'folds the stripped payload into another its remaining content matches', + { + analysis: analysisOf( + [ + payload('ta-split', 'cats', { gloss: { en: 'cats' }, morphemes: CAT_S }), + payload('ta-plain', 'Cats', { ...EARLIER, gloss: { en: 'cats' } }), + ], + [ + link('tl-split', 'ta-split', TOKEN, 'cats'), + link('tl-other', 'ta-plain', OTHER_TOKEN, 'cats'), + ], + ), + expectStore: (store) => { + expect(ids(store.tokenAnalyses)).toStrictEqual(['ta-plain']); + expect( + store.tokenAnalysisLinks.every(({ analysisId }) => analysisId === 'ta-plain'), + ).toBe(true); + }, + }, + ], + [ + 'stages nothing for a token with no approved analysis', + { + analysis: analysisOf( + [payload('ta-cats', 'cats', { morphemes: CAT_S })], + [link('tl-other', 'ta-cats', OTHER_TOKEN, 'cats')], + ), + expectStore: (store) => { + expect(store.tokenAnalyses[0].updatedAt).toBe(FIXTURE_STAMPS.updatedAt); + }, + }, + ], + [ + 'stages nothing for a payload with no breakdown', + { + analysis: analysisOf( + [payload('ta-cats', 'cats', { gloss: { en: 'cats' } })], + [link('tl-cats', 'ta-cats', TOKEN, 'cats')], + ), + expectStore: (store) => { + expect(store.tokenAnalyses[0].updatedAt).toBe(FIXTURE_STAMPS.updatedAt); + }, + }, + ], + [ + 'removes an approved link whose payload is gone, and nothing else', + { + analysis: analysisOf([], [link('tl-orphan', 'ta-missing', TOKEN, 'cats')]), + expectStore: (store) => { + expect(store).toStrictEqual(emptyAnalysis()); + }, + }, + ], + ]; + + it.each(cases)('%s', async (_name, { analysis, expectStore }) => { + const outcome = await runParity( + analysis, + 'en', + { type: deleteMorphemes.type, payload: { tokenRef: TOKEN, id: 'new-analysis', now: NOW } }, + (buffer) => + deleteMorphemesCommand(buffer, { tokenRef: TOKEN, analysisId: 'new-analysis', now: NOW }), + ); + + expectParity(outcome, expectStore); + }); +}); diff --git a/src/__tests__/services/commands/morphemeCommands.test.ts b/src/__tests__/services/commands/morphemeCommands.test.ts new file mode 100644 index 000000000..d7df54d61 --- /dev/null +++ b/src/__tests__/services/commands/morphemeCommands.test.ts @@ -0,0 +1,160 @@ +/// + +import type { TextAnalysis, TokenAnalysis, TokenAnalysisLink } from 'interlinearizer'; +import { ChangeBuffer } from '../../../services/commands/changeBuffer'; +import { + deleteMorphemes, + writeMorphemeGloss, + writeMorphemes, +} from '../../../services/commands/morphemeCommands'; +import { DraftRepository } from '../../../services/draftRepository'; +import { emptyAnalysis, emptyDraft } from '../../../types/empty-factories'; +import { FIXTURE_STAMPS, memoryStorage, silentLogger } from '../../test-helpers'; + +const NOW = '2026-06-01T00:00:00.000Z'; +const STAMP = { analysisId: 'new-analysis', linkId: 'new-link', now: NOW }; + +const shared: TokenAnalysis = { + id: 'ta-cats', + ...FIXTURE_STAMPS, + surfaceText: 'cats', + morphemes: [ + { id: 'm-cat', form: 'cat', writingSystem: 'en' }, + { id: 'm-s', form: '-s', writingSystem: 'en' }, + ], +}; + +function link(id: string, tokenRef: string): TokenAnalysisLink { + return { + id, + analysisId: 'ta-cats', + ...FIXTURE_STAMPS, + status: 'approved', + token: { tokenRef, surfaceText: 'cats' }, + }; +} + +async function bufferOver(analysis: Partial): Promise { + const { storage } = memoryStorage(); + const repository = new DraftRepository(storage, 'src', silentLogger()); + await repository.replaceAll({ + ...emptyDraft('src'), + analysis: { ...emptyAnalysis(), ...analysis }, + }); + return new ChangeBuffer(repository); +} + +describe('writeMorphemes', () => { + it('stages only the link when a re-split leaves a shared breakdown unchanged', async () => { + const buffer = await bufferOver({ + tokenAnalyses: [shared], + tokenAnalysisLinks: [link('tl-1', 'GEN 1:1:0'), link('tl-2', 'GEN 1:2:0')], + }); + + await writeMorphemes(buffer, { + ...STAMP, + tokenRef: 'GEN 1:1:0', + surfaceText: 'cats', + writingSystem: 'en', + morphemes: [ + { id: 'new-m-0', form: 'cat' }, + { id: 'new-m-1', form: '-s' }, + ], + }); + + expect(buffer.toChangeSet('writeMorphemes')).toStrictEqual({ + intent: 'writeMorphemes', + tokenAnalysisLinks: { + puts: [{ ...link('tl-1', 'GEN 1:1:0'), updatedAt: NOW }], + deletes: [], + }, + }); + }); +}); + +describe('writeMorphemeGloss', () => { + it('stages nothing for a token with no approved analysis', async () => { + const buffer = await bufferOver({ tokenAnalyses: [shared] }); + + await writeMorphemeGloss(buffer, { + ...STAMP, + tokenRef: 'GEN 1:1:0', + morphemeId: 'm-cat', + value: 'feline', + analysisLanguage: 'en', + }); + + expect(buffer.toChangeSet('writeMorphemeGloss')).toStrictEqual({ + intent: 'writeMorphemeGloss', + }); + }); + + it('stages only the link when a gloss leaves a shared breakdown unchanged', async () => { + const buffer = await bufferOver({ + tokenAnalyses: [shared], + tokenAnalysisLinks: [link('tl-1', 'GEN 1:1:0'), link('tl-2', 'GEN 1:2:0')], + }); + + await writeMorphemeGloss(buffer, { + ...STAMP, + tokenRef: 'GEN 1:1:0', + morphemeId: 'm-cat', + value: '', + analysisLanguage: 'en', + }); + + expect(buffer.toChangeSet('writeMorphemeGloss')).toStrictEqual({ + intent: 'writeMorphemeGloss', + tokenAnalysisLinks: { + puts: [{ ...link('tl-1', 'GEN 1:1:0'), updatedAt: NOW }], + deletes: [], + }, + }); + }); + + it('stages nothing for a morpheme the breakdown lacks', async () => { + const buffer = await bufferOver({ + tokenAnalyses: [shared], + tokenAnalysisLinks: [link('tl-1', 'GEN 1:1:0')], + }); + + await writeMorphemeGloss(buffer, { + ...STAMP, + tokenRef: 'GEN 1:1:0', + morphemeId: 'm-missing', + value: 'feline', + analysisLanguage: 'en', + }); + + expect(buffer.toChangeSet('writeMorphemeGloss')).toStrictEqual({ + intent: 'writeMorphemeGloss', + }); + }); +}); + +describe('deleteMorphemes', () => { + it("stages only the link's removal when stripping a shared payload empties it", async () => { + const buffer = await bufferOver({ + tokenAnalyses: [shared], + tokenAnalysisLinks: [link('tl-1', 'GEN 1:1:0'), link('tl-2', 'GEN 1:2:0')], + }); + + await deleteMorphemes(buffer, { ...STAMP, tokenRef: 'GEN 1:1:0' }); + + expect(buffer.toChangeSet('deleteMorphemes')).toStrictEqual({ + intent: 'deleteMorphemes', + tokenAnalysisLinks: { puts: [], deletes: ['tl-1'] }, + }); + }); + + it('stages nothing for a payload with no breakdown', async () => { + const buffer = await bufferOver({ + tokenAnalyses: [{ ...shared, morphemes: undefined, gloss: { en: 'cats' } }], + tokenAnalysisLinks: [link('tl-1', 'GEN 1:1:0')], + }); + + await deleteMorphemes(buffer, { ...STAMP, tokenRef: 'GEN 1:1:0' }); + + expect(buffer.toChangeSet('deleteMorphemes')).toStrictEqual({ intent: 'deleteMorphemes' }); + }); +}); diff --git a/src/__tests__/services/commands/parity.ts b/src/__tests__/services/commands/parity.ts index 0ee721576..52d73a895 100644 --- a/src/__tests__/services/commands/parity.ts +++ b/src/__tests__/services/commands/parity.ts @@ -1,7 +1,14 @@ /// import type { UnknownAction } from '@reduxjs/toolkit'; -import type { Book, SegmentationDelta, TextAnalysis, TokenSnapshot } from 'interlinearizer'; +import type { + Book, + SegmentationDelta, + TextAnalysis, + TokenAnalysis, + TokenAnalysisLink, + TokenSnapshot, +} from 'interlinearizer'; import { resegmentBook } from 'parsers/papi/resegmentBook'; import { ChangeBuffer } from '../../../services/commands/changeBuffer'; import { DraftRepository } from '../../../services/draftRepository'; @@ -12,7 +19,7 @@ import reducer, { updatePhrase, } from '../../../store/analysisSlice'; import type { ChangeSet, IndexDifference } from '../../../types/analysis-repository'; -import { emptyDraft } from '../../../types/empty-factories'; +import { emptyAnalysis, emptyDraft } from '../../../types/empty-factories'; import { buildBookIndexes } from '../../../utils/book-indexes'; import { phrasesStraddlingBoundary, splitPhraseAtBoundary } from '../../../utils/phrase-arc'; import { @@ -22,7 +29,67 @@ import { reanchorSegmentation, splitSegmentBefore, } from '../../../utils/segmentation'; -import { memoryStorage, silentLogger } from '../../test-helpers'; +import { FIXTURE_STAMPS, memoryStorage, silentLogger } from '../../test-helpers'; + +/** Stamps dated before {@link FIXTURE_STAMPS}. */ +export const EARLIER = { + createdAt: '2025-06-01T00:00:00.000Z', + updatedAt: '2025-06-01T00:00:00.000Z', +}; +/** Stamps dated after {@link FIXTURE_STAMPS} and before {@link NOW}. */ +export const LATER = { + createdAt: '2026-03-01T00:00:00.000Z', + updatedAt: '2026-03-01T00:00:00.000Z', +}; + +/** `Token.ref` of the token that a case edits. */ +export const TOKEN = 'GEN 1:1:0'; +/** `Token.ref` of a token that a case leaves alone. */ +export const OTHER_TOKEN = 'GEN 1:2:0'; + +/** A token analysis stamped with {@link FIXTURE_STAMPS}, with `fields` overriding the defaults. */ +export function payload(id: string, surfaceText: string, fields: Partial = {}) { + return { id, ...FIXTURE_STAMPS, surfaceText, ...fields }; +} + +/** An approved link stamped with {@link FIXTURE_STAMPS}, with `fields` overriding the defaults. */ +export function link( + id: string, + analysisId: string, + tokenRef: string, + surfaceText: string, + fields: Partial = {}, +): TokenAnalysisLink { + return { + id, + analysisId, + ...FIXTURE_STAMPS, + status: 'approved', + token: { tokenRef, surfaceText }, + ...fields, + }; +} + +/** An analysis holding only the given token analyses and links. */ +export function analysisOf( + tokenAnalyses: TokenAnalysis[], + tokenAnalysisLinks: TokenAnalysisLink[], +): TextAnalysis { + return { ...emptyAnalysis(), tokenAnalyses, tokenAnalysisLinks }; +} + +/** The ids of `records`, sorted. */ +export const ids = (records: readonly { id: string }[]) => records.map(({ id }) => id).sort(); + +/** One input that a parity suite runs through both the reducer and the command. */ +export type ParityCase = { + /** What the draft holds before the input runs. */ + analysis: TextAnalysis; + /** What varies between the suite's cases, passed to both the reducer and the command. */ + input: Input; + /** What the reducer's result must show, so the case exercises the path it names. */ + expectStore: (store: TextAnalysis) => void; +}; /** What one input gives through the store's reducer and through the host's command. */ export type ParityOutcome = { @@ -127,7 +194,7 @@ export type BoundaryParityOutcome = ParityOutcome & { hostSegmentation: SegmentationDelta | undefined; }; -/** The time both ways of a boundary run stamp on the records they touch. */ +/** The time stamped on every record that either way of a run touches. */ export const NOW = '2026-06-01T00:00:00.000Z'; /** Makes ids in a fixed sequence, so both ways of a run spend the same ids in the same order. */ @@ -160,12 +227,12 @@ function webViewBoundaryEdit( analysisLanguage: seed.analysisLanguage, }).values(); phrasesStraddlingBoundary(boundaryRef, phraseLinks, fullTokenOrder).forEach( - ({ link, splitAfterTokenRef }) => { + ({ link: straddlingLink, splitAfterTokenRef }) => { const forkId = newId(); const linkId = newId(); const analysisId = newId(); splitPhraseAtBoundary( - link, + straddlingLink, splitAfterTokenRef, { createPhrase: (tokens: TokenSnapshot[]) => { @@ -233,3 +300,16 @@ export async function runBoundaryParity( hostSegmentation: draft.segmentation, }; } + +/** + * Asserts that the reducer took the path that the case names, and that the command left the same + * analysis and consistent indexes. + */ +export function expectParity( + outcome: ParityOutcome, + expectStore: ParityCase['expectStore'], +): void { + expectStore(outcome.store); + expect(outcome.host).toStrictEqual(outcome.store); + expect(outcome.indexDifferences).toStrictEqual([]); +} diff --git a/src/__tests__/services/commands/phraseCommands.parity.test.ts b/src/__tests__/services/commands/phraseCommands.parity.test.ts index 73ed1e199..62b28eb55 100644 --- a/src/__tests__/services/commands/phraseCommands.parity.test.ts +++ b/src/__tests__/services/commands/phraseCommands.parity.test.ts @@ -28,10 +28,8 @@ import { import { emptyAnalysis } from '../../../types/empty-factories'; import { splitPhraseAtBoundary } from '../../../utils/phrase-arc'; import { FIXTURE_STAMPS } from '../../test-helpers'; -import { runParity } from './parity'; +import { EARLIER, ids, NOW, type ParityCase, runParity } from './parity'; -const NOW = '2026-06-01T00:00:00.000Z'; -const EARLIER = { createdAt: '2025-06-01T00:00:00.000Z', updatedAt: '2025-06-01T00:00:00.000Z' }; const STAMP = { forkId: 'fork', now: NOW }; const NEW = { linkId: 'new-link', analysisId: 'new-analysis' }; const SENSE = { authority: 'fwlite', senseId: 's-beginning' } as const; @@ -67,21 +65,12 @@ function analysisOf( return { ...emptyAnalysis(), phraseAnalyses, phraseAnalysisLinks }; } -const ids = (records: readonly { id: string }[]) => records.map(({ id }) => id).sort(); - const linkOf = (store: TextAnalysis, id: string) => store.phraseAnalysisLinks.find((l) => l.id === id); const payloadOf = (store: TextAnalysis, id: string) => store.phraseAnalyses.find((pa) => pa.id === id); -type ParityCase = { - analysis: TextAnalysis; - input: Input; - /** What the reducer's result must show, so the case exercises the path it names. */ - expectStore: (store: TextAnalysis) => void; -}; - /** * Runs `analysis` through both `action` and `command`. Checks the reducer's result with * `expectStore`, then checks that the command leaves the same analysis with consistent indexes. diff --git a/src/__tests__/services/commands/staleCommands.parity.test.ts b/src/__tests__/services/commands/staleCommands.parity.test.ts index 10e055a39..67755d3b1 100644 --- a/src/__tests__/services/commands/staleCommands.parity.test.ts +++ b/src/__tests__/services/commands/staleCommands.parity.test.ts @@ -5,7 +5,6 @@ import type { SegmentAnalysis, SegmentAnalysisLink, TextAnalysis, - TokenAnalysis, TokenAnalysisLink, } from 'interlinearizer'; import { @@ -22,11 +21,18 @@ import { } from '../../../store/analysisSlice'; import { emptyAnalysis } from '../../../types/empty-factories'; import { FIXTURE_STAMPS } from '../../test-helpers'; -import { runParity } from './parity'; - -function payload(id: string, surfaceText: string, fields: Partial = {}) { - return { id, ...FIXTURE_STAMPS, surfaceText, ...fields }; -} +import { + analysisOf, + EARLIER, + ids, + LATER, + NOW, + OTHER_TOKEN, + type ParityCase, + payload, + runParity, + TOKEN, +} from './parity'; function morpheme(id: string, form: string, fields: Partial = {}) { return { id, form, writingSystem: 'qaa', ...fields }; @@ -48,28 +54,6 @@ function link( }; } -function analysisOf( - tokenAnalyses: TokenAnalysis[], - tokenAnalysisLinks: TokenAnalysisLink[], -): TextAnalysis { - return { ...emptyAnalysis(), tokenAnalyses, tokenAnalysisLinks }; -} - -const NOW = '2026-06-01T00:00:00.000Z'; -const EARLIER = { createdAt: '2025-06-01T00:00:00.000Z', updatedAt: '2025-06-01T00:00:00.000Z' }; -const LATER = { createdAt: '2026-03-01T00:00:00.000Z', updatedAt: '2026-03-01T00:00:00.000Z' }; -const TOKEN = 'GEN 1:1:0'; -const OTHER_TOKEN = 'GEN 1:2:0'; - -type ParityCase = { - analysis: TextAnalysis; - input: Input; - /** What the reducer's result must show, so the case exercises the path it names. */ - expectStore: (store: TextAnalysis) => void; -}; - -const ids = (records: readonly { id: string }[]) => records.map(({ id }) => id).sort(); - describe('discardStaleAnalysis parity with the reducer', () => { const cases: [string, ParityCase<{ tokenRef: string }>][] = [ [ diff --git a/src/__tests__/services/commands/tokenCommands.parity.test.ts b/src/__tests__/services/commands/tokenCommands.parity.test.ts index 7d61825af..a83d34841 100644 --- a/src/__tests__/services/commands/tokenCommands.parity.test.ts +++ b/src/__tests__/services/commands/tokenCommands.parity.test.ts @@ -1,60 +1,28 @@ /// -import type { TextAnalysis, TokenAnalysis, TokenAnalysisLink } from 'interlinearizer'; import { approveAnalysisForToken as approveCommand, writeGloss as writeGlossCommand, } from '../../../services/commands/tokenCommands'; import { approveAnalysisForToken, writeGloss } from '../../../store/analysisSlice'; import { emptyAnalysis } from '../../../types/empty-factories'; -import { FIXTURE_STAMPS } from '../../test-helpers'; -import { runParity } from './parity'; - -const NOW = '2026-06-01T00:00:00.000Z'; -const EARLIER = { createdAt: '2025-06-01T00:00:00.000Z', updatedAt: '2025-06-01T00:00:00.000Z' }; -const LATER = { createdAt: '2026-03-01T00:00:00.000Z', updatedAt: '2026-03-01T00:00:00.000Z' }; - -function payload(id: string, surfaceText: string, fields: Partial = {}) { - return { id, ...FIXTURE_STAMPS, surfaceText, ...fields }; -} - -function link( - id: string, - analysisId: string, - tokenRef: string, - fields: Partial = {}, -): TokenAnalysisLink { - return { - id, - analysisId, - ...FIXTURE_STAMPS, - status: 'approved', - token: { tokenRef, surfaceText: 'in' }, - ...fields, - }; -} - -function analysisOf( - tokenAnalyses: TokenAnalysis[], - tokenAnalysisLinks: TokenAnalysisLink[], -): TextAnalysis { - return { ...emptyAnalysis(), tokenAnalyses, tokenAnalysisLinks }; -} - -const TOKEN = 'GEN 1:1:0'; -const OTHER_TOKEN = 'GEN 1:2:0'; - -type ParityCase = { - analysis: TextAnalysis; - input: Input; - /** What the reducer's result must show, so the case exercises the path it names. */ - expectStore: (store: TextAnalysis) => void; -}; +import { + analysisOf, + EARLIER, + expectParity, + ids, + LATER, + link, + NOW, + OTHER_TOKEN, + type ParityCase, + payload, + runParity, + TOKEN, +} from './parity'; type GlossInput = { surfaceText?: string; value: string }; -const ids = (records: readonly { id: string }[]) => records.map(({ id }) => id).sort(); - describe('writeGloss parity with the reducer', () => { const cases: [string, ParityCase][] = [ [ @@ -73,7 +41,7 @@ describe('writeGloss parity with the reducer', () => { { analysis: analysisOf( [payload('ta-in', 'in', { gloss: { en: 'at' } })], - [link('tl-other', 'ta-in', OTHER_TOKEN)], + [link('tl-other', 'ta-in', OTHER_TOKEN, 'in')], ), input: { value: 'at' }, expectStore: (store) => { @@ -89,7 +57,7 @@ describe('writeGloss parity with the reducer', () => { { analysis: analysisOf( [payload('ta-in', 'in', { gloss: { en: 'at' } })], - [link('tl-held', 'ta-in', TOKEN, { status: 'suggested', confidence: 'low' })], + [link('tl-held', 'ta-in', TOKEN, 'in', { status: 'suggested', confidence: 'low' })], ), input: { value: 'at' }, expectStore: (store) => { @@ -108,7 +76,7 @@ describe('writeGloss parity with the reducer', () => { glossSenseRef: { authority: 'fwlite', senseId: 's-at' }, }), ], - [link('tl-in', 'ta-in', TOKEN)], + [link('tl-in', 'ta-in', TOKEN, 'in')], ), input: { surfaceText: 'In,', value: 'at first' }, expectStore: (store) => { @@ -127,7 +95,7 @@ describe('writeGloss parity with the reducer', () => { { analysis: analysisOf( [payload('ta-in', 'In', { gloss: { en: 'at' } })], - [link('tl-in', 'ta-in', TOKEN), link('tl-other', 'ta-in', OTHER_TOKEN)], + [link('tl-in', 'ta-in', TOKEN, 'in'), link('tl-other', 'ta-in', OTHER_TOKEN, 'in')], ), input: { value: 'start' }, expectStore: (store) => { @@ -147,7 +115,7 @@ describe('writeGloss parity with the reducer', () => { morphemesStale: true, }), ], - [link('tl-in', 'ta-at', TOKEN), link('tl-other', 'ta-within', OTHER_TOKEN)], + [link('tl-in', 'ta-at', TOKEN, 'in'), link('tl-other', 'ta-within', OTHER_TOKEN, 'in')], ), input: { value: 'within' }, expectStore: (store) => { @@ -165,8 +133,8 @@ describe('writeGloss parity with the reducer', () => { payload('ta-within', 'In', { ...EARLIER, gloss: { en: 'within' } }), ], [ - link('tl-in', 'ta-at', TOKEN, LATER), - link('tl-offered', 'ta-within', TOKEN, { ...EARLIER, status: 'suggested' }), + link('tl-in', 'ta-at', TOKEN, 'in', LATER), + link('tl-offered', 'ta-within', TOKEN, 'in', { ...EARLIER, status: 'suggested' }), ], ), input: { value: 'within' }, @@ -189,8 +157,8 @@ describe('writeGloss parity with the reducer', () => { payload('ta-within', 'In', { ...EARLIER, gloss: { en: 'within' } }), ], [ - link('tl-in', 'ta-at', TOKEN), - link('tl-offered', 'ta-within', OTHER_TOKEN, { status: 'suggested' }), + link('tl-in', 'ta-at', TOKEN, 'in'), + link('tl-offered', 'ta-within', OTHER_TOKEN, 'in', { status: 'suggested' }), ], ), input: { value: 'within' }, @@ -209,7 +177,7 @@ describe('writeGloss parity with the reducer', () => { { analysis: analysisOf( [payload('ta-in', 'In', { gloss: { en: 'at' } })], - [link('tl-in', 'ta-in', TOKEN)], + [link('tl-in', 'ta-in', TOKEN, 'in')], ), input: { value: '' }, expectStore: (store) => { @@ -227,7 +195,7 @@ describe('writeGloss parity with the reducer', () => { glossSenseRef: { authority: 'fwlite', senseId: 's-at' }, }), ], - [link('tl-in', 'ta-in', TOKEN)], + [link('tl-in', 'ta-in', TOKEN, 'in')], ), input: { value: ' ' }, expectStore: (store) => { @@ -241,7 +209,7 @@ describe('writeGloss parity with the reducer', () => { { analysis: analysisOf( [payload('ta-in', 'In', { gloss: { en: 'at' } })], - [link('tl-in', 'ta-in', TOKEN), link('tl-other', 'ta-in', OTHER_TOKEN)], + [link('tl-in', 'ta-in', TOKEN, 'in'), link('tl-other', 'ta-in', OTHER_TOKEN, 'in')], ), input: { value: '' }, expectStore: (store) => { @@ -256,8 +224,8 @@ describe('writeGloss parity with the reducer', () => { analysis: analysisOf( [payload('ta-in', 'In', { gloss: { en: 'at' } })], [ - link('tl-in', 'ta-in', TOKEN), - link('tl-rejected', 'ta-in', TOKEN, { status: 'rejected' }), + link('tl-in', 'ta-in', TOKEN, 'in'), + link('tl-rejected', 'ta-in', TOKEN, 'in', { status: 'rejected' }), ], ), input: { value: '' }, @@ -275,7 +243,7 @@ describe('writeGloss parity with the reducer', () => { payload('ta-in', 'In', { gloss: { en: 'at' }, pos: 'prep' }), payload('ta-bare', 'in', { ...EARLIER, pos: 'prep' }), ], - [link('tl-in', 'ta-in', TOKEN)], + [link('tl-in', 'ta-in', TOKEN, 'in')], ), input: { value: '' }, expectStore: (store) => { @@ -289,7 +257,7 @@ describe('writeGloss parity with the reducer', () => { { analysis: analysisOf( [payload('ta-in', 'In', { gloss: { en: 'at' } })], - [link('tl-in', 'ta-in', TOKEN), link('tl-other', 'ta-in', OTHER_TOKEN)], + [link('tl-in', 'ta-in', TOKEN, 'in'), link('tl-other', 'ta-in', OTHER_TOKEN, 'in')], ), input: { value: 'at' }, expectStore: (store) => { @@ -308,7 +276,10 @@ describe('writeGloss parity with the reducer', () => { payload('ta-old', 'In', { gloss: { en: 'old' } }), payload('ta-new', 'In', { gloss: { en: 'new' } }), ], - [link('tl-old', 'ta-old', TOKEN, EARLIER), link('tl-new', 'ta-new', TOKEN, LATER)], + [ + link('tl-old', 'ta-old', TOKEN, 'in', EARLIER), + link('tl-new', 'ta-new', TOKEN, 'in', LATER), + ], ), input: { value: 'newer' }, expectStore: (store) => { @@ -331,7 +302,7 @@ describe('writeGloss parity with the reducer', () => { [ 'removes an approved link whose payload is gone before writing', { - analysis: analysisOf([], [link('tl-orphan', 'ta-missing', TOKEN)]), + analysis: analysisOf([], [link('tl-orphan', 'ta-missing', TOKEN, 'in')]), input: { value: 'at' }, expectStore: (store) => { expect(ids(store.tokenAnalysisLinks)).toStrictEqual(['new-link']); @@ -341,7 +312,7 @@ describe('writeGloss parity with the reducer', () => { [ 'removes an approved link whose payload is gone even on a blank write', { - analysis: analysisOf([], [link('tl-orphan', 'ta-missing', TOKEN)]), + analysis: analysisOf([], [link('tl-orphan', 'ta-missing', TOKEN, 'in')]), input: { value: '' }, expectStore: (store) => { expect(store).toStrictEqual(emptyAnalysis()); @@ -378,9 +349,7 @@ describe('writeGloss parity with the reducer', () => { }), ); - expectStore(outcome.store); - expect(outcome.host).toStrictEqual(outcome.store); - expect(outcome.indexDifferences).toStrictEqual([]); + expectParity(outcome, expectStore); }); }); @@ -412,7 +381,7 @@ describe('approveAnalysisForToken parity with the reducer', () => { { analysis: analysisOf( [payload('ta-in', 'In', { gloss: { en: 'at' } })], - [link('tl-held', 'ta-in', TOKEN, { status: 'candidate', confidence: 'guess' })], + [link('tl-held', 'ta-in', TOKEN, 'in', { status: 'candidate', confidence: 'guess' })], ), input: { analysisId: 'ta-in' }, expectStore: (store) => { @@ -432,7 +401,7 @@ describe('approveAnalysisForToken parity with the reducer', () => { payload('ta-at', 'In', { gloss: { en: 'at' } }), payload('ta-on', 'In', { gloss: { en: 'on' } }), ], - [link('tl-in', 'ta-at', TOKEN)], + [link('tl-in', 'ta-at', TOKEN, 'in')], ), input: { analysisId: 'ta-on' }, expectStore: (store) => { @@ -449,7 +418,7 @@ describe('approveAnalysisForToken parity with the reducer', () => { payload('ta-at', 'In', { gloss: { en: 'at' } }), payload('ta-on', 'In', { gloss: { en: 'on' } }), ], - [link('tl-in', 'ta-at', TOKEN), link('tl-other', 'ta-at', OTHER_TOKEN)], + [link('tl-in', 'ta-at', TOKEN, 'in'), link('tl-other', 'ta-at', OTHER_TOKEN, 'in')], ), input: { analysisId: 'ta-on' }, expectStore: (store) => { @@ -466,8 +435,8 @@ describe('approveAnalysisForToken parity with the reducer', () => { payload('ta-on', 'In', { gloss: { en: 'on' } }), ], [ - link('tl-in', 'ta-at', TOKEN, EARLIER), - link('tl-held', 'ta-on', TOKEN, { ...LATER, status: 'suggested' }), + link('tl-in', 'ta-at', TOKEN, 'in', EARLIER), + link('tl-held', 'ta-on', TOKEN, 'in', { ...LATER, status: 'suggested' }), ], ), input: { analysisId: 'ta-on' }, @@ -486,7 +455,7 @@ describe('approveAnalysisForToken parity with the reducer', () => { [ 'stages nothing for a payload not stored', { - analysis: analysisOf([], [link('tl-orphan', 'ta-missing', TOKEN)]), + analysis: analysisOf([], [link('tl-orphan', 'ta-missing', TOKEN, 'in')]), input: { analysisId: 'ta-unknown' }, expectStore: (store) => { expect(ids(store.tokenAnalysisLinks)).toStrictEqual(['tl-orphan']); @@ -498,7 +467,10 @@ describe('approveAnalysisForToken parity with the reducer', () => { { analysis: analysisOf( [payload('ta-in', 'In', { gloss: { en: 'at' } })], - [link('tl-in', 'ta-in', TOKEN, LATER), link('tl-orphan', 'ta-missing', TOKEN, EARLIER)], + [ + link('tl-in', 'ta-in', TOKEN, 'in', LATER), + link('tl-orphan', 'ta-missing', TOKEN, 'in', EARLIER), + ], ), input: { analysisId: 'ta-in' }, expectStore: (store) => { @@ -511,7 +483,7 @@ describe('approveAnalysisForToken parity with the reducer', () => { { analysis: analysisOf( [payload('ta-in', 'In', { gloss: { en: 'at' } })], - [link('tl-orphan', 'ta-missing', TOKEN)], + [link('tl-orphan', 'ta-missing', TOKEN, 'in')], ), input: { analysisId: 'ta-in' }, expectStore: (store) => { @@ -545,8 +517,6 @@ describe('approveAnalysisForToken parity with the reducer', () => { }), ); - expectStore(outcome.store); - expect(outcome.host).toStrictEqual(outcome.store); - expect(outcome.indexDifferences).toStrictEqual([]); + expectParity(outcome, expectStore); }); }); diff --git a/src/main.ts b/src/main.ts index 1118e6cda..bd3eee84a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -15,6 +15,7 @@ import { AnalysisSessions, type OpenedSession, type Session } from './services/a import * as bookService from './services/bookService'; import * as boundaryCommands from './services/commands/boundaryCommands'; import type { ChangeBuffer } from './services/commands/changeBuffer'; +import * as morphemeCommands from './services/commands/morphemeCommands'; import * as staleCommands from './services/commands/staleCommands'; import * as freeTranslationCommands from './services/commands/freeTranslationCommands'; import * as phraseCommands from './services/commands/phraseCommands'; @@ -871,7 +872,8 @@ function editStamp() { * Writes a token's gloss in the session's analysis language; a blank value clears it. * * @returns The changes it applied, as JSON. - * @throws As {@link runSessionEdit} does. + * @throws If the session is read-only or not open, or the edit is refused or fails. The error is + * logged before rethrowing. */ function writeGloss( sessionId: string, @@ -894,7 +896,8 @@ function writeGloss( * Approves a stored token analysis for a token, in place of any approval the token held. * * @returns The changes it applied, as JSON. - * @throws As {@link runSessionEdit} does. + * @throws If the session is read-only or not open, or the edit is refused or fails. The error is + * logged before rethrowing. */ function approveAnalysisForToken( sessionId: string, @@ -912,11 +915,75 @@ function approveAnalysisForToken( ); } +/** + * Writes a token's morpheme breakdown, each form in `writingSystem`. + * + * @returns The changes it applied, as JSON. + * @throws If the session is read-only or not open, or the edit is refused or fails. The error is + * logged before rethrowing. + */ +function writeMorphemes( + sessionId: string, + tokenRef: string, + surfaceText: string, + forms: string[], + writingSystem: string, +): Promise { + return runSessionEdit(sessionId, 'writeMorphemes', (buffer) => + morphemeCommands.writeMorphemes(buffer, { + ...editStamp(), + tokenRef, + surfaceText, + writingSystem, + morphemes: forms.map((form) => ({ id: crypto.randomUUID(), form })), + }), + ); +} + +/** + * Removes a token's morpheme breakdown. + * + * @returns The changes it applied, as JSON. + * @throws If the session is read-only or not open, or the edit is refused or fails. The error is + * logged before rethrowing. + */ +function deleteMorphemes(sessionId: string, tokenRef: string): Promise { + return runSessionEdit(sessionId, 'deleteMorphemes', (buffer) => + morphemeCommands.deleteMorphemes(buffer, { ...editStamp(), tokenRef }), + ); +} + +/** + * Glosses one morpheme of a token's breakdown in the session's analysis language; a blank value + * clears the morpheme's gloss. + * + * @returns The changes it applied, as JSON. + * @throws If the session is read-only or not open, or the edit is refused or fails. The error is + * logged before rethrowing. + */ +function writeMorphemeGloss( + sessionId: string, + tokenRef: string, + morphemeId: string, + value: string, +): Promise { + return runSessionEdit(sessionId, 'writeMorphemeGloss', (buffer, session) => + morphemeCommands.writeMorphemeGloss(buffer, { + ...editStamp(), + tokenRef, + morphemeId, + value, + analysisLanguage: session.analysisLanguage, + }), + ); +} + /** * Gives up the place an analysis went stale at, leaving its other places alone. * * @returns The changes it applied, as JSON. - * @throws As {@link runSessionEdit} does. + * @throws If the session is read-only or not open, or the edit is refused or fails. The error is + * logged before rethrowing. */ function discardStaleAnalysis( sessionId: string, @@ -933,7 +1000,8 @@ function discardStaleAnalysis( * whatever that token held. * * @returns The changes it applied, as JSON. - * @throws As {@link runSessionEdit} does. + * @throws If the session is read-only or not open, or the edit is refused or fails. The error is + * logged before rethrowing. */ function reapplyStaleAnalysis( sessionId: string, @@ -961,7 +1029,8 @@ function reapplyStaleAnalysis( * segment. * * @returns The changes it applied, as JSON. - * @throws As {@link runSessionEdit} does. + * @throws If the session is read-only or not open, or the edit is refused or fails. The error is + * logged before rethrowing. */ function keepStaleFreeTranslation( sessionId: string, @@ -983,7 +1052,8 @@ function keepStaleFreeTranslation( * Drops a stale free translation, even one whose verse the book no longer holds. * * @returns The changes it applied, as JSON. - * @throws As {@link runSessionEdit} does. + * @throws If the session is read-only or not open, or the edit is refused or fails. The error is + * logged before rethrowing. */ function discardStaleFreeTranslation(sessionId: string, analysisId: string): Promise { return runSessionEdit(sessionId, 'discardStaleFreeTranslation', (buffer) => @@ -1960,6 +2030,73 @@ export async function activate(context: ExecutionActivationContext): Promise & { + /** Id a forked token analysis takes, spent only when the removal forks a shared payload. */ + analysisId: string; +}; + +/** What glossing one morpheme of a token's breakdown needs. */ +export type WriteMorphemeGlossRequest = Pick & { + /** `MorphemeAnalysis.id` of the morpheme glossed. */ + morphemeId: string; + /** The gloss; blank clears the morpheme's gloss in `analysisLanguage`. */ + value: string; + /** BCP 47 tag of the language the gloss is written in. */ + analysisLanguage: string; + /** Id a forked token analysis takes, spent only when the edit forks a shared payload. */ + analysisId: string; +}; + +/** + * Stages writing a token's morpheme breakdown. The edit is per token: a payload that other tokens + * share is forked first. A form that the breakdown already carried keeps its morpheme whole, so + * links to that morpheme survive. Every morpheme takes `writingSystem`. The breakdown is never left + * marked stale. An edit that makes the payload identical to another folds it in. A token with no + * approved analysis takes a content-identical stored one, or a new one. + */ +export async function writeMorphemes( + buffer: ChangeBuffer, + request: WriteMorphemesRequest, +): Promise { + const { tokenRef, surfaceText, writingSystem, analysisId, morphemes, now } = request; + + const resolved = await resolveApprovedAnalysis(buffer, tokenRef); + if (resolved) { + const { link, analysis } = resolved; + const target = (await isSharedByOtherTokens(buffer, link, analysis.id)) + ? forkSharedAnalysis(buffer, link, analysis, analysisId, now) + : analysis; + target.surfaceText = surfaceText; + target.updatedAt = now; + link.token.surfaceText = surfaceText; + link.updatedAt = now; + target.morphemes = reconcileMorphemes(target.morphemes, morphemes, writingSystem); + delete target.morphemesStale; + buffer.put('tokenAnalyses', target); + buffer.put('tokenAnalysisLinks', link); + await mergeIntoIdenticalPayload(buffer, target, now); + return; + } + + await appendApprovedAnalysis( + buffer, + { + id: analysisId, + createdAt: now, + updatedAt: now, + surfaceText, + morphemes: morphemes.map(({ id, form }) => ({ id, form, writingSystem })), + }, + request, + ); +} + +/** + * Stages removing a token's morpheme breakdown and its stale mark. The edit is per token: a payload + * that other tokens share is forked first. A payload that the removal leaves empty is detached from + * the token and goes once nothing links it. A payload left with content folds into an identical + * payload, if one exists. It stages nothing for a token with no approved analysis, or one with no + * breakdown. + */ +export async function deleteMorphemes( + buffer: ChangeBuffer, + { tokenRef, analysisId, now }: DeleteMorphemesRequest, +): Promise { + const resolved = await resolveApprovedAnalysis(buffer, tokenRef); + if (!resolved?.analysis.morphemes) return; + const { link, analysis } = resolved; + + const target = (await isSharedByOtherTokens(buffer, link, analysis.id)) + ? forkSharedAnalysis(buffer, link, analysis, analysisId, now) + : analysis; + delete target.morphemes; + delete target.morphemesStale; + target.updatedAt = now; + link.updatedAt = now; + if (isEmptyTokenAnalysis(target)) { + await detachEmptiedAnalysis(buffer, target, link); + return; + } + buffer.put('tokenAnalyses', target); + buffer.put('tokenAnalysisLinks', link); + await mergeIntoIdenticalPayload(buffer, target, now); +} + +/** + * Stages glossing one morpheme of a token's approved breakdown. The edit is per token: a payload + * that other tokens share is forked first, the morpheme keeping its id on the fork. A blank value + * clears the morpheme's gloss in `analysisLanguage`. The morpheme and its analysis stay, even when + * no language's gloss is left. An edit or clear that makes the payload identical to another folds + * it in. It stages nothing for a token with no approved analysis, or one whose breakdown lacks the + * morpheme. + */ +export async function writeMorphemeGloss( + buffer: ChangeBuffer, + { + tokenRef, + morphemeId, + value, + analysisLanguage: lang, + analysisId, + now, + }: WriteMorphemeGlossRequest, +): Promise { + const resolved = await resolveApprovedAnalysis(buffer, tokenRef); + if (!resolved) return; + const { link, analysis } = resolved; + if (!analysis.morphemes?.some((m) => m.id === morphemeId)) return; + + const target = (await isSharedByOtherTokens(buffer, link, analysis.id)) + ? forkSharedAnalysis(buffer, link, analysis, analysisId, now) + : analysis; + const morpheme = target.morphemes?.find((m) => m.id === morphemeId); + /* v8 ignore next -- the fork keeps every morpheme id, so this always resolves */ + if (!morpheme) return; + + if (value.trim() === '') { + if (morpheme.gloss) { + delete morpheme.gloss[lang]; + if (Object.keys(morpheme.gloss).length === 0) delete morpheme.gloss; + } + } else { + if (!morpheme.gloss) morpheme.gloss = {}; + morpheme.gloss[lang] = value; + } + target.updatedAt = now; + link.updatedAt = now; + buffer.put('tokenAnalyses', target); + buffer.put('tokenAnalysisLinks', link); + await mergeIntoIdenticalPayload(buffer, target, now); +} diff --git a/src/services/commands/tokenCommands.ts b/src/services/commands/tokenCommands.ts index 53d35ccdb..0c45ac676 100644 --- a/src/services/commands/tokenCommands.ts +++ b/src/services/commands/tokenCommands.ts @@ -57,7 +57,7 @@ export async function resolveApprovedAnalysis( } /** Whether a token other than `link`'s links the payload `analysisId`, at any status. */ -async function isSharedByOtherTokens( +export async function isSharedByOtherTokens( buffer: ChangeBuffer, link: TokenAnalysisLink, analysisId: string, @@ -72,7 +72,7 @@ async function isSharedByOtherTokens( * * @returns The copy, staged. */ -function forkSharedAnalysis( +export function forkSharedAnalysis( buffer: ChangeBuffer, link: TokenAnalysisLink, analysis: TokenAnalysis, @@ -110,6 +110,21 @@ async function approveHeldLink( return held; } +/** + * Removes `link` after an edit through it emptied its payload. The payload goes too when no other + * link names it, and otherwise stays, emptied, as the edit left it. + */ +export async function detachEmptiedAnalysis( + buffer: ChangeBuffer, + analysis: TokenAnalysis, + link: TokenAnalysisLink, +): Promise { + buffer.delete('tokenAnalysisLinks', link); + const { tokenAnalysisLinks } = await buffer.findLinksToAnalyses([analysis.id]); + if (tokenAnalysisLinks.length === 0) buffer.delete('tokenAnalyses', analysis); + else buffer.put('tokenAnalyses', analysis); +} + /** Removes the payload once no link of any status names it. */ export async function reclaimIfUnlinked( buffer: ChangeBuffer, @@ -154,7 +169,7 @@ async function coalesceLinksPerToken(buffer: ChangeBuffer, analysisId: string): * that links both keeps one link. The survivor's breakdown stays stale only when both payloads' * were. */ -async function mergeIntoIdenticalPayload( +export async function mergeIntoIdenticalPayload( buffer: ChangeBuffer, analysis: TokenAnalysis, now: string, @@ -184,7 +199,7 @@ async function mergeIntoIdenticalPayload( * stored payload is shared where one exists. Otherwise `analysis` is staged. A non-approved link * that the token holds to the shared payload is approved, rather than joined by a second link. */ -async function appendApprovedAnalysis( +export async function appendApprovedAnalysis( buffer: ChangeBuffer, analysis: TokenAnalysis, { tokenRef, now, linkId }: TokenEdit, @@ -233,10 +248,7 @@ export async function writeGloss(buffer: ChangeBuffer, request: WriteGlossReques if (isBlank) { clearAnalysisGloss(target, lang); if (isEmptyTokenAnalysis(target)) { - buffer.delete('tokenAnalysisLinks', link); - const { tokenAnalysisLinks } = await buffer.findLinksToAnalyses([target.id]); - if (tokenAnalysisLinks.length === 0) buffer.delete('tokenAnalyses', target); - else buffer.put('tokenAnalyses', target); + await detachEmptiedAnalysis(buffer, target, link); return; } } else { diff --git a/src/types/interlinearizer.d.ts b/src/types/interlinearizer.d.ts index bec6421ce..90a49c465 100644 --- a/src/types/interlinearizer.d.ts +++ b/src/types/interlinearizer.d.ts @@ -422,6 +422,45 @@ declare module 'papi-shared-types' { analysisId: string, ) => Promise; + /** + * Writes a token's morpheme breakdown, each form in `writingSystem`. + * + * @returns A JSON string of the changes it applied. + * @throws If the session is read-only or not open, or the edit is refused or fails (logged + * before rethrowing). + */ + 'interlinearizer.writeMorphemes': ( + sessionId: string, + tokenRef: string, + surfaceText: string, + forms: string[], + writingSystem: string, + ) => Promise; + + /** + * Removes a token's morpheme breakdown. + * + * @returns A JSON string of the changes it applied. + * @throws If the session is read-only or not open, or the edit is refused or fails (logged + * before rethrowing). + */ + 'interlinearizer.deleteMorphemes': (sessionId: string, tokenRef: string) => Promise; + + /** + * Glosses one morpheme of a token's breakdown in a session's analysis language; a blank value + * clears the morpheme's gloss. + * + * @returns A JSON string of the changes it applied. + * @throws If the session is read-only or not open, or the edit is refused or fails (logged + * before rethrowing). + */ + 'interlinearizer.writeMorphemeGloss': ( + sessionId: string, + tokenRef: string, + morphemeId: string, + value: string, + ) => Promise; + /** * Gives up the place an analysis went stale at, leaving its other places alone. *