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
3 changes: 2 additions & 1 deletion src/db/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,8 @@ export async function rollbackLastMigration(db: SQLiteDB): Promise<void> {
export async function getMigrationStatus(db: SQLiteDB): Promise<MigrationStatus[]> {
try {
await ensureSchemaVersionTable(db);
} catch {
} catch (err) {
logger.warn('Failed to ensure schema version table', err);
return [];
}

Expand Down
4 changes: 3 additions & 1 deletion src/db/repository/base.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { z } from 'zod';
import { getDb, SQLiteDB } from '../client';
import { logger } from '../../lib/logger';

export class RepositoryBase {
protected get db(): SQLiteDB {
Expand All @@ -26,7 +27,8 @@ export class RepositoryBase {
if (typeof r.metadata === 'string') {
try {
r.metadata = JSON.parse(r.metadata) as Record<string, unknown>;
} catch {
} catch (err) {
logger.debug('Failed to parse metadata JSON, defaulting to empty object', err);
r.metadata = {};
}
}
Expand Down
125 changes: 125 additions & 0 deletions src/features/editor/__tests__/extensions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { describe, it, expect } from 'vitest';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { MentionExtension } from '../MentionExtension';
import { ClaimExtension } from '../ClaimExtension';

function createEditor(extensions = []) {
return new Editor({
extensions: [StarterKit, ...extensions],
content: '',
});
}

describe('MentionExtension', () => {
it('registers mention mark', () => {
const editor = createEditor([MentionExtension]);
expect(editor.extensionManager.extensions.find(e => e.name === 'mention')).toBeDefined();
});

it('can set a mention mark', () => {
const editor = createEditor([MentionExtension]);
editor.commands.setContent('<p>Test</p>');

Check warning on line 22 in src/features/editor/__tests__/extensions.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/editor/__tests__/extensions.test.ts#L22

HTML passed in to function 'editor.commands.setContent'
editor.commands.setTextSelection({ from: 1, to: 5 });
editor.commands.setMention({ entityId: 'e1', entityName: 'Entity 1' });
const html = editor.getHTML();

Check warning on line 25 in src/features/editor/__tests__/extensions.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/editor/__tests__/extensions.test.ts#L25

Unencoded return value from function 'editor.getHTML' used in HTML context
expect(html).toContain('data-entity-id="e1"');

Check warning on line 26 in src/features/editor/__tests__/extensions.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/editor/__tests__/extensions.test.ts#L26

HTML passed in to function 'expect'
expect(html).toContain('data-entity-name="Entity 1"');

Check warning on line 27 in src/features/editor/__tests__/extensions.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/editor/__tests__/extensions.test.ts#L27

HTML passed in to function 'expect'
});

it('can toggle a mention mark', () => {
const editor = createEditor([MentionExtension]);
editor.commands.setContent('<p>Test</p>');
editor.commands.setTextSelection({ from: 1, to: 5 });
editor.commands.toggleMention({ entityId: 'e1', entityName: 'Entity 1' });
const html = editor.getHTML();
expect(html).toContain('data-entity-id="e1"');
editor.commands.setTextSelection({ from: 1, to: 5 });
editor.commands.toggleMention({ entityId: 'e1', entityName: 'Entity 1' });
const htmlAfter = editor.getHTML();

Check warning on line 39 in src/features/editor/__tests__/extensions.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/editor/__tests__/extensions.test.ts#L39

Unencoded return value from function 'editor.getHTML' used in HTML context
expect(htmlAfter).not.toContain('data-entity-id="e1"');

Check warning on line 40 in src/features/editor/__tests__/extensions.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/editor/__tests__/extensions.test.ts#L40

HTML passed in to function 'expect'
});

it('can unset a mention mark', () => {
const editor = createEditor([MentionExtension]);
editor.commands.setContent('<p>Test</p>');
editor.commands.setMention({ entityId: 'e1', entityName: 'Entity 1' });
editor.commands.unsetMention();
const html = editor.getHTML();
expect(html).not.toContain('data-entity-id');

Check warning on line 49 in src/features/editor/__tests__/extensions.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/editor/__tests__/extensions.test.ts#L49

HTML passed in to function 'expect'
});

it('parses HTML with mention attributes', () => {
const editor = createEditor([MentionExtension]);
editor.commands.setContent('<p><span data-entity-id="e1" data-entity-name="Entity 1">text</span></p>');

Check warning on line 54 in src/features/editor/__tests__/extensions.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/editor/__tests__/extensions.test.ts#L54

HTML passed in to function 'editor.commands.setContent'
const json = editor.getJSON();
const marks = json.content?.[0].content?.[0].marks as Array<{ type: string; attrs?: Record<string, string> }> | undefined;
expect(marks).toBeDefined();
const mentionMark = marks?.find(m => m.type === 'mention');
expect(mentionMark).toBeDefined();
expect(mentionMark?.attrs).toEqual({ entityId: 'e1', entityName: 'Entity 1' });
});

it('applies CSS class to rendered mention', () => {
const editor = createEditor([MentionExtension]);
editor.commands.setContent('<p>Test</p>');
editor.commands.setTextSelection({ from: 1, to: 5 });
editor.commands.setMention({ entityId: 'e1', entityName: 'Entity 1' });
const html = editor.getHTML();
expect(html).toContain('class="entity-mention"');

Check warning on line 69 in src/features/editor/__tests__/extensions.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/editor/__tests__/extensions.test.ts#L69

HTML passed in to function 'expect'
});
});

describe('ClaimExtension', () => {
it('registers claim mark', () => {
const editor = createEditor([ClaimExtension]);
expect(editor.extensionManager.extensions.find(e => e.name === 'claim')).toBeDefined();
});

it('can set a claim mark', () => {
const editor = createEditor([ClaimExtension]);
editor.commands.setContent('<p>Test</p>');
editor.commands.setTextSelection({ from: 1, to: 5 });
editor.commands.setClaim();
const html = editor.getHTML();
expect(html).toContain('knowledge-claim');

Check warning on line 85 in src/features/editor/__tests__/extensions.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/editor/__tests__/extensions.test.ts#L85

HTML passed in to function 'expect'
});

it('can toggle a claim mark', () => {
const editor = createEditor([ClaimExtension]);
editor.commands.setContent('<p>Test</p>');
editor.commands.setTextSelection({ from: 1, to: 5 });
editor.commands.toggleClaim();
expect(editor.getHTML()).toContain('knowledge-claim');
editor.commands.setTextSelection({ from: 1, to: 5 });
editor.commands.toggleClaim();
expect(editor.getHTML()).not.toContain('knowledge-claim');
});

it('can unset a claim mark', () => {
const editor = createEditor([ClaimExtension]);
editor.commands.setContent('<p>Test</p>');
editor.commands.setClaim();
editor.commands.unsetClaim();
expect(editor.getHTML()).not.toContain('knowledge-claim');
});

it('parses HTML with claim class', () => {
const editor = createEditor([ClaimExtension]);
editor.commands.setContent('<p><span class="knowledge-claim">important fact</span></p>');

Check warning on line 109 in src/features/editor/__tests__/extensions.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/editor/__tests__/extensions.test.ts#L109

HTML passed in to function 'editor.commands.setContent'
const json = editor.getJSON();
const marks = json.content?.[0].content?.[0].marks as Array<{ type: string }> | undefined;
expect(marks).toBeDefined();
const claimMark = marks?.find(m => m.type === 'claim');
expect(claimMark).toBeDefined();
});

it('applies CSS class to rendered claim', () => {
const editor = createEditor([ClaimExtension]);
editor.commands.setContent('<p>Test</p>');
editor.commands.setTextSelection({ from: 1, to: 5 });
editor.commands.setClaim();
const html = editor.getHTML();
expect(html).toContain('class="knowledge-claim"');

Check warning on line 123 in src/features/editor/__tests__/extensions.test.ts

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

src/features/editor/__tests__/extensions.test.ts#L123

HTML passed in to function 'expect'
});
});
10 changes: 8 additions & 2 deletions src/features/graph/GraphSnapshotManager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import { IRepository } from '../../db/repository';
import { logger } from '../../lib/logger';
import { validateSnapshotData } from './graph-schemas';

export function useGraphSnapshotManager(repository: IRepository) {
const [snapshotMode, setSnapshotMode] = useState(false);
Expand All @@ -26,9 +27,14 @@ export function useGraphSnapshotManager(repository: IRepository) {
nodes: { id: string; label: string }[],
edges: { id: string; source: string; target: string; label?: string }[]
) => {
setSnapshotData({ nodes, edges });
const validated = validateSnapshotData({ nodes, edges });
if (!validated) {
logger.warn('Invalid snapshot data rejected', { nodeCount: nodes.length, edgeCount: edges.length });
return;
}
setSnapshotData(validated);
setSnapshotMode(true);
logger.info(`Snapshot loaded with ${nodes.length} nodes, ${edges.length} edges`);
logger.info(`Snapshot loaded with ${validated.nodes.length} nodes, ${validated.edges.length} edges`);
}, []);

const handleExitSnapshot = useCallback(() => {
Expand Down
53 changes: 53 additions & 0 deletions src/features/graph/__tests__/GraphKeyboardNav.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { validateSnapshotData } from '../graph-schemas';

describe('GraphKeyboardNav', () => {
it('validates snapshot data before loading', () => {
const validData = {
nodes: [{ id: 'n1', label: 'A' }, { id: 'n2', label: 'B' }],
edges: [{ id: 'e1', source: 'n1', target: 'n2' }],
};
const result = validateSnapshotData(validData);
expect(result).not.toBeNull();
expect(result?.nodes).toHaveLength(2);
expect(result?.edges).toHaveLength(1);
});

it('rejects invalid snapshot data', () => {
expect(validateSnapshotData(null)).toBeNull();
expect(validateSnapshotData({})).toBeNull();
expect(validateSnapshotData({ nodes: 'bad' })).toBeNull();
});

it('filters out placeholder nodes', () => {
const nodes = ['placeholder', 'n1', 'n2'];
const visibleNodes = nodes.filter(n => n !== 'placeholder');
expect(visibleNodes).toEqual(['n1', 'n2']);
});

it('cycles through nodes with Tab key', () => {
const nodes = ['n1', 'n2', 'n3'];
const currentIdx = 0;
const dir = 1;
const next = ((currentIdx + dir) % nodes.length + nodes.length) % nodes.length;
expect(next).toBe(1);
});

it('wraps around with Shift+Tab', () => {
const nodes = ['n1', 'n2', 'n3'];
const currentIdx = 0;
const dir = -1;
const next = ((currentIdx + dir) % nodes.length + nodes.length) % nodes.length;
expect(next).toBe(2);
});

it('navigates to first neighbor with ArrowRight', () => {
const neighbors = ['n2', 'n3'];
expect(neighbors[0]).toBe('n2');
});

it('navigates to last neighbor with ArrowLeft', () => {
const neighbors = ['n2', 'n3'];
expect(neighbors[neighbors.length - 1]).toBe('n3');
});
});
92 changes: 92 additions & 0 deletions src/features/graph/__tests__/graph-schemas.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { describe, it, expect } from 'vitest';
import {
GraphNodeSchema,
GraphEdgeSchema,
GraphSnapshotDataSchema,
validateSnapshotData,
} from '../graph-schemas';

describe('GraphNodeSchema', () => {
it('accepts valid node', () => {
expect(GraphNodeSchema.parse({ id: 'n1', label: 'Test' })).toEqual({ id: 'n1', label: 'Test' });
});

it('rejects empty id', () => {
expect(() => GraphNodeSchema.parse({ id: '', label: 'Test' })).toThrow();
});

it('rejects missing label', () => {
expect(() => GraphNodeSchema.parse({ id: 'n1' })).toThrow();
});
});

describe('GraphEdgeSchema', () => {
it('accepts valid edge', () => {
expect(GraphEdgeSchema.parse({ id: 'e1', source: 'n1', target: 'n2' })).toEqual({
id: 'e1', source: 'n1', target: 'n2', label: undefined,
});
});

it('accepts edge with label', () => {
const edge = GraphEdgeSchema.parse({ id: 'e1', source: 'n1', target: 'n2', label: 'contains' });
expect(edge.label).toBe('contains');
});

it('rejects empty source', () => {
expect(() => GraphEdgeSchema.parse({ id: 'e1', source: '', target: 'n2' })).toThrow();
});

it('rejects empty target', () => {
expect(() => GraphEdgeSchema.parse({ id: 'e1', source: 'n1', target: '' })).toThrow();
});
});

describe('GraphSnapshotDataSchema', () => {
it('accepts valid snapshot data', () => {
const data = {
nodes: [{ id: 'n1', label: 'A' }],
edges: [{ id: 'e1', source: 'n1', target: 'n2' }],
};
expect(GraphSnapshotDataSchema.parse(data)).toEqual(data);
});

it('accepts empty arrays', () => {
expect(GraphSnapshotDataSchema.parse({ nodes: [], edges: [] })).toEqual({ nodes: [], edges: [] });
});
});

describe('validateSnapshotData', () => {
it('returns parsed data for valid input', () => {
const input = {
nodes: [{ id: 'n1', label: 'A' }, { id: 'n2', label: 'B' }],
edges: [{ id: 'e1', source: 'n1', target: 'n2', label: 'link' }],
};
expect(validateSnapshotData(input)).toEqual(input);
});

it('returns null for invalid input', () => {
expect(validateSnapshotData({ nodes: 'not-an-array' })).toBeNull();
});

it('returns null for missing nodes', () => {
expect(validateSnapshotData({ edges: [] })).toBeNull();
});

it('returns null for node with empty id', () => {
expect(validateSnapshotData({ nodes: [{ id: '', label: 'X' }], edges: [] })).toBeNull();
});

it('returns null for edge with empty source', () => {
expect(validateSnapshotData({
nodes: [{ id: 'n1', label: 'A' }],
edges: [{ id: 'e1', source: '', target: 'n2' }],
})).toBeNull();
});

it('returns null for completely invalid data', () => {
expect(validateSnapshotData(null)).toBeNull();
expect(validateSnapshotData(undefined)).toBeNull();
expect(validateSnapshotData('string')).toBeNull();
expect(validateSnapshotData(42)).toBeNull();
});
});
22 changes: 18 additions & 4 deletions src/features/graph/graph-schemas.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
import { z } from 'zod';

export const GraphNodeSchema = z.object({
id: z.string(),
id: z.string().min(1),
label: z.string(),
});

export const GraphEdgeSchema = z.object({
id: z.string(),
source: z.string(),
target: z.string(),
id: z.string().min(1),
source: z.string().min(1),
target: z.string().min(1),
label: z.string().optional(),
});

export const GraphSnapshotDataSchema = z.object({
nodes: z.array(GraphNodeSchema),
edges: z.array(GraphEdgeSchema),
});

export type GraphNode = z.infer<typeof GraphNodeSchema>;
export type GraphEdge = z.infer<typeof GraphEdgeSchema>;
export type GraphSnapshotData = z.infer<typeof GraphSnapshotDataSchema>;

export function validateSnapshotData(data: unknown): GraphSnapshotData | null {
const result = GraphSnapshotDataSchema.safeParse(data);
if (result.success) {
return result.data;
}
return null;
}
13 changes: 8 additions & 5 deletions src/lib/chat-persistence.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { logger } from './logger';

const DB_NAME = 'dks-chat-history';
const DB_VERSION = 1;
const STORE_NAME = 'messages';
Expand Down Expand Up @@ -45,7 +47,8 @@ export const loadChatHistory = async (): Promise<ChatMessage[]> => {
};
request.onerror = () => reject(new Error(String(request.error)));
});
} catch {
} catch (err) {
logger.warn('Failed to load chat history', err);
return [];
}
};
Expand All @@ -62,8 +65,8 @@ export const saveChatHistory = async (messages: ChatMessage[]): Promise<void> =>
tx.oncomplete = () => resolve();
tx.onerror = () => reject(new Error(String(tx.error)));
});
} catch {
// Silently fail — chat history persistence is best-effort
} catch (err) {
logger.warn('Failed to save chat history', err);
}
};

Expand All @@ -77,7 +80,7 @@ export const clearChatHistory = async (): Promise<void> => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(new Error(String(tx.error)));
});
} catch {
// Silently fail
} catch (err) {
logger.warn('Failed to clear chat history', err);
}
};
Loading
Loading