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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 4 additions & 2 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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,
Expand Down
20 changes: 0 additions & 20 deletions src/__tests__/components/CreateProjectModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/components/ProjectModals.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import type { InterlinearProjectSummary } from '../../types/interlinear-project-summary';

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

Expand Down
6 changes: 2 additions & 4 deletions src/components/CreateProjectModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}%`[] = [
Expand Down
4 changes: 2 additions & 2 deletions src/components/InterlinearizerLoader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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%'];

Expand Down Expand Up @@ -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<ActiveProjectState | undefined>(
const [activeProject] = useWebViewState<InterlinearProjectSummary | undefined>(
'activeProject',
undefined,
);
Expand Down
16 changes: 6 additions & 10 deletions src/components/ProjectModals.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<ActiveProjectState | undefined>(
'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
Expand Down
53 changes: 2 additions & 51 deletions src/components/SelectInterlinearProjectModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}%`[] = [
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/types/interlinear-project-summary.ts
Original file line number Diff line number Diff line change
@@ -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'
>;
27 changes: 27 additions & 0 deletions src/utils/interlinear-project-summary.ts
Original file line number Diff line number Diff line change
@@ -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')
);
}
Loading