160 lines
5.6 KiB
TypeScript
160 lines
5.6 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { applySavedViewState, initialAppState } from '../../src/app/app-state';
|
|
import { makeSheet } from '../../src/importers/sheet-factory';
|
|
import type { StorageAdapter } from '../../src/persistence/storage-adapter';
|
|
import {
|
|
VIEW_BOOKMARK_LIMIT,
|
|
VIEW_BOOKMARK_STORAGE_KEY,
|
|
captureViewState,
|
|
loadProjectViewBookmarks,
|
|
pathSnapshotFindingIds,
|
|
parseViewBookmarks,
|
|
saveProjectViewBookmarks,
|
|
type ViewBookmark
|
|
} from '../../src/features/view-bookmarks/view-bookmarks';
|
|
|
|
class MemoryStorage implements StorageAdapter {
|
|
values = new Map<string, string>();
|
|
get(key: string) { return this.values.get(key) ?? null; }
|
|
set(key: string, value: string) { this.values.set(key, value); }
|
|
remove(key: string) { this.values.delete(key); }
|
|
}
|
|
|
|
function bookmark(projectId: string, index: number): ViewBookmark {
|
|
return {
|
|
id: `${projectId}-${index}`,
|
|
projectId,
|
|
name: `View ${index}`,
|
|
createdAt: '2026-08-17T00:00:00.000Z',
|
|
view: {
|
|
activeSheetId: 'sheet-a',
|
|
view: 'columns',
|
|
search: '',
|
|
sortColumn: 'all',
|
|
sortDirection: 'desc',
|
|
titleColumn: 'Title',
|
|
statusFilter: 'deviation',
|
|
facetFilters: {},
|
|
actionOnly: false,
|
|
selectedRecordId: ''
|
|
}
|
|
};
|
|
}
|
|
|
|
function pathBookmark(projectId: string, index: number): ViewBookmark {
|
|
return {
|
|
...bookmark(projectId, index),
|
|
kind: 'path',
|
|
path: {
|
|
activeNodeIds: ['finding:a', 'tactic:b'],
|
|
nodeIds: ['finding:a', 'tactic:b', 'technique:c']
|
|
}
|
|
};
|
|
}
|
|
|
|
describe('view bookmarks', () => {
|
|
it('captures an independent copy of the complete working view', () => {
|
|
const source = {
|
|
...initialAppState,
|
|
activeSheetId: 'sheet-a',
|
|
view: 'table' as const,
|
|
search: 'synthetic',
|
|
statusFilter: 'deviation',
|
|
facetFilters: { severity: { values: ['critical', 'high'] } },
|
|
selectedRecordId: 'record-a'
|
|
};
|
|
const captured = captureViewState(source);
|
|
source.facetFilters.severity.values?.push('medium');
|
|
|
|
expect(captured).toMatchObject({
|
|
activeSheetId: 'sheet-a',
|
|
view: 'table',
|
|
search: 'synthetic',
|
|
statusFilter: 'deviation',
|
|
selectedRecordId: 'record-a'
|
|
});
|
|
expect(captured.facetFilters.severity.values).toEqual(['critical', 'high']);
|
|
});
|
|
|
|
it('keeps at most seven bookmarks per project and preserves other projects', () => {
|
|
const storage = new MemoryStorage();
|
|
storage.set(VIEW_BOOKMARK_STORAGE_KEY, JSON.stringify([bookmark('project-b', 1)]));
|
|
saveProjectViewBookmarks(
|
|
storage,
|
|
'project-a',
|
|
Array.from({ length: VIEW_BOOKMARK_LIMIT + 2 }, (_, index) => bookmark('project-a', index))
|
|
);
|
|
|
|
expect(loadProjectViewBookmarks(storage, 'project-a')).toHaveLength(7);
|
|
expect(loadProjectViewBookmarks(storage, 'project-b').map(item => item.name)).toEqual(['View 1']);
|
|
});
|
|
|
|
it('shares the seven slots between normal and path snapshots', () => {
|
|
const storage = new MemoryStorage();
|
|
const mixed = [bookmark('project-a', 0), pathBookmark('project-a', 1), ...Array.from(
|
|
{ length: VIEW_BOOKMARK_LIMIT },
|
|
(_, index) => bookmark('project-a', index + 2)
|
|
)];
|
|
saveProjectViewBookmarks(storage, 'project-a', mixed);
|
|
|
|
const saved = loadProjectViewBookmarks(storage, 'project-a');
|
|
expect(saved).toHaveLength(VIEW_BOOKMARK_LIMIT);
|
|
expect(saved[1]).toMatchObject({
|
|
kind: 'path',
|
|
path: { activeNodeIds: ['finding:a', 'tactic:b'], nodeIds: ['finding:a', 'tactic:b', 'technique:c'] }
|
|
});
|
|
});
|
|
|
|
it('rejects path snapshots without active nodes or a complete direct-neighbour set', () => {
|
|
const validPath = pathBookmark('project-a', 1);
|
|
const emptyPath = { ...validPath, id: 'empty', path: { activeNodeIds: [], nodeIds: [] } };
|
|
const incompletePath = { ...validPath, id: 'incomplete', path: { activeNodeIds: ['missing'], nodeIds: ['other'] } };
|
|
expect(parseViewBookmarks(JSON.stringify([validPath, emptyPath, incompletePath]))).toEqual([validPath]);
|
|
});
|
|
|
|
it('derives the Work finding scope from all finding nodes in a path snapshot', () => {
|
|
expect(pathSnapshotFindingIds({
|
|
activeNodeIds: ['category:Identity'],
|
|
nodeIds: ['category:Identity', 'finding:a', 'finding:b', 'finding:a', 'tactic:Execution']
|
|
})).toEqual(['a', 'b']);
|
|
});
|
|
|
|
it('ignores malformed local entries instead of applying partial state', () => {
|
|
const valid = bookmark('project-a', 1);
|
|
expect(parseViewBookmarks(JSON.stringify([{ bad: true }, valid]))).toEqual([valid]);
|
|
expect(parseViewBookmarks('{broken')).toEqual([]);
|
|
});
|
|
|
|
it('restores a saved view atomically only when its sheet still exists', () => {
|
|
const sheet = { ...makeSheet('Synthetic', [['Title'], ['One']], '', 'synthetic.csv'), id: 'sheet-a' };
|
|
const current = {
|
|
...initialAppState,
|
|
pathFindingIds: ['stale-path-record'],
|
|
dataset: {
|
|
fileName: 'synthetic.csv',
|
|
fileSize: 1,
|
|
lastModified: 1,
|
|
format: 'csv',
|
|
rawText: '',
|
|
sheets: [sheet],
|
|
profileId: 'generic',
|
|
scanTool: 'Synthetic'
|
|
}
|
|
};
|
|
const saved = {
|
|
...bookmark('project-a', 1).view,
|
|
view: 'records' as const,
|
|
search: 'one',
|
|
statusFilter: 'all',
|
|
facetFilters: { severity: { values: ['high', 'critical'] } },
|
|
selectedRecordId: sheet.analysis.records[0].id
|
|
};
|
|
|
|
const restored = applySavedViewState(current, saved);
|
|
expect(restored).toMatchObject(saved);
|
|
expect(restored.pathFindingIds).toEqual([]);
|
|
expect(restored.facetFilters).not.toBe(saved.facetFilters);
|
|
expect(applySavedViewState(current, { ...saved, activeSheetId: 'missing' })).toBe(current);
|
|
});
|
|
});
|