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 @@ -43,7 +43,7 @@ This is a **Platform.Bible extension** for interlinear Bible text alignment. Pla

[src/main.ts](src/main.ts) — called by Platform.Bible on activation. Exports two lifecycle functions:

- `activate(context)` — stores the `ExecutionToken`, registers the `interlinearizer.mainWebView` WebView provider, command handlers, the `interlinearizer.onUndoFromNotification` network event emitter, the `interlinearizer.continuousScroll` project settings validator, and the `onDidOpenWebView` / `onDidUpdateWebView` / `onDidCloseWebView` subscriptions. All registrations are added to `context.registrations` so the platform disposes them on deactivation.
- `activate(context)` — stores the `ExecutionToken`, registers the `interlinearizer.mainWebView` WebView provider, command handlers, the `interlinearizer.onUndoFromNotification` and `interlinearizer.onAnalysisChanged` network event emitters, the `interlinearizer.continuousScroll` project settings validator, and the `onDidOpenWebView` / `onDidUpdateWebView` / `onDidCloseWebView` subscriptions. All registrations are added to `context.registrations` so the platform disposes them on deactivation.
- `deactivate()` — clears `openWebViewsByProject`, lets go of every book `bookService` keeps, and returns `true`.

`openWebViewsByProject` (`Map<string, string>`) tracks one open WebView ID per project to prevent duplicates; reopening an already-open project brings that tab to front via the `existingId` option.
Expand Down
70 changes: 70 additions & 0 deletions src/__tests__/hooks/useInterlinearizerBookData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useProjectSetting } from '@papi/frontend/react';
import { act, renderHook, waitFor } from '@testing-library/react';
import type { SerializedVerseRef } from '@sillsdev/scripture';
import type { Book } from 'interlinearizer';
import { useEvent } from 'platform-bible-react';
import useInterlinearizerBookData from '../../hooks/useInterlinearizerBookData';
import { getMockedSendCommand, makeSegment, makeWordToken } from '../test-helpers';

Expand Down Expand Up @@ -297,4 +298,73 @@ describe('useInterlinearizerBookData', () => {
expect(jest.mocked(papi.notifications.send)).not.toHaveBeenCalled();
});
});

describe('when Scripture text is edited', () => {
/** Stands in for the network event the platform relays a Scripture edit on. */
const ANALYSIS_CHANGED_EVENT = () => () => true;

beforeEach(() => {
jest.mocked(papi.network.getNetworkEvent).mockReturnValue(ANALYSIS_CHANGED_EVENT);
});

/** Relays an analysis change to the hook, as the platform does. */
function emitChange(sourceProjectId: string, bookId: string): void {
const handler = [...jest.mocked(useEvent).mock.calls]
.reverse()
.find(([event]) => event === ANALYSIS_CHANGED_EVENT)?.[1];
if (typeof handler !== 'function') throw new Error('nothing listens for the change event');
act(() => handler({ sourceProjectId, changeId: 'change-1', scriptureEdit: { bookId } }));
}

it('listens for analysis changes', async () => {
const { result } = renderBookData();
await waitFor(() => expect(result.current.isLoading).toBe(false));

expect(papi.network.getNetworkEvent).toHaveBeenCalledWith(
'interlinearizer.onAnalysisChanged',
);
});

it('fetches the book again when its own text is edited', async () => {
const { result } = renderBookData();
await waitFor(() => expect(result.current.book).toEqual(TEST_BOOK));
const revised: Book = { ...TEST_BOOK, textVersion: 'v2' };
serveBooks(revised);

emitChange('test-project', 'GEN');

await waitFor(() => expect(result.current.book).toEqual(revised));
});

it('fetches nothing more when another project is edited', async () => {
const { result } = renderBookData();
await waitFor(() => expect(result.current.isLoading).toBe(false));

emitChange('other-project', 'GEN');

expect(result.current.isLoading).toBe(false);
expect(mockSendCommand).toHaveBeenCalledTimes(1);
});

it('fetches nothing more when another book is edited', async () => {
const { result } = renderBookData();
await waitFor(() => expect(result.current.isLoading).toBe(false));

emitChange('test-project', 'EXO');

expect(result.current.isLoading).toBe(false);
expect(mockSendCommand).toHaveBeenCalledTimes(1);
});

it('fetches the book again when the book it moved to is edited', async () => {
const { result, rerender } = renderBookData();
await waitFor(() => expect(result.current.book).toEqual(TEST_BOOK));
rerender({ scrRef: EXO_1_1 });
await waitFor(() => expect(result.current.book).toEqual(EXO_BOOK));

emitChange('test-project', 'EXO');

await waitFor(() => expect(mockSendCommand).toHaveBeenCalledTimes(3));
});
});
});
52 changes: 52 additions & 0 deletions src/__tests__/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,58 @@ describe('main', () => {
expect(emitter.emit).toHaveBeenCalledWith({ notificationId: 'notification-1' });
});

describe('interlinearizer.onAnalysisChanged', () => {
/** Gives each event its own emitter, as the platform does. */
function emittersByEvent() {
const emitters = new Map<string, { emit: jest.Mock; dispose: jest.Mock }>();
__mockCreateNetworkEventEmitterAsync.mockImplementation(async (eventType: string) => {
const emitter = { emit: jest.fn(), dispose: jest.fn() };
emitters.set(eventType, emitter);
return emitter;
});
return emitters;
}

it('emits the project and book of an edit to the open book', async () => {
const emitters = emittersByEvent();
await activate(createTestActivationContext());
const [listener] = jest.mocked(bookService.onOpenBookTextChanged).mock.calls[0];

listener('src-project', 'GEN');

expect(emitters.get('interlinearizer.onAnalysisChanged')?.emit).toHaveBeenCalledWith({
sourceProjectId: 'src-project',
changeId: expect.any(String),
scriptureEdit: { bookId: 'GEN' },
});
});

it('gives each emitted change its own id', async () => {
const emitters = emittersByEvent();
await activate(createTestActivationContext());
const [listener] = jest.mocked(bookService.onOpenBookTextChanged).mock.calls[0];

listener('src-project', 'GEN');
listener('src-project', 'GEN');

const emit = emitters.get('interlinearizer.onAnalysisChanged')?.emit;
expect(emit?.mock.calls[0][0].changeId).not.toBe(emit?.mock.calls[1][0].changeId);
});

it('disposes the emitter and stops listening on deactivation', async () => {
const emitters = emittersByEvent();
const unsubscribe = jest.fn(() => true);
jest.mocked(bookService.onOpenBookTextChanged).mockReturnValue(unsubscribe);
const context = createTestActivationContext();
await activate(context);

await context.registrations.runAllUnsubscribers();

expect(emitters.get('interlinearizer.onAnalysisChanged')?.dispose).toHaveBeenCalled();
expect(unsubscribe).toHaveBeenCalled();
});
});

it('registers the WebView provider with a callable getWebView handler', async () => {
const context = createTestActivationContext();
await activate(context);
Expand Down
236 changes: 236 additions & 0 deletions src/__tests__/services/bookService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
getSegments,
getTokenizedBook,
getWritingSystem,
onOpenBookTextChanged,
} from '../../services/bookService';
import type { BookOutline, SegmentPage } from '../../types/segment-pages';
import { buildBookOutline } from '../../utils/book-outline';
Expand Down Expand Up @@ -533,3 +534,238 @@ describe('closeAllProjects', () => {
expect(project.subscriptions.every((s) => s.unsubscribe.mock.calls.length === 1)).toBe(true);
});
});

describe('onOpenBookTextChanged', () => {
const unsubscribers: Array<() => boolean> = [];

/** A listener registered for the test, removed once it ends. */
function listen() {
const listener = jest.fn();
unsubscribers.push(onOpenBookTextChanged(listener));
return listener;
}

beforeEach(() => {
segmentStandIns();
});

afterEach(() => {
unsubscribers.splice(0).forEach((unsubscribe) => unsubscribe());
});

it('reports an edit to the outlined book with its project and book', async () => {
const project = serveProject((bookId) => usj(bookId));
const listener = listen();
await getBookOutline('src', 'GEN');

project.subscriptions[0].deliver(usj('GEN', 'v2'));

expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith('src', 'GEN');
});

it('reports an edit to every listener', async () => {
const project = serveProject((bookId) => usj(bookId));
const listeners = [listen(), listen()];
await getBookOutline('src', 'GEN');

project.subscriptions[0].deliver(usj('GEN', 'v2'));

listeners.forEach((listener) => expect(listener).toHaveBeenCalledTimes(1));
});

it('reads the edited text when the book is next requested', async () => {
let version = 'v1';
const project = serveProject((bookId) => usj(bookId, version));
await getBookOutline('src', 'GEN');
version = 'v2';

project.subscriptions[0].deliver(usj('GEN', version));
await settle();

expect(parseBook(await getTokenizedBook('src', 'GEN')).textVersion).toBe('v2@en');
});

it('reads the edited text when the held book heard of the edit first', async () => {
let version = 'v1';
const project = serveProject((bookId) => usj(bookId, version));
const listener = listen();
await getBookOutline('src', 'GEN');
version = 'v2';

project.subscriptions[1].deliver(usj('GEN', version));
project.subscriptions[0].deliver(usj('GEN', version));
await settle();

expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith('src', 'GEN');
expect(parseBook(await getTokenizedBook('src', 'GEN')).textVersion).toBe('v2@en');
});

it('still reports an edit after other books have been read in between', async () => {
let version = 'v1';
const project = serveProject((bookId) => usj(bookId, version));
const listener = listen();
await getBookOutline('src', 'GEN');
await getTokenizedBook('src', 'EXO');
await getTokenizedBook('src', 'LEV');
version = 'v2';

project.subscriptions[0].deliver(usj('GEN', version));
await settle();

expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith('src', 'GEN');
expect(parseBook(await getTokenizedBook('src', 'GEN')).textVersion).toBe('v2@en');
});

it('keeps the book held for another when the outlined book is edited', async () => {
const project = serveProject((bookId) => usj(bookId));
await getBookOutline('src', 'GEN');
await getTokenizedBook('src', 'EXO');
const held = project.subscriptions.at(-1);

project.subscriptions[0].deliver(usj('GEN', 'v2'));
await settle();

expect(held?.unsubscribe).not.toHaveBeenCalled();
});

it('stops a watch that fails to read its book', async () => {
const project = serveProject();
const outline = getBookOutline('src', 'GEN');
await settle();
const [failed, held] = project.subscriptions;

failed.deliver(platformError('busy'));
held.deliver(usj('GEN'));
await outline;
await settle();

expect(failed.unsubscribe).toHaveBeenCalled();
});

it('watches the book again on the next outline once its watch fails to read it', async () => {
const project = serveProject();
const listener = listen();
const outline = getBookOutline('src', 'GEN');
await settle();
const [failed, held] = project.subscriptions;
failed.deliver(platformError('busy'));
held.deliver(usj('GEN'));
await outline;
await settle();

await getBookOutline('src', 'GEN');
await settle();
const watch = project.subscriptions[2];
watch.deliver(usj('GEN'));
watch.deliver(usj('GEN', 'v2'));

expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith('src', 'GEN');
});

it('keeps the newer watch when a replaced watch fails to read its book', async () => {
Comment thread
imnasnainaec marked this conversation as resolved.
const project = serveProject();
getBookOutline('src', 'GEN').catch(() => undefined);
await settle();
const replaced = project.subscriptions[0];
getBookOutline('src', 'EXO').catch(() => undefined);
await settle();
project.subscriptions.filter((s) => s.bookId === 'EXO').forEach((s) => s.deliver(usj('EXO')));
const subscriptionCount = project.subscriptions.length;

replaced.deliver(platformError('busy'));
await settle();
await getBookOutline('src', 'EXO');

expect(project.subscriptions).toHaveLength(subscriptionCount);
});

it('reports nothing for a book only read whole', async () => {
const project = serveProject((bookId) => usj(bookId));
const listener = listen();
await getTokenizedBook('src', 'GEN');
await getSegments('src', 'GEN', 0, 1);

project.subscriptions.forEach((subscription) => subscription.deliver(usj('GEN', 'v2')));

expect(listener).not.toHaveBeenCalled();
});

it('watches an outlined book once however often it is outlined', async () => {
const project = serveProject((bookId) => usj(bookId));

await getBookOutline('src', 'GEN');
await getBookOutline('src', 'GEN');

expect(project.subscriptions).toHaveLength(2);
});

it('stops watching the previous book once another is outlined', async () => {
const project = serveProject((bookId) => usj(bookId));
await getBookOutline('src', 'GEN');
const [first] = project.subscriptions;

await getBookOutline('src', 'EXO');
await settle();

expect(first.unsubscribe).toHaveBeenCalled();
});

it('reports edits to the newly outlined book', async () => {
const project = serveProject((bookId) => usj(bookId));
const listener = listen();
await getBookOutline('src', 'GEN');
await getBookOutline('src', 'EXO');
const exo = project.subscriptions.find((s) => s.bookId === 'EXO');

exo?.deliver(usj('EXO', 'v2'));

expect(listener).toHaveBeenCalledTimes(1);
expect(listener).toHaveBeenCalledWith('src', 'EXO');
});

it("stops watching the project's outlined book once it closes", async () => {
const project = serveProject((bookId) => usj(bookId));
await getBookOutline('src', 'GEN');

await closeProject('src');

expect(project.subscriptions.every((s) => s.unsubscribe.mock.calls.length === 1)).toBe(true);
});

it('closes a project that has an outlined book but nothing held', async () => {
Comment thread
imnasnainaec marked this conversation as resolved.
const project = serveProject((bookId) => usj(bookId));
await getBookOutline('src', 'GEN');
project.subscriptions[1].deliver(usj('GEN', 'v2'));
await settle();

await closeProject('src');

expect(project.subscriptions.every((s) => s.unsubscribe.mock.calls.length === 1)).toBe(true);
});

it('stops watching every outlined book once all projects close', async () => {
const project = serveProject((bookId) => usj(bookId));
await getBookOutline('src', 'GEN');
await getBookOutline('other', 'EXO');

await closeAllProjects();

expect(project.subscriptions.every((s) => s.unsubscribe.mock.calls.length === 1)).toBe(true);
});

it('does not call a listener once it is removed', async () => {
const project = serveProject((bookId) => usj(bookId));
const listener = jest.fn();
const unsubscribe = onOpenBookTextChanged(listener);
await getBookOutline('src', 'GEN');

unsubscribe();
project.subscriptions[0].deliver(usj('GEN', 'v2'));

expect(listener).not.toHaveBeenCalled();
});
});
Loading
Loading