Skip to content

Commit 75c3f58

Browse files
Re-converge writeMorphemeGloss clear path onto identical sibling
The clear branch returned before mergeIntoIdenticalPayload, so clearing a morpheme gloss back to a sibling's state left a duplicate payload the write path would have collapsed (double-counting the form in the suggestion pool). Merge on the clear path too, making dedupe symmetric across both directions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7d2245f commit 75c3f58

2 files changed

Lines changed: 116 additions & 0 deletions

File tree

src/__tests__/store/analysisSlice.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1576,6 +1576,105 @@ describe('writeMorphemeGloss', () => {
15761576
store.getState().analysis.analysis.tokenAnalyses[0].morphemes?.[0].gloss,
15771577
).toBeUndefined();
15781578
});
1579+
1580+
it('re-converges onto an identical sibling after a morpheme gloss edit', () => {
1581+
// Two payloads for "cats" differ only in morpheme-0's gloss; glossing tok-1's morpheme to match
1582+
// tok-2's makes them content-identical, so the two payloads should collapse back onto one.
1583+
const ta1: TokenAnalysis = {
1584+
id: 'ta-1',
1585+
surfaceText: 'cats',
1586+
morphemes: [
1587+
{ id: 'm-1', form: 'cat', writingSystem: 'und' },
1588+
{ id: 'm-2', form: '-s', writingSystem: 'und', gloss: { und: 'PL' } },
1589+
],
1590+
};
1591+
const ta2: TokenAnalysis = {
1592+
id: 'ta-2',
1593+
surfaceText: 'cats',
1594+
morphemes: [
1595+
{ id: 'm-3', form: 'cat', writingSystem: 'und', gloss: { und: 'feline' } },
1596+
{ id: 'm-4', form: '-s', writingSystem: 'und', gloss: { und: 'PL' } },
1597+
],
1598+
};
1599+
const store = createAnalysisStore({
1600+
analysis: {
1601+
analysis: {
1602+
...emptyAnalysis(),
1603+
tokenAnalyses: [ta1, ta2],
1604+
tokenAnalysisLinks: [
1605+
{
1606+
analysisId: 'ta-1',
1607+
status: 'approved',
1608+
token: { tokenRef: 'tok-1', surfaceText: 'cats' },
1609+
},
1610+
{
1611+
analysisId: 'ta-2',
1612+
status: 'approved',
1613+
token: { tokenRef: 'tok-2', surfaceText: 'cats' },
1614+
},
1615+
],
1616+
},
1617+
analysisLanguage: 'und',
1618+
},
1619+
});
1620+
1621+
store.dispatch(writeMorphemeGloss({ tokenRef: 'tok-1', morphemeId: 'm-1', value: 'feline' }));
1622+
1623+
const { tokenAnalyses, tokenAnalysisLinks } = store.getState().analysis.analysis;
1624+
expect(tokenAnalyses).toHaveLength(1);
1625+
expect(tokenAnalysisLinks).toHaveLength(2);
1626+
expect(tokenAnalysisLinks.every((l) => l.analysisId === tokenAnalyses[0].id)).toBe(true);
1627+
});
1628+
1629+
it('re-converges onto an identical sibling after clearing a morpheme gloss', () => {
1630+
// Two payloads for "cats" differ only in that tok-1's morpheme-0 carries a stray gloss; clearing
1631+
// it makes the two payloads content-identical, so they should collapse back onto one — the clear
1632+
// path must re-converge symmetrically with the write path, not leave a duplicate behind.
1633+
const ta1: TokenAnalysis = {
1634+
id: 'ta-1',
1635+
surfaceText: 'cats',
1636+
morphemes: [
1637+
{ id: 'm-1', form: 'cat', writingSystem: 'und', gloss: { und: 'feline' } },
1638+
{ id: 'm-2', form: '-s', writingSystem: 'und', gloss: { und: 'PL' } },
1639+
],
1640+
};
1641+
const ta2: TokenAnalysis = {
1642+
id: 'ta-2',
1643+
surfaceText: 'cats',
1644+
morphemes: [
1645+
{ id: 'm-3', form: 'cat', writingSystem: 'und' },
1646+
{ id: 'm-4', form: '-s', writingSystem: 'und', gloss: { und: 'PL' } },
1647+
],
1648+
};
1649+
const store = createAnalysisStore({
1650+
analysis: {
1651+
analysis: {
1652+
...emptyAnalysis(),
1653+
tokenAnalyses: [ta1, ta2],
1654+
tokenAnalysisLinks: [
1655+
{
1656+
analysisId: 'ta-1',
1657+
status: 'approved',
1658+
token: { tokenRef: 'tok-1', surfaceText: 'cats' },
1659+
},
1660+
{
1661+
analysisId: 'ta-2',
1662+
status: 'approved',
1663+
token: { tokenRef: 'tok-2', surfaceText: 'cats' },
1664+
},
1665+
],
1666+
},
1667+
analysisLanguage: 'und',
1668+
},
1669+
});
1670+
1671+
store.dispatch(writeMorphemeGloss({ tokenRef: 'tok-1', morphemeId: 'm-1', value: '' }));
1672+
1673+
const { tokenAnalyses, tokenAnalysisLinks } = store.getState().analysis.analysis;
1674+
expect(tokenAnalyses).toHaveLength(1);
1675+
expect(tokenAnalysisLinks).toHaveLength(2);
1676+
expect(tokenAnalysisLinks.every((l) => l.analysisId === tokenAnalyses[0].id)).toBe(true);
1677+
});
15791678
});
15801679

15811680
describe('selectApprovedMorphemes', () => {

src/store/analysisSlice.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -616,6 +616,11 @@ const analysisSlice = createSlice({
616616
* itself is kept (a breakdown is content in its own right), so unlike `writeGloss` this never
617617
* removes the enclosing analysis.
618618
*
619+
* Both the write and the clear are identity-changing edits, so each re-converges onto an
620+
* existing content-identical payload ({@link mergeIntoIdenticalPayload}) — keeping the create
621+
* path's dedupe invariant symmetric across both directions, so a clear back to a sibling's
622+
* state never leaves a duplicate.
623+
*
619624
* @param state - Current slice state (Immer draft).
620625
* @param action - Action carrying the morpheme gloss payload.
621626
*/
@@ -635,11 +640,23 @@ const analysisSlice = createSlice({
635640
delete morpheme.gloss[lang];
636641
if (Object.keys(morpheme.gloss).length === 0) delete morpheme.gloss;
637642
}
643+
// Clearing a morpheme gloss is itself an identity-changing edit: dropping the gloss can make
644+
// this payload identical to a sibling whose only difference was that gloss, so re-converge
645+
// here too — the write branch below does the same. Without this the dedupe is asymmetric
646+
// (an edit re-merges, a clear back to the sibling's state leaves a duplicate the suggestion
647+
// pool would double-count), and unlike writeGloss this clear keeps the morpheme record, so
648+
// the duplicate payload survives rather than being reclaimed.
649+
mergeIntoIdenticalPayload(state, resolved.analysis);
638650
return;
639651
}
640652

641653
if (!morpheme.gloss) morpheme.gloss = {};
642654
morpheme.gloss[lang] = value;
655+
// A morpheme gloss is part of analysis identity (see analysesAreIdentical), so editing one
656+
// in place can make this payload identical to an existing one (e.g. a homograph whose only
657+
// difference was this morpheme's gloss); re-converge so the dedupe the create path guarantees
658+
// on first write also holds after morpheme gloss edits (mirrors writeGloss/writeMorphemes).
659+
mergeIntoIdenticalPayload(state, resolved.analysis);
643660
},
644661
forkAnalysisForToken: {
645662
/**

0 commit comments

Comments
 (0)