diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5366e8a89..94399ed99 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -28,6 +28,9 @@ updates: - dependency-name: platform-bible-react allow: # dependencies + - dependency-name: '@dnd-kit/core' + - dependency-name: '@dnd-kit/sortable' + - dependency-name: '@dnd-kit/utilities' - dependency-name: '@reduxjs/toolkit' - dependency-name: react-redux # devDependencies @@ -52,6 +55,7 @@ updates: applies-to: version-updates update-types: ['minor', 'patch'] patterns: + - '@dnd-kit/*' - '@reduxjs/toolkit' - react-redux dev-tooling: diff --git a/__mocks__/lucide-react.tsx b/__mocks__/lucide-react.tsx index 2a01ea791..89679d3d0 100644 --- a/__mocks__/lucide-react.tsx +++ b/__mocks__/lucide-react.tsx @@ -12,6 +12,20 @@ export function LocateFixed(props: Readonly<{ className?: string }>): ReactEleme return ; } +/** + * Stub for the ArrowUpToLine icon. + */ +export function ArrowUpToLine(props: Readonly<{ className?: string }>): ReactElement { + return ; +} + +/** + * Stub for the GripVertical icon. + */ +export function GripVertical(props: Readonly<{ className?: string }>): ReactElement { + return ; +} + /** * Stub for the Info icon. */ @@ -77,6 +91,11 @@ export function Merge(props: Readonly<{ className?: string }>): ReactElement { return ; } +/** Stub for the ArrowRight icon. */ +export function ArrowRight(props: Readonly<{ className?: string }>): ReactElement { + return ; +} + /** * Stub for the Split icon: one stroke diverging into two, the mirror of the {@link Merge} glyph's * join. diff --git a/__mocks__/platform-bible-react.tsx b/__mocks__/platform-bible-react.tsx index bb6ae6096..2587ff8c8 100644 --- a/__mocks__/platform-bible-react.tsx +++ b/__mocks__/platform-bible-react.tsx @@ -20,6 +20,7 @@ import { } from 'react'; import type { ChangeEventHandler, + FocusEventHandler, CSSProperties, KeyboardEventHandler, MouseEventHandler, @@ -420,6 +421,7 @@ export const Input = forwardRef< className?: string; style?: CSSProperties; disabled?: boolean; + onBlur?: FocusEventHandler; onChange?: ChangeEventHandler; onKeyDown?: KeyboardEventHandler; 'aria-label'?: string; @@ -434,6 +436,7 @@ export const Input = forwardRef< className, style, disabled, + onBlur, onChange, onKeyDown, 'aria-label': ariaLabel, @@ -451,6 +454,7 @@ export const Input = forwardRef< className={className} style={style} disabled={disabled} + onBlur={onBlur} onChange={onChange} onKeyDown={onKeyDown} aria-label={ariaLabel} @@ -610,6 +614,42 @@ export function Switch({ ); } +/** + * Stub checkbox rendered as a native `` so `toBeChecked`, `toBeDisabled`, and + * click interactions work in tests. (The real component renders a ` + ); -} +}); /** * Stub dialog title rendered as the `

` the real component produces, keeping the heading role and @@ -997,10 +1065,14 @@ export function DialogTitle({ * Context carrying the {@link Popover}'s open state and change handler down to * {@link PopoverTrigger}, mirroring how the real Radix-based component coordinates the two. */ -const PopoverContext = createContext<{ - onOpenChange?: (open: boolean) => void; - open?: boolean; -}>({}); +// Undefined outside a Popover, so PopoverAnchor can refuse as the real component does. +const PopoverContext = createContext< + | { + onOpenChange?: (open: boolean) => void; + open?: boolean; + } + | undefined +>(undefined); /** * Stub popover root that renders its children unconditionally. The extension conditionally mounts @@ -1048,14 +1120,27 @@ export function PopoverTrigger({ * Stub popover anchor that renders its children as-is, matching the real component's `asChild` * pass-through behavior. */ +/** + * Stub portal-container provider, rendering its children where they stand — jsdom has no portal + * behavior worth reproducing. + */ +export function PopoverPortalContainerProvider({ + children, +}: Readonly<{ children?: ReactNode; container?: HTMLElement | null }>): ReactElement { + return <>{children}; +} + export function PopoverAnchor({ children, }: Readonly<{ children?: ReactNode; asChild?: boolean }>): ReactElement { + // Throws outside a Popover as the real component does. + if (useContext(PopoverContext) === undefined) + throw new Error('`PopoverAnchor` must be used within `Popover`'); return <>{children}; } /** - * Stub popover content rendered as a `
` — the role + * Stub popover content rendered as a `
` — the role * matching the real component, which is what makes its `aria-label` meaningful, and which is why a * test that must reach a modal instead selects on `[data-slot="dialog-content"]`. The real component * implements positioning, portaling, and dismissal internally; this stub exposes the dismissal @@ -1140,6 +1225,7 @@ export function PopoverContent({ ref={contentRef} aria-label={ariaLabel} className={className} + data-slot="popover-content" data-testid={testId} id={id} role={role} diff --git a/contributions/localizedStrings.json b/contributions/localizedStrings.json index bf9e3c2b7..08be7c0c2 100644 --- a/contributions/localizedStrings.json +++ b/contributions/localizedStrings.json @@ -47,11 +47,65 @@ "%interlinearizer_analysisCatalog_confidence_low%": "Low", "%interlinearizer_analysisCatalog_confidence_guess%": "Guess", "%interlinearizer_analysisCatalog_noGloss%": "(no gloss)", + "%interlinearizer_analysisCatalog_morphemeNoGloss%": "(no gloss)", + "%interlinearizer_analysisCatalog_noBreakdown%": "(not split into morphemes)", "%interlinearizer_analysisCatalog_usageCount%": "Uses in the whole draft", "%interlinearizer_analysisCatalog_usageCountInBook%": "Uses in {book}", "%interlinearizer_analysisCatalog_noUsages%": "Not used anywhere", "%interlinearizer_analysisCatalog_showAllUsages%": "Show {count} more", + "%interlinearizer_analysisCatalog_editGloss%": "Gloss", + "%interlinearizer_analysisCatalog_editMorphemes%": "Split into morphemes", + "%interlinearizer_analysisCatalog_editMorphemesHint%": "Enter morpheme forms separated by spaces", + "%interlinearizer_analysisCatalog_editMorphemesSave%": "Save breakdown", + "%interlinearizer_analysisCatalog_editMorphemesCancel%": "Cancel", + "%interlinearizer_analysisCatalog_editMorphemesReset%": "Discard breakdown", + "%interlinearizer_analysisCatalog_editMorphemesOpen%": "Edit breakdown for {form}", + "%interlinearizer_analysisCatalog_confirmResetPrompt%": "Discard this breakdown, with the glosses and lexicon links on it, everywhere {form} is used?", + "%interlinearizer_analysisCatalog_confirmResetAction%": "Discard breakdown", + "%interlinearizer_analysisCatalog_confirmResplitPrompt%": "This breakdown drops {forms}, discarding the glosses and lexicon links on them everywhere this analysis is used. Save anyway?", + "%interlinearizer_analysisCatalog_confirmResplitAction%": "Save and discard", + "%interlinearizer_analysisCatalog_morphemeGloss%": "Gloss for morpheme {form}", + "%interlinearizer_analysisCatalog_appliesToAll%": "Edits here apply to all {count} uses of this analysis.", + "%interlinearizer_analysisCatalog_merge%": "Merge…", + "%interlinearizer_analysisCatalog_mergeTitle%": "Merge an analysis", + "%interlinearizer_analysisCatalog_mergeForm%": "Form", + "%interlinearizer_analysisCatalog_mergeUsageCount%": "{count} uses", + "%interlinearizer_analysisCatalog_mergePromote%": "Merge the others into {gloss}", + "%interlinearizer_analysisCatalog_mergeReorder%": "Reorder {gloss}", + "%interlinearizer_analysisCatalog_mergeRevertField%": "Undo this edit", + "%interlinearizer_analysisCatalog_mergeMorphemeGlosses%": "Morpheme glosses", + "%interlinearizer_analysisCatalog_mergeClearMorphemeGloss%": "Clear the gloss for {form}", + "%interlinearizer_analysisCatalog_mergeConfidence%": "Confidence", + "%interlinearizer_analysisCatalog_mergeConfidenceNone%": "Not recorded", + "%interlinearizer_analysisCatalog_mergeReset%": "Undo all edits", + "%interlinearizer_analysisCatalog_mergeWillCollapse%": "This will also absorb the identical analysis {gloss}, which is not selected.", + "%interlinearizer_analysisCatalog_mergeCancel%": "Cancel", + "%interlinearizer_analysisCatalog_mergeConfirm%": "Merge", + "%interlinearizer_analysisCatalog_merged%": "Merged into {gloss} — now {count} uses.", + "%interlinearizer_analysisCatalog_mergedNoGloss%": "Merged into {form} — now {count} uses.", + "%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_deleteFallbackDrifted%": "{count} uses will fall back to another analysis, which one depending on the text beneath each of them.", + "%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_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", + "%interlinearizer_analysisCatalog_closeConfirmDiscard%": "Discard and close", + "%interlinearizer_analysisCatalog_discardForDeletePrompt%": "You have typed a morpheme breakdown but not saved it. Deleting this analysis discards it.", + "%interlinearizer_analysisCatalog_discardForDeleteConfirm%": "Discard and delete", + "%interlinearizer_analysisCatalog_discardForMergePrompt%": "You have typed a morpheme breakdown but not saved it. Merging this analysis discards it.", + "%interlinearizer_analysisCatalog_discardForMergeConfirm%": "Discard and merge", + "%interlinearizer_projectSettings_title%": "Interlinearizer", "%interlinearizer_projectSettings_continuousScroll%": "Continuous Scroll", "%interlinearizer_projectSettings_continuousScrollDescription%": "Display words in a continuous horizontal scroll strip instead of chapter-segmented rows", @@ -83,8 +137,10 @@ "%interlinearizer_morphemeEditor_cancel%": "Cancel", "%interlinearizer_morphemeEditor_done%": "Done", "%interlinearizer_morphemeEditor_emptyHint%": "Enter morpheme forms separated by spaces", - "%interlinearizer_morphemeEditor_confirmResetPrompt%": "Discard this breakdown and its glosses?", + "%interlinearizer_morphemeEditor_confirmResetPrompt%": "Discard this breakdown, with the glosses and lexicon links on it?", "%interlinearizer_morphemeEditor_confirmResetAction%": "Reset", + "%interlinearizer_morphemeEditor_confirmResplitPrompt%": "This breakdown drops {forms}, discarding the glosses and lexicon links on them. Save anyway?", + "%interlinearizer_morphemeEditor_confirmResplitAction%": "Save and discard", "%interlinearizer_morphemeGloss_label%": "Gloss for morpheme {form}", "%interlinearizer_tokenChip_editMorphemes%": "Edit morpheme breakdown for {token}", "%interlinearizer_tokenChip_defineMorphemes%": "Define morpheme breakdown for {token}", diff --git a/package-lock.json b/package-lock.json index 4fe399048..7319e7ee1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "0.3.0-alpha.0", "license": "MIT", "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@reduxjs/toolkit": "^2.12.0", "@sillsdev/scripture": "^2.1.0", "platform-bible-utils": "file:../paranext-core/lib/platform-bible-utils", @@ -1197,6 +1200,59 @@ "node": ">=10.0.0" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/sortable": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz", + "integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==", + "license": "MIT", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.3.0", + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/@dotenvx/dotenvx": { "version": "1.75.1", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz", @@ -17015,7 +17071,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tw-animate-css": { diff --git a/package.json b/package.json index 5d8847ede..8ef9331e3 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,9 @@ "react-dom": ">=19.0.0" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@reduxjs/toolkit": "^2.12.0", "@sillsdev/scripture": "^2.1.0", "platform-bible-utils": "file:../paranext-core/lib/platform-bible-utils", diff --git a/src/__tests__/components/AnalysisCatalogPanel.test.tsx b/src/__tests__/components/AnalysisCatalogPanel.test.tsx index 9855ddd44..21d048377 100644 --- a/src/__tests__/components/AnalysisCatalogPanel.test.tsx +++ b/src/__tests__/components/AnalysisCatalogPanel.test.tsx @@ -38,13 +38,19 @@ function mockInterfaceLanguage(interfaceLanguage: string[] = ['und']): void { }); } -/** Builds a link from `tokenRef` to the analysis, approved unless another status is given. */ +/** + * Builds a link from `tokenRef` to the analysis, approved unless another status is given. + * + * `surfaceText` defaults to the form these fixtures' analyses overwhelmingly carry, since a + * snapshot disagreeing with its analysis is what the store reads as baseline drift. + */ function link( analysisId: string, tokenRef: string, status: AssignmentStatus = 'approved', + surfaceText = 'ἀρχῇ', ): TokenAnalysisLink { - return { ...FIXTURE_STAMPS, analysisId, status, token: { tokenRef, surfaceText: 'word' } }; + return { ...FIXTURE_STAMPS, analysisId, status, token: { tokenRef, surfaceText } }; } /** The token ref {@link FocusRequestProbe} last claimed, or `undefined` when it claimed nothing. */ @@ -70,6 +76,10 @@ type PanelOptions = Partial<{ currentBook: string; analysis: TextAnalysis; analysisLanguage: string; + /** Receives the analysis after every store write, so a test can assert on what was persisted. */ + onSave: (analysis: TextAnalysis) => void; + /** Receives whether any edit is in progress, so a test can assert on the unsaved indicator. */ + onPendingEditsChange: (pending: boolean) => void; /** Records every reference the panel navigates to, through the host scroll-group hook. */ setScrRef: (ref: SerializedVerseRef) => void; /** Reference the host scroll group reports, i.e. where the view already sits. */ @@ -78,8 +88,17 @@ type PanelOptions = Partial<{ mountedBook: string; /** Whether the project breaks words into morphemes. Defaults on, so the filter is under test. */ showMorphology: boolean; + /** Whether the store holds a read-only analysis, as a Paratext 9 import does. */ + readOnly: boolean; + /** Whether suggestions are shown. A deletion reports a fallback outcome only while they are. */ + showSuggestions: boolean; + /** Live text per token ref. Defaults to {@link undriftedText}. */ + liveSurfaceText: (tokenRef: string) => string | undefined; }>; +/** Reads every token as still carrying the form its analysis was recorded under. */ +const undriftedText = () => 'ἀρχῇ'; + /** * Wraps a subject in the seeded analysis store and real navigation provider the panel needs. * @@ -101,6 +120,10 @@ function PanelProviders({ {children} @@ -115,6 +138,7 @@ function renderPanel(overrides: PanelOptions = {}) { {})} showMorphology={overrides.showMorphology ?? true} sourceLanguageTag="el" @@ -137,6 +161,7 @@ function ReopenableCatalog() { {isOpen && ( setIsOpen(false)} showMorphology sourceLanguageTag="el" @@ -174,6 +199,7 @@ function renderPanelWithGlossEditing(overrides: PanelOptions = {}) { {})} showMorphology={overrides.showMorphology ?? true} sourceLanguageTag="el" @@ -201,6 +227,19 @@ async function openFilters(): Promise { } /** Reads the catalog row for `analysisId`, failing the test when the list has no such row. */ +/** + * Opens a row's breakdown editor the way a reader does: by clicking the forms row of the breakdown + * box, or the define-breakdown control where the analysis has no breakdown yet. + */ +async function openBreakdown(row: HTMLElement): Promise { + const defineControl = within(row).queryByTestId('catalog-row-breakdown-open'); + if (defineControl) { + await userEvent.click(defineControl); + return; + } + await userEvent.click(within(row).getAllByTestId('catalog-row-morpheme')[0]); +} + function rowFor(analysisId: string): HTMLElement { const row = screen .getAllByTestId('catalog-row') @@ -209,11 +248,41 @@ function rowFor(analysisId: string): HTMLElement { return row; } +/** The analyses the open merge picker offers, in the order it lists them. */ +function mergeCandidateIds(): (string | undefined)[] { + return screen + .getAllByTestId('catalog-merge-candidate') + .map((candidate) => candidate.dataset.analysisId); +} + +/** The open merge picker's summary cell for one analysis, to scope a query to. */ +function mergeCandidateFor(analysisId: string): HTMLElement { + const candidate = screen + .getAllByTestId('catalog-merge-candidate') + .find((element) => element.dataset.analysisId === analysisId); + if (!candidate) throw new Error(`no merge candidate for "${analysisId}"`); + return candidate; +} + +/** One analysis's checkbox in the open merge panel, which says whether the merge folds it in. */ +function mergeCheckFor(analysisId: string): HTMLElement { + return within(mergeCandidateFor(analysisId)).getByTestId('catalog-merge-check'); +} + +/** The control that makes one analysis the survivor the rest merge into. */ +function mergePromoteFor(analysisId: string): HTMLElement { + return within(mergeCandidateFor(analysisId)).getByTestId('catalog-merge-promote'); +} + describe('AnalysisCatalogPanel', () => { beforeEach(() => { claimedFocusRequest = undefined; editGloss = () => {}; - mockKeyAsValueLocalizedStrings(); + // The merge prompt is resolved because it reaches the reader as tooltip content, which the + // platform stub refuses to render from an unresolved key. + mockKeyAsValueLocalizedStrings({ + '%interlinearizer_analysisCatalog_mergePrompt%': 'Every use becomes the one you merge into.', + }); mockInterfaceLanguage(); }); @@ -560,7 +629,7 @@ describe('AnalysisCatalogPanel', () => { expect(listedAnalysisIds()).toEqual(['in-gen', 'in-exo']); }); - it('keeps offering a chosen book the draft has since stopped using', async () => { + it('releases a chosen book the draft has since stopped using', async () => { const analysis: TextAnalysis = { ...PER_BOOK, tokenAnalyses: [ @@ -576,12 +645,12 @@ describe('AnalysisCatalogPanel', () => { act(() => editGloss('EXO 3:14:0', 'λόγος', '')); - // The books facet is down to one choice and would rightly offer none of its own, but the - // selection still narrows the list — so the choice that clears it has to stay on screen. - expect(screen.getByRole('option', { name: 'EXO' })).toBeInTheDocument(); + // The books facet is down to one choice and offers none of its own, so the selection it was + // narrowing by is spent rather than left on screen to be cleared by hand. + expect(screen.queryByRole('option', { name: 'EXO' })).not.toBeInTheDocument(); }); - it('restores the list when a chosen book the draft stopped using is deselected', async () => { + it('restores the list when a chosen book the draft stopped using is withdrawn', async () => { const analysis: TextAnalysis = { ...PER_BOOK, tokenAnalyses: [ @@ -592,12 +661,11 @@ describe('AnalysisCatalogPanel', () => { renderPanelWithGlossEditing({ analysis }); await openFilters(); await userEvent.click(screen.getByRole('option', { name: 'EXO' })); - act(() => editGloss('EXO 3:14:0', 'λόγος', '')); - await userEvent.click(screen.getByRole('option', { name: 'EXO' })); + act(() => editGloss('EXO 3:14:0', 'λόγος', '')); - // Deselecting has to actually clear the filter rather than merely unmount its control, or the - // reader is left with an empty list and no way back to the draft. + // Releasing the filter has to widen the list on its own, or the reader is left with an empty + // list and no way back to the draft. expect(listedAnalysisIds()).toEqual(['in-gen']); }); @@ -721,11 +789,11 @@ describe('AnalysisCatalogPanel', () => { expect(listedAnalysisIds()).toEqual(['spaces']); }); - it('stops marking a value rather than spinning when the marking cannot tell it apart', async () => { + it('numbers a value rather than spinning when the marking cannot tell it apart', async () => { // A localization that drops `{value}` leaves the marking spelling whatever name it was given, - // so repeating it can never clear a collision. Two choices then share a name and one of them - // is unselectable — but the panel renders, where a render that never returns takes the whole - // WebView down with it. + // so repeating it can never clear a collision. Numbering does not go through the marking, so + // it tells the two apart whatever the translation says — and the panel renders, where a + // render that never returns takes the whole WebView down with it. mockKeyAsValueLocalizedStrings({ '%interlinearizer_analysisCatalog_filter_untagged%': '(none)', '%interlinearizer_analysisCatalog_filter_recordedValue%': '{value}', @@ -739,10 +807,41 @@ describe('AnalysisCatalogPanel', () => { tokenAnalysisLinks: [link('named', 'GEN 1:1:0'), link('untagged', 'GEN 1:2:0')], }; renderPanel({ analysis }); + await openFilters(); + + await userEvent.click(screen.getByRole('option', { name: '(none) (2)' })); + + expect(listedAnalysisIds()).toEqual(['named']); + }); + it('counts past a numbered name another value already holds', async () => { + // A marking spelling a constant collides with itself, so more than one value falls through + // to numbering and they have to count past each other rather than each start over. + mockKeyAsValueLocalizedStrings({ + '%interlinearizer_analysisCatalog_filter_empty%': '(empty)', + '%interlinearizer_analysisCatalog_filter_recordedValue%': 'recorded', + }); + const analysis: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'blank', surfaceText: 'λόγος', pos: '' }, + { ...FIXTURE_STAMPS, id: 'one-space', surfaceText: 'ἦν', pos: ' ' }, + { ...FIXTURE_STAMPS, id: 'two-spaces', surfaceText: 'θεός', pos: ' ' }, + { ...FIXTURE_STAMPS, id: 'three-spaces', surfaceText: 'ἀρχή', pos: ' ' }, + ], + tokenAnalysisLinks: [ + link('blank', 'GEN 1:1:0'), + link('one-space', 'GEN 1:2:0'), + link('two-spaces', 'GEN 1:3:0'), + link('three-spaces', 'GEN 1:4:0'), + ], + }; + renderPanel({ analysis }); await openFilters(); - expect(screen.getAllByRole('option', { name: '(none)' })).toHaveLength(2); + await userEvent.click(screen.getByRole('option', { name: '(empty) (3)' })); + + expect(listedAnalysisIds()).toEqual(['three-spaces']); }); it('names the language the missing-gloss filter asks about, in the interface language', async () => { @@ -873,6 +972,92 @@ describe('AnalysisCatalogPanel', () => { expect(listedAnalysisIds()).toEqual(['blank']); }); + // The platform combo box resolves a click by matching the label back to its entry, so a real + // value reading exactly as the untagged placeholder would otherwise take that choice's clicks. + /** Two analyses in two books, so the books facet is offered and one edit can collapse it. */ + const TWO_BOOKS: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'in-gen', surfaceText: 'λόγος', gloss: { en: 'word' } }, + { ...FIXTURE_STAMPS, id: 'in-exo', surfaceText: 'ἦν', gloss: { en: 'was' } }, + ], + tokenAnalysisLinks: [link('in-gen', 'GEN 1:1:0'), link('in-exo', 'EXO 1:1:0')], + }; + + // An edit beside the panel can remove the last row carrying a chosen value, which takes that + // facet's control off screen. Holding the choice would narrow the list to nothing with no + // control left to widen it back by. + it('releases a book filter once an edit leaves that facet with nothing to offer', async () => { + renderPanelWithGlossEditing({ analysis: TWO_BOOKS, analysisLanguage: 'en' }); + await openFilters(); + const books = within(screen.getByTestId('catalog-filter-books')); + await userEvent.click(books.getByRole('option', { name: 'EXO' })); + expect(listedAnalysisIds()).toEqual(['in-exo']); + + // Clearing its only gloss empties the payload, which drops the analysis and its link. + act(() => editGloss('EXO 1:1:0', 'ἦν', '')); + + expect(listedAnalysisIds()).toEqual(['in-gen']); + }); + + // A withdrawn choice is spent, not merely unused: held, it would come back with its facet and + // narrow the listing by a filter the reader had already watched release. + it('leaves a released book filter released once the edge that withdrew it is undone', async () => { + renderPanelWithGlossEditing({ analysis: TWO_BOOKS, analysisLanguage: 'en' }); + await openFilters(); + const books = within(screen.getByTestId('catalog-filter-books')); + await userEvent.click(books.getByRole('option', { name: 'EXO' })); + act(() => editGloss('EXO 1:1:0', 'ἦν', '')); + expect(listedAnalysisIds()).toEqual(['in-gen']); + + // Glossing it again analyzes the token afresh — a new payload under a new id, so the restored + // row is matched on count rather than named — and raises the books facet that offers EXO. + act(() => editGloss('EXO 1:1:0', 'ἦν', 'was')); + + expect(listedAnalysisIds()).toHaveLength(2); + expect(listedAnalysisIds()).toContain('in-gen'); + }); + + it('stops counting a filter the facets have withdrawn as active', async () => { + renderPanelWithGlossEditing({ analysis: TWO_BOOKS, analysisLanguage: 'en' }); + await openFilters(); + const books = within(screen.getByTestId('catalog-filter-books')); + await userEvent.click(books.getByRole('option', { name: 'EXO' })); + + act(() => editGloss('EXO 1:1:0', 'ἦν', '')); + + expect(screen.getByTestId('catalog-filters-button')).toHaveTextContent( + '%interlinearizer_analysisCatalog_filters%', + ); + }); + + it('tells the untagged choice apart from a value that reads the same', async () => { + const analysis: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + { + ...FIXTURE_STAMPS, + id: 'named', + surfaceText: 'λόγος', + pos: '%interlinearizer_analysisCatalog_filter_untagged%', + }, + { ...FIXTURE_STAMPS, id: 'untagged', surfaceText: 'ἦν' }, + ], + tokenAnalysisLinks: [link('named', 'GEN 1:1:0'), link('untagged', 'GEN 1:2:0')], + }; + renderPanel({ analysis }); + await openFilters(); + + // The untagged choice keeps the plain label and the value reading the same is marked as a + // recorded value, so an exact match on the label reaches the choice rather than the value. + const pos = within(screen.getByTestId('catalog-filter-pos')); + await userEvent.click( + pos.getByRole('option', { name: '%interlinearizer_analysisCatalog_filter_untagged%' }), + ); + + expect(listedAnalysisIds()).toEqual(['untagged']); + }); + it('offers no book choice for a draft confined to one book', async () => { const analysis: TextAnalysis = { ...emptyAnalysis(), @@ -991,6 +1176,7 @@ describe('AnalysisCatalogPanel', () => { {}} showMorphology={false} sourceLanguageTag="el" @@ -1145,6 +1331,7 @@ describe('AnalysisCatalogPanel', () => { {}} showMorphology sourceLanguageTag="el" @@ -1166,6 +1353,7 @@ describe('AnalysisCatalogPanel', () => { {}} showMorphology sourceLanguageTag="el" @@ -1232,8 +1420,14 @@ describe('AnalysisCatalogPanel', () => { await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + // Each morpheme's gloss is an editable field, so it reads off the input rather than the text. const morphemes = within(rowFor('ta-1')).getAllByTestId('catalog-row-morpheme'); - expect(morphemes.map((m) => m.textContent)).toEqual(['λογword', 'οςNOM.SG']); + expect(morphemes.map((m) => m.textContent)).toEqual(['λογ', 'ος']); + expect( + within(rowFor('ta-1')) + .getAllByTestId('catalog-row-morpheme-gloss-input') + .map((i) => i.getAttribute('value')), + ).toEqual(['word', 'NOM.SG']); }); it('shows a morpheme with no gloss in the active language as its form alone', async () => { @@ -1495,4 +1689,1926 @@ describe('AnalysisCatalogPanel', () => { expect(onClose).toHaveBeenCalled(); }); + + describe('editing a row', () => { + /** One analysis, shared by two tokens, so an edit here is visibly an edit to both. */ + const SHARED: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'λόγος', gloss: { en: 'word' } }, + ], + tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0'), link('ta-1', 'GEN 1:3:4')], + }; + + /** Expands the row and returns its detail, where the edit controls live. */ + async function expandRow(analysisId: string): Promise { + await userEvent.click(within(rowFor(analysisId)).getByTestId('catalog-row-toggle')); + return rowFor(analysisId); + } + + it('names how many uses an edit here rewrites', async () => { + renderPanel({ analysis: SHARED }); + + await expandRow('ta-1'); + + expect(within(rowFor('ta-1')).getByTestId('catalog-row-applies-to-all')).toHaveTextContent( + '%interlinearizer_analysisCatalog_appliesToAll%', + ); + }); + + it('omits the note for a lone use, which an edit reaches no further than', async () => { + const analysis: TextAnalysis = { + ...SHARED, + tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0')], + }; + renderPanel({ analysis }); + + await expandRow('ta-1'); + + expect( + within(rowFor('ta-1')).queryByTestId('catalog-row-applies-to-all'), + ).not.toBeInTheDocument(); + }); + + it('omits the note for an analysis nothing uses', async () => { + const analysis: TextAnalysis = { ...SHARED, tokenAnalysisLinks: [] }; + renderPanel({ analysis }); + + await expandRow('ta-1'); + + expect( + within(rowFor('ta-1')).queryByTestId('catalog-row-applies-to-all'), + ).not.toBeInTheDocument(); + }); + + it('rewrites the gloss for every token linked to the analysis', async () => { + const onSave = jest.fn(); + renderPanel({ analysis: SHARED, onSave }); + + const row = await expandRow('ta-1'); + const input = within(row).getByTestId('catalog-row-gloss-input'); + await userEvent.clear(input); + await userEvent.type(input, 'message'); + await userEvent.tab(); + + // One payload holding both links, so the single write reached both tokens without forking. + const saved: TextAnalysis = onSave.mock.calls.at(-1)[0]; + expect(saved.tokenAnalyses).toHaveLength(1); + expect(saved.tokenAnalyses[0].gloss).toEqual({ en: 'message' }); + expect(saved.tokenAnalysisLinks.map((l) => l.analysisId)).toEqual(['ta-1', 'ta-1']); + }); + + it('rewrites the morpheme breakdown for every token linked to the analysis', async () => { + const onSave = jest.fn(); + renderPanel({ analysis: SHARED, onSave }); + + const row = await expandRow('ta-1'); + await openBreakdown(row); + const input = within(rowFor('ta-1')).getByTestId('morpheme-breakdown-input'); + await userEvent.clear(input); + await userEvent.type(input, 'λογ ος'); + await userEvent.click(within(rowFor('ta-1')).getByTestId('morpheme-breakdown-save')); + + const saved: TextAnalysis = onSave.mock.calls.at(-1)[0]; + expect(saved.tokenAnalyses).toHaveLength(1); + expect(saved.tokenAnalyses[0].morphemes?.map((m) => m.form)).toEqual(['λογ', 'ος']); + expect(saved.tokenAnalysisLinks.map((l) => l.analysisId)).toEqual(['ta-1', 'ta-1']); + }); + + it('leaves the breakdown alone when the editor is canceled', async () => { + const onSave = jest.fn(); + renderPanel({ analysis: SHARED, onSave }); + + const row = await expandRow('ta-1'); + await openBreakdown(row); + await userEvent.type( + within(rowFor('ta-1')).getByTestId('morpheme-breakdown-input'), + 'λογ ος', + ); + await userEvent.click(within(rowFor('ta-1')).getByTestId('morpheme-breakdown-cancel')); + + expect(onSave).not.toHaveBeenCalled(); + }); + + /** A record with a breakdown whose morphemes carry no glosses of their own. */ + const SEGMENTED_NO_GLOSSES: TextAnalysis = { + ...SHARED, + tokenAnalyses: [ + { + ...SHARED.tokenAnalyses[0], + morphemes: [{ ...FIXTURE_STAMPS, id: 'm-1', form: 'λογ', writingSystem: 'el' }], + }, + ], + }; + + /** A record whose morphemes carry glosses, so clearing its breakdown destroys them. */ + const GLOSSED_MORPHEMES: TextAnalysis = { + ...SHARED, + tokenAnalyses: [ + { + ...SHARED.tokenAnalyses[0], + morphemes: [ + { + ...FIXTURE_STAMPS, + id: 'm-1', + form: 'λογ', + writingSystem: 'el', + gloss: { en: 'word' }, + }, + { ...FIXTURE_STAMPS, id: 'm-2', form: 'ος', writingSystem: 'el' }, + ], + }, + ], + }; + + /** Opens the breakdown editor on `ta-1` and asks it for the unsegmented state. */ + async function clearBreakdown(): Promise { + const row = await expandRow('ta-1'); + await openBreakdown(row); + await userEvent.click(within(rowFor('ta-1')).getByTestId('morpheme-breakdown-reset')); + } + + it('confirms before clearing a breakdown whose morphemes carry glosses', async () => { + const onSave = jest.fn(); + renderPanel({ analysis: GLOSSED_MORPHEMES, onSave }); + + await clearBreakdown(); + + expect(within(rowFor('ta-1')).getByTestId('catalog-row-editor')).toHaveTextContent( + '%interlinearizer_analysisCatalog_confirmResetPrompt%', + ); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('clears the breakdown once the reset is confirmed', async () => { + const onSave = jest.fn(); + renderPanel({ analysis: GLOSSED_MORPHEMES, onSave }); + + await clearBreakdown(); + await userEvent.click(within(rowFor('ta-1')).getByTestId('morpheme-reset-confirm-action')); + + const saved: TextAnalysis = onSave.mock.calls.at(-1)[0]; + expect(saved.tokenAnalyses[0].morphemes).toBeUndefined(); + }); + + it('abandons the reset on Escape, keeping the breakdown', async () => { + const onSave = jest.fn(); + renderPanel({ analysis: GLOSSED_MORPHEMES, onSave }); + + await clearBreakdown(); + await userEvent.type( + within(rowFor('ta-1')).getByTestId('morpheme-breakdown-confirm-cancel'), + '{Escape}', + ); + + // A confirmation exists because the loss is irreversible, 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%', + ); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('keeps the breakdown when the reset is declined', async () => { + const onSave = jest.fn(); + renderPanel({ analysis: GLOSSED_MORPHEMES, onSave }); + + await clearBreakdown(); + await userEvent.click( + within(rowFor('ta-1')).getByTestId('morpheme-breakdown-confirm-cancel'), + ); + + expect(onSave).not.toHaveBeenCalled(); + expect(within(rowFor('ta-1')).getByTestId('morpheme-breakdown-input')).toBeInTheDocument(); + }); + + it('clears a breakdown carrying no morpheme glosses without asking', async () => { + const onSave = jest.fn(); + renderPanel({ analysis: SEGMENTED_NO_GLOSSES, onSave }); + + await clearBreakdown(); + + const saved: TextAnalysis = onSave.mock.calls.at(-1)[0]; + expect(saved.tokenAnalyses[0].morphemes).toBeUndefined(); + }); + + /** Opens the breakdown editor on `ta-1` and re-splits it to `forms`, then saves. */ + async function resplitBreakdown(forms: string): Promise { + const row = await expandRow('ta-1'); + await openBreakdown(row); + const input = within(rowFor('ta-1')).getByTestId('morpheme-breakdown-input'); + await userEvent.clear(input); + await userEvent.type(input, forms); + await userEvent.click(within(rowFor('ta-1')).getByTestId('morpheme-breakdown-save')); + } + + it('confirms before a re-split that strands a glossed morpheme', async () => { + const onSave = jest.fn(); + renderPanel({ analysis: GLOSSED_MORPHEMES, onSave }); + + await resplitBreakdown('λογος'); + + expect(within(rowFor('ta-1')).getByTestId('morpheme-split-confirm')).toHaveTextContent( + '%interlinearizer_analysisCatalog_confirmResplitPrompt%', + ); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('re-splits once the loss is confirmed', async () => { + const onSave = jest.fn(); + renderPanel({ analysis: GLOSSED_MORPHEMES, onSave }); + + await resplitBreakdown('λογος'); + await userEvent.click(within(rowFor('ta-1')).getByTestId('morpheme-split-confirm-action')); + + const saved: TextAnalysis = onSave.mock.calls.at(-1)[0]; + expect(saved.tokenAnalyses[0].morphemes?.map((m) => m.form)).toEqual(['λογος']); + }); + + it('keeps the breakdown when the re-split is declined', async () => { + const onSave = jest.fn(); + renderPanel({ analysis: GLOSSED_MORPHEMES, onSave }); + + await resplitBreakdown('λογος'); + await userEvent.click( + within(rowFor('ta-1')).getByTestId('morpheme-breakdown-confirm-cancel'), + ); + + expect(onSave).not.toHaveBeenCalled(); + expect(within(rowFor('ta-1')).getByTestId('morpheme-breakdown-input')).toHaveValue('λογος'); + }); + + it('re-splits without asking when every glossed morpheme survives', async () => { + // Only the unglossed "ος" is dropped, and bare segmentation is cheap to retype. + const onSave = jest.fn(); + renderPanel({ analysis: GLOSSED_MORPHEMES, onSave }); + + await resplitBreakdown('λογ'); + + const saved: TextAnalysis = onSave.mock.calls.at(-1)[0]; + expect(saved.tokenAnalyses[0].morphemes?.map((m) => m.form)).toEqual(['λογ']); + }); + + it('keeps a breakdown draft across collapsing the row', async () => { + renderPanel({ analysis: SHARED }); + + const row = await expandRow('ta-1'); + await openBreakdown(row); + const input = within(rowFor('ta-1')).getByTestId('morpheme-breakdown-input'); + await userEvent.clear(input); + await userEvent.type(input, 'λογ ος'); + + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + + expect(within(rowFor('ta-1')).getByTestId('morpheme-breakdown-input')).toHaveValue('λογ ος'); + }); + + it('keeps a breakdown draft across a search that stops listing the row', async () => { + renderPanel({ analysis: SHARED }); + + const row = await expandRow('ta-1'); + await openBreakdown(row); + const input = within(rowFor('ta-1')).getByTestId('morpheme-breakdown-input'); + await userEvent.clear(input); + await userEvent.type(input, 'λογ ος'); + + await userEvent.type(searchBox(), 'zzz'); + expect(screen.queryAllByTestId('catalog-row')).toHaveLength(0); + await userEvent.clear(searchBox()); + + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + expect(within(rowFor('ta-1')).getByTestId('morpheme-breakdown-input')).toHaveValue('λογ ος'); + }); + + it('reports a held breakdown draft while its row is unmounted', async () => { + const onPendingEditsChange = jest.fn(); + renderPanel({ analysis: SHARED, onPendingEditsChange }); + + const row = await expandRow('ta-1'); + await openBreakdown(row); + await userEvent.type(within(rowFor('ta-1')).getByTestId('morpheme-breakdown-input'), '-ος'); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + + expect(onPendingEditsChange).toHaveBeenLastCalledWith(true); + }); + + describe('closing over an unsaved breakdown', () => { + /** Opens the breakdown editor on `ta-1` and types a re-segmentation without saving it. */ + async function typeUnsavedBreakdown() { + const row = await expandRow('ta-1'); + await openBreakdown(row); + const input = within(rowFor('ta-1')).getByTestId('morpheme-breakdown-input'); + await userEvent.clear(input); + await userEvent.type(input, 'λογ ος'); + } + + it('asks before closing over a breakdown draft', async () => { + const onClose = jest.fn(); + renderPanel({ analysis: SHARED, onClose }); + await typeUnsavedBreakdown(); + + await userEvent.click(screen.getByTestId('analysis-catalog-close')); + + expect(screen.getByTestId('catalog-close-title')).toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('keeps the draft in hand when the close is declined', async () => { + const onClose = jest.fn(); + renderPanel({ analysis: SHARED, onClose }); + await typeUnsavedBreakdown(); + await userEvent.click(screen.getByTestId('analysis-catalog-close')); + + await userEvent.click(screen.getByTestId('catalog-close-cancel')); + + expect(onClose).not.toHaveBeenCalled(); + expect(within(rowFor('ta-1')).getByTestId('morpheme-breakdown-input')).toHaveValue( + 'λογ ος', + ); + }); + + it('closes without asking once the breakdown is saved', async () => { + const onClose = jest.fn(); + renderPanel({ analysis: SHARED, onClose }); + await typeUnsavedBreakdown(); + await userEvent.click(within(rowFor('ta-1')).getByTestId('morpheme-breakdown-save')); + + await userEvent.click(screen.getByTestId('analysis-catalog-close')); + + expect(onClose).toHaveBeenCalled(); + expect(screen.queryByTestId('catalog-close-title')).not.toBeInTheDocument(); + }); + + it('closes without asking when the draft only re-states the current breakdown', async () => { + const onClose = jest.fn(); + renderPanel({ analysis: SHARED, onClose }); + // The editor pre-fills the whole word, which is not typed work. + const row = await expandRow('ta-1'); + await openBreakdown(row); + + await userEvent.click(screen.getByTestId('analysis-catalog-close')); + + expect(onClose).toHaveBeenCalled(); + }); + + // An import can record the unsegmented state as one morpheme repeating the whole word. + it('closes without asking over an untouched breakdown of one morpheme spanning the word', async () => { + const onClose = jest.fn(); + renderPanel({ + analysis: { + ...SHARED, + tokenAnalyses: [ + { + ...FIXTURE_STAMPS, + id: 'ta-1', + surfaceText: 'λόγος', + gloss: { en: 'word' }, + morphemes: [{ id: 'm-1', form: 'λόγος', writingSystem: 'grc' }], + }, + ], + }, + onClose, + }); + const row = await expandRow('ta-1'); + await openBreakdown(row); + + await userEvent.click(screen.getByTestId('analysis-catalog-close')); + + expect(onClose).toHaveBeenCalled(); + }); + + it('withdraws the question when the draft is canceled beneath it', async () => { + const onClose = jest.fn(); + renderPanel({ analysis: SHARED, onClose }); + await typeUnsavedBreakdown(); + await userEvent.click(screen.getByTestId('analysis-catalog-close')); + + await userEvent.click(within(rowFor('ta-1')).getByTestId('morpheme-breakdown-cancel')); + + expect(screen.queryByTestId('catalog-close-title')).not.toBeInTheDocument(); + expect(onClose).not.toHaveBeenCalled(); + }); + }); + + describe('over a breakdown an edit beside the panel stranded', () => { + /** + * An analysis one token approves, carrying nothing but a gloss. Blanking that gloss from the + * view empties the record, which removes it — the shape that strands a draft. A record two + * tokens share forks instead, leaving the row standing. + */ + const SOLE_USE: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'λόγος', gloss: { en: 'word' } }, + ], + tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0')], + }; + + /** + * Opens the breakdown editor on `ta-1` and types a re-segmentation without saving it, in a + * panel rendered beside a probe standing in for the interlinear view. + */ + async function typeStrandableBreakdown(overrides: PanelOptions = {}) { + const rendered = renderPanelWithGlossEditing({ analysis: SOLE_USE, ...overrides }); + const row = await expandRow('ta-1'); + await openBreakdown(row); + const input = within(rowFor('ta-1')).getByTestId('morpheme-breakdown-input'); + await userEvent.clear(input); + await userEvent.type(input, 'λογ ος'); + return rendered; + } + + it('reports the loss when an edit beside the panel removes the row', async () => { + mockKeyAsValueLocalizedStrings({ + '%interlinearizer_analysisCatalog_draftStranded%': 'An edit removed {form}.', + }); + await typeStrandableBreakdown(); + + act(() => editGloss('GEN 1:1:0', 'λόγος', '')); + + // The form is all that is left to recognize the lost draft by, its record being gone. + expect(screen.getByTestId('catalog-stranded-draft-notice')).toHaveTextContent( + 'An edit removed λόγος.', + ); + }); + + it('dismisses the report on request', async () => { + await typeStrandableBreakdown(); + act(() => editGloss('GEN 1:1:0', 'λόγος', '')); + + await userEvent.click(screen.getByTestId('catalog-stranded-draft-notice-dismiss')); + + expect(screen.queryByTestId('catalog-stranded-draft-notice')).not.toBeInTheDocument(); + }); + + // Left in hand the draft would hold the unsaved mark on over work that can never be saved. + it('stops reporting uncommitted text once the draft is stranded', async () => { + const onPendingEditsChange = jest.fn(); + await typeStrandableBreakdown({ onPendingEditsChange }); + expect(onPendingEditsChange).toHaveBeenLastCalledWith(true); + + act(() => editGloss('GEN 1:1:0', 'λόγος', '')); + + expect(onPendingEditsChange).toHaveBeenLastCalledWith(false); + }); + + it('leaves a draft alone while its row is still listed', async () => { + await typeStrandableBreakdown(); + + act(() => editGloss('GEN 2:1:0', 'ἦν', 'was')); + + expect(screen.queryByTestId('catalog-stranded-draft-notice')).not.toBeInTheDocument(); + expect(within(rowFor('ta-1')).getByTestId('morpheme-breakdown-input')).toHaveValue( + 'λογ ος', + ); + }); + + 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')); + + expect(screen.queryByTestId('catalog-stranded-draft-notice')).not.toBeInTheDocument(); + }); + }); + + it('commits a gloss edit on Enter', async () => { + const onSave = jest.fn(); + renderPanel({ analysis: SHARED, onSave }); + + const row = await expandRow('ta-1'); + const input = within(row).getByTestId('catalog-row-gloss-input'); + await userEvent.clear(input); + await userEvent.type(input, 'message{Enter}'); + + const saved: TextAnalysis = onSave.mock.calls.at(-1)[0]; + expect(saved.tokenAnalyses[0].gloss).toEqual({ en: 'message' }); + }); + + it('reverts a gloss edit on Escape', async () => { + const onSave = jest.fn(); + renderPanel({ analysis: SHARED, onSave }); + + const row = await expandRow('ta-1'); + const input = within(row).getByTestId('catalog-row-gloss-input'); + await userEvent.clear(input); + await userEvent.type(input, 'message{Escape}'); + + // Reverted to the committed text, so the blur that follows has nothing left to write. + expect(input).toHaveAttribute('value', 'word'); + await userEvent.tab(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it('commits a breakdown edit on Enter', async () => { + const onSave = jest.fn(); + renderPanel({ analysis: SHARED, onSave }); + + const row = await expandRow('ta-1'); + await openBreakdown(row); + const input = within(rowFor('ta-1')).getByTestId('morpheme-breakdown-input'); + await userEvent.clear(input); + await userEvent.type(input, 'λογ ος{Enter}'); + + const saved: TextAnalysis = onSave.mock.calls.at(-1)[0]; + expect(saved.tokenAnalyses[0].morphemes?.map((m) => m.form)).toEqual(['λογ', 'ος']); + }); + + it('abandons a breakdown edit on Escape', async () => { + const onSave = jest.fn(); + renderPanel({ analysis: SHARED, onSave }); + + const row = await expandRow('ta-1'); + await openBreakdown(row); + await userEvent.type( + within(rowFor('ta-1')).getByTestId('morpheme-breakdown-input'), + 'λογ ος{Escape}', + ); + + expect(onSave).not.toHaveBeenCalled(); + expect( + within(rowFor('ta-1')).queryByTestId('morpheme-breakdown-input'), + ).not.toBeInTheDocument(); + }); + + it('writes nothing when the editor is emptied, which reads as no breakdown at all', async () => { + const analysis: TextAnalysis = { + ...SHARED, + tokenAnalyses: [ + { + ...SHARED.tokenAnalyses[0], + morphemes: [{ ...FIXTURE_STAMPS, id: 'm-1', form: 'λογ', writingSystem: 'el' }], + }, + ], + }; + const onSave = jest.fn(); + renderPanel({ analysis, onSave }); + + const row = await expandRow('ta-1'); + await openBreakdown(row); + await userEvent.clear(within(rowFor('ta-1')).getByTestId('morpheme-breakdown-input')); + await userEvent.click(within(rowFor('ta-1')).getByTestId('morpheme-breakdown-save')); + + // An empty field is one with nothing typed in it yet, not a request for the unsegmented + // state — which has its own control, so the save refuses rather than guessing between them. + expect(onSave).not.toHaveBeenCalled(); + expect(within(rowFor('ta-1')).getByTestId('morpheme-empty-hint')).toBeInTheDocument(); + }); + + it('keeps a whole-word draft as a one-morpheme breakdown', async () => { + const analysis: TextAnalysis = { + ...SHARED, + tokenAnalyses: [ + { + ...SHARED.tokenAnalyses[0], + morphemes: [ + { ...FIXTURE_STAMPS, id: 'm-1', form: 'λογ', writingSystem: 'el' }, + { ...FIXTURE_STAMPS, id: 'm-2', form: 'ος', writingSystem: 'el' }, + ], + }, + ], + }; + const onSave = jest.fn(); + renderPanel({ analysis, onSave }); + + const row = await expandRow('ta-1'); + await openBreakdown(row); + const input = within(rowFor('ta-1')).getByTestId('morpheme-breakdown-input'); + await userEvent.clear(input); + // Not a request for the unsegmented state, which has its own control. + await userEvent.type(input, 'λόγος'); + await userEvent.click(within(rowFor('ta-1')).getByTestId('morpheme-breakdown-save')); + + const saved: TextAnalysis = onSave.mock.calls.at(-1)[0]; + expect(saved.tokenAnalyses[0].morphemes?.map((m) => m.form)).toEqual(['λόγος']); + }); + + it('rewrites a morpheme gloss for every token linked to the analysis', async () => { + const analysis: TextAnalysis = { + ...SHARED, + tokenAnalyses: [ + { + ...SHARED.tokenAnalyses[0], + morphemes: [{ ...FIXTURE_STAMPS, id: 'm-1', form: 'λογ', writingSystem: 'el' }], + }, + ], + }; + const onSave = jest.fn(); + renderPanel({ analysis, onSave }); + + const row = await expandRow('ta-1'); + await userEvent.type( + within(row).getByTestId('catalog-row-morpheme-gloss-input'), + 'word-stem', + ); + await userEvent.tab(); + + const saved: TextAnalysis = onSave.mock.calls.at(-1)[0]; + expect(saved.tokenAnalyses[0].morphemes?.[0].gloss).toEqual({ en: 'word-stem' }); + }); + + describe('reporting uncommitted text', () => { + /** One analysis with a breakdown, so the breakdown editor opens onto committed forms. */ + const SEGMENTED: TextAnalysis = { + ...SHARED, + tokenAnalyses: [ + { + ...SHARED.tokenAnalyses[0], + morphemes: [{ ...FIXTURE_STAMPS, id: 'm-1', form: 'λογ', writingSystem: 'el' }], + }, + ], + }; + + it('reports a gloss held uncommitted, so the unsaved indicator lights while typing', async () => { + const onPendingEditsChange = jest.fn(); + renderPanel({ analysis: SHARED, onPendingEditsChange }); + + const row = await expandRow('ta-1'); + await userEvent.type(within(row).getByTestId('catalog-row-gloss-input'), '!'); + + expect(onPendingEditsChange).toHaveBeenLastCalledWith(true); + }); + + it('stops reporting a gloss once it commits on blur', async () => { + const onPendingEditsChange = jest.fn(); + renderPanel({ analysis: SHARED, onPendingEditsChange }); + + const row = await expandRow('ta-1'); + await userEvent.type(within(row).getByTestId('catalog-row-gloss-input'), '!'); + await userEvent.tab(); + + expect(onPendingEditsChange).toHaveBeenLastCalledWith(false); + }); + + it('reports a breakdown held uncommitted', async () => { + const onPendingEditsChange = jest.fn(); + renderPanel({ analysis: SEGMENTED, onPendingEditsChange }); + + const row = await expandRow('ta-1'); + await openBreakdown(row); + await userEvent.type(within(rowFor('ta-1')).getByTestId('morpheme-breakdown-input'), '-ος'); + + expect(onPendingEditsChange).toHaveBeenLastCalledWith(true); + }); + + it('stops reporting a breakdown once it is canceled', async () => { + const onPendingEditsChange = jest.fn(); + renderPanel({ analysis: SEGMENTED, onPendingEditsChange }); + + const row = await expandRow('ta-1'); + await openBreakdown(row); + await userEvent.type(within(rowFor('ta-1')).getByTestId('morpheme-breakdown-input'), '-ος'); + await userEvent.click(within(rowFor('ta-1')).getByTestId('morpheme-breakdown-cancel')); + + expect(onPendingEditsChange).toHaveBeenLastCalledWith(false); + }); + + // The editor pre-fills the whole word on an unsegmented row, which is not typed work. + it('reports nothing for a breakdown draft the reader has not touched', async () => { + const onPendingEditsChange = jest.fn(); + renderPanel({ analysis: SHARED, onPendingEditsChange }); + + const row = await expandRow('ta-1'); + await openBreakdown(row); + + expect(onPendingEditsChange).not.toHaveBeenCalled(); + }); + }); + }); + + describe('on a read-only analysis', () => { + /** An analysis with a breakdown and a homograph peer, so every control has something to act on. */ + const READ_ONLY: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + { + ...FIXTURE_STAMPS, + id: 'ta-1', + surfaceText: 'λόγος', + gloss: { en: 'word' }, + morphemes: [ + { + ...FIXTURE_STAMPS, + id: 'm-1', + form: 'λογ', + gloss: { en: 'speak' }, + writingSystem: 'el', + }, + { ...FIXTURE_STAMPS, id: 'm-2', form: 'ος', gloss: { en: 'NOM' }, writingSystem: 'el' }, + ], + }, + { ...FIXTURE_STAMPS, id: 'ta-2', surfaceText: 'λόγος', gloss: { en: 'speech' } }, + ], + tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0'), link('ta-2', 'GEN 1:2:0')], + }; + + /** Expands `ta-1` and returns its detail, where the edit controls would be. */ + async function expandReadOnlyRow(): Promise { + renderPanel({ analysis: READ_ONLY, readOnly: true }); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + return rowFor('ta-1'); + } + + // An import is not the extension's record to rewrite: the storage layer rejects the write, and + // the store has no save path, so an edit made here would vanish on the next remount. + it('renders the gloss as text rather than as an input', async () => { + const row = await expandReadOnlyRow(); + + expect(within(row).getByTestId('readonly-catalog-gloss')).toHaveTextContent('word'); + expect(within(row).queryByTestId('catalog-row-gloss-input')).not.toBeInTheDocument(); + }); + + it('renders the morpheme glosses as text rather than as inputs', async () => { + const row = await expandReadOnlyRow(); + + expect( + within(row) + .getAllByTestId('readonly-catalog-morpheme-gloss') + .map((el) => el.textContent), + ).toEqual(['speak', 'NOM']); + expect(within(row).queryByTestId('catalog-row-morpheme-gloss-input')).not.toBeInTheDocument(); + }); + + it('shows the breakdown without the control that would re-segment it', async () => { + const row = await expandReadOnlyRow(); + + expect( + within(row) + .getAllByTestId('catalog-row-morpheme') + .map((el) => el.textContent), + ).toEqual(['λογ', 'ος']); + expect(within(row).queryByTestId('catalog-row-breakdown-open')).not.toBeInTheDocument(); + }); + + it('withholds the merge and delete controls', async () => { + const row = await expandReadOnlyRow(); + + expect(within(row).queryByTestId('catalog-row-merge')).not.toBeInTheDocument(); + expect(within(row).queryByTestId('catalog-row-delete')).not.toBeInTheDocument(); + }); + + // The box's heading names a split the reader cannot make from a read-only row. + it('drops the breakdown box where the analysis segments nothing', async () => { + const unsegmented: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [{ ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'λόγος' }], + tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0')], + }; + renderPanel({ analysis: unsegmented, readOnly: true }); + + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + + expect(within(rowFor('ta-1')).queryByTestId('catalog-row-morpheme')).not.toBeInTheDocument(); + }); + + it('still shows the gloss of an analysis that segments nothing', async () => { + const unsegmented: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'λόγος', gloss: { en: 'word' } }, + ], + tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0')], + }; + renderPanel({ analysis: unsegmented, analysisLanguage: 'en', readOnly: true }); + + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + + expect(within(rowFor('ta-1')).getByTestId('readonly-catalog-gloss')).toHaveTextContent( + 'word', + ); + }); + + it('names an unglossed morpheme as unglossed rather than leaving it blank', async () => { + const unglossed: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + { + ...FIXTURE_STAMPS, + id: 'ta-1', + surfaceText: 'λόγος', + morphemes: [{ ...FIXTURE_STAMPS, id: 'm-1', form: 'λογ', writingSystem: 'el' }], + }, + ], + tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0')], + }; + renderPanel({ analysis: unglossed, readOnly: true }); + + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + + expect( + within(rowFor('ta-1')).getByTestId('readonly-catalog-morpheme-gloss'), + ).toHaveTextContent('%interlinearizer_analysisCatalog_morphemeNoGloss%'); + }); + + // Reading the catalog is the whole point of opening it on an import, so only the writes go. + it('still lists the analyses and their usages', async () => { + const row = await expandReadOnlyRow(); + + expect(listedAnalysisIds().toSorted()).toEqual(['ta-1', 'ta-2']); + expect(within(row).getByTestId('catalog-usage')).toHaveAttribute( + 'data-token-ref', + 'GEN 1:1:0', + ); + }); + }); + + describe('merging on edit', () => { + /** Two homographs whose glosses differ, so editing one into the other's collapses them. */ + const TWO_HOMOGRAPHS: 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'), + link('ta-2', 'GEN 2:7:2'), + ], + }; + + /** Edits `ta-1`'s gloss to match `ta-2`'s, which collapses the two onto `ta-2`. */ + async function editIntoEquality(): Promise { + 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(); + } + + it('drops the edited row and moves its usages onto the surviving one', async () => { + renderPanel({ analysis: TWO_HOMOGRAPHS }); + + await editIntoEquality(); + + expect(listedAnalysisIds()).toEqual(['ta-2']); + expect(within(rowFor('ta-2')).getByTestId('catalog-row-usage-count')).toHaveTextContent('3'); + }); + + it('announces where the edited row went', async () => { + renderPanel({ analysis: TWO_HOMOGRAPHS }); + + await editIntoEquality(); + + // The notice names the surviving gloss and its new count, so a row vanishing while another's + // count jumps reads as the convergence it is rather than as lost work. + expect(screen.getByTestId('catalog-merge-notice')).toHaveTextContent( + '%interlinearizer_analysisCatalog_merged%', + ); + }); + + it('names the survivor by its form when it carries no gloss', async () => { + const morphemes = [{ ...FIXTURE_STAMPS, id: 'm-1', form: 'ἀρχ', writingSystem: 'el' }]; + const analysis: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + // A breakdown apiece, so clearing ta-1's gloss leaves a record with content rather than + // an empty one — and one identical to ta-2, which collapses the two onto a survivor + // there is no gloss to name. + { + ...FIXTURE_STAMPS, + id: 'ta-1', + surfaceText: 'ἀρχῇ', + gloss: { en: 'start' }, + morphemes, + }, + { ...FIXTURE_STAMPS, id: 'ta-2', surfaceText: 'ἀρχῇ', morphemes }, + ], + tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0'), link('ta-2', 'GEN 1:3:4')], + }; + renderPanel({ analysis }); + + 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(screen.getByTestId('catalog-merge-notice')).toHaveTextContent( + '%interlinearizer_analysisCatalog_mergedNoGloss%', + ); + }); + + it('leaves no notice when an edit empties the record away', async () => { + const analysis: TextAnalysis = { + ...emptyAnalysis(), + // Gloss and nothing else, so clearing it leaves an empty record, which is removed outright + // rather than collapsed onto anything — there is no survivor to send the reader to. + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'λόγος', gloss: { en: 'word' } }, + ], + tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0')], + }; + renderPanel({ analysis }); + + 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(screen.queryByTestId('catalog-merge-notice')).not.toBeInTheDocument(); + expect(screen.queryAllByTestId('catalog-row')).toHaveLength(0); + }); + + it('leaves no notice when the emptied record’s token keeps an unrelated candidate', async () => { + const analysis: TextAnalysis = { + ...emptyAnalysis(), + // A token may carry several links at once, only the approved one being unique. Clearing the + // approved record's gloss empties it away, leaving the candidate behind untouched — which + // is not a collapse onto it, however much the surviving link looks like one. + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'λόγος', gloss: { en: 'word' } }, + { ...FIXTURE_STAMPS, id: 'ta-2', surfaceText: 'λόγος', gloss: { en: 'reason' } }, + ], + tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0'), link('ta-2', 'GEN 1:1:0', 'candidate')], + }; + renderPanel({ analysis }); + + 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(screen.queryByTestId('catalog-merge-notice')).not.toBeInTheDocument(); + }); + + it('announces where an unused row went', async () => { + const analysis: TextAnalysis = { + ...emptyAnalysis(), + // ta-1 is unlinked, as an imported wordform inventory arrives. No usage count moves when it + // collapses, so the notice is all the reader gets. + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'ἀρχῇ', gloss: { en: 'start' } }, + { ...FIXTURE_STAMPS, id: 'ta-2', surfaceText: 'ἀρχῇ', gloss: { en: 'beginning' } }, + ], + tokenAnalysisLinks: [link('ta-2', 'GEN 1:3:4'), link('ta-2', 'GEN 2:7:2')], + }; + renderPanel({ analysis }); + + await editIntoEquality(); + + expect(listedAnalysisIds()).toEqual(['ta-2']); + expect(screen.getByTestId('catalog-merge-notice')).toHaveTextContent( + '%interlinearizer_analysisCatalog_merged%', + ); + }); + + it('leaves no notice when an edit collapses nothing', async () => { + renderPanel({ analysis: TWO_HOMOGRAPHS }); + + 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, 'origin'); + await userEvent.tab(); + + expect(screen.queryByTestId('catalog-merge-notice')).not.toBeInTheDocument(); + }); + + // The earlier notice would name a row that is no longer there, over a count the merge moved. + it('replaces the notice when a merge takes the survivor it named away', async () => { + const analysis: TextAnalysis = { + ...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'), + ], + }; + renderPanel({ analysis }); + await editIntoEquality(); + expect(screen.getByTestId('catalog-merge-notice')).toBeInTheDocument(); + + await userEvent.click(within(rowFor('ta-3')).getByTestId('catalog-row-toggle')); + await userEvent.click(within(rowFor('ta-3')).getByTestId('catalog-row-merge')); + await userEvent.click(mergeCheckFor('ta-2')); + await userEvent.click(screen.getByTestId('catalog-merge-confirm')); + + expect(listedAnalysisIds()).toEqual(['ta-3']); + // Reporting the merge just made rather than the collapse an earlier edit caused. + expect(screen.getByTestId('catalog-merge-notice')).toHaveTextContent( + '%interlinearizer_analysisCatalog_merged%', + ); + }); + + it('dismisses the notice from its own control', async () => { + renderPanel({ analysis: TWO_HOMOGRAPHS }); + await editIntoEquality(); + + await userEvent.click(screen.getByTestId('catalog-merge-notice-dismiss')); + + 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 () => { + const analysis: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'ta-src', surfaceText: 'ἀρχῇ', gloss: { en: 'aaa' } }, + ...Array.from({ length: 100 }, (_unused, index) => ({ + ...FIXTURE_STAMPS, + id: `filler-${index}`, + surfaceText: `word${index}`, + gloss: { en: `g${String(index).padStart(3, '0')}` }, + })), + { ...FIXTURE_STAMPS, id: 'ta-dst', surfaceText: 'ἀρχῇ', gloss: { en: 'zzz' } }, + ], + tokenAnalysisLinks: [], + }; + renderPanel({ analysis }); + + await userEvent.click(screen.getByTestId('catalog-sort-gloss')); + expect(listedAnalysisIds()).not.toContain('ta-dst'); + + await userEvent.click(within(rowFor('ta-src')).getByTestId('catalog-row-toggle')); + const input = within(rowFor('ta-src')).getByTestId('catalog-row-gloss-input'); + await userEvent.clear(input); + await userEvent.type(input, 'zzz'); + await userEvent.tab(); + + expect(screen.getByTestId('catalog-merge-notice')).toBeInTheDocument(); + expect(listedAnalysisIds()).toContain('ta-dst'); + }); + + // The notice outlives the listing it was raised against, so a search narrowing the survivor + // away leaves it naming a row that is nowhere to be mounted. + it('holds the notice when a search excludes the survivor', async () => { + renderPanel({ analysis: TWO_HOMOGRAPHS }); + await editIntoEquality(); + + await userEvent.type(searchBox(), 'zzz'); + + expect(screen.queryAllByTestId('catalog-row')).toHaveLength(0); + expect(screen.getByTestId('catalog-merge-notice')).toBeInTheDocument(); + }); + }); + + describe('merging into another row', () => { + const TWO_HOMOGRAPHS: 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('offers the merge control to a row with pool peers', async () => { + renderPanel({ analysis: TWO_HOMOGRAPHS }); + + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + + expect(within(rowFor('ta-1')).getByTestId('catalog-row-merge')).toBeInTheDocument(); + }); + + it('withholds the merge control from a row with no pool peers', async () => { + const analysis: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [{ ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'λόγος' }], + tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0')], + }; + renderPanel({ analysis }); + + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + + expect(within(rowFor('ta-1')).queryByTestId('catalog-row-merge')).not.toBeInTheDocument(); + }); + + it('moves every usage onto the chosen target', async () => { + renderPanel({ analysis: TWO_HOMOGRAPHS }); + 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(listedAnalysisIds()).toEqual(['ta-1']); + expect(within(rowFor('ta-1')).getByTestId('catalog-row-usage-count')).toHaveTextContent('2'); + }); + + it('names the surface form both sides share', async () => { + renderPanel({ analysis: TWO_HOMOGRAPHS }); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-merge')); + + expect(screen.getByTestId('catalog-merge-form')).toHaveTextContent('ἀρχῇ'); + expect(screen.getByTestId('catalog-merge-form-label')).toHaveTextContent( + '%interlinearizer_analysisCatalog_mergeForm%', + ); + }); + + // The stub renders tooltip content as the trigger's `title`, clipping being unmeasurable here. + + it('starts the row the panel was opened from as the surviving analysis', async () => { + renderPanel({ analysis: TWO_HOMOGRAPHS, analysisLanguage: 'en' }); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-merge')); + + expect(mergeCandidateIds()[0]).toBe('ta-1'); + expect(mergeCheckFor('ta-1')).toBeDisabled(); + }); + + it('leaves every other analysis out so the merge is not defaulted into', async () => { + renderPanel({ analysis: TWO_HOMOGRAPHS, analysisLanguage: 'en' }); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-merge')); + + expect(mergeCheckFor('ta-2')).not.toBeChecked(); + expect(screen.getByTestId('catalog-merge-confirm')).toBeDisabled(); + }); + + it('lists every homograph, the opened row included', async () => { + const analysis: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + ...TWO_HOMOGRAPHS.tokenAnalyses, + { ...FIXTURE_STAMPS, id: 'ta-3', surfaceText: 'ἀρχῇ', gloss: { en: 'origin' } }, + ], + tokenAnalysisLinks: [...TWO_HOMOGRAPHS.tokenAnalysisLinks, link('ta-3', 'GEN 1:5:2')], + }; + renderPanel({ analysis }); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-merge')); + + expect(mergeCandidateIds()).toEqual(['ta-1', 'ta-2', 'ta-3']); + }); + + it('makes a promoted analysis the one the rest are merged into', async () => { + renderPanel({ analysis: TWO_HOMOGRAPHS }); + 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(mergePromoteFor('ta-2')); + + expect(mergeCandidateIds()[0]).toBe('ta-2'); + // The analysis it displaced was going to survive, so it stays in the merge. + expect(mergeCheckFor('ta-1')).toBeChecked(); + }); + + it('refuses to merge while nothing but the survivor is selected', async () => { + renderPanel({ analysis: TWO_HOMOGRAPHS }); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-merge')); + + expect(screen.getByTestId('catalog-merge-confirm')).toBeDisabled(); + }); + + it('offers the merge once another analysis is selected', async () => { + renderPanel({ analysis: TWO_HOMOGRAPHS }); + 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')); + + expect(screen.getByTestId('catalog-merge-confirm')).toBeEnabled(); + }); + + it('merges into the analysis the panel was pointed at, not the row it was opened from', async () => { + const analysis: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + ...TWO_HOMOGRAPHS.tokenAnalyses, + { ...FIXTURE_STAMPS, id: 'ta-3', surfaceText: 'ἀρχῇ', gloss: { en: 'origin' } }, + ], + tokenAnalysisLinks: [...TWO_HOMOGRAPHS.tokenAnalysisLinks, link('ta-3', 'GEN 1:5:2')], + }; + renderPanel({ analysis }); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-merge')); + + // Opened from ta-1, but redirected: promoting ta-3 leaves ta-1 in the merge it was heading. + await userEvent.click(mergePromoteFor('ta-3')); + await userEvent.click(mergeCheckFor('ta-2')); + await userEvent.click(screen.getByTestId('catalog-merge-confirm')); + + expect(listedAnalysisIds()).toEqual(['ta-3']); + expect(within(rowFor('ta-3')).getByTestId('catalog-row-usage-count')).toHaveTextContent('3'); + }); + + it('shows the opened row its morpheme breakdown beside its gloss', async () => { + const analysis: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + { + ...FIXTURE_STAMPS, + id: 'ta-1', + surfaceText: 'ἀρχῇ', + gloss: { en: 'start' }, + morphemes: [ + { + ...FIXTURE_STAMPS, + id: 'm-1', + form: 'ἀρχ', + gloss: { en: 'begin' }, + writingSystem: 'el', + }, + { + ...FIXTURE_STAMPS, + id: 'm-2', + form: 'ῇ', + gloss: { en: 'DAT' }, + writingSystem: 'el', + }, + ], + }, + { ...FIXTURE_STAMPS, id: 'ta-2', surfaceText: 'ἀρχῇ', gloss: { en: 'beginning' } }, + ], + tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0'), link('ta-2', 'GEN 1:3:4')], + }; + renderPanel({ analysis, analysisLanguage: 'en' }); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-merge')); + + const breakdown = within(mergeCandidateFor('ta-1')).getByTestId('catalog-merge-breakdown'); + expect(breakdown).toHaveTextContent('ἀρχ'); + expect(breakdown).toHaveTextContent('DAT'); + }); + + it('shows each candidate its morpheme breakdown beside its gloss', async () => { + const analysis: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'ἀρχῇ', gloss: { en: 'start' } }, + { + ...FIXTURE_STAMPS, + id: 'ta-2', + surfaceText: 'ἀρχῇ', + gloss: { en: 'beginning' }, + morphemes: [ + { + ...FIXTURE_STAMPS, + id: 'm-1', + form: 'ἀρχ', + gloss: { en: 'begin' }, + writingSystem: 'el', + }, + { + ...FIXTURE_STAMPS, + id: 'm-2', + form: 'ῇ', + gloss: { en: 'DAT' }, + writingSystem: 'el', + }, + ], + }, + ], + tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0'), link('ta-2', 'GEN 1:3:4')], + }; + renderPanel({ analysis, analysisLanguage: 'en' }); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-merge')); + + const breakdown = within(mergeCandidateFor('ta-2')).getByTestId('catalog-merge-breakdown'); + expect(breakdown).toHaveTextContent('ἀρχ'); + expect(breakdown).toHaveTextContent('DAT'); + }); + + it('says so outright on a target that carries no breakdown', async () => { + renderPanel({ analysis: TWO_HOMOGRAPHS, analysisLanguage: 'en' }); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-merge')); + + // Blank where a breakdown would be reads as a rendering fault rather than as an analysis + // that segments nothing, which is what the reader is choosing between. + expect( + within(mergeCandidateFor('ta-2')).getByTestId('catalog-merge-morpheme-none'), + ).toHaveTextContent('%interlinearizer_analysisCatalog_noBreakdown%'); + }); + + it('leaves both analyses alone when the picker is canceled', async () => { + renderPanel({ analysis: TWO_HOMOGRAPHS }); + 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(screen.getByTestId('catalog-merge-cancel')); + + // Both still listed; the order is the default most-used-first, which the two tie on. + expect(listedAnalysisIds()).toHaveLength(2); + expect(listedAnalysisIds()).toContain('ta-1'); + expect(listedAnalysisIds()).toContain('ta-2'); + }); + + it('labels a candidate that carries no gloss rather than leaving it nameless', async () => { + const analysis: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'ἀρχῇ', gloss: { en: 'start' } }, + { + ...FIXTURE_STAMPS, + id: 'ta-2', + surfaceText: 'ἀρχῇ', + morphemes: [{ ...FIXTURE_STAMPS, id: 'm-1', form: 'ἀρχ', writingSystem: 'el' }], + }, + ], + tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0'), link('ta-2', 'GEN 1:3:4')], + }; + renderPanel({ analysis }); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-merge')); + + // The stub leaves every key unresolved, which stands in for the lookup not having landed — + // so the candidate falls back to the em dash rather than being offered as a blank choice. + expect( + within(mergeCandidateFor('ta-2')).getByTestId('catalog-merge-gloss'), + ).toHaveTextContent('—'); + }); + + it('refuses to merge until a target is chosen', async () => { + renderPanel({ analysis: TWO_HOMOGRAPHS }); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-merge')); + + expect(screen.getByTestId('catalog-merge-confirm')).toBeDisabled(); + }); + + // The merge is allowed to converge; the panel warns first, and the notice then names the record + // the collapse actually left standing rather than the one the merge was aimed at. + it('reports the analysis a converging merge left standing', async () => { + const analysis: TextAnalysis = { + ...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'), + ], + }; + renderPanel({ analysis, analysisLanguage: 'en' }); + 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')); + + // Edited to say what ta-3 says, which the merge leaves out — so the survivor collapses onto it. + const gloss = screen.getByTestId('catalog-merge-content-gloss'); + await userEvent.clear(gloss); + await userEvent.type(gloss, 'origin'); + expect(screen.getByTestId('catalog-merge-collapse-warning')).toBeInTheDocument(); + await userEvent.click(screen.getByTestId('catalog-merge-confirm')); + + expect(listedAnalysisIds()).toEqual(['ta-3']); + expect(within(rowFor('ta-3')).getByTestId('catalog-row-usage-count')).toHaveTextContent('3'); + expect(screen.getByTestId('catalog-merge-notice')).toHaveTextContent( + '%interlinearizer_analysisCatalog_merged%', + ); + }); + + // A notice would name a row the listing no longer has. + it('raises no notice when a merge settled on nothing takes every record away', async () => { + renderPanel({ analysis: TWO_HOMOGRAPHS }); + 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.clear(screen.getByTestId('catalog-merge-content-gloss')); + await userEvent.click(screen.getByTestId('catalog-merge-confirm')); + + expect(screen.getByTestId('analysis-catalog-empty')).toBeInTheDocument(); + expect(screen.queryByTestId('catalog-merge-notice')).not.toBeInTheDocument(); + }); + + describe('over an unsaved breakdown', () => { + /** Expands `ta-1` and types a re-segmentation into it without saving. */ + async function typeUnsavedBreakdown(analysisId = 'ta-1') { + await userEvent.click(within(rowFor(analysisId)).getByTestId('catalog-row-toggle')); + await openBreakdown(rowFor(analysisId)); + const input = within(rowFor(analysisId)).getByTestId('morpheme-breakdown-input'); + await userEvent.clear(input); + await userEvent.type(input, 'ἀρχ ῇ'); + } + + it('asks before merging away the analysis a breakdown draft is keyed to', async () => { + renderPanel({ analysis: TWO_HOMOGRAPHS }); + await typeUnsavedBreakdown(); + + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-merge')); + + expect(screen.getByTestId('catalog-close-title')).toBeInTheDocument(); + expect(screen.queryByTestId('catalog-merge-confirm')).not.toBeInTheDocument(); + }); + + it('keeps the draft and both analyses when the discard is declined', async () => { + renderPanel({ analysis: TWO_HOMOGRAPHS }); + await typeUnsavedBreakdown(); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-merge')); + + await userEvent.click(screen.getByTestId('catalog-close-cancel')); + + expect(listedAnalysisIds()).toHaveLength(2); + expect(within(rowFor('ta-1')).getByTestId('morpheme-breakdown-input')).toHaveValue('ἀρχ ῇ'); + }); + + it('opens the merge picker once the draft is given up', async () => { + renderPanel({ analysis: TWO_HOMOGRAPHS }); + await typeUnsavedBreakdown(); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-merge')); + + await userEvent.click(screen.getByTestId('catalog-close-discard')); + + expect(screen.getByTestId('catalog-merge-confirm')).toBeInTheDocument(); + expect(listedAnalysisIds()).toHaveLength(2); + }); + + // The opening ask covers only the row the picker was opened from. + it('asks before a confirmed merge spends an analysis a draft is keyed to', async () => { + const analysis: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + ...TWO_HOMOGRAPHS.tokenAnalyses, + { ...FIXTURE_STAMPS, id: 'ta-3', surfaceText: 'ἀρχῇ', gloss: { en: 'origin' } }, + ], + tokenAnalysisLinks: [...TWO_HOMOGRAPHS.tokenAnalysisLinks, link('ta-3', 'GEN 1:5:2')], + }; + renderPanel({ analysis }); + await typeUnsavedBreakdown(); + + // Opened from ta-2, which carries no draft, then made to fold in ta-1, which does. + await userEvent.click(within(rowFor('ta-2')).getByTestId('catalog-row-toggle')); + await userEvent.click(within(rowFor('ta-2')).getByTestId('catalog-row-merge')); + await userEvent.click(mergeCheckFor('ta-1')); + await userEvent.click(screen.getByTestId('catalog-merge-confirm')); + + expect(screen.getByTestId('catalog-close-title')).toBeInTheDocument(); + expect(listedAnalysisIds()).toHaveLength(3); + }); + + it('merges the analysis whose draft was given up', async () => { + const analysis: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + ...TWO_HOMOGRAPHS.tokenAnalyses, + { ...FIXTURE_STAMPS, id: 'ta-3', surfaceText: 'ἀρχῇ', gloss: { en: 'origin' } }, + ], + tokenAnalysisLinks: [...TWO_HOMOGRAPHS.tokenAnalysisLinks, link('ta-3', 'GEN 1:5:2')], + }; + renderPanel({ analysis }); + await typeUnsavedBreakdown(); + await userEvent.click(within(rowFor('ta-2')).getByTestId('catalog-row-toggle')); + await userEvent.click(within(rowFor('ta-2')).getByTestId('catalog-row-merge')); + await userEvent.click(mergeCheckFor('ta-1')); + await userEvent.click(screen.getByTestId('catalog-merge-confirm')); + + await userEvent.click(screen.getByTestId('catalog-close-discard')); + + expect(listedAnalysisIds()).not.toContain('ta-1'); + expect(within(rowFor('ta-2')).getByTestId('catalog-row-usage-count')).toHaveTextContent( + '2', + ); + }); + + /** Three records of the one form, so a merge can be opened from a row carrying no draft. */ + const THREE_HOMOGRAPHS: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + ...TWO_HOMOGRAPHS.tokenAnalyses, + { ...FIXTURE_STAMPS, id: 'ta-3', surfaceText: 'ἀρχῇ', gloss: { en: 'origin' } }, + ], + tokenAnalysisLinks: [...TWO_HOMOGRAPHS.tokenAnalysisLinks, link('ta-3', 'GEN 1:5:2')], + }; + + /** Opens the picker on the undrafted `ta-3` and folds both drafted records into it. */ + async function confirmMergeOfBothDrafts() { + await typeUnsavedBreakdown('ta-1'); + await typeUnsavedBreakdown('ta-2'); + await userEvent.click(within(rowFor('ta-3')).getByTestId('catalog-row-toggle')); + await userEvent.click(within(rowFor('ta-3')).getByTestId('catalog-row-merge')); + await userEvent.click(mergeCheckFor('ta-1')); + await userEvent.click(mergeCheckFor('ta-2')); + await userEvent.click(screen.getByTestId('catalog-merge-confirm')); + } + + // One agreement is a decision about the draft it named, not about every other draft the same + // merge would spend. + it('asks once per draft when a merge would spend more than one', async () => { + renderPanel({ analysis: THREE_HOMOGRAPHS }); + await confirmMergeOfBothDrafts(); + + await userEvent.click(screen.getByTestId('catalog-close-discard')); + + expect(screen.getByTestId('catalog-close-title')).toBeInTheDocument(); + expect(listedAnalysisIds()).toHaveLength(3); + }); + + it('merges once every draft it would spend has been given up', async () => { + renderPanel({ analysis: THREE_HOMOGRAPHS }); + await confirmMergeOfBothDrafts(); + + await userEvent.click(screen.getByTestId('catalog-close-discard')); + await userEvent.click(screen.getByTestId('catalog-close-discard')); + + expect(listedAnalysisIds()).toEqual(['ta-3']); + }); + + it('returns to the panel with the same analyses selected when the ask is declined', async () => { + const analysis: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + ...TWO_HOMOGRAPHS.tokenAnalyses, + { ...FIXTURE_STAMPS, id: 'ta-3', surfaceText: 'ἀρχῇ', gloss: { en: 'origin' } }, + ], + tokenAnalysisLinks: [...TWO_HOMOGRAPHS.tokenAnalysisLinks, link('ta-3', 'GEN 1:5:2')], + }; + renderPanel({ analysis }); + await typeUnsavedBreakdown(); + await userEvent.click(within(rowFor('ta-2')).getByTestId('catalog-row-toggle')); + await userEvent.click(within(rowFor('ta-2')).getByTestId('catalog-row-merge')); + await userEvent.click(mergeCheckFor('ta-1')); + await userEvent.click(screen.getByTestId('catalog-merge-confirm')); + + await userEvent.click(screen.getByTestId('catalog-close-cancel')); + + expect(mergeCheckFor('ta-1')).toBeChecked(); + expect(listedAnalysisIds()).toHaveLength(3); + }); + + it('stops warning about a draft whose analysis the merge took with it', async () => { + const onClose = jest.fn(); + renderPanel({ analysis: TWO_HOMOGRAPHS, onClose }); + await typeUnsavedBreakdown(); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-merge')); + await userEvent.click(screen.getByTestId('catalog-close-discard')); + await userEvent.click(mergeCheckFor('ta-2')); + await userEvent.click(screen.getByTestId('catalog-merge-confirm')); + + await userEvent.click(screen.getByTestId('analysis-catalog-close')); + + expect(onClose).toHaveBeenCalled(); + }); + }); + + // The store refuses a merge into a record that is gone, so a picker left confirmable would + // close on a merge that never happened, reporting nothing. + it('withholds confirmation once an edit removes the only analysis selected', async () => { + const analysis: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + ...TWO_HOMOGRAPHS.tokenAnalyses, + { ...FIXTURE_STAMPS, id: 'ta-3', surfaceText: 'ἀρχῇ', gloss: { en: 'origin' } }, + ], + tokenAnalysisLinks: [...TWO_HOMOGRAPHS.tokenAnalysisLinks, link('ta-3', 'GEN 1:5:2')], + }; + renderPanelWithGlossEditing({ analysis, analysisLanguage: 'en' }); + 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')); + + // Emptying its gloss removes the record, taking it out of the panel's candidates. + act(() => editGloss('GEN 1:3:4', 'word', '')); + + expect(screen.getByTestId('catalog-merge-confirm')).toBeDisabled(); + }); + + // A record left alone in its form has no homograph to merge with, so nothing to choose. + it('closes the picker once an edit leaves the opened row its form alone', async () => { + renderPanelWithGlossEditing({ analysis: TWO_HOMOGRAPHS, analysisLanguage: 'en' }); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-toggle')); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-merge')); + + act(() => editGloss('GEN 1:3:4', 'word', '')); + + expect(screen.queryByTestId('catalog-merge-title')).not.toBeInTheDocument(); + expect(listedAnalysisIds()).toEqual(['ta-1']); + }); + }); + + describe('deleting a row', () => { + /** One analysis nothing else shares a form with, so deleting it leaves its token blank. */ + const LONE: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'λόγος', gloss: { en: 'word' } }, + ], + 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 { + 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 }); + + await openDeleteConfirm('ta-1'); + + expect(screen.getByTestId('catalog-delete-outcome')).toHaveTextContent( + '%interlinearizer_analysisCatalog_deleteBlank%', + ); + }); + + it('states the fallback the uses take when a homograph survives', async () => { + const analysis: 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-1', 'GEN 1:3:4'), + link('ta-2', 'GEN 2:7:2'), + ], + }; + renderPanel({ analysis, showSuggestions: true }); + + await openDeleteConfirm('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%', + ); + }); + + /** Two homographs, the first used twice, so deleting it falls back to the second. */ + const TWO_HOMOGRAPHS: 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-1', 'GEN 1:3:4'), + link('ta-2', 'GEN 2:7:2'), + ], + }; + + it('describes rather than names the fallback when a use has drifted off its analyzed form', async () => { + renderPanel({ + 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'); + + expect(screen.getByTestId('catalog-delete-outcome')).toHaveTextContent( + '%interlinearizer_analysisCatalog_deleteFallbackDrifted%', + ); + }); + + it('describes rather than names the fallback when a use sits in an unloaded book', async () => { + renderPanel({ + analysis: TWO_HOMOGRAPHS, + liveSurfaceText: (ref) => (ref === 'GEN 1:3:4' ? undefined : 'ἀρχῇ'), + showSuggestions: true, + }); + + await openDeleteConfirm('ta-1'); + + expect(screen.getByTestId('catalog-delete-outcome')).toHaveTextContent( + '%interlinearizer_analysisCatalog_deleteFallbackDrifted%', + ); + }); + + it('states that nothing else changes when the analysis is used nowhere', async () => { + const analysis: TextAnalysis = { ...LONE, tokenAnalysisLinks: [] }; + renderPanel({ analysis }); + + await openDeleteConfirm('ta-1'); + + expect(screen.getByTestId('catalog-delete-outcome')).toHaveTextContent( + '%interlinearizer_analysisCatalog_deleteBlankNone%', + ); + }); + + it('says nothing about unapplied assignments when there are none', async () => { + const analysis: TextAnalysis = { ...LONE, tokenAnalysisLinks: [] }; + renderPanel({ analysis }); + + await openDeleteConfirm('ta-1'); + + expect(screen.queryByTestId('catalog-delete-unapplied')).not.toBeInTheDocument(); + }); + + // 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. + it('warns that unapplied assignments are deleted too', async () => { + const analysis: TextAnalysis = { + ...LONE, + tokenAnalysisLinks: [link('ta-1', 'GEN 1:1:0', 'candidate')], + }; + renderPanel({ analysis }); + + await openDeleteConfirm('ta-1'); + + expect(screen.getByTestId('catalog-delete-outcome')).toHaveTextContent( + '%interlinearizer_analysisCatalog_deleteBlankNone%', + ); + expect(screen.getByTestId('catalog-delete-unapplied')).toHaveTextContent( + '%interlinearizer_analysisCatalog_deleteUnapplied%', + ); + }); + + /** Two homographs, the first used twice, so deleting it falls back to the second. */ + const UNGLOSSED_FALLBACK: TextAnalysis = { + ...emptyAnalysis(), + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'ἀρχῇ', gloss: { en: 'start' } }, + // A breakdown but no gloss: analyzed enough to win the fallback, with no word to quote. + { + ...FIXTURE_STAMPS, + id: 'ta-2', + surfaceText: 'ἀρχῇ', + morphemes: [{ ...FIXTURE_STAMPS, id: 'm-1', form: 'ἀρχ', writingSystem: 'el' }], + }, + ], + tokenAnalysisLinks: [ + link('ta-1', 'GEN 1:1:0'), + link('ta-1', 'GEN 1:3:4'), + link('ta-2', 'GEN 2:7:2'), + ], + }; + + it('describes a fallback that carries no gloss rather than naming it', async () => { + renderPanel({ analysis: UNGLOSSED_FALLBACK, showSuggestions: true }); + + await openDeleteConfirm('ta-1'); + + expect(screen.getByTestId('catalog-delete-outcome')).toHaveTextContent( + '%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 () => { + const onSave = jest.fn(); + renderPanel({ analysis: LONE, onSave }); + await openDeleteConfirm('ta-1'); + + await userEvent.click(screen.getByTestId('catalog-delete-confirm')); + + 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) { + await userEvent.click(within(rowFor(analysisId)).getByTestId('catalog-row-toggle')); + await openBreakdown(rowFor(analysisId)); + const input = within(rowFor(analysisId)).getByTestId('morpheme-breakdown-input'); + await userEvent.clear(input); + await userEvent.type(input, 'λογ ος'); + } + + it('asks before deleting the analysis a breakdown draft is keyed to', async () => { + renderPanel({ 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. + expect(screen.getByTestId('catalog-close-title')).toBeInTheDocument(); + expect(screen.queryByTestId('catalog-delete-confirm')).not.toBeInTheDocument(); + }); + + it('keeps the draft and the analysis when the discard is declined', async () => { + const onSave = jest.fn(); + renderPanel({ analysis: LONE, onSave }); + await typeUnsavedBreakdown('ta-1'); + await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-delete')); + + await userEvent.click(screen.getByTestId('catalog-close-cancel')); + + expect(onSave).not.toHaveBeenCalled(); + expect(within(rowFor('ta-1')).getByTestId('morpheme-breakdown-input')).toHaveValue( + 'λογ ος', + ); + }); + + it('puts the deletion itself to the reader once the draft is given up', async () => { + const onSave = jest.fn(); + renderPanel({ analysis: LONE, onSave }); + 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(); + }); + + it('deletes without asking about a draft that re-states the current breakdown', async () => { + renderPanel({ 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.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 }); + 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')); + + expect(onClose).toHaveBeenCalled(); + }); + }); + }); }); diff --git a/src/__tests__/components/AnalysisStore.test.tsx b/src/__tests__/components/AnalysisStore.test.tsx index 546cdf16d..e6fe412a9 100644 --- a/src/__tests__/components/AnalysisStore.test.tsx +++ b/src/__tests__/components/AnalysisStore.test.tsx @@ -5,11 +5,15 @@ import { act, render, renderHook, screen } from '@testing-library/react'; 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 { FIXTURE_STAMPS } from '../test-helpers'; +import type { AnalysisEditOutcome } from '../../components/AnalysisStore'; import { AnalysisStoreProvider, useAnalysis, + useAnalysisDeletionOutcome, useAnalysisLanguage, + useAnalysisRowDispatch, useApproveAnalysisDispatch, useGloss, useGlossDispatch, @@ -137,6 +141,8 @@ function renderStoreHook( initialAnalysis?: TextAnalysis; onSave?: (analysis: TextAnalysis) => void; onGlossChange?: (tokenRef: string, value: string) => void; + showSuggestions?: boolean; + readOnly?: boolean; }> = {}, ) { const { analysisLanguage = 'und', ...rest } = options; @@ -1536,3 +1542,229 @@ describe('useApproveAnalysisDispatch', () => { ); }); }); + +function approvedLink(analysisId: string, tokenRef: string): TokenAnalysisLink { + return { + ...FIXTURE_STAMPS, + analysisId, + token: { tokenRef, surfaceText: 'ἀρχῇ' }, + status: 'approved', + }; +} + +/** Two homographs glossed differently, so editing `ta-1` into `ta-2`'s gloss collapses them. */ +function twoHomographs(links: readonly TokenAnalysisLink[]): TextAnalysis { + return { + ...emptyAnalysis(), + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'ἀρχῇ', gloss: { und: 'start' } }, + { ...FIXTURE_STAMPS, id: 'ta-2', surfaceText: 'ἀρχῇ', gloss: { und: 'beginning' } }, + ], + tokenAnalysisLinks: [...links], + }; +} + +describe('useAnalysisRowDispatch', () => { + it('reports an ordinary edit as leaving the record standing', () => { + const { result } = renderStoreHook(() => useAnalysisRowDispatch(), { + initialAnalysis: twoHomographs([approvedLink('ta-1', 'tok-1')]), + }); + + let outcome: AnalysisEditOutcome | undefined; + act(() => { + outcome = result.current.writeGloss('ta-1', 'origin'); + }); + + expect(outcome).toStrictEqual({ kind: 'edited' }); + }); + + it('reports a collapse onto a sibling, naming the survivor', () => { + const { result } = renderStoreHook(() => useAnalysisRowDispatch(), { + initialAnalysis: twoHomographs([ + approvedLink('ta-1', 'tok-1'), + approvedLink('ta-2', 'tok-2'), + ]), + }); + + let outcome: AnalysisEditOutcome | undefined; + act(() => { + outcome = result.current.writeGloss('ta-1', 'beginning'); + }); + + expect(outcome).toStrictEqual({ + kind: 'merged', + survivingAnalysisId: 'ta-2', + survivingGloss: 'beginning', + survivingUsageCount: 2, + }); + }); + + // The case links cannot report: an unlinked record repoints nothing when it collapses. + it('reports a collapse of a record no token links to', () => { + const { result } = renderStoreHook(() => useAnalysisRowDispatch(), { + initialAnalysis: twoHomographs([approvedLink('ta-2', 'tok-2')]), + }); + + let outcome: AnalysisEditOutcome | undefined; + act(() => { + outcome = result.current.writeGloss('ta-1', 'beginning'); + }); + + expect(outcome).toStrictEqual({ + kind: 'merged', + survivingAnalysisId: 'ta-2', + survivingGloss: 'beginning', + survivingUsageCount: 1, + }); + }); + + it('reports an edit that empties the record as a removal', () => { + const { result } = renderStoreHook(() => useAnalysisRowDispatch(), { + initialAnalysis: twoHomographs([approvedLink('ta-1', 'tok-1')]), + }); + + act(() => { + result.current.writeGloss('ta-1', 'beginning'); + }); + + // ta-2 is the survivor the first write recorded, so this checks a stale one is not reported. + let outcome: AnalysisEditOutcome | undefined; + act(() => { + outcome = result.current.writeGloss('ta-2', ''); + }); + + expect(outcome).toStrictEqual({ kind: 'removed' }); + }); + + it('reports a collapse driven by a morpheme breakdown', () => { + const analysis = twoHomographs([approvedLink('ta-2', 'tok-2')]); + const { result } = renderStoreHook(() => useAnalysisRowDispatch(), { + initialAnalysis: { + ...analysis, + // Same gloss apiece, so the records differ only by breakdown. + tokenAnalyses: [ + { ...FIXTURE_STAMPS, id: 'ta-1', surfaceText: 'ἀρχῇ', gloss: { und: 'beginning' } }, + { + ...FIXTURE_STAMPS, + id: 'ta-2', + surfaceText: 'ἀρχῇ', + gloss: { und: 'beginning' }, + morphemes: [{ ...FIXTURE_STAMPS, id: 'm-1', form: 'ἀρχ', writingSystem: 'el' }], + }, + ], + }, + }); + + let outcome: AnalysisEditOutcome | undefined; + act(() => { + outcome = result.current.writeMorphemes('ta-1', ['ἀρχ'], 'el'); + }); + + expect(outcome?.kind).toBe('merged'); + }); + + it('throws when called outside an AnalysisStoreProvider', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => renderHook(() => useAnalysisRowDispatch())).toThrow( + 'useAnalysisRowDispatch must be used inside an AnalysisStoreProvider', + ); + }); +}); + +describe('useAnalysisDeletionOutcome', () => { + /** Reads every token as still carrying the form its analysis was recorded under. */ + const liveArche = () => 'ἀρχῇ'; + + it('reports the surviving homograph the affected tokens fall back to', () => { + const { result } = renderStoreHook(() => useAnalysisDeletionOutcome(), { + initialAnalysis: twoHomographs([ + approvedLink('ta-1', 'tok-1'), + approvedLink('ta-2', 'tok-2'), + ]), + showSuggestions: true, + }); + + expect(result.current('ta-1', liveArche)).toStrictEqual({ + kind: 'fallback', + usageCount: 1, + unappliedCount: 0, + fallbackGloss: 'beginning', + }); + }); + + it('reports that same fallback as blank while suggestions are hidden', () => { + const { result } = renderStoreHook(() => useAnalysisDeletionOutcome(), { + initialAnalysis: twoHomographs([ + approvedLink('ta-1', 'tok-1'), + approvedLink('ta-2', 'tok-2'), + ]), + showSuggestions: false, + }); + + expect(result.current('ta-1', liveArche)).toStrictEqual({ + kind: 'blank', + usageCount: 1, + unappliedCount: 0, + }); + }); + + it('reports a fallback as blank while the analysis is read-only', () => { + const { result } = renderStoreHook(() => useAnalysisDeletionOutcome(), { + initialAnalysis: twoHomographs([ + approvedLink('ta-1', 'tok-1'), + approvedLink('ta-2', 'tok-2'), + ]), + readOnly: true, + showSuggestions: true, + }); + + expect(result.current('ta-1', liveArche)).toStrictEqual({ + kind: 'blank', + usageCount: 1, + unappliedCount: 0, + }); + }); + + // The suggested link is the unapplied assignment; the two approvals make ta-2 the fallback. + it('keeps the unapplied count when a fallback is reported as blank', () => { + const { result } = renderStoreHook(() => useAnalysisDeletionOutcome(), { + initialAnalysis: twoHomographs([ + approvedLink('ta-1', 'tok-1'), + approvedLink('ta-2', 'tok-2'), + { ...approvedLink('ta-1', 'tok-3'), status: 'suggested' }, + ]), + showSuggestions: false, + }); + + expect(result.current('ta-1', liveArche)).toStrictEqual({ + kind: 'blank', + usageCount: 1, + unappliedCount: 1, + }); + }); + + it('reports a blank outcome when no homograph survives', () => { + const { result } = renderStoreHook(() => useAnalysisDeletionOutcome(), { + initialAnalysis: makeAnalysisWithGloss('tok-1', 'hello'), + }); + + expect(result.current('tok-1-analysis', liveArche)).toStrictEqual({ + kind: 'blank', + usageCount: 1, + unappliedCount: 0, + }); + }); + + it('returns undefined for an id that resolves to no record', () => { + const { result } = renderStoreHook(() => useAnalysisDeletionOutcome()); + + expect(result.current('ta-missing', liveArche)).toBeUndefined(); + }); + + it('throws when called outside an AnalysisStoreProvider', () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + expect(() => renderHook(() => useAnalysisDeletionOutcome())).toThrow( + 'useAnalysisDeletionOutcome must be used inside an AnalysisStoreProvider', + ); + }); +}); diff --git a/src/__tests__/components/CatalogCloseModal.test.tsx b/src/__tests__/components/CatalogCloseModal.test.tsx new file mode 100644 index 000000000..2ebd0be08 --- /dev/null +++ b/src/__tests__/components/CatalogCloseModal.test.tsx @@ -0,0 +1,60 @@ +/// +/// + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import CatalogCloseModal, { CLOSE_STRING_KEYS } from '../../components/CatalogCloseModal'; + +/** Each key resolving to itself: the text arrives as a prop, so only key placement is assertable. */ +const STRINGS = Object.fromEntries(CLOSE_STRING_KEYS.map((k) => [k, k])); + +/** The modal with both callbacks stubbed, so a test asserts on which one the click reached. */ +function renderModal(overrides: { onConfirm?: jest.Mock; onCancel?: jest.Mock } = {}) { + const onConfirm = overrides.onConfirm ?? jest.fn(); + const onCancel = overrides.onCancel ?? jest.fn(); + render( + , + ); + return { onConfirm, onCancel }; +} + +describe('CatalogCloseModal', () => { + it('names what closing would discard', () => { + renderModal(); + + expect(screen.getByTestId('catalog-close-title')).toHaveTextContent( + '%interlinearizer_analysisCatalog_closeConfirmTitle%', + ); + expect(screen.getByTestId('catalog-close-prompt')).toHaveTextContent( + '%interlinearizer_analysisCatalog_closeConfirmPrompt%', + ); + }); + + it('closes the panel when the discard is confirmed', async () => { + const { onConfirm, onCancel } = renderModal(); + + await userEvent.click(screen.getByTestId('catalog-close-discard')); + + expect(onConfirm).toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + }); + + it('backs out to the panel when the close is declined', async () => { + const { onConfirm, onCancel } = renderModal(); + + await userEvent.click(screen.getByTestId('catalog-close-cancel')); + + expect(onCancel).toHaveBeenCalled(); + expect(onConfirm).not.toHaveBeenCalled(); + }); + + // Escape resolves to cancel, not discard, so the reflex that dismisses a dialog keeps the draft. + it('keeps the draft when dismissed by Escape', async () => { + const { onConfirm, onCancel } = renderModal(); + + await userEvent.keyboard('{Escape}'); + + expect(onCancel).toHaveBeenCalled(); + expect(onConfirm).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/components/CatalogMergeModal.test.tsx b/src/__tests__/components/CatalogMergeModal.test.tsx new file mode 100644 index 000000000..9fcca330c --- /dev/null +++ b/src/__tests__/components/CatalogMergeModal.test.tsx @@ -0,0 +1,972 @@ +/// +/// + +import { useLocalizedStrings } from '@papi/frontend/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { MorphemeAnalysis } from 'interlinearizer'; +import CatalogMergeModal, { + MERGE_STRING_KEYS, + dropIndex, +} from '../../components/CatalogMergeModal'; +import type { CatalogRow } from '../../utils/analysis-query'; + +/** Each key resolving to itself: the text arrives as a prop, so only key placement is assertable. */ +const STRINGS = Object.fromEntries(MERGE_STRING_KEYS.map((k) => [k, k])); + +beforeEach(() => { + // The breakdown editor looks its own strings up rather than taking them as props. + jest.mocked(useLocalizedStrings).mockReturnValue([{}, false]); +}); + +const analysisLanguage = 'en'; + +/** + * Builds a catalog row of the one surface form these tests merge, carrying only what a case sets. + * + * The listed gloss also lands in `glosses` under the analysis language, as a row built from a + * stored analysis carries it; a case setting `glosses` outright is opting into a further language. + */ +function row(analysisId: string, overrides: Partial = {}): CatalogRow { + const gloss = overrides.gloss ?? ''; + return { + analysisId, + surfaceText: 'λόγος', + gloss: '', + glosses: gloss ? { [analysisLanguage]: gloss } : undefined, + morphemes: [], + usageCount: 0, + usageCountInBook: 0, + usages: [], + books: new Set(), + searchText: '', + ...overrides, + }; +} + +/** Builds a morpheme of the source writing system, glossed only where a case says so. */ +function morpheme(id: string, form: string, gloss?: string): MorphemeAnalysis { + return { + id, + form, + writingSystem: 'grc', + gloss: gloss === undefined ? undefined : { [analysisLanguage]: gloss }, + }; +} + +/** The modal over `candidates`, opened from the first of them unless told otherwise. */ +function renderModal( + candidates: readonly CatalogRow[], + overrides: { + onConfirm?: jest.Mock; + onCancel?: jest.Mock; + initialSurvivorId?: string; + showMorphology?: boolean; + strings?: Record; + } = {}, +) { + const onConfirm = overrides.onConfirm ?? jest.fn(); + const onCancel = overrides.onCancel ?? jest.fn(); + const initialSurvivorId = overrides.initialSurvivorId ?? candidates[0].analysisId; + const modal = (over: readonly CatalogRow[]) => ( + + ); + const { rerender } = render(modal(candidates)); + /** Re-renders the mounted modal over a changed listing, as an edit beside the panel leaves it. */ + const setCandidates = (next: readonly CatalogRow[]) => rerender(modal(next)); + return { onConfirm, onCancel, setCandidates }; +} + +/** + * Re-splits the merged breakdown the way a reader does: open the editor from the breakdown box (or + * the define-breakdown control where there is none yet), type the forms, and save. + */ +async function setBreakdown(user: ReturnType, forms: string) { + const open = screen.queryByTestId('catalog-merge-breakdown-open'); + await user.click(open ?? screen.getAllByTestId('catalog-merge-content-morpheme')[0]); + const input = screen.getByTestId('morpheme-breakdown-input'); + await user.clear(input); + await user.type(input, forms); + await user.click(screen.getByTestId('morpheme-breakdown-save')); + // Dropping a glossed morpheme confirms before it commits, whether the breakdown is re-split or + // emptied back to the whole word. + const confirm = + screen.queryByTestId('morpheme-split-confirm-action') ?? + screen.queryByTestId('morpheme-reset-confirm-action'); + if (confirm) await user.click(confirm); +} + +describe('CatalogMergeModal', () => { + it('fills the merged gloss from the analysis the merge was opened from', () => { + renderModal([row('ta-1', { gloss: 'word' }), row('ta-2', { gloss: 'speech' })]); + + expect(screen.getByTestId('catalog-merge-content-gloss')).toHaveValue('word'); + }); + + it('keeps what the reader types into the merged gloss', async () => { + renderModal([row('ta-1', { gloss: 'word' }), row('ta-2', { gloss: 'speech' })]); + + await userEvent.clear(screen.getByTestId('catalog-merge-content-gloss')); + await userEvent.type(screen.getByTestId('catalog-merge-content-gloss'), 'utterance'); + + expect(screen.getByTestId('catalog-merge-content-gloss')).toHaveValue('utterance'); + }); + + it('lists every analysis of the form, the survivor first', () => { + renderModal([row('ta-1', { gloss: 'word' }), row('ta-2', { gloss: 'speech' })], { + initialSurvivorId: 'ta-2', + }); + + expect( + screen.getAllByTestId('catalog-merge-candidate').map((el) => el.dataset.analysisId), + ).toEqual(['ta-2', 'ta-1']); + }); + + it('holds the survivor in the merge, its checkbox being no decision to make', () => { + renderModal([row('ta-1', { gloss: 'word' }), row('ta-2', { gloss: 'speech' })]); + + const [survivorBox] = screen.getAllByTestId('catalog-merge-check'); + expect(survivorBox).toBeChecked(); + expect(survivorBox).toBeDisabled(); + }); + + it('leaves every other analysis out of the merge until it is checked', () => { + renderModal([row('ta-1', { gloss: 'word' }), row('ta-2', { gloss: 'speech' })]); + + expect(screen.getAllByTestId('catalog-merge-check')[1]).not.toBeChecked(); + }); + + it('fills a field the survivor lacks once a lower analysis joins the merge', async () => { + renderModal([row('ta-1'), row('ta-2', { gloss: 'speech' })]); + + await userEvent.click(screen.getAllByTestId('catalog-merge-check')[1]); + + expect(screen.getByTestId('catalog-merge-content-gloss')).toHaveValue('speech'); + }); + + it('empties a field again when the only analysis donating it leaves the merge', async () => { + renderModal([row('ta-1'), row('ta-2', { gloss: 'speech' })]); + const donorBox = screen.getAllByTestId('catalog-merge-check')[1]; + + await userEvent.click(donorBox); + await userEvent.click(donorBox); + + expect(screen.getByTestId('catalog-merge-content-gloss')).toHaveValue(''); + }); + + it('withholds the merge while the survivor is the only analysis in it', () => { + renderModal([row('ta-1', { gloss: 'word' }), row('ta-2', { gloss: 'speech' })]); + + expect(screen.getByTestId('catalog-merge-confirm')).toBeDisabled(); + }); + + it('offers the merge once another analysis joins it', async () => { + renderModal([row('ta-1', { gloss: 'word' }), row('ta-2', { gloss: 'speech' })]); + + await userEvent.click(screen.getAllByTestId('catalog-merge-check')[1]); + + expect(screen.getByTestId('catalog-merge-confirm')).toBeEnabled(); + }); + + it('commits the survivor and every analysis folded into it', async () => { + const { onConfirm } = renderModal([ + row('ta-1', { gloss: 'word' }), + row('ta-2', { gloss: 'speech' }), + row('ta-3', { gloss: 'account' }), + ]); + + await userEvent.click(screen.getAllByTestId('catalog-merge-check')[2]); + await userEvent.click(screen.getByTestId('catalog-merge-confirm')); + + expect(onConfirm).toHaveBeenCalledWith( + 'ta-1', + ['ta-3'], + { + gloss: 'word', + glossFromAnalysisId: 'ta-1', + morphemes: [], + pos: undefined, + features: undefined, + confidence: undefined, + }, + 'λόγος', + ); + }); + + it('commits the content the panel settled rather than what the survivor said', async () => { + const { onConfirm } = renderModal([ + row('ta-1', { gloss: 'word' }), + row('ta-2', { gloss: 'speech' }), + ]); + + await userEvent.click(screen.getAllByTestId('catalog-merge-check')[1]); + await userEvent.clear(screen.getByTestId('catalog-merge-content-gloss')); + await userEvent.type(screen.getByTestId('catalog-merge-content-gloss'), 'utterance'); + await userEvent.click(screen.getByTestId('catalog-merge-confirm')); + + expect(onConfirm).toHaveBeenCalledWith( + 'ta-1', + ['ta-2'], + expect.objectContaining({ gloss: 'utterance' }), + 'λόγος', + ); + }); + + it('makes a promoted analysis the survivor the rest merge into', async () => { + const { onConfirm } = renderModal([ + row('ta-1', { gloss: 'word' }), + row('ta-2', { gloss: 'speech' }), + ]); + + await userEvent.click(screen.getAllByTestId('catalog-merge-promote')[1]); + await userEvent.click(screen.getByTestId('catalog-merge-confirm')); + + expect(onConfirm).toHaveBeenCalledWith('ta-2', ['ta-1'], expect.anything(), 'λόγος'); + }); + + it('hands over the donors as the reader ranked them, not as they were checked', async () => { + const { onConfirm } = renderModal([ + row('ta-1', { gloss: 'word' }), + row('ta-2', { gloss: 'speech' }), + row('ta-3', { gloss: 'saying' }), + ]); + + await userEvent.click(screen.getAllByTestId('catalog-merge-check')[1]); + await userEvent.click(screen.getAllByTestId('catalog-merge-check')[2]); + // Lifts the third analysis over the second, then restores the survivor the promotion displaced. + await userEvent.click(screen.getAllByTestId('catalog-merge-promote')[2]); + await userEvent.click(screen.getAllByTestId('catalog-merge-promote')[1]); + await userEvent.click(screen.getByTestId('catalog-merge-confirm')); + + expect(onConfirm).toHaveBeenCalledWith('ta-1', ['ta-3', 'ta-2'], expect.anything(), 'λόγος'); + }); + + it('lifts a promoted analysis to the top of the listing', async () => { + renderModal([row('ta-1', { gloss: 'word' }), row('ta-2', { gloss: 'speech' })]); + + await userEvent.click(screen.getAllByTestId('catalog-merge-promote')[1]); + + expect( + screen.getAllByTestId('catalog-merge-candidate').map((el) => el.dataset.analysisId), + ).toEqual(['ta-2', 'ta-1']); + }); + + it('keeps the analysis a promotion displaced in the merge', async () => { + renderModal([row('ta-1', { gloss: 'word' }), row('ta-2', { gloss: 'speech' })]); + + await userEvent.click(screen.getAllByTestId('catalog-merge-promote')[1]); + + expect(screen.getAllByTestId('catalog-merge-check')[1]).toBeChecked(); + }); + + it('offers no promotion on the analysis already surviving', () => { + renderModal([row('ta-1', { gloss: 'word' }), row('ta-2', { gloss: 'speech' })]); + + expect(screen.getAllByTestId('catalog-merge-promote')[0]).toBeDisabled(); + }); + + it('refills the merged content from the analysis a promotion made survivor', async () => { + renderModal([row('ta-1', { gloss: 'word' }), row('ta-2', { gloss: 'speech' })]); + + await userEvent.click(screen.getAllByTestId('catalog-merge-promote')[1]); + + expect(screen.getByTestId('catalog-merge-content-gloss')).toHaveValue('speech'); + }); + + it('names each analysis by how many tokens carry it', () => { + renderModal([ + row('ta-1', { gloss: 'word', usageCount: 12 }), + row('ta-2', { gloss: 'speech', usageCount: 3 }), + ]); + + expect(screen.getAllByTestId('catalog-merge-usage-count')[0]).toHaveTextContent( + '%interlinearizer_analysisCatalog_mergeUsageCount%', + ); + }); + + it('shows each analysis the fields the merged content could take from it', () => { + renderModal([ + row('ta-1', { gloss: 'word' }), + row('ta-2', { gloss: 'speech', confidence: 'low' }), + ]); + + expect(screen.getAllByTestId('catalog-merge-candidate')[1]).toHaveTextContent('low'); + }); + + it('leaves a part of speech off an analysis, nothing offering to edit one', () => { + renderModal([row('ta-1', { gloss: 'word' }), row('ta-2', { gloss: 'speech', pos: 'verb' })]); + + expect(screen.getAllByTestId('catalog-merge-candidate')[1]).not.toHaveTextContent('verb'); + }); + + it('says nothing about collapsing while every analysis left out stays distinct', async () => { + renderModal([ + row('ta-1', { gloss: 'word' }), + row('ta-2', { gloss: 'speech' }), + row('ta-3', { gloss: 'account' }), + ]); + + await userEvent.click(screen.getAllByTestId('catalog-merge-check')[1]); + + expect(screen.queryByTestId('catalog-merge-collapse-warning')).not.toBeInTheDocument(); + }); + + it('warns that an analysis left out will be absorbed when the merged content comes to match it', async () => { + renderModal([ + row('ta-1', { gloss: 'word' }), + row('ta-2', { gloss: 'speech' }), + row('ta-3', { gloss: 'account' }), + ]); + + await userEvent.click(screen.getAllByTestId('catalog-merge-check')[1]); + await userEvent.clear(screen.getByTestId('catalog-merge-content-gloss')); + await userEvent.type(screen.getByTestId('catalog-merge-content-gloss'), 'account'); + + expect(screen.getByTestId('catalog-merge-collapse-warning')).toHaveTextContent( + '%interlinearizer_analysisCatalog_mergeWillCollapse%', + ); + }); + + it('still allows a merge that will collapse, the warning having said so', async () => { + renderModal([ + row('ta-1', { gloss: 'word' }), + row('ta-2', { gloss: 'speech' }), + row('ta-3', { gloss: 'account' }), + ]); + + await userEvent.click(screen.getAllByTestId('catalog-merge-check')[1]); + await userEvent.clear(screen.getByTestId('catalog-merge-content-gloss')); + await userEvent.type(screen.getByTestId('catalog-merge-content-gloss'), 'account'); + + expect(screen.getByTestId('catalog-merge-confirm')).toBeEnabled(); + }); + + // Resolved rather than echoed as its key, the substitution being the whole of what is asserted. + it('names a collapsing analysis by its form where it carries no gloss', async () => { + renderModal([row('ta-1', { gloss: 'word' }), row('ta-2', { gloss: 'speech' }), row('ta-3')], { + strings: { + ...STRINGS, + '%interlinearizer_analysisCatalog_mergeWillCollapse%': 'also absorbs {gloss}', + }, + }); + + await userEvent.click(screen.getAllByTestId('catalog-merge-check')[1]); + await userEvent.clear(screen.getByTestId('catalog-merge-content-gloss')); + + expect(screen.getByTestId('catalog-merge-collapse-warning')).toHaveTextContent( + 'also absorbs λόγος', + ); + }); + + it('lists an analysis of the form an edit beside the panel raised while it was open', () => { + const { setCandidates } = renderModal([ + row('ta-1', { gloss: 'word' }), + row('ta-2', { gloss: 'speech' }), + ]); + + setCandidates([ + row('ta-1', { gloss: 'word' }), + row('ta-2', { gloss: 'speech' }), + row('ta-3', { gloss: 'account' }), + ]); + + expect( + screen.getAllByTestId('catalog-merge-candidate').map((el) => el.dataset.analysisId), + ).toEqual(['ta-1', 'ta-2', 'ta-3']); + }); + + it('warns about an analysis raised while it was open that the merged content comes to match', async () => { + const { setCandidates } = renderModal([ + row('ta-1', { gloss: 'word' }), + row('ta-2', { gloss: 'speech' }), + ]); + + setCandidates([ + row('ta-1', { gloss: 'word' }), + row('ta-2', { gloss: 'speech' }), + row('ta-3', { gloss: 'account' }), + ]); + await userEvent.click(screen.getAllByTestId('catalog-merge-check')[1]); + await userEvent.clear(screen.getByTestId('catalog-merge-content-gloss')); + await userEvent.type(screen.getByTestId('catalog-merge-content-gloss'), 'account'); + + expect(screen.getByTestId('catalog-merge-collapse-warning')).toHaveTextContent( + '%interlinearizer_analysisCatalog_mergeWillCollapse%', + ); + }); + + it('leaves an analysis raised while it was open out of the merge until it is checked', () => { + const { setCandidates } = renderModal([ + row('ta-1', { gloss: 'word' }), + row('ta-2', { gloss: 'speech' }), + ]); + + setCandidates([ + row('ta-1', { gloss: 'word' }), + row('ta-2', { gloss: 'speech' }), + row('ta-3', { gloss: 'account' }), + ]); + + expect(screen.getAllByTestId('catalog-merge-check')[2]).not.toBeChecked(); + }); + + it('carries a part of speech onto the survivor, having offered no way to change it', async () => { + const { onConfirm } = renderModal([ + row('ta-1', { gloss: 'word', pos: 'noun' }), + row('ta-2', { gloss: 'speech' }), + ]); + + await userEvent.click(screen.getAllByTestId('catalog-merge-check')[1]); + await userEvent.click(screen.getByTestId('catalog-merge-confirm')); + + expect(onConfirm).toHaveBeenCalledWith( + 'ta-1', + ['ta-2'], + expect.objectContaining({ pos: 'noun' }), + 'λόγος', + ); + }); + + it('carries a donor part of speech the survivor lacks', async () => { + const { onConfirm } = renderModal([ + row('ta-1', { gloss: 'word' }), + row('ta-2', { gloss: 'speech', pos: 'verb' }), + ]); + + await userEvent.click(screen.getAllByTestId('catalog-merge-check')[1]); + await userEvent.click(screen.getByTestId('catalog-merge-confirm')); + + expect(onConfirm).toHaveBeenCalledWith( + 'ta-1', + ['ta-2'], + expect.objectContaining({ pos: 'verb' }), + 'λόγος', + ); + }); + + it('offers no revert on a field the reader has not edited', () => { + renderModal([row('ta-1', { gloss: 'word' }), row('ta-2', { gloss: 'speech' })]); + + expect(screen.queryByTestId('catalog-merge-revert-gloss')).not.toBeInTheDocument(); + }); + + it('takes an edited field back to what the merged analyses derive', async () => { + renderModal([row('ta-1', { gloss: 'word' }), row('ta-2', { gloss: 'speech' })]); + + await userEvent.clear(screen.getByTestId('catalog-merge-content-gloss')); + await userEvent.type(screen.getByTestId('catalog-merge-content-gloss'), 'utterance'); + await userEvent.click(screen.getByTestId('catalog-merge-revert-gloss')); + + expect(screen.getByTestId('catalog-merge-content-gloss')).toHaveValue('word'); + }); + + it('takes every edited field back at once', async () => { + renderModal([row('ta-1', { gloss: 'word', confidence: 'high' })]); + + await userEvent.type(screen.getByTestId('catalog-merge-content-gloss'), 'ing'); + await userEvent.click(screen.getByTestId('catalog-merge-content-confidence')); + await userEvent.click(screen.getByTestId('catalog-merge-confidence-low')); + await userEvent.click(screen.getByTestId('catalog-merge-reset')); + + expect(screen.getByTestId('catalog-merge-content-gloss')).toHaveValue('word'); + expect(screen.queryByTestId('catalog-merge-revert-confidence')).not.toBeInTheDocument(); + }); + + it('offers no reset while nothing has been edited', () => { + renderModal([row('ta-1', { gloss: 'word' }), row('ta-2', { gloss: 'speech' })]); + + expect(screen.queryByTestId('catalog-merge-reset')).not.toBeInTheDocument(); + }); + + it('shows the breakdown of an analysis that has one', () => { + renderModal([ + row('ta-1', { gloss: 'word' }), + row('ta-2', { + gloss: 'speech', + morphemes: [ + { id: 'm-1', form: 'λόγ', writingSystem: 'grc' }, + { id: 'm-2', form: 'ος', writingSystem: 'grc' }, + ], + }), + ]); + + const breakdown = screen.getAllByTestId('catalog-merge-breakdown')[1]; + expect(breakdown).toHaveTextContent('λόγ'); + expect(breakdown).toHaveTextContent('ος'); + }); + + it('says so outright for an analysis that segments nothing', () => { + renderModal([row('ta-1', { gloss: 'word' }), row('ta-2', { gloss: 'speech' })]); + + // Both candidates, the notice standing in for a breakdown rather than the row falling blank. + expect(screen.getAllByTestId('catalog-merge-morpheme-none')).toHaveLength(2); + }); + + it('names no feature an analysis carries, nothing offering to edit one', () => { + renderModal([ + row('ta-1', { gloss: 'word' }), + row('ta-2', { gloss: 'speech', features: { Case: 'Nom' } }), + ]); + + expect(screen.getAllByTestId('catalog-merge-candidate')[1]).not.toHaveTextContent('Case'); + }); + + it('offers every analysis a handle to reorder it by', () => { + renderModal([row('ta-1', { gloss: 'word' }), row('ta-2', { gloss: 'speech' })]); + + expect(screen.getAllByTestId('catalog-merge-drag-handle')).toHaveLength(2); + }); + + it('fills the merged breakdown from the highest-ranked analysis in the merge that has one', async () => { + const user = userEvent.setup(); + renderModal([ + row('ta-1', { gloss: 'word' }), + row('ta-2', { gloss: 'speech', morphemes: [morpheme('m-1', 'λόγος')] }), + row('ta-3', { gloss: 'reason', morphemes: [morpheme('m-2', 'λόγ'), morpheme('m-3', 'ος')] }), + ]); + + await user.click(screen.getAllByTestId('catalog-merge-check')[2]); + + expect( + screen.getAllByTestId('catalog-merge-content-morpheme').map((c) => c.textContent), + ).toEqual(['λόγ', 'ος']); + }); + + it('offers a way to split the merged content when no analysis in the merge has a breakdown', () => { + renderModal([row('ta-1', { gloss: 'word' }), row('ta-2', { gloss: 'speech' })]); + + expect(screen.getByTestId('catalog-merge-breakdown-open')).toBeInTheDocument(); + }); + + it('re-splits the merged content when the reader edits the breakdown', async () => { + const user = userEvent.setup(); + renderModal([ + row('ta-1', { gloss: 'word', morphemes: [morpheme('m-1', 'λόγος')] }), + row('ta-2', { gloss: 'speech' }), + ]); + + await setBreakdown(user, 'λόγ ος'); + + expect( + screen.getAllByTestId('catalog-merge-content-morpheme').map((c) => c.textContent), + ).toEqual(['λόγ', 'ος']); + }); + + it('reads a breakdown of the whole form as a single morpheme', async () => { + const user = userEvent.setup(); + const { onConfirm } = renderModal([ + row('ta-1', { gloss: 'word', morphemes: [morpheme('m-1', 'λόγ'), morpheme('m-2', 'ος')] }), + row('ta-2', { gloss: 'speech' }), + ]); + await user.click(screen.getAllByTestId('catalog-merge-check')[1]); + + await setBreakdown(user, 'λόγος'); + await user.click(screen.getByTestId('catalog-merge-confirm')); + + expect(onConfirm.mock.calls[0][2].morphemes.map((m: { form: string }) => m.form)).toEqual([ + 'λόγος', + ]); + }); + + it('empties the merged breakdown from the editor that offers to reset it', async () => { + const user = userEvent.setup(); + const { onConfirm } = renderModal([ + row('ta-1', { gloss: 'word', morphemes: [morpheme('m-1', 'λόγ'), morpheme('m-2', 'ος')] }), + row('ta-2', { gloss: 'speech' }), + ]); + await user.click(screen.getAllByTestId('catalog-merge-check')[1]); + + await user.click(screen.getAllByTestId('catalog-merge-content-morpheme')[0]); + await user.click(screen.getByTestId('morpheme-breakdown-reset')); + await user.click(screen.getByTestId('catalog-merge-confirm')); + + expect(onConfirm.mock.calls[0][2].morphemes).toEqual([]); + }); + + it('takes an edited breakdown back to what the merged analyses derive', async () => { + const user = userEvent.setup(); + renderModal([ + row('ta-1', { gloss: 'word', morphemes: [morpheme('m-1', 'λόγ'), morpheme('m-2', 'ος')] }), + row('ta-2', { gloss: 'speech' }), + ]); + + await setBreakdown(user, 'λόγο ς'); + await user.click(screen.getByTestId('catalog-merge-revert-morphemeForms')); + + expect( + screen.getAllByTestId('catalog-merge-content-morpheme').map((c) => c.textContent), + ).toEqual(['λόγ', 'ος']); + }); + + it('fills each morpheme gloss from the highest-ranked analysis in the merge carrying its form', async () => { + const user = userEvent.setup(); + renderModal([ + row('ta-1', { + gloss: 'word', + morphemes: [morpheme('m-1', 'λόγ'), morpheme('m-2', 'ος')], + }), + row('ta-2', { + gloss: 'speech', + morphemes: [morpheme('m-3', 'λόγ', 'say'), morpheme('m-4', 'ος', 'nom.sg')], + }), + ]); + + await user.click(screen.getAllByTestId('catalog-merge-check')[1]); + + const glosses = screen.getAllByTestId('catalog-merge-content-morpheme-gloss'); + expect(glosses[0]).toHaveValue('say'); + expect(glosses[1]).toHaveValue('nom.sg'); + }); + + it('sends a click on the breakdown box to the field the forms are staged in', async () => { + // The box is the token strip's, where a form click opens the breakdown editor; here the forms + // are staged in a field whose pending value and revert button have to stay visible, so the click + // goes there instead. + const user = userEvent.setup(); + renderModal([ + row('ta-1', { gloss: 'word', morphemes: [morpheme('m-1', 'λόγ'), morpheme('m-2', 'ος')] }), + row('ta-2', { gloss: 'speech' }), + ]); + + await user.click(screen.getAllByTestId('catalog-merge-content-morpheme')[0]); + + expect(screen.getByTestId('morpheme-breakdown-input')).toBeInTheDocument(); + }); + + it('keeps what the reader types into a morpheme gloss', async () => { + const user = userEvent.setup(); + renderModal([ + row('ta-1', { gloss: 'word', morphemes: [morpheme('m-1', 'λόγ'), morpheme('m-2', 'ος')] }), + row('ta-2', { gloss: 'speech' }), + ]); + + await user.type(screen.getAllByTestId('catalog-merge-content-morpheme-gloss')[0], 'say'); + + expect(screen.getAllByTestId('catalog-merge-content-morpheme-gloss')[0]).toHaveValue('say'); + }); + + it('takes one morpheme gloss back to what the merged analyses derive', async () => { + const user = userEvent.setup(); + renderModal([ + row('ta-1', { + gloss: 'word', + morphemes: [morpheme('m-1', 'λόγ', 'say'), morpheme('m-2', 'ος', 'nom.sg')], + }), + row('ta-2', { gloss: 'speech' }), + ]); + await user.type(screen.getAllByTestId('catalog-merge-content-morpheme-gloss')[0], '-stem'); + + await user.click(screen.getByTestId('catalog-merge-revert-morpheme-gloss')); + + const glosses = screen.getAllByTestId('catalog-merge-content-morpheme-gloss'); + expect(glosses[0]).toHaveValue('say'); + expect(glosses[1]).toHaveValue('nom.sg'); + }); + + it('offers no revert on a morpheme gloss the reader has not touched', () => { + renderModal([ + row('ta-1', { gloss: 'word', morphemes: [morpheme('m-1', 'λόγ', 'say')] }), + row('ta-2', { gloss: 'speech' }), + ]); + + expect(screen.queryByTestId('catalog-merge-revert-morpheme-gloss')).not.toBeInTheDocument(); + }); + + it('offers one morpheme gloss field once the breakdown is collapsed to the whole word', async () => { + const user = userEvent.setup(); + renderModal([ + row('ta-1', { gloss: 'word', morphemes: [morpheme('m-1', 'λόγ'), morpheme('m-2', 'ος')] }), + row('ta-2', { gloss: 'speech' }), + ]); + await user.type(screen.getAllByTestId('catalog-merge-content-morpheme-gloss')[1], 'nom.sg'); + + await setBreakdown(user, 'λόγος'); + + expect(screen.queryAllByTestId('catalog-merge-content-morpheme-gloss')).toHaveLength(1); + }); + + it('carries a typed morpheme gloss to the form it was typed on across a re-split', async () => { + const user = userEvent.setup(); + renderModal([ + row('ta-1', { gloss: 'word', morphemes: [morpheme('m-1', 'λόγ'), morpheme('m-2', 'ος')] }), + row('ta-2', { gloss: 'speech' }), + ]); + await user.type(screen.getAllByTestId('catalog-merge-content-morpheme-gloss')[0], 'say'); + + // The re-split keeps λόγ, at the other index. + await setBreakdown(user, 'ο λόγ'); + + const glosses = screen.getAllByTestId('catalog-merge-content-morpheme-gloss'); + expect(glosses[0]).toHaveValue(''); + expect(glosses[1]).toHaveValue('say'); + }); + + it('drops a typed morpheme gloss whose form the re-split leaves nothing of', async () => { + const user = userEvent.setup(); + renderModal([ + row('ta-1', { gloss: 'word', morphemes: [morpheme('m-1', 'λόγ'), morpheme('m-2', 'ος')] }), + row('ta-2', { gloss: 'speech' }), + ]); + await user.type(screen.getAllByTestId('catalog-merge-content-morpheme-gloss')[0], 'say'); + + await setBreakdown(user, 'λό γος'); + + // The re-split leaves no λόγ for the gloss typed about it, and λό is a different form. + expect(screen.getAllByTestId('catalog-merge-content-morpheme-gloss')[0]).toHaveValue(''); + }); + + it('keeps a typed morpheme gloss across a change of survivor', async () => { + const user = userEvent.setup(); + renderModal([ + row('ta-1', { gloss: 'word', morphemes: [morpheme('m-1', 'λόγ'), morpheme('m-2', 'ος')] }), + row('ta-2', { + gloss: 'speech', + morphemes: [morpheme('m-3', 'λόγ', 'say'), morpheme('m-4', 'ος')], + }), + ]); + await user.type(screen.getAllByTestId('catalog-merge-content-morpheme-gloss')[0], 'word-stem'); + + await user.click(screen.getAllByTestId('catalog-merge-promote')[1]); + + expect(screen.getAllByTestId('catalog-merge-content-morpheme-gloss')[0]).toHaveValue( + 'word-stem', + ); + }); + + it('carries a typed morpheme gloss to its form when promotion changes the breakdown', async () => { + const user = userEvent.setup(); + const { onConfirm } = renderModal([ + row('ta-1', { gloss: 'word', morphemes: [morpheme('m-1', 'λόγ'), morpheme('m-2', 'ος')] }), + row('ta-2', { + gloss: 'speech', + morphemes: [morpheme('m-3', 'λόγο'), morpheme('m-4', 'ς')], + }), + ]); + await user.type(screen.getAllByTestId('catalog-merge-content-morpheme-gloss')[1], 'nom.sg'); + + // ta-2 reads the word a different way, so the breakdown it brings has no ος for the gloss. + await user.click(screen.getAllByTestId('catalog-merge-promote')[1]); + + const glosses = screen.getAllByTestId('catalog-merge-content-morpheme-gloss'); + expect(glosses[0]).toHaveValue(''); + expect(glosses[1]).toHaveValue(''); + + await user.click(screen.getByTestId('catalog-merge-confirm')); + expect(onConfirm).toHaveBeenCalledWith( + 'ta-2', + ['ta-1'], + expect.objectContaining({ + morphemes: [ + expect.objectContaining({ form: 'λόγο', gloss: undefined }), + expect.objectContaining({ form: 'ς', gloss: undefined }), + ], + }), + 'λόγος', + ); + }); + + it('carries a typed morpheme gloss to its form when unchecking changes the breakdown', async () => { + const user = userEvent.setup(); + renderModal([ + row('ta-1', { gloss: 'word' }), + row('ta-2', { gloss: 'speech', morphemes: [morpheme('m-1', 'λόγο'), morpheme('m-2', 'ς')] }), + row('ta-3', { gloss: 'account', morphemes: [morpheme('m-3', 'λόγ'), morpheme('m-4', 'ος')] }), + ]); + await user.click(screen.getAllByTestId('catalog-merge-check')[1]); + await user.click(screen.getAllByTestId('catalog-merge-check')[2]); + await user.type(screen.getAllByTestId('catalog-merge-content-morpheme-gloss')[1], 'nom.sg'); + + // Dropping ta-2 hands the breakdown to ta-3, which reads the word another way. + await user.click(screen.getAllByTestId('catalog-merge-check')[1]); + + const glosses = screen.getAllByTestId('catalog-merge-content-morpheme-gloss'); + expect(glosses[0]).toHaveValue(''); + expect(glosses[1]).toHaveValue(''); + }); + + it('leaves a re-split of the reader’s standing when promotion changes the breakdown', async () => { + const user = userEvent.setup(); + renderModal([ + row('ta-1', { gloss: 'word', morphemes: [morpheme('m-1', 'λόγ'), morpheme('m-2', 'ος')] }), + row('ta-2', { + gloss: 'speech', + morphemes: [morpheme('m-3', 'λόγο'), morpheme('m-4', 'ς')], + }), + ]); + await setBreakdown(user, 'λό γος'); + await user.type(screen.getAllByTestId('catalog-merge-content-morpheme-gloss')[1], 'the-rest'); + + // The re-split outranks the breakdown ta-2 would bring, so neither it nor the gloss moves. + await user.click(screen.getAllByTestId('catalog-merge-promote')[1]); + + const glosses = screen.getAllByTestId('catalog-merge-content-morpheme-gloss'); + expect(glosses).toHaveLength(2); + expect(glosses[1]).toHaveValue('the-rest'); + }); + + it('carries a typed morpheme gloss to its form when a re-split is taken back', async () => { + const user = userEvent.setup(); + renderModal([ + row('ta-1', { gloss: 'word', morphemes: [morpheme('m-1', 'λόγ'), morpheme('m-2', 'ος')] }), + row('ta-2', { gloss: 'speech' }), + ]); + await setBreakdown(user, 'λό γος'); + await user.type(screen.getAllByTestId('catalog-merge-content-morpheme-gloss')[1], 'the-rest'); + + // Taking the re-split back returns λόγ|ος, which has no γος for the gloss typed about it. + await user.click(screen.getByTestId('catalog-merge-revert-morphemeForms')); + + const glosses = screen.getAllByTestId('catalog-merge-content-morpheme-gloss'); + expect(glosses).toHaveLength(2); + expect(glosses[0]).toHaveValue(''); + expect(glosses[1]).toHaveValue(''); + }); + + it('offers no way to author a feature, nothing rendering one back', () => { + renderModal([ + row('ta-1', { gloss: 'word', features: { Case: 'Nom' } }), + row('ta-2', { gloss: 'speech' }), + ]); + + expect(screen.queryByTestId('catalog-merge-feature-add')).not.toBeInTheDocument(); + expect( + screen.queryByTestId('catalog-merge-content-feature-stored:Case'), + ).not.toBeInTheDocument(); + }); + + it('carries the features an analysis arrived with onto the survivor', async () => { + const { onConfirm } = renderModal([ + row('ta-1', { gloss: 'word' }), + row('ta-2', { gloss: 'speech', features: { Case: 'Nom' } }), + ]); + + await userEvent.click(screen.getAllByTestId('catalog-merge-check')[1]); + await userEvent.click(screen.getByTestId('catalog-merge-confirm')); + + expect(onConfirm).toHaveBeenCalledWith( + 'ta-1', + ['ta-2'], + expect.objectContaining({ features: { Case: 'Nom' } }), + 'λόγος', + ); + }); + it('fills the confidence from the highest-ranked analysis in the merge carrying one', async () => { + const user = userEvent.setup(); + renderModal([ + row('ta-1', { gloss: 'word' }), + row('ta-2', { gloss: 'speech', confidence: 'high' }), + ]); + + await user.click(screen.getAllByTestId('catalog-merge-check')[1]); + + expect(screen.getByTestId('catalog-merge-content-confidence')).toHaveTextContent('high'); + }); + + it('records the confidence the reader picks', async () => { + const user = userEvent.setup(); + const { onConfirm } = renderModal([ + row('ta-1', { gloss: 'word', confidence: 'high' }), + row('ta-2', { gloss: 'speech' }), + ]); + await user.click(screen.getAllByTestId('catalog-merge-check')[1]); + + await user.click(screen.getByTestId('catalog-merge-confidence-guess')); + await user.click(screen.getByTestId('catalog-merge-confirm')); + + expect(onConfirm.mock.calls[0][2].confidence).toBe('guess'); + }); + + it('records no confidence once the reader takes it to none', async () => { + const user = userEvent.setup(); + const { onConfirm } = renderModal([ + row('ta-1', { gloss: 'word', confidence: 'high' }), + row('ta-2', { gloss: 'speech', confidence: 'low' }), + ]); + await user.click(screen.getAllByTestId('catalog-merge-check')[1]); + + await user.click(screen.getByTestId('catalog-merge-confidence-none')); + await user.click(screen.getByTestId('catalog-merge-confirm')); + + expect(onConfirm.mock.calls[0][2].confidence).toBeUndefined(); + }); + + it('takes an edited confidence back to what the merged analyses derive', async () => { + const user = userEvent.setup(); + renderModal([ + row('ta-1', { gloss: 'word', confidence: 'high' }), + row('ta-2', { gloss: 'speech' }), + ]); + await user.click(screen.getByTestId('catalog-merge-confidence-guess')); + + await user.click(screen.getByTestId('catalog-merge-revert-confidence')); + + expect(screen.getByTestId('catalog-merge-content-confidence')).toHaveTextContent('high'); + }); + + it('commits the breakdown, morpheme glosses and confidence the panel settled', async () => { + const user = userEvent.setup(); + const { onConfirm } = renderModal([ + row('ta-1', { gloss: 'word' }), + row('ta-2', { gloss: 'speech' }), + ]); + await user.click(screen.getAllByTestId('catalog-merge-check')[1]); + + await setBreakdown(user, 'λόγ ος'); + await user.type(screen.getAllByTestId('catalog-merge-content-morpheme-gloss')[0], 'say'); + await user.click(screen.getByTestId('catalog-merge-confidence-medium')); + await user.click(screen.getByTestId('catalog-merge-confirm')); + + const content = onConfirm.mock.calls[0][2]; + expect(content.morphemes.map((m: MorphemeAnalysis) => m.form)).toEqual(['λόγ', 'ος']); + expect(content.morphemes[0].gloss).toEqual({ en: 'say' }); + expect(content.confidence).toBe('medium'); + }); + + it('takes the breakdown and confidence back with every other edited field', async () => { + const user = userEvent.setup(); + renderModal([ + row('ta-1', { + gloss: 'word', + morphemes: [morpheme('m-1', 'λόγος', 'word')], + confidence: 'high', + }), + row('ta-2', { gloss: 'speech' }), + ]); + + await setBreakdown(user, 'λόγ ος'); + await user.click(screen.getByTestId('catalog-merge-confidence-guess')); + + await user.click(screen.getByTestId('catalog-merge-reset')); + + expect( + screen.getAllByTestId('catalog-merge-content-morpheme').map((c) => c.textContent), + ).toEqual(['λόγος']); + expect(screen.getByTestId('catalog-merge-content-confidence')).toHaveTextContent('high'); + }); +}); + +describe('dropIndex', () => { + // Until the reader moves something, no analysis has been rearranged — so this is the state every + // first drag resolves against. + it('resolves a drop on an analysis the reader has not yet rearranged', () => { + expect(dropIndex(['ta-1', 'ta-2', 'ta-3', 'ta-4'], 'ta-4')).toBe(3); + }); + + it('resolves a drop on the analysis heading the listing', () => { + expect(dropIndex(['ta-1', 'ta-2', 'ta-3'], 'ta-1')).toBe(0); + }); + + it('reports no place for a drop on something the listing does not hold', () => { + expect(dropIndex(['ta-1', 'ta-2'], 'ta-9')).toBeUndefined(); + }); +}); diff --git a/src/__tests__/components/MorphemeBox.test.tsx b/src/__tests__/components/MorphemeBox.test.tsx index c42cee51b..31b144edd 100644 --- a/src/__tests__/components/MorphemeBox.test.tsx +++ b/src/__tests__/components/MorphemeBox.test.tsx @@ -4,6 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import type { MorphemeAnalysis } from 'interlinearizer'; +import { Popover } from 'platform-bible-react'; import * as AnalysisStore from '../../components/AnalysisStore'; import { MorphemeBox, MorphemeGlossInput } from '../../components/MorphemeBox'; import { TOKEN_CHIP_LABEL_KEYS } from '../../components/PhraseStripContext'; @@ -33,21 +34,33 @@ const MORPHEMES: MorphemeAnalysis[] = [ */ function renderBox(props: Partial[0]> = {}) { return render( - , + // Wrapped as TokenChip wraps it: the box anchors the breakdown editor's popover. + + + , ); } describe('MorphemeBox', () => { + it('names no rows, the token strip labeling its breakdown by position alone', () => { + // The catalog and the merge panel label the two rows; on the strip the surrounding interlinear + // line already says what they are, and a label column there would inset every chip. + renderBox(); + + expect(screen.queryByTestId('morpheme-form-row-label')).not.toBeInTheDocument(); + expect(screen.queryByTestId('morpheme-gloss-row-label')).not.toBeInTheDocument(); + }); + it('renders one form cell per morpheme', () => { renderBox(); expect(screen.getByText('hel')).toBeInTheDocument(); @@ -99,7 +112,7 @@ describe('MorphemeBox', () => { it('sizes the column template to the morpheme count', () => { const { container } = renderBox(); const box = container.querySelector('[style*="grid-template-columns"]'); - expect(box).toHaveStyle({ gridTemplateColumns: 'repeat(2, minmax(1ch, auto))' }); + expect(box).toHaveStyle({ gridTemplateColumns: 'repeat(2, minmax(4ch, auto))' }); }); it('takes its box metrics from the shared morphology-slot utility', () => { @@ -143,16 +156,18 @@ describe('MorphemeBox', () => { // Stand-in for an ancestor React handler, not real UI. // eslint-disable-next-line jsx-a11y/no-static-element-interactions
- + + +
, ); fireEvent.mouseDown(screen.getByText('-lo')); @@ -168,16 +183,18 @@ describe('MorphemeBox', () => { // Stand-in for TokenChip's label, not real UI. // eslint-disable-next-line jsx-a11y/label-has-associated-control , ); @@ -370,3 +387,203 @@ describe('MorphemeBox read-only', () => { expect(onEditBreakdown).not.toHaveBeenCalled(); }); }); + +/** Renders a read-only box, whose cells are static text rather than controls. */ +function renderReadOnlyBox( + morphemes: readonly MorphemeAnalysis[], + props: Partial[0]> = {}, +) { + setMockAnalysisReadOnly(true); + return render( + + + , + ); +} + +/** Builds a morpheme, glossed only where a case says so. */ +function grcMorpheme( + id: string, + form: string, + gloss?: Readonly>, +): MorphemeAnalysis { + return { id, form, writingSystem: 'grc', gloss }; +} + +describe('a read-only breakdown', () => { + it('shows each morpheme of a breakdown', () => { + renderReadOnlyBox([grcMorpheme('m-1', 'λόγ'), grcMorpheme('m-2', 'ος')]); + + expect(screen.getAllByTestId('readonly-morpheme-form').map((cell) => cell.textContent)).toEqual( + ['λόγ', 'ος'], + ); + }); + + it('keeps the morphemes in the order the breakdown records them', () => { + // A breakdown read out of order says the word is segmented other than it is, which is the one + // thing the forms are there to convey. + renderReadOnlyBox([ + grcMorpheme('m-1', 'ἀπο'), + grcMorpheme('m-2', 'στελ'), + grcMorpheme('m-3', 'λω'), + ]); + + expect(screen.getAllByTestId('readonly-morpheme-form').map((cell) => cell.textContent)).toEqual( + ['ἀπο', 'στελ', 'λω'], + ); + }); + + it('sits each gloss in the grid column of the form it belongs to', () => { + // Form over gloss in a shared column is what the boxed grid is for; a gloss under the wrong + // form misreports the segmentation. + renderReadOnlyBox([ + grcMorpheme('m-1', 'λόγ', { en: 'word' }), + grcMorpheme('m-2', 'ος', { en: 'NOM.SG' }), + ]); + + expect(screen.getAllByTestId('readonly-morpheme-form')[1]).toHaveStyle({ + gridColumn: '2', + gridRow: '1', + }); + expect(screen.getAllByTestId('readonly-morpheme-gloss')[1]).toHaveStyle({ + gridColumn: '2', + gridRow: '2', + }); + }); + + it("shows a morpheme's gloss in the analysis language", () => { + renderReadOnlyBox([grcMorpheme('m-1', 'λόγ', { en: 'word' })]); + + expect(screen.getByTestId('readonly-morpheme-gloss')).toHaveTextContent('word'); + }); + + it('reads the glosses under the analysis language it is given', () => { + renderReadOnlyBox([grcMorpheme('m-1', 'λόγ', { en: 'word', fr: 'parole' })], { + analysisLanguage: 'fr', + }); + + expect(screen.getByTestId('readonly-morpheme-gloss')).toHaveTextContent('parole'); + }); + + it('glosses each morpheme from its own record', () => { + renderReadOnlyBox([ + grcMorpheme('m-1', 'λόγ', { en: 'word' }), + grcMorpheme('m-2', 'ος', { en: 'NOM.SG' }), + ]); + + expect(screen.getAllByTestId('readonly-morpheme-gloss')[1]).toHaveTextContent('NOM.SG'); + }); + + describe('a morpheme carrying no gloss', () => { + it('names the absence where the caller supplies a label', () => { + // A blank cell reads as a rendering gap in a view that offers no field to fill. + renderReadOnlyBox([grcMorpheme('m-1', 'λόγ')], { noGlossLabel: 'no gloss' }); + + expect(screen.getByTestId('readonly-morpheme-gloss')).toHaveTextContent('no gloss'); + }); + + it('leaves the cell blank where the caller supplies no label', () => { + renderReadOnlyBox([grcMorpheme('m-1', 'λόγ')]); + + expect(screen.getByTestId('readonly-morpheme-gloss')).toBeEmptyDOMElement(); + }); + + it('names the absence where a morpheme is glossed only in another language', () => { + renderReadOnlyBox([grcMorpheme('m-1', 'λόγ', { fr: 'parole' })], { + noGlossLabel: 'no gloss', + }); + + const gloss = screen.getByTestId('readonly-morpheme-gloss'); + expect(gloss).toHaveTextContent('no gloss'); + expect(gloss).not.toHaveTextContent('parole'); + }); + + it('names the absence where a morpheme is glossed to an empty string', () => { + // An emptied gloss is the same absence as an unset one, which a blank cell would not say. + renderReadOnlyBox([grcMorpheme('m-1', 'λόγ', { en: '' })], { noGlossLabel: 'no gloss' }); + + expect(screen.getByTestId('readonly-morpheme-gloss')).toHaveTextContent('no gloss'); + }); + }); + + describe('an analysis that segments nothing', () => { + it('says outright that it is not split where the caller supplies a label', () => { + renderReadOnlyBox([], { noBreakdownLabel: 'not split' }); + + expect(screen.getByTestId('readonly-morpheme-form-none')).toHaveTextContent('not split'); + }); + + it('renders nothing where the caller supplies no label', () => { + // An unanalyzed token shows its "define breakdown" affordance instead, so a notice here + // would duplicate it. + renderReadOnlyBox([]); + + expect(screen.queryByTestId('readonly-morpheme-form')).not.toBeInTheDocument(); + expect(screen.queryByTestId('readonly-morpheme-form-none')).not.toBeInTheDocument(); + }); + + it('shows no morpheme columns', () => { + renderReadOnlyBox([], { noBreakdownLabel: 'not split' }); + + expect(screen.queryByTestId('readonly-morpheme-form')).not.toBeInTheDocument(); + }); + }); + + it('shows no not-split notice for an analysis that has a breakdown', () => { + renderReadOnlyBox([grcMorpheme('m-1', 'λόγ')], { noBreakdownLabel: 'not split' }); + + expect(screen.queryByTestId('readonly-morpheme-form-none')).not.toBeInTheDocument(); + }); +}); + +describe('row labels', () => { + const ROW_LABELS = { forms: 'Morphemes', glosses: 'Glosses' }; + + beforeEach(() => { + setMockAnalysisReadOnly(false); + }); + + it('names each row alongside the cells it names', () => { + renderBox({ rowLabels: ROW_LABELS }); + + expect(screen.getByTestId('morpheme-form-row-label')).toHaveTextContent('Morphemes'); + expect(screen.getByTestId('morpheme-gloss-row-label')).toHaveTextContent('Glosses'); + }); + + it('puts each label in the row track of the cells it names', () => { + renderBox({ rowLabels: ROW_LABELS }); + + expect(screen.getByTestId('morpheme-form-row-label')).toHaveStyle({ + gridColumn: '1', + gridRow: '1', + }); + expect(screen.getByTestId('morpheme-gloss-row-label')).toHaveStyle({ + gridColumn: '1', + gridRow: '2', + }); + }); + + it('shifts the morpheme columns past the label track', () => { + renderBox({ rowLabels: ROW_LABELS }); + + expect(screen.getByText('hel')).toHaveStyle({ gridColumn: '2' }); + expect(screen.getByText('-lo')).toHaveStyle({ gridColumn: '3' }); + }); + + it('reserves no label track where the caller names no rows', () => { + // The token strip's breakdown is unlabeled; a track for absent labels would inset every chip. + renderBox(); + + expect(screen.getByText('hel')).toHaveStyle({ gridColumn: '1' }); + }); +}); diff --git a/src/__tests__/components/MorphemeEditor.test.tsx b/src/__tests__/components/MorphemeEditor.test.tsx index 9e550d346..cec4d4e39 100644 --- a/src/__tests__/components/MorphemeEditor.test.tsx +++ b/src/__tests__/components/MorphemeEditor.test.tsx @@ -4,6 +4,7 @@ import { useLocalizedStrings } from '@papi/frontend/react'; import { fireEvent, render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import type { MorphemeAnalysis } from 'interlinearizer'; import type { ComponentProps } from 'react'; import { MorphemeBreakdownPopover } from '../../components/MorphemeEditor'; @@ -17,9 +18,17 @@ const LOCALIZED = { '%interlinearizer_morphemeEditor_emptyHint%': 'Enter morpheme forms separated by spaces', '%interlinearizer_morphemeEditor_confirmResetPrompt%': 'Discard this breakdown and its glosses?', '%interlinearizer_morphemeEditor_confirmResetAction%': 'Reset', + '%interlinearizer_morphemeEditor_confirmResplitPrompt%': + 'This breakdown drops {forms}, discarding the glosses on it. Save anyway?', + '%interlinearizer_morphemeEditor_confirmResplitAction%': 'Save and discard', '%interlinearizer_morphemeGloss_label%': 'Gloss for morpheme {form}', }; +/** A morpheme carrying a gloss, so dropping it is the loss a re-split confirms over. */ +function glossed(id: string, form: string): MorphemeAnalysis { + return { id, form, writingSystem: 'und', gloss: { und: form } }; +} + beforeEach(() => { jest.mocked(useLocalizedStrings).mockReturnValue([LOCALIZED, false]); }); @@ -333,6 +342,25 @@ describe('MorphemeBreakdownPopover', () => { expect(screen.getByRole('textbox', { name: 'morpheme gloss' })).toHaveFocus(); }); + it('leaves the close-focus default alone when no gloss input is named', async () => { + // Opened from an ordinary tabbable trigger rather than a token chip, there is no gloss field + // to land in and the popover's own focus restoration is what belongs. + render( + <> + + + , + ); + const elsewhere = screen.getByRole('textbox', { name: 'elsewhere' }); + elsewhere.focus(); + + // The stub blurs the focused element for an unprevented close event, which is how it stands in + // for Radix moving focus itself — so an unprevented default is what this asserts. + await userEvent.click(screen.getByTestId('popover-close')); + + expect(elsewhere).not.toHaveFocus(); + }); + it('leaves focus alone when the popover was dismissed by a press outside it', async () => { // A press outside has already put focus where the user aimed it — typically another token they // clicked. Pulling focus back to this chip's first morpheme gloss would yank it out of that @@ -445,6 +473,109 @@ describe('MorphemeBreakdownPopover', () => { }); }); + describe('re-split confirmation', () => { + /** + * Renders the popover over a glossed breakdown of "unbelievable" that this token solely owns, + * so a re-split dropping any of its forms destroys that form's gloss outright. + */ + function renderResplitting( + props: Partial> = {}, + ) { + return renderPopover({ + initialValue: 'un- believ -able', + morphemes: [glossed('m-1', 'un-'), glossed('m-2', 'believ'), glossed('m-3', '-able')], + onReset: jest.fn(), + ...props, + }); + } + + /** Replaces the draft with `value` and commits it. */ + async function commit(value: string) { + await userEvent.clear(screen.getByRole('textbox')); + await userEvent.type(screen.getByRole('textbox'), value); + await userEvent.keyboard('{Enter}'); + } + + it('asks before a re-split that strands a glossed form', async () => { + const onSave = jest.fn(); + const onClose = jest.fn(); + renderResplitting({ onSave, onClose }); + await commit('un- believe'); + expect(screen.getByTestId('morpheme-split-confirm')).toBeInTheDocument(); + expect(onSave).not.toHaveBeenCalled(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('names the stranded forms in the prompt', async () => { + renderResplitting(); + await commit('un- believe'); + expect(screen.getByTestId('morpheme-split-confirm')).toHaveTextContent( + 'This breakdown drops believ, -able, discarding the glosses on it. Save anyway?', + ); + }); + + it('saves and closes when the confirmation is accepted', async () => { + const onSave = jest.fn(); + const onClose = jest.fn(); + renderResplitting({ onSave, onClose }); + await commit('un- believe'); + await userEvent.click(screen.getByTestId('morpheme-split-confirm-action')); + expect(onSave).toHaveBeenCalledWith('un- believe'); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('returns to the draft when the confirmation is canceled', async () => { + const onSave = jest.fn(); + renderResplitting({ onSave }); + await commit('un- believe'); + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(screen.queryByTestId('morpheme-split-confirm')).not.toBeInTheDocument(); + expect(screen.getByRole('textbox')).toHaveValue('un- believe'); + expect(onSave).not.toHaveBeenCalled(); + }); + + 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 + // must not answer the prompt, even though an outside press on an edited draft normally saves. + const onSave = jest.fn(); + const onClose = jest.fn(); + renderResplitting({ onSave, onClose }); + await commit('un- believe'); + await userEvent.click(screen.getByTestId('popover-outside')); + expect(onSave).not.toHaveBeenCalled(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('saves without asking when the re-split keeps every glossed form', async () => { + const onSave = jest.fn(); + renderResplitting({ onSave }); + await commit('un- believ -able -ness'); + expect(screen.queryByTestId('morpheme-split-confirm')).not.toBeInTheDocument(); + expect(onSave).toHaveBeenCalledWith('un- believ -able -ness'); + }); + + it('saves without asking when the stranded form carried no gloss', async () => { + const onSave = jest.fn(); + renderResplitting({ + morphemes: [glossed('m-1', 'un-'), { id: 'm-2', form: 'believ', writingSystem: 'und' }], + onSave, + }); + await commit('un- believe'); + expect(screen.queryByTestId('morpheme-split-confirm')).not.toBeInTheDocument(); + expect(onSave).toHaveBeenCalledWith('un- believe'); + }); + + it('saves without asking when the payload is shared, its morphemes withheld', async () => { + // A shared payload is forked rather than re-segmented in place, so the co-linked tokens keep + // the glosses this token drops and there is nothing to confirm. + const onSave = jest.fn(); + renderResplitting({ morphemes: undefined, onSave }); + await commit('un- believe'); + expect(screen.queryByTestId('morpheme-split-confirm')).not.toBeInTheDocument(); + expect(onSave).toHaveBeenCalledWith('un- believe'); + }); + }); + it('falls back to the token gloss input on close when the chip has no morpheme gloss field', async () => { render(
)} diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx index 0dd80f1a5..8881c63b2 100644 --- a/src/components/InterlinearizerLoader.tsx +++ b/src/components/InterlinearizerLoader.tsx @@ -546,6 +546,21 @@ function InterlinearizerLoaderInner({ [verseBook, segmentationVersion, draftVersion, isDraftLoading, isImportView], ); + /** The loaded book's current token text, by ref. */ + const liveTokensByRef = useMemo(() => { + const byRef = new Map(); + book?.segments.forEach((segment) => + segment.tokens.forEach((token) => byRef.set(token.ref, token.surfaceText)), + ); + return byRef; + }, [book]); + + /** Reads the loaded book's current text for a ref, `undefined` for one in any other book. */ + const liveSurfaceText = useCallback( + (tokenRef: string) => liveTokensByRef.get(tokenRef), + [liveTokensByRef], + ); + const { undismissedLostBoundaries, onDismiss: handleDismissLostBoundaries } = useLostBoundaryDismissal({ verseBook, @@ -1258,6 +1273,7 @@ function InterlinearizerLoaderInner({ // mid-load, and counting against the book being left would relabel every row for // the duration. currentBook={scrRef.book} + liveSurfaceText={liveSurfaceText} onClose={handleCatalogClose} showMorphology={showMorphology} sourceLanguageTag={writingSystem} diff --git a/src/components/MorphemeBox.tsx b/src/components/MorphemeBox.tsx index 7f0634a69..6453e7f0b 100644 --- a/src/components/MorphemeBox.tsx +++ b/src/components/MorphemeBox.tsx @@ -1,6 +1,14 @@ import type { MorphemeAnalysis, Token } from 'interlinearizer'; import { PopoverAnchor } from 'platform-bible-react'; -import { type MouseEvent, useEffect, useState } from 'react'; +import { + cloneElement, + isValidElement, + type CSSProperties, + type MouseEvent, + type ReactNode, + useEffect, + useState, +} from 'react'; import { useAnalysisReadOnly, useMorphemeGlossDispatch, @@ -9,6 +17,9 @@ import { import { TOKEN_CHIP_LABEL_KEYS, type TokenChipLabels } from './PhraseStripContext'; import { formatTemplate } from '../utils/format-template'; +/** The narrowest a morpheme column goes, below which its form and gloss stop being legible. */ +const MIN_MORPHEME_COLUMN = '4ch'; + /** * Inline _display_ of an analyzed token's morpheme breakdown. The popover where forms are actually * entered lives separately. @@ -28,19 +39,33 @@ import { formatTemplate } from '../utils/format-template'; * Renders the {@link PopoverAnchor} the editor popover is positioned from; the caller owns the * `Popover` root and the popover content. */ -export function MorphemeBox({ +function MorphemeBoxInner({ token, + surfaceText, morphemes, analysisLanguage, disabled, popoverOpen, onEditBreakdown, onGlossFocus, + renderGloss, + rowLabels, + readOnly, + morphemeTestId, + glossTestId, + noGlossLabel, + noBreakdownLabel, labels = TOKEN_CHIP_LABEL_KEYS, }: Readonly<{ - /** The analyzed word token whose breakdown is shown. */ - token: Token & { type: 'word' }; - /** The token's ordered morpheme breakdown; one grid column per entry. */ + /** + * The analyzed word token whose breakdown is shown, and whose ref each gloss commits against. + * Omitted where the breakdown belongs to an analysis rather than to one token, which supplies + * `surfaceText` and its own `renderGloss` instead. + */ + token?: Token & { type: 'word' }; + /** The word the breakdown splits, naming it in the edit control. Defaults to the token's. */ + surfaceText?: string; + /** The ordered morpheme breakdown; one grid column per entry. */ morphemes: readonly MorphemeAnalysis[]; /** BCP 47 tag for reading and writing each morpheme gloss. */ analysisLanguage: string; @@ -48,14 +73,37 @@ export function MorphemeBox({ disabled: boolean; /** When true, the editor popover is open; the box renders its active look. */ popoverOpen: boolean; - /** Called when a form cell is clicked (while enabled) to open the whole-breakdown editor. */ - onEditBreakdown: () => void; + /** + * Called when a form cell is clicked (while enabled) to open the whole-breakdown editor. A + * read-only box renders no such control, so it needs none. + */ + onEditBreakdown?: () => void; /** * Called when any morpheme gloss input receives focus, so the chip can report the token as * focused; these fields are gloss fields of the same token as the chip's own gloss input, so * focusing one must move the view's focus just as focusing that input does. */ - onGlossFocus: () => void; + onGlossFocus?: () => void; + /** + * Renders the gloss cell for one morpheme. Omitted, each gloss is a token-keyed input committing + * through the analysis store; supplying one commits against the analysis instead. + */ + renderGloss?: (morpheme: MorphemeAnalysis, index: number) => ReactNode; + /** Names the two rows. Omitted, neither is labeled. */ + rowLabels?: Readonly<{ forms: string; glosses: string }>; + /** Whether the breakdown is shown without the affordances that edit it. */ + readOnly: boolean; + /** `data-testid` marking each morpheme's form cell. */ + morphemeTestId?: string; + /** `data-testid` marking each column's gloss cell. */ + glossTestId?: string; + /** + * Stands in for a morpheme carrying no gloss, where the breakdown is read-only. An editable one + * shows a field there, which reads as waiting to be filled rather than as a gap. + */ + noGlossLabel?: string; + /** Shown where the analysis segments nothing. Omitted, nothing renders in that case. */ + noBreakdownLabel?: string; /** * Accessible labels for this box and its gloss inputs, resolved once per strip. Defaults to the * unresolved keys, which is what they show until the strip's lookup lands. @@ -67,112 +115,192 @@ export function MorphemeBox({ // on the container (rather than per cell) avoids a one-frame un-tint as the pointer crosses the // gap between adjacent form cells. const [isFormsHovered, setIsFormsHovered] = useState(false); - const readOnly = useAnalysisReadOnly(); - // Read-only renders the same grid without the editor affordance: no edit-breakdown control, - // no hover tint, and static gloss text under each form. const inert = disabled || readOnly; - const editLabel = formatTemplate(labels.editMorphemes, { token: token.surfaceText }); + /* v8 ignore next -- a token or a surface text is always supplied */ + const word = surfaceText ?? token?.surfaceText ?? ''; + const editLabel = formatTemplate(labels.editMorphemes, { token: word }); + + const formTestId = morphemeTestId ?? (readOnly ? 'readonly-morpheme-form' : 'morpheme-form'); + const cellGlossTestId = glossTestId ?? (readOnly ? 'readonly-morpheme-gloss' : 'morpheme-gloss'); + + if (morphemes.length === 0) + return noBreakdownLabel ? ( + + {noBreakdownLabel} + + ) : undefined; + + // Labels are the grid's first column, each sharing a row track with the cells it names so the two + // line up by construction. The token strip passes none and gets the bare box. + const labelTrack = rowLabels ? 'auto ' : ''; + const firstCell = rowLabels ? 2 : 1; + + /** One morpheme's form cell, which read-only renders as static text. */ + const formCell = (m: MorphemeAnalysis, i: number) => { + const placement = { gridColumn: firstCell + i, gridRow: 1 }; + if (readOnly) + return ( + + {m.form} + + ); + + const formClassName = `tw:flex tw:items-center tw:justify-center tw:whitespace-nowrap tw:rounded tw:px-0.5 tw:font-mono tw:text-xs tw:text-muted-foreground tw:transition-colors${inert ? '' : ' tw:cursor-pointer'}${isFormsHovered && !inert ? ' tw:bg-accent' : ''}`; + // preventDefault stops the ancestor label from forwarding the click to the gloss input, where + // the focus would dismiss the editor this same click opens. + const handleClick = (e: MouseEvent) => { + e.preventDefault(); + if (!inert) onEditBreakdown?.(); + }; + + // The first cell is the single accessible "edit breakdown" control (a real button); the rest + // share its click and hover behavior but carry no button semantics, so assistive tech sees one + // control for the whole breakdown. + if (i === 0) + return ( + + ); + + return ( + + ); + }; + + /** + * One morpheme's gloss cell: the caller's own where it owns the commit path, otherwise the + * token-keyed input that writes through the analysis store, or static text when read-only. + */ + const glossCell = (m: MorphemeAnalysis, i: number) => { + const placement = { gridColumn: firstCell + i, gridRow: 2 }; + if (renderGloss) { + const cell = renderGloss(m, i); + /* v8 ignore next -- every caller returns an element; the guard satisfies cloneElement's type */ + if (!isValidElement<{ style?: CSSProperties }>(cell)) return cell; + // Placed on the caller's own element rather than a wrapper: a wrapper between a cell and the + // grid takes the click first, reintroducing the label forwarding the form cells cancel. + return cloneElement(cell, { key: m.id, style: { ...cell.props.style, ...placement } }); + } + + if (readOnly) { + const gloss = m.gloss?.[analysisLanguage]; + return ( + + {gloss || noGlossLabel || ''} + + ); + } + + return ( + {})} + tokenRef={token?.ref ?? ''} + /> + ); + }; return (
setIsFormsHovered(true)} - onMouseLeave={() => setIsFormsHovered(false)} + onMouseEnter={readOnly ? undefined : () => setIsFormsHovered(true)} + onMouseLeave={readOnly ? undefined : () => setIsFormsHovered(false)} + style={{ + // The label column will not shrink, so without a floor the morpheme columns absorb the + // whole of a narrow container's shortfall. + gridTemplateColumns: `${labelTrack}repeat(${morphemes.length}, minmax(${MIN_MORPHEME_COLUMN}, auto))`, + }} > - {/* Forms row. The first cell is the single accessible "edit breakdown" control (a real - button); the rest are presentational form cells that share its click and hover behavior - but carry no button semantics, so assistive tech sees one control for the whole - breakdown. The cells share grid columns with the gloss inputs below so each form sits - directly above its gloss. */} - {morphemes.map((m, i) => { - const formClassName = `tw:flex tw:items-center tw:justify-center tw:whitespace-nowrap tw:rounded tw:px-0.5 tw:font-mono tw:text-xs tw:text-muted-foreground tw:transition-colors${inert ? '' : ' tw:cursor-pointer'}${isFormsHovered && !inert ? ' tw:bg-accent' : ''}`; - const formStyle = { gridColumn: i + 1, gridRow: 1 }; - // preventDefault stops the ancestor
); } +/** Props {@link MorphemeBox} takes, with the read-only state it may instead read from the store. */ +type MorphemeBoxProps = Omit[0], 'readOnly'> & + Readonly<{ readOnly?: boolean }>; + +/** The box with its read-only state taken from the analysis store. */ +function StoreMorphemeBox(props: Omit) { + return ; +} + +/** + * The breakdown box. Reads its read-only state from the analysis store unless the caller says, so + * it renders outside that store too. + */ +export function MorphemeBox({ readOnly, ...props }: MorphemeBoxProps) { + if (readOnly === undefined) return ; + return ; +} + /** * Renders a single morpheme's gloss as an editable input filling its grid column, directly under * the morpheme's form. Writes to the store on blur when the draft differs from the committed value. diff --git a/src/components/MorphemeEditor.tsx b/src/components/MorphemeEditor.tsx index 93bd14bef..8abc7d83b 100644 --- a/src/components/MorphemeEditor.tsx +++ b/src/components/MorphemeEditor.tsx @@ -1,7 +1,10 @@ import { useLocalizedStrings } from '@papi/frontend/react'; +import type { MorphemeAnalysis } from 'interlinearizer'; import { Button, Input, Label, PopoverContent } from 'platform-bible-react'; +import { formatReplacementString, type LanguageStrings } from 'platform-bible-utils'; import { useId, useRef, useState } from 'react'; import type { KeyboardEvent, MouseEvent } from 'react'; +import { morphemeFormsLostByResplit } from '../store/analysisSlice'; const POPOVER_STRING_KEYS = [ '%interlinearizer_morphemeEditor_splitLabel%', @@ -11,8 +14,39 @@ const POPOVER_STRING_KEYS = [ '%interlinearizer_morphemeEditor_emptyHint%', '%interlinearizer_morphemeEditor_confirmResetPrompt%', '%interlinearizer_morphemeEditor_confirmResetAction%', + '%interlinearizer_morphemeEditor_confirmResplitPrompt%', + '%interlinearizer_morphemeEditor_confirmResplitAction%', ] as const satisfies `%${string}%`[]; +/** The wording the panel renders, so an edit carrying different consequences can say so. */ +export type MorphemeEditorLabels = Readonly<{ + splitLabel: string; + reset: string; + cancel: string; + done: string; + emptyHint: string; + confirmResetPrompt: string; + confirmResetAction: string; + /** Takes a `{forms}` replacement naming the annotated forms the save would strand. */ + confirmResplitPrompt: string; + confirmResplitAction: string; +}>; + +/** The panel's own wording, for a caller that overrides none of it. */ +function defaultLabels(strings: LanguageStrings): MorphemeEditorLabels { + return { + splitLabel: strings['%interlinearizer_morphemeEditor_splitLabel%'], + reset: strings['%interlinearizer_morphemeEditor_reset%'], + cancel: strings['%interlinearizer_morphemeEditor_cancel%'], + done: strings['%interlinearizer_morphemeEditor_done%'], + emptyHint: strings['%interlinearizer_morphemeEditor_emptyHint%'], + confirmResetPrompt: strings['%interlinearizer_morphemeEditor_confirmResetPrompt%'], + confirmResetAction: strings['%interlinearizer_morphemeEditor_confirmResetAction%'], + confirmResplitPrompt: strings['%interlinearizer_morphemeEditor_confirmResplitPrompt%'], + confirmResplitAction: strings['%interlinearizer_morphemeEditor_confirmResplitAction%'], + }; +} + /** * Inline popover for defining or re-splitting a token's morpheme breakdown. This is the _editor_ * only; the inline display of a breakdown lives separately. @@ -37,29 +71,44 @@ const POPOVER_STRING_KEYS = [ * carry no offsets and need not reconstruct the surface text. * * Clicking Reset swaps the panel into a confirmation when `needsResetConfirm` says the reset would - * destroy glosses this token solely owns. The confirmation replaces the panel's own content rather - * than opening a second surface: the panel is portaled to `document.body`, so it floats over the - * token chip and cannot reflow it, and nesting a modal inside this already-modal popover would - * stack two focus traps. + * destroy annotation this token solely owns. A re-split that strands an annotated form confirms on + * the same terms, naming the forms it is about to drop, since losing some of a breakdown is as + * irreversible as losing all of it. The confirmation replaces the panel's own content rather than + * opening a second surface: the panel is portaled to `document.body`, so it floats over the token + * chip and cannot reflow it, and nesting a modal inside this already-modal popover would stack two + * focus traps. * * Renders the content of a `platform-bible-react` `Popover`; the caller owns the `Popover` root and * the `PopoverAnchor` the panel is positioned from, and must render this component only while the * popover is open so the draft state re-initializes from `initialValue` on every open. The popover * is modal, so interactions outside the panel are blocked while it is open. + * + * The draft is held here unless the caller passes `draft` and `onDraftChange`, which hand it up + * instead — for a caller that outlives this panel and must keep an unsaved re-segmentation across + * its own unmounting, since the draft commits on neither blur nor unmount. */ export function MorphemeBreakdownPopover({ initialValue, + draft: controlledDraft, + onDraftChange, onSave, onClose, onReset, needsResetConfirm = false, + morphemes = [], glossInputId, + labels, }: Readonly<{ /** * Pre-filled text for the input (current morpheme forms joined by spaces, or the full surface - * text when no breakdown exists yet). + * text when no breakdown exists yet). Ignored while the draft is controlled, the caller having + * seeded its own. */ initialValue: string; + /** The draft text, when the caller holds it. Omit to let the editor hold its own. */ + draft?: string; + /** Records an edit to a controlled draft. Required with `draft`, ignored without it. */ + onDraftChange?: (draft: string) => void; /** Called with the raw input string when the user commits. */ onSave: (value: string) => void; /** Called to dismiss the popover. */ @@ -72,22 +121,42 @@ export function MorphemeBreakdownPopover({ */ onReset?: () => void; /** - * Whether a reset would irreversibly discard morpheme glosses no other token still holds, in - * which case the Reset button confirms first. Ignored when `onReset` is absent, since there is - * then no breakdown to lose. + * Whether a reset would irreversibly discard morpheme glosses or lexicon references no other + * token still holds, in which case the Reset button confirms first. Ignored when `onReset` is + * absent, since there is then no breakdown to lose. */ needsResetConfirm?: boolean; + /** + * The token's current morphemes, which a re-split is weighed against to find the annotated forms + * it would strand. Pass them only when this token solely owns its payload; a shared payload is + * forked rather than re-segmented in place, so nothing it drops is lost project-wide and the + * default empty list correctly reports no loss. + */ + morphemes?: readonly MorphemeAnalysis[]; /** * Id of the token's gloss input; used to locate the chip on close so focus lands on its first * morpheme gloss field (falling back to the gloss input itself), rather than on the non-tabbable - * morpheme trigger. + * morpheme trigger. Omit where the panel opens from no such chip, leaving focus restoration to + * the popover's own default. */ - glossInputId: string; + glossInputId?: string; + /** + * Overrides for the panel's wording, for a caller whose edit means something other than + * re-splitting the one token in hand. Defaults to the panel's own. + */ + labels?: MorphemeEditorLabels; }>) { - const [localizedStrings] = useLocalizedStrings(POPOVER_STRING_KEYS); + const [defaultStrings] = useLocalizedStrings(POPOVER_STRING_KEYS); const inputId = useId(); - const [draft, setDraft] = useState(initialValue); - const [confirmingReset, setConfirmingReset] = useState(false); + const [uncontrolledDraft, setUncontrolledDraft] = useState(initialValue); + const draft = controlledDraft ?? uncontrolledDraft; + const setDraft = (value: string) => { + if (onDraftChange) onDraftChange(value); + else setUncontrolledDraft(value); + }; + const localizedStrings = labels ?? defaultLabels(defaultStrings); + /** Which loss the reader is being asked to confirm, or `undefined` while the editor is showing. */ + const [confirming, setConfirming] = useState<'reset' | 'resplit' | undefined>(undefined); // Whether the panel is closing because the user pressed the pointer outside it, so the close // handling can leave focus where that press put it instead of pulling it back to this chip. const dismissedByOutsidePointerRef = useRef(false); @@ -114,21 +183,31 @@ export function MorphemeBreakdownPopover({ /** * Removes the breakdown and closes, or swaps the panel into the confirmation first when the reset - * would discard glosses no other token holds. + * would discard annotation no other token holds. */ const requestReset = () => { if (needsResetConfirm) { - setConfirmingReset(true); + setConfirming('reset'); return; } onReset?.(); onClose(); }; + // The annotated forms this draft would strand. Empty for a shared payload, whose morphemes are + // withheld, because the write forks it rather than re-segmenting what the others read. + const lostForms = morphemeFormsLostByResplit(morphemes, forms); + + const writeResplit = () => { + onSave(draft.trim()); + onClose(); + }; + /** * Resolves the current draft: an empty draft does nothing, an unedited draft over an existing * breakdown dismisses without rewriting it, and anything else saves — including an unedited - * pre-fill when there is no existing breakdown to leave unchanged. + * pre-fill when there is no existing breakdown to leave unchanged, and first confirming when the + * save would strand an annotated form. */ const handleSave = () => { if (isEmpty) return; @@ -136,8 +215,11 @@ export function MorphemeBreakdownPopover({ onClose(); return; } - onSave(draft.trim()); - onClose(); + if (lostForms.length > 0) { + setConfirming('resplit'); + return; + } + writeResplit(); }; /** Handles Enter to commit. Escape is handled by the popover itself (`onEscapeKeyDown`). */ @@ -153,9 +235,9 @@ export function MorphemeBreakdownPopover({ * was not edited — then the interaction acts like Cancel, because an accidental outside click is * not a deliberate commit. An empty draft is likewise dismissed without writing: the commit path * refuses to interpret it and would otherwise leave the panel open, but an outside click on a - * modal popover must always dismiss. While the reset confirmation is showing, an outside click - * dismisses it without resetting: the confirmation exists precisely because the loss is - * irreversible, so it must not be answered by a stray click. + * modal popover must always dismiss. While either confirmation is showing, an outside click + * dismisses it without writing: a confirmation exists precisely because the loss is irreversible, + * so it must not be answered by a stray click. * * Wired to `onPointerDownOutside` rather than the broader `onInteractOutside`, which also fires * when focus merely moves outside the panel. A modal popover is not supposed to dismiss on that @@ -165,7 +247,7 @@ export function MorphemeBreakdownPopover({ */ const handlePointerDownOutside = () => { dismissedByOutsidePointerRef.current = true; - if (confirmingReset || isUnedited || isEmpty) { + if (confirming || isUnedited || isEmpty) { onClose(); return; } @@ -195,8 +277,12 @@ export function MorphemeBreakdownPopover({ * where the user aimed it (typically another token), so pulling focus back to this chip would * yank it out of whatever they just clicked. The default is still prevented, so Radix does not * restore focus to the pre-open element either — the click's own focus stands. + * + * With no gloss input named there is no chip to land in, so the popover's own focus restoration + * is left alone. */ const handleCloseAutoFocus = (e: Event) => { + if (glossInputId === undefined) return; e.preventDefault(); if (dismissedByOutsidePointerRef.current) return; const glossInput = document.getElementById(glossInputId); @@ -217,42 +303,65 @@ export function MorphemeBreakdownPopover({ onPointerDownOutside={handlePointerDownOutside} onMouseDown={stopMouseEvents} > - {confirmingReset ? ( + {confirming ? ( <> -

- {localizedStrings['%interlinearizer_morphemeEditor_confirmResetPrompt%']} + {/* Capped so the prompt wraps: the popover sizes to its content, and an unbounded + sentence widens it past the panel. */} +

+ {confirming === 'reset' + ? localizedStrings.confirmResetPrompt + : formatReplacementString(localizedStrings.confirmResplitPrompt, { + forms: lostForms.join(', '), + })}

) : ( <> setDraft(e.target.value)} @@ -261,26 +370,39 @@ export function MorphemeBreakdownPopover({ /> {isEmpty && (

- {localizedStrings['%interlinearizer_morphemeEditor_emptyHint%']} + {localizedStrings.emptyHint}

)}
{onReset && ( )} - -
diff --git a/src/components/TokenChip.tsx b/src/components/TokenChip.tsx index 25250df99..01c87483d 100644 --- a/src/components/TokenChip.tsx +++ b/src/components/TokenChip.tsx @@ -29,7 +29,8 @@ import { useGlossDispatch, useMorphemeBreakdownDispatch, useMorphemeDeleteDispatch, - useMorphemeResetLosesGlosses, + useMorphemePayloadIsSolelyOwned, + useMorphemeResetLosesAnnotation, useMorphemes, useReportGlossEditing, useResolvedTokenAnalysis, @@ -114,7 +115,8 @@ export function TokenChip({ const analysisLanguage = useAnalysisLanguage(); const dispatchMorphemeBreakdown = useMorphemeBreakdownDispatch(); const dispatchMorphemeDelete = useMorphemeDeleteDispatch(); - const resetLosesGlosses = useMorphemeResetLosesGlosses(token.ref); + const resetLosesAnnotation = useMorphemeResetLosesAnnotation(token.ref); + const payloadIsSolelyOwned = useMorphemePayloadIsSolelyOwned(token.ref); const showSuggestions = useShowSuggestions(); const readOnly = useAnalysisReadOnly(); // Only resolve the pool when suggestions are actually shown; off (or read-only, which never @@ -491,7 +493,11 @@ export function TokenChip({ initialValue={ hasMorphemes ? morphemes.map((m) => m.form).join(' ') : token.surfaceText } - needsResetConfirm={resetLosesGlosses} + // Withheld for a shared payload, which the write forks rather than re-segmenting in + // place, so a form this token drops survives on the tokens still reading the + // original and there is nothing to confirm. + morphemes={payloadIsSolelyOwned ? morphemes : undefined} + needsResetConfirm={resetLosesAnnotation} onClose={() => setPopoverOpen(false)} onReset={hasMorphemes ? () => dispatchMorphemeDelete(token.ref) : undefined} onSave={handleMorphemeSave} diff --git a/src/components/__mocks__/AnalysisStore.tsx b/src/components/__mocks__/AnalysisStore.tsx index 492f63dbc..13ea8c4d8 100644 --- a/src/components/__mocks__/AnalysisStore.tsx +++ b/src/components/__mocks__/AnalysisStore.tsx @@ -112,10 +112,18 @@ export function useMorphemeDeleteDispatch(): (tokenRef: string) => void { } /** - * Reports that a morpheme reset never loses glosses in mock context, so the editor takes its + * Reports that a morpheme reset never loses annotation in mock context, so the editor takes its * unconfirmed path by default. Tests covering the confirm step mock this module member directly. */ -export function useMorphemeResetLosesGlosses(): boolean { +export function useMorphemeResetLosesAnnotation(): boolean { + return false; +} + +/** + * Reports the payload as shared in mock context, so a breakdown edit takes its unconfirmed path by + * default. Tests covering the confirm step mock this module member directly. + */ +export function useMorphemePayloadIsSolelyOwned(): boolean { return false; } diff --git a/src/components/modals/ModalShell.tsx b/src/components/modals/ModalShell.tsx index fac5d5f2e..d96d4eea0 100644 --- a/src/components/modals/ModalShell.tsx +++ b/src/components/modals/ModalShell.tsx @@ -1,5 +1,10 @@ -import { Dialog, DialogContent, DialogTitle } from 'platform-bible-react'; -import type { ReactNode } from 'react'; +import { + Dialog, + DialogContent, + DialogTitle, + PopoverPortalContainerProvider, +} from 'platform-bible-react'; +import { useState, type ReactNode } from 'react'; /** * Shared chrome for the project modals: the platform dialog surface, its accessibility wiring, and @@ -8,6 +13,10 @@ import type { ReactNode } from 'react'; * The platform dialog supplies a focus trap, scroll lock, and focus restore on close, so a modal * genuinely blocks the view behind it rather than merely covering it. * + * A popover opened inside a modal portals into the dialog rather than to `document.body`, where the + * focus trap would pull focus straight back out of it and the dismiss layer would read a click in + * it as a click outside the modal. + * * @param props.titleTestId - Test id tagged onto the title heading, which is how a modal is * identified from outside. The heading's `id` belongs to the platform dialog, which generates it * and points its own `aria-labelledby` there. @@ -16,24 +25,32 @@ import type { ReactNode } from 'react'; * @param props.onClose - Called when the user dismisses the modal, by pressing Escape or by * clicking outside it; a caller with an inline confirmation of its own may instead back out of * that first. Omit to make the modal undismissable, which callers do while a submission is in - * flight so neither route can abandon work the user has already committed to. + * flight so neither route can abandon work the user has already committed to. An outside click is + * withheld while a popover is open inside the modal. * @param props.children - Modal body content rendered below the title. Omitted while a modal is * still resolving its localized content, so the blocking overlay can show before the body * exists. + * @param props.titleAdornment - Rendered inline after the title text, for a control that qualifies + * the whole modal rather than any one field in it. */ export function ModalShell({ titleTestId, title, + titleAdornment, width, onClose, children, }: Readonly<{ titleTestId: string; title: string; + titleAdornment?: ReactNode; width: string; onClose?: () => void; children?: ReactNode; }>) { + // eslint-disable-next-line no-null/no-null -- the platform's container prop takes null until the dialog mounts + const [dialogEl, setDialogEl] = useState(null); + return ( event.preventDefault()} + // reason. An idle modal has nothing to abandon, so the click dismisses it as Escape does, + // except while a popover is open inside it — that press belongs to the popover. + onInteractOutside={(event) => { + if (!onClose || dialogEl?.querySelector('[data-slot="popover-content"]')) + event.preventDefault(); + }} + ref={setDialogEl} showCloseButton={false} > - - {title} - - {children} + + + {title} + {titleAdornment} + + {children} + ); diff --git a/src/hooks/useRowWindow.ts b/src/hooks/useRowWindow.ts index ee1bbca53..0eb44ccf3 100644 --- a/src/hooks/useRowWindow.ts +++ b/src/hooks/useRowWindow.ts @@ -39,11 +39,17 @@ export interface UseRowWindowResult { * compared by reference: a reader who has scrolled deep into one listing and then narrows it is * looking at a new list, not further down the old one. A caller passes everything that decides * which listing it is showing, and one whose listing never changes passes nothing. Keyed on that - * rather than on `rows`, which turns over on any edit to the underlying analysis as well. + * rather than on `rows`, which turns over on any edit to the underlying analysis as well — a gloss + * approved in the view beside an open catalog would otherwise collapse a deeply scrolled list back + * to its first chunk. + * + * A caller with a row it must be able to point the reader at passes that row's index as + * `mustMount`, which the window covers however far down the listing it falls. */ export default function useRowWindow( rows: readonly T[], listing?: unknown, + mustMount?: number, ): UseRowWindowResult { const [count, setCount] = useState(INITIAL_ROW_COUNT); @@ -91,7 +97,13 @@ export default function useRowWindow( return () => observer.disconnect(); }, [scrollEl, sentinelEl, count, rows.length]); - const windowRows = useMemo(() => rows.slice(0, count), [rows, count]); + // Applied over the count rather than into it, so the window still shrinks back to its first chunk + // when the listing changes: a row held mounted for one notice must not raise the floor for the + // listing after it. + const windowRows = useMemo( + () => rows.slice(0, mustMount === undefined ? count : Math.max(count, mustMount + 1)), + [rows, count, mustMount], + ); return { windowRows, scrollRef, sentinelRef }; } diff --git a/src/store/analysisSlice.ts b/src/store/analysisSlice.ts index 42cbeaf27..968098790 100644 --- a/src/store/analysisSlice.ts +++ b/src/store/analysisSlice.ts @@ -1,5 +1,6 @@ import { createSelector, createSlice, current, type PayloadAction } from '@reduxjs/toolkit'; import type { + Confidence, MorphemeAnalysis, PhraseAnalysis, PhraseAnalysisLink, @@ -11,7 +12,12 @@ import type { TokenSnapshot, } from 'interlinearizer'; import { emptyAnalysis } from '../types/empty-factories'; -import { analysesAreIdentical } from '../utils/analysis-identity'; +import { + analysesAreIdentical, + morphemeCarriesAnnotation, + normalizeSurfaceForm, + reconcileMorphemes, +} from '../utils/analysis-identity'; import { buildCatalogRows } from '../utils/analysis-query'; import { isEmptyMultiString } from '../utils/multi-string'; import { @@ -28,6 +34,12 @@ export type AnalysisState = { analysis: TextAnalysis; /** BCP 47 tag identifying the language used when reading and writing gloss values. */ analysisLanguage: string; + /** + * The record the last write's collapse left standing. Reports a collapse the state cannot show: a + * record holding no links — how an imported wordform inventory arrives — repoints nothing when it + * collapses, leaving what its removal would leave. Never reaches storage. + */ + lastCollapseSurvivorId?: string; }; /** Payload for the {@link writeGloss} action, extended with a pre-generated UUID. */ @@ -331,6 +343,37 @@ function forkSharedAnalysis( return state.analysis.tokenAnalyses[state.analysis.tokenAnalyses.length - 1]; } +/** + * Leaves a token holding one link to `analysisId` where it held several, keeping the link the read + * selectors surface and approving it if any it supersedes was, so the "at most one approved link + * per token" invariant holds across the collapse. + * + * The survivor keeps the token's first annotation date and, where it is raised to approved, the + * superseded approval's `confidence` — so a collapse retires a link without retiring what it + * recorded. + */ +function coalesceLinksPerToken(state: AnalysisState, analysisId: string, now: string): void { + const survivorByToken = new Map(); + state.analysis.tokenAnalysisLinks.forEach((l) => { + if (l.analysisId !== analysisId) return; + const superseded = survivorByToken.get(l.token.tokenRef); + if (superseded) { + if (superseded.createdAt < l.createdAt) l.createdAt = superseded.createdAt; + if (superseded.status === 'approved' && l.status !== 'approved') { + l.status = 'approved'; + l.updatedAt = now; + if (superseded.confidence === undefined) delete l.confidence; + else l.confidence = superseded.confidence; + } + } + survivorByToken.set(l.token.tokenRef, l); + }); + const survivors = new Set(survivorByToken.values()); + state.analysis.tokenAnalysisLinks = state.analysis.tokenAnalysisLinks.filter( + (l) => l.analysisId !== analysisId || survivors.has(l), + ); +} + /** * Re-converges a just-edited payload onto an existing content-identical one, so an in-place edit * can never leave two identical payloads the way the create path's find-or-create prevents on first @@ -339,18 +382,137 @@ function forkSharedAnalysis( * homograph instance that was edited to match a sibling back onto one shared payload (frequency * re-merged, no duplicate suggestion). A no-op when the edit left the payload unique. * + * A token that linked both payloads is left holding one link, the two having come to name the same + * record, dated by the earlier of the two. + * * The surviving payload keeps its own timestamps and the repointed links keep theirs: no write was * aimed at the survivor or at any token's annotation, only at which record holds the content. + * + * `settleProvenance` moves that boundary for a write that chose the survivor's confidence, which + * identity excludes and the collapse would otherwise leave saying whatever it already said. An edit + * that converged incidentally chose nothing and leaves it alone. + * + * Leaves the survivor in {@link AnalysisState.lastCollapseSurvivorId}. */ -function mergeIntoIdenticalPayload(state: AnalysisState, analysis: TokenAnalysis): void { +function mergeIntoIdenticalPayload( + state: AnalysisState, + analysis: TokenAnalysis, + now: string, + settleProvenance = false, +): void { const other = state.analysis.tokenAnalyses.find( (ta) => ta !== analysis && analysesAreIdentical(ta, analysis), ); if (!other) return; + if (settleProvenance) { + if (analysis.confidence === undefined) delete other.confidence; + else other.confidence = analysis.confidence; + other.updatedAt = now; + } state.analysis.tokenAnalysisLinks.forEach((l) => { if (l.analysisId === analysis.id) l.analysisId = other.id; }); + coalesceLinksPerToken(state, other.id, now); state.analysis.tokenAnalyses = state.analysis.tokenAnalyses.filter((ta) => ta !== analysis); + state.lastCollapseSurvivorId = other.id; +} + +/** + * What a merge settles the surviving analysis says. Every field is written as given and absence + * clears what the record held, a missing value being one the merge decided against rather than one + * it had nothing to say about. + */ +export interface MergedContent { + /** Gloss in the store's analysis language, blank clearing it. */ + gloss: string; + /** + * Analysis `gloss` was taken from, whose sense the survivor keeps resolving it through. Absent + * when the reader typed the gloss, which leaves the survivor no sense. + */ + glossFromAnalysisId?: string; + morphemes: readonly MorphemeAnalysis[]; + pos?: string; + features?: Readonly>; + confidence?: Confidence; +} + +/** + * Carries the glosses outside `lang` off the donors onto the survivor, which keeps its own wherever + * it has them, and settles the sense the survivor's gloss resolves through. + * + * @param survivor Mutated in place. + * @param donors The records being dropped, most-preferred first. + * @param lang BCP 47 tag the merge was conducted in, the one gloss this leaves alone. + * @param glossFrom The analysis the settled gloss came from, whose sense the survivor takes; + * `undefined` for a gloss answering to no record, which leaves the survivor without one. + */ +function carryOverUnsettledContent( + survivor: TokenAnalysis, + donors: readonly TokenAnalysis[], + lang: string, + glossFrom: TokenAnalysis | undefined, +): void { + survivor.glossSenseRef = glossFrom?.glossSenseRef; + + donors.forEach((donor) => { + Object.entries(donor.gloss ?? {}).forEach(([tag, gloss]) => { + if (tag === lang) return; + if (!survivor.gloss) survivor.gloss = {}; + if (survivor.gloss[tag] === undefined) survivor.gloss[tag] = gloss; + }); + }); +} + +/** + * Copies a merged morpheme, leaving it unglossed in `lang` where the gloss holds nothing that + * renders — analysis identity must not see a difference the reader cannot. + */ +function copyMergedMorpheme(morpheme: MorphemeAnalysis, lang: string): MorphemeAnalysis { + const copy = { ...morpheme }; + const gloss = copy.gloss?.[lang]; + if (gloss !== undefined && gloss.trim() === '') { + copy.gloss = { ...copy.gloss }; + delete copy.gloss[lang]; + if (Object.keys(copy.gloss).length === 0) delete copy.gloss; + } + return copy; +} + +/** Writes merged content onto an analysis, clearing each field the merge settled on nothing for. */ +function applyMergedContent(analysis: TokenAnalysis, content: MergedContent, lang: string): void { + if (content.gloss.trim() === '') { + if (analysis.gloss) { + delete analysis.gloss[lang]; + if (Object.keys(analysis.gloss).length === 0) delete analysis.gloss; + } + } else { + if (!analysis.gloss) analysis.gloss = {}; + analysis.gloss[lang] = content.gloss; + } + + if (content.morphemes.length === 0) delete analysis.morphemes; + else analysis.morphemes = content.morphemes.map((m) => copyMergedMorpheme(m, lang)); + + if (content.pos === undefined) delete analysis.pos; + else analysis.pos = content.pos; + + if (content.features === undefined) delete analysis.features; + else analysis.features = { ...content.features }; + + if (content.confidence === undefined) delete analysis.confidence; + else analysis.confidence = content.confidence; +} + +/** + * Drops a `TokenAnalysis` and every link pointing at it, addressed by id alone — so the record goes + * on its own terms and takes every token with it, rather than being retired as one token lets go of + * it. A no-op when the id resolves to no payload. + */ +function removeAnalysisAndLinks(state: AnalysisState, analysisId: string): void { + state.analysis.tokenAnalyses = state.analysis.tokenAnalyses.filter((ta) => ta.id !== analysisId); + state.analysis.tokenAnalysisLinks = state.analysis.tokenAnalysisLinks.filter( + (l) => l.analysisId !== analysisId, + ); } /** @@ -380,6 +542,47 @@ function isEmptyTokenAnalysis(analysis: TokenAnalysis): boolean { ); } +/** + * Empties one language's gloss, dropping the gloss and the analysis-wide `glossSenseRef` once no + * language is left holding usable text — a sibling left holding only whitespace counts for neither, + * so the sense is never stranded on a gloss nothing renders. + */ +function clearAnalysisGloss(analysis: TokenAnalysis, lang: string): void { + if (analysis.gloss) { + delete analysis.gloss[lang]; + if (isEmptyMultiString(analysis.gloss)) delete analysis.gloss; + } + if (!analysis.gloss) delete analysis.glossSenseRef; +} + +/** + * The annotated forms a re-split to `forms` would strand: those whose morpheme carries a gloss or a + * lexicon reference and which the new breakdown leaves no morpheme to hold, in the order the old + * breakdown listed them. Empty when the re-split keeps every annotated form, which is the common + * case. + * + * Forms are matched as a re-split itself matches them — by form, first-come-first-served within a + * repeated form — so the answer can never disagree with what the write goes on to drop. A form is + * counted once per occurrence: re-splitting "ba ba" to a single "ba" strands the second. + * + * Bare forms are left out. Losing one costs only the segmentation, which the reader is retyping + * anyway, and prompting about it would train them to click through the prompt that does carry a + * loss. + */ +export function morphemeFormsLostByResplit( + old: readonly MorphemeAnalysis[] | undefined, + forms: readonly string[], +): string[] { + const remaining = new Map(); + forms.forEach((form) => remaining.set(form, (remaining.get(form) ?? 0) + 1)); + return (old ?? []).reduce((lost, morpheme) => { + const spare = remaining.get(morpheme.form) ?? 0; + if (spare > 0) remaining.set(morpheme.form, spare - 1); + else if (morphemeCarriesAnnotation(morpheme)) lost.push(morpheme.form); + return lost; + }, []); +} + const analysisSlice = createSlice({ name: 'analysis', initialState: defaultState, @@ -414,6 +617,10 @@ const analysisSlice = createSlice({ * just as an edit does, so the co-linked tokens keep the shared gloss rather than being * stranded on an emptied payload. A blank write to a token with no approved analysis is a * no-op, so a focus/blur cycle on an empty gloss never creates a record. + * + * A gloss the reader typed resolves through no lexicon sense of theirs, so a rewrite drops + * the payload's `glossSenseRef`. A clear drops it only once no language still holds usable + * gloss text, the sense belonging to the analysis rather than to the emptied language. */ reducer(state, action: PayloadAction) { const { tokenRef, surfaceText, value, id, now } = action.payload; @@ -437,24 +644,22 @@ const analysisSlice = createSlice({ link.token.surfaceText = surfaceText; link.updatedAt = now; if (isBlank) { - if (target.gloss) { - delete target.gloss[lang]; - if (Object.keys(target.gloss).length === 0) delete target.gloss; - } + clearAnalysisGloss(target, lang); // When the clear empties the analysis, detach it; otherwise the cleared payload (e.g. one // left holding only morphemes) can be identical to an existing sibling, so re-converge — // mirroring writeMorphemeGloss's clear path so a clear never leaves a duplicate the // suggestion pool would double-count. if (isEmptyTokenAnalysis(target)) detachTokenAnalysisLink(state, target, link); - else mergeIntoIdenticalPayload(state, target); + else mergeIntoIdenticalPayload(state, target, now); return; } if (!target.gloss) target.gloss = {}; target.gloss[lang] = value; + delete target.glossSenseRef; // An in-place edit can make this payload identical to an existing one (e.g. a homograph // instance re-glossed to match its sibling); re-converge so the dedupe the create path // guarantees on first write also holds after edits. - mergeIntoIdenticalPayload(state, target); + mergeIntoIdenticalPayload(state, target, now); return; } @@ -529,25 +734,11 @@ const analysisSlice = createSlice({ target.updatedAt = now; link.token.surfaceText = surfaceText; link.updatedAt = now; - // Multimap with consumed entries so duplicate forms (e.g. reduplication "ba ba") each - // match a distinct old morpheme in order, instead of all inheriting the last one. - const oldByForm = new Map(); - (target.morphemes ?? []).forEach((m) => { - const bucket = oldByForm.get(m.form); - if (bucket) bucket.push(m); - else oldByForm.set(m.form, [m]); - }); - target.morphemes = morphemes.map(({ id, form }) => { - const old = oldByForm.get(form)?.shift(); - // Keep the preserved morpheme's id (the prepared id is discarded) so external - // references to it stay valid; only the writing system is refreshed. - if (old) return { ...old, writingSystem }; - return { id, form, writingSystem }; - }); + target.morphemes = reconcileMorphemes(target.morphemes, morphemes, writingSystem); // An in-place breakdown edit can make this payload identical to an existing one (e.g. a // homograph re-segmented to match a sibling); re-converge so the dedupe the create path // guarantees on first write also holds after morpheme edits (mirrors writeGloss). - mergeIntoIdenticalPayload(state, target); + mergeIntoIdenticalPayload(state, target, now); return; } @@ -601,7 +792,7 @@ const analysisSlice = createSlice({ } // Removing the breakdown can leave this payload identical to an existing one; re-converge so // dedupe holds after morphology-only edits, the same way writeGloss does after a gloss edit. - mergeIntoIdenticalPayload(state, target); + mergeIntoIdenticalPayload(state, target, now); }, }, /** @@ -674,7 +865,250 @@ const analysisSlice = createSlice({ // A morpheme gloss is part of analysis identity (see analysesAreIdentical), so editing or // clearing one can make this payload identical to an existing one (e.g. a homograph whose // only difference was this morpheme's gloss); re-converge so dedupe holds after edits too. - mergeIntoIdenticalPayload(state, target); + mergeIntoIdenticalPayload(state, target, now); + }, + }, + // The reducers below are keyed by `analysisId` rather than `tokenRef`, and the key is the whole + // of the scope distinction: a `tokenRef` edit changes what one token means and forks a shared + // payload to do it, an `analysisId` edit changes what the record says everywhere. Neither + // family takes a scope flag, because the address the caller can supply already says which act + // it is. + /** + * Writes a gloss onto a `TokenAnalysis` addressed by its own id, changing what that record says + * for every token linked to it. + * + * A blank `value` clears the active language's gloss, and an edit that empties the record + * removes it and every link to it. An edit that makes the record identical to a sibling + * collapses it into that sibling, so the edited row disappears from the catalog. + * + * A gloss the reader typed resolves through no lexicon sense of theirs, so a rewrite drops the + * record's `glossSenseRef`. A clear drops it only once no language still holds usable gloss + * text, the sense belonging to the analysis rather than to the emptied language. + */ + writeAnalysisGloss: { + /** Reads the clock before the action reaches the reducer, keeping the reducer pure. */ + prepare(arg: { analysisId: string; value: string }) { + return { payload: { ...arg, now: nowIso() } }; + }, + reducer(state, action: PayloadAction<{ analysisId: string; value: string; now: string }>) { + const { analysisId, value, now } = action.payload; + const lang = state.analysisLanguage; + + const analysis = state.analysis.tokenAnalyses.find((ta) => ta.id === analysisId); + if (!analysis) return; + state.lastCollapseSurvivorId = undefined; + + if (value.trim() === '') { + clearAnalysisGloss(analysis, lang); + } else { + if (!analysis.gloss) analysis.gloss = {}; + analysis.gloss[lang] = value; + delete analysis.glossSenseRef; + } + analysis.updatedAt = now; + + // Removed outright rather than left as an empty payload the pool would still carry. + if (isEmptyTokenAnalysis(analysis)) { + removeAnalysisAndLinks(state, analysisId); + return; + } + mergeIntoIdenticalPayload(state, analysis, now); + }, + }, + /** + * Re-segments the morpheme breakdown on a `TokenAnalysis` addressed by its own id, for every + * token linked to it, so one correction fixes a mis-split word across all its occurrences. + * + * A form the breakdown already carried keeps its morpheme whole — its id, so + * `MorphemeLink.morphemeId` stays valid, along with its gloss and lexicon references — while a + * form with no counterpart is minted fresh. A re-split that drops a form drops what it carried + * with it, there being no morpheme left to hold it. An empty `forms` removes the breakdown, and + * removes the record when nothing else remains on it. + */ + writeAnalysisMorphemes: { + /** + * Mints an id per form and reads the clock before the action reaches the reducer, keeping the + * reducer pure. Only a form the breakdown cannot already account for spends the id offered + * for it. + */ + prepare(arg: { analysisId: string; forms: readonly string[]; writingSystem: string }) { + return { + payload: { + analysisId: arg.analysisId, + writingSystem: arg.writingSystem, + morphemes: arg.forms.map((form) => ({ id: crypto.randomUUID(), form })), + now: nowIso(), + }, + }; + }, + reducer( + state, + action: PayloadAction<{ + analysisId: string; + writingSystem: string; + morphemes: readonly { id: string; form: string }[]; + now: string; + }>, + ) { + const { analysisId, writingSystem, morphemes, now } = action.payload; + + const analysis = state.analysis.tokenAnalyses.find((ta) => ta.id === analysisId); + if (!analysis) return; + state.lastCollapseSurvivorId = undefined; + + if (morphemes.length === 0) delete analysis.morphemes; + else analysis.morphemes = reconcileMorphemes(analysis.morphemes, morphemes, writingSystem); + analysis.updatedAt = now; + + if (isEmptyTokenAnalysis(analysis)) { + removeAnalysisAndLinks(state, analysisId); + return; + } + mergeIntoIdenticalPayload(state, analysis, now); + }, + }, + /** + * Writes a gloss onto one morpheme of a `TokenAnalysis` addressed by its own id, for every + * token linked to it. Clearing the gloss keeps the morpheme, a breakdown being content in its + * own right, so this never empties the enclosing record. + */ + writeAnalysisMorphemeGloss: { + /** Reads the clock before the action reaches the reducer, keeping the reducer pure. */ + prepare(arg: { analysisId: string; morphemeId: string; value: string }) { + return { payload: { ...arg, now: nowIso() } }; + }, + reducer( + state, + action: PayloadAction<{ + analysisId: string; + morphemeId: string; + value: string; + now: string; + }>, + ) { + const { analysisId, morphemeId, value, now } = action.payload; + const lang = state.analysisLanguage; + + const analysis = state.analysis.tokenAnalyses.find((ta) => ta.id === analysisId); + const morpheme = analysis?.morphemes?.find((m) => m.id === morphemeId); + if (!analysis || !morpheme) return; + state.lastCollapseSurvivorId = undefined; + + if (value.trim() === '') { + if (morpheme.gloss) { + delete morpheme.gloss[lang]; + if (Object.keys(morpheme.gloss).length === 0) delete morpheme.gloss; + } + } else { + if (!morpheme.gloss) morpheme.gloss = {}; + morpheme.gloss[lang] = value; + } + analysis.updatedAt = now; + // A morpheme gloss is part of analysis identity, so this edit can collapse onto a sibling. + mergeIntoIdenticalPayload(state, analysis, now); + }, + }, + /** + * Removes a `TokenAnalysis` and every link to it. Its tokens fall back to whatever the + * suggestion pool still offers for their surface form — a surviving homograph, or nothing, in + * which case they read as blank; {@link selectAnalysisDeletionOutcome} reports which. + * + * Irreversible, and the only reducer that drops a record the user never emptied. + */ + deleteAnalysis(state, action: PayloadAction<{ analysisId: string }>) { + removeAnalysisAndLinks(state, action.payload.analysisId); + }, + /** + * Folds several `TokenAnalysis` records into one and writes the content they agreed on onto it, + * so a reader consolidating a form's homographs settles what the survivor says in the same + * stroke that gathers the tokens onto it. + * + * Settling the content and gathering the links is indivisible: no state is reachable in which + * the survivor has been rewritten but the records it is absorbing still hold their tokens. + * + * The survivor is stamped, content having been written to it. A merged id that resolves to no + * payload is skipped, and one naming the survivor is ignored rather than dropping the record + * the merge is keeping. No-ops entirely when the survivor resolves to no payload, there being + * nothing to write onto. + * + * Where a token held links to both a merged record and the survivor it is left holding one, + * approved if either was and keeping that approval's `confidence` and the earlier `createdAt`. + * A survivor whose settled content matches a record the merge did not fold in collapses onto + * it, so consolidating can never leave two payloads saying the same thing. + * + * Glosses in languages besides the one the merge was conducted in are carried off the records + * being dropped rather than going with them, the survivor's own standing where it holds them. + * `mergedAnalysisIds` ranks the donors most-preferred first, deciding which of them a carried + * value comes from. The sense reference instead follows the settled gloss, resting with + * whichever record supplied it and clearing for a gloss the reader typed. + * + * A merge settling on no content at all takes the survivor with it, releasing every gathered + * token to the suggestion pool rather than leaving them approved against a blank record. A + * survivor left holding only carried-over content is content enough to keep. + */ + mergeAnalysesInto: { + /** Reads the clock before the action reaches the reducer, keeping the reducer pure. */ + prepare(arg: { + survivorAnalysisId: string; + mergedAnalysisIds: readonly string[]; + content: MergedContent; + }) { + return { payload: { ...arg, now: nowIso() } }; + }, + reducer( + state, + action: PayloadAction<{ + survivorAnalysisId: string; + mergedAnalysisIds: readonly string[]; + content: MergedContent; + now: string; + }>, + ) { + const { survivorAnalysisId, mergedAnalysisIds, content, now } = action.payload; + const survivor = state.analysis.tokenAnalyses.find((ta) => ta.id === survivorAnalysisId); + if (!survivor) return; + state.lastCollapseSurvivorId = undefined; + + const merged = new Set( + mergedAnalysisIds.filter( + (id) => + id !== survivorAnalysisId && state.analysis.tokenAnalyses.some((ta) => ta.id === id), + ), + ); + + // Resolved while the donors are still standing. + const donors = [...merged] + .map((id) => state.analysis.tokenAnalyses.find((ta) => ta.id === id)) + .filter((ta) => ta !== undefined); + + const glossFrom = + content.glossFromAnalysisId === undefined + ? undefined + : state.analysis.tokenAnalyses.find((ta) => ta.id === content.glossFromAnalysisId); + + applyMergedContent(survivor, content, state.analysisLanguage); + carryOverUnsettledContent(survivor, donors, state.analysisLanguage, glossFrom); + survivor.updatedAt = now; + + state.analysis.tokenAnalysisLinks.forEach((l) => { + if (merged.has(l.analysisId)) { + l.analysisId = survivorAnalysisId; + l.updatedAt = now; + } + }); + coalesceLinksPerToken(state, survivorAnalysisId, now); + state.analysis.tokenAnalyses = state.analysis.tokenAnalyses.filter( + (ta) => !merged.has(ta.id), + ); + + // Merged away to nothing, the record goes rather than holding every gathered token at a + // blank approval, which would render as no gloss and block the pool from offering one. + if (isEmptyTokenAnalysis(survivor)) { + removeAnalysisAndLinks(state, survivorAnalysisId); + return; + } + + mergeIntoIdenticalPayload(state, survivor, now, true); }, }, /** @@ -941,6 +1375,11 @@ export const { writeMorphemes, deleteMorphemes, writeMorphemeGloss, + writeAnalysisGloss, + writeAnalysisMorphemes, + writeAnalysisMorphemeGloss, + deleteAnalysis, + mergeAnalysesInto, approveAnalysisForToken, createPhrase, updatePhrase, @@ -1054,6 +1493,104 @@ export const selectCatalogRows = createSelector( buildCatalogRows({ tokenAnalyses, tokenAnalysisLinks }, { analysisLanguage, currentBook }), ); +/** + * What deleting a `TokenAnalysis` would do to the tokens that approve it, so an irreversible delete + * can be confirmed with its concrete consequence rather than a generic "are you sure". + */ +export interface AnalysisDeletionOutcome { + /** + * `'blank'` when the affected tokens are left reading as unanalyzed, `'fallback'` when a + * surviving homograph takes over and they read as that instead. + */ + kind: 'blank' | 'fallback'; + /** How many tokens the deletion affects. */ + usageCount: number; + /** + * What the affected tokens will read once the deletion commits. Absent when the surviving peer + * carries no gloss in the active analysis language, leaving no word to quote at the user. + */ + fallbackGloss?: string; + /** + * Whether `fallbackGloss` is uncertain — some affected token cannot be shown to still carry the + * form the fallback was derived from, so it may come to read something else. + */ + drifted?: boolean; + /** + * How many tokens record this analysis without approving it — assignments an import wrote that no + * surface displays. They go with the deletion like the approvals do. + */ + unappliedCount: number; +} + +/** + * Reports what {@link deleteAnalysis} would do to the given row, for the confirmation to name. + * Returns `undefined` when the id resolves to no payload, so a stale row cannot open a confirmation + * for a record that is already gone. + * + * Judges the fallback against the text as it now stands, read through `liveSurfaceText` — which + * covers the loaded book alone, giving `undefined` for a ref in any other. + */ +export function selectAnalysisDeletionOutcome( + state: AnalysisState, + analysisId: string, + liveSurfaceText: (tokenRef: string) => string | undefined, +): AnalysisDeletionOutcome | undefined { + const analysis = state.analysis.tokenAnalyses.find((ta) => ta.id === analysisId); + if (!analysis) return undefined; + + const approvedTokenCounts = selectApprovedTokenCountByAnalysisId(state); + + // Counted off the same index the catalog row counts by, so the confirmation and the row it opened + // from cannot name two different numbers: both count the tokens an approval sits on rather than + // the approvals themselves. + // + // Non-approved links are left out of this number though the deletion drops them too: they are not + // places the analysis is applied, so counting one here would name a consequence no token displays. + const usageCount = approvedTokenCounts.get(analysisId) ?? 0; + + // Counted by distinct token, matching usageCount, so a token an import recorded twice reads as the + // one place it is that the deletion touches. + const unappliedCount = new Set( + state.analysis.tokenAnalysisLinks + .filter((l) => l.analysisId === analysisId && l.status !== 'approved') + .map((l) => l.token.tokenRef), + ).size; + + // The fallback is what the affected tokens come to read, so a record nothing approves has none + // however many homographs the pool still offers for its form. + if (usageCount === 0) return { kind: 'blank', usageCount, unappliedCount }; + + // Ask the engine, so the confirmation names the peer that actually wins. The payload is dropped + // from the pool outright rather than discounted by one approval: a deletion removes all of its + // approvals at once, and a discounted multi-token payload would compete to replace itself. + const survivingPool = buildPoolIndex( + selectAnalysisById(state), + new Map([...approvedTokenCounts].filter(([id]) => id !== analysisId)), + ); + + const fallback = deriveTokenSuggestion(survivingPool, analysis.surfaceText); + if (!fallback) return { kind: 'blank', usageCount, unappliedCount }; + + // A token lands on whatever its own live form leads to, not on the fallback derived above, so a + // token that has moved off that form — or that cannot be read to check — is one the confirmation + // must hedge over rather than promise a word to. + const analyzedForm = normalizeSurfaceForm(analysis.surfaceText); + const drifted = state.analysis.tokenAnalysisLinks.some((l) => { + if (l.analysisId !== analysisId || l.status !== 'approved') return false; + const live = liveSurfaceText(l.token.tokenRef); + return live === undefined || normalizeSurfaceForm(live) !== analyzedForm; + }); + + const gloss = fallback.suggested.gloss?.[state.analysisLanguage]; + return { + kind: 'fallback', + usageCount, + unappliedCount, + ...(drifted ? { drifted } : {}), + ...(gloss ? { fallbackGloss: gloss } : {}), + }; +} + /** * Returns the merged analysis the renderer shows for a token: its approved decision when one * exists, otherwise the engine's suggestion derived live from the approved-analysis pool, or @@ -1106,21 +1643,24 @@ export function selectSuggestionAfterClearing( } /** - * Reports whether removing `tokenRef`'s morpheme breakdown would destroy gloss data no other token + * Reports whether removing `tokenRef`'s morpheme breakdown would destroy annotation no other token * still holds — the condition under which the morpheme editor confirms before resetting. True only - * when at least one morpheme carries a gloss AND this token is the sole approved link to its - * payload. A payload shared with other tokens is forked rather than emptied by `deleteMorphemes`, - * so the co-linked tokens keep their morphemes and nothing is lost project-wide; a breakdown with - * no glosses is bare segmentation that is cheap to retype. Sharing is judged by the same + * when at least one morpheme carries a gloss or a lexicon reference AND this token is the sole + * approved link to its payload. A payload shared with other tokens is forked rather than emptied, + * so the co-linked tokens keep their morphemes and nothing is lost project-wide; an unannotated + * breakdown is bare segmentation that is cheap to retype. Sharing is judged by the same * approved-link count the write path tests before it forks, so the two can never disagree about * what "shared" means. */ -export function selectMorphemeResetLosesGlosses(state: AnalysisState, tokenRef: string): boolean { +export function selectMorphemeResetLosesAnnotation( + state: AnalysisState, + tokenRef: string, +): boolean { const approvedId = selectApprovedIdByTokenRef(state).get(tokenRef); if (approvedId === undefined) return false; const analysis = selectAnalysisById(state).get(approvedId); - const hasGlossedMorpheme = analysis?.morphemes?.some((m) => m.gloss !== undefined) ?? false; - if (!hasGlossedMorpheme) return false; + const hasAnnotatedMorpheme = analysis?.morphemes?.some(morphemeCarriesAnnotation) ?? false; + if (!hasAnnotatedMorpheme) return false; // A payload referenced by more than one approved link is forked rather than emptied, so only a // sole link loses anything. /* v8 ignore next -- approvedId comes from the map the counts are built from, so it is always present */ @@ -1128,6 +1668,25 @@ export function selectMorphemeResetLosesGlosses(state: AnalysisState, tokenRef: return approvedTokenCount <= 1; } +/** + * Reports whether `tokenRef` is the only approved holder of its payload, so a breakdown edit here + * destroys what it drops instead of leaving it with co-linked tokens. False when the token has no + * approval at all. + * + * Sharing is judged by the same approved-link count a breakdown write forks on, so the two can + * never disagree about which edits are recoverable. + */ +export function selectMorphemePayloadIsSolelyOwned( + state: AnalysisState, + tokenRef: string, +): boolean { + const approvedId = selectApprovedIdByTokenRef(state).get(tokenRef); + if (approvedId === undefined) return false; + /* v8 ignore next -- approvedId comes from the map the counts are built from, so it is always present */ + const approvedTokenCount = selectApprovedTokenCountByAnalysisId(state).get(approvedId) ?? 0; + return approvedTokenCount <= 1; +} + const EMPTY_MORPHEMES: readonly MorphemeAnalysis[] = []; /** diff --git a/src/utils/analysis-identity.ts b/src/utils/analysis-identity.ts index 519a9a4ae..decbe62de 100644 --- a/src/utils/analysis-identity.ts +++ b/src/utils/analysis-identity.ts @@ -78,6 +78,44 @@ function morphemeIdentity(morpheme: MorphemeAnalysis) { return { form, gloss, entryRef, senseRef, allomorphRef, grammarRef }; } +/** + * Reports whether a morpheme carries annotation that only a person could restore — what an + * irreversible breakdown edit confirms before destroying. Its segmentation does not count, being + * cheap to retype and already on screen in the draft the reader is editing. + */ +export function morphemeCarriesAnnotation(morpheme: MorphemeAnalysis): boolean { + return ( + morpheme.gloss !== undefined || + morpheme.entryRef !== undefined || + morpheme.senseRef !== undefined || + morpheme.allomorphRef !== undefined || + morpheme.grammarRef !== undefined + ); +} + +/** + * Re-segments a breakdown to `morphemes`, keeping a form the old breakdown already carried whole — + * its id, and everything a person put on it — so only a form with no counterpart there is minted + * fresh under `writingSystem`. A form repeated within one breakdown keeps a distinct old morpheme + * per occurrence rather than every occurrence collapsing onto the first. + */ +export function reconcileMorphemes( + old: readonly MorphemeAnalysis[] | undefined, + morphemes: readonly { id: string; form: string }[], + writingSystem: string, +): MorphemeAnalysis[] { + const oldByForm = new Map(); + (old ?? []).forEach((m) => { + const bucket = oldByForm.get(m.form); + if (bucket) bucket.push(m); + else oldByForm.set(m.form, [m]); + }); + return morphemes.map(({ id, form }) => { + const kept = oldByForm.get(form)?.shift(); + return kept ? { ...kept, writingSystem } : { id, form, writingSystem }; + }); +} + /** * Reports whether two token analyses carry the same meaning and so should share one stored payload * rather than being duplicated. diff --git a/src/utils/analysis-query.ts b/src/utils/analysis-query.ts index d80d3d823..9bf5f7f97 100644 --- a/src/utils/analysis-query.ts +++ b/src/utils/analysis-query.ts @@ -2,6 +2,8 @@ import { Canon } from '@sillsdev/scripture'; import type { Confidence, MorphemeAnalysis, + MultiString, + SenseRef, TextAnalysis, TokenAnalysis, TokenAnalysisLink, @@ -28,6 +30,10 @@ export interface CatalogRow { * none. */ gloss: string; + /** Every gloss the analysis carries, the scope's analysis language among them. */ + glosses?: MultiString; + /** Lexicon sense the whole analysis resolves to. */ + glossSenseRef?: SenseRef; morphemes: readonly MorphemeAnalysis[]; pos?: string; /** Morphosyntactic features, each feature name mapped to the analysis's value for it. */ @@ -234,6 +240,8 @@ export function buildCatalogRows( analysisId: ta.id, surfaceText: ta.surfaceText, gloss: glossForScope(ta, scope.analysisLanguage), + glosses: ta.gloss, + glossSenseRef: ta.glossSenseRef, morphemes: ta.morphemes ?? [], pos: ta.pos, features: ta.features, @@ -400,6 +408,83 @@ function isActive(selected: readonly T[] | undefined): selected is readonly T return selected !== undefined && selected.length > 0; } +/** + * Drops from a selection every choice its facet no longer offers, or the whole selection when the + * facet is gone. + * + * @returns The selection itself when every choice survives; `undefined` where nothing is left to + * keep, so an emptied selection reads as no filter rather than as one nothing satisfies. + */ +function retainOffered( + selected: readonly T[] | undefined, + offered: readonly T[] | undefined, +): readonly T[] | undefined { + if (!isActive(selected)) return undefined; + if (!offered) return undefined; + const kept = selected.filter((choice) => offered.includes(choice)); + if (kept.length === selected.length) return selected; + return kept.length === 0 ? undefined : kept; +} + +/** Whether two feature selections name the same values for the same features. */ +function sameFeatureSelections( + a: CatalogFilters['features'], + b: CatalogFilters['features'], +): boolean { + const namesA = Object.keys(a ?? {}); + const namesB = Object.keys(b ?? {}); + if (namesA.length !== namesB.length) return false; + return namesA.every((name) => a?.[name] === b?.[name]); +} + +/** + * Narrows a set of filters to the choices the facets still offer, so a selection cannot outlive the + * choice it names. + * + * A facet collapses as the rows behind it change — an edit that removes the last row in the only + * other book leaves {@link deriveFacets} offering no books facet at all — while the selection naming + * that book lives on. Left alone the pair strands the reader: the listing is narrowed to nothing by + * a filter whose control is no longer on screen to widen it back by. + * + * @returns The given filters unchanged when every selection is still offered, so storing the result + * back over them settles rather than looping. + */ +export function reconcileFilters(filters: CatalogFilters, facets: CatalogFacets): CatalogFilters { + const features = Object.entries(filters.features ?? {}).reduce< + Record + >((acc, [name, values]) => { + const kept = retainOffered(values, facets.features?.[name]); + if (kept) acc[name] = kept; + return acc; + }, {}); + + const books = retainOffered(filters.books, facets.books); + const pos = retainOffered(filters.pos, facets.pos); + const confidence = retainOffered(filters.confidence, facets.confidence); + const keptFeatures = Object.keys(features).length === 0 ? undefined : features; + + // Built field by field rather than by overriding a spread of `filters`: a withdrawn selection is + // absent rather than `undefined`, and spreading the original first would keep the stale key. + const reconciled: CatalogFilters = { + ...(filters.zeroUsages !== undefined && { zeroUsages: filters.zeroUsages }), + ...(filters.missingGloss !== undefined && { missingGloss: filters.missingGloss }), + ...(filters.morphemes !== undefined && { morphemes: filters.morphemes }), + ...(books && { books }), + ...(pos && { pos }), + ...(confidence && { confidence }), + ...(keptFeatures && { features: keptFeatures }), + }; + + // The value filters are the only ones a facet can withdraw: the remaining three are offered + // unconditionally, so nothing can strand them. + const isUnchanged = + books === filters.books && + pos === filters.pos && + confidence === filters.confidence && + sameFeatureSelections(keptFeatures, filters.features); + return isUnchanged ? filters : reconciled; +} + /** * Whether a field holding one value at a time is in a state its selection accepts, carrying no * value being the state the selection's `undefined` choice accepts. diff --git a/src/utils/merge-content.ts b/src/utils/merge-content.ts new file mode 100644 index 000000000..2dba6ade4 --- /dev/null +++ b/src/utils/merge-content.ts @@ -0,0 +1,411 @@ +import type { + Confidence, + EntryRef, + MorphemeAnalysis, + MultiString, + TokenAnalysis, +} from 'interlinearizer'; +import { analysesAreIdentical, reconcileMorphemes } from './analysis-identity'; +import type { CatalogRow } from './analysis-query'; + +/** + * What a merge would write onto the surviving analysis, assembled from the ordered analyses and + * whatever the reader has typed over them. + */ +export interface MergedContentDraft { + /** Gloss in the analysis language, `''` when the merge would leave the survivor without one. */ + gloss: string; + /** + * Analysis `gloss` was taken from, whose sense the survivor keeps resolving it through. Absent + * when the reader typed the gloss or the merge settled on none. + */ + glossFromAnalysisId?: string; + morphemes: readonly MorphemeAnalysis[]; + /** Settled from the merged analyses alone, no edit reaching it. */ + pos?: string; + /** Settled from the merged analyses alone, no edit reaching them. */ + features?: Readonly>; + confidence?: Confidence; +} + +/** + * Stands for a field the reader emptied, which an optional field cannot say by holding `undefined` + * — that is how {@link MergeContentEdits} says the field was never touched. Emptying one is a + * decision that the merge should write nothing there, and no analysis may fill it back in. + */ +export const CLEARED = Symbol('cleared'); + +/** A field's edit: what to write, or {@link CLEARED} to write nothing. */ +type Edited = T | typeof CLEARED; + +/** What the reader has typed over the derived content, each field absent until they touch it. */ +export interface MergeContentEdits { + gloss?: string; + /** + * The breakdown as a list of forms alone — a form this leaves standing keeps the morpheme it had, + * gloss and lexicon references included. + */ + morphemeForms?: readonly string[]; + /** + * Each morpheme's gloss by its place in the breakdown, so two occurrences of one repeated form + * are edited apart. A blank entry records that the morpheme should carry no gloss, which no donor + * may fill in. + */ + morphemeGlosses?: Readonly>; + confidence?: Edited; +} + +/** + * Carries the gloss edits typed against the breakdown `from` over to the forms of `to`, so an edit + * stays on the form it was made about. An edit whose form `to` does not reach is dropped, there + * being no morpheme left for it to be about, and a repeated form's occurrences stay distinct. + */ +export function remapMorphemeGlossEdits( + glossEdits: Readonly> | undefined, + from: readonly MorphemeAnalysis[], + to: readonly string[], +): Readonly> | undefined { + if (!glossEdits) return undefined; + + const editsOfForm = new Map(); + from.forEach((m, index) => { + const edit = glossEdits[index]; + if (edit === undefined) return; + const bucket = editsOfForm.get(m.form); + if (bucket) bucket.push(edit); + else editsOfForm.set(m.form, [edit]); + }); + + const remapped: Record = {}; + to.forEach((form, index) => { + const edit = editsOfForm.get(form)?.shift(); + if (edit !== undefined) remapped[index] = edit; + }); + return Object.keys(remapped).length > 0 ? remapped : undefined; +} + +/** The state a merge panel derives its content from. */ +export interface MergeContentInput { + /** The analyses of the form, the survivor first — the order fallback reads down. */ + order: readonly CatalogRow[]; + /** Which analyses the merge would fold in, the survivor always among them. */ + checked: ReadonlySet; + edits: MergeContentEdits; + /** BCP 47 tag the glosses are read and written under. */ + analysisLanguage: string; + /** Writing system a re-split breakdown's minted morphemes are recorded under. */ + sourceLanguageTag: string; +} + +/** + * Whether the merge may be confirmed, and what stands in its way or awaits it. + * + * A merge that would converge is allowed rather than refused — the collapse is what the store does + * with two records saying the same thing, and the reader can mean it — and names the record it + * would absorb. + */ +export type MergeVerdict = + | { canConfirm: false; reason: 'nothing-checked'; collapsingAnalysisId?: undefined } + | { canConfirm: true; reason: 'will-collapse'; collapsingAnalysisId: string } + | { canConfirm: true; reason?: undefined; collapsingAnalysisId?: undefined }; + +/** What the panel shows and what confirming it would do. */ +export interface MergeContentDerivation { + /** The content the merge would write, which is what the editable fields are filled from. */ + content: MergedContentDraft; + verdict: MergeVerdict; +} + +/** How a reorder leaves the arrangement and the merge set. */ +export interface MergeReorder { + /** The analyses most-preferred first, the survivor at its head. */ + orderedIds: readonly string[]; + /** The analyses folded in besides the survivor. */ + mergedIds: ReadonlySet; +} + +/** + * Moves one analysis to a new place in the arrangement, keeping the merge set honest about what the + * move did: an analysis displaced from the head was going to survive, so it stays in the merge + * rather than dropping out of it, and the one taking its place leaves the set it now heads. + * + * Moving an analysis to where it already sits changes nothing. + */ +export function reorderForMerge( + current: MergeReorder, + analysisId: string, + toIndex: number, +): MergeReorder { + const from = current.orderedIds.indexOf(analysisId); + if (from === -1 || from === toIndex) return current; + + const orderedIds = [...current.orderedIds]; + orderedIds.splice(from, 1); + orderedIds.splice(toIndex, 0, analysisId); + + const [survivor] = current.orderedIds; + const [nextSurvivor] = orderedIds; + if (nextSurvivor === survivor) return { orderedIds, mergedIds: current.mergedIds }; + + const mergedIds = new Set(current.mergedIds); + mergedIds.add(survivor); + mergedIds.delete(nextSurvivor); + return { orderedIds, mergedIds }; +} + +/** Whether two references name one lexicon entry. */ +function sameEntry(a: EntryRef | undefined, b: EntryRef | undefined): boolean { + return ( + a !== undefined && + b !== undefined && + a.authority === b.authority && + a.projectId === b.projectId && + a.entryId === b.entryId + ); +} + +/** + * A row read as the stored analysis it stands for, so convergence is judged by the same rule the + * store dedupes by rather than by a second definition of sameness that could drift from it. + * + * The id and timestamps are placeholders, sameness resting on content alone. + */ +function asAnalysis( + content: Pick, + surfaceText: string, +): TokenAnalysis { + return { + id: '', + createdAt: '', + updatedAt: '', + surfaceText, + gloss: content.glosses, + glossSenseRef: content.glossSenseRef, + morphemes: [...content.morphemes], + pos: content.pos, + features: content.features ? { ...content.features } : undefined, + }; +} + +/** + * The glosses and sense the merge would leave the survivor holding: only the analysis language is + * the reader's to settle, and what rides along untouched is what tells the survivor apart from a + * record reading the same in the listed language. + */ +function settledGlossContent( + content: MergedContentDraft, + survivor: CatalogRow, + donors: readonly CatalogRow[], + analysisLanguage: string, +): Pick { + const glosses: MultiString = {}; + [...donors].reverse().forEach((d) => Object.assign(glosses, d.glosses)); + Object.assign(glosses, survivor.glosses); + + if (content.gloss) glosses[analysisLanguage] = content.gloss; + else delete glosses[analysisLanguage]; + + return { + glosses: Object.keys(glosses).length > 0 ? glosses : undefined, + glossSenseRef: donors.find((d) => d.analysisId === content.glossFromAnalysisId)?.glossSenseRef, + }; +} + +/** + * Whether the merge may go ahead, and what qualifies it: a merge needs something to fold in beyond + * the survivor, an edit on its own being no merge at all, and one that would converge is allowed + * but named so it can be said what is about to be absorbed. + */ +function verdictFor( + donors: readonly CatalogRow[], + collapsingAnalysisId: string | undefined, +): MergeVerdict { + if (donors.length < 2) return { canConfirm: false, reason: 'nothing-checked' }; + if (collapsingAnalysisId) + return { canConfirm: true, reason: 'will-collapse', collapsingAnalysisId }; + return { canConfirm: true }; +} + +/** + * The breakdown the checked analyses derive, before any re-split of the reader's stands over it. + * + * Taken whole rather than assembled morpheme by morpheme: a breakdown is one reading of the word, + * and forms drawn from two of them would segment it a way no analysis actually claims. + */ +export function deriveBreakdown( + order: readonly CatalogRow[], + checked: ReadonlySet, +): readonly MorphemeAnalysis[] { + return ( + order + .filter((r) => checked.has(r.analysisId)) + .map((r) => r.morphemes) + .find((forms) => forms.length > 0) ?? [] + ); +} + +/** + * Assembles the content a merge would write from the ordered analyses, the reader's edits over + * them, and which analyses are being folded in. + * + * A field the survivor lacks is filled from the next analysis down that has one, which is why the + * order is the reader's to arrange: it ranks the donors. Only analyses being folded in may donate — + * one left unchecked survives on its own and has no business putting content into another record. + */ +export function deriveMergeContent({ + order, + checked, + edits, + analysisLanguage, + sourceLanguageTag, +}: MergeContentInput): MergeContentDerivation { + const donors = order.filter((r) => checked.has(r.analysisId)); + + /** The first donor's value for a field, the survivor's own coming first among them. */ + const donated = (read: (r: CatalogRow) => T | undefined): T | undefined => + donors.map(read).find((value) => value !== undefined); + + const derivedBreakdown = deriveBreakdown(order, checked); + + // A breakdown edit supplies forms alone, so a form it leaves standing keeps the morpheme it had, + // lexicon references and all, rather than being rebuilt as a bare form. + const breakdown: readonly MorphemeAnalysis[] = edits.morphemeForms + ? reconcileMorphemes( + derivedBreakdown, + edits.morphemeForms.map((form, index) => ({ id: `merged-${index}`, form })), + sourceLanguageTag, + ) + : derivedBreakdown; + + /** + * What the checked analyses say about each occurrence of each form, the highest-ranked donor's + * first — keyed by the occurrence and not the form alone, so a repeated form's second occurrence + * draws what a donor said about _its_ second, rather than an annotation already spoken for. + */ + const donatedMorphemes = new Map(); + donors.forEach((r) => { + const seenOfForm = new Map(); + r.morphemes.forEach((m) => { + const occurrence = seenOfForm.get(m.form) ?? 0; + seenOfForm.set(m.form, occurrence + 1); + const key = `${occurrence}${m.form}`; + const bucket = donatedMorphemes.get(key); + if (bucket) bucket.push(m); + else donatedMorphemes.set(key, [m]); + }); + }); + + /** The first donation of one morpheme field, the morpheme's own value coming first. */ + const donatedField = ( + own: T | undefined, + donations: readonly MorphemeAnalysis[], + read: (m: MorphemeAnalysis) => T | undefined, + ): T | undefined => own ?? donations.map(read).find((value) => value !== undefined); + + /** + * A morpheme's lexicon references, always describing one entry: a sense, an allomorph and a + * grammar reference are scoped by their entry, so drawn from whichever donor happened to define + * each they would name parts of entries the morpheme does not resolve to. + * + * A morpheme resolving to no entry keeps its own references, which are all the merge can say when + * nothing names an entry to scope them. + */ + const donatedLexicon = ( + m: MorphemeAnalysis, + donations: readonly MorphemeAnalysis[], + ): Pick => { + const entryRef = donatedField(m.entryRef, donations, (d) => d.entryRef); + if (entryRef === undefined) + return { + entryRef, + senseRef: m.senseRef, + allomorphRef: m.allomorphRef, + grammarRef: m.grammarRef, + }; + + const ofEntry = sameEntry(m.entryRef, entryRef) ? m : undefined; + const scoped = donations.filter((d) => sameEntry(d.entryRef, entryRef)); + return { + entryRef, + senseRef: donatedField(ofEntry?.senseRef, scoped, (d) => d.senseRef), + allomorphRef: donatedField(ofEntry?.allomorphRef, scoped, (d) => d.allomorphRef), + grammarRef: donatedField(ofEntry?.grammarRef, scoped, (d) => d.grammarRef), + }; + }; + + /** How many of each form the breakdown has reached, which picks the donation it draws. */ + const seenOfForm = new Map(); + + // Annotation fills in per morpheme, matched by form: unlike the segmentation, what a morpheme + // means and what it resolves to in the lexicon is said of the morpheme itself, which a donor that + // reached the same form is saying about the same thing. Only a morpheme still lacking a value + // takes a donation, the reader's own edits outranking both. + const morphemes = breakdown.map((m, index) => { + const occurrence = seenOfForm.get(m.form) ?? 0; + seenOfForm.set(m.form, occurrence + 1); + const donations = donatedMorphemes.get(`${occurrence}${m.form}`) ?? []; + + // Every tag but the analysis language settles here, that one alone being the reader's to edit. + const otherGlosses: Record = {}; + [...donations].reverse().forEach((d) => Object.assign(otherGlosses, d.gloss)); + Object.assign(otherGlosses, m.gloss); + delete otherGlosses[analysisLanguage]; + + const carried: MorphemeAnalysis = { + ...m, + ...donatedLexicon(m, donations), + gloss: Object.keys(otherGlosses).length > 0 ? otherGlosses : undefined, + }; + + const settledGloss = donatedField( + m.gloss?.[analysisLanguage], + donations, + (d) => d.gloss?.[analysisLanguage], + ); + const gloss = edits.morphemeGlosses?.[index] ?? settledGloss; + // A blank edit empties the gloss rather than leaving whatever the morpheme arrived carrying: + // emptying one is a decision that it should carry none, which is what no gloss at all says. + if (gloss === undefined || gloss.trim() === '') return carried; + return { ...carried, gloss: { ...carried.gloss, [analysisLanguage]: gloss } }; + }); + + /** One optional field's settled value: its edit where there is one, else what a donor gives. */ + const settled = (edit: Edited | undefined, read: (r: CatalogRow) => T | undefined) => { + if (edit === CLEARED) return undefined; + return edit ?? donated(read); + }; + + /** The highest-ranked donor carrying a gloss, which is the one an unedited merge settles on. */ + const glossDonor = donors.find((r) => r.gloss !== ''); + + // An edit stands whatever the analyses say, a blank one included: emptying a field is a decision + // about what the merge should write, not an absence for a lower analysis to fill. + const gloss = edits.gloss ?? glossDonor?.gloss ?? ''; + + const content: MergedContentDraft = { + gloss, + // Named only where a donor supplied the settled text: a gloss the reader composed resolves + // through no lexicon sense of theirs. + glossFromAnalysisId: + edits.gloss === undefined && gloss !== '' ? glossDonor?.analysisId : undefined, + morphemes, + pos: donated((r) => r.pos), + features: donated((r) => r.features), + confidence: settled(edits.confidence, (r) => r.confidence), + }; + + const [survivor] = order; + + // Judged against what the merge would leave standing, so a record being folded in is not read as + // a record the survivor is about to collide with. + const written = asAnalysis( + { ...content, ...settledGlossContent(content, survivor, donors, analysisLanguage) }, + survivor.surfaceText, + ); + const collapsing = order.find( + (r) => + !checked.has(r.analysisId) && analysesAreIdentical(written, asAnalysis(r, r.surfaceText)), + ); + + return { content, verdict: verdictFor(donors, collapsing?.analysisId) }; +}