Spaces:
Sleeping
Sleeping
| /** | |
| * 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 } | |
| ); | |
| }); | |
| }); | |