diff --git a/AGENTS.md b/AGENTS.md index 4ae01e83a..9805e188c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,7 +35,7 @@ This is a **Platform.Bible extension** for interlinear Bible text alignment. Pla [src/main.ts](src/main.ts) — called by Platform.Bible on activation. Exports two lifecycle functions: -- `activate(context)` — stores the `ExecutionToken`, registers the `interlinearizer.mainWebView` WebView provider, command handlers, the `interlinearizer.continuousScroll` project settings validator, and the `onDidOpenWebView` / `onDidCloseWebView` subscriptions. All registrations are added to `context.registrations` so the platform disposes them on deactivation. +- `activate(context)` — stores the `ExecutionToken`, registers the `interlinearizer.mainWebView` WebView provider, command handlers, the `interlinearizer.onUndoFromNotification` network event emitter, the `interlinearizer.continuousScroll` project settings validator, and the `onDidOpenWebView` / `onDidCloseWebView` subscriptions. All registrations are added to `context.registrations` so the platform disposes them on deactivation. - `deactivate()` — clears `openWebViewsByProject` and returns `true`. `openWebViewsByProject` (`Map`) tracks one open WebView ID per project to prevent duplicates; reopening an already-open project brings that tab to front via the `existingId` option. diff --git a/__mocks__/papi-backend.ts b/__mocks__/papi-backend.ts index 26bfef0bf..821d2b78b 100644 --- a/__mocks__/papi-backend.ts +++ b/__mocks__/papi-backend.ts @@ -17,6 +17,7 @@ const mockDeleteUserData = jest.fn(); const mockNotificationsSend = jest.fn(); const mockProjectDataProvidersGet = jest.fn(); const mockGetLocalizedString = jest.fn(); +const mockCreateNetworkEventEmitterAsync = jest.fn(); const mockLogger = { debug: jest.fn(), error: jest.fn(), @@ -43,6 +44,9 @@ const papi = { localization: { getLocalizedString: mockGetLocalizedString, }, + network: { + createNetworkEventEmitterAsync: mockCreateNetworkEventEmitterAsync, + }, storage: { readUserData: mockReadUserData, writeUserData: mockWriteUserData, @@ -79,6 +83,7 @@ const defaultExport = { __mockNotificationsSend: mockNotificationsSend, __mockProjectDataProvidersGet: mockProjectDataProvidersGet, __mockGetLocalizedString: mockGetLocalizedString, + __mockCreateNetworkEventEmitterAsync: mockCreateNetworkEventEmitterAsync, __mockLogger: mockLogger, }; diff --git a/__mocks__/papi-frontend.ts b/__mocks__/papi-frontend.ts index 338952063..21a3dad47 100644 --- a/__mocks__/papi-frontend.ts +++ b/__mocks__/papi-frontend.ts @@ -12,6 +12,9 @@ const mockLogger = { const mockSendCommand = jest.fn(); const mockNotificationsSend = jest.fn(); +const mockNotificationsDismiss = jest.fn(); +const mockGetLocalizedString = jest.fn(); +const mockGetNetworkEvent = jest.fn(); const mockProjectDataProvidersGet = jest.fn(); const mockNetworkObjectsGet = jest.fn(); const mockWaitForNetworkObject = jest.fn(); @@ -22,6 +25,13 @@ const papi = { }, notifications: { send: mockNotificationsSend, + dismiss: mockNotificationsDismiss, + }, + localization: { + getLocalizedString: mockGetLocalizedString, + }, + network: { + getNetworkEvent: mockGetNetworkEvent, }, menuData: { dataProviderName: 'platform.menuDataServiceDataProvider', diff --git a/__mocks__/platform-bible-react.tsx b/__mocks__/platform-bible-react.tsx index 65757fcd2..ee9c3e023 100644 --- a/__mocks__/platform-bible-react.tsx +++ b/__mocks__/platform-bible-react.tsx @@ -116,6 +116,24 @@ export const MOCK_WIPE_MENU_ITEM: MenuItemContainingCommand = { localizeNotes: '', }; +/** Sentinel menu item passed by the mock toolbar when the undo button is clicked. */ +export const MOCK_UNDO_MENU_ITEM: MenuItemContainingCommand = { + label: '%interlinearizer_undo%', + command: 'interlinearizer.undo', + group: 'interlinearizer.editActions', + order: 1, + localizeNotes: '', +}; + +/** Sentinel menu item passed by the mock toolbar when the redo button is clicked. */ +export const MOCK_REDO_MENU_ITEM: MenuItemContainingCommand = { + label: '%interlinearizer_redo%', + command: 'interlinearizer.redo', + group: 'interlinearizer.editActions', + order: 2, + localizeNotes: '', +}; + /** Sentinel menu item passed by the mock toolbar when the analysis-catalog button is clicked. */ export const MOCK_OPEN_ANALYSIS_CATALOG_MENU_ITEM: MenuItemContainingCommand = { label: '%interlinearizer_openAnalysisCatalog%', @@ -247,6 +265,24 @@ export function TabToolbar({ Wipe )} + {onSelectProjectMenuItem && ( + + )} + {onSelectProjectMenuItem && ( + + )} {onSelectProjectMenuItem && ( + {onRedoClick && ( + + )} + + ); +} + /** * Stub toggle switch rendered as a native checkbox so tests can read and change the checked state * without the real Radix UI implementation. diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index 7915705b5..b6802faee 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -17,6 +17,20 @@ "%interlinearizer_openLexiconChooser%": "Connect a Lexicon…", "%interlinearizer_error_openLexiconChooser_failed%": "The lexicon chooser could not be opened.", + "%interlinearizer_menu_column_edit%": "Edit", + "%interlinearizer_undo%": "Undo", + "%interlinearizer_redo%": "Redo", + "%interlinearizer_undone_catalogEdit%": "Undid an edit to the analysis of {form}.", + "%interlinearizer_redone_catalogEdit%": "Redid an edit to the analysis of {form}.", + "%interlinearizer_undone_catalogMerge%": "Undid a merge into the analysis of {form}.", + "%interlinearizer_redone_catalogMerge%": "Redid a merge into the analysis of {form}.", + "%interlinearizer_undone_catalogDelete%": "Undid deleting the analysis of {form} ({count} uses).", + "%interlinearizer_redone_catalogDelete%": "Redid deleting the analysis of {form} ({count} uses).", + "%interlinearizer_undone_wipeBook%": "Undid wiping {book}.", + "%interlinearizer_redone_wipeBook%": "Redid wiping {book}.", + "%interlinearizer_undone_wipeAll%": "Undid wiping the whole draft.", + "%interlinearizer_redone_wipeAll%": "Redid wiping the whole draft.", + "%interlinearizer_menu_column_view%": "View", "%interlinearizer_openAnalysisCatalog%": "Analysis Catalog", "%interlinearizer_openConcordance%": "Concordance", @@ -113,16 +127,13 @@ "%interlinearizer_analysisCatalog_mergedDismiss%": "Dismiss", "%interlinearizer_analysisCatalog_draftStranded%": "An edit elsewhere removed the analysis of {form}, discarding the breakdown you were typing for it.", "%interlinearizer_analysisCatalog_delete%": "Delete", - "%interlinearizer_analysisCatalog_deleteTitle%": "Delete the analysis of {form}?", - "%interlinearizer_analysisCatalog_deleteBlank%": "{count} uses will be left with no analysis.", - "%interlinearizer_analysisCatalog_deleteBlankNone%": "No token is analyzed as this, so nothing on screen changes.", - "%interlinearizer_analysisCatalog_deleteFallback%": "{count} uses will fall back to {gloss}.", - "%interlinearizer_analysisCatalog_deleteFallbackNoGloss%": "{count} uses will fall back to another analysis of the same form.", - "%interlinearizer_analysisCatalog_deleteFallbackUncertain%": "{count} uses will each fall back to another analysis or be left with none, depending on the use.", - "%interlinearizer_analysisCatalog_deleteUnapplied%": "{count} tokens also record this analysis without using it; those records are deleted too.", - "%interlinearizer_analysisCatalog_deleteUndoWarning%": "This cannot be undone.", - "%interlinearizer_analysisCatalog_deleteCancel%": "Cancel", - "%interlinearizer_analysisCatalog_deleteConfirm%": "Delete", + "%interlinearizer_analysisCatalog_deleted%": "Deleted the analysis of {form}.", + "%interlinearizer_analysisCatalog_deleteBlank%": "{count} uses are left with no analysis.", + "%interlinearizer_analysisCatalog_deleteBlankNone%": "No token was analyzed as this, so nothing on screen changed.", + "%interlinearizer_analysisCatalog_deleteFallback%": "{count} uses fall back to {gloss}.", + "%interlinearizer_analysisCatalog_deleteFallbackNoGloss%": "{count} uses fall back to another analysis of the same form.", + "%interlinearizer_analysisCatalog_deleteFallbackUncertain%": "{count} uses each fall back to another analysis or are left with none, depending on the use.", + "%interlinearizer_analysisCatalog_deleteUnapplied%": "{count} tokens also recorded this analysis without using it; those records were deleted too.", "%interlinearizer_analysisCatalog_closeConfirmTitle%": "Discard the unsaved breakdown?", "%interlinearizer_analysisCatalog_closeConfirmPrompt%": "You have typed a morpheme breakdown but not saved it. Closing the catalog discards it.", "%interlinearizer_analysisCatalog_closeConfirmCancel%": "Keep editing", diff --git a/contributions/menus.json b/contributions/menus.json index 67d7967cb..061871fca 100644 --- a/contributions/menus.json +++ b/contributions/menus.json @@ -22,10 +22,15 @@ "localizeNotes": "Interlinearizer top menu column for project actions", "order": 1 }, + "interlinearizer.edit": { + "label": "%interlinearizer_menu_column_edit%", + "localizeNotes": "Interlinearizer top menu column for undoing and redoing edits", + "order": 2 + }, "interlinearizer.view": { "label": "%interlinearizer_menu_column_view%", "localizeNotes": "Interlinearizer top menu column for opening views of the analysis", - "order": 2 + "order": 3 } }, "groups": { @@ -45,6 +50,10 @@ "column": "interlinearizer.project", "order": 4 }, + "interlinearizer.editActions": { + "column": "interlinearizer.edit", + "order": 1 + }, "interlinearizer.viewActions": { "column": "interlinearizer.view", "order": 1 @@ -100,6 +109,20 @@ "order": 1, "command": "interlinearizer.openLexiconChooser" }, + { + "label": "%interlinearizer_undo%", + "localizeNotes": "Interlinearizer top menu > Edit > Undo the latest edit to the draft", + "group": "interlinearizer.editActions", + "order": 1, + "command": "interlinearizer.undo" + }, + { + "label": "%interlinearizer_redo%", + "localizeNotes": "Interlinearizer top menu > Edit > Redo the most recently undone edit", + "group": "interlinearizer.editActions", + "order": 2, + "command": "interlinearizer.redo" + }, { "label": "%interlinearizer_openAnalysisCatalog%", "localizeNotes": "Interlinearizer top menu > Open the analysis catalog, listing every analysis recorded in the draft with its usage counts and locations", diff --git a/src/__tests__/components/AnalysisCatalogPanel.test.tsx b/src/__tests__/components/AnalysisCatalogPanel.test.tsx index 19c49ef4b..4e9d9f782 100644 --- a/src/__tests__/components/AnalysisCatalogPanel.test.tsx +++ b/src/__tests__/components/AnalysisCatalogPanel.test.tsx @@ -6,10 +6,13 @@ import type { SerializedVerseRef } from '@sillsdev/scripture'; import { act, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { AssignmentStatus, TextAnalysis, TokenAnalysisLink } from 'interlinearizer'; -import { useEffect, useState, type ReactNode } from 'react'; -import AnalysisCatalogPanel from '../../components/AnalysisCatalogPanel'; +import { createRef, useEffect, useState, type ReactNode, type Ref } from 'react'; +import AnalysisCatalogPanel, { + type AnalysisCatalogPanelHandle, +} from '../../components/AnalysisCatalogPanel'; import { AnalysisStoreProvider, useGlossDispatch } from '../../components/AnalysisStore'; import { InterlinearNavProvider, useInterlinearNav } from '../../components/InterlinearNavContext'; +import type { StepSummary } from '../../hooks/useDraftProject'; import { emptyAnalysis } from '../../types/empty-factories'; import type { HeadingPlacement } from '../../utils/analysis-query'; import { defaultScrRef, FIXTURE_STAMPS, makeScrollGroupHook } from '../test-helpers'; @@ -82,8 +85,11 @@ function FocusPublishProbe({ tokenRef }: Readonly<{ tokenRef: string | undefined /** Options every `renderPanel` call may override. */ type PanelOptions = Partial<{ + ref: Ref; onClose: () => void; onShowConcordance: () => void; + asOneStep: (action: () => T, summary: StepSummary | ((result: T) => StepSummary)) => T; + announceUndoable: (message: string) => void; currentBook: string; analysis: TextAnalysis; analysisLanguage: string; @@ -151,6 +157,9 @@ function renderPanel(overrides: PanelOptions = {}) { return render( { ).not.toBeInTheDocument(); }); + it('marks the gloss input as holding nothing uncommitted', async () => { + renderPanel({ analysis: SHARED }); + + const row = await expandRow('ta-1'); + + expect(within(row).getByTestId('catalog-row-gloss-input')).toHaveAttribute( + 'data-draft-field', + 'committed', + ); + }); + + it('marks the gloss input as holding uncommitted text once typed into', async () => { + renderPanel({ analysis: SHARED }); + + const row = await expandRow('ta-1'); + const input = within(row).getByTestId('catalog-row-gloss-input'); + await userEvent.type(input, 's'); + + expect(input).toHaveAttribute('data-draft-field', 'pending'); + }); + it('rewrites the gloss for every token linked to the analysis', async () => { const onSave = jest.fn(); renderPanel({ analysis: SHARED, onSave }); @@ -2168,7 +2198,7 @@ describe('AnalysisCatalogPanel', () => { '{Escape}', ); - // A confirmation exists because the loss is irreversible, so nothing writes until it is + // A confirmation exists because the loss is destructive, so nothing writes until it is // taken, whichever way the panel is left. expect(within(rowFor('ta-1')).getByTestId('catalog-row-editor')).not.toHaveTextContent( '%interlinearizer_analysisCatalog_confirmResetPrompt%', @@ -2510,9 +2540,8 @@ describe('AnalysisCatalogPanel', () => { it('does not report a draft the panel deleted the row for itself', async () => { await typeStrandableBreakdown(); await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-delete')); - await userEvent.click(screen.getByTestId('catalog-close-discard')); - await userEvent.click(screen.getByTestId('catalog-delete-confirm')); + await userEvent.click(screen.getByTestId('catalog-close-discard')); expect(screen.queryByTestId('catalog-stranded-draft-notice')).not.toBeInTheDocument(); }); @@ -3047,6 +3076,16 @@ describe('AnalysisCatalogPanel', () => { expect(screen.queryByTestId('catalog-merge-notice')).not.toBeInTheDocument(); }); + it('takes the notice down once a row is revealed', async () => { + const ref = createRef(); + renderPanel({ ref, analysis: TWO_HOMOGRAPHS }); + await editIntoEquality(); + + act(() => ref.current?.revealRow('ta-2')); + + expect(screen.queryByTestId('catalog-merge-notice')).not.toBeInTheDocument(); + }); + // Neither homograph is linked, so the survivor inherits no usages to carry it up the listing // and stays wherever its gloss sorts it — here past the end of the window's first chunk. it('mounts a survivor the window would otherwise leave off', async () => { @@ -3633,7 +3672,229 @@ describe('AnalysisCatalogPanel', () => { }); }); + describe('undo steps', () => { + /** A step grouper that runs each action as it comes, recording each step's summary. */ + function spyOnSteps() { + const summaries: StepSummary[] = []; + const asOneStep = ( + action: () => T, + summary: StepSummary | ((result: T) => StepSummary), + ): T => { + const result = action(); + summaries.push(typeof summary === 'function' ? summary(result) : summary); + return result; + }; + return { asOneStep, summaries }; + } + + it('summarizes a gloss edit as an edit to its analysis', async () => { + const { asOneStep, summaries } = spyOnSteps(); + renderPanel({ + asOneStep, + analysis: { + ...emptyAnalysis(), + tokenAnalyses: [{ ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'λόγος' }], + tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0')], + }, + }); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + + await userEvent.type(within(rowFor('ta-1')).getByTestId('catalog-row-gloss-input'), 'word'); + await userEvent.tab(); + + expect(summaries).toContainEqual({ + kind: 'catalogEdit', + form: 'λόγος', + analysisId: 'ta-1', + survivingAnalysisId: 'ta-1', + }); + }); + + it('names the analysis an edit collapsed its row onto as the survivor', async () => { + const { asOneStep, summaries } = spyOnSteps(); + renderPanel({ + asOneStep, + analysis: { + ...emptyAnalysis(), + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'ἀρχῇ', gloss: { en: 'start' } }, + { ...FIXTURE_STAMPS, id: 'ta-2', surfaceText: 'ἀρχῇ', gloss: { en: 'beginning' } }, + ], + tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0'), link('ta-2', 'GEN 1:3:4')], + }, + }); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + const input = within(rowFor('ta-1')).getByTestId('catalog-row-gloss-input'); + + await userEvent.clear(input); + await userEvent.type(input, 'beginning'); + await userEvent.tab(); + + expect(summaries).toContainEqual({ + kind: 'catalogEdit', + form: 'ἀρχῇ', + analysisId: 'ta-1', + survivingAnalysisId: 'ta-2', + }); + }); + + it('names no survivor for an edit that empties its record away', async () => { + const { asOneStep, summaries } = spyOnSteps(); + renderPanel({ + asOneStep, + analysis: { + ...emptyAnalysis(), + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'λόγος', gloss: { en: 'word' } }, + ], + tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0')], + }, + }); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + + await userEvent.clear(within(rowFor('ta-1')).getByTestId('catalog-row-gloss-input')); + await userEvent.tab(); + + expect(summaries).toContainEqual({ kind: 'catalogEdit', form: 'λόγος', analysisId: 'ta-1' }); + }); + + it('summarizes a merge as a merge into its analysis', async () => { + const { asOneStep, summaries } = spyOnSteps(); + renderPanel({ + asOneStep, + analysis: { + ...emptyAnalysis(), + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'ἀρχῇ', gloss: { en: 'start' } }, + { ...FIXTURE_STAMPS, id: 'ta-2', surfaceText: 'ἀρχῇ', gloss: { en: 'beginning' } }, + ], + tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0'), link('ta-2', 'GEN 1:3:4')], + }, + }); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-merge')); + await userEvent.click(mergeCheckFor('ta-2')); + await userEvent.click(screen.getByTestId('catalog-merge-confirm')); + + expect(summaries).toContainEqual({ + kind: 'catalogMerge', + form: 'ἀρχῇ', + analysisId: 'ta-1', + survivingAnalysisId: 'ta-1', + }); + }); + + it('names the analysis a converging merge left standing as the survivor', async () => { + const { asOneStep, summaries } = spyOnSteps(); + renderPanel({ + asOneStep, + analysisLanguage: 'en', + analysis: { + ...emptyAnalysis(), + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'ἀρχῇ', gloss: { en: 'start' } }, + { ...FIXTURE_STAMPS, id: 'ta-2', surfaceText: 'ἀρχῇ', gloss: { en: 'beginning' } }, + { ...FIXTURE_STAMPS, id: 'ta-3', surfaceText: 'ἀρχῇ', gloss: { en: 'origin' } }, + ], + tokenAnalysisLinks: [ + link('ta-1', 'GEN 1:1:0'), + link('ta-2', 'GEN 1:3:4'), + link('ta-3', 'GEN 2:7:2'), + ], + }, + }); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-merge')); + await userEvent.click(mergeCheckFor('ta-2')); + const gloss = screen.getByTestId('catalog-merge-content-gloss'); + + await userEvent.clear(gloss); + await userEvent.type(gloss, 'origin'); + await userEvent.click(screen.getByTestId('catalog-merge-confirm')); + + expect(summaries).toContainEqual({ + kind: 'catalogMerge', + form: 'ἀρχῇ', + analysisId: 'ta-1', + survivingAnalysisId: 'ta-3', + }); + }); + + it('summarizes a deletion as deleting its analysis', async () => { + const { asOneStep, summaries } = spyOnSteps(); + renderPanel({ + asOneStep, + analysis: { + ...emptyAnalysis(), + tokenAnalyses: [{ ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'λόγος' }], + tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0')], + }, + }); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-delete')); + + expect(summaries).toContainEqual({ + kind: 'catalogDelete', + form: 'λόγος', + analysisId: 'ta-1', + count: 1, + }); + }); + }); + + describe('revealing a row', () => { + it('mounts a row asked for that the window would otherwise leave off', async () => { + const ref = createRef(); + renderPanel({ + ref, + analysis: { + ...emptyAnalysis(), + tokenAnalyses: Array.from({ length: 100 }, (_unused, index) => ({ + ...FIXTURE_STAMPS, + id: `filler-${index}`, + surfaceText: `word${index}`, + gloss: { en: `g${String(index).padStart(3, '0')}` }, + })), + tokenAnalysisLinks: [], + }, + }); + await userEvent.click(screen.getByTestId('catalog-sort-gloss')); + expect(listedAnalysisIds()).not.toContain('filler-99'); + + act(() => ref.current?.revealRow('filler-99')); + + expect(listedAnalysisIds()).toContain('filler-99'); + }); + + it('stops holding a revealed row mounted once the listing changes', async () => { + const ref = createRef(); + renderPanel({ + ref, + analysis: { + ...emptyAnalysis(), + tokenAnalyses: Array.from({ length: 100 }, (_unused, index) => ({ + ...FIXTURE_STAMPS, + id: `filler-${index}`, + surfaceText: `word${index}`, + gloss: { en: `g${String(index).padStart(3, '0')}` }, + })), + tokenAnalysisLinks: [], + }, + }); + await userEvent.click(screen.getByTestId('catalog-sort-gloss')); + act(() => ref.current?.revealRow('filler-99')); + + // Matches every row, so filler-99 stays in the listing, past the end of its first chunk. + await userEvent.type(searchBox(), 'word'); + + expect(listedAnalysisIds()).not.toContain('filler-99'); + }); + }); + describe('deleting a row', () => { + const announceUndoable = jest.fn(); /** One analysis nothing else shares a form with, so deleting it leaves its token blank. */ const LONE: TextAnalysis = { ...emptyAnalysis(), @@ -3643,19 +3904,19 @@ describe('AnalysisCatalogPanel', () => { tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0'), link('ta-1', 'GEN 1:3:4')], }; - /** Expands the row and opens its delete confirmation. */ - async function openDeleteConfirm(analysisId: string): Promise { + /** Expands the row and deletes it. */ + async function deleteRow(analysisId: string): Promise { await userEvent.click(within(rowFor(analysisId)).getByTestId('catalog-row-toggle')); await userEvent.click(within(rowFor(analysisId)).getByTestId('catalog-row-delete')); } it('states that the uses are left blank when no homograph survives', async () => { - renderPanel({ analysis: LONE }); + renderPanel({ announceUndoable, analysis: LONE }); - await openDeleteConfirm('ta-1'); + await deleteRow('ta-1'); - expect(screen.getByTestId('catalog-delete-outcome')).toHaveTextContent( - '%interlinearizer_analysisCatalog_deleteBlank%', + expect(announceUndoable).toHaveBeenCalledWith( + expect.stringContaining('%interlinearizer_analysisCatalog_deleteBlank%'), ); }); @@ -3672,14 +3933,14 @@ describe('AnalysisCatalogPanel', () => { link('ta-2', 'GEN 2:7:2'), ], }; - renderPanel({ analysis, showSuggestions: true }); + renderPanel({ announceUndoable, analysis, showSuggestions: true }); - await openDeleteConfirm('ta-1'); + await deleteRow('ta-1'); - // The two outcomes must be told apart: this copy is the only guard before an irreversible - // delete, and promising a fallback that does not exist is worse than no confirmation at all. - expect(screen.getByTestId('catalog-delete-outcome')).toHaveTextContent( - '%interlinearizer_analysisCatalog_deleteFallback%', + // The two outcomes must be told apart: this copy is all the reader learns of what the delete + // did, and promising a fallback that does not exist is worse than saying nothing. + expect(announceUndoable).toHaveBeenCalledWith( + expect.stringContaining('%interlinearizer_analysisCatalog_deleteFallback%'), ); }); @@ -3699,69 +3960,73 @@ describe('AnalysisCatalogPanel', () => { it('describes rather than names the fallback when a use has drifted off its analyzed form', async () => { renderPanel({ + announceUndoable, analysis: TWO_HOMOGRAPHS, // Analyzed as "ἀρχῇ", but the baseline beneath it now reads otherwise. liveSurfaceText: (ref) => (ref === 'GEN 1:3:4' ? 'ἀρχή' : 'ἀρχῇ'), showSuggestions: true, }); - await openDeleteConfirm('ta-1'); + await deleteRow('ta-1'); - expect(screen.getByTestId('catalog-delete-outcome')).toHaveTextContent( - '%interlinearizer_analysisCatalog_deleteFallbackUncertain%', + expect(announceUndoable).toHaveBeenCalledWith( + expect.stringContaining('%interlinearizer_analysisCatalog_deleteFallbackUncertain%'), ); }); it('describes rather than names the fallback when a use sits in an unloaded book', async () => { renderPanel({ + announceUndoable, analysis: TWO_HOMOGRAPHS, liveSurfaceText: (ref) => (ref === 'GEN 1:3:4' ? undefined : 'ἀρχῇ'), showSuggestions: true, }); - await openDeleteConfirm('ta-1'); + await deleteRow('ta-1'); - expect(screen.getByTestId('catalog-delete-outcome')).toHaveTextContent( - '%interlinearizer_analysisCatalog_deleteFallbackUncertain%', + expect(announceUndoable).toHaveBeenCalledWith( + expect.stringContaining('%interlinearizer_analysisCatalog_deleteFallbackUncertain%'), ); }); it('states that nothing else changes when the analysis is used nowhere', async () => { const analysis: TextAnalysis = { ...LONE, tokenAnalysisLinks: [] }; - renderPanel({ analysis }); + renderPanel({ announceUndoable, analysis }); - await openDeleteConfirm('ta-1'); + await deleteRow('ta-1'); - expect(screen.getByTestId('catalog-delete-outcome')).toHaveTextContent( - '%interlinearizer_analysisCatalog_deleteBlankNone%', + expect(announceUndoable).toHaveBeenCalledWith( + expect.stringContaining('%interlinearizer_analysisCatalog_deleteBlankNone%'), ); }); it('says nothing about unapplied assignments when there are none', async () => { const analysis: TextAnalysis = { ...LONE, tokenAnalysisLinks: [] }; - renderPanel({ analysis }); + renderPanel({ announceUndoable, analysis }); - await openDeleteConfirm('ta-1'); + await deleteRow('ta-1'); - expect(screen.queryByTestId('catalog-delete-unapplied')).not.toBeInTheDocument(); + expect(announceUndoable).toHaveBeenCalledWith( + expect.not.stringContaining('%interlinearizer_analysisCatalog_deleteUnapplied%'), + ); }); - // An imported analysis no token approves still shows on screen nowhere, so the outcome line - // rightly says nothing changes — this second line is what tells the reader data goes with it. + // An imported analysis no token approves still shows on screen nowhere, so the outcome sentence + // rightly says nothing changes — this further sentence is what tells the reader data goes with it. it('warns that unapplied assignments are deleted too', async () => { const analysis: TextAnalysis = { ...LONE, tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0', 'candidate')], }; - renderPanel({ analysis }); + renderPanel({ announceUndoable, analysis }); - await openDeleteConfirm('ta-1'); + await deleteRow('ta-1'); - expect(screen.getByTestId('catalog-delete-outcome')).toHaveTextContent( - '%interlinearizer_analysisCatalog_deleteBlankNone%', + expect(announceUndoable).toHaveBeenCalledWith( + expect.stringContaining('%interlinearizer_analysisCatalog_deleteBlankNone%'), ); - expect(screen.getByTestId('catalog-delete-unapplied')).toHaveTextContent( - '%interlinearizer_analysisCatalog_deleteUnapplied%', + expect(announceUndoable).toHaveBeenCalledWith( + expect.stringContaining('%interlinearizer_analysisCatalog_deleteUnapplied%'), ); }); @@ -3786,101 +4051,26 @@ describe('AnalysisCatalogPanel', () => { }; it('describes a fallback that carries no gloss rather than naming it', async () => { - renderPanel({ analysis: UNGLOSSED_FALLBACK, showSuggestions: true }); + renderPanel({ announceUndoable, analysis: UNGLOSSED_FALLBACK, showSuggestions: true }); - await openDeleteConfirm('ta-1'); + await deleteRow('ta-1'); - expect(screen.getByTestId('catalog-delete-outcome')).toHaveTextContent( - '%interlinearizer_analysisCatalog_deleteFallbackNoGloss%', + expect(announceUndoable).toHaveBeenCalledWith( + expect.stringContaining('%interlinearizer_analysisCatalog_deleteFallbackNoGloss%'), ); }); - // Committing on the outcome the reader was shown would blank every affected use after - // promising them a word, which is the one mistake this irreversible copy exists to prevent. - describe('over a fallback an edit beside the panel withdrew', () => { - const FALLBACK: TextAnalysis = { - ...emptyAnalysis(), - tokenAnalyses: [ - { ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'ἀρχῇ', gloss: { en: 'start' } }, - { ...FIXTURE_STAMPS, id: 'ta-2', surfaceText: 'ἀρχῇ', gloss: { en: 'beginning' } }, - ], - tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0'), link('ta-2', 'GEN 1:3:4')], - }; - - it('restates the outcome rather than deleting on the withdrawn promise', async () => { - renderPanelWithGlossEditing({ - analysis: FALLBACK, - analysisLanguage: 'en', - showSuggestions: true, - }); - await openDeleteConfirm('ta-1'); - expect(screen.getByTestId('catalog-delete-outcome')).toHaveTextContent( - '%interlinearizer_analysisCatalog_deleteFallback%', - ); - - act(() => editGloss('GEN 1:3:4', 'word', '')); - await userEvent.click(screen.getByTestId('catalog-delete-confirm')); - - expect(screen.getByTestId('catalog-delete-outcome')).toHaveTextContent( - '%interlinearizer_analysisCatalog_deleteBlank%', - ); - }); - - it('keeps the analysis until the restated outcome is confirmed', async () => { - const onSave = jest.fn(); - renderPanelWithGlossEditing({ - analysis: FALLBACK, - analysisLanguage: 'en', - showSuggestions: true, - onSave, - }); - await openDeleteConfirm('ta-1'); - act(() => editGloss('GEN 1:3:4', 'word', '')); - - await userEvent.click(screen.getByTestId('catalog-delete-confirm')); - - expect(listedAnalysisIds()).toContain('ta-1'); - }); - - it('deletes once the restated outcome is confirmed in turn', async () => { - renderPanelWithGlossEditing({ - analysis: FALLBACK, - analysisLanguage: 'en', - showSuggestions: true, - }); - await openDeleteConfirm('ta-1'); - act(() => editGloss('GEN 1:3:4', 'word', '')); - await userEvent.click(screen.getByTestId('catalog-delete-confirm')); - - await userEvent.click(screen.getByTestId('catalog-delete-confirm')); - - expect(screen.queryAllByTestId('catalog-row')).toHaveLength(0); - }); - }); - - it('removes the analysis and its links when confirmed', async () => { + it('removes the analysis and its links at once', async () => { const onSave = jest.fn(); - renderPanel({ analysis: LONE, onSave }); - await openDeleteConfirm('ta-1'); + renderPanel({ announceUndoable, analysis: LONE, onSave }); - await userEvent.click(screen.getByTestId('catalog-delete-confirm')); + await deleteRow('ta-1'); const saved: TextAnalysis = onSave.mock.calls.at(-1)[0]; expect(saved.tokenAnalyses).toEqual([]); expect(saved.tokenAnalysisLinks).toEqual([]); }); - it('leaves the analysis untouched when the confirmation is canceled', async () => { - const onSave = jest.fn(); - renderPanel({ analysis: LONE, onSave }); - await openDeleteConfirm('ta-1'); - - await userEvent.click(screen.getByTestId('catalog-delete-cancel')); - - expect(onSave).not.toHaveBeenCalled(); - expect(listedAnalysisIds()).toEqual(['ta-1']); - }); - describe('over an unsaved breakdown', () => { /** Expands the row and types a re-segmentation into it without saving. */ async function typeUnsavedBreakdown(analysisId: string) { @@ -3892,19 +4082,19 @@ describe('AnalysisCatalogPanel', () => { } it('asks before deleting the analysis a breakdown draft is keyed to', async () => { - renderPanel({ analysis: LONE }); + renderPanel({ announceUndoable, analysis: LONE }); await typeUnsavedBreakdown('ta-1'); await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-delete')); - // The draft is put to the reader before the deletion is, so declining costs them nothing. + // The draft is put to the reader before the deletion happens, so declining costs nothing. expect(screen.getByTestId('catalog-close-title')).toBeInTheDocument(); - expect(screen.queryByTestId('catalog-delete-confirm')).not.toBeInTheDocument(); + expect(listedAnalysisIds()).toEqual(['ta-1']); }); it('keeps the draft and the analysis when the discard is declined', async () => { const onSave = jest.fn(); - renderPanel({ analysis: LONE, onSave }); + renderPanel({ announceUndoable, analysis: LONE, onSave }); await typeUnsavedBreakdown('ta-1'); await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-delete')); @@ -3916,37 +4106,33 @@ describe('AnalysisCatalogPanel', () => { ); }); - it('puts the deletion itself to the reader once the draft is given up', async () => { - const onSave = jest.fn(); - renderPanel({ analysis: LONE, onSave }); + it('deletes once the draft is given up', async () => { + renderPanel({ announceUndoable, analysis: LONE }); await typeUnsavedBreakdown('ta-1'); await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-delete')); await userEvent.click(screen.getByTestId('catalog-close-discard')); - // Giving up the draft is not consent to the deletion, which still has its own say. - expect(screen.getByTestId('catalog-delete-confirm')).toBeInTheDocument(); - expect(onSave).not.toHaveBeenCalled(); + expect(screen.queryAllByTestId('catalog-row')).toHaveLength(0); }); it('deletes without asking about a draft that re-states the current breakdown', async () => { - renderPanel({ analysis: LONE }); + renderPanel({ announceUndoable, analysis: LONE }); await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); await openBreakdown(rowFor('ta-1')); await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-delete')); - expect(screen.getByTestId('catalog-delete-confirm')).toBeInTheDocument(); + expect(screen.queryAllByTestId('catalog-row')).toHaveLength(0); expect(screen.queryByTestId('catalog-close-title')).not.toBeInTheDocument(); }); it('stops warning about a draft whose analysis the deletion took with it', async () => { const onClose = jest.fn(); - renderPanel({ analysis: LONE, onClose }); + renderPanel({ announceUndoable, analysis: LONE, onClose }); await typeUnsavedBreakdown('ta-1'); await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-delete')); await userEvent.click(screen.getByTestId('catalog-close-discard')); - await userEvent.click(screen.getByTestId('catalog-delete-confirm')); await userEvent.click(screen.getByTestId('analysis-catalog-close')); diff --git a/src/__tests__/components/AnalysisStore.test.tsx b/src/__tests__/components/AnalysisStore.test.tsx index 4cc3c5eec..a7170b27d 100644 --- a/src/__tests__/components/AnalysisStore.test.tsx +++ b/src/__tests__/components/AnalysisStore.test.tsx @@ -6,8 +6,7 @@ import userEvent from '@testing-library/user-event'; import type { TextAnalysis, TokenAnalysis, TokenAnalysisLink } from 'interlinearizer'; import type { ReactNode } from 'react'; import { emptyAnalysis } from '../../types/empty-factories'; -import { resegmentBook } from '../../parsers/papi/resegmentBook'; -import { FIXTURE_STAMPS, makeVerseBook } from '../test-helpers'; +import { FIXTURE_STAMPS, makePhraseLink } from '../test-helpers'; import type { AnalysisEditOutcome } from '../../components/AnalysisStore'; import { AnalysisStoreProvider, @@ -31,7 +30,6 @@ import { usePhraseDispatch, usePhraseGloss, usePhraseGlossDispatch, - useReanchorToBook, useReportGlossEditing, useResolvedTokenAnalysis, useSuggestionAfterClearing, @@ -143,10 +141,11 @@ function renderStoreHook( options: Readonly<{ analysisLanguage?: string; initialAnalysis?: TextAnalysis; - onSave?: (analysis: TextAnalysis) => void; + onSave?: (analysis: TextAnalysis, location?: string) => void; onGlossChange?: (tokenRef: string, value: string) => void; showSuggestions?: boolean; readOnly?: boolean; + subscribeToReplacements?: (listener: (analysis: TextAnalysis) => void) => () => void; }> = {}, ) { const { analysisLanguage = 'und', ...rest } = options; @@ -328,6 +327,111 @@ describe('useAnalysis', () => { }); }); +describe('edit locations', () => { + /** An analysis holding one phrase, `p1`, over the first two words of GEN 1:1. */ + const PHRASED: TextAnalysis = { + ...emptyAnalysis(), + phraseAnalyses: [{ ...FIXTURE_STAMPS, id: 'p1', surfaceText: 'In the' }], + phraseAnalysisLinks: [makePhraseLink('p1', ['GEN 1:1:0', 'GEN 1:1:3'])], + }; + + it('saves a gloss edit as made at its token', () => { + const onSave = jest.fn(); + const { result } = renderStoreHook(() => useGlossDispatch(), { onSave }); + + act(() => result.current('GEN 1:1:3', 'the', 'le')); + + expect(onSave).toHaveBeenLastCalledWith(expect.anything(), 'GEN 1:1:3'); + }); + + it("saves a phrase gloss edit as made at the phrase's first token", () => { + const onSave = jest.fn(); + const { result } = renderStoreHook(() => usePhraseGlossDispatch(), { + onSave, + initialAnalysis: PHRASED, + }); + + act(() => result.current('p1', 'au commencement')); + + expect(onSave).toHaveBeenLastCalledWith(expect.anything(), 'GEN 1:1:0'); + }); + + it("saves a phrase's deletion as made at the first token it held", () => { + const onSave = jest.fn(); + const { result } = renderStoreHook(() => usePhraseDispatch(), { + onSave, + initialAnalysis: PHRASED, + }); + + act(() => result.current.deletePhrase('p1')); + + expect(onSave).toHaveBeenLastCalledWith(expect.anything(), 'GEN 1:1:0'); + }); + + it('saves a catalog edit as made at no one place', () => { + const onSave = jest.fn(); + const { result } = renderStoreHook(() => useAnalysisRowDispatch(), { + onSave, + initialAnalysis: makeAnalysisWithGloss('GEN 1:1:0', 'in'), + }); + + act(() => { + result.current.deleteAnalysis('GEN 1:1:0-analysis'); + }); + + expect(onSave).toHaveBeenLastCalledWith(expect.anything(), undefined); + }); +}); + +describe('analysis replacements', () => { + /** Stands in for the draft's feed of replaced analyses, letting a test push one. */ + function makeReplacementFeed() { + let listener: ((analysis: TextAnalysis) => void) | undefined; + const unsubscribe = jest.fn(); + return { + subscribe: (next: (analysis: TextAnalysis) => void) => { + listener = next; + return unsubscribe; + }, + replace: (analysis: TextAnalysis) => listener?.(analysis), + unsubscribe, + }; + } + + it('follows an analysis the draft replaces', () => { + const feed = makeReplacementFeed(); + const { result } = renderStoreHook(() => useAnalysis(), { + subscribeToReplacements: feed.subscribe, + }); + const replaced = makeAnalysisWithGloss('tok-1', 'restored'); + + act(() => feed.replace(replaced)); + + expect(result.current).toBe(replaced); + }); + + it('does not save back an analysis the draft replaced', () => { + const feed = makeReplacementFeed(); + const onSave = jest.fn(); + renderStoreHook(() => useAnalysis(), { subscribeToReplacements: feed.subscribe, onSave }); + + act(() => feed.replace(makeAnalysisWithGloss('tok-1', 'restored'))); + + expect(onSave).not.toHaveBeenCalled(); + }); + + it('stops following the draft when it unmounts', () => { + const feed = makeReplacementFeed(); + const { unmount } = renderStoreHook(() => useAnalysis(), { + subscribeToReplacements: feed.subscribe, + }); + + unmount(); + + expect(feed.unsubscribe).toHaveBeenCalled(); + }); +}); + describe('useGlossDispatch', () => { it('replaces the existing approved analysis on subsequent writes for the same token', () => { const { result } = renderStoreHook(() => ({ @@ -1873,106 +1977,3 @@ describe('useAnalysisDeletionOutcome', () => { ); }); }); - -describe('useReanchorToBook', () => { - /** Seeds an approved gloss on the sole occurrence of `surfaceText` in a one-verse book. */ - function glossedAnalysis(text: string, surfaceText: string, gloss: string): TextAnalysis { - const token = makeVerseBook([{ sid: 'GEN 1:1', text }]).segments[0].tokens.find( - (t) => t.surfaceText === surfaceText, - ); - if (!token) throw new Error('fixture missing token'); - return makeAnalysisWithGloss(token.ref, gloss, surfaceText); - } - - it('re-points a link when the loaded book shifted its token', () => { - const initialAnalysis = glossedAnalysis('it was unbelievable', 'unbelievable', 'incroyable'); - const book = makeVerseBook([{ sid: 'GEN 1:1', text: 'it was and unbelievable' }]); - - const { result } = renderStoreHook( - () => { - useReanchorToBook(book); - return useAnalysis(); - }, - { initialAnalysis }, - ); - - const moved = book.segments[0].tokens.find((t) => t.surfaceText === 'unbelievable'); - expect(result.current.tokenAnalysisLinks[0].token.tokenRef).toBe(moved?.ref); - }); - - it('moves a split piece translation along with its stored split', () => { - const storedSplits = [{ tokenRef: 'GEN 1:1:6', surfaceText: 'beta' }]; - const book = resegmentBook(makeVerseBook([{ sid: 'GEN 1:1', text: 'alpha and beta' }]), { - removedVerseStarts: [], - addedStarts: [{ tokenRef: 'GEN 1:1:10', surfaceText: 'beta' }], - }); - const initialAnalysis: TextAnalysis = { - ...emptyAnalysis(), - segmentAnalyses: [{ id: 'sa-1', ...FIXTURE_STAMPS, surfaceText: 'beta' }], - segmentAnalysisLinks: [ - { analysisId: 'sa-1', ...FIXTURE_STAMPS, status: 'approved', segmentId: 'GEN 1:1:6' }, - ], - }; - - const { result } = renderStoreHook( - () => { - useReanchorToBook(book, storedSplits); - return useAnalysis(); - }, - { initialAnalysis }, - ); - - expect(result.current.segmentAnalysisLinks[0].segmentId).toBe('GEN 1:1:10'); - }); - - it('persists the healed analysis through onSave', () => { - const initialAnalysis = glossedAnalysis('it was unbelievable', 'unbelievable', 'incroyable'); - const book = makeVerseBook([{ sid: 'GEN 1:1', text: 'it was and unbelievable' }]); - const onSave = jest.fn(); - - renderStoreHook(() => useReanchorToBook(book), { initialAnalysis, onSave }); - - expect(onSave).toHaveBeenCalledTimes(1); - }); - - it('does not save when the book still matches the stored refs', () => { - const initialAnalysis = glossedAnalysis('it was unbelievable', 'unbelievable', 'incroyable'); - const book = makeVerseBook([{ sid: 'GEN 1:1', text: 'it was unbelievable' }]); - const onSave = jest.fn(); - - renderStoreHook(() => useReanchorToBook(book), { initialAnalysis, onSave }); - - expect(onSave).not.toHaveBeenCalled(); - }); - - it('leaves a read-only store alone so an import is never rewritten', () => { - const initialAnalysis = glossedAnalysis('it was unbelievable', 'unbelievable', 'incroyable'); - const book = makeVerseBook([{ sid: 'GEN 1:1', text: 'it was and unbelievable' }]); - - const { result } = renderStoreHook( - () => { - useReanchorToBook(book); - return useAnalysis(); - }, - { initialAnalysis, readOnly: true }, - ); - - expect(result.current).toBe(initialAnalysis); - }); - - it('waits for a book rather than re-anchoring against nothing', () => { - const initialAnalysis = glossedAnalysis('it was unbelievable', 'unbelievable', 'incroyable'); - const onSave = jest.fn(); - - const { result } = renderStoreHook( - () => { - useReanchorToBook(undefined); - return useAnalysis(); - }, - { initialAnalysis, onSave }, - ); - - expect(result.current).toBe(initialAnalysis); - expect(onSave).not.toHaveBeenCalled(); - }); -}); diff --git a/src/__tests__/components/FocusStore.test.tsx b/src/__tests__/components/FocusStore.test.tsx index 9b4abebf1..e38752c8e 100644 --- a/src/__tests__/components/FocusStore.test.tsx +++ b/src/__tests__/components/FocusStore.test.tsx @@ -589,6 +589,14 @@ describe('FocusProvider resolution rules', () => { expect(harness.read()).toMatchObject({ tokenRef: 'GEN 1:1:1', origin: 'request' }); }); + it("claims a request naming a segment on the segment's first word token", () => { + const harness = renderFocus(makeBook(), GEN_1_1); + + act(() => harness.read().nav.requestFocusToken('GEN 1:2')); + + expect(harness.read()).toMatchObject({ tokenRef: 'GEN 1:2:0', origin: 'request' }); + }); + it('leaves a request naming another book pending', () => { const harness = renderFocus(makeBook(), GEN_1_1); @@ -646,6 +654,10 @@ describe('FocusProvider resolution rules', () => { expect(mountWithPendingRequest('GEN 1:1:1')[0]).toBe('GEN 1:1:1'); }); + it('mounts on the first word token of a segment a pending request names', () => { + expect(mountWithPendingRequest('GEN 1:2')[0]).toBe('GEN 1:2:0'); + }); + it('mounts on the active verse when a pending request matches no word token', () => { expect(mountWithPendingRequest('GEN 1:1:99').at(-1)).toBe('GEN 1:1:0'); }); diff --git a/src/__tests__/components/Interlinearizer.test.tsx b/src/__tests__/components/Interlinearizer.test.tsx index 66c873826..f5a95f6f4 100644 --- a/src/__tests__/components/Interlinearizer.test.tsx +++ b/src/__tests__/components/Interlinearizer.test.tsx @@ -139,8 +139,6 @@ jest.mock('../../components/AnalysisStore', () => ({ updatePhrase: (...args: Parameters) => mockUpdatePhrase(...args), deletePhrase: (...args: Parameters) => mockDeletePhrase(...args), }), - /** No-op: these tests render no store, and re-anchoring is covered against the real one. */ - useReanchorToBook: () => {}, })); jest.mock('../../components/ContinuousView', () => ({ @@ -413,6 +411,7 @@ function renderInterlinearizer({ segmentationDispatch, formerBoundaries, unmergeableStarts, + asOneStep, }: { book?: Book; continuousScroll?: boolean; @@ -426,6 +425,7 @@ function renderInterlinearizer({ segmentationDispatch?: SegmentationDispatch; formerBoundaries?: ReadonlyMap; unmergeableStarts?: ReadonlySet; + asOneStep?: (action: () => void) => void; } = {}) { return render( withNav( @@ -435,6 +435,7 @@ function renderInterlinearizer({ segmentationDispatch={segmentationDispatch} formerBoundaries={formerBoundaries} unmergeableStarts={unmergeableStarts} + asOneStep={asOneStep} scrRef={scrRef} phraseMode={{ kind: 'view' }} setPhraseMode={() => {}} @@ -1912,8 +1913,12 @@ describe('segmentation dispatch force-break', () => { * Renders with continuous scroll on (so the stubbed ContinuousView captures the segmentation * context) and returns the wrapped dispatch. */ - function renderAndCaptureDispatch(raw: SegmentationDispatch, book: Book): SegmentationDispatch { - renderInterlinearizer({ book, continuousScroll: true, segmentationDispatch: raw }); + function renderAndCaptureDispatch( + raw: SegmentationDispatch, + book: Book, + asOneStep?: (action: () => void) => void, + ): SegmentationDispatch { + renderInterlinearizer({ book, continuousScroll: true, segmentationDispatch: raw, asOneStep }); const dispatch = capturedSegmentation?.dispatch; if (!dispatch) throw new Error('expected a captured segmentation dispatch'); return dispatch; @@ -1983,6 +1988,44 @@ describe('segmentation dispatch force-break', () => { expect(raw.move).toHaveBeenCalledWith('GEN 1:1:0', 'GEN 1:2:0'); }); + it("makes a split's force-break and boundary write one undo step", () => { + const raw = makeRawDispatch(); + const writesInStep: string[] = []; + let stepOpen = false; + const asOneStep = (action: () => void) => { + stepOpen = true; + action(); + stepOpen = false; + }; + mockDeletePhrase.mockImplementation(() => stepOpen && writesInStep.push('break')); + jest.mocked(raw.split).mockImplementation(() => stepOpen && writesInStep.push('split')); + mockPhraseLinkById.set('p1', makePhraseLink('p1', ['GEN 1:1:0', 'GEN 1:2:0'])); + const dispatch = renderAndCaptureDispatch(raw, GEN_1_MULTI_BOOK, asOneStep); + + dispatch.split('GEN 1:2:0'); + + expect(writesInStep).toEqual(['break', 'split']); + }); + + it("makes a move's force-break and boundary write one undo step", () => { + const raw = makeRawDispatch(); + const writesInStep: string[] = []; + let stepOpen = false; + const asOneStep = (action: () => void) => { + stepOpen = true; + action(); + stepOpen = false; + }; + mockDeletePhrase.mockImplementation(() => stepOpen && writesInStep.push('break')); + jest.mocked(raw.move).mockImplementation(() => stepOpen && writesInStep.push('move')); + mockPhraseLinkById.set('p1', makePhraseLink('p1', ['GEN 1:1:0', 'GEN 1:2:0'])); + const dispatch = renderAndCaptureDispatch(raw, GEN_1_MULTI_BOOK, asOneStep); + + dispatch.move('GEN 1:1:0', 'GEN 1:2:0'); + + expect(writesInStep).toEqual(['break', 'move']); + }); + it('skips the force-break when the boundary ref is unknown to the book', () => { const raw = makeRawDispatch(); mockPhraseLinkById.set('p1', makePhraseLink('p1', ['GEN 1:1:0', 'GEN 1:2:0'])); diff --git a/src/__tests__/components/InterlinearizerLoader.test.tsx b/src/__tests__/components/InterlinearizerLoader.test.tsx index e96b18464..4498cc47a 100644 --- a/src/__tests__/components/InterlinearizerLoader.test.tsx +++ b/src/__tests__/components/InterlinearizerLoader.test.tsx @@ -4,14 +4,25 @@ import papi, { logger } from '@papi/frontend'; import { useData, useLocalizedStrings, useProjectSetting, useSetting } from '@papi/frontend/react'; import type { SerializedVerseRef } from '@sillsdev/scripture'; +import { useEvent } from 'platform-bible-react'; import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import type { Book, DraftProject, PhraseAnalysisLink, TextAnalysis } from 'interlinearizer'; +import type { + Book, + DraftProject, + PhraseAnalysisLink, + TextAnalysis, + TokenSnapshot, +} from 'interlinearizer'; import { useState as useReactState } from 'react'; import type { Dispatch, ReactNode, SetStateAction } from 'react'; import { useStore } from 'react-redux'; -import { useGlossDispatch } from '../../components/AnalysisStore'; -import InterlinearizerLoader from '../../components/InterlinearizerLoader'; +import { useAnalysis, useGlossDispatch, usePhraseDispatch } from '../../components/AnalysisStore'; +import { useInterlinearNav } from '../../components/InterlinearNavContext'; +import InterlinearizerLoader, { + BOOK_NAME_TIMEOUT_MS, + UNDO_NOTIFICATION_DURATION_MS, +} from '../../components/InterlinearizerLoader'; import { RECENTER_FADE_MS } from '../../components/recenter-fade'; import useConcordanceIndex, { type ConcordanceIndex } from '../../hooks/useConcordanceIndex'; import useInterlinearizerBookData from '../../hooks/useInterlinearizerBookData'; @@ -194,6 +205,7 @@ type CapturedInterlinearizerProps = { formerBoundaries: ReadonlyMap; unmergeableStarts?: ReadonlySet; segmentationVersion: number; + asOneStep: (action: () => void) => void; }; let capturedInterlinearizerProps: CapturedInterlinearizerProps | undefined; let interlinearizerMountCount = 0; @@ -207,8 +219,8 @@ type CapturedStoreProps = { analysisLanguage: string; /** Analysis seeded into the store; not reactive after mount. */ initialAnalysis?: TextAnalysis; - /** Called after each store mutation with the updated analysis. */ - onSave?: (analysis: TextAnalysis) => void; + /** Called after each store mutation with the updated analysis and where it was made. */ + onSave?: (analysis: TextAnalysis, location?: string) => void; /** Called with whether any gloss input holds uncommitted text. */ onPendingEditsChange?: (pending: boolean) => void; /** Whether un-approved tokens render the engine's suggestion. */ @@ -244,6 +256,15 @@ let mountStoreProbe = false; /** The Redux store the probe is mounted in, captured so a test can compare store identity. */ let probeStore: unknown; +/** The analysis the store the probe is mounted in holds. */ +let probeAnalysis: TextAnalysis | undefined; + +/** Creates a phrase through the store the probe is mounted in, returning its id. */ +let probeCreatePhrase: ((tokens: TokenSnapshot[]) => string) | undefined; + +/** The token a pending focus request names in GEN, read by the probe. */ +let probeFocusRequest: string | undefined; + /** Writes a gloss through the store the probe is mounted in. */ let probeWriteGloss: ((tokenRef: string, surfaceText: string, value: string) => void) | undefined; @@ -253,7 +274,10 @@ let probeWriteGloss: ((tokenRef: string, surfaceText: string, value: string) => */ function StoreProbe() { probeStore = useStore(); + probeAnalysis = useAnalysis(); probeWriteGloss = useGlossDispatch(); + probeCreatePhrase = usePhraseDispatch().createPhrase; + probeFocusRequest = useInterlinearNav().peekFocusRequest('GEN'); return undefined; } @@ -276,6 +300,36 @@ jest.mock('../../components/Interlinearizer', () => { }; }); +/** An approved analysis of `surfaceText`, written against the token at `tokenRef`. */ +function analysisApprovingAt(tokenRef: string, surfaceText: string): TextAnalysis { + return { + ...emptyAnalysis(), + tokenAnalyses: [{ ...FIXTURE_STAMPS, id: 'ta-1', surfaceText }], + tokenAnalysisLinks: [ + { + ...FIXTURE_STAMPS, + analysisId: 'ta-1', + status: 'approved', + token: { tokenRef, surfaceText }, + }, + ], + }; +} + +/** A one-verse book with a second word to split before. */ +const ALPHA_BETA_BOOK: Book = { + id: 'GEN', + bookRef: 'GEN', + textVersion: 'v1', + duplicateVerseIds: [], + segments: [ + makeSegment('GEN 1:1', 'Alpha beta.', [ + makeWordToken('GEN 1:1:0', 'Alpha'), + makeWordToken('GEN 1:1:6', 'beta', 6), + ]), + ], +}; + /** Minimal project summary used across modal interaction tests. */ type MockProject = { id: string; @@ -1977,6 +2031,42 @@ describe('InterlinearizerLoader', () => { }); }); + it('offers no undo or redo buttons in the import view', async () => { + mockImportCommands(); + await renderImportView(); + + expect(screen.queryByRole('button', { name: '%undoButton_tooltip%' })).toBeNull(); + expect(screen.queryByRole('button', { name: '%redoButton_tooltip%' })).toBeNull(); + }); + + it('leaves the draft alone on Ctrl+Z while an import is showing', async () => { + mockImportCommands(); + mockPdpGet.mockResolvedValue({ + getPt9InterlinearManifest: async () => probeOf({ 'Lexicon.xml': 'aaaa1111' }), + }); + mockBookData({ book: ALPHA_BETA_BOOK }); + jest.useFakeTimers(); + await act(async () => renderLoader()); + act(() => capturedInterlinearizerProps?.segmentationDispatch.split('GEN 1:1:6')); + act(() => jest.advanceTimersByTime(300)); + fireEvent.click(screen.getByTestId('tab-toolbar-project-menu')); + await act(async () => fireEvent.click(screen.getByTestId('select-modal-open-import'))); + await screen.findByTestId('pt9-import-banner'); + mockSendCommand.mockClear(); + + act(() => { + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + }); + act(() => jest.advanceTimersByTime(300)); + jest.useRealTimers(); + + expect(mockSendCommand).not.toHaveBeenCalledWith( + 'interlinearizer.saveDraft', + expect.anything(), + expect.anything(), + ); + }); + it('hands the import view a segmentation dispatch that cannot write the draft', async () => { mockImportCommands(); await renderImportView(); @@ -2684,10 +2774,19 @@ describe('InterlinearizerLoader', () => { * @returns The persisted delta, or `undefined` when no draft has been saved or it carried none. */ function lastPersistedSegmentation(): DraftProject['segmentation'] { + return lastPersistedDraft()?.segmentation; + } + + /** + * Reads the draft back out of the most recent `saveDraft` call. + * + * @returns The persisted draft, or `undefined` when none has been saved. + */ + function lastPersistedDraft(): DraftProject | undefined { const calls = mockSendCommand.mock.calls.filter(([c]) => c === 'interlinearizer.saveDraft'); const last = calls[calls.length - 1]; const json = last?.[2]; - return typeof json === 'string' ? JSON.parse(json).segmentation : undefined; + return typeof json === 'string' ? JSON.parse(json) : undefined; } /** @@ -2843,6 +2942,22 @@ describe('InterlinearizerLoader', () => { ); }); + it("re-anchors the draft's analyses to the loaded book, and persists them", async () => { + // Written against "Al beta.", where "beta" began at offset 3. + const analysis = analysisApprovingAt('GEN 1:1:3', 'beta'); + mockSendCommand.mockResolvedValue(JSON.stringify({ ...emptyDraft(testProjectId), analysis })); + mockBookData({ book: TWO_VERSE_BOOK }); + await act(async () => { + renderLoader(); + }); + + await waitFor(() => + expect(lastPersistedDraft()?.analysis.tokenAnalysisLinks[0].token.tokenRef).toBe( + 'GEN 1:1:6', + ), + ); + }); + it('clears the segmentation field when an edit restores the default segmentation', async () => { mockBookData({ book: TWO_VERSE_BOOK }); await act(async () => { @@ -4198,29 +4313,41 @@ const LUK_1_1_BOOK: Book = { ], }; +/** Resets the loader's mocks and captures for a test that reads the store through the probe. */ +function prepareStoreProbeTest(): void { + mountStoreProbe = true; + probeStore = undefined; + probeAnalysis = undefined; + probeWriteGloss = undefined; + probeCreatePhrase = undefined; + probeFocusRequest = undefined; + capturedInterlinearizerProps = undefined; + capturedStoreProps = undefined; + interlinearizerMountCount = 0; + mockBookData(); + mockLexiconRegistry(); + mockOptimisticSetting(); + mockLostBoundaries([]); + mockProjectBookIds(undefined); + mockSendCommand.mockResolvedValue(JSON.stringify(emptyDraft(testProjectId))); + jest + .mocked(useData) + .mockReturnValue( + new Proxy({}, { get: () => jest.fn().mockReturnValue([undefined, jest.fn(), false]) }), + ); + // Unresolved, as the platform leaves a string it has not localized: the key stands for itself. + jest + .mocked(useLocalizedStrings) + .mockImplementation((keys: readonly string[]) => [ + Object.fromEntries(keys.map((k) => [k, k])), + false, + ]); + mockSettings(); + mockSourceShortName(''); +} + describe('analysis store lifetime', () => { - beforeEach(() => { - mountStoreProbe = true; - probeStore = undefined; - probeWriteGloss = undefined; - capturedInterlinearizerProps = undefined; - capturedStoreProps = undefined; - interlinearizerMountCount = 0; - mockBookData(); - mockLexiconRegistry(); - mockOptimisticSetting(); - mockLostBoundaries([]); - mockProjectBookIds(undefined); - mockSendCommand.mockResolvedValue(JSON.stringify(emptyDraft(testProjectId))); - jest - .mocked(useData) - .mockReturnValue( - new Proxy({}, { get: () => jest.fn().mockReturnValue([undefined, jest.fn(), false]) }), - ); - jest.mocked(useLocalizedStrings).mockReturnValue([{}, false]); - mockSettings(); - mockSourceShortName(''); - }); + beforeEach(prepareStoreProbeTest); afterEach(() => { mountStoreProbe = false; @@ -4258,6 +4385,30 @@ describe('analysis store lifetime', () => { expect(probeStore).toBe(storeBefore); }); + it('shows the store what re-anchoring made of the draft', async () => { + mockBookData({ + book: { + id: 'GEN', + bookRef: 'GEN', + textVersion: 'v1', + duplicateVerseIds: [], + segments: [ + makeSegment('GEN 1:1', 'Alpha beta.', [ + makeWordToken('GEN 1:1:0', 'Alpha'), + makeWordToken('GEN 1:1:6', 'beta', 6), + ]), + ], + }, + }); + // Written against "Al beta.", where "beta" began at offset 3. + const analysis = analysisApprovingAt('GEN 1:1:3', 'beta'); + mockSendCommand.mockResolvedValue(JSON.stringify({ ...emptyDraft(testProjectId), analysis })); + + await act(async () => renderLoader()); + + expect(probeAnalysis?.tokenAnalysisLinks[0].token.tokenRef).toBe('GEN 1:1:6'); + }); + it('rebuilds the store when the draft is replaced wholesale', async () => { // The store's seed is not reactive, so a replacement (New / Open / Wipe) reseeds by remounting // the provider. Hoisting it above the book key must not cost that: a wiped draft whose store @@ -4277,3 +4428,721 @@ describe('analysis store lifetime', () => { expect(capturedStoreProps?.initialAnalysis).toEqual(emptyAnalysis()); }); }); + +describe('undo and redo', () => { + beforeEach(() => { + prepareStoreProbeTest(); + mockBookData({ book: ALPHA_BETA_BOOK }); + jest.mocked(papi.notifications.send).mockResolvedValue('notification-id'); + }); + + afterEach(() => { + mountStoreProbe = false; + }); + + /** Renders the loader and glosses "Alpha" through the store. */ + async function renderAndGloss(): Promise { + await act(async () => renderLoader()); + act(() => probeWriteGloss?.('GEN 1:1:0', 'Alpha', 'alpha')); + } + + it('undoes the last edit on Ctrl+Z', async () => { + await renderAndGloss(); + + act(() => { + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + }); + + expect(probeAnalysis?.tokenAnalysisLinks).toHaveLength(0); + }); + + it('redoes an undone edit on Ctrl+Y', async () => { + await renderAndGloss(); + + act(() => { + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + }); + act(() => { + fireEvent.keyDown(document.body, { key: 'y', ctrlKey: true }); + }); + + expect(probeAnalysis?.tokenAnalysisLinks).toHaveLength(1); + }); + + it("undoes an editor action's analysis and boundary edits as one step", async () => { + await act(async () => renderLoader()); + + act(() => + capturedInterlinearizerProps?.asOneStep(() => { + probeWriteGloss?.('GEN 1:1:0', 'Alpha', 'alpha'); + capturedInterlinearizerProps?.segmentationDispatch.split('GEN 1:1:6'); + }), + ); + act(() => { + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + }); + + expect(probeAnalysis?.tokenAnalysisLinks).toHaveLength(0); + expect(capturedInterlinearizerProps?.book.segments).toHaveLength(1); + }); + + it('leaves the draft alone on Ctrl+Z while a dialog is open over it', async () => { + await renderAndGloss(); + render(
); + + act(() => { + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + }); + + expect(probeAnalysis?.tokenAnalysisLinks).toHaveLength(1); + }); + + describe('showing the edit', () => { + /** Renders the loader on GEN 1:1 with a spy on the scroll group's reference setter. */ + async function renderWithScrRefSpy() { + const setScrRef = jest.fn(); + await act(async () => + renderLoader({ useWebViewScrollGroupScrRef: makeScrollGroupHook(undefined, setScrRef) }), + ); + return setScrRef; + } + + it('takes the reader to the verse an undone edit was made in', async () => { + const setScrRef = await renderWithScrRefSpy(); + act(() => probeWriteGloss?.('GEN 1:5:0', 'word', 'mot')); + + act(() => { + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + }); + + expect(setScrRef).toHaveBeenLastCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 5 }); + }); + + it('focuses the token an undone edit was made at', async () => { + await renderWithScrRefSpy(); + act(() => probeWriteGloss?.('GEN 1:5:0', 'word', 'mot')); + + act(() => { + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + }); + + expect(probeFocusRequest).toBe('GEN 1:5:0'); + }); + + it('asks to focus the segment an undone edit was made in', async () => { + await renderWithScrRefSpy(); + act(() => capturedStoreProps?.onSave?.(analysisApprovingAt('GEN 1:5:0', 'word'), 'GEN 1:5')); + + act(() => { + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + }); + + expect(probeFocusRequest).toBe('GEN 1:5'); + }); + + it('takes the reader to the verse a redone edit was made in', async () => { + const setScrRef = await renderWithScrRefSpy(); + act(() => probeWriteGloss?.('GEN 1:5:0', 'word', 'mot')); + act(() => { + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + }); + setScrRef.mockClear(); + + act(() => { + fireEvent.keyDown(document.body, { key: 'y', ctrlKey: true }); + }); + + expect(setScrRef).toHaveBeenLastCalledWith({ book: 'GEN', chapterNum: 1, verseNum: 5 }); + }); + + it('leaves the reader in place when the undone edit was made at no one place', async () => { + const setScrRef = await renderWithScrRefSpy(); + act(() => capturedStoreProps?.onSave?.(analysisApprovingAt('GEN 1:5:0', 'word'))); + + act(() => { + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + }); + + expect(setScrRef).not.toHaveBeenCalled(); + }); + }); + + describe('phrase editing', () => { + const ALPHA_BETA: TokenSnapshot[] = [ + { tokenRef: 'GEN 1:1:0', surfaceText: 'Alpha' }, + { tokenRef: 'GEN 1:1:6', surfaceText: 'beta' }, + ]; + + /** Puts the view into editing the phrase `phraseId`. */ + function editPhrase(phraseId: string): void { + act(() => + capturedInterlinearizerProps?.setPhraseMode({ + kind: 'edit', + phraseId, + originalTokens: ALPHA_BETA, + }), + ); + } + + it('stops editing a phrase an undo removes', async () => { + await act(async () => renderLoader()); + let phraseId = ''; + act(() => { + phraseId = probeCreatePhrase?.(ALPHA_BETA) ?? ''; + }); + editPhrase(phraseId); + + act(() => { + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + }); + + expect(capturedInterlinearizerProps?.phraseMode).toEqual({ kind: 'view' }); + }); + + it('keeps editing a phrase the undo leaves in place', async () => { + await act(async () => renderLoader()); + let phraseId = ''; + act(() => { + phraseId = probeCreatePhrase?.(ALPHA_BETA) ?? ''; + }); + act(() => probeWriteGloss?.('GEN 1:1:0', 'Alpha', 'alpha')); + editPhrase(phraseId); + + act(() => { + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + }); + + expect(capturedInterlinearizerProps?.phraseMode.kind).toBe('edit'); + }); + }); + + describe('announcing a step with no one place', () => { + /** Renders the loader, glosses a word, and wipes the whole draft. */ + async function renderAndWipe(): Promise { + await renderAndGloss(); + await act(async () => { + screen.getByTestId('tab-toolbar-wipe').click(); + }); + await act(async () => { + screen.getByTestId('wipe-confirm-all').click(); + }); + } + + it('announces an undone wipe', async () => { + await renderAndWipe(); + + await act(async () => { + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + }); + + expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith( + expect.objectContaining({ message: '%interlinearizer_undone_wipeAll%' }), + ); + }); + + it('announces a redone wipe', async () => { + await renderAndWipe(); + act(() => { + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + }); + + await act(async () => { + fireEvent.keyDown(document.body, { key: 'y', ctrlKey: true }); + }); + + expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith( + expect.objectContaining({ message: '%interlinearizer_redone_wipeAll%' }), + ); + }); + + describe('naming the book of an undone book wipe', () => { + /** Renders the loader, glosses a word in GEN, wipes GEN, and undoes the wipe. */ + async function wipeBookAndUndo(): Promise { + jest.mocked(useLocalizedStrings).mockImplementation((keys: readonly string[]) => [ + Object.fromEntries( + keys.map((k) => [ + k, + { + '%interlinearizer_undone_wipeBook%': 'Undid wiping {book}.', + '%interlinearizer_redone_wipeBook%': 'Redid wiping {book}.', + }[k] ?? k, + ]), + ), + false, + ]); + await renderAndGloss(); + await act(async () => { + screen.getByTestId('tab-toolbar-wipe').click(); + }); + await act(async () => { + screen.getByTestId('wipe-confirm-book').click(); + }); + await act(async () => { + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + }); + } + + it('names the book in the interface language', async () => { + jest + .mocked(papi.localization.getLocalizedString) + .mockImplementation(async ({ localizeKey }) => + localizeKey === '%LocalizedId.GEN%' ? 'Mwanzo' : localizeKey, + ); + + await wipeBookAndUndo(); + + expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Undid wiping Mwanzo.' }), + ); + }); + + it('names the book in English when the interface language has no name for it', async () => { + jest + .mocked(papi.localization.getLocalizedString) + .mockImplementation(async ({ localizeKey }) => localizeKey); + + await wipeBookAndUndo(); + + expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Undid wiping Genesis.' }), + ); + }); + + it('names the book in English when its name in the interface language never arrives', async () => { + jest.useFakeTimers(); + try { + jest + .mocked(papi.localization.getLocalizedString) + .mockImplementation(() => new Promise(() => {})); + await wipeBookAndUndo(); + + await act(async () => { + jest.advanceTimersByTime(BOOK_NAME_TIMEOUT_MS); + }); + + expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Undid wiping Genesis.' }), + ); + } finally { + jest.useRealTimers(); + } + }); + + it('announces a redo after the undo whose book name was still resolving', async () => { + let resolveUndoneName: (name: string) => void = () => {}; + jest + .mocked(papi.localization.getLocalizedString) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveUndoneName = resolve; + }), + ) + .mockResolvedValue('Mwanzo'); + + await wipeBookAndUndo(); + await act(async () => { + fireEvent.keyDown(document.body, { key: 'y', ctrlKey: true }); + }); + await act(async () => { + resolveUndoneName('Mwanzo'); + }); + + const messages = jest + .mocked(papi.notifications.send) + .mock.calls.map(([options]) => options.message); + expect(messages.filter((m) => m.includes('wiping'))).toEqual([ + 'Undid wiping Mwanzo.', + 'Redid wiping Mwanzo.', + ]); + }); + }); + + it('announces an undone catalog edit', async () => { + mockSendCommand.mockResolvedValue( + JSON.stringify({ + ...emptyDraft(testProjectId), + analysis: analysisApprovingAt('GEN 1:1:0', 'Alpha'), + }), + ); + await act(async () => renderLoader()); + await userEvent.click(screen.getByTestId('tab-toolbar-analysis-catalog')); + await userEvent.click(screen.getByTestId('catalog-row-toggle')); + await userEvent.type(screen.getByTestId('catalog-row-gloss-input'), 'alpha'); + await userEvent.tab(); + + await act(async () => { + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + }); + + expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith( + expect.objectContaining({ message: '%interlinearizer_undone_catalogEdit%' }), + ); + }); + + it('scrolls the open catalog to the row an undone catalog edit acted on', async () => { + const scrollIntoView = jest.fn(); + // jsdom implements no scrollIntoView for the row to call. + Object.defineProperty(Element.prototype, 'scrollIntoView', { + configurable: true, + value: scrollIntoView, + }); + try { + mockSendCommand.mockResolvedValue( + JSON.stringify({ + ...emptyDraft(testProjectId), + analysis: analysisApprovingAt('GEN 1:1:0', 'Alpha'), + }), + ); + await act(async () => renderLoader()); + await userEvent.click(screen.getByTestId('tab-toolbar-analysis-catalog')); + await userEvent.click(screen.getByTestId('catalog-row-toggle')); + await userEvent.type(screen.getByTestId('catalog-row-gloss-input'), 'alpha'); + await userEvent.tab(); + + act(() => { + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + }); + + expect(scrollIntoView.mock.contexts).toContain(screen.getByTestId('catalog-row')); + } finally { + Reflect.deleteProperty(Element.prototype, 'scrollIntoView'); + } + }); + + it('scrolls the open catalog to the row a redone catalog edit collapsed onto', async () => { + const scrollIntoView = jest.fn(); + // jsdom implements no scrollIntoView for the row to call. + Object.defineProperty(Element.prototype, 'scrollIntoView', { + configurable: true, + value: scrollIntoView, + }); + const approvedAt = (analysisId: string, tokenRef: string) => ({ + ...FIXTURE_STAMPS, + analysisId, + status: 'approved' as const, + token: { tokenRef, surfaceText: 'Alpha' }, + }); + const rowFor = (analysisId: string) => + screen.getAllByTestId('catalog-row').find((row) => row.dataset.analysisId === analysisId); + try { + mockSendCommand.mockResolvedValue( + JSON.stringify({ + ...emptyDraft(testProjectId), + analysisLanguages: ['en'], + analysis: { + ...emptyAnalysis(), + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'Alpha', gloss: { en: 'first' } }, + { ...FIXTURE_STAMPS, id: 'ta-2', surfaceText: 'Alpha', gloss: { en: 'second' } }, + ], + tokenAnalysisLinks: [ + approvedAt('ta-1', 'GEN 1:1:0'), + approvedAt('ta-2', 'GEN 2:1:0'), + ], + }, + }), + ); + await act(async () => renderLoader()); + await userEvent.click(screen.getByTestId('tab-toolbar-analysis-catalog')); + const edited = rowFor('ta-1'); + if (!edited) throw new Error('no catalog row for ta-1'); + await userEvent.click(within(edited).getByTestId('catalog-row-toggle')); + const input = within(edited).getByTestId('catalog-row-gloss-input'); + await userEvent.clear(input); + await userEvent.type(input, 'second'); + await userEvent.tab(); + act(() => { + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + }); + scrollIntoView.mockClear(); + + act(() => { + fireEvent.keyDown(document.body, { key: 'y', ctrlKey: true }); + }); + + expect(scrollIntoView.mock.contexts).toContain(rowFor('ta-2')); + } finally { + Reflect.deleteProperty(Element.prototype, 'scrollIntoView'); + } + }); + + it('scrolls the open catalog to no row on redoing a deletion', async () => { + jest.mocked(papi.notifications.dismiss).mockResolvedValue(undefined); + const scrollIntoView = jest.fn(); + // jsdom implements no scrollIntoView for the row to call. + Object.defineProperty(Element.prototype, 'scrollIntoView', { + configurable: true, + value: scrollIntoView, + }); + try { + mockSendCommand.mockResolvedValue( + JSON.stringify({ + ...emptyDraft(testProjectId), + analysis: analysisApprovingAt('GEN 1:1:0', 'Alpha'), + }), + ); + await act(async () => renderLoader()); + await userEvent.click(screen.getByTestId('tab-toolbar-analysis-catalog')); + await userEvent.click(screen.getByTestId('catalog-row-toggle')); + await userEvent.click(screen.getByTestId('catalog-row-delete')); + act(() => { + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + }); + scrollIntoView.mockClear(); + + act(() => { + fireEvent.keyDown(document.body, { key: 'y', ctrlKey: true }); + }); + + expect(scrollIntoView).not.toHaveBeenCalled(); + } finally { + Reflect.deleteProperty(Element.prototype, 'scrollIntoView'); + } + }); + + it('announces nothing for a step it can show in place', async () => { + await renderAndGloss(); + + act(() => { + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + }); + + expect(jest.mocked(papi.notifications.send)).not.toHaveBeenCalled(); + }); + }); + + describe('undoing from a deletion notification', () => { + /** Stands in for the network event the platform relays a notification's Undo click on. */ + const UNDO_EVENT = () => () => true; + + beforeEach(() => { + jest.mocked(papi.network.getNetworkEvent).mockReturnValue(UNDO_EVENT); + jest.mocked(papi.notifications.send).mockResolvedValue('toast-1'); + jest.mocked(papi.notifications.dismiss).mockResolvedValue(undefined); + mockSendCommand.mockResolvedValue( + JSON.stringify({ + ...emptyDraft(testProjectId), + analysis: analysisApprovingAt('GEN 1:1:0', 'Alpha'), + }), + ); + }); + + /** Renders the loader and deletes the draft's one analysis from the catalog. */ + async function renderAndDelete() { + const view = await act(async () => renderLoader()); + await userEvent.click(screen.getByTestId('tab-toolbar-analysis-catalog')); + await userEvent.click(screen.getByTestId('catalog-row-toggle')); + await userEvent.click(screen.getByTestId('catalog-row-delete')); + return view; + } + + /** Clicks Undo on the notification `notificationId`, as the platform relays the click. */ + function clickNotificationUndo(notificationId: string): void { + const handler = [...jest.mocked(useEvent).mock.calls] + .reverse() + .find(([event]) => event === UNDO_EVENT)?.[1]; + if (typeof handler !== 'function') throw new Error('nothing listens for the undo event'); + act(() => handler({ notificationId })); + } + + it('offers to undo a deletion in its notification', async () => { + await renderAndDelete(); + + expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith( + expect.objectContaining({ + clickCommand: 'interlinearizer.undoFromNotification', + clickCommandLabel: '%interlinearizer_undo%', + }), + ); + }); + + it('keeps the notification up for as long as it offers', async () => { + await renderAndDelete(); + + expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith( + expect.objectContaining({ duration: UNDO_NOTIFICATION_DURATION_MS }), + ); + }); + + it("undoes the deletion when its notification's Undo is clicked", async () => { + await renderAndDelete(); + + clickNotificationUndo('toast-1'); + + expect(probeAnalysis?.tokenAnalysisLinks).toHaveLength(1); + }); + + it('offers the undo again when a dialog open over the draft blocks it', async () => { + await renderAndDelete(); + render(
); + jest.mocked(papi.notifications.send).mockClear(); + + clickNotificationUndo('toast-1'); + + expect(jest.mocked(papi.notifications.send)).toHaveBeenCalledWith( + expect.objectContaining({ clickCommand: 'interlinearizer.undoFromNotification' }), + ); + }); + + it('ignores an Undo click on a notification it did not send', async () => { + await renderAndDelete(); + + clickNotificationUndo('toast-2'); + + expect(probeAnalysis?.tokenAnalysisLinks).toHaveLength(0); + }); + + it('takes the notification down once a later step is made', async () => { + await renderAndDelete(); + + act(() => probeWriteGloss?.('GEN 1:1:6', 'beta', 'bêta')); + + expect(jest.mocked(papi.notifications.dismiss)).toHaveBeenCalledWith('toast-1'); + }); + + it('undoes nothing from a notification a later step took down', async () => { + await renderAndDelete(); + act(() => probeWriteGloss?.('GEN 1:1:6', 'beta', 'bêta')); + + clickNotificationUndo('toast-1'); + + expect(probeAnalysis?.tokenAnalysisLinks.map((l) => l.token.tokenRef)).toEqual(['GEN 1:1:6']); + }); + + it('takes down a notification a step superseded while it was on its way', async () => { + let deliver: (id: string) => void = () => {}; + jest.mocked(papi.notifications.send).mockReturnValue( + new Promise((resolve) => { + deliver = resolve; + }), + ); + await renderAndDelete(); + act(() => probeWriteGloss?.('GEN 1:1:6', 'beta', 'bêta')); + + await act(async () => deliver('toast-1')); + + expect(jest.mocked(papi.notifications.dismiss)).toHaveBeenCalledWith('toast-1'); + }); + + it('takes the notification down when the view closes', async () => { + const { unmount } = await renderAndDelete(); + + unmount(); + + expect(jest.mocked(papi.notifications.dismiss)).toHaveBeenCalledWith('toast-1'); + }); + + it('takes down a notification that arrives after the view closed', async () => { + let deliver: (id: string) => void = () => {}; + jest.mocked(papi.notifications.send).mockReturnValue( + new Promise((resolve) => { + deliver = resolve; + }), + ); + const { unmount } = await renderAndDelete(); + unmount(); + + await act(async () => deliver('toast-1')); + + expect(jest.mocked(papi.notifications.dismiss)).toHaveBeenCalledWith('toast-1'); + }); + + it('undoes the latest deletion when an older notification arrives after it', async () => { + const deliveries: ((id: string) => void)[] = []; + jest.mocked(papi.notifications.send).mockImplementation( + () => + new Promise((resolve) => { + deliveries.push(resolve); + }), + ); + await renderAndDelete(); + act(() => screen.getByTestId('tab-toolbar-undo').click()); + await userEvent.click(screen.getByTestId('catalog-row-toggle')); + await userEvent.click(screen.getByTestId('catalog-row-delete')); + + await act(async () => deliveries.at(-1)?.('toast-2')); + await act(async () => deliveries[0]('toast-1')); + clickNotificationUndo('toast-2'); + + expect(probeAnalysis?.tokenAnalysisLinks).toHaveLength(1); + }); + }); + + it('brings wiped glosses back on Ctrl+Z', async () => { + await renderAndGloss(); + await act(async () => { + screen.getByTestId('tab-toolbar-wipe').click(); + }); + await act(async () => { + screen.getByTestId('wipe-confirm-all').click(); + }); + + act(() => { + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + }); + + expect(probeAnalysis?.tokenAnalysisLinks).toHaveLength(1); + }); + + it('undoes from the Edit menu', async () => { + await renderAndGloss(); + + act(() => screen.getByTestId('tab-toolbar-undo').click()); + + expect(probeAnalysis?.tokenAnalysisLinks).toHaveLength(0); + }); + + it('redoes from the Edit menu', async () => { + await renderAndGloss(); + + act(() => screen.getByTestId('tab-toolbar-undo').click()); + act(() => screen.getByTestId('tab-toolbar-redo').click()); + + expect(probeAnalysis?.tokenAnalysisLinks).toHaveLength(1); + }); + + it("undoes from the toolbar's undo button", async () => { + await renderAndGloss(); + + act(() => screen.getByRole('button', { name: '%undoButton_tooltip%' }).click()); + + expect(probeAnalysis?.tokenAnalysisLinks).toHaveLength(0); + }); + + it("redoes from the toolbar's redo button", async () => { + await renderAndGloss(); + + act(() => screen.getByRole('button', { name: '%undoButton_tooltip%' }).click()); + act(() => screen.getByRole('button', { name: '%redoButton_tooltip%' }).click()); + + expect(probeAnalysis?.tokenAnalysisLinks).toHaveLength(1); + }); + + it('disables the toolbar buttons while there is nothing to undo or redo', async () => { + await act(async () => renderLoader()); + + expect(screen.getByRole('button', { name: '%undoButton_tooltip%' })).toBeDisabled(); + expect(screen.getByRole('button', { name: '%redoButton_tooltip%' })).toBeDisabled(); + }); + + // The toolbar lays its end area out in reverse, so the pair must reach it as one child to keep + // its own order. + it('hands the toolbar the undo buttons and view options as one row, undo first', async () => { + await act(async () => renderLoader()); + + const undoButton = screen.getByRole('button', { name: '%undoButton_tooltip%' }); + const viewOptions = screen.getByTestId('view-options-dropdown'); + const row = undoButton.parentElement; + expect(row).toBe(viewOptions.parentElement); + expect(row).not.toBe(screen.getByTestId('tab-toolbar-end')); + const order = [...(row?.children ?? [])]; + expect(order.indexOf(undoButton)).toBeLessThan(order.indexOf(viewOptions)); + }); + + it('enables the toolbar undo button once there is an edit to undo', async () => { + await renderAndGloss(); + + expect(screen.getByRole('button', { name: '%undoButton_tooltip%' })).toBeEnabled(); + }); +}); diff --git a/src/__tests__/components/MorphemeBox.test.tsx b/src/__tests__/components/MorphemeBox.test.tsx index 31b144edd..537ec96d2 100644 --- a/src/__tests__/components/MorphemeBox.test.tsx +++ b/src/__tests__/components/MorphemeBox.test.tsx @@ -293,6 +293,43 @@ describe('MorphemeGlossInput', () => { expect(dispatchMock).not.toHaveBeenCalled(); }); + it('marks the gloss input as holding nothing uncommitted', () => { + jest.spyOn(AnalysisStore, 'useMorphemeGlossDispatch').mockReturnValue(jest.fn()); + render( + , + ); + expect(screen.getByRole('textbox', { name: 'Gloss for morpheme un-' })).toHaveAttribute( + 'data-draft-field', + 'committed', + ); + }); + + it('marks the gloss input as holding uncommitted text once typed into', async () => { + jest.spyOn(AnalysisStore, 'useMorphemeGlossDispatch').mockReturnValue(jest.fn()); + render( + , + ); + const input = screen.getByRole('textbox', { name: 'Gloss for morpheme un-' }); + await userEvent.type(input, 'not'); + expect(input).toHaveAttribute('data-draft-field', 'pending'); + }); + it('dispatches the gloss on blur when the draft differs', async () => { const dispatchMock = jest.fn(); jest.spyOn(AnalysisStore, 'useMorphemeGlossDispatch').mockReturnValue(dispatchMock); diff --git a/src/__tests__/components/MorphemeEditor.test.tsx b/src/__tests__/components/MorphemeEditor.test.tsx index cec4d4e39..c25b261bd 100644 --- a/src/__tests__/components/MorphemeEditor.test.tsx +++ b/src/__tests__/components/MorphemeEditor.test.tsx @@ -391,8 +391,8 @@ describe('MorphemeBreakdownPopover', () => { describe('reset confirmation', () => { /** - * Renders the popover on a glossed, solely-linked breakdown — the state in which a reset is - * irreversible, so both reset routes confirm first. + * Renders the popover on a glossed, solely-linked breakdown — the state in which a reset + * destroys annotation, so both reset routes confirm first. */ function renderConfirming( props: Partial> = {}, @@ -449,7 +449,7 @@ describe('MorphemeBreakdownPopover', () => { }); it('dismisses without resetting when interacting outside the confirmation', async () => { - // The confirmation exists because the loss is irreversible, so a stray outside click must + // The confirmation exists because the loss is destructive, so a stray outside click must // not answer it — even though an outside click on an edited draft normally commits. const onReset = jest.fn(); const onSave = jest.fn(); @@ -535,7 +535,7 @@ describe('MorphemeBreakdownPopover', () => { }); it('leaves a pending re-split unwritten when the user presses outside the panel', async () => { - // The same reasoning as the reset confirmation: the loss is irreversible, so a stray click + // The same reasoning as the reset confirmation: the loss is destructive, so a stray click // must not answer the prompt, even though an outside press on an edited draft normally saves. const onSave = jest.fn(); const onClose = jest.fn(); diff --git a/src/__tests__/components/PhraseBox.test.tsx b/src/__tests__/components/PhraseBox.test.tsx index 9c99b418a..e21457698 100644 --- a/src/__tests__/components/PhraseBox.test.tsx +++ b/src/__tests__/components/PhraseBox.test.tsx @@ -1106,6 +1106,23 @@ describe('PhraseBox', () => { expect(screen.getByRole('button', { name: 'Remove World' })).toBeInTheDocument(); }); + it('marks the phrase gloss input as holding nothing uncommitted', () => { + mockUsePhraseLinkForToken.mockReturnValue(TEST_PHRASE_LINK); + renderBox(); + expect(screen.getByTestId('phrase-gloss-input')).toHaveAttribute( + 'data-draft-field', + 'committed', + ); + }); + + it('marks the phrase gloss input as holding uncommitted text once typed into', async () => { + mockUsePhraseLinkForToken.mockReturnValue(TEST_PHRASE_LINK); + renderBox(); + const glossInput = screen.getByTestId('phrase-gloss-input'); + await userEvent.type(glossInput, 'hello'); + expect(glossInput).toHaveAttribute('data-draft-field', 'pending'); + }); + it('writes phrase gloss on blur when draft differs from committed', async () => { mockUsePhraseGloss.mockReturnValue(''); const dispatchSpy = jest.fn(); diff --git a/src/__tests__/components/SegmentFreeTranslationInput.test.tsx b/src/__tests__/components/SegmentFreeTranslationInput.test.tsx index 9d6672075..567926403 100644 --- a/src/__tests__/components/SegmentFreeTranslationInput.test.tsx +++ b/src/__tests__/components/SegmentFreeTranslationInput.test.tsx @@ -43,6 +43,24 @@ describe('SegmentFreeTranslationInput', () => { expect(mockDispatch).toHaveBeenCalledWith('GEN 1:1', 'In the beginning', 'Au commencement'); }); + it('marks the input as holding nothing uncommitted', () => { + render(); + + expect(screen.getByTestId('segment-free-translation-input')).toHaveAttribute( + 'data-draft-field', + 'committed', + ); + }); + + it('marks the input as holding uncommitted text once typed into', async () => { + render(); + + const input = screen.getByTestId('segment-free-translation-input'); + await userEvent.type(input, 'Au'); + + expect(input).toHaveAttribute('data-draft-field', 'pending'); + }); + it('renders the stored translation as plain text when read-only', () => { mockReadOnlyState.value = true; mockCommittedState.value = 'Au commencement'; diff --git a/src/__tests__/components/TokenChip.test.tsx b/src/__tests__/components/TokenChip.test.tsx index e4f2ed853..92a71db46 100644 --- a/src/__tests__/components/TokenChip.test.tsx +++ b/src/__tests__/components/TokenChip.test.tsx @@ -171,6 +171,28 @@ describe('TokenChip', () => { ).toBeInTheDocument(); }); + it('marks its gloss input as holding nothing uncommitted', () => { + render( + + + , + ); + expect( + screen.getByRole('textbox', { name: '%interlinearizer_tokenChip_glossLabel%' }), + ).toHaveAttribute('data-draft-field', 'committed'); + }); + + it('marks its gloss input as holding uncommitted text once typed into', async () => { + render( + + + , + ); + const input = screen.getByRole('textbox', { name: '%interlinearizer_tokenChip_glossLabel%' }); + await userEvent.type(input, 'in'); + expect(input).toHaveAttribute('data-draft-field', 'pending'); + }); + it('shows the current gloss value from the store', () => { const initialAnalysis = { tokenAnalyses: [ diff --git a/src/__tests__/hooks/useDraftProject.test.ts b/src/__tests__/hooks/useDraftProject.test.ts index 82b85573e..53fea902b 100644 --- a/src/__tests__/hooks/useDraftProject.test.ts +++ b/src/__tests__/hooks/useDraftProject.test.ts @@ -4,7 +4,7 @@ import papi, { logger } from '@papi/frontend'; import { act, renderHook, waitFor } from '@testing-library/react'; import type { DraftProject, TextAnalysis } from 'interlinearizer'; import { FIXTURE_STAMPS } from '../test-helpers'; -import useDraftProject from '../../hooks/useDraftProject'; +import useDraftProject, { type DraftContent, type StepSummary } from '../../hooks/useDraftProject'; import { emptyAnalysis } from '../../types/empty-factories'; import { CURRENT_MODEL_VERSION } from '../../types/model-version'; @@ -238,6 +238,23 @@ describe('useDraftProject', () => { jest.useRealTimers(); }); + it('leaves a clean draft clean when an edit keeps the boundaries it has', async () => { + const { result } = await renderLoaded(); + + act(() => result.current.autosaveSegmentation(undefined)); + + expect(result.current.dirty).toBe(false); + }); + + it('does not bump segmentationVersion for an edit that keeps the boundaries it has', async () => { + const { result } = await renderLoaded(); + const versionBefore = result.current.segmentationVersion; + + act(() => result.current.autosaveSegmentation(undefined)); + + expect(result.current.segmentationVersion).toBe(versionBefore); + }); + it('replaces a pending debounced write when called again before it flushes', async () => { const { result } = await renderLoaded(); @@ -620,6 +637,641 @@ describe('useDraftProject', () => { }); }); + describe('undo history', () => { + it('drops the history when the source changes', async () => { + const view = renderHook(({ source }) => useDraftProject(source, PLATFORM_LANGUAGE), { + initialProps: { source: SOURCE_PROJECT_ID }, + }); + await waitFor(() => expect(view.result.current.isDraftLoading).toBe(false)); + act(() => view.result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + + view.rerender({ source: 'source-project-2' }); + await waitFor(() => expect(view.result.current.isDraftLoading).toBe(false)); + + expect(view.result.current.canUndo).toBe(false); + }); + + it('undoes an analysis edit back to the analysis before it', async () => { + const loaded = analysisWithToken('tok-loaded'); + mockGetDraftResolves(makeDraft({ analysis: loaded })); + const { result } = await renderLoaded(); + + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + act(() => result.current.undo()); + + expect(result.current.getDraftSnapshot()?.analysis).toEqual(loaded); + }); + + it('hands the analysis an undo restores to subscribers', async () => { + const loaded = analysisWithToken('tok-loaded'); + mockGetDraftResolves(makeDraft({ analysis: loaded })); + const { result } = await renderLoaded(); + const listener = jest.fn(); + result.current.subscribeToAnalysisReplacements(listener); + + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + act(() => result.current.undo()); + + expect(listener).toHaveBeenCalledWith(loaded); + }); + + it('does not hand subscribers an edit the analysis store made itself', async () => { + const { result } = await renderLoaded(); + const listener = jest.fn(); + result.current.subscribeToAnalysisReplacements(listener); + + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('stops handing analyses to a listener once it unsubscribes', async () => { + const { result } = await renderLoaded(); + const listener = jest.fn(); + const unsubscribe = result.current.subscribeToAnalysisReplacements(listener); + + unsubscribe(); + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + act(() => result.current.undo()); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('undoes a boundary edit back to the boundaries before it', async () => { + const { result } = await renderLoaded(); + + act(() => + result.current.autosaveSegmentation({ removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }), + ); + act(() => result.current.undo()); + + expect(result.current.getDraftSnapshot()?.segmentation).toBeUndefined(); + }); + + it('redoes an undone boundary edit', async () => { + const { result } = await renderLoaded(); + const delta = { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }; + + act(() => result.current.autosaveSegmentation(delta)); + act(() => result.current.undo()); + act(() => result.current.redo()); + + expect(result.current.getDraftSnapshot()?.segmentation).toBe(delta); + }); + + it('bumps segmentationVersion when an undo changes the boundaries', async () => { + const { result } = await renderLoaded(); + act(() => + result.current.autosaveSegmentation({ removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }), + ); + const versionBefore = result.current.segmentationVersion; + + act(() => result.current.undo()); + + expect(result.current.segmentationVersion).toBe(versionBefore + 1); + }); + + it('redoes an undone edit', async () => { + const { result } = await renderLoaded(); + const edited = analysisWithToken('tok-edited'); + + act(() => result.current.autosaveAnalysis(edited)); + act(() => result.current.undo()); + act(() => result.current.redo()); + + expect(result.current.getDraftSnapshot()?.analysis).toBe(edited); + }); + + it('records no step for a save that changes nothing', async () => { + const loaded = analysisWithToken('tok-loaded'); + mockGetDraftResolves(makeDraft({ analysis: loaded })); + const { result } = await renderLoaded(); + const edited = analysisWithToken('tok-edited'); + + act(() => result.current.autosaveAnalysis(edited)); + act(() => result.current.autosaveAnalysis(edited)); + act(() => result.current.undo()); + + expect(result.current.getDraftSnapshot()?.analysis).toEqual(loaded); + }); + + it('undoes the saves made as one step together', async () => { + const loaded = analysisWithToken('tok-loaded'); + mockGetDraftResolves(makeDraft({ analysis: loaded })); + const { result } = await renderLoaded(); + + act(() => + result.current.asOneStep(() => { + result.current.autosaveAnalysis(analysisWithToken('tok-edited')); + result.current.autosaveSegmentation({ + removedVerseStarts: ['GEN 1:2:0'], + addedStarts: [], + }); + }), + ); + act(() => result.current.undo()); + + expect(result.current.getDraftSnapshot()?.analysis).toEqual(loaded); + expect(result.current.getDraftSnapshot()?.segmentation).toBeUndefined(); + }); + + it('leaves the draft clean when there is nothing to undo', async () => { + const { result } = await renderLoaded(); + + act(() => result.current.undo()); + + expect(result.current.dirty).toBe(false); + }); + + it('forgets the history when a project is opened', async () => { + const { result } = await renderLoaded(); + const opened = analysisWithToken('tok-open'); + + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + act(() => result.current.loadFromProject({ analysis: opened, analysisLanguages: ['de'] })); + act(() => result.current.undo()); + + expect(result.current.getDraftSnapshot()?.analysis).toBe(opened); + }); + + it('forgets the history when a new draft is started', async () => { + mockGetDraftResolves(makeDraft({ analysis: analysisWithToken('tok-loaded') })); + const { result } = await renderLoaded(); + + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + act(() => result.current.newDraft({ analysisLanguages: ['sw'] })); + act(() => result.current.undo()); + + expect(result.current.getDraftSnapshot()?.analysis).toEqual(emptyAnalysis()); + }); + + it('has nothing to undo or redo before any edit', async () => { + const { result } = await renderLoaded(); + + expect(result.current.canUndo).toBe(false); + expect(result.current.canRedo).toBe(false); + }); + + it('can undo once an edit is made', async () => { + const { result } = await renderLoaded(); + + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + + expect(result.current.canUndo).toBe(true); + }); + + it('can redo once an edit is undone', async () => { + const { result } = await renderLoaded(); + + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + act(() => result.current.undo()); + + expect(result.current.canRedo).toBe(true); + expect(result.current.canUndo).toBe(false); + }); + + it('has nothing to undo once a project is opened', async () => { + const { result } = await renderLoaded(); + + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + act(() => + result.current.loadFromProject({ analysis: emptyAnalysis(), analysisLanguages: [] }), + ); + + expect(result.current.canUndo).toBe(false); + }); + + describe('wipes', () => { + it('undoes a book wipe', async () => { + const loaded = analysisWithToken('tok-loaded'); + mockGetDraftResolves(makeDraft({ analysis: loaded })); + const { result } = await renderLoaded(); + + act(() => result.current.wipeBook('GEN')); + act(() => { + result.current.undo(); + }); + + expect(result.current.getDraftSnapshot()?.analysis).toEqual(loaded); + }); + + it('undoes a whole-draft wipe', async () => { + const loaded = analysisWithToken('tok-loaded'); + mockGetDraftResolves(makeDraft({ analysis: loaded })); + const { result } = await renderLoaded(); + + act(() => result.current.wipeAll()); + act(() => { + result.current.undo(); + }); + + expect(result.current.getDraftSnapshot()?.analysis).toEqual(loaded); + }); + + it('keeps the edits made before a wipe undoable', async () => { + const loaded = analysisWithToken('tok-loaded'); + mockGetDraftResolves(makeDraft({ analysis: loaded })); + const { result } = await renderLoaded(); + + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + act(() => result.current.wipeAll()); + act(() => { + result.current.undo(); + }); + act(() => { + result.current.undo(); + }); + + expect(result.current.getDraftSnapshot()?.analysis).toEqual(loaded); + }); + + it('is dirty after undoing a whole-draft wipe', async () => { + mockGetDraftResolves(makeDraft({ analysis: analysisWithToken('tok-loaded') })); + const { result } = await renderLoaded(); + + act(() => result.current.wipeAll()); + act(() => { + result.current.undo(); + }); + + expect(result.current.dirty).toBe(true); + }); + + it('is clean again after redoing a whole-draft wipe', async () => { + mockGetDraftResolves(makeDraft({ analysis: analysisWithToken('tok-loaded') })); + const { result } = await renderLoaded(); + + act(() => result.current.wipeAll()); + act(() => { + result.current.undo(); + }); + act(() => { + result.current.redo(); + }); + + expect(result.current.dirty).toBe(false); + }); + }); + + describe('history revision', () => { + it('moves when a step is recorded', async () => { + const { result } = await renderLoaded(); + const before = result.current.getHistoryRevision(); + + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + + expect(result.current.getHistoryRevision()).not.toBe(before); + }); + + it('moves when a step is undone', async () => { + const { result } = await renderLoaded(); + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + const before = result.current.getHistoryRevision(); + + act(() => { + result.current.undo(); + }); + + expect(result.current.getHistoryRevision()).not.toBe(before); + }); + + it('holds still through a re-anchor', async () => { + mockGetDraftResolves(makeDraft({ analysis: analysisWithToken('tok') })); + const { result } = await renderLoaded(); + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + const before = result.current.getHistoryRevision(); + + act(() => + result.current.reanchorBook('GEN', (content) => ({ + ...content, + analysis: analysisWithToken('tok-reanchored'), + })), + ); + + expect(result.current.getHistoryRevision()).toBe(before); + }); + + it('tells subscribers when it moves', async () => { + const { result } = await renderLoaded(); + const listener = jest.fn(); + result.current.subscribeToHistoryRevisions(listener); + + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it('stops telling a subscriber once it unsubscribes', async () => { + const { result } = await renderLoaded(); + const listener = jest.fn(); + const unsubscribe = result.current.subscribeToHistoryRevisions(listener); + + unsubscribe(); + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + + expect(listener).not.toHaveBeenCalled(); + }); + }); + + describe('step summaries', () => { + it('summarizes a grouped step as it was told to', async () => { + const { result } = await renderLoaded(); + + act(() => + result.current.asOneStep( + () => result.current.autosaveAnalysis(analysisWithToken('tok-edited')), + { kind: 'catalogDelete', form: 'word', analysisId: 'ta-1', count: 2 }, + ), + ); + let summary: StepSummary | undefined; + act(() => { + summary = result.current.undo()?.summary; + }); + + expect(summary).toEqual({ + kind: 'catalogDelete', + form: 'word', + analysisId: 'ta-1', + count: 2, + }); + }); + + it('summarizes a grouped step by what its action returned', async () => { + const { result } = await renderLoaded(); + + act(() => + result.current.asOneStep( + () => { + result.current.autosaveAnalysis(analysisWithToken('tok-edited')); + return 'ta-3'; + }, + (survivor) => ({ + kind: 'catalogMerge', + form: 'word', + analysisId: 'ta-1', + survivingAnalysisId: survivor, + }), + ), + ); + let summary: StepSummary | undefined; + act(() => { + summary = result.current.undo()?.summary; + }); + + expect(summary).toEqual({ + kind: 'catalogMerge', + form: 'word', + analysisId: 'ta-1', + survivingAnalysisId: 'ta-3', + }); + }); + }); + + describe('edit locations', () => { + it('names where an undone edit was made', async () => { + const { result } = await renderLoaded(); + + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'), 'GEN 1:1:0')); + let location: string | undefined; + act(() => { + location = result.current.undo()?.location; + }); + + expect(location).toBe('GEN 1:1:0'); + }); + + it('names where a redone edit was made', async () => { + const { result } = await renderLoaded(); + + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'), 'GEN 1:1:0')); + act(() => { + result.current.undo(); + }); + let location: string | undefined; + act(() => { + location = result.current.redo()?.location; + }); + + expect(location).toBe('GEN 1:1:0'); + }); + + it('names where an undone boundary edit was made', async () => { + const { result } = await renderLoaded(); + + act(() => + result.current.autosaveSegmentation( + { removedVerseStarts: [], addedStarts: [{ tokenRef: 'GEN 1:1:6', surfaceText: 'b' }] }, + 'GEN 1:1:6', + ), + ); + let location: string | undefined; + act(() => { + location = result.current.undo()?.location; + }); + + expect(location).toBe('GEN 1:1:6'); + }); + + it('names a grouped step by where its first edit was made', async () => { + const { result } = await renderLoaded(); + + act(() => + result.current.asOneStep(() => { + result.current.autosaveAnalysis(analysisWithToken('tok-edited'), 'GEN 1:1:0'); + result.current.autosaveSegmentation( + { removedVerseStarts: ['GEN 1:2:0'], addedStarts: [] }, + 'GEN 1:2:0', + ); + }), + ); + let location: string | undefined; + act(() => { + location = result.current.undo()?.location; + }); + + expect(location).toBe('GEN 1:1:0'); + }); + }); + + describe('dirty baseline', () => { + it('is clean again when an undo returns to the loaded content', async () => { + const { result } = await renderLoaded(); + + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + act(() => result.current.undo()); + + expect(result.current.dirty).toBe(false); + }); + + it('stays dirty when an undo lands on content that was never synced', async () => { + const { result } = await renderLoaded(); + + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-first'))); + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-second'))); + act(() => result.current.undo()); + + expect(result.current.dirty).toBe(true); + }); + + it('is clean again when an undo returns to the last saved content', async () => { + const { result } = await renderLoaded(); + const saved = analysisWithToken('tok-saved'); + + act(() => result.current.autosaveAnalysis(saved)); + act(() => result.current.markSynced(saved, undefined)); + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-after-save'))); + act(() => result.current.undo()); + + expect(result.current.dirty).toBe(false); + }); + + it('is clean again when an undo returns to an opened project', async () => { + const { result } = await renderLoaded(); + + act(() => + result.current.loadFromProject({ + analysis: analysisWithToken('tok-open'), + analysisLanguages: ['de'], + }), + ); + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + act(() => result.current.undo()); + + expect(result.current.dirty).toBe(false); + }); + + it('stays dirty after undoing into a draft that loaded unsaved', async () => { + mockGetDraftResolves(makeDraft({ dirty: true })); + const { result } = await renderLoaded(); + + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + act(() => result.current.undo()); + + expect(result.current.dirty).toBe(true); + }); + + it('persists the draft as clean once an undo returns it to the baseline', async () => { + const { result } = await renderLoaded(); + + jest.useFakeTimers(); + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + act(() => result.current.undo()); + act(() => jest.advanceTimersByTime(300)); + jest.useRealTimers(); + + expect(lastSavedDraft().dirty).toBe(false); + }); + }); + + describe('re-anchoring', () => { + /** A pass that renames the content's token analysis, so a test can see where it ran. */ + const renameToken = + (suffix: string) => + (content: DraftContent): DraftContent => ({ + ...content, + analysis: analysisWithToken(`${content.analysis.tokenAnalyses[0]?.id}${suffix}`), + }); + + it("applies a book's pass to the draft", async () => { + mockGetDraftResolves(makeDraft({ analysis: analysisWithToken('tok') })); + const { result } = await renderLoaded(); + + act(() => result.current.reanchorBook('GEN', renameToken('+GEN'))); + + expect(result.current.getDraftSnapshot()?.analysis.tokenAnalyses[0].id).toBe('tok+GEN'); + }); + + it('keeps a re-anchor when the edit before it is undone', async () => { + mockGetDraftResolves(makeDraft({ analysis: analysisWithToken('tok-loaded') })); + const { result } = await renderLoaded(); + + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + act(() => result.current.reanchorBook('GEN', renameToken('+GEN'))); + act(() => result.current.undo()); + + expect(result.current.getDraftSnapshot()?.analysis.tokenAnalyses[0].id).toBe( + 'tok-loaded+GEN', + ); + }); + + it('hands a re-anchored analysis to subscribers', async () => { + mockGetDraftResolves(makeDraft({ analysis: analysisWithToken('tok') })); + const { result } = await renderLoaded(); + const listener = jest.fn(); + result.current.subscribeToAnalysisReplacements(listener); + + act(() => result.current.reanchorBook('GEN', renameToken('+GEN'))); + + expect(listener).toHaveBeenCalledWith(analysisWithToken('tok+GEN')); + }); + + it('leaves the draft clean when a pass moves nothing', async () => { + const { result } = await renderLoaded(); + + act(() => result.current.reanchorBook('GEN', (content) => content)); + + expect(result.current.dirty).toBe(false); + }); + + it('leaves a clean draft clean when a pass moves something', async () => { + mockGetDraftResolves(makeDraft({ analysis: analysisWithToken('tok') })); + const { result } = await renderLoaded(); + + act(() => result.current.reanchorBook('GEN', renameToken('+GEN'))); + + expect(result.current.dirty).toBe(false); + }); + + it('keeps an edited draft dirty through a re-anchor', async () => { + mockGetDraftResolves(makeDraft({ analysis: analysisWithToken('tok') })); + const { result } = await renderLoaded(); + + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + act(() => result.current.reanchorBook('GEN', renameToken('+GEN'))); + + expect(result.current.dirty).toBe(true); + }); + + it('is clean again once an edit is undone past two passes over its book', async () => { + mockGetDraftResolves(makeDraft({ analysis: analysisWithToken('tok') })); + const { result } = await renderLoaded(); + + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + act(() => result.current.reanchorBook('GEN', renameToken('+a'))); + act(() => result.current.reanchorBook('GEN', renameToken('+b'))); + act(() => result.current.undo()); + + expect(result.current.dirty).toBe(false); + }); + + it('replays a pass run twice only once on undo', async () => { + mockGetDraftResolves(makeDraft({ analysis: analysisWithToken('tok-loaded') })); + const { result } = await renderLoaded(); + const pass = renameToken('+GEN'); + + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + act(() => result.current.reanchorBook('GEN', pass)); + act(() => result.current.reanchorBook('GEN', pass)); + act(() => result.current.undo()); + + expect(result.current.getDraftSnapshot()?.analysis.tokenAnalyses[0].id).toBe( + 'tok-loaded+GEN', + ); + }); + + it('is clean again once the edit before a re-anchor is undone', async () => { + mockGetDraftResolves(makeDraft({ analysis: analysisWithToken('tok') })); + const { result } = await renderLoaded(); + + act(() => result.current.autosaveAnalysis(analysisWithToken('tok-edited'))); + act(() => result.current.reanchorBook('GEN', renameToken('+GEN'))); + act(() => result.current.undo()); + + expect(result.current.dirty).toBe(false); + }); + }); + }); + it('does not update state or throw when unmounted before getDraft resolves', async () => { let resolveGetDraft: (json: string) => void = () => {}; const deferred = new Promise((resolve) => { diff --git a/src/__tests__/hooks/useUndoRedoKeys.test.tsx b/src/__tests__/hooks/useUndoRedoKeys.test.tsx new file mode 100644 index 000000000..1f20c96a0 --- /dev/null +++ b/src/__tests__/hooks/useUndoRedoKeys.test.tsx @@ -0,0 +1,199 @@ +/// + +import { fireEvent, render, renderHook, screen } from '@testing-library/react'; +import useUndoRedoKeys from '../../hooks/useUndoRedoKeys'; +import { pretendMacOs } from '../test-helpers'; + +/** Binds the hook to fresh undo and redo spies. */ +function renderKeys() { + const undo = jest.fn(); + const redo = jest.fn(); + const view = renderHook(() => useUndoRedoKeys({ undo, redo })); + return { undo, redo, ...view }; +} + +describe('useUndoRedoKeys', () => { + it('undoes on Ctrl+Z', () => { + const { undo } = renderKeys(); + + fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + + expect(undo).toHaveBeenCalledTimes(1); + }); + + it('redoes on Ctrl+Y', () => { + const { redo } = renderKeys(); + + fireEvent.keyDown(document.body, { key: 'y', ctrlKey: true }); + + expect(redo).toHaveBeenCalledTimes(1); + }); + + it('redoes rather than undoes on Ctrl+Shift+Z', () => { + const { undo, redo } = renderKeys(); + + fireEvent.keyDown(document.body, { key: 'Z', ctrlKey: true, shiftKey: true }); + + expect(redo).toHaveBeenCalledTimes(1); + expect(undo).not.toHaveBeenCalled(); + }); + + it('claims the shortcut from the browser', () => { + renderKeys(); + + const notCanceled = fireEvent.keyDown(document.body, { key: 'z', ctrlKey: true }); + + expect(notCanceled).toBe(false); + }); + + it('leaves other shortcuts to the browser', () => { + const { undo, redo } = renderKeys(); + + const notCanceled = fireEvent.keyDown(document.body, { key: 'c', ctrlKey: true }); + + expect(undo).not.toHaveBeenCalled(); + expect(redo).not.toHaveBeenCalled(); + expect(notCanceled).toBe(true); + }); + + it('leaves Ctrl+Z to a text field that holds no draft content', () => { + const { undo } = renderKeys(); + render(); + + const notCanceled = fireEvent.keyDown(screen.getByLabelText('search'), { + key: 'z', + ctrlKey: true, + }); + + expect(undo).not.toHaveBeenCalled(); + expect(notCanceled).toBe(true); + }); + + it('leaves Ctrl+Z to a draft field holding uncommitted text', () => { + const { undo } = renderKeys(); + render(); + + fireEvent.keyDown(screen.getByLabelText('gloss'), { key: 'z', ctrlKey: true }); + + expect(undo).not.toHaveBeenCalled(); + }); + + it('undoes from a draft field with nothing uncommitted while another holds uncommitted text', () => { + const { undo } = renderKeys(); + render( + <> + + + , + ); + + fireEvent.keyDown(screen.getByLabelText('gloss'), { key: 'z', ctrlKey: true }); + + expect(undo).toHaveBeenCalledTimes(1); + }); + + it('undoes from a draft field with nothing uncommitted', () => { + const { undo } = renderKeys(); + render(