hmc-rag / frontend /src /lib /export.test.ts
webmuppet
Refactor: replace Streamlit with React+Vite SPA + FastAPI server
b5c0df4
Raw
History Blame Contribute Delete
13.6 kB
/**
* Tests for frontend/src/lib/export.ts
*
* Covers:
* - Unit tests for specific export format details
* - Property 7: Markdown export round-trip completeness
*
* **Validates: Requirements 11.1, 11.2, 11.3, 11.4**
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import * as fc from "fast-check";
import {
exportConversation,
exportSingleResponse,
exportAllSavedResponses,
} from "./export";
import type {
Citation,
Conversation,
Message,
QueryResult,
SavedResponse,
} from "../types";
// ---------------------------------------------------------------------------
// DOM stubs — jsdom doesn't implement URL.createObjectURL or anchor.click
// ---------------------------------------------------------------------------
let downloadedContent: string | null = null;
let downloadedFilename: string | null = null;
beforeEach(() => {
downloadedContent = null;
downloadedFilename = null;
// Stub URL.createObjectURL / revokeObjectURL
vi.stubGlobal("URL", {
createObjectURL: (_blob: Blob) => {
// Capture blob content synchronously via FileReaderSync isn't available
// in jsdom; instead we intercept via the Blob constructor mock below.
return "blob:mock-url";
},
revokeObjectURL: vi.fn(),
});
// Intercept anchor click to capture filename and content
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
const el = originalCreateElement(tag);
if (tag === "a") {
Object.defineProperty(el, "click", {
value: () => {
downloadedFilename = (el as HTMLAnchorElement).download;
},
writable: true,
});
}
return el;
});
// Intercept Blob to capture content
vi.stubGlobal(
"Blob",
class MockBlob {
content: string;
type: string;
constructor(parts: BlobPart[], options?: BlobPropertyBag) {
this.content = parts.join("");
this.type = options?.type ?? "";
downloadedContent = this.content;
}
}
);
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function makeQueryResult(citations: Citation[]): QueryResult {
return {
citations,
domains_searched: [],
sections_retrieved: citations.length,
timing: {},
token_usage: { total_tokens: 0 },
};
}
function makeCitation(
index: number,
title: string,
domain: string,
source_url?: string
): Citation {
return { index, domain, title, source_url };
}
function makeConversation(
messages: Message[],
results: Record<string, QueryResult>
): Conversation {
return {
id: "conv-1",
title: "Test conversation",
created_at: "2024-01-15T00:00:00Z",
last_active_at: "2024-01-15T00:00:00Z",
messages,
history: [],
results,
};
}
// ---------------------------------------------------------------------------
// Unit tests — exportSingleResponse
// ---------------------------------------------------------------------------
describe("exportSingleResponse", () => {
it("includes YAML front-matter with date, model, and type fields", () => {
const result = makeQueryResult([]);
exportSingleResponse("What is the rule?", "The rule is X.", result);
expect(downloadedContent).toContain("---");
expect(downloadedContent).toMatch(/date: \d{4}-\d{2}-\d{2}/);
expect(downloadedContent).toContain("model: claude-3-5-sonnet");
expect(downloadedContent).toContain("type: response");
});
it("includes the question as an H1 heading", () => {
const result = makeQueryResult([]);
exportSingleResponse("What is the rule?", "The rule is X.", result);
expect(downloadedContent).toContain("# What is the rule?");
});
it("includes the answer body", () => {
const result = makeQueryResult([]);
exportSingleResponse("What is the rule?", "The rule is X.", result);
expect(downloadedContent).toContain("The rule is X.");
});
it("includes a Sources section with citations when present", () => {
const result = makeQueryResult([
makeCitation(1, "Section A", "example.com", "https://example.com/a"),
makeCitation(2, "Section B", "other.com"),
]);
exportSingleResponse("What is the rule?", "The rule is X.", result);
expect(downloadedContent).toContain("## Sources");
expect(downloadedContent).toContain(
"[Section A](https://example.com/a) — example.com"
);
expect(downloadedContent).toContain("Section B — other.com");
});
it("omits the Sources section when there are no citations", () => {
const result = makeQueryResult([]);
exportSingleResponse("What is the rule?", "The rule is X.", result);
expect(downloadedContent).not.toContain("## Sources");
});
it("renders citations with source_url as hyperlinks", () => {
const result = makeQueryResult([
makeCitation(1, "My Section", "example.com", "https://example.com"),
]);
exportSingleResponse("Q?", "A.", result);
expect(downloadedContent).toContain("[My Section](https://example.com)");
});
it("renders citations without source_url as plain text", () => {
const result = makeQueryResult([
makeCitation(1, "My Section", "example.com"),
]);
exportSingleResponse("Q?", "A.", result);
expect(downloadedContent).toContain("1. My Section — example.com");
expect(downloadedContent).not.toContain("[My Section]");
});
it("generates a filename with the correct pattern", () => {
const result = makeQueryResult([]);
exportSingleResponse("What are the rules?", "Answer.", result);
// Filename: health-marketing-{YYYYMMDD}-{slug}.md
expect(downloadedFilename).toMatch(
/^health-marketing-\d{8}-what-are-the-rules\.md$/
);
});
it("slugifies the question: lowercase, hyphens, max 40 chars", () => {
const result = makeQueryResult([]);
exportSingleResponse(
"What Are The ADVERTISING Rules For Chiropractors In New Zealand?",
"Answer.",
result
);
// slug should be lowercase, hyphens, truncated to 40 chars
expect(downloadedFilename).toMatch(/^health-marketing-\d{8}-[a-z0-9-]+\.md$/);
const slug = downloadedFilename!.replace(/^health-marketing-\d{8}-/, "").replace(/\.md$/, "");
expect(slug.length).toBeLessThanOrEqual(40);
});
});
// ---------------------------------------------------------------------------
// Unit tests — exportConversation
// ---------------------------------------------------------------------------
describe("exportConversation", () => {
it("includes type: conversation in front-matter", () => {
const messages: Message[] = [
{ role: "user", content: "Question 1?" },
{ role: "assistant", content: "Answer 1." },
];
const results = { "1": makeQueryResult([]) };
const conv = makeConversation(messages, results);
exportConversation(conv);
expect(downloadedContent).toContain("type: conversation");
});
it("generates one H1 section per Q&A pair", () => {
const messages: Message[] = [
{ role: "user", content: "Question 1?" },
{ role: "assistant", content: "Answer 1." },
{ role: "user", content: "Question 2?" },
{ role: "assistant", content: "Answer 2." },
];
const results = {
"1": makeQueryResult([]),
"3": makeQueryResult([]),
};
const conv = makeConversation(messages, results);
exportConversation(conv);
expect(downloadedContent).toContain("# Question 1?");
expect(downloadedContent).toContain("# Question 2?");
expect(downloadedContent).toContain("Answer 1.");
expect(downloadedContent).toContain("Answer 2.");
});
it("uses the first user question for the filename slug", () => {
const messages: Message[] = [
{ role: "user", content: "First question here?" },
{ role: "assistant", content: "Answer." },
];
const conv = makeConversation(messages, { "1": makeQueryResult([]) });
exportConversation(conv);
expect(downloadedFilename).toMatch(/first-question-here/);
});
});
// ---------------------------------------------------------------------------
// Unit tests — exportAllSavedResponses
// ---------------------------------------------------------------------------
describe("exportAllSavedResponses", () => {
it("includes type: saved-responses in front-matter", () => {
exportAllSavedResponses([]);
expect(downloadedContent).toContain("type: saved-responses");
});
it("concatenates multiple saved responses with --- separators", () => {
const saved: SavedResponse[] = [
{
id: "conv-1:1",
question: "Q1?",
answer: "A1.",
saved_at: "2024-01-15T00:00:00Z",
source_conversation_id: "conv-1",
source_conversation_title: "Conv 1",
msg_index: 1,
messages_pair: [],
history_pair: [],
result: makeQueryResult([makeCitation(1, "Sec A", "a.com")]),
},
{
id: "conv-2:1",
question: "Q2?",
answer: "A2.",
saved_at: "2024-01-16T00:00:00Z",
source_conversation_id: "conv-2",
source_conversation_title: "Conv 2",
msg_index: 1,
messages_pair: [],
history_pair: [],
result: makeQueryResult([makeCitation(1, "Sec B", "b.com")]),
},
];
exportAllSavedResponses(saved);
expect(downloadedContent).toContain("# Q1?");
expect(downloadedContent).toContain("# Q2?");
expect(downloadedContent).toContain("---");
});
it("uses saved-responses in the filename", () => {
exportAllSavedResponses([]);
expect(downloadedFilename).toMatch(/saved-responses\.md$/);
});
});
// ---------------------------------------------------------------------------
// Property 7: Markdown export round-trip completeness
//
// For any completed assistant message with citations, the Markdown export
// generated by the browser SHALL contain the question text, the answer body,
// and a Sources section listing every citation title that was present in the
// QueryResult.
//
// **Validates: Requirements 11.3**
// ---------------------------------------------------------------------------
describe("Property 7: Markdown export round-trip completeness", () => {
// Arbitraries
const nonEmptyString = fc.string({ minLength: 1, maxLength: 200 });
const citationArb = fc
.record({
index: fc.integer({ min: 1, max: 20 }),
domain: fc.stringMatching(/^[a-z]{3,10}\.[a-z]{2,4}$/),
title: nonEmptyString,
source_url: fc.option(
fc.webUrl({ validSchemes: ["https"] }),
{ nil: undefined }
),
})
.map(
(c): Citation => ({
index: c.index,
domain: c.domain,
title: c.title,
source_url: c.source_url ?? undefined,
})
);
const queryResultArb = fc
.array(citationArb, { minLength: 0, maxLength: 10 })
.map(
(citations): QueryResult => ({
citations,
domains_searched: [],
sections_retrieved: citations.length,
timing: {},
token_usage: { total_tokens: 0 },
})
);
it("export contains question, answer, and all citation titles", () => {
fc.assert(
fc.property(nonEmptyString, nonEmptyString, queryResultArb, (question, answer, result) => {
exportSingleResponse(question, answer, result);
const content = downloadedContent ?? "";
// Must contain the question as H1
expect(content).toContain(`# ${question}`);
// Must contain the answer body
expect(content).toContain(answer);
// Must contain every citation title
for (const citation of result.citations) {
expect(content).toContain(citation.title);
}
// If there are citations, must have a Sources section
if (result.citations.length > 0) {
expect(content).toContain("## Sources");
}
}),
{ numRuns: 100 }
);
});
it("export contains all citation titles for a full conversation", () => {
const messagesPairArb = fc
.tuple(nonEmptyString, nonEmptyString)
.map(([q, a]): Message[] => [
{ role: "user", content: q },
{ role: "assistant", content: a },
]);
fc.assert(
fc.property(
fc.array(fc.tuple(messagesPairArb, queryResultArb), {
minLength: 1,
maxLength: 5,
}),
(pairs) => {
const messages: Message[] = pairs.flatMap(([msgs]) => msgs);
const results: Record<string, QueryResult> = {};
pairs.forEach(([_msgs, result], i) => {
// assistant message index = i*2 + 1
results[String(i * 2 + 1)] = result;
});
const conv = makeConversation(messages, results);
exportConversation(conv);
const content = downloadedContent ?? "";
// Every question and answer must appear
pairs.forEach(([msgs, result]) => {
const question = msgs[0].content;
const answer = msgs[1].content;
expect(content).toContain(`# ${question}`);
expect(content).toContain(answer);
// Every citation title must appear
for (const citation of result.citations) {
expect(content).toContain(citation.title);
}
});
}
),
{ numRuns: 50 }
);
});
});