Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ temp-build

# #endregion

# AI files
.claude

# Playwright test output
e2e-tests/playwright-report
e2e-tests/test-results
60 changes: 57 additions & 3 deletions __mocks__/platform-bible-react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export const BOOK_CHAPTER_CONTROL_STRING_KEYS = [
export const MOCK_SELECT_PROJECT_MENU_ITEM: MenuItemContainingCommand = {
label: '%interlinearizer_menu_select_project%',
command: 'interlinearizer.openSelectProjectModal',
group: 'interlinearizer.project.actions',
group: 'interlinearizer.projectActions',
order: 1,
localizeNotes: '',
};
Expand All @@ -51,7 +51,7 @@ export const MOCK_SELECT_PROJECT_MENU_ITEM: MenuItemContainingCommand = {
export const MOCK_NEW_PROJECT_MENU_ITEM: MenuItemContainingCommand = {
label: '%interlinearizer_menu_new_project%',
command: 'interlinearizer.openNewProjectModal',
group: 'interlinearizer.project.actions',
group: 'interlinearizer.projectActions',
order: 2,
localizeNotes: '',
};
Expand All @@ -60,11 +60,38 @@ export const MOCK_NEW_PROJECT_MENU_ITEM: MenuItemContainingCommand = {
export const MOCK_VIEW_PROJECT_INFO_MENU_ITEM: MenuItemContainingCommand = {
label: '%interlinearizer_menu_view_project_info%',
command: 'interlinearizer.openProjectInfoModal',
group: 'interlinearizer.project.actions',
group: 'interlinearizer.projectActions',
order: 3,
localizeNotes: '',
};

/** Sentinel menu item passed by the mock toolbar when the save button is clicked. */
export const MOCK_SAVE_MENU_ITEM: MenuItemContainingCommand = {
label: '%interlinearizer_save%',
command: 'interlinearizer.save',
group: 'interlinearizer.fileActions',
order: 1,
localizeNotes: '',
};

/** Sentinel menu item passed by the mock toolbar when the save-as button is clicked. */
export const MOCK_SAVE_AS_MENU_ITEM: MenuItemContainingCommand = {
label: '%interlinearizer_saveAs%',
command: 'interlinearizer.openSaveAsModal',
group: 'interlinearizer.fileActions',
order: 2,
localizeNotes: '',
};

/** Sentinel menu item passed by the mock toolbar when the wipe button is clicked. */
export const MOCK_WIPE_MENU_ITEM: MenuItemContainingCommand = {
label: '%interlinearizer_wipe%',
command: 'interlinearizer.wipe',
group: 'interlinearizer.draftActions',
order: 1,
localizeNotes: '',
};


/**
* Stub toolbar that renders project-menu and view-info buttons using sentinel menu items so tests
Expand Down Expand Up @@ -127,6 +154,33 @@ export function TabToolbar({
View project info
</button>
)}
{onSelectProjectMenuItem && (
<button
type="button"
data-testid="tab-toolbar-save"
onClick={() => onSelectProjectMenuItem(MOCK_SAVE_MENU_ITEM)}
>
Save
</button>
)}
{onSelectProjectMenuItem && (
<button
type="button"
data-testid="tab-toolbar-save-as"
onClick={() => onSelectProjectMenuItem(MOCK_SAVE_AS_MENU_ITEM)}
>
Save as
</button>
)}
{onSelectProjectMenuItem && (
<button
type="button"
data-testid="tab-toolbar-wipe"
onClick={() => onSelectProjectMenuItem(MOCK_WIPE_MENU_ITEM)}
>
Wipe
</button>
)}
{onSelectViewInfoMenuItem && (
<button
type="button"
Expand Down
32 changes: 31 additions & 1 deletion contributions/localizedStrings.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
"%interlinearizer_openSelectProjectModal%": "Select Interlinear Project…",
"%interlinearizer_openNewProjectModal%": "New Interlinear Project…",
"%interlinearizer_openProjectInfoModal%": "View Project Info…",
"%interlinearizer_save%": "Save",
"%interlinearizer_saveAs%": "Save As…",
"%interlinearizer_wipe%": "Wipe…",

"%interlinearizer_projectSettings_title%": "Interlinearizer",
"%interlinearizer_projectSettings_continuousScroll%": "Continuous Scroll",
Expand Down Expand Up @@ -68,14 +71,41 @@
"%interlinearizer_modal_select_none%": "No interlinear projects exist for this source yet.",
"%interlinearizer_modal_select_name_unnamed%": "Unnamed",
"%interlinearizer_modal_select_info_button_label%": "Project info",
"%interlinearizer_modal_select_active_badge%": "Active",
"%interlinearizer_modal_select_create_new%": "Create New",
"%interlinearizer_modal_select_cancel%": "Cancel",

"%interlinearizer_confirm_discard_title%": "Discard unsaved changes?",
"%interlinearizer_confirm_discard_body%": "Your draft has changes that haven't been saved to a project. Continuing will discard them.",
"%interlinearizer_confirm_discard_ok%": "Discard",
"%interlinearizer_confirm_discard_cancel%": "Cancel",

"%interlinearizer_modal_saveAs_title%": "Save As",
"%interlinearizer_modal_saveAs_new_section%": "Save as a new project",
"%interlinearizer_modal_saveAs_save_new%": "Save as New Project",
"%interlinearizer_modal_saveAs_existing_section%": "Or overwrite an existing project",
"%interlinearizer_modal_saveAs_none%": "No existing projects for this source yet.",
"%interlinearizer_modal_saveAs_overwrite%": "Overwrite",
"%interlinearizer_modal_saveAs_overwrite_confirm_body%": "Overwrite this project? Its saved analysis will be replaced with the current draft.",
"%interlinearizer_modal_saveAs_overwrite_confirm_ok%": "Overwrite",
"%interlinearizer_modal_saveAs_overwrite_confirm_cancel%": "Cancel",
"%interlinearizer_modal_saveAs_cancel%": "Cancel",

"%interlinearizer_wipe_modal_title%": "Wipe draft analysis",
"%interlinearizer_wipe_modal_prompt%": "Choose how much of the draft's analysis to remove. Save afterward to persist the change to a project.",
"%interlinearizer_wipe_modal_scope_book%": "Current book",
"%interlinearizer_wipe_modal_scope_book_description%": "Removes all analysis for the current book from the draft.",
"%interlinearizer_wipe_modal_scope_all%": "Entire draft",
"%interlinearizer_wipe_modal_scope_all_description%": "Removes all analysis from the draft.",
"%interlinearizer_wipe_modal_confirm%": "Wipe",
"%interlinearizer_wipe_modal_cancel%": "Cancel",

"%interlinearizer_error_create_project_failed%": "Could not create the interlinearizer project. Please try again.",
"%interlinearizer_error_save_metadata_failed%": "Could not save project info. Please try again.",
"%interlinearizer_error_delete_project_failed%": "Could not delete the interlinearizer project. Please try again.",
"%interlinearizer_error_update_project_failed%": "Could not update the interlinearizer project. Please try again.",
"%interlinearizer_error_load_projects_failed%": "Could not load interlinear projects. Please try again."
"%interlinearizer_error_load_projects_failed%": "Could not load interlinear projects. Please try again.",
"%interlinearizer_error_save_draft_failed%": "Could not save your working draft. Please try again."
}
}
}
29 changes: 29 additions & 0 deletions contributions/menus.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@
"interlinearizer.projectActions": {
"column": "interlinearizer.project",
"order": 1
},
"interlinearizer.fileActions": {
"column": "interlinearizer.project",
"order": 2
},
"interlinearizer.draftActions": {
"column": "interlinearizer.project",
"order": 3
}
},
"items": [
Expand All @@ -50,6 +58,27 @@
"group": "interlinearizer.projectActions",
"order": 3,
"command": "interlinearizer.openProjectInfoModal"
},
{
"label": "%interlinearizer_save%",
"localizeNotes": "Interlinearizer top menu > Save the current draft to the active project",
"group": "interlinearizer.fileActions",
"order": 1,
"command": "interlinearizer.save"
},
{
"label": "%interlinearizer_saveAs%",
"localizeNotes": "Interlinearizer top menu > Save the draft to a new project or overwrite an existing one",
"group": "interlinearizer.fileActions",
"order": 2,
"command": "interlinearizer.openSaveAsModal"
},
{
"label": "%interlinearizer_wipe%",
"localizeNotes": "Interlinearizer top menu > Open the wipe dialog to remove the current book's or the whole draft's analysis",
"group": "interlinearizer.draftActions",
"order": 1,
"command": "interlinearizer.wipe"
}
]
}
Expand Down
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@
"verseref",
"versification",
"wordform",
"worktrees",
"ZWNJ"
],
"ignoreWords": ["Ελληνικά", "homme", "ʼelohim", "ʻokina"],
Expand Down
15 changes: 12 additions & 3 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,11 @@ const config: Config = {
'^(.+)\\.(scss|sass|css)\\?inline$': '<rootDir>/__mocks__/styleInlineMock.ts',
},

/** Exclude dist from module resolution to avoid Haste naming collision with root package.json. */
modulePathIgnorePatterns: ['<rootDir>/dist'],
/**
* Exclude `.claude` from module resolution to avoid nested git worktrees. Exclude `dist` from
* module resolution to avoid Haste naming collision with root package.json.
*/
modulePathIgnorePatterns: ['<rootDir>/.claude', '<rootDir>/dist'],

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

Expand Down
109 changes: 109 additions & 0 deletions src/__tests__/components/AnalysisStore.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
usePhraseDispatch,
usePhraseGloss,
usePhraseGlossDispatch,
useReportGlossEditing,
} from '../../components/AnalysisStore';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1146,3 +1147,111 @@ describe('useMorphemeGlossDispatch', () => {
);
});
});

/**
* Reports its `isEditing` prop through {@link useReportGlossEditing}, used to drive the provider's
* pending-edits accounting from tests.
*
* @param props - Component props.
* @param props.isEditing - Whether this stand-in input currently holds uncommitted text.
* @returns An empty fragment; the component exists only for its hook side effect.
*/
function EditingReporter({ isEditing }: Readonly<{ isEditing: boolean }>) {
useReportGlossEditing(isEditing);
return undefined;
}

describe('useReportGlossEditing', () => {
it('reports true when the first input starts editing and false when it stops', () => {
const onPendingEditsChange = jest.fn();
const { rerender } = render(
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
<EditingReporter isEditing={false} />
</AnalysisStoreProvider>,
);
// No editor active yet: nothing reported.
expect(onPendingEditsChange).not.toHaveBeenCalled();

rerender(
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
<EditingReporter isEditing />
</AnalysisStoreProvider>,
);
expect(onPendingEditsChange).toHaveBeenLastCalledWith(true);

rerender(
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
<EditingReporter isEditing={false} />
</AnalysisStoreProvider>,
);
expect(onPendingEditsChange).toHaveBeenLastCalledWith(false);
});

it('reports only the 0↔non-0 transitions when multiple inputs edit concurrently', () => {
const onPendingEditsChange = jest.fn();
const renderWith = (a: boolean, b: boolean) => (
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
<EditingReporter isEditing={a} />
<EditingReporter isEditing={b} />
</AnalysisStoreProvider>
);
const { rerender } = render(renderWith(false, false));
expect(onPendingEditsChange).not.toHaveBeenCalled();

rerender(renderWith(true, false));
expect(onPendingEditsChange).toHaveBeenCalledTimes(1);
expect(onPendingEditsChange).toHaveBeenLastCalledWith(true);

// Second input also starts editing: still pending, no new transition reported.
rerender(renderWith(true, true));
expect(onPendingEditsChange).toHaveBeenCalledTimes(1);

// First input stops: one editor remains, so still no transition.
rerender(renderWith(false, true));
expect(onPendingEditsChange).toHaveBeenCalledTimes(1);

// Last editor stops: now we cross back to zero.
rerender(renderWith(false, false));
expect(onPendingEditsChange).toHaveBeenCalledTimes(2);
expect(onPendingEditsChange).toHaveBeenLastCalledWith(false);
});

it('reports false when an actively-editing input unmounts', () => {
const onPendingEditsChange = jest.fn();
const { rerender } = render(
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
<EditingReporter isEditing />
</AnalysisStoreProvider>,
);
expect(onPendingEditsChange).toHaveBeenLastCalledWith(true);

rerender(
<AnalysisStoreProvider analysisLanguage="und" onPendingEditsChange={onPendingEditsChange}>
<span />
</AnalysisStoreProvider>,
);
expect(onPendingEditsChange).toHaveBeenLastCalledWith(false);
});

it('does not throw when no onPendingEditsChange is provided', () => {
const { rerender } = render(
<AnalysisStoreProvider analysisLanguage="und">
<EditingReporter isEditing={false} />
</AnalysisStoreProvider>,
);
expect(() =>
rerender(
<AnalysisStoreProvider analysisLanguage="und">
<EditingReporter isEditing />
</AnalysisStoreProvider>,
),
).not.toThrow();
});

it('throws when called outside an AnalysisStoreProvider', () => {
jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<EditingReporter isEditing={false} />)).toThrow(
'useReportGlossEditing must be used inside an AnalysisStoreProvider',
);
});
});
28 changes: 1 addition & 27 deletions src/__tests__/components/InterlinearNavContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,33 +11,7 @@ import {
useInterlinearNav,
} from '../../components/InterlinearNavContext';
import { RECENTER_FADE_MS } from '../../components/recenter-fade';

/** Tuple shape returned by the PAPI scroll-group hook. */
type ScrollGroupTuple = [
SerializedVerseRef,
(r: SerializedVerseRef) => void,
number | undefined,
(id: number | undefined) => void,
];

/**
* Builds a `useWebViewScrollGroupScrRef` stub returning the given tuple parts. Defaults cover the
* common case so a test only overrides what it asserts on.
*
* @param ref - The scripture reference the stub reports.
* @param setScrRef - The reference setter; defaults to a noop.
* @param scrollGroupId - The active scroll-group id; defaults to `undefined` (unlinked).
* @param setScrollGroupId - The scroll-group setter; defaults to a noop.
* @returns A hook returning the assembled tuple.
*/
function makeScrollGroupHook(
ref: SerializedVerseRef,
setScrRef: (r: SerializedVerseRef) => void = () => {},
scrollGroupId: number | undefined = undefined,
setScrollGroupId: (id: number | undefined) => void = () => {},
) {
return (): ScrollGroupTuple => [ref, setScrRef, scrollGroupId, setScrollGroupId];
}
import { makeScrollGroupHook, type ScrollGroupTuple } from '../test-helpers';

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