diff --git a/AGENTS.md b/AGENTS.md index fc4e85a6..61f56ece 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,7 +99,7 @@ Key invariants: `Segment.baselineText.slice(charStart, charEnd) === Token.surfac ## Testing -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). +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. `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. diff --git a/jest.config.ts b/jest.config.ts index a5136314..670457f5 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -29,13 +29,15 @@ const config: Config = { */ collectCoverage: false, - /** Collect coverage from all source files. Excludes type declarations and test files. */ + /** Collect coverage from all source files. Excludes tests, types, and select utils. */ collectCoverageFrom: [ 'src/**/*.{ts,tsx}', '!src/**/*.d.ts', '!src/**/__tests__/**', '!src/**/*.test.{ts,tsx}', '!src/**/*.spec.{ts,tsx}', + '!src/types/**', + '!src/utils/interlinear-project-summary.ts', ], /** Directory for coverage output. */ @@ -53,7 +55,7 @@ const config: Config = { /** Ts-jest compiles TS to JS; V8 instruments it for coverage. */ coverageProvider: 'v8', - /** Enforce 100% coverage on parsers, main, and web-view. */ + /** Enforce 100% coverage on parsers, main, and web-view (except where explicitly excluded). */ coverageThreshold: { global: { branches: 100, diff --git a/src/__tests__/components/CreateProjectModal.test.tsx b/src/__tests__/components/CreateProjectModal.test.tsx index e82d8973..8bf0403d 100644 --- a/src/__tests__/components/CreateProjectModal.test.tsx +++ b/src/__tests__/components/CreateProjectModal.test.tsx @@ -8,26 +8,6 @@ import papi from '@papi/frontend'; import { useLocalizedStrings } from '@papi/frontend/react'; import { CreateProjectModal } from '../../components/CreateProjectModal'; -jest.mock('../../components/SelectInterlinearProjectModal', () => ({ - __esModule: true, - /** Minimal re-implementation that avoids importing the real module's coverage into this suite. */ - isInterlinearProjectSummary(p: unknown): boolean { - if (!p || typeof p !== 'object') return false; - if (!('id' in p) || typeof p.id !== 'string') return false; - if (!('createdAt' in p) || typeof p.createdAt !== 'string') return false; - if (!('sourceProjectId' in p) || typeof p.sourceProjectId !== 'string') return false; - if ( - !('analysisLanguages' in p) || - !Array.isArray(p.analysisLanguages) || - !p.analysisLanguages.every((l) => typeof l === 'string') - ) - return false; - if ('name' in p && typeof p.name !== 'string') return false; - if ('description' in p && typeof p.description !== 'string') return false; - return true; - }, -})); - const testProjectId = 'test-project-id'; describe('CreateProjectModal', () => { diff --git a/src/__tests__/components/ProjectModals.test.tsx b/src/__tests__/components/ProjectModals.test.tsx index 3ec06c9b..12cfbbdf 100644 --- a/src/__tests__/components/ProjectModals.test.tsx +++ b/src/__tests__/components/ProjectModals.test.tsx @@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event'; import { makeWebViewState } from '../test-helpers'; import type { ModalState } from '../../components/ProjectModals'; import ProjectModals from '../../components/ProjectModals'; -import type { InterlinearProjectSummary } from '../../components/SelectInterlinearProjectModal'; +import type { InterlinearProjectSummary } from '../../types/interlinear-project-summary'; /** Minimal project summary used in tests. */ const MOCK_PROJECT: InterlinearProjectSummary = { diff --git a/src/__tests__/components/SelectInterlinearProjectModal.test.tsx b/src/__tests__/components/SelectInterlinearProjectModal.test.tsx index 860a32a8..7bf717e6 100644 --- a/src/__tests__/components/SelectInterlinearProjectModal.test.tsx +++ b/src/__tests__/components/SelectInterlinearProjectModal.test.tsx @@ -6,10 +6,8 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import papi from '@papi/frontend'; import { useLocalizedStrings } from '@papi/frontend/react'; -import { - SelectInterlinearProjectModal, - type InterlinearProjectSummary, -} from '../../components/SelectInterlinearProjectModal'; +import { SelectInterlinearProjectModal } from '../../components/SelectInterlinearProjectModal'; +import type { InterlinearProjectSummary } from '../../types/interlinear-project-summary'; const mockSendCommand = jest.mocked(papi.commands.sendCommand); diff --git a/src/components/CreateProjectModal.tsx b/src/components/CreateProjectModal.tsx index d00e79d9..0b061ea6 100644 --- a/src/components/CreateProjectModal.tsx +++ b/src/components/CreateProjectModal.tsx @@ -2,10 +2,8 @@ import papi, { logger } from '@papi/frontend'; import { useLocalizedStrings } from '@papi/frontend/react'; import { Button } from 'platform-bible-react'; import { useState, useCallback, useRef } from 'react'; -import { - type InterlinearProjectSummary, - isInterlinearProjectSummary, -} from './SelectInterlinearProjectModal'; +import type { InterlinearProjectSummary } from '../types/interlinear-project-summary'; +import { isInterlinearProjectSummary } from '../utils/interlinear-project-summary'; /** Localized string keys used by {@link CreateProjectModal}. */ const CREATE_PROJECT_MODAL_STRING_KEYS: `%${string}%`[] = [ diff --git a/src/components/InterlinearizerLoader.tsx b/src/components/InterlinearizerLoader.tsx index e8cdc928..55368cdc 100644 --- a/src/components/InterlinearizerLoader.tsx +++ b/src/components/InterlinearizerLoader.tsx @@ -7,11 +7,11 @@ import { isPlatformError } from 'platform-bible-utils'; import { useCallback, useMemo, useState } from 'react'; import useInterlinearizerBookData from '../hooks/useInterlinearizerBookData'; import useOptimisticBooleanSetting from '../hooks/useOptimisticBooleanSetting'; +import type { InterlinearProjectSummary } from '../types/interlinear-project-summary'; import ContinuousScrollToggle from './ContinuousScrollToggle'; import Interlinearizer from './Interlinearizer'; import ProjectModals, { type ModalState } from './ProjectModals'; import ScriptureNavControls from './ScriptureNavControls'; -import type { ActiveProjectState } from './SelectInterlinearProjectModal'; const STRING_KEYS: `%${string}%`[] = ['%interlinearizer_continuousScrollToggle%']; @@ -64,7 +64,7 @@ export default function InterlinearizerLoader({ * restores. The setter lives in {@link ProjectModals}, which writes to the same `'activeProject'` * key; this component reads the value to decide which menu items to show. */ - const [activeProject] = useWebViewState( + const [activeProject] = useWebViewState( 'activeProject', undefined, ); diff --git a/src/components/ProjectModals.tsx b/src/components/ProjectModals.tsx index 90f044d3..93666b24 100644 --- a/src/components/ProjectModals.tsx +++ b/src/components/ProjectModals.tsx @@ -1,12 +1,9 @@ import type { UseWebViewStateHook } from '@papi/core'; import { useCallback, useState } from 'react'; +import type { InterlinearProjectSummary } from '../types/interlinear-project-summary'; import { CreateProjectModal } from './CreateProjectModal'; import { ProjectMetadataModal } from './ProjectMetadataModal'; -import { - type ActiveProjectState, - type InterlinearProjectSummary, - SelectInterlinearProjectModal, -} from './SelectInterlinearProjectModal'; +import { SelectInterlinearProjectModal } from './SelectInterlinearProjectModal'; /** Which modal is currently visible. Only one can be open at a time. */ export type ModalState = 'none' | 'select' | 'create' | 'metadata'; @@ -33,16 +30,15 @@ export default function ProjectModals({ setModal, useWebViewState, }: Readonly<{ - activeProject: ActiveProjectState | undefined; + activeProject: InterlinearProjectSummary | undefined; modal: ModalState; projectId: string; setModal: (modal: ModalState) => void; useWebViewState: UseWebViewStateHook; }>) { - const [, setActiveProject, resetActiveProject] = useWebViewState( - 'activeProject', - undefined, - ); + const [, setActiveProject, resetActiveProject] = useWebViewState< + InterlinearProjectSummary | undefined + >('activeProject', undefined); /** * The project currently open in the metadata modal. Set when the user clicks the info icon in the diff --git a/src/components/SelectInterlinearProjectModal.tsx b/src/components/SelectInterlinearProjectModal.tsx index b4e607af..174af8d2 100644 --- a/src/components/SelectInterlinearProjectModal.tsx +++ b/src/components/SelectInterlinearProjectModal.tsx @@ -3,7 +3,8 @@ import { useLocalizedStrings } from '@papi/frontend/react'; import { Info } from 'lucide-react'; import { Button } from 'platform-bible-react'; import { useCallback, useEffect, useRef, useState } from 'react'; -import type { InterlinearProject } from 'interlinearizer'; +import type { InterlinearProjectSummary } from '../types/interlinear-project-summary'; +import { isInterlinearProjectSummary } from '../utils/interlinear-project-summary'; /** Localized string keys used by {@link SelectInterlinearProjectModal}. */ const SELECT_INTERLINEAR_PROJECT_STRING_KEYS: `%${string}%`[] = [ @@ -15,56 +16,6 @@ const SELECT_INTERLINEAR_PROJECT_STRING_KEYS: `%${string}%`[] = [ '%interlinearizer_modal_select_info_button_label%', ]; -/** The subset of InterlinearProject fields this modal displays and returns. */ -export type InterlinearProjectSummary = Pick< - InterlinearProject, - | 'id' - | 'createdAt' - | 'sourceProjectId' - | 'targetProjectId' - | 'analysisLanguages' - | 'name' - | 'description' ->; - -/** Fields of the active interlinear project persisted in WebView state. */ -export type ActiveProjectState = Pick< - InterlinearProjectSummary, - | 'id' - | 'createdAt' - | 'name' - | 'description' - | 'sourceProjectId' - | 'targetProjectId' - | 'analysisLanguages' ->; - -/** - * Type guard for {@link InterlinearProjectSummary} parsed from unknown JSON. - * - * @param p - The value to test, typically a parsed JSON object of unknown shape. - * @returns `true` if `p` satisfies the {@link InterlinearProjectSummary} shape, narrowing its type - * accordingly. - */ -export function isInterlinearProjectSummary(p: unknown): p is InterlinearProjectSummary { - return ( - !!p && - typeof p === 'object' && - 'id' in p && - typeof p.id === 'string' && - 'createdAt' in p && - typeof p.createdAt === 'string' && - 'sourceProjectId' in p && - typeof p.sourceProjectId === 'string' && - 'analysisLanguages' in p && - Array.isArray(p.analysisLanguages) && - p.analysisLanguages.every((l) => typeof l === 'string') && - (!('name' in p) || typeof p.name === 'string') && - (!('description' in p) || typeof p.description === 'string') && - (!('targetProjectId' in p) || typeof p.targetProjectId === 'string') - ); -} - /** * Modal that lists all existing interlinearizer projects for a source project and lets the user * select one, view its details (via the info icon), or request that a new one be created. Fires diff --git a/src/types/interlinear-project-summary.ts b/src/types/interlinear-project-summary.ts new file mode 100644 index 00000000..2528268f --- /dev/null +++ b/src/types/interlinear-project-summary.ts @@ -0,0 +1,13 @@ +import type { InterlinearProject } from 'interlinearizer'; + +/** Displayable summary of an interlinear project used across project selection and metadata UI. */ +export type InterlinearProjectSummary = Pick< + InterlinearProject, + | 'id' + | 'createdAt' + | 'sourceProjectId' + | 'targetProjectId' + | 'analysisLanguages' + | 'name' + | 'description' +>; diff --git a/src/utils/interlinear-project-summary.ts b/src/utils/interlinear-project-summary.ts new file mode 100644 index 00000000..0e1c5191 --- /dev/null +++ b/src/utils/interlinear-project-summary.ts @@ -0,0 +1,27 @@ +import type { InterlinearProjectSummary } from '../types/interlinear-project-summary'; + +/** + * Type guard for {@link InterlinearProjectSummary} parsed from unknown JSON. + * + * @param p - The value to test, typically a parsed JSON object of unknown shape. + * @returns `true` if `p` satisfies the {@link InterlinearProjectSummary} shape, narrowing its type + * accordingly. + */ +export function isInterlinearProjectSummary(p: unknown): p is InterlinearProjectSummary { + return ( + !!p && + typeof p === 'object' && + 'id' in p && + typeof p.id === 'string' && + 'createdAt' in p && + typeof p.createdAt === 'string' && + 'sourceProjectId' in p && + typeof p.sourceProjectId === 'string' && + 'analysisLanguages' in p && + Array.isArray(p.analysisLanguages) && + p.analysisLanguages.every((l) => typeof l === 'string') && + (!('name' in p) || typeof p.name === 'string') && + (!('description' in p) || typeof p.description === 'string') && + (!('targetProjectId' in p) || typeof p.targetProjectId === 'string') + ); +}