Files
markdown-converter/tests/main/SemanticEngine.test.js
T
amitwh c3fe72bcae feat(templates,qa): template gallery + pluggable DocQA engine
Two more features from the deferred menu:

Daily-note template gallery:
- src/main/DailyNotesTemplates.js — pure module: listTemplates() /
  saveTemplate() / deleteTemplate() / labelFor() with injectable IO.
- src/main/DailyNotes.js — openOrCreate() now accepts seedContent so a
  non-default template can seed a NEW note (existing notes never get
  clobbered).
- src/main.js — IPC channels daily-templates:list / save / delete /
  apply. apply renders the chosen template (with {date}/{weekday}
  substitution) and pipes through DailyNotes.openOrCreate.
- src/sidebar/daily-templates-panel.js — gallery UI: list, +New
  (prompt for name + content), Use (applies to today's note),
  delete (refuses to remove the last template so the default survives).
- src/renderer.js — registers the panel.
- src/index.html — icon (already added).

Pluggable DocQA engine (semantic search hook):
- src/main/SemanticEngine.js — engine interface with defaultEngine() (TF-idF,
  always available) and neuralEngine() (lazy @xenova/transformers,
  falls back gracefully when the dep is missing). getEngine(name)
  resolves either.
- src/main/DocQA.js — ask() is now async and accepts an engine arg.
  TF-idF path unchanged; neural path calls engine.rank(question, chunks)
  directly. The chunk corpus is built up front regardless of engine so
  ranking is consistent.
- src/main.js — doc-qa:ask IPC resolves the engine via SemanticEngine.getEngine(name)
  before calling DocQA.ask. The renderer can pass {engine: 'transformers'}
  to opt in once @xenova/transformers is installed.

Tests (51 new across this batch):
- tests/main/DailyNotesTemplates.test.js (18): labelFor separators /
  edge cases / non-string safety, listTemplates empty / present / sort,
  saveTemplate nested dir + .md extension + validation + null content,
  deleteTemplate success / missing / validation.
- tests/daily-templates-panel.test.js (12): mount + empty state + list +
  XSS safety, Use button (apply + error path), Delete button (success +
  last-template guard), New template (save + cancel), refresh.
- tests/main/SemanticEngine.test.js (8): default engine shape + rank
  matches WorkspaceSearch, getEngine for tf-idf / unknown / transformers
  (graceful fallback when @xenova/transformers missing), parity check.
- DocQA: 5 new tests for engine arg (custom engine.rank called, default
  fallback, neural hit shape translation); existing tests updated to
  await the now-async ask().

Full suite: 76 suites, 914 tests, lint+format clean.

Activation for the neural engine:
  npm install @xenova/transformers
  (heavy; ~50 MiB with deps) — then 'transformers' is selectable in
  doc-qa:ask. Until then, all calls use TF-idF transparently.

Amit Haridas
2026-09-14 11:57:32 +05:30

88 lines
3.4 KiB
JavaScript

/**
* @jest-environment node
*
* SemanticEngine tests — verify the engine interface contract and the
* fallback path. The neural engine itself isn't loaded in tests (no
* @xenova/transformers dep installed in CI); we just verify the
* graceful fallback behavior.
*/
const SemanticEngine = require('../../src/main/SemanticEngine');
const WorkspaceSearch = require('../../src/main/WorkspaceSearch');
describe('SemanticEngine.defaultEngine', () => {
test('returns an engine object with the right shape', () => {
const e = SemanticEngine.defaultEngine();
expect(e.name).toBe('tf-idf');
expect(e.isNeural).toBe(false);
expect(typeof e.rank).toBe('function');
});
test('rank() returns WorkspaceSearch-style results', async () => {
const e = SemanticEngine.defaultEngine();
const chunks = [
{ path: 'a.md', content: 'rust async with tokio', mtimeMs: 1 },
{ path: 'b.md', content: 'unrelated content', mtimeMs: 1 },
];
const r = await e.rank('rust async', chunks);
expect(r.length).toBeGreaterThan(0);
// First result should match the query
expect(r[0].filePath).toBe('a.md');
// Shape matches WorkspaceSearch
for (const hit of r) {
expect(hit).toHaveProperty('filePath');
expect(hit).toHaveProperty('score');
expect(hit).toHaveProperty('snippet');
}
});
test('rank() filters out non-matching chunks', async () => {
const e = SemanticEngine.defaultEngine();
const r = await e.rank('rust', [
{ path: 'x.md', content: 'totally unrelated prose about cats' },
{ path: 'y.md', content: 'rust rust rust everywhere' },
]);
expect(r.every((hit) => hit.filePath === 'y.md')).toBe(true);
});
});
describe('SemanticEngine.getEngine', () => {
test('returns the default engine for "tf-idf"', async () => {
const e = await SemanticEngine.getEngine('tf-idf');
expect(e.isNeural).toBe(false);
});
test('returns the default engine for an unknown name (graceful fallback)', async () => {
const e = await SemanticEngine.getEngine('not-a-real-engine');
expect(e.isNeural).toBe(false);
expect(e.name).toBe('tf-idf');
});
test('neural engine falls back to tf-idf when @xenova/transformers is missing', async () => {
// No install of @xenova/transformers in CI; the require() inside
// neuralEngine() should fail and fall back.
const e = await SemanticEngine.getEngine('transformers', { allowRemote: false });
expect(e.isNeural).toBe(false);
expect(e.name).toBe('tf-idf');
});
test('default engine is consistent across calls', () => {
const a = SemanticEngine.defaultEngine();
const b = SemanticEngine.defaultEngine();
expect(a).not.toBe(b);
expect(a.name).toBe(b.name);
});
});
describe('SemanticEngine — WorkspaceSearch parity', () => {
test('default engine returns the same shape WorkspaceSearch.search would', async () => {
const files = [
{ path: '/a.md', content: 'rust async programming', mtimeMs: 1 },
{ path: '/b.md', content: 'python web dev', mtimeMs: 2 },
];
const e = SemanticEngine.defaultEngine();
const engineResult = await e.rank('rust', files);
const directResult = WorkspaceSearch.search({ query: 'rust', files, limit: files.length });
expect(engineResult.map((r) => r.filePath)).toEqual(directResult.map((r) => r.filePath));
expect(engineResult[0].score).toBe(directResult[0].score);
});
});