` — 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