Skip to content

Commit abe8ea7

Browse files
Add UI for morphosyntactic analysis of tokens
1 parent b9dec18 commit abe8ea7

27 files changed

Lines changed: 1446 additions & 12 deletions

contributions/localizedStrings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
"%interlinearizer_projectSettings_simplifyPhrasesDescription%": "Hide interactive controls (split, unlink, remove-token) on phrases that are not currently focused, leaving only their style change on hover",
2525
"%interlinearizer_projectSettings_chapterLabelInVerse%": "Show Chapter in Verse Label",
2626
"%interlinearizer_projectSettings_chapterLabelInVerseDescription%": "Mark chapter boundaries by labeling the first verse of each chapter as chapter:verse instead of showing an inline chapter header above it",
27+
"%interlinearizer_viewOption_showMorphology%": "Show morphology",
28+
"%interlinearizer_projectSettings_showMorphology%": "Show Morphology",
29+
"%interlinearizer_projectSettings_showMorphologyDescription%": "Display morpheme breakdown and per-morpheme glosses beneath each word token",
2730
"%interlinearizer_linkButton_crossSegmentDisabledTooltip%": "Cross-segment phrases are not supported. This link button is outside the current segment.",
2831

2932
"%interlinearizer_modal_create_title%": "Create Interlinear Project",

contributions/projectSettings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@
2121
"label": "%interlinearizer_projectSettings_chapterLabelInVerse%",
2222
"description": "%interlinearizer_projectSettings_chapterLabelInVerseDescription%",
2323
"default": false
24+
},
25+
"interlinearizer.showMorphology": {
26+
"label": "%interlinearizer_projectSettings_showMorphology%",
27+
"description": "%interlinearizer_projectSettings_showMorphologyDescription%",
28+
"default": false
2429
}
2530
}
2631
}

cspell.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"bara",
1919
"baselining",
2020
"BBCCCVVV",
21+
"believ",
2122
"clickability",
2223
"cullable",
2324
"deconflict",
@@ -63,6 +64,7 @@
6364
"struc",
6465
"Stylesheet",
6566
"typedefs",
67+
"unanalyzed",
6668
"unhover",
6769
"unobserves",
6870
"unphrased",

src/__tests__/components/AnalysisStore.test.tsx

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,12 @@ import type { TextAnalysis, TokenAnalysis, TokenAnalysisLink } from 'interlinear
88
import {
99
AnalysisStoreProvider,
1010
useAnalysis,
11+
useAnalysisLanguage,
1112
useGloss,
1213
useGlossDispatch,
14+
useMorphemeBreakdownDispatch,
15+
useMorphemeGlossDispatch,
16+
useMorphemes,
1317
usePhraseLinkByIdMap,
1418
usePhraseLinkForToken,
1519
usePhraseLinkMap,
@@ -812,3 +816,249 @@ describe('usePhraseGlossDispatch', () => {
812816
);
813817
});
814818
});
819+
820+
// ---------------------------------------------------------------------------
821+
// Morpheme hooks
822+
// ---------------------------------------------------------------------------
823+
824+
/**
825+
* Renders the morpheme forms for a token, used to assert on `useMorphemes`.
826+
*
827+
* @param props.tokenRef - Token ref to subscribe to.
828+
* @returns JSX element with joined morpheme forms.
829+
*/
830+
function MorphemeReader({ tokenRef }: Readonly<{ tokenRef: string }>) {
831+
const morphemes = useMorphemes(tokenRef);
832+
return <span data-testid="morphemes">{morphemes.map((m) => m.form).join(',')}</span>;
833+
}
834+
835+
/**
836+
* Renders the analysis language, used to assert on `useAnalysisLanguage`.
837+
*
838+
* @returns JSX element with the analysis language string.
839+
*/
840+
function LanguageReader() {
841+
const lang = useAnalysisLanguage();
842+
return <span data-testid="lang">{lang}</span>;
843+
}
844+
845+
/**
846+
* Renders a button that dispatches a morpheme breakdown, used to test
847+
* `useMorphemeBreakdownDispatch`.
848+
*
849+
* @param props.tokenRef - Token ref to write.
850+
* @param props.surfaceText - Surface text of the token.
851+
* @param props.forms - Morpheme forms to write.
852+
* @returns JSX element suitable for passing to `render`.
853+
*/
854+
function MorphemeWriter({
855+
tokenRef,
856+
surfaceText,
857+
forms,
858+
}: Readonly<{ tokenRef: string; surfaceText: string; forms: string[] }>) {
859+
const dispatch = useMorphemeBreakdownDispatch();
860+
return (
861+
<button onClick={() => dispatch(tokenRef, surfaceText, forms)} type="button">
862+
break
863+
</button>
864+
);
865+
}
866+
867+
/**
868+
* Renders a button that dispatches a morpheme gloss, used to test `useMorphemeGlossDispatch`.
869+
*
870+
* @param props.tokenRef - Token ref to write.
871+
* @param props.morphemeId - Morpheme id to gloss.
872+
* @param props.value - Gloss value.
873+
* @returns JSX element suitable for passing to `render`.
874+
*/
875+
function MorphemeGlossWriter({
876+
tokenRef,
877+
morphemeId,
878+
value,
879+
}: Readonly<{ tokenRef: string; morphemeId: string; value: string }>) {
880+
const dispatch = useMorphemeGlossDispatch();
881+
return (
882+
<button onClick={() => dispatch(tokenRef, morphemeId, value)} type="button">
883+
gloss
884+
</button>
885+
);
886+
}
887+
888+
/**
889+
* Renders a component that calls `useMorphemes` without a provider, used to test the error.
890+
*
891+
* @returns Nothing — only mounted to trigger the throw.
892+
*/
893+
function MorphemesUser() {
894+
useMorphemes('tok-1');
895+
return undefined;
896+
}
897+
898+
/**
899+
* Renders a component that calls `useAnalysisLanguage` without a provider, used to test the error.
900+
*
901+
* @returns Nothing — only mounted to trigger the throw.
902+
*/
903+
function LanguageUser() {
904+
useAnalysisLanguage();
905+
return undefined;
906+
}
907+
908+
/**
909+
* Renders a component that calls `useMorphemeBreakdownDispatch` without a provider.
910+
*
911+
* @returns Nothing — only mounted to trigger the throw.
912+
*/
913+
function MorphemeBreakdownDispatchUser() {
914+
useMorphemeBreakdownDispatch();
915+
return undefined;
916+
}
917+
918+
/**
919+
* Renders a component that calls `useMorphemeGlossDispatch` without a provider.
920+
*
921+
* @returns Nothing — only mounted to trigger the throw.
922+
*/
923+
function MorphemeGlossDispatchUser() {
924+
useMorphemeGlossDispatch();
925+
return undefined;
926+
}
927+
928+
describe('useMorphemes', () => {
929+
it('returns empty array when no morphemes exist', () => {
930+
render(
931+
<AnalysisStoreProvider analysisLanguage="und">
932+
<MorphemeReader tokenRef="tok-1" />
933+
</AnalysisStoreProvider>,
934+
);
935+
expect(screen.getByTestId('morphemes')).toHaveTextContent('');
936+
});
937+
938+
it('returns morphemes from an approved analysis with morphemes', () => {
939+
const ta: TokenAnalysis = {
940+
id: 'ta-1',
941+
surfaceText: 'unbelievable',
942+
morphemes: [
943+
{ id: 'm-1', form: 'un-', writingSystem: 'und' },
944+
{ id: 'm-2', form: 'believe', writingSystem: 'und' },
945+
],
946+
};
947+
const link: TokenAnalysisLink = {
948+
analysisId: 'ta-1',
949+
status: 'approved',
950+
token: { tokenRef: 'tok-1', surfaceText: 'unbelievable' },
951+
};
952+
const analysis: TextAnalysis = {
953+
segmentAnalyses: [],
954+
segmentAnalysisLinks: [],
955+
tokenAnalyses: [ta],
956+
tokenAnalysisLinks: [link],
957+
phraseAnalyses: [],
958+
phraseAnalysisLinks: [],
959+
};
960+
render(
961+
<AnalysisStoreProvider initialAnalysis={analysis} analysisLanguage="und">
962+
<MorphemeReader tokenRef="tok-1" />
963+
</AnalysisStoreProvider>,
964+
);
965+
expect(screen.getByTestId('morphemes')).toHaveTextContent('un-,believe');
966+
});
967+
968+
it('throws when called outside an AnalysisStoreProvider', () => {
969+
jest.spyOn(console, 'error').mockImplementation(() => {});
970+
expect(() => render(<MorphemesUser />)).toThrow(
971+
'useMorphemes must be used inside an AnalysisStoreProvider',
972+
);
973+
});
974+
});
975+
976+
describe('useAnalysisLanguage', () => {
977+
it('returns the analysis language from the provider', () => {
978+
render(
979+
<AnalysisStoreProvider analysisLanguage="fr">
980+
<LanguageReader />
981+
</AnalysisStoreProvider>,
982+
);
983+
expect(screen.getByTestId('lang')).toHaveTextContent('fr');
984+
});
985+
986+
it('throws when called outside an AnalysisStoreProvider', () => {
987+
jest.spyOn(console, 'error').mockImplementation(() => {});
988+
expect(() => render(<LanguageUser />)).toThrow(
989+
'useAnalysisLanguage must be used inside an AnalysisStoreProvider',
990+
);
991+
});
992+
});
993+
994+
describe('useMorphemeBreakdownDispatch', () => {
995+
it('writes morphemes and calls onSave', async () => {
996+
const onSave = jest.fn();
997+
render(
998+
<AnalysisStoreProvider analysisLanguage="und" onSave={onSave}>
999+
<MorphemeWriter tokenRef="tok-1" surfaceText="cat" forms={['ca', '-t']} />
1000+
<MorphemeReader tokenRef="tok-1" />
1001+
</AnalysisStoreProvider>,
1002+
);
1003+
1004+
await userEvent.click(screen.getByRole('button', { name: 'break' }));
1005+
1006+
expect(screen.getByTestId('morphemes')).toHaveTextContent('ca,-t');
1007+
expect(onSave).toHaveBeenCalledTimes(1);
1008+
const saved: TextAnalysis = onSave.mock.calls[0][0];
1009+
expect(saved.tokenAnalyses).toHaveLength(1);
1010+
expect(saved.tokenAnalyses[0].morphemes).toHaveLength(2);
1011+
});
1012+
1013+
it('throws when called outside an AnalysisStoreProvider', () => {
1014+
jest.spyOn(console, 'error').mockImplementation(() => {});
1015+
expect(() => render(<MorphemeBreakdownDispatchUser />)).toThrow(
1016+
'useMorphemeBreakdownDispatch must be used inside an AnalysisStoreProvider',
1017+
);
1018+
});
1019+
});
1020+
1021+
describe('useMorphemeGlossDispatch', () => {
1022+
it('writes a morpheme gloss and calls onSave', async () => {
1023+
const onSave = jest.fn();
1024+
const ta: TokenAnalysis = {
1025+
id: 'ta-1',
1026+
surfaceText: 'cat',
1027+
morphemes: [
1028+
{ id: 'm-1', form: 'ca', writingSystem: 'und' },
1029+
{ id: 'm-2', form: '-t', writingSystem: 'und' },
1030+
],
1031+
};
1032+
const link: TokenAnalysisLink = {
1033+
analysisId: 'ta-1',
1034+
status: 'approved',
1035+
token: { tokenRef: 'tok-1', surfaceText: 'cat' },
1036+
};
1037+
const analysis: TextAnalysis = {
1038+
segmentAnalyses: [],
1039+
segmentAnalysisLinks: [],
1040+
tokenAnalyses: [ta],
1041+
tokenAnalysisLinks: [link],
1042+
phraseAnalyses: [],
1043+
phraseAnalysisLinks: [],
1044+
};
1045+
render(
1046+
<AnalysisStoreProvider initialAnalysis={analysis} analysisLanguage="und" onSave={onSave}>
1047+
<MorphemeGlossWriter tokenRef="tok-1" morphemeId="m-1" value="prefix" />
1048+
</AnalysisStoreProvider>,
1049+
);
1050+
1051+
await userEvent.click(screen.getByRole('button', { name: 'gloss' }));
1052+
1053+
expect(onSave).toHaveBeenCalledTimes(1);
1054+
const saved: TextAnalysis = onSave.mock.calls[0][0];
1055+
expect(saved.tokenAnalyses[0].morphemes?.[0].gloss).toStrictEqual({ und: 'prefix' });
1056+
});
1057+
1058+
it('throws when called outside an AnalysisStoreProvider', () => {
1059+
jest.spyOn(console, 'error').mockImplementation(() => {});
1060+
expect(() => render(<MorphemeGlossDispatchUser />)).toThrow(
1061+
'useMorphemeGlossDispatch must be used inside an AnalysisStoreProvider',
1062+
);
1063+
});
1064+
});

src/__tests__/components/ContinuousView.test.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,7 @@ function requiredProps(
419419
wordTokenByRef: ReadonlyMap<string, Token & { type: 'word' }>;
420420
hideInactiveLinkButtons: boolean;
421421
simplifyPhrases: boolean;
422+
showMorphology: boolean;
422423
} {
423424
const { tokenSegmentMap, tokenDocOrder, wordTokenByRef } = buildLookups(book);
424425
return {
@@ -433,6 +434,7 @@ function requiredProps(
433434
wordTokenByRef,
434435
hideInactiveLinkButtons: false,
435436
simplifyPhrases: false,
437+
showMorphology: false,
436438
};
437439
}
438440

@@ -899,6 +901,7 @@ describe('ContinuousView scroll behavior', () => {
899901
wordTokenByRef={wordTokenByRef}
900902
hideInactiveLinkButtons={false}
901903
simplifyPhrases={false}
904+
showMorphology={false}
902905
/>
903906
);
904907
}
@@ -945,6 +948,7 @@ describe('ContinuousView scroll behavior', () => {
945948
wordTokenByRef={wordTokenByRef}
946949
hideInactiveLinkButtons
947950
simplifyPhrases={false}
951+
showMorphology={false}
948952
/>
949953
);
950954
}

0 commit comments

Comments
 (0)