Skip to content

Commit e2e2f8a

Browse files
Add interlinearizer project storage and createProject command
- Add `src/projectStorage.ts` with `createProject`, `getProject`, `listProjects`, and `deleteProject` backed by `papi.storage` - Register `interlinearizer.createProject` command in `main.ts` that prompts for source/target projects, writes the record, and surfaces storage errors as notifications - Add `InterlinearProject` type and `interlinearizer.createProject` signature to shared type declarations - Extend PAPI backend mock with `papi.storage` and `papi.notifications` - Broaden Jest coverage to all `src/**` files - Add full test suites for the storage module and the new command
1 parent 3340605 commit e2e2f8a

8 files changed

Lines changed: 650 additions & 12 deletions

File tree

__mocks__/papi-backend.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ const mockSelectProject = jest.fn();
1010
const mockGetOpenWebViewDefinition = jest.fn();
1111
const mockOnDidOpenWebView = jest.fn();
1212
const mockOnDidCloseWebView = jest.fn();
13+
const mockReadUserData = jest.fn();
14+
const mockWriteUserData = jest.fn();
15+
const mockDeleteUserData = jest.fn();
16+
const mockNotificationsSend = jest.fn();
1317
const mockLogger = {
1418
debug: jest.fn(),
1519
error: jest.fn(),
@@ -24,6 +28,14 @@ const papi = {
2428
dialogs: {
2529
selectProject: mockSelectProject,
2630
},
31+
notifications: {
32+
send: mockNotificationsSend,
33+
},
34+
storage: {
35+
readUserData: mockReadUserData,
36+
writeUserData: mockWriteUserData,
37+
deleteUserData: mockDeleteUserData,
38+
},
2739
webViewProviders: {
2840
registerWebViewProvider: mockRegisterWebViewProvider,
2941
},
@@ -44,6 +56,10 @@ const defaultExport = {
4456
__mockGetOpenWebViewDefinition: mockGetOpenWebViewDefinition,
4557
__mockOnDidOpenWebView: mockOnDidOpenWebView,
4658
__mockOnDidCloseWebView: mockOnDidCloseWebView,
59+
__mockReadUserData: mockReadUserData,
60+
__mockWriteUserData: mockWriteUserData,
61+
__mockDeleteUserData: mockDeleteUserData,
62+
__mockNotificationsSend: mockNotificationsSend,
4763
__mockLogger: mockLogger,
4864
};
4965

@@ -58,6 +74,10 @@ module.exports = {
5874
__mockGetOpenWebViewDefinition: mockGetOpenWebViewDefinition,
5975
__mockOnDidOpenWebView: mockOnDidOpenWebView,
6076
__mockOnDidCloseWebView: mockOnDidCloseWebView,
77+
__mockReadUserData: mockReadUserData,
78+
__mockWriteUserData: mockWriteUserData,
79+
__mockDeleteUserData: mockDeleteUserData,
80+
__mockNotificationsSend: mockNotificationsSend,
6181
__mockLogger: mockLogger,
6282
};
6383

jest.config.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,10 @@ const config: Config = {
2323
*/
2424
collectCoverage: false,
2525

26-
/**
27-
* Collect coverage from parsers, main entry (main.ts), and WebView UI
28-
* (interlinearizer.web-view.tsx). Excludes test files, type declarations, and build artifacts.
29-
*/
26+
/** Collect coverage from all source files. Excludes type declarations and test files. */
3027
collectCoverageFrom: [
31-
'src/parsers/**/*.ts',
32-
'src/main.ts',
33-
'src/**/*.web-view.tsx',
34-
'!src/parsers/**/*.d.ts',
28+
'src/**/*.{ts,tsx}',
29+
'!src/**/*.d.ts',
3530
'!src/**/__tests__/**',
3631
'!src/**/*.test.{ts,tsx}',
3732
'!src/**/*.spec.{ts,tsx}',

src/__tests__/main.test.ts

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ interface PapiBackendTestMock {
1616
__mockGetOpenWebViewDefinition: jest.Mock;
1717
__mockOnDidOpenWebView: jest.Mock;
1818
__mockOnDidCloseWebView: jest.Mock;
19+
__mockReadUserData: jest.Mock;
20+
__mockWriteUserData: jest.Mock;
21+
__mockNotificationsSend: jest.Mock;
1922
__mockLogger: { debug: jest.Mock; error: jest.Mock; info: jest.Mock; warn: jest.Mock };
2023
}
2124

@@ -34,6 +37,9 @@ function isPapiBackendTestMock(m: unknown): m is PapiBackendTestMock {
3437
'__mockGetOpenWebViewDefinition' in m &&
3538
'__mockOnDidOpenWebView' in m &&
3639
'__mockOnDidCloseWebView' in m &&
40+
'__mockReadUserData' in m &&
41+
'__mockWriteUserData' in m &&
42+
'__mockNotificationsSend' in m &&
3743
'__mockLogger' in m
3844
);
3945
}
@@ -47,6 +53,9 @@ const {
4753
__mockGetOpenWebViewDefinition,
4854
__mockOnDidOpenWebView,
4955
__mockOnDidCloseWebView,
56+
__mockReadUserData,
57+
__mockWriteUserData,
58+
__mockNotificationsSend,
5059
__mockLogger,
5160
} = papiBackendMock;
5261

@@ -63,6 +72,12 @@ describe('main', () => {
6372
__mockGetOpenWebViewDefinition.mockResolvedValue(undefined);
6473
__mockOnDidOpenWebView.mockReturnValue(jest.fn());
6574
__mockOnDidCloseWebView.mockReturnValue(jest.fn());
75+
__mockReadUserData.mockRejectedValue(
76+
Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }),
77+
);
78+
__mockWriteUserData.mockResolvedValue(undefined);
79+
__mockNotificationsSend.mockResolvedValue('mock-notification-id');
80+
jest.spyOn(crypto, 'randomUUID').mockReturnValue('00000000-0000-0000-0000-000000000000');
6681
});
6782

6883
describe('activate', () => {
@@ -92,12 +107,12 @@ describe('main', () => {
92107
);
93108
});
94109

95-
it('adds all four registrations to the activation context', async () => {
110+
it('adds all five registrations to the activation context', async () => {
96111
const context = createTestActivationContext();
97112

98113
await activate(context);
99114

100-
expect(context.registrations.unsubscribers.size).toBe(4);
115+
expect(context.registrations.unsubscribers.size).toBe(5);
101116
});
102117

103118
it('logs activation start and finish', async () => {
@@ -330,6 +345,84 @@ describe('main', () => {
330345
});
331346
});
332347

348+
describe('interlinearizer.createProject command', () => {
349+
async function getCreateProjectHandler(): Promise<
350+
(analysisWritingSystem: string) => Promise<string | undefined>
351+
> {
352+
const context = createTestActivationContext();
353+
await activate(context);
354+
const rawHandler = findRegisteredHandler('interlinearizer.createProject');
355+
if (!rawHandler) throw new Error('Handler not found for interlinearizer.createProject');
356+
return async (ws: string): Promise<string | undefined> => {
357+
const result: unknown = await rawHandler(ws);
358+
return typeof result === 'string' ? result : undefined;
359+
};
360+
}
361+
362+
it('registers the interlinearizer.createProject command', async () => {
363+
const context = createTestActivationContext();
364+
365+
await activate(context);
366+
367+
expect(__mockRegisterCommand).toHaveBeenCalledWith(
368+
'interlinearizer.createProject',
369+
expect.any(Function),
370+
expect.any(Object),
371+
);
372+
});
373+
374+
it('creates and returns the new project ID when both pickers are confirmed', async () => {
375+
__mockSelectProject.mockResolvedValueOnce('src-project').mockResolvedValueOnce('tgt-project');
376+
const handler = await getCreateProjectHandler();
377+
378+
const result = await handler('en');
379+
380+
expect(result).toBe('00000000-0000-0000-0000-000000000000');
381+
expect(__mockWriteUserData).toHaveBeenCalledWith(
382+
expect.anything(),
383+
'project:00000000-0000-0000-0000-000000000000',
384+
expect.stringContaining('"sourceProjectId":"src-project"'),
385+
);
386+
});
387+
388+
it('returns undefined when the source picker is cancelled', async () => {
389+
__mockSelectProject.mockResolvedValue(undefined);
390+
const handler = await getCreateProjectHandler();
391+
392+
const result = await handler('en');
393+
394+
expect(result).toBeUndefined();
395+
expect(__mockWriteUserData).not.toHaveBeenCalled();
396+
});
397+
398+
it('returns undefined when the target picker is cancelled', async () => {
399+
__mockSelectProject.mockResolvedValueOnce('src-project').mockResolvedValueOnce(undefined);
400+
const handler = await getCreateProjectHandler();
401+
402+
const result = await handler('en');
403+
404+
expect(result).toBeUndefined();
405+
expect(__mockWriteUserData).not.toHaveBeenCalled();
406+
});
407+
408+
it('logs the error, sends an error notification, and returns undefined when storage fails', async () => {
409+
__mockSelectProject.mockResolvedValueOnce('src-project').mockResolvedValueOnce('tgt-project');
410+
__mockWriteUserData.mockRejectedValue(new Error('disk full'));
411+
const handler = await getCreateProjectHandler();
412+
413+
const result = await handler('en');
414+
415+
expect(result).toBeUndefined();
416+
expect(__mockLogger.error).toHaveBeenCalledWith(
417+
'Interlinearizer: failed to create project',
418+
expect.any(Error),
419+
);
420+
expect(__mockNotificationsSend).toHaveBeenCalledWith(
421+
expect.objectContaining({ severity: 'error' }),
422+
);
423+
});
424+
});
425+
333426
describe('WebView lifecycle event subscriptions', () => {
334427
it('subscribes to onDidOpenWebView and onDidCloseWebView during activation', async () => {
335428
const context = createTestActivationContext();

0 commit comments

Comments
 (0)