Skip to content

Commit 3d68073

Browse files
alex-rawlings-yycclaudeimnasnainaec
authored
Add auto-saving draft with Save / Save As and Wipe (#108)
* Add auto-saving draft with Save / Save As and Wipe Editing the interlinearizer no longer writes to the active project on every keystroke. Each source project now has an always-present draft (draft:{sourceProjectId}) that auto-saves every edit to papi storage, decoupled from the user's saved projects and never shown in the picker, so work is never lost. Persistence is now explicit: - Save writes the draft to the active project - Save As writes the draft to a new project, or overwrites an existing one - New starts an empty draft (a project is created only on Save As) - Open loads a project into the draft as a working copy - Wipe clears the draft — the current book or the whole thing Switching projects (New / Open) while the draft is dirty prompts to discard. The tab title shows a "●" marker while the draft has unsaved changes, toggled via updateWebViewDefinition (Platform.Bible exposes no native unsaved-tab indicator). Implementation: - DraftProject type + isDraftProject guard; getDraft/saveDraft storage with a per-source serialization queue; getDraft/saveDraft backend commands. - useDraftProject hook owns the draft (autosave, dirty tracking, and a draftVersion that remounts the editor on New/Open/Wipe). - New SaveAsProjectModal, WipeConfirm, and DiscardDraftConfirm; the New modal is repurposed to configure a draft rather than create a project. - removeBookFromAnalysis util backs the per-book wipe. Alignment links are intentionally not carried in the draft yet (no link-editing feature exists); Save preserves a target project's existing links. Deferred UX decisions are recorded in user-questions.md. 988 tests, 100% coverage, lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Improve handling of save new, overwrite, wipe all * Fix draft save mid-edit race and discard-confirm overlay - markSynced now only clears the dirty flag when the live draft still matches the persisted snapshot, so a gloss edit made during an in-flight Save / Save As no longer clears the unsaved-changes indicator while the project is left stale. The save paths pass the analysis they wrote. - The discard-changes confirmation overlays the active modal instead of replacing it, so canceling returns to the Create dialog with its typed input intact, and confirming Open no longer unmounts and re-fetches the still-open select modal underneath. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Indicate active project better, improve pending edits system * Fix menu item group name case, disable save as overwrite when submitting * Disarm setConfirmOverwrite in loadProjects * Make overwrite more robust * Address code review feedback on save-draft-project (#113) * Address review feedback: race fix, draft validation, autosave debounce, save guard - handleConfirmReplace: await openProject so the discard confirm stays up during the project fetch; setPendingReplace only clears after the modal is dismissed - getDraft: validate parsed JSON with isDraftProject; resets to empty draft on shape mismatch instead of propagating an unvalidated object - autosaveAnalysis: debounce persist (300 ms trailing edge) to prevent unbounded command queue growth under rapid typing; applyReplacement cancels any pending debounce before its immediate write, and the sourceProjectId effect cleanup cancels it on source change - handleSave: add isSavingRef guard (matching SaveAsProjectModal pattern) so a rapid double-click cannot fire two saveAnalysis commands Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Restore project creation in the New flow startNewDraft was refactored to only reset the local draft, but this meant clicking "Create" in "New Interlinear Project..." never persisted anything. The project would not appear in "Select Interlinear Project..." because interlinearizer.createProject was never called. Restore the original behaviour: startNewDraft now calls createProject, loads the empty project into the draft via loadFromProject, and sets it as the active Save target — exactly what the old CreateProjectModal did before the draft model was introduced. handleConfirmReplace now also awaits startNewDraft so the discard confirm stays visible for the duration of the network call, consistent with the openProject path. Remove the now-unused resetDraft prop from ProjectModals and its call-site in InterlinearizerLoader. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Cancel debounced autosave in markSynced to prevent stale dirty overwrite The setTimeout closure in autosaveAnalysis captures `next` ({dirty:true}) at scheduling time. If markSynced runs before the 300ms timer fires (e.g. a fast Save round-trip), it persists {dirty:false} first — then the stale timer fires and persists the captured {dirty:true}, overwriting the clean state in storage. applyReplacement already cancels the timer before its own persist; apply the same pattern to markSynced so the {dirty:false} record is always the last write. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix tests, coverage, and JSDoc for debounce and project-creation changes - Update autosaveAnalysis tests to advance fake timers past the 300ms debounce before asserting saveDraft was called - Rewrite the two ProjectModals "new flow" tests to assert interlinearizer.createProject is called instead of the removed resetDraft prop; remove resetDraft from ModalsOverrides/buildProps - Fix InterlinearizerLoader autosave test with the same fake-timer pattern - Add startNewDraft error-path tests (invalid shape, targetProjectId branch, and throw) to close ProjectModals branch coverage gaps - Add useDraftProject test verifying applyReplacement cancels a pending autosave timer so the stale dirty write never fires after a reset - Add projectStorage getDraft test for the isDraftProject validation-fail path (returns empty draft + warns) - Fix /* v8 ignore next */ spans on multi-line guards in InterlinearizerLoader (isSavingRef) and ProjectModals (isCreatingRef) - Remove stale @param props.resetDraft JSDoc; document re-entry guard in handleCreateDraft JSDoc - Fix PendingReplace.new.config type from undefined NewDraftConfig to CreateDraftConfig - jest.config.ts: exclude .claude worktrees from Haste module resolution; add isolatedModules: true so ts-jest runs from worktree paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Tidy * Fix debounce data loss on unmount and duplicate-create on DiscardDraftConfirm - Flush the pending debounced autosave in the useEffect cleanup so the last edit before unmount or source-project change is not silently dropped. - Add isCreatingRef guard to the 'new' path of handleConfirmReplace, matching the guard already present in handleCreateDraft, so rapid clicks on the DiscardDraftConfirm button cannot fire multiple createProject commands. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Remove resetDraft, dead NewDraftConfig type, and related tests resetDraft is no longer called by any production code — the New flow now goes through startNewDraft → loadFromProject in ProjectModals. Remove the hook method, its exported NewDraftConfig type, its unit tests, and the stale prop reference in the InterlinearizerLoader mock. Also make handleCreateDraft async so it awaits the isCreatingRef reset, and tighten two inline comments in useDraftProject to reflect that the cleanup now flushes rather than silently drops a pending autosave. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add loading feedback to Create modal and drop redundant submitting refs - Add `isSubmitting` prop to `CreateProjectModal`; `ProjectModals` threads `isCreating` state into it so both buttons are disabled during the backend round-trip, addressing the reviewer's missing-loading-indicator comment - Drop the `ref + state` dual pattern from `ProjectModals`, `SaveAsProjectModal`, and `ProjectMetadataModal`; state alone is sufficient because the buttons are already disabled before a second invocation can reach the guard - Add a `useDraftProject` test covering the timeout-cancellation branch in `applyReplacement` to restore 100% coverage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Disable DiscardDraftConfirm buttons during async project creation The confirm overlay remained interactive during the backend round-trip when the 'new' branch of handleConfirmReplace was awaiting startNewDraft. Both buttons now receive isSubmitting so the user gets clear visual feedback and cannot interact with the dialog while creation is in flight. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Tweak * Fix race: disable DiscardDraftConfirm during both open and new-draft flows isCreating only covered the new-draft branch of handleConfirmReplace, leaving the open-project branch unguarded. A single isReplacing state now wraps both paths, so the discard overlay buttons are disabled for the full duration of the async operation. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> * Move wipe options to dedicated modal, improve new draft handling --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: D. Ror. <imnasnainaec@gmail.com>
1 parent 4ee0808 commit 3d68073

44 files changed

Lines changed: 4662 additions & 1284 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ temp-build
3333

3434
# #endregion
3535

36+
# AI files
37+
.claude
38+
3639
# Playwright test output
3740
e2e-tests/playwright-report
3841
e2e-tests/test-results

__mocks__/platform-bible-react.tsx

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export const BOOK_CHAPTER_CONTROL_STRING_KEYS = [
4242
export const MOCK_SELECT_PROJECT_MENU_ITEM: MenuItemContainingCommand = {
4343
label: '%interlinearizer_menu_select_project%',
4444
command: 'interlinearizer.openSelectProjectModal',
45-
group: 'interlinearizer.project.actions',
45+
group: 'interlinearizer.projectActions',
4646
order: 1,
4747
localizeNotes: '',
4848
};
@@ -51,7 +51,7 @@ export const MOCK_SELECT_PROJECT_MENU_ITEM: MenuItemContainingCommand = {
5151
export const MOCK_NEW_PROJECT_MENU_ITEM: MenuItemContainingCommand = {
5252
label: '%interlinearizer_menu_new_project%',
5353
command: 'interlinearizer.openNewProjectModal',
54-
group: 'interlinearizer.project.actions',
54+
group: 'interlinearizer.projectActions',
5555
order: 2,
5656
localizeNotes: '',
5757
};
@@ -60,11 +60,38 @@ export const MOCK_NEW_PROJECT_MENU_ITEM: MenuItemContainingCommand = {
6060
export const MOCK_VIEW_PROJECT_INFO_MENU_ITEM: MenuItemContainingCommand = {
6161
label: '%interlinearizer_menu_view_project_info%',
6262
command: 'interlinearizer.openProjectInfoModal',
63-
group: 'interlinearizer.project.actions',
63+
group: 'interlinearizer.projectActions',
6464
order: 3,
6565
localizeNotes: '',
6666
};
6767

68+
/** Sentinel menu item passed by the mock toolbar when the save button is clicked. */
69+
export const MOCK_SAVE_MENU_ITEM: MenuItemContainingCommand = {
70+
label: '%interlinearizer_save%',
71+
command: 'interlinearizer.save',
72+
group: 'interlinearizer.fileActions',
73+
order: 1,
74+
localizeNotes: '',
75+
};
76+
77+
/** Sentinel menu item passed by the mock toolbar when the save-as button is clicked. */
78+
export const MOCK_SAVE_AS_MENU_ITEM: MenuItemContainingCommand = {
79+
label: '%interlinearizer_saveAs%',
80+
command: 'interlinearizer.openSaveAsModal',
81+
group: 'interlinearizer.fileActions',
82+
order: 2,
83+
localizeNotes: '',
84+
};
85+
86+
/** Sentinel menu item passed by the mock toolbar when the wipe button is clicked. */
87+
export const MOCK_WIPE_MENU_ITEM: MenuItemContainingCommand = {
88+
label: '%interlinearizer_wipe%',
89+
command: 'interlinearizer.wipe',
90+
group: 'interlinearizer.draftActions',
91+
order: 1,
92+
localizeNotes: '',
93+
};
94+
6895

6996
/**
7097
* Stub toolbar that renders project-menu and view-info buttons using sentinel menu items so tests
@@ -127,6 +154,33 @@ export function TabToolbar({
127154
View project info
128155
</button>
129156
)}
157+
{onSelectProjectMenuItem && (
158+
<button
159+
type="button"
160+
data-testid="tab-toolbar-save"
161+
onClick={() => onSelectProjectMenuItem(MOCK_SAVE_MENU_ITEM)}
162+
>
163+
Save
164+
</button>
165+
)}
166+
{onSelectProjectMenuItem && (
167+
<button
168+
type="button"
169+
data-testid="tab-toolbar-save-as"
170+
onClick={() => onSelectProjectMenuItem(MOCK_SAVE_AS_MENU_ITEM)}
171+
>
172+
Save as
173+
</button>
174+
)}
175+
{onSelectProjectMenuItem && (
176+
<button
177+
type="button"
178+
data-testid="tab-toolbar-wipe"
179+
onClick={() => onSelectProjectMenuItem(MOCK_WIPE_MENU_ITEM)}
180+
>
181+
Wipe
182+
</button>
183+
)}
130184
{onSelectViewInfoMenuItem && (
131185
<button
132186
type="button"

contributions/localizedStrings.json

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
"%interlinearizer_openSelectProjectModal%": "Select Interlinear Project…",
1111
"%interlinearizer_openNewProjectModal%": "New Interlinear Project…",
1212
"%interlinearizer_openProjectInfoModal%": "View Project Info…",
13+
"%interlinearizer_save%": "Save",
14+
"%interlinearizer_saveAs%": "Save As…",
15+
"%interlinearizer_wipe%": "Wipe…",
1316

1417
"%interlinearizer_projectSettings_title%": "Interlinearizer",
1518
"%interlinearizer_projectSettings_continuousScroll%": "Continuous Scroll",
@@ -68,14 +71,41 @@
6871
"%interlinearizer_modal_select_none%": "No interlinear projects exist for this source yet.",
6972
"%interlinearizer_modal_select_name_unnamed%": "Unnamed",
7073
"%interlinearizer_modal_select_info_button_label%": "Project info",
74+
"%interlinearizer_modal_select_active_badge%": "Active",
7175
"%interlinearizer_modal_select_create_new%": "Create New",
7276
"%interlinearizer_modal_select_cancel%": "Cancel",
7377

78+
"%interlinearizer_confirm_discard_title%": "Discard unsaved changes?",
79+
"%interlinearizer_confirm_discard_body%": "Your draft has changes that haven't been saved to a project. Continuing will discard them.",
80+
"%interlinearizer_confirm_discard_ok%": "Discard",
81+
"%interlinearizer_confirm_discard_cancel%": "Cancel",
82+
83+
"%interlinearizer_modal_saveAs_title%": "Save As",
84+
"%interlinearizer_modal_saveAs_new_section%": "Save as a new project",
85+
"%interlinearizer_modal_saveAs_save_new%": "Save as New Project",
86+
"%interlinearizer_modal_saveAs_existing_section%": "Or overwrite an existing project",
87+
"%interlinearizer_modal_saveAs_none%": "No existing projects for this source yet.",
88+
"%interlinearizer_modal_saveAs_overwrite%": "Overwrite",
89+
"%interlinearizer_modal_saveAs_overwrite_confirm_body%": "Overwrite this project? Its saved analysis will be replaced with the current draft.",
90+
"%interlinearizer_modal_saveAs_overwrite_confirm_ok%": "Overwrite",
91+
"%interlinearizer_modal_saveAs_overwrite_confirm_cancel%": "Cancel",
92+
"%interlinearizer_modal_saveAs_cancel%": "Cancel",
93+
94+
"%interlinearizer_wipe_modal_title%": "Wipe draft analysis",
95+
"%interlinearizer_wipe_modal_prompt%": "Choose how much of the draft's analysis to remove. Save afterward to persist the change to a project.",
96+
"%interlinearizer_wipe_modal_scope_book%": "Current book",
97+
"%interlinearizer_wipe_modal_scope_book_description%": "Removes all analysis for the current book from the draft.",
98+
"%interlinearizer_wipe_modal_scope_all%": "Entire draft",
99+
"%interlinearizer_wipe_modal_scope_all_description%": "Removes all analysis from the draft.",
100+
"%interlinearizer_wipe_modal_confirm%": "Wipe",
101+
"%interlinearizer_wipe_modal_cancel%": "Cancel",
102+
74103
"%interlinearizer_error_create_project_failed%": "Could not create the interlinearizer project. Please try again.",
75104
"%interlinearizer_error_save_metadata_failed%": "Could not save project info. Please try again.",
76105
"%interlinearizer_error_delete_project_failed%": "Could not delete the interlinearizer project. Please try again.",
77106
"%interlinearizer_error_update_project_failed%": "Could not update the interlinearizer project. Please try again.",
78-
"%interlinearizer_error_load_projects_failed%": "Could not load interlinear projects. Please try again."
107+
"%interlinearizer_error_load_projects_failed%": "Could not load interlinear projects. Please try again.",
108+
"%interlinearizer_error_save_draft_failed%": "Could not save your working draft. Please try again."
79109
}
80110
}
81111
}

contributions/menus.json

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,14 @@
2727
"interlinearizer.projectActions": {
2828
"column": "interlinearizer.project",
2929
"order": 1
30+
},
31+
"interlinearizer.fileActions": {
32+
"column": "interlinearizer.project",
33+
"order": 2
34+
},
35+
"interlinearizer.draftActions": {
36+
"column": "interlinearizer.project",
37+
"order": 3
3038
}
3139
},
3240
"items": [
@@ -50,6 +58,27 @@
5058
"group": "interlinearizer.projectActions",
5159
"order": 3,
5260
"command": "interlinearizer.openProjectInfoModal"
61+
},
62+
{
63+
"label": "%interlinearizer_save%",
64+
"localizeNotes": "Interlinearizer top menu > Save the current draft to the active project",
65+
"group": "interlinearizer.fileActions",
66+
"order": 1,
67+
"command": "interlinearizer.save"
68+
},
69+
{
70+
"label": "%interlinearizer_saveAs%",
71+
"localizeNotes": "Interlinearizer top menu > Save the draft to a new project or overwrite an existing one",
72+
"group": "interlinearizer.fileActions",
73+
"order": 2,
74+
"command": "interlinearizer.openSaveAsModal"
75+
},
76+
{
77+
"label": "%interlinearizer_wipe%",
78+
"localizeNotes": "Interlinearizer top menu > Open the wipe dialog to remove the current book's or the whole draft's analysis",
79+
"group": "interlinearizer.draftActions",
80+
"order": 1,
81+
"command": "interlinearizer.wipe"
5382
}
5483
]
5584
}

cspell.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
"verseref",
7878
"versification",
7979
"wordform",
80+
"worktrees",
8081
"ZWNJ"
8182
],
8283
"ignoreWords": ["Ελληνικά", "homme", "ʼelohim", "ʻokina"],

jest.config.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,11 @@ const config: Config = {
104104
'^(.+)\\.(scss|sass|css)\\?inline$': '<rootDir>/__mocks__/styleInlineMock.ts',
105105
},
106106

107-
/** Exclude dist from module resolution to avoid Haste naming collision with root package.json. */
108-
modulePathIgnorePatterns: ['<rootDir>/dist'],
107+
/**
108+
* Exclude `.claude` from module resolution to avoid nested git worktrees. Exclude `dist` from
109+
* module resolution to avoid Haste naming collision with root package.json.
110+
*/
111+
modulePathIgnorePatterns: ['<rootDir>/.claude', '<rootDir>/dist'],
109112

110113
/** Load @testing-library/jest-dom matchers and browser API stubs for React component tests. */
111114
setupFilesAfterEnv: [
@@ -129,9 +132,15 @@ const config: Config = {
129132
/**
130133
* Transform TS/TSX with ts-jest (webpack uses SWC; Jest does not run webpack). Explicitly list
131134
* ts-jest so other preprocessors can be added later without dropping TS support.
135+
*
136+
* `isolatedModules: true` transpiles each file individually without a full type-check pass. This
137+
* is required when running from a git worktree whose `typeRoots` relative paths (e.g.
138+
* `../paranext-core/lib`) do not resolve from the worktree subdirectory; those paths are correct
139+
* for the repo root but would cause every test suite to fail with TS2307 otherwise. Type-safety
140+
* is still enforced by `npm run lint:typecheck` (tsc --noEmit) from the repo root.
132141
*/
133142
transform: {
134-
'\\.tsx?$': 'ts-jest',
143+
'\\.tsx?$': ['ts-jest', { isolatedModules: true }],
135144
},
136145
};
137146

src/__tests__/components/AnalysisStore.test.tsx

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
usePhraseDispatch,
2222
usePhraseGloss,
2323
usePhraseGlossDispatch,
24+
useReportGlossEditing,
2425
} from '../../components/AnalysisStore';
2526

2627
// ---------------------------------------------------------------------------
@@ -1146,3 +1147,111 @@ describe('useMorphemeGlossDispatch', () => {
11461147
);
11471148
});
11481149
});
1150+
1151+
/**
1152+
* Reports its `isEditing` prop through {@link useReportGlossEditing}, used to drive the provider's
1153+
* pending-edits accounting from tests.
1154+
*
1155+
* @param props - Component props.
1156+
* @param props.isEditing - Whether this stand-in input currently holds uncommitted text.
1157+
* @returns An empty fragment; the component exists only for its hook side effect.
1158+
*/
1159+
function EditingReporter({ isEditing }: Readonly<{ isEditing: boolean }>) {
1160+
useReportGlossEditing(isEditing);
1161+
return undefined;
1162+
}
1163+
1164+
describe('useReportGlossEditing', () => {
1165+
it('reports true when the first input starts editing and false when it stops', () => {
1166+
const onPendingEditsChange = jest.fn();
1167+
const { rerender } = render(
1168+
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
1169+
<EditingReporter isEditing={false} />
1170+
</AnalysisStoreProvider>,
1171+
);
1172+
// No editor active yet: nothing reported.
1173+
expect(onPendingEditsChange).not.toHaveBeenCalled();
1174+
1175+
rerender(
1176+
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
1177+
<EditingReporter isEditing />
1178+
</AnalysisStoreProvider>,
1179+
);
1180+
expect(onPendingEditsChange).toHaveBeenLastCalledWith(true);
1181+
1182+
rerender(
1183+
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
1184+
<EditingReporter isEditing={false} />
1185+
</AnalysisStoreProvider>,
1186+
);
1187+
expect(onPendingEditsChange).toHaveBeenLastCalledWith(false);
1188+
});
1189+
1190+
it('reports only the 0↔non-0 transitions when multiple inputs edit concurrently', () => {
1191+
const onPendingEditsChange = jest.fn();
1192+
const renderWith = (a: boolean, b: boolean) => (
1193+
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
1194+
<EditingReporter isEditing={a} />
1195+
<EditingReporter isEditing={b} />
1196+
</AnalysisStoreProvider>
1197+
);
1198+
const { rerender } = render(renderWith(false, false));
1199+
expect(onPendingEditsChange).not.toHaveBeenCalled();
1200+
1201+
rerender(renderWith(true, false));
1202+
expect(onPendingEditsChange).toHaveBeenCalledTimes(1);
1203+
expect(onPendingEditsChange).toHaveBeenLastCalledWith(true);
1204+
1205+
// Second input also starts editing: still pending, no new transition reported.
1206+
rerender(renderWith(true, true));
1207+
expect(onPendingEditsChange).toHaveBeenCalledTimes(1);
1208+
1209+
// First input stops: one editor remains, so still no transition.
1210+
rerender(renderWith(false, true));
1211+
expect(onPendingEditsChange).toHaveBeenCalledTimes(1);
1212+
1213+
// Last editor stops: now we cross back to zero.
1214+
rerender(renderWith(false, false));
1215+
expect(onPendingEditsChange).toHaveBeenCalledTimes(2);
1216+
expect(onPendingEditsChange).toHaveBeenLastCalledWith(false);
1217+
});
1218+
1219+
it('reports false when an actively-editing input unmounts', () => {
1220+
const onPendingEditsChange = jest.fn();
1221+
const { rerender } = render(
1222+
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
1223+
<EditingReporter isEditing />
1224+
</AnalysisStoreProvider>,
1225+
);
1226+
expect(onPendingEditsChange).toHaveBeenLastCalledWith(true);
1227+
1228+
rerender(
1229+
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
1230+
<span />
1231+
</AnalysisStoreProvider>,
1232+
);
1233+
expect(onPendingEditsChange).toHaveBeenLastCalledWith(false);
1234+
});
1235+
1236+
it('does not throw when no onPendingEditsChange is provided', () => {
1237+
const { rerender } = render(
1238+
<AnalysisStoreProvider analysisLanguage="und">
1239+
<EditingReporter isEditing={false} />
1240+
</AnalysisStoreProvider>,
1241+
);
1242+
expect(() =>
1243+
rerender(
1244+
<AnalysisStoreProvider analysisLanguage="und">
1245+
<EditingReporter isEditing />
1246+
</AnalysisStoreProvider>,
1247+
),
1248+
).not.toThrow();
1249+
});
1250+
1251+
it('throws when called outside an AnalysisStoreProvider', () => {
1252+
jest.spyOn(console, 'error').mockImplementation(() => {});
1253+
expect(() => render(<EditingReporter isEditing={false} />)).toThrow(
1254+
'useReportGlossEditing must be used inside an AnalysisStoreProvider',
1255+
);
1256+
});
1257+
});

src/__tests__/components/InterlinearNavContext.test.tsx

Lines changed: 1 addition & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,33 +11,7 @@ import {
1111
useInterlinearNav,
1212
} from '../../components/InterlinearNavContext';
1313
import { RECENTER_FADE_MS } from '../../components/recenter-fade';
14-
15-
/** Tuple shape returned by the PAPI scroll-group hook. */
16-
type ScrollGroupTuple = [
17-
SerializedVerseRef,
18-
(r: SerializedVerseRef) => void,
19-
number | undefined,
20-
(id: number | undefined) => void,
21-
];
22-
23-
/**
24-
* Builds a `useWebViewScrollGroupScrRef` stub returning the given tuple parts. Defaults cover the
25-
* common case so a test only overrides what it asserts on.
26-
*
27-
* @param ref - The scripture reference the stub reports.
28-
* @param setScrRef - The reference setter; defaults to a noop.
29-
* @param scrollGroupId - The active scroll-group id; defaults to `undefined` (unlinked).
30-
* @param setScrollGroupId - The scroll-group setter; defaults to a noop.
31-
* @returns A hook returning the assembled tuple.
32-
*/
33-
function makeScrollGroupHook(
34-
ref: SerializedVerseRef,
35-
setScrRef: (r: SerializedVerseRef) => void = () => {},
36-
scrollGroupId: number | undefined = undefined,
37-
setScrollGroupId: (id: number | undefined) => void = () => {},
38-
) {
39-
return (): ScrollGroupTuple => [ref, setScrRef, scrollGroupId, setScrollGroupId];
40-
}
14+
import { makeScrollGroupHook, type ScrollGroupTuple } from '../test-helpers';
4115

4216
/**
4317
* Renders {@link useInterlinearNav} inside a provider wired to the given scroll-group hook.

0 commit comments

Comments
 (0)