Skip to content
Draft
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
8 changes: 8 additions & 0 deletions __mocks__/papi-frontend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const mockLogger = {
const mockSendCommand = jest.fn();
const mockNotificationsSend = jest.fn();
const mockProjectDataProvidersGet = jest.fn();
const mockNetworkObjectsGet = jest.fn();
const mockWaitForNetworkObject = jest.fn();

const papi = {
commands: {
Expand All @@ -27,6 +29,12 @@ const papi = {
projectDataProviders: {
get: mockProjectDataProvidersGet,
},
networkObjects: {
get: mockNetworkObjectsGet,
},
networkObjectStatus: {
waitForNetworkObject: mockWaitForNetworkObject,
},
};

module.exports = {
Expand Down
3 changes: 3 additions & 0 deletions contributions/localizedStrings.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@
"%interlinearizer_viewOption_freeScrollStrip%": "Scroll strip freely",
"%interlinearizer_projectSettings_freeScrollStrip%": "Scroll Strip Freely",
"%interlinearizer_projectSettings_freeScrollStripDescription%": "Scroll the continuous strip with the mouse wheel instead of stepping the focus one phrase at a time",
"%interlinearizer_projectSettings_lexiconAuthority%": "Lexicon Software",
"%interlinearizer_projectSettings_lexiconCode%": "Lexicon",
"%interlinearizer_projectSettings_lexiconCodeDescription%": "The lexicon this project's glosses are linked to. Clear it to stop using that lexicon; glosses already linked to it keep the wording they were saved with",
"%interlinearizer_viewOption_showSuggestions%": "Show suggestions",
"%interlinearizer_glossInput_placeholder%": "gloss",
"%interlinearizer_freeTranslationInput_placeholder%": "Free translation",
Expand Down
10 changes: 10 additions & 0 deletions contributions/projectSettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@
"label": "%interlinearizer_projectSettings_freeScrollStrip%",
"description": "%interlinearizer_projectSettings_freeScrollStripDescription%",
"default": false
},
"interlinearizer.lexiconAuthority": {
"label": "%interlinearizer_projectSettings_lexiconAuthority%",
"default": "",
"isHidden": true
},
"interlinearizer.lexiconCode": {
"label": "%interlinearizer_projectSettings_lexiconCode%",
"description": "%interlinearizer_projectSettings_lexiconCodeDescription%",
"default": ""
}
}
}
Expand Down
127 changes: 112 additions & 15 deletions src/__tests__/hooks/useLexiconRegistry.test.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,132 @@
/// <reference types="jest" />

import { renderHook } from '@testing-library/react';
import { renderHook, waitFor } from '@testing-library/react';
import { useProjectSetting } from '@papi/frontend/react';
import useLexiconRegistry from '../../hooks/useLexiconRegistry';
import { fwLiteLexiconProvider } from '../../utils/fw-lite-lexicon';
import { FW_LITE_AUTHORITY } from '../../utils/lexicon-authorities';

jest.mock('../../utils/fw-lite-lexicon', () => ({
fwLiteLexiconProvider: {
authority: 'fw-lite',
isAvailable: jest.fn(),
connect: jest.fn(),
},
}));

const provider = jest.mocked(fwLiteLexiconProvider);
const mockUseProjectSetting = jest.mocked(useProjectSetting);

/** Serves the stored link, keyed by setting so the two halves can disagree. */
function storeLink(authority?: string, lexiconCode?: string) {
mockUseProjectSetting.mockImplementation((_projectId, key) => [
key === 'interlinearizer.lexiconAuthority' ? authority : lexiconCode,
jest.fn(),
jest.fn(),
false,
]);
}

/** A resolver that answers for FieldWorks Lite and can be searched only when given a lexicon. */
function stubResolver(lexiconId?: string) {
return {
authorities: [FW_LITE_AUTHORITY],
capabilities: {
search: !!lexiconId,
create: !!lexiconId,
allomorphs: false,
msas: false,
},
resolveSense: jest.fn(async () => undefined),
searchByForm: jest.fn(async () => []),
createEntry: jest.fn(async () => {
throw new Error('unused');
}),
};
}

beforeEach(() => {
provider.connect.mockImplementation(stubResolver);
provider.isAvailable.mockResolvedValue(true);
storeLink('fw-lite', 'lex-1');
});

describe('useLexiconRegistry', () => {
it('offers no lexicon capability while no lexicon is connected', () => {
const { result } = renderHook(() => useLexiconRegistry());
it('holds no lexicon on the first render, so a consumer never waits on one', () => {
const { result } = renderHook(() => useLexiconRegistry('project-1'));

expect(result.current.resolverWith('search')).toBeUndefined();
expect(result.current.isForeign({ authority: FW_LITE_AUTHORITY })).toBe(true);
});

it('connects the linked lexicon once the software has answered', async () => {
const { result } = renderHook(() => useLexiconRegistry('project-1'));

await waitFor(() => expect(result.current.resolverWith('search')).toBeDefined());
expect(provider.connect).toHaveBeenCalledWith('lex-1');
});

it('reads a ref of unreachable software as foreign', async () => {
provider.isAvailable.mockResolvedValue(false);

const { result } = renderHook(() => useLexiconRegistry('project-1'));

await waitFor(() => expect(provider.isAvailable).toHaveBeenCalled());
expect(result.current.isForeign({ authority: FW_LITE_AUTHORITY })).toBe(true);
});

it('reads a ref of reachable but unlinked software as native, so it renders as a miss', async () => {
storeLink('', '');

const { result } = renderHook(() => useLexiconRegistry('project-1'));

await waitFor(() =>
expect(result.current.isForeign({ authority: FW_LITE_AUTHORITY })).toBe(false),
);
expect(result.current.resolverWith('search')).toBeUndefined();
});

it('treats every ref as foreign while no lexicon is connected', () => {
const { result } = renderHook(() => useLexiconRegistry());
it.each([
['an authority without a lexicon code', 'fw-lite', ''],
['a lexicon code without an authority', '', 'lex-1'],
])('treats %s as no link', async (_case, authority, lexiconCode) => {
storeLink(authority, lexiconCode);

const { result } = renderHook(() => useLexiconRegistry('project-1'));

expect(result.current.isForeign({ authority: 'some-lexicon' })).toBe(true);
await waitFor(() => expect(provider.connect).toHaveBeenCalled());
expect(provider.connect).toHaveBeenLastCalledWith(undefined);
expect(result.current.resolverWith('search')).toBeUndefined();
});

it('resolves no sense while no lexicon is connected', async () => {
const { result } = renderHook(() => useLexiconRegistry());
it('treats a setting the platform could not read as unset', async () => {
storeLink(undefined, undefined);

await expect(
result.current.resolveSense({ authority: 'some-lexicon', senseId: 's-1' }),
).resolves.toBeUndefined();
const { result } = renderHook(() => useLexiconRegistry('project-1'));

await waitFor(() => expect(provider.connect).toHaveBeenCalled());
expect(result.current.resolverWith('search')).toBeUndefined();
});

it('hands back one registry, so a consumer can hold on to it', () => {
const { result, rerender } = renderHook(() => useLexiconRegistry());
const first = result.current;
it('hands back one registry across renders, so a consumer can hold on to it', async () => {
const { result, rerender } = renderHook(() => useLexiconRegistry('project-1'));
await waitFor(() => expect(result.current.resolverWith('search')).toBeDefined());
const settled = result.current;

rerender();

expect(result.current).toBe(first);
expect(result.current).toBe(settled);
});

it('answers for the project in view, so a second project gets its own link', async () => {
const { result, rerender } = renderHook(({ projectId }) => useLexiconRegistry(projectId), {
initialProps: { projectId: 'project-1' },
});
await waitFor(() => expect(result.current.resolverWith('search')).toBeDefined());

storeLink('fw-lite', 'lex-2');
rerender({ projectId: 'project-2' });

await waitFor(() => expect(provider.connect).toHaveBeenLastCalledWith('lex-2'));
});
});
38 changes: 38 additions & 0 deletions src/__tests__/test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,3 +389,41 @@ export function getMockedPdpGet(papiModule: unknown): jest.Mock {
}
throw new Error('Expected the mocked @papi/frontend projectDataProviders.get');
}

/**
* Reaches one jest fn inside the papi-frontend mock by walking `path`, so a mock that moves or
* disappears fails loudly here rather than as a puzzling `undefined` in a test.
*
* @param path - Property names from the module down to the mock.
* @throws When the path does not lead to a jest fn.
*/
function getMockedPapiFn(papiModule: unknown, path: readonly string[]): jest.Mock {
const target = path.reduce<unknown>(
(current, key) =>
!!current && typeof current === 'object' ? Reflect.get(current, key) : undefined,
papiModule,
);
if (jest.isMockFunction(target)) return target;
throw new Error(`Expected the mocked @papi/frontend ${path.join('.')}`);
}

/**
* Returns the papi-frontend mock's `networkObjects.get` as the raw jest fn, so tests can resolve
* partial network objects (only the methods under test) without type assertions against the full
* service interface.
*
* @throws When the module is not the jest papi-frontend mock.
*/
export function getMockedNetworkObjectGet(papiModule: unknown): jest.Mock {
return getMockedPapiFn(papiModule, ['networkObjects', 'get']);
}

/**
* Returns the papi-frontend mock's `networkObjectStatus.waitForNetworkObject` as the raw jest fn,
* so a test can decide whether a network object ever registers.
*
* @throws When the module is not the jest papi-frontend mock.
*/
export function getMockedWaitForNetworkObject(papiModule: unknown): jest.Mock {
return getMockedPapiFn(papiModule, ['networkObjectStatus', 'waitForNetworkObject']);
}
Loading
Loading