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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,8 @@ Saved projects are listed through [ProjectSummaries](src/services/projectSummari

[AnalysisSessions](src/services/analysisSessions.ts) keeps the host's sessions, behind the `openSession`, `closeSession` and `setAnalysisLanguage` commands. A session is a handle on one source project's draft, paired with the interlinear project it saves to, or on a Paratext 9 import alone, read-only. A source has at most one session: opening another closes it. Each session runs its commands one at a time, through `run`, or `runEdit` for a command that changes stored analysis, which a read-only session refuses. An edit reaches the repository as a `ChangeSet` of whole-record puts and deletes, applied through the session's [EditHistory](src/services/editHistory.ts) so that undo can revert it; boundary changes ride in the change set's scope record. The WebView doesn't use sessions yet.

Host commands live in [src/services/commands/](src/services/commands/), one module per family. A command stages its changes in a [ChangeBuffer](src/services/commands/changeBuffer.ts), whose reads answer as the repository will once those changes apply, and `AnalysisSessions.edit` applies what it staged as one undo step after the approval rule passes. Each command copies the reducer it replaces, step for step, and its parity tests run one input through both with [runParity](src/__tests__/services/commands/parity.ts); a reducer change in Phase 3 updates its command and parity tests in the same PR.

### Components

Prefer Platform.Bible's `platform-bible-react` components (`Button`, `Input`, `Textarea`, `Label`, `Switch`, `RadioGroup`, `Popover`, `Tooltip`, etc.) over hand-rolled raw HTML (`<button>`, `<input>`, `<textarea>`, `<label>`, custom dropdowns) wherever an equivalent exists — this keeps the UI visually and behaviorally consistent with the host app and gives us theming and accessibility for free. Reserve raw elements for cases where the platform component can't preserve behavior the extension depends on (e.g. `field-sizing: content` gloss inputs); when you do, add a comment explaining why. When a raw element is replaced by a platform component in tests, extend `__mocks__/platform-bible-react.tsx` to stub the newly-used component.
Expand Down
21 changes: 21 additions & 0 deletions src/__tests__/analysis-repository-conformance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,27 @@ export function describeAnalysisRepositoryConformance(
return store.open(SOURCE);
}

it("reads the draft's own record", async () => {
const repository = await overFullDraft();

await expect(repository.getScope()).resolves.toStrictEqual(scopeRecordOf(fullDraft()));
});

it("reads the record of a draft never saved as an empty draft's", async () => {
await expect((await store.open(SOURCE)).getScope()).resolves.toStrictEqual(
scopeRecordOf(emptyDraft(SOURCE)),
);
});

it('reads the record the applied changes leave', async () => {
const repository = await overFullDraft();
const scope = { ...scopeRecordOf(fullDraft()), analysisLanguages: ['de'] };

await repository.apply({ intent: 'rescope', scope });

await expect(repository.getScope()).resolves.toStrictEqual(scope);
});

it('gets each payload kind by id, once per id, skipping ids with nothing stored', async () => {
const repository = await overFullDraft();

Expand Down
58 changes: 58 additions & 0 deletions src/__tests__/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import * as projectStorage from '../services/projectStorage';
import * as pt9ImportService from '../services/pt9ImportService';
import { type AnalysisRepository, UnreadableBooksError } from '../types/analysis-repository';
import { emptyDraft } from '../types/empty-factories';
import { scopeRecordOf } from '../utils/change-set';
import { toProjectSummary } from '../types/interlinear-project-summary';
import { createTestActivationContext, makeStubProject } from './test-helpers';

Expand Down Expand Up @@ -401,6 +402,8 @@ describe('main', () => {
'interlinearizer.undoEdit',
'interlinearizer.redoEdit',
'interlinearizer.checkIndexes',
'interlinearizer.writeGloss',
'interlinearizer.approveAnalysisForToken',
'interlinearizer.openSelectProjectModal',
'interlinearizer.openNewProjectModal',
'interlinearizer.openProjectInfoModal',
Expand Down Expand Up @@ -1848,6 +1851,7 @@ describe('main', () => {
checkIndexes: async () => [
{ index: 'tokenAnalyses', key: 'ta-1', missing: ['ta-1'], extra: [] },
],
getScope: async () => scopeRecordOf(draft),
};
}

Expand Down Expand Up @@ -1877,6 +1881,22 @@ describe('main', () => {
checkIndexes: handler<(sessionId: string) => Promise<string>>(
'interlinearizer.checkIndexes',
),
writeGloss: handler<
(
sessionId: string,
tokenRef: string,
surfaceText: string,
value: string,
) => Promise<string>
>('interlinearizer.writeGloss'),
approveAnalysisForToken: handler<
(
sessionId: string,
tokenRef: string,
surfaceText: string,
analysisId: string,
) => Promise<string>
>('interlinearizer.approveAnalysisForToken'),
};
}

Expand Down Expand Up @@ -2060,6 +2080,44 @@ describe('main', () => {
);
});

it("writes a token's gloss in the session's language, replying with the changes", async () => {
const { openSession, setAnalysisLanguage, writeGloss } = await activateSessionCommands();
const { sessionId } = JSON.parse(await openSession('src-project'));
await setAnalysisLanguage(sessionId, 'fr');

const changes = JSON.parse(await writeGloss(sessionId, 'GEN 1:1:0', 'In', 'au'));

expect(changes).toMatchObject({
intent: 'writeGloss',
tokenAnalyses: { puts: [{ surfaceText: 'In', gloss: { fr: 'au' } }], deletes: [] },
tokenAnalysisLinks: {
puts: [{ status: 'approved', token: { tokenRef: 'GEN 1:1:0', surfaceText: 'In' } }],
deletes: [],
},
});
});

it('logs the error and rethrows when an edit fails', async () => {
const { writeGloss } = await activateSessionCommands();

await expect(writeGloss('not-a-session', 'GEN 1:1:0', 'In', 'at')).rejects.toThrow(
'no session not-a-session is open',
);
expect(__mockLogger.error).toHaveBeenCalledWith(
'Interlinearizer: failed to writeGloss in session not-a-session',
expect.any(Error),
);
});

it('approves an analysis for a token, replying with the changes', async () => {
const { openSession, approveAnalysisForToken } = await activateSessionCommands();
const { sessionId } = JSON.parse(await openSession('src-project'));

await expect(
approveAnalysisForToken(sessionId, 'GEN 1:1:0', 'In', 'ta-unknown').then(JSON.parse),
).resolves.toStrictEqual({ intent: 'approveAnalysisForToken' });
});

it('closes every session on deactivation', async () => {
const { openSession } = await activateSessionCommands();
await openSession('src-project');
Expand Down
109 changes: 109 additions & 0 deletions src/__tests__/services/analysisSessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
type SessionHost,
UNDETERMINED_LANGUAGE,
} from '../../services/analysisSessions';
import { ApprovalRuleError } from '../../services/commands/approvalRule';
import { DraftRepository } from '../../services/draftRepository';
import { Pt9ImportRepository } from '../../services/pt9ImportRepository';
import { draftKey } from '../../services/storageKeys';
Expand Down Expand Up @@ -477,6 +478,114 @@ describe('AnalysisSessions', () => {
});
});

describe('edit', () => {
const payload = (id: string) => ({ id, ...FIXTURE_STAMPS, surfaceText: id });
const approvedLink = (id: string) => ({
id,
analysisId: 'a',
...FIXTURE_STAMPS,
status: 'approved' as const,
token: { tokenRef: 'GEN 1:1:0', surfaceText: 'a' },
});

/** The source project's draft as stored. */
const stored = async (storage: ReturnType<typeof makeSessions>['storage']) =>
(await new DraftRepository(storage, SOURCE, silentLogger()).exportAll()).draft;

it('applies what the command stages as one undo step, marking the draft dirty', async () => {
const { sessions, storage } = makeSessions();
const { sessionId } = await sessions.open(SOURCE);

const changes = await sessions.edit(sessionId, 'add', async (buffer) => {
buffer.create('tokenAnalyses', payload('a'));
buffer.create('tokenAnalyses', payload('b'));
});

expect(changes).toMatchObject({ intent: 'add' });
await expect(stored(storage)).resolves.toMatchObject({
dirty: true,
analysis: { tokenAnalyses: [payload('a'), payload('b')] },
});
await sessions.undo(sessionId);
await expect(stored(storage)).resolves.toMatchObject({ analysis: { tokenAnalyses: [] } });
});

it("lists the session's analysis language on a draft listing none, undoing it with the edit", async () => {
const { host, sessions, storage } = makeSessions();
host.getInterfaceLanguages.mockResolvedValue(['de']);
const { sessionId } = await sessions.open(SOURCE);

await sessions.edit(sessionId, 'add', async (buffer) => {
buffer.create('tokenAnalyses', payload('a'));
});

await expect(stored(storage)).resolves.toMatchObject({ analysisLanguages: ['de'] });
await sessions.undo(sessionId);
await expect(stored(storage)).resolves.toMatchObject({ analysisLanguages: [] });
});

it('leaves the analysis languages of a draft listing some', async () => {
const { sessions, storage, saveDraft } = makeSessions();
await saveDraft({ ...emptyDraft(SOURCE), analysisLanguages: ['fr', 'en'] });
const { sessionId } = await sessions.open(SOURCE);
await sessions.setAnalysisLanguage(sessionId, 'de');

const changes = await sessions.edit(sessionId, 'add', async (buffer) => {
buffer.create('tokenAnalyses', payload('a'));
});

expect(changes.scope).toBeUndefined();
await expect(stored(storage)).resolves.toMatchObject({ analysisLanguages: ['fr', 'en'] });
});

it('applies nothing, and records no undo step, for a command staging nothing', async () => {
const { sessions, storage } = makeSessions();
const { sessionId } = await sessions.open(SOURCE);

await expect(sessions.edit(sessionId, 'nothing', async () => {})).resolves.toStrictEqual({
intent: 'nothing',
});

await expect(sessions.undo(sessionId)).resolves.toBeUndefined();
await expect(stored(storage)).resolves.toMatchObject({ dirty: false });
});

it('gives the command the session it runs on', async () => {
const { sessions } = makeSessions();
const { sessionId } = await sessions.open(SOURCE);
await sessions.setAnalysisLanguage(sessionId, 'fr');

await sessions.edit(sessionId, 'read', async (_buffer, session) => {
expect(session.analysisLanguage).toBe('fr');
});
});

it('refuses changes that would approve a second analysis for a token, applying nothing', async () => {
const { sessions, storage } = makeSessions();
const { sessionId } = await sessions.open(SOURCE);

await expect(
sessions.edit(sessionId, 'approveTwice', async (buffer) => {
buffer.create('tokenAnalysisLinks', approvedLink('l-1'));
buffer.create('tokenAnalysisLinks', approvedLink('l-2'));
}),
).rejects.toThrow(ApprovalRuleError);

await expect(stored(storage)).resolves.toMatchObject({
analysis: { tokenAnalysisLinks: [] },
});
});

it("refuses an edit on a Paratext 9 import's session without running it", async () => {
const { sessions } = makeSessions([makeImport()]);
const { sessionId } = await sessions.open(SOURCE, 'import-id');
const command = jest.fn(async () => {});

await expect(sessions.edit(sessionId, 'add', command)).rejects.toThrow(ReadOnlyAnalysisError);
expect(command).not.toHaveBeenCalled();
});
});

describe('setAnalysisLanguage', () => {
it('sets the language the commands sent after it read', async () => {
const { sessions } = makeSessions();
Expand Down
92 changes: 92 additions & 0 deletions src/__tests__/services/commands/approvalRule.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/// <reference types="jest" />

import type { SegmentAnalysisLink, TextAnalysis, TokenAnalysisLink } from 'interlinearizer';
import { ApprovalRuleError, assertApprovalRule } from '../../../services/commands/approvalRule';
import { ChangeBuffer } from '../../../services/commands/changeBuffer';
import { DraftRepository } from '../../../services/draftRepository';
import { emptyAnalysis, emptyDraft } from '../../../types/empty-factories';
import { FIXTURE_STAMPS, memoryStorage, silentLogger } from '../../test-helpers';

function tokenLink(
id: string,
status: TokenAnalysisLink['status'] = 'approved',
): TokenAnalysisLink {
return {
id,
analysisId: `ta-${id}`,
...FIXTURE_STAMPS,
status,
token: { tokenRef: 'GEN 1:1:0', surfaceText: 'In' },
};
}

function segmentLink(id: string): SegmentAnalysisLink {
return {
id,
analysisId: `sa-${id}`,
...FIXTURE_STAMPS,
status: 'approved',
segmentId: 'GEN 1:1',
};
}

async function over(analysis: Partial<TextAnalysis>) {
const { storage } = memoryStorage();
const repository = new DraftRepository(storage, 'src', silentLogger());
await repository.replaceAll({
...emptyDraft('src'),
analysis: { ...emptyAnalysis(), ...analysis },
});
return { repository, buffer: new ChangeBuffer(repository) };
}

describe('assertApprovalRule', () => {
it('accepts changes that leave each token one approval', async () => {
const { repository, buffer } = await over({ tokenAnalysisLinks: [tokenLink('a')] });

buffer.delete('tokenAnalysisLinks', tokenLink('a'));
buffer.create('tokenAnalysisLinks', tokenLink('b'));

await expect(assertApprovalRule(buffer, repository)).resolves.toBeUndefined();
});

it('refuses a second approval for a token', async () => {
const { repository, buffer } = await over({ tokenAnalysisLinks: [tokenLink('a')] });

buffer.create('tokenAnalysisLinks', tokenLink('b'));

await expect(assertApprovalRule(buffer, repository)).rejects.toThrow(
new ApprovalRuleError(['GEN 1:1:0']),
);
});

it('refuses a second approval for a segment', async () => {
const { repository, buffer } = await over({ segmentAnalysisLinks: [segmentLink('a')] });

buffer.create('segmentAnalysisLinks', segmentLink('b'));

await expect(assertApprovalRule(buffer, repository)).rejects.toThrow(ApprovalRuleError);
});

it('accepts changes that leave a stored breach no worse', async () => {
const { repository, buffer } = await over({
tokenAnalysisLinks: [tokenLink('a'), tokenLink('b'), tokenLink('c', 'suggested')],
});

buffer.put('tokenAnalysisLinks', { ...tokenLink('a'), updatedAt: '2026-06-01T00:00:00.000Z' });
buffer.put('tokenAnalysisLinks', tokenLink('c', 'rejected'));

await expect(assertApprovalRule(buffer, repository)).resolves.toBeUndefined();
});

it('names the places it refuses on the error', async () => {
const { repository, buffer } = await over({});

buffer.create('tokenAnalysisLinks', tokenLink('a'));
buffer.create('tokenAnalysisLinks', tokenLink('b'));

await expect(assertApprovalRule(buffer, repository)).rejects.toMatchObject({
places: ['GEN 1:1:0'],
});
});
});
Loading
Loading