Skip to content
Open
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
28 changes: 28 additions & 0 deletions __mocks__/papi-frontend-react.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,33 @@
*/
const KNOWN_PROJECT_DATA_METHODS = new Set(['BookUSJ']);

/** Known method names on data providers accessed via {@link useData}. */
const KNOWN_DATA_METHODS = new Set(['WebViewMenu']);

/**
* Mock for `useData`. Returns an object whose known methods each return
* `[undefined, jest.fn(), false]`, matching the real hook's `[data, setter, isLoading]` tuple.
* Accessing an unknown method throws to catch misspelled provider keys in tests.
*
* @param _providerName - Ignored data provider name.
* @returns Proxy object exposing known data methods.
*/
const useData = jest.fn((_providerName: unknown) =>
new Proxy(
{},
{
get(_target, prop: string | symbol) {
if (typeof prop === 'string' && KNOWN_DATA_METHODS.has(prop)) {
return () => [undefined, jest.fn(), false];
}
throw new Error(
`useData mock: unknown method "${String(prop)}". Add it to KNOWN_DATA_METHODS if intentional.`,
);
},
},
),
);

/**
* Mock for `useProjectData`. Returns an object whose known methods each return
* `[undefined, jest.fn(), false]`, matching the real hook's `[data, setter, isLoading]` tuple.
Expand Down Expand Up @@ -74,6 +101,7 @@ const useRecentScriptureRefs = jest

module.exports = {
__esModule: true,
useData,
useProjectData,
useProjectSetting,
useLocalizedStrings,
Expand Down
7 changes: 7 additions & 0 deletions __mocks__/papi-frontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,15 @@ const mockLogger = {
warn: jest.fn(),
};

const mockPapi = {
menuData: {
dataProviderName: 'platform.menuDataServiceDataProvider',
},
};

module.exports = {
__esModule: true,
default: mockPapi,
logger: mockLogger,
};

Expand Down
152 changes: 142 additions & 10 deletions __mocks__/platform-bible-react.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
/**
* @file Jest mock for platform-bible-react. The real package ships ESM which Jest cannot parse
* without extra transform configuration. This stub provides the subset used by extension
* components: `BookChapterControl`, `BOOK_CHAPTER_CONTROL_STRING_KEYS`, `TabToolbar`, and
* `ScrollGroupSelector`.
* components: `BookChapterControl`, `BOOK_CHAPTER_CONTROL_STRING_KEYS`, `TabToolbar`,
* `ScrollGroupSelector`, `Switch`, and `Label`.
*/

import type { ReactElement, ReactNode } from 'react';

export interface MenuItemContainingCommand {
label: `%${string}%`;
command: `${string}.${string}`;
group: `${string}.${string}`;
order: number;
localizeNotes: string;
tooltip?: `%${string}%`;
searchTerms?: `%${string}%`;
iconPathBefore?: string;
iconPathAfter?: string;
}

export type SelectMenuItemHandler = (selectedMenuItem: MenuItemContainingCommand) => void;

interface SerializedVerseRef {
book: string;
chapterNum: number;
Expand All @@ -15,40 +29,120 @@ interface SerializedVerseRef {
versificationStr?: string;
}

export const BOOK_CHAPTER_CONTROL_STRING_KEYS: string[] = [];
/** Localization keys required by {@link BookChapterControl}. */
export const BOOK_CHAPTER_CONTROL_STRING_KEYS = [
'%scripture_section_ot_long%',
'%scripture_section_nt_long%',
'%scripture_section_dc_long%',
'%scripture_section_extra_long%',
'%history_recent%',
'%history_recentSearches_ariaLabel%',
] as const;

/** Sentinel menu item fired when the retokenize toolbar button is clicked in tests. */
export const MOCK_RETOKENIZE_MENU_ITEM: MenuItemContainingCommand = {
label: '%interlinearizer_retokenize%',
command: 'interlinearizer.retokenize',
group: 'interlinearizer.projectData',
order: 1,
localizeNotes: '',
};

/**
* Stub toolbar that renders a fixed button per known project menu action, each firing a sentinel
* {@link MenuItemContainingCommand} so tests can trigger commands by clicking a stable
* `data-testid` without coupling to `projectMenuData` shape.
*
* @param props - Component props.
* @param props.startAreaChildren - Content rendered in the start slot.
* @param props.endAreaChildren - Content rendered in the end slot.
* @param props.onSelectProjectMenuItem - Called with a sentinel item when a project-menu button is
* clicked.
* @param props.onSelectViewInfoMenuItem - Called with a sentinel item when the view-info button is
* clicked.
* @returns A div with `data-testid="tab-toolbar"` containing the rendered buttons.
*/
export function TabToolbar({
startAreaChildren,
endAreaChildren,
onSelectProjectMenuItem,
onSelectViewInfoMenuItem,
}: Readonly<{
className?: string;
startAreaChildren?: ReactNode;
centerAreaChildren?: ReactNode;
endAreaChildren?: ReactNode;
onSelectProjectMenuItem?: () => void;
onSelectViewInfoMenuItem?: () => void;
onSelectProjectMenuItem: SelectMenuItemHandler;
onSelectViewInfoMenuItem: SelectMenuItemHandler;
projectMenuData?: unknown;
tabViewMenuData?: unknown;
id?: string;
menuButtonIcon?: ReactNode;
}>): ReactElement {
return (
<div data-testid="tab-toolbar">
<div data-testid="tab-toolbar-start">{startAreaChildren}</div>
<div data-testid="tab-toolbar-end">{endAreaChildren}</div>
{onSelectProjectMenuItem && (
<button
type="button"
data-testid="tab-toolbar-retokenize"
onClick={() => onSelectProjectMenuItem(MOCK_RETOKENIZE_MENU_ITEM)}
>
Retokenize
</button>
)}
{onSelectViewInfoMenuItem && (
<button
type="button"
data-testid="tab-toolbar-view-info-menu"
onClick={() =>
onSelectViewInfoMenuItem({
label: '%mock.viewInfo%',
command: 'mock.viewInfo',
group: 'mock.group',
order: 0,
localizeNotes: '',
})
}
>
View info menu
</button>
)}
</div>
);
}

/**
* Stub scroll-group selector rendered as a native `<select>` so tests can change the scroll group
* without the real component's styling or animation.
*
* @param props - Component props.
* @param props.availableScrollGroupIds - IDs to populate as `<option>` elements.
* @param props.scrollGroupId - The currently selected group ID.
* @param props.onChangeScrollGroupId - Called with the newly selected ID when the selection changes.
* @returns A `<select data-testid="scroll-group-selector">` element.
*/
export function ScrollGroupSelector({
availableScrollGroupIds,
scrollGroupId,
onChangeScrollGroupId,
}: Readonly<{
availableScrollGroupIds?: (number | undefined)[];
scrollGroupId?: number;
onChangeScrollGroupId?: (id: number | undefined) => void;
availableScrollGroupIds: (number | undefined)[];
scrollGroupId: number | undefined;
onChangeScrollGroupId: (id: number | undefined) => void;
localizedStrings?: Record<string, string>;
size?: 'default' | 'sm';
className?: string;
id?: string;
}>): ReactElement {
return (
<select
data-testid="scroll-group-selector"
value={scrollGroupId ?? ''}
onChange={(e) => onChangeScrollGroupId?.(e.target.value === '' ? undefined : Number(e.target.value))}
onChange={(e) =>
onChangeScrollGroupId(e.target.value === '' ? undefined : Number(e.target.value))
}
>
<option value="">—</option>
{availableScrollGroupIds?.map((id) => (
Expand All @@ -60,6 +154,16 @@ export function ScrollGroupSelector({
);
}

/**
* Stub book/chapter control that displays the current reference as text and exposes a single
* "Submit reference" button so tests can simulate reference changes without the real picker UI.
*
* @param props - Component props.
* @param props.scrRef - The currently displayed scripture reference.
* @param props.handleSubmit - Called with `scrRef` when the submit button is clicked.
* @param props.onAddRecentSearch - Called with `scrRef` after `handleSubmit` when provided.
* @returns A `<div data-testid="book-chapter-control">` with a submit button.
*/
export function BookChapterControl({
scrRef,
handleSubmit,
Expand All @@ -68,6 +172,8 @@ export function BookChapterControl({
scrRef: SerializedVerseRef;
handleSubmit: (ref: SerializedVerseRef) => void;
className?: string;
getActiveBookIds?: () => string[];
localizedBookNames?: Map<string, { localizedId: string; localizedName: string }>;
localizedStrings?: Record<string, string>;
recentSearches?: SerializedVerseRef[];
onAddRecentSearch?: (scrRef: SerializedVerseRef) => void;
Expand All @@ -76,13 +182,30 @@ export function BookChapterControl({
return (
<div data-testid="book-chapter-control">
{scrRef.book} {scrRef.chapterNum}:{scrRef.verseNum}
<button type="button" onClick={() => {handleSubmit(scrRef); onAddRecentSearch?.(scrRef);}}>
<button
type="button"
onClick={() => {
handleSubmit(scrRef);
onAddRecentSearch?.(scrRef);
}}
>
Submit reference
</button>
</div>
);
}

/**
* Stub toggle switch rendered as a native checkbox so tests can read and change the checked state
* without the real Radix UI implementation.
*
* @param props - Component props.
* @param props.checked - Whether the switch is on.
* @param props.disabled - Whether the switch is disabled.
* @param props.id - HTML `id` attribute forwarded to the input.
* @param props.onCheckedChange - Called with the new boolean state on change.
* @returns A native `<input type="checkbox">` element.
*/
export function Switch({
checked,
disabled,
Expand All @@ -105,6 +228,15 @@ export function Switch({
);
}

/**
* Stub label rendered as a native `<label>` element.
*
* @param props - Component props.
* @param props.children - Label content.
* @param props.className - CSS class names.
* @param props.htmlFor - ID of the associated form control.
* @returns A native `<label>` element.
*/
export function Label({
children,
className,
Expand Down
4 changes: 3 additions & 1 deletion contributions/localizedStrings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
"%interlinearizer_projectSettings_title%": "Interlinearizer",
"%interlinearizer_projectSettings_continuousScroll%": "Continuous Scroll",
"%interlinearizer_projectSettings_continuousScrollDescription%": "Display tokens in a continuous horizontal scroll strip instead of chapter-segmented rows",
"%interlinearizer_continuousScrollToggle%": "Continuous Scroll"
"%interlinearizer_continuousScrollToggle%": "Continuous Scroll",
"%interlinearizer_menu_column_project%": "Project",
"%interlinearizer_retokenize%": "Retokenize Book"
}
}
}
26 changes: 26 additions & 0 deletions contributions/menus.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,32 @@
"items": []
},
"webViewMenus": {
"interlinearizer.mainWebView": {
"topMenu": {
"columns": {
"interlinearizer.project": {
"label": "%interlinearizer_menu_column_project%",
"localizeNotes": "Interlinearizer top menu column for project actions",
"order": 1
}
},
"groups": {
"interlinearizer.projectActions": {
"column": "interlinearizer.project",
"order": 1
}
},
"items": [
{
"label": "%interlinearizer_retokenize%",
"localizeNotes": "Interlinearizer top menu > Re-run tokenization from the latest USJ",
"group": "interlinearizer.projectActions",
"order": 1,
"command": "interlinearizer.retokenize"
}
]
}
},
"platformScriptureEditor.react": {
"topMenu": {
"columns": {},
Expand Down
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@
"recalc",
"reinitializing",
"reserialized",
"retokenize",
"sandboxed",
"scriptio",
"sillsdev",
Expand Down
Loading
Loading