Skip to content

Commit ac655ea

Browse files
authored
Extract modal type, util (#81)
1 parent 5dcab20 commit ac655ea

11 files changed

Lines changed: 60 additions & 95 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ Key invariants: `Segment.baselineText.slice(charStart, charEnd) === Token.surfac
9999

100100
## Testing
101101

102-
Jest with ts-jest, jsdom environment. PAPI is fully mocked in `__mocks__/`. Coverage is enforced at 100% on all `src/**` files (branches, functions, lines, statements).
102+
Jest with ts-jest, jsdom environment. PAPI is fully mocked in `__mocks__/`. Coverage is enforced at 100% on all `src/**` files (branches, functions, lines, statements), aside for select explicit exclusions.
103103

104104
`resetMocks: true` is set globally — mock implementations are cleared before every test, so each test must set up its own mocks (typically in `beforeEach`). Never rely on implementation state leaking from a prior test.
105105

jest.config.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,15 @@ const config: Config = {
2929
*/
3030
collectCoverage: false,
3131

32-
/** Collect coverage from all source files. Excludes type declarations and test files. */
32+
/** Collect coverage from all source files. Excludes tests, types, and select utils. */
3333
collectCoverageFrom: [
3434
'src/**/*.{ts,tsx}',
3535
'!src/**/*.d.ts',
3636
'!src/**/__tests__/**',
3737
'!src/**/*.test.{ts,tsx}',
3838
'!src/**/*.spec.{ts,tsx}',
39+
'!src/types/**',
40+
'!src/utils/interlinear-project-summary.ts',
3941
],
4042

4143
/** Directory for coverage output. */
@@ -53,7 +55,7 @@ const config: Config = {
5355
/** Ts-jest compiles TS to JS; V8 instruments it for coverage. */
5456
coverageProvider: 'v8',
5557

56-
/** Enforce 100% coverage on parsers, main, and web-view. */
58+
/** Enforce 100% coverage on parsers, main, and web-view (except where explicitly excluded). */
5759
coverageThreshold: {
5860
global: {
5961
branches: 100,

src/__tests__/components/CreateProjectModal.test.tsx

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,6 @@ import papi from '@papi/frontend';
88
import { useLocalizedStrings } from '@papi/frontend/react';
99
import { CreateProjectModal } from '../../components/CreateProjectModal';
1010

11-
jest.mock('../../components/SelectInterlinearProjectModal', () => ({
12-
__esModule: true,
13-
/** Minimal re-implementation that avoids importing the real module's coverage into this suite. */
14-
isInterlinearProjectSummary(p: unknown): boolean {
15-
if (!p || typeof p !== 'object') return false;
16-
if (!('id' in p) || typeof p.id !== 'string') return false;
17-
if (!('createdAt' in p) || typeof p.createdAt !== 'string') return false;
18-
if (!('sourceProjectId' in p) || typeof p.sourceProjectId !== 'string') return false;
19-
if (
20-
!('analysisLanguages' in p) ||
21-
!Array.isArray(p.analysisLanguages) ||
22-
!p.analysisLanguages.every((l) => typeof l === 'string')
23-
)
24-
return false;
25-
if ('name' in p && typeof p.name !== 'string') return false;
26-
if ('description' in p && typeof p.description !== 'string') return false;
27-
return true;
28-
},
29-
}));
30-
3111
const testProjectId = 'test-project-id';
3212

3313
describe('CreateProjectModal', () => {

src/__tests__/components/ProjectModals.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event';
77
import { makeWebViewState } from '../test-helpers';
88
import type { ModalState } from '../../components/ProjectModals';
99
import ProjectModals from '../../components/ProjectModals';
10-
import type { InterlinearProjectSummary } from '../../components/SelectInterlinearProjectModal';
10+
import type { InterlinearProjectSummary } from '../../types/interlinear-project-summary';
1111

1212
/** Minimal project summary used in tests. */
1313
const MOCK_PROJECT: InterlinearProjectSummary = {

src/__tests__/components/SelectInterlinearProjectModal.test.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,8 @@ import { render, screen, waitFor } from '@testing-library/react';
66
import userEvent from '@testing-library/user-event';
77
import papi from '@papi/frontend';
88
import { useLocalizedStrings } from '@papi/frontend/react';
9-
import {
10-
SelectInterlinearProjectModal,
11-
type InterlinearProjectSummary,
12-
} from '../../components/SelectInterlinearProjectModal';
9+
import { SelectInterlinearProjectModal } from '../../components/SelectInterlinearProjectModal';
10+
import type { InterlinearProjectSummary } from '../../types/interlinear-project-summary';
1311

1412
const mockSendCommand = jest.mocked(papi.commands.sendCommand);
1513

src/components/CreateProjectModal.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,8 @@ import papi, { logger } from '@papi/frontend';
22
import { useLocalizedStrings } from '@papi/frontend/react';
33
import { Button } from 'platform-bible-react';
44
import { useState, useCallback, useRef } from 'react';
5-
import {
6-
type InterlinearProjectSummary,
7-
isInterlinearProjectSummary,
8-
} from './SelectInterlinearProjectModal';
5+
import type { InterlinearProjectSummary } from '../types/interlinear-project-summary';
6+
import { isInterlinearProjectSummary } from '../utils/interlinear-project-summary';
97

108
/** Localized string keys used by {@link CreateProjectModal}. */
119
const CREATE_PROJECT_MODAL_STRING_KEYS: `%${string}%`[] = [

src/components/InterlinearizerLoader.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ import { isPlatformError } from 'platform-bible-utils';
77
import { useCallback, useMemo, useState } from 'react';
88
import useInterlinearizerBookData from '../hooks/useInterlinearizerBookData';
99
import useOptimisticBooleanSetting from '../hooks/useOptimisticBooleanSetting';
10+
import type { InterlinearProjectSummary } from '../types/interlinear-project-summary';
1011
import ContinuousScrollToggle from './ContinuousScrollToggle';
1112
import Interlinearizer from './Interlinearizer';
1213
import ProjectModals, { type ModalState } from './ProjectModals';
1314
import ScriptureNavControls from './ScriptureNavControls';
14-
import type { ActiveProjectState } from './SelectInterlinearProjectModal';
1515

1616
const STRING_KEYS: `%${string}%`[] = ['%interlinearizer_continuousScrollToggle%'];
1717

@@ -64,7 +64,7 @@ export default function InterlinearizerLoader({
6464
* restores. The setter lives in {@link ProjectModals}, which writes to the same `'activeProject'`
6565
* key; this component reads the value to decide which menu items to show.
6666
*/
67-
const [activeProject] = useWebViewState<ActiveProjectState | undefined>(
67+
const [activeProject] = useWebViewState<InterlinearProjectSummary | undefined>(
6868
'activeProject',
6969
undefined,
7070
);

src/components/ProjectModals.tsx

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,9 @@
11
import type { UseWebViewStateHook } from '@papi/core';
22
import { useCallback, useState } from 'react';
3+
import type { InterlinearProjectSummary } from '../types/interlinear-project-summary';
34
import { CreateProjectModal } from './CreateProjectModal';
45
import { ProjectMetadataModal } from './ProjectMetadataModal';
5-
import {
6-
type ActiveProjectState,
7-
type InterlinearProjectSummary,
8-
SelectInterlinearProjectModal,
9-
} from './SelectInterlinearProjectModal';
6+
import { SelectInterlinearProjectModal } from './SelectInterlinearProjectModal';
107

118
/** Which modal is currently visible. Only one can be open at a time. */
129
export type ModalState = 'none' | 'select' | 'create' | 'metadata';
@@ -33,16 +30,15 @@ export default function ProjectModals({
3330
setModal,
3431
useWebViewState,
3532
}: Readonly<{
36-
activeProject: ActiveProjectState | undefined;
33+
activeProject: InterlinearProjectSummary | undefined;
3734
modal: ModalState;
3835
projectId: string;
3936
setModal: (modal: ModalState) => void;
4037
useWebViewState: UseWebViewStateHook;
4138
}>) {
42-
const [, setActiveProject, resetActiveProject] = useWebViewState<ActiveProjectState | undefined>(
43-
'activeProject',
44-
undefined,
45-
);
39+
const [, setActiveProject, resetActiveProject] = useWebViewState<
40+
InterlinearProjectSummary | undefined
41+
>('activeProject', undefined);
4642

4743
/**
4844
* The project currently open in the metadata modal. Set when the user clicks the info icon in the

src/components/SelectInterlinearProjectModal.tsx

Lines changed: 2 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { useLocalizedStrings } from '@papi/frontend/react';
33
import { Info } from 'lucide-react';
44
import { Button } from 'platform-bible-react';
55
import { useCallback, useEffect, useRef, useState } from 'react';
6-
import type { InterlinearProject } from 'interlinearizer';
6+
import type { InterlinearProjectSummary } from '../types/interlinear-project-summary';
7+
import { isInterlinearProjectSummary } from '../utils/interlinear-project-summary';
78

89
/** Localized string keys used by {@link SelectInterlinearProjectModal}. */
910
const SELECT_INTERLINEAR_PROJECT_STRING_KEYS: `%${string}%`[] = [
@@ -15,56 +16,6 @@ const SELECT_INTERLINEAR_PROJECT_STRING_KEYS: `%${string}%`[] = [
1516
'%interlinearizer_modal_select_info_button_label%',
1617
];
1718

18-
/** The subset of InterlinearProject fields this modal displays and returns. */
19-
export type InterlinearProjectSummary = Pick<
20-
InterlinearProject,
21-
| 'id'
22-
| 'createdAt'
23-
| 'sourceProjectId'
24-
| 'targetProjectId'
25-
| 'analysisLanguages'
26-
| 'name'
27-
| 'description'
28-
>;
29-
30-
/** Fields of the active interlinear project persisted in WebView state. */
31-
export type ActiveProjectState = Pick<
32-
InterlinearProjectSummary,
33-
| 'id'
34-
| 'createdAt'
35-
| 'name'
36-
| 'description'
37-
| 'sourceProjectId'
38-
| 'targetProjectId'
39-
| 'analysisLanguages'
40-
>;
41-
42-
/**
43-
* Type guard for {@link InterlinearProjectSummary} parsed from unknown JSON.
44-
*
45-
* @param p - The value to test, typically a parsed JSON object of unknown shape.
46-
* @returns `true` if `p` satisfies the {@link InterlinearProjectSummary} shape, narrowing its type
47-
* accordingly.
48-
*/
49-
export function isInterlinearProjectSummary(p: unknown): p is InterlinearProjectSummary {
50-
return (
51-
!!p &&
52-
typeof p === 'object' &&
53-
'id' in p &&
54-
typeof p.id === 'string' &&
55-
'createdAt' in p &&
56-
typeof p.createdAt === 'string' &&
57-
'sourceProjectId' in p &&
58-
typeof p.sourceProjectId === 'string' &&
59-
'analysisLanguages' in p &&
60-
Array.isArray(p.analysisLanguages) &&
61-
p.analysisLanguages.every((l) => typeof l === 'string') &&
62-
(!('name' in p) || typeof p.name === 'string') &&
63-
(!('description' in p) || typeof p.description === 'string') &&
64-
(!('targetProjectId' in p) || typeof p.targetProjectId === 'string')
65-
);
66-
}
67-
6819
/**
6920
* Modal that lists all existing interlinearizer projects for a source project and lets the user
7021
* select one, view its details (via the info icon), or request that a new one be created. Fires
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { InterlinearProject } from 'interlinearizer';
2+
3+
/** Displayable summary of an interlinear project used across project selection and metadata UI. */
4+
export type InterlinearProjectSummary = Pick<
5+
InterlinearProject,
6+
| 'id'
7+
| 'createdAt'
8+
| 'sourceProjectId'
9+
| 'targetProjectId'
10+
| 'analysisLanguages'
11+
| 'name'
12+
| 'description'
13+
>;

0 commit comments

Comments
 (0)