Skip to content

Commit 2dc45e8

Browse files
Ask before a re-split strands a glossed morpheme
Both breakdown editors now name the forms whose glosses a re-split would drop and confirm first, matching the prompt each already showed for clearing a breakdown outright. The token editor skips the prompt for a shared payload, which the write forks rather than re-segmenting in place.
1 parent 65bdd6f commit 2dc45e8

11 files changed

Lines changed: 485 additions & 45 deletions

File tree

contributions/localizedStrings.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@
6060
"%interlinearizer_analysisCatalog_editMorphemesOpen%": "Edit breakdown for {form}",
6161
"%interlinearizer_analysisCatalog_confirmResetPrompt%": "Discard this breakdown and its glosses everywhere {form} is used?",
6262
"%interlinearizer_analysisCatalog_confirmResetAction%": "Discard breakdown",
63+
"%interlinearizer_analysisCatalog_confirmResplitPrompt%": "This breakdown drops {forms}, discarding the glosses on it everywhere this analysis is used. Save anyway?",
64+
"%interlinearizer_analysisCatalog_confirmResplitAction%": "Save and discard",
6365
"%interlinearizer_analysisCatalog_morphemeGloss%": "Gloss for morpheme {form}",
6466
"%interlinearizer_analysisCatalog_appliesToAll%": "Edits here apply to every use of this analysis.",
6567
"%interlinearizer_analysisCatalog_merge%": "Merge…",
@@ -118,6 +120,8 @@
118120
"%interlinearizer_morphemeEditor_emptyHint%": "Enter morpheme forms separated by spaces",
119121
"%interlinearizer_morphemeEditor_confirmResetPrompt%": "Discard this breakdown and its glosses?",
120122
"%interlinearizer_morphemeEditor_confirmResetAction%": "Reset",
123+
"%interlinearizer_morphemeEditor_confirmResplitPrompt%": "This breakdown drops {forms}, discarding the glosses on it. Save anyway?",
124+
"%interlinearizer_morphemeEditor_confirmResplitAction%": "Save and discard",
121125
"%interlinearizer_morphemeGloss_label%": "Gloss for morpheme {form}",
122126
"%interlinearizer_tokenChip_editMorphemes%": "Edit morpheme breakdown for {token}",
123127
"%interlinearizer_tokenChip_defineMorphemes%": "Define morpheme breakdown for {token}",

src/__tests__/components/AnalysisCatalogPanel.test.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1712,6 +1712,63 @@ describe('AnalysisCatalogPanel', () => {
17121712
expect(saved.tokenAnalyses[0].morphemes).toBeUndefined();
17131713
});
17141714

1715+
/** Opens the breakdown editor on `ta-1` and re-splits it to `forms`, then saves. */
1716+
async function resplitBreakdown(forms: string): Promise<void> {
1717+
const row = await expandRow('ta-1');
1718+
await userEvent.click(within(row).getByTestId('catalog-row-breakdown-open'));
1719+
const input = within(rowFor('ta-1')).getByTestId('catalog-row-breakdown-input');
1720+
await userEvent.clear(input);
1721+
await userEvent.type(input, forms);
1722+
await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-breakdown-save'));
1723+
}
1724+
1725+
it('confirms before a re-split that strands a glossed morpheme', async () => {
1726+
const onSave = jest.fn();
1727+
renderPanel({ analysis: GLOSSED_MORPHEMES, onSave });
1728+
1729+
await resplitBreakdown('λογος');
1730+
1731+
expect(within(rowFor('ta-1')).getByTestId('catalog-row-breakdown-confirm')).toHaveTextContent(
1732+
'%interlinearizer_analysisCatalog_confirmResplitPrompt%',
1733+
);
1734+
expect(onSave).not.toHaveBeenCalled();
1735+
});
1736+
1737+
it('re-splits once the loss is confirmed', async () => {
1738+
const onSave = jest.fn();
1739+
renderPanel({ analysis: GLOSSED_MORPHEMES, onSave });
1740+
1741+
await resplitBreakdown('λογος');
1742+
await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-breakdown-save'));
1743+
1744+
const saved: TextAnalysis = onSave.mock.calls.at(-1)[0];
1745+
expect(saved.tokenAnalyses[0].morphemes?.map((m) => m.form)).toEqual(['λογος']);
1746+
});
1747+
1748+
it('keeps the breakdown when the re-split is declined', async () => {
1749+
const onSave = jest.fn();
1750+
renderPanel({ analysis: GLOSSED_MORPHEMES, onSave });
1751+
1752+
await resplitBreakdown('λογος');
1753+
await userEvent.click(within(rowFor('ta-1')).getByTestId('catalog-row-breakdown-cancel'));
1754+
1755+
expect(onSave).not.toHaveBeenCalled();
1756+
expect(within(rowFor('ta-1')).getByTestId('catalog-row-breakdown-input')).toHaveValue(
1757+
'λογος',
1758+
);
1759+
});
1760+
1761+
it('re-splits without asking when every glossed morpheme survives', async () => {
1762+
// Only the unglossed "ος" is dropped, and bare segmentation is cheap to retype.
1763+
const onSave = jest.fn();
1764+
renderPanel({ analysis: GLOSSED_MORPHEMES, onSave });
1765+
1766+
await resplitBreakdown('λογ');
1767+
1768+
const saved: TextAnalysis = onSave.mock.calls.at(-1)[0];
1769+
expect(saved.tokenAnalyses[0].morphemes?.map((m) => m.form)).toEqual(['λογ']);
1770+
});
1771+
17151772
it('keeps a breakdown draft across collapsing the row', async () => {
17161773
renderPanel({ analysis: SHARED });
17171774

src/__tests__/components/MorphemeEditor.test.tsx

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import { useLocalizedStrings } from '@papi/frontend/react';
55
import { fireEvent, render, screen } from '@testing-library/react';
66
import userEvent from '@testing-library/user-event';
7+
import type { MorphemeAnalysis } from 'interlinearizer';
78
import type { ComponentProps } from 'react';
89
import { MorphemeBreakdownPopover } from '../../components/MorphemeEditor';
910

@@ -17,9 +18,17 @@ const LOCALIZED = {
1718
'%interlinearizer_morphemeEditor_emptyHint%': 'Enter morpheme forms separated by spaces',
1819
'%interlinearizer_morphemeEditor_confirmResetPrompt%': 'Discard this breakdown and its glosses?',
1920
'%interlinearizer_morphemeEditor_confirmResetAction%': 'Reset',
21+
'%interlinearizer_morphemeEditor_confirmResplitPrompt%':
22+
'This breakdown drops {forms}, discarding the glosses on it. Save anyway?',
23+
'%interlinearizer_morphemeEditor_confirmResplitAction%': 'Save and discard',
2024
'%interlinearizer_morphemeGloss_label%': 'Gloss for morpheme {form}',
2125
};
2226

27+
/** A morpheme carrying a gloss, so dropping it is the loss a re-split confirms over. */
28+
function glossed(id: string, form: string): MorphemeAnalysis {
29+
return { id, form, writingSystem: 'und', gloss: { und: form } };
30+
}
31+
2332
beforeEach(() => {
2433
jest.mocked(useLocalizedStrings).mockReturnValue([LOCALIZED, false]);
2534
});
@@ -450,6 +459,110 @@ describe('MorphemeBreakdownPopover', () => {
450459
});
451460
});
452461

462+
describe('re-split confirmation', () => {
463+
/**
464+
* Renders the popover over a glossed breakdown of "unbelievable" that this token solely owns,
465+
* so a re-split dropping any of its forms destroys that form's gloss outright.
466+
*/
467+
function renderResplitting(
468+
props: Partial<ComponentProps<typeof MorphemeBreakdownPopover>> = {},
469+
) {
470+
return renderPopover({
471+
initialValue: 'un- believ -able',
472+
morphemes: [glossed('m-1', 'un-'), glossed('m-2', 'believ'), glossed('m-3', '-able')],
473+
onReset: jest.fn(),
474+
surfaceText: 'unbelievable',
475+
...props,
476+
});
477+
}
478+
479+
/** Replaces the draft with `value` and commits it. */
480+
async function commit(value: string) {
481+
await userEvent.clear(screen.getByRole('textbox'));
482+
await userEvent.type(screen.getByRole('textbox'), value);
483+
await userEvent.keyboard('{Enter}');
484+
}
485+
486+
it('asks before a re-split that strands a glossed form', async () => {
487+
const onSave = jest.fn();
488+
const onClose = jest.fn();
489+
renderResplitting({ onSave, onClose });
490+
await commit('un- believe');
491+
expect(screen.getByTestId('morpheme-split-confirm')).toBeInTheDocument();
492+
expect(onSave).not.toHaveBeenCalled();
493+
expect(onClose).not.toHaveBeenCalled();
494+
});
495+
496+
it('names the stranded forms in the prompt', async () => {
497+
renderResplitting();
498+
await commit('un- believe');
499+
expect(screen.getByTestId('morpheme-split-confirm')).toHaveTextContent(
500+
'This breakdown drops believ, -able, discarding the glosses on it. Save anyway?',
501+
);
502+
});
503+
504+
it('saves and closes when the confirmation is accepted', async () => {
505+
const onSave = jest.fn();
506+
const onClose = jest.fn();
507+
renderResplitting({ onSave, onClose });
508+
await commit('un- believe');
509+
await userEvent.click(screen.getByTestId('morpheme-split-confirm-action'));
510+
expect(onSave).toHaveBeenCalledWith('un- believe');
511+
expect(onClose).toHaveBeenCalledTimes(1);
512+
});
513+
514+
it('returns to the draft when the confirmation is canceled', async () => {
515+
const onSave = jest.fn();
516+
renderResplitting({ onSave });
517+
await commit('un- believe');
518+
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
519+
expect(screen.queryByTestId('morpheme-split-confirm')).not.toBeInTheDocument();
520+
expect(screen.getByRole('textbox')).toHaveValue('un- believe');
521+
expect(onSave).not.toHaveBeenCalled();
522+
});
523+
524+
it('leaves a pending re-split unwritten when the user presses outside the panel', async () => {
525+
// The same reasoning as the reset confirmation: the loss is irreversible, so a stray click
526+
// must not answer the prompt, even though an outside press on an edited draft normally saves.
527+
const onSave = jest.fn();
528+
const onClose = jest.fn();
529+
renderResplitting({ onSave, onClose });
530+
await commit('un- believe');
531+
await userEvent.click(screen.getByTestId('popover-outside'));
532+
expect(onSave).not.toHaveBeenCalled();
533+
expect(onClose).toHaveBeenCalledTimes(1);
534+
});
535+
536+
it('saves without asking when the re-split keeps every glossed form', async () => {
537+
const onSave = jest.fn();
538+
renderResplitting({ onSave });
539+
await commit('un- believ -able -ness');
540+
expect(screen.queryByTestId('morpheme-split-confirm')).not.toBeInTheDocument();
541+
expect(onSave).toHaveBeenCalledWith('un- believ -able -ness');
542+
});
543+
544+
it('saves without asking when the stranded form carried no gloss', async () => {
545+
const onSave = jest.fn();
546+
renderResplitting({
547+
morphemes: [glossed('m-1', 'un-'), { id: 'm-2', form: 'believ', writingSystem: 'und' }],
548+
onSave,
549+
});
550+
await commit('un- believe');
551+
expect(screen.queryByTestId('morpheme-split-confirm')).not.toBeInTheDocument();
552+
expect(onSave).toHaveBeenCalledWith('un- believe');
553+
});
554+
555+
it('saves without asking when the payload is shared, its morphemes withheld', async () => {
556+
// A shared payload is forked rather than re-segmented in place, so the co-linked tokens keep
557+
// the glosses this token drops and there is nothing to confirm.
558+
const onSave = jest.fn();
559+
renderResplitting({ morphemes: undefined, onSave });
560+
await commit('un- believe');
561+
expect(screen.queryByTestId('morpheme-split-confirm')).not.toBeInTheDocument();
562+
expect(onSave).toHaveBeenCalledWith('un- believe');
563+
});
564+
});
565+
453566
it('falls back to the token gloss input on close when the chip has no morpheme gloss field', async () => {
454567
render(
455568
<label>

src/__tests__/components/TokenChip.test.tsx

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,21 @@ jest.mock('../../components/MorphemeEditor', () => ({
2323
onClose,
2424
onReset,
2525
needsResetConfirm,
26+
morphemes,
2627
}: Readonly<{
2728
onSave: (v: string) => void;
2829
onClose: () => void;
2930
onReset?: () => void;
3031
needsResetConfirm?: boolean;
32+
morphemes?: readonly { form: string }[];
3133
}>) {
3234
return (
33-
<div data-testid="morpheme-popover" data-needs-reset-confirm={needsResetConfirm}>
35+
<div
36+
data-testid="morpheme-popover"
37+
data-needs-reset-confirm={needsResetConfirm}
38+
// Absent entirely when the chip withholds them, which is how a shared payload reads.
39+
data-resplit-morphemes={morphemes?.map((m) => m.form).join(' ')}
40+
>
3441
<button onClick={() => onSave('hel -lo')} type="button">
3542
mock-save
3643
</button>
@@ -689,6 +696,41 @@ describe('TokenChip', () => {
689696
);
690697
});
691698

699+
it('hands the popover the morphemes to weigh a re-split against when this token owns them', async () => {
700+
jest.spyOn(AnalysisStore, 'useMorphemePayloadIsSolelyOwned').mockReturnValue(true);
701+
jest
702+
.spyOn(AnalysisStore, 'useMorphemes')
703+
.mockReturnValue([{ id: 'm-1', form: 'hel', writingSystem: 'und' }]);
704+
705+
render(
706+
<AnalysisStoreProvider analysisLanguage="und">
707+
<TokenChip {...requiredProps()} showMorphology />
708+
</AnalysisStoreProvider>,
709+
);
710+
await userEvent.click(screen.getByRole('button', { name: 'mock-edit-breakdown' }));
711+
expect(screen.getByTestId('morpheme-popover')).toHaveAttribute(
712+
'data-resplit-morphemes',
713+
'hel',
714+
);
715+
});
716+
717+
it('withholds the morphemes when the payload is shared, so a re-split loses nothing', async () => {
718+
// The write forks a shared payload rather than re-segmenting it, so the co-linked tokens keep
719+
// whatever this one drops and there is nothing for the popover to confirm.
720+
jest.spyOn(AnalysisStore, 'useMorphemePayloadIsSolelyOwned').mockReturnValue(false);
721+
jest
722+
.spyOn(AnalysisStore, 'useMorphemes')
723+
.mockReturnValue([{ id: 'm-1', form: 'hel', writingSystem: 'und' }]);
724+
725+
render(
726+
<AnalysisStoreProvider analysisLanguage="und">
727+
<TokenChip {...requiredProps()} showMorphology />
728+
</AnalysisStoreProvider>,
729+
);
730+
await userEvent.click(screen.getByRole('button', { name: 'mock-edit-breakdown' }));
731+
expect(screen.getByTestId('morpheme-popover')).not.toHaveAttribute('data-resplit-morphemes');
732+
});
733+
692734
it('focuses the main gloss input on a surface-text mouse-down when the box precedes it', () => {
693735
jest
694736
.spyOn(AnalysisStore, 'useMorphemes')

src/__tests__/store/analysisSlice.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/// <reference types="jest" />
22

33
import type {
4+
MorphemeAnalysis,
45
PhraseAnalysisLink,
56
SegmentAnalysis,
67
SegmentAnalysisLink,
@@ -18,11 +19,13 @@ import {
1819
deletePhrase,
1920
mergeAnalysisInto,
2021
mergePhrases,
22+
morphemeFormsLostByResplit,
2123
selectAnalysisDeletionOutcome,
2224
selectAnalysisMergePeers,
2325
selectApprovedGloss,
2426
selectApprovedMorphemes,
2527
selectCatalogRows,
28+
selectMorphemePayloadIsSolelyOwned,
2629
selectMorphemeResetLosesGlosses,
2730
selectPhraseLinkByTokenRef,
2831
selectPhraseGloss,
@@ -2337,6 +2340,73 @@ describe('selectMorphemeResetLosesGlosses', () => {
23372340
});
23382341
});
23392342

2343+
describe('selectMorphemePayloadIsSolelyOwned', () => {
2344+
it('reports not solely owned when the token has no approved analysis', () => {
2345+
const store = createAnalysisStore();
2346+
expect(selectMorphemePayloadIsSolelyOwned(store.getState().analysis, 'tok-1')).toBe(false);
2347+
});
2348+
2349+
it('reports solely owned when this token is the only approved link', () => {
2350+
const store = createAnalysisStore();
2351+
store.dispatch(writeMorphemes('tok-1', 'cats', ['cat', '-s'], 'en'));
2352+
expect(selectMorphemePayloadIsSolelyOwned(store.getState().analysis, 'tok-1')).toBe(true);
2353+
});
2354+
2355+
it('reports not solely owned when another token shares the payload', () => {
2356+
const store = createAnalysisStore();
2357+
store.dispatch(writeMorphemes('tok-1', 'cats', ['cat', '-s'], 'en'));
2358+
const [{ analysisId }] = store
2359+
.getState()
2360+
.analysis.analysis.tokenAnalysisLinks.filter((l) => l.token.tokenRef === 'tok-1');
2361+
store.dispatch(approveAnalysisForToken({ tokenRef: 'tok-2', surfaceText: 'cats', analysisId }));
2362+
expect(selectMorphemePayloadIsSolelyOwned(store.getState().analysis, 'tok-2')).toBe(false);
2363+
});
2364+
});
2365+
2366+
describe('morphemeFormsLostByResplit', () => {
2367+
/** A morpheme carrying a gloss, so a re-split dropping it destroys something. */
2368+
function glossed(id: string, form: string): MorphemeAnalysis {
2369+
return { id, form, writingSystem: 'en', gloss: { und: form } };
2370+
}
2371+
2372+
it('reports nothing lost when every glossed form survives the re-split', () => {
2373+
const old = [glossed('m-1', 'un-'), glossed('m-2', 'believ')];
2374+
expect(morphemeFormsLostByResplit(old, ['un-', 'believ', '-able'])).toEqual([]);
2375+
});
2376+
2377+
it('reports a glossed form the new breakdown has no morpheme for', () => {
2378+
const old = [glossed('m-1', 'un-'), glossed('m-2', 'believ'), glossed('m-3', '-able')];
2379+
expect(morphemeFormsLostByResplit(old, ['un-', 'believe'])).toEqual(['believ', '-able']);
2380+
});
2381+
2382+
it('leaves out a stranded form that carried no gloss', () => {
2383+
// Losing bare segmentation costs only what the reader is retyping anyway.
2384+
const old = [glossed('m-1', 'un-'), { id: 'm-2', form: 'believ', writingSystem: 'en' }];
2385+
expect(morphemeFormsLostByResplit(old, ['un-', 'believe'])).toEqual([]);
2386+
});
2387+
2388+
it('counts a repeated form once per occurrence the re-split drops', () => {
2389+
// The surviving "ba" takes the first old morpheme, as a re-split itself would, so the second is
2390+
// what goes.
2391+
const old = [glossed('m-1', 'ba'), glossed('m-2', 'ba')];
2392+
expect(morphemeFormsLostByResplit(old, ['ba'])).toEqual(['ba']);
2393+
});
2394+
2395+
it('reports nothing lost when a repeated form keeps every occurrence', () => {
2396+
const old = [glossed('m-1', 'ba'), glossed('m-2', 'ba')];
2397+
expect(morphemeFormsLostByResplit(old, ['ba', 'ba'])).toEqual([]);
2398+
});
2399+
2400+
it('reports every glossed form lost when the breakdown is cleared', () => {
2401+
const old = [glossed('m-1', 'un-'), glossed('m-2', 'believ')];
2402+
expect(morphemeFormsLostByResplit(old, [])).toEqual(['un-', 'believ']);
2403+
});
2404+
2405+
it('reports nothing lost when there was no breakdown to begin with', () => {
2406+
expect(morphemeFormsLostByResplit(undefined, ['un-', 'believ'])).toEqual([]);
2407+
});
2408+
});
2409+
23402410
describe('analysis timestamps', () => {
23412411
const FIRST_WRITE = '2026-04-01T09:00:00.000Z';
23422412
const SECOND_WRITE = '2026-04-02T10:30:00.000Z';

0 commit comments

Comments
 (0)