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 contributions/localizedStrings.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"%interlinearizer_modal_metadata_description_label%": "Description",
"%interlinearizer_modal_metadata_description_placeholder%": "e.g. Token-level English glosses",
"%interlinearizer_modal_metadata_created_label%": "Created",
"%interlinearizer_modal_metadata_modified_label%": "Modified",
"%interlinearizer_modal_metadata_source_label%": "Source Project",
"%interlinearizer_modal_metadata_analysis_language_label%": "Analysis Language",
"%interlinearizer_modal_metadata_language_placeholder%": "e.g. en",
Expand All @@ -81,6 +82,7 @@
"%interlinearizer_modal_select_name_unnamed%": "Unnamed",
"%interlinearizer_modal_select_info_button_label%": "Project info",
"%interlinearizer_modal_select_active_badge%": "Active",
"%interlinearizer_modal_select_modified_prefix%": "Modified",
"%interlinearizer_modal_select_create_new%": "Create New",
"%interlinearizer_modal_select_cancel%": "Cancel",

Expand Down
35 changes: 35 additions & 0 deletions src/__tests__/components/modals/ProjectMetadataModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const LOCALIZED: Record<string, string> = {
'%interlinearizer_modal_metadata_analysis_language_label%': 'Analysis Language',
'%interlinearizer_modal_metadata_language_placeholder%': 'e.g. en',
'%interlinearizer_modal_metadata_created_label%': 'Created',
'%interlinearizer_modal_metadata_modified_label%': 'Modified',
'%interlinearizer_modal_metadata_source_label%': 'Source Project',
'%interlinearizer_modal_metadata_save%': 'Save',
'%interlinearizer_modal_metadata_close%': 'Close',
Expand All @@ -35,6 +36,7 @@ const testProps = {
sourceProjectId: 'src-project-id',
analysisLanguages: ['en'],
createdAt: '2026-01-15T10:30:00.000Z',
updatedAt: '2026-01-20T14:00:00.000Z',
onClose: jest.fn(),
onProjectSaved: jest.fn(),
onProjectDeleted: jest.fn(),
Expand Down Expand Up @@ -72,6 +74,12 @@ describe('ProjectMetadataModal', () => {
expect(screen.getByLabelText(/analysis language/i)).toHaveValue('en');
});

it('displays the locale-formatted modified date', () => {
render(<ProjectMetadataModal {...testProps} />);
const expectedDate = new Date(testProps.updatedAt).toLocaleString();
expect(screen.getByText(expectedDate)).toBeInTheDocument();
});

it('calls onClose when the Close button is clicked', async () => {
const onClose = jest.fn();
render(<ProjectMetadataModal {...testProps} onClose={onClose} />);
Expand Down Expand Up @@ -146,6 +154,33 @@ describe('ProjectMetadataModal', () => {
);
});

it('threads the server-refreshed updatedAt from the returned project into onProjectSaved', async () => {
mockSendCommand.mockResolvedValue(
JSON.stringify({ id: 'il-project-uuid', updatedAt: '2026-06-15T09:00:00.000Z' }),
);
const onProjectSaved = jest.fn();
render(<ProjectMetadataModal {...testProps} onProjectSaved={onProjectSaved} />);

await userEvent.click(screen.getByRole('button', { name: /^save$/i }));

await waitFor(() =>
expect(onProjectSaved).toHaveBeenCalledWith(
expect.objectContaining({ updatedAt: '2026-06-15T09:00:00.000Z' }),
),
);
});

it('omits updatedAt from onProjectSaved when the returned project has none', async () => {
// The default mock returns '{}', so no updatedAt is present to thread through.
const onProjectSaved = jest.fn();
render(<ProjectMetadataModal {...testProps} onProjectSaved={onProjectSaved} />);

await userEvent.click(screen.getByRole('button', { name: /^save$/i }));

await waitFor(() => expect(onProjectSaved).toHaveBeenCalled());
expect(onProjectSaved.mock.calls[0][0]).not.toHaveProperty('updatedAt');
});

it('calls onClose after a successful save', async () => {
const onClose = jest.fn();
render(<ProjectMetadataModal {...testProps} onClose={onClose} />);
Expand Down
63 changes: 61 additions & 2 deletions src/__tests__/components/modals/ProjectModals.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { emptyAnalysis } from '../../../types/empty-factories';
const MOCK_PROJECT: InterlinearProjectSummary = {
id: 'proj-1',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
sourceProjectId: 'source-proj',
analysisLanguages: ['en'],
name: 'My Project',
Expand All @@ -24,6 +25,7 @@ const MOCK_PROJECT: InterlinearProjectSummary = {
const MOCK_PROJECT_2: InterlinearProjectSummary = {
id: 'proj-2',
createdAt: '2026-02-01T00:00:00Z',
updatedAt: '2026-02-01T00:00:00Z',
sourceProjectId: 'source-proj',
analysisLanguages: ['fr'],
name: 'French Project',
Expand Down Expand Up @@ -214,6 +216,7 @@ jest.mock('../../../components/modals/ProjectMetadataModal', () => ({
name?: string;
description?: string;
analysisLanguages: string[];
updatedAt?: string;
}) => void;
onProjectDeleted?: (id: string) => void;
}) => (
Expand All @@ -225,7 +228,11 @@ jest.mock('../../../components/modals/ProjectMetadataModal', () => ({
type="button"
data-testid="metadata-save"
onClick={() => {
onProjectSaved?.({ name: 'Updated', analysisLanguages: ['fr'] });
onProjectSaved?.({
name: 'Updated',
analysisLanguages: ['fr'],
updatedAt: '2026-06-15T09:00:00Z',
});
onClose();
}}
>
Expand Down Expand Up @@ -850,6 +857,55 @@ describe('ProjectModals', () => {
expect(setModal).toHaveBeenCalledWith('none');
});

it('makes the server-returned project (with its refreshed updatedAt) the active project on overwrite', async () => {
const setActiveProject = jest.fn();
const refreshed = { ...MOCK_PROJECT, updatedAt: '2026-06-15T09:00:00Z' };
jest
.mocked(papi.commands.sendCommand)
// saveAnalysis resolves void, then updateProjectMetadata returns the refreshed project.
.mockResolvedValueOnce(undefined)
.mockResolvedValueOnce(JSON.stringify(refreshed));
render(
<ProjectModals
{...buildProps({
modal: 'saveAs',
useWebViewState: makeWebViewStateWithActiveProjectSpies({ set: setActiveProject }),
})}
/>,
);

await userEvent.click(screen.getByTestId('saveas-overwrite'));

// The refreshed project — not a copy of the pre-save summary — becomes active, so the cached
// Modified time reflects the write rather than the stale value.
await waitFor(() => expect(setActiveProject).toHaveBeenCalledWith(refreshed));
});

it('falls back to the pre-save summary with the draft config when overwrite metadata returns no project', async () => {
const setActiveProject = jest.fn();
// Both commands resolve undefined (the blanket default), so the update payload is missing.
render(
<ProjectModals
{...buildProps({
modal: 'saveAs',
useWebViewState: makeWebViewStateWithActiveProjectSpies({ set: setActiveProject }),
})}
/>,
);

await userEvent.click(screen.getByTestId('saveas-overwrite'));

// With no server payload the active project is rebuilt from the target plus the draft's
// config (languages carried over, target cleared since the draft has none).
await waitFor(() =>
expect(setActiveProject).toHaveBeenCalledWith({
...MOCK_PROJECT,
analysisLanguages: ['en'],
targetProjectId: undefined,
}),
);
});

it('sends the draft segmentation delta when overwriting an existing project', async () => {
const markSynced = jest.fn();
render(
Expand Down Expand Up @@ -967,11 +1023,14 @@ describe('ProjectModals', () => {
/>,
);
await userEvent.click(screen.getByTestId('metadata-save'));
// The saved edits ({ name: 'Updated', analysisLanguages: ['fr'] }) merge onto the active project.
// The saved edits (name, languages, and the server-refreshed updatedAt) are merged onto the
// active project, so a regression that drops the update branch — or the refreshed timestamp —
// is caught here.
expect(setActiveProject).toHaveBeenCalledWith({
...MOCK_PROJECT,
name: 'Updated',
analysisLanguages: ['fr'],
updatedAt: '2026-06-15T09:00:00Z',
});
expect(setModal).toHaveBeenCalledWith('none');
});
Expand Down
2 changes: 2 additions & 0 deletions src/__tests__/components/modals/SaveAsProjectModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,13 +32,15 @@ const LOCALIZED: Record<string, string> = {
const STUB_PROJECT: InterlinearProjectSummary = {
id: 'proj-uuid',
createdAt: '2026-01-15T10:30:00.000Z',
updatedAt: '2026-01-15T10:30:00.000Z',
sourceProjectId: 'src-proj',
analysisLanguages: ['en'],
};

const STUB_PROJECT_2: InterlinearProjectSummary = {
id: 'proj-uuid-2',
createdAt: '2026-02-01T08:00:00.000Z',
updatedAt: '2026-02-01T08:00:00.000Z',
sourceProjectId: 'src-proj',
analysisLanguages: ['fr'],
name: 'French glosses',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,18 +19,21 @@ const LOCALIZED: Record<string, string> = {
'%interlinearizer_modal_select_name_unnamed%': 'Unnamed',
'%interlinearizer_modal_select_info_button_label%': 'Project info',
'%interlinearizer_modal_select_active_badge%': 'Active',
'%interlinearizer_modal_select_modified_prefix%': 'Modified',
};

const STUB_PROJECT: InterlinearProjectSummary = {
id: 'proj-uuid',
createdAt: '2026-01-15T10:30:00.000Z',
updatedAt: '2026-01-15T10:30:00.000Z',
sourceProjectId: 'src-proj',
analysisLanguages: ['en'],
};

const STUB_PROJECT_2: InterlinearProjectSummary = {
id: 'proj-uuid-2',
createdAt: '2026-02-01T08:00:00.000Z',
updatedAt: '2026-02-01T08:00:00.000Z',
sourceProjectId: 'src-proj',
analysisLanguages: ['fr'],
name: 'French glosses',
Expand Down Expand Up @@ -101,6 +104,41 @@ describe('SelectInterlinearProjectModal', () => {
await waitFor(() => expect(screen.getByText('en')).toBeInTheDocument());
});

it('orders projects most-recently-modified first', async () => {
// STUB_PROJECT is older (Jan 15) than STUB_PROJECT_2 (Feb 1); returned oldest-first so the
// component must reorder to put the newer project on top.
mockSendCommand.mockResolvedValue(JSON.stringify([STUB_PROJECT, STUB_PROJECT_2]));
render(<SelectInterlinearProjectModal {...defaultProps} />);
await waitFor(() => expect(screen.getByText('French glosses')).toBeInTheDocument());

const rows = screen.getAllByRole('button', { name: /french glosses|unnamed/i });
expect(rows[0]).toHaveAccessibleName(/french glosses/i);
expect(rows[1]).toHaveAccessibleName(/unnamed/i);
});

it('keeps both projects listed when their modified times are equal', async () => {
// Two distinct projects sharing an identical updatedAt exercise the sort comparator's
// equal-timestamp branch; both must still render.
const sameTimeAsStub: InterlinearProjectSummary = {
...STUB_PROJECT_2,
id: 'proj-uuid-3',
updatedAt: STUB_PROJECT.updatedAt,
};
mockSendCommand.mockResolvedValue(JSON.stringify([STUB_PROJECT, sameTimeAsStub]));
render(<SelectInterlinearProjectModal {...defaultProps} />);

await waitFor(() => expect(screen.getByText('French glosses')).toBeInTheDocument());
expect(screen.getByText('Unnamed')).toBeInTheDocument();
});

it('shows the modified date in each row', async () => {
mockSendCommand.mockResolvedValue(JSON.stringify([STUB_PROJECT]));
render(<SelectInterlinearProjectModal {...defaultProps} />);

const expectedDate = new Date(STUB_PROJECT.updatedAt).toLocaleString();
await waitFor(() => expect(screen.getByText(`Modified ${expectedDate}`)).toBeInTheDocument());
});

it('calls onSelect with the project when a project row is clicked', async () => {
const onSelect = jest.fn();
mockSendCommand.mockResolvedValue(JSON.stringify([STUB_PROJECT]));
Expand Down
62 changes: 59 additions & 3 deletions src/__tests__/services/projectStorage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,14 @@ describe('projectStorage', () => {
expect(project.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});

it('sets updatedAt equal to createdAt at creation', async () => {
__mockReadUserData.mockRejectedValue(enoentError());

const project = await createProject(token, 'src-proj', ['en']);

expect(project.updatedAt).toBe(project.createdAt);
});

it('omits links and targetProjectId for analysis-only projects', async () => {
__mockReadUserData.mockRejectedValue(enoentError());

Expand Down Expand Up @@ -243,6 +251,17 @@ describe('projectStorage', () => {

describe('updateProjectMetadata', () => {
const storedProject = makeStubProject('proj-id');
// `updateProjectMetadata` stamps `updatedAt` with `new Date()`; freeze the clock so the exact
// stored payload is deterministic and distinct from the fixture's `createdAt`/`updatedAt`.
const UPDATE_TIME = '2026-03-01T12:00:00.000Z';

beforeEach(() => {
jest.useFakeTimers().setSystemTime(new Date(UPDATE_TIME));
});

afterEach(() => {
jest.useRealTimers();
});

it('returns the updated project with the new name and description', async () => {
__mockReadUserData.mockResolvedValue(JSON.stringify(storedProject));
Expand All @@ -260,10 +279,23 @@ describe('projectStorage', () => {
expect(__mockWriteUserData).toHaveBeenCalledWith(
token,
'project:proj-id',
JSON.stringify({ ...storedProject, name: 'My Name', description: 'My Desc' }),
JSON.stringify({
...storedProject,
updatedAt: UPDATE_TIME,
name: 'My Name',
description: 'My Desc',
}),
);
});

it('refreshes updatedAt to the current time', async () => {
__mockReadUserData.mockResolvedValue(JSON.stringify(storedProject));

const result = await updateProjectMetadata(token, 'proj-id', 'My Name', 'My Desc', ['en']);

expect(result?.updatedAt).toBe(UPDATE_TIME);
});

it('removes name and description when called with undefined', async () => {
const withMeta = { ...storedProject, name: 'Old', description: 'Old desc' };
__mockReadUserData.mockResolvedValue(JSON.stringify(withMeta));
Expand Down Expand Up @@ -419,6 +451,17 @@ describe('projectStorage', () => {
...emptyAnalysis(),
tokenAnalyses: [{ id: 'ta-1', surfaceText: 'In', gloss: { en: 'in' } }],
};
// `updateAnalysis` stamps `updatedAt` with `new Date()`; freeze the clock so the exact stored
// payload is deterministic and distinct from the fixture's `createdAt`/`updatedAt`.
const UPDATE_TIME = '2026-03-01T12:00:00.000Z';

beforeEach(() => {
jest.useFakeTimers().setSystemTime(new Date(UPDATE_TIME));
});

afterEach(() => {
jest.useRealTimers();
});

it('returns the updated project with the new analysis', async () => {
__mockReadUserData.mockResolvedValue(JSON.stringify(storedProject));
Expand All @@ -436,10 +479,18 @@ describe('projectStorage', () => {
expect(__mockWriteUserData).toHaveBeenCalledWith(
token,
'project:proj-id',
JSON.stringify({ ...storedProject, analysis: newAnalysis }),
JSON.stringify({ ...storedProject, analysis: newAnalysis, updatedAt: UPDATE_TIME }),
);
});

it('refreshes updatedAt to the current time', async () => {
__mockReadUserData.mockResolvedValue(JSON.stringify(storedProject));

const result = await updateAnalysis(token, 'proj-id', newAnalysis);

expect(result?.updatedAt).toBe(UPDATE_TIME);
});

it('returns undefined when the project does not exist', async () => {
__mockReadUserData.mockRejectedValue(enoentError());

Expand All @@ -465,7 +516,12 @@ describe('projectStorage', () => {
expect(__mockWriteUserData).toHaveBeenCalledWith(
token,
'project:proj-id',
JSON.stringify({ ...storedProject, analysis: newAnalysis, segmentation }),
JSON.stringify({
...storedProject,
analysis: newAnalysis,
updatedAt: UPDATE_TIME,
segmentation,
}),
);
});

Expand Down
1 change: 1 addition & 0 deletions src/__tests__/test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ export function makeStubProject(id = 'proj-id'): InterlinearProject {
return {
id,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
sourceProjectId: 'src-project',
analysisLanguages: ['en'],
analysis: emptyAnalysis(),
Expand Down
Loading
Loading