@@ -8,8 +8,12 @@ import type { TextAnalysis, TokenAnalysis, TokenAnalysisLink } from 'interlinear
88import {
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+ } ) ;
0 commit comments