Spaces:
Runtime error
Runtime error
| /** | |
| * Property-based tests for storage helpers. | |
| * | |
| * **Validates: Requirements 6.1, 6.3, 6.4, 7.3, 7.4, 6.5** | |
| * | |
| * Properties tested: | |
| * 1 — Conversation cap enforcement | |
| * 2 — Saved response cap enforcement | |
| * 3 — Saved response idempotency | |
| * 4 — Conversation title truncation | |
| * 5 — Saved response sidebar truncation (titleFromMessages ≤ 80 chars) | |
| * 8 — Conversation restore fidelity | |
| */ | |
| import { describe, it, beforeEach, vi, expect } from "vitest"; | |
| import * as fc from "fast-check"; | |
| import { | |
| readConversations, | |
| writeConversations, | |
| readSavedResponses, | |
| writeSavedResponses, | |
| titleFromMessages, | |
| savedResponseId, | |
| } from "./storage"; | |
| import type { Conversation, Message, SavedResponse, QueryResult } from "../types"; | |
| // --------------------------------------------------------------------------- | |
| // In-memory localStorage mock | |
| // --------------------------------------------------------------------------- | |
| class InMemoryStorage implements Storage { | |
| private store: Record<string, string> = {}; | |
| get length(): number { | |
| return Object.keys(this.store).length; | |
| } | |
| clear(): void { | |
| this.store = {}; | |
| } | |
| getItem(key: string): string | null { | |
| return Object.prototype.hasOwnProperty.call(this.store, key) | |
| ? this.store[key] | |
| : null; | |
| } | |
| key(index: number): string | null { | |
| return Object.keys(this.store)[index] ?? null; | |
| } | |
| removeItem(key: string): void { | |
| delete this.store[key]; | |
| } | |
| setItem(key: string, value: string): void { | |
| this.store[key] = value; | |
| } | |
| } | |
| // Install the mock before any imports use localStorage | |
| const mockStorage = new InMemoryStorage(); | |
| vi.stubGlobal("localStorage", mockStorage); | |
| // --------------------------------------------------------------------------- | |
| // Arbitraries | |
| // --------------------------------------------------------------------------- | |
| /** ISO 8601 UTC timestamp arbitrary */ | |
| const isoTimestamp = fc | |
| .date({ min: new Date("2020-01-01"), max: new Date("2030-01-01") }) | |
| .map((d) => d.toISOString()); | |
| /** Arbitrary Message */ | |
| const messageArb: fc.Arbitrary<Message> = fc.record({ | |
| role: fc.constantFrom("user" as const, "assistant" as const), | |
| content: fc.string({ minLength: 0, maxLength: 500 }), | |
| }); | |
| /** Arbitrary QueryResult */ | |
| const queryResultArb: fc.Arbitrary<QueryResult> = fc.record({ | |
| citations: fc.array( | |
| fc.record({ | |
| index: fc.nat(), | |
| domain: fc.string({ minLength: 1, maxLength: 50 }), | |
| title: fc.string({ minLength: 1, maxLength: 100 }), | |
| source_url: fc.option(fc.webUrl(), { nil: undefined }), | |
| line_num: fc.option(fc.nat(), { nil: undefined }), | |
| }), | |
| { maxLength: 5 } | |
| ), | |
| domains_searched: fc.array(fc.string({ minLength: 1, maxLength: 50 }), { | |
| maxLength: 5, | |
| }), | |
| sections_retrieved: fc.nat({ max: 20 }), | |
| timing: fc.dictionary( | |
| fc.string({ minLength: 1, maxLength: 20 }), | |
| // Use finite floats that survive JSON round-trip (no -0, NaN, or Infinity) | |
| fc.float({ noNaN: true, noDefaultInfinity: true }).filter(v => !Object.is(v, -0)) | |
| ), | |
| token_usage: fc.record({ total_tokens: fc.nat({ max: 10000 }) }), | |
| }); | |
| /** Arbitrary Conversation */ | |
| const conversationArb: fc.Arbitrary<Conversation> = fc | |
| .tuple( | |
| fc.uuid(), | |
| isoTimestamp, | |
| isoTimestamp, | |
| fc.array(messageArb, { maxLength: 10 }), | |
| fc.array(messageArb, { maxLength: 10 }), | |
| fc.array(fc.tuple(fc.nat({ max: 20 }), queryResultArb), { maxLength: 5 }) | |
| ) | |
| .map(([id, created_at, last_active_at, messages, history, resultPairs]) => { | |
| const results: Record<string, QueryResult> = {}; | |
| for (const [idx, result] of resultPairs) { | |
| results[String(idx)] = result; | |
| } | |
| // Derive title from messages (mirrors real usage) | |
| const userMsg = messages.find((m) => m.role === "user"); | |
| const rawTitle = userMsg?.content ?? "New conversation"; | |
| const title = | |
| rawTitle.length > 80 ? rawTitle.slice(0, 80) + "\u2026" : rawTitle; | |
| return { id, title, created_at, last_active_at, messages, history, results }; | |
| }); | |
| /** Arbitrary SavedResponse with given conversationId and msgIndex */ | |
| const savedResponseArb = ( | |
| conversationId: string, | |
| msgIndex: number | |
| ): fc.Arbitrary<SavedResponse> => | |
| fc | |
| .tuple( | |
| fc.string({ minLength: 1, maxLength: 200 }), | |
| fc.string({ minLength: 1, maxLength: 2000 }), | |
| isoTimestamp, | |
| fc.string({ minLength: 1, maxLength: 80 }), | |
| fc.array(messageArb, { minLength: 2, maxLength: 2 }), | |
| fc.array(messageArb, { minLength: 2, maxLength: 2 }), | |
| queryResultArb | |
| ) | |
| .map( | |
| ([ | |
| question, | |
| answer, | |
| saved_at, | |
| source_conversation_title, | |
| messages_pair, | |
| history_pair, | |
| result, | |
| ]) => ({ | |
| id: savedResponseId(conversationId, msgIndex), | |
| question, | |
| answer, | |
| saved_at, | |
| source_conversation_id: conversationId, | |
| source_conversation_title, | |
| msg_index: msgIndex, | |
| messages_pair, | |
| history_pair, | |
| result, | |
| }) | |
| ); | |
| /** Arbitrary SavedResponse with random conversationId and msgIndex */ | |
| const anySavedResponseArb: fc.Arbitrary<SavedResponse> = fc | |
| .tuple(fc.uuid(), fc.nat({ max: 100 })) | |
| .chain(([convId, msgIdx]) => savedResponseArb(convId, msgIdx)); | |
| // --------------------------------------------------------------------------- | |
| // Setup | |
| // --------------------------------------------------------------------------- | |
| beforeEach(() => { | |
| mockStorage.clear(); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // Property 1: Conversation cap enforcement | |
| // --------------------------------------------------------------------------- | |
| describe("Property 1: Conversation cap enforcement", () => { | |
| /** | |
| * **Validates: Requirements 6.3** | |
| * | |
| * For any sequence of conversation saves, hmc-conversations length ≤ 20 | |
| * and the evicted entry always has the minimum last_active_at. | |
| */ | |
| it("hmc-conversations never exceeds 20 entries and evicts the oldest", () => { | |
| fc.assert( | |
| fc.property( | |
| fc.array(conversationArb, { minLength: 1, maxLength: 40 }), | |
| (conversations) => { | |
| mockStorage.clear(); | |
| writeConversations(conversations); | |
| const stored = readConversations(); | |
| // Cap is enforced | |
| expect(stored.length).toBeLessThanOrEqual(20); | |
| // If we had more than 20, the stored set must not contain the | |
| // entry with the globally minimum last_active_at (it was evicted). | |
| if (conversations.length > 20) { | |
| const minTs = Math.min( | |
| ...conversations.map((c) => | |
| new Date(c.last_active_at).getTime() | |
| ) | |
| ); | |
| const storedIds = new Set(stored.map((c) => c.id)); | |
| const evictedCandidates = conversations.filter( | |
| (c) => new Date(c.last_active_at).getTime() === minTs | |
| ); | |
| // At least one of the minimum-timestamp entries must be absent | |
| // (it was evicted). We only check when there's a unique minimum. | |
| const uniqueMin = evictedCandidates.length === 1; | |
| if (uniqueMin) { | |
| expect(storedIds.has(evictedCandidates[0].id)).toBe(false); | |
| } | |
| } | |
| } | |
| ) | |
| ); | |
| }); | |
| it("stored conversations are sorted by last_active_at descending", () => { | |
| fc.assert( | |
| fc.property( | |
| fc.array(conversationArb, { minLength: 2, maxLength: 30 }), | |
| (conversations) => { | |
| mockStorage.clear(); | |
| writeConversations(conversations); | |
| const stored = readConversations(); | |
| for (let i = 1; i < stored.length; i++) { | |
| const prev = new Date(stored[i - 1].last_active_at).getTime(); | |
| const curr = new Date(stored[i].last_active_at).getTime(); | |
| expect(prev).toBeGreaterThanOrEqual(curr); | |
| } | |
| } | |
| ) | |
| ); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // Property 2: Saved response cap enforcement | |
| // --------------------------------------------------------------------------- | |
| describe("Property 2: Saved response cap enforcement", () => { | |
| /** | |
| * **Validates: Requirements 7.4** | |
| * | |
| * For any sequence of saved-response saves, hmc-saved-responses length ≤ 50 | |
| * and eviction is by oldest saved_at. | |
| */ | |
| it("hmc-saved-responses never exceeds 50 entries and evicts the oldest", () => { | |
| fc.assert( | |
| fc.property( | |
| fc.array(anySavedResponseArb, { minLength: 1, maxLength: 80 }), | |
| (responses) => { | |
| mockStorage.clear(); | |
| writeSavedResponses(responses); | |
| const stored = readSavedResponses(); | |
| // Cap is enforced | |
| expect(stored.length).toBeLessThanOrEqual(50); | |
| // If we had more than 50, the entry with the globally minimum | |
| // saved_at must have been evicted. | |
| if (responses.length > 50) { | |
| const minTs = Math.min( | |
| ...responses.map((r) => new Date(r.saved_at).getTime()) | |
| ); | |
| const storedIds = new Set(stored.map((r) => r.id)); | |
| const evictedCandidates = responses.filter( | |
| (r) => new Date(r.saved_at).getTime() === minTs | |
| ); | |
| const uniqueMin = evictedCandidates.length === 1; | |
| if (uniqueMin) { | |
| expect(storedIds.has(evictedCandidates[0].id)).toBe(false); | |
| } | |
| } | |
| } | |
| ) | |
| ); | |
| }); | |
| it("stored saved responses are sorted by saved_at descending", () => { | |
| fc.assert( | |
| fc.property( | |
| fc.array(anySavedResponseArb, { minLength: 2, maxLength: 60 }), | |
| (responses) => { | |
| mockStorage.clear(); | |
| writeSavedResponses(responses); | |
| const stored = readSavedResponses(); | |
| for (let i = 1; i < stored.length; i++) { | |
| const prev = new Date(stored[i - 1].saved_at).getTime(); | |
| const curr = new Date(stored[i].saved_at).getTime(); | |
| expect(prev).toBeGreaterThanOrEqual(curr); | |
| } | |
| } | |
| ) | |
| ); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // Property 3: Saved response idempotency | |
| // --------------------------------------------------------------------------- | |
| describe("Property 3: Saved response idempotency", () => { | |
| /** | |
| * **Validates: Requirements 7.3** | |
| * | |
| * Saving the same (conversation_id, msg_index) pair multiple times results | |
| * in exactly one record per pair. The upsert pattern: filter out existing | |
| * record by id, then prepend the new one. | |
| */ | |
| it("saving the same (conversationId, msgIndex) pair multiple times yields exactly one record", () => { | |
| fc.assert( | |
| fc.property( | |
| fc.uuid(), | |
| fc.nat({ max: 100 }), | |
| fc.integer({ min: 2, max: 5 }), | |
| (conversationId, msgIndex, saveCount) => { | |
| mockStorage.clear(); | |
| const id = savedResponseId(conversationId, msgIndex); | |
| // Simulate saving the same pair `saveCount` times (upsert pattern) | |
| for (let i = 0; i < saveCount; i++) { | |
| const existing = readSavedResponses(); | |
| // Upsert: remove any existing record with the same id, then add new | |
| const withoutDuplicate = existing.filter((r) => r.id !== id); | |
| const newResponse: SavedResponse = { | |
| id, | |
| question: `Question version ${i}`, | |
| answer: `Answer version ${i}`, | |
| saved_at: new Date(Date.now() + i * 1000).toISOString(), | |
| source_conversation_id: conversationId, | |
| source_conversation_title: "Test conversation", | |
| msg_index: msgIndex, | |
| messages_pair: [ | |
| { role: "user", content: `Q${i}` }, | |
| { role: "assistant", content: `A${i}` }, | |
| ], | |
| history_pair: [ | |
| { role: "user", content: `Q${i}` }, | |
| { role: "assistant", content: `A${i}` }, | |
| ], | |
| result: { | |
| citations: [], | |
| domains_searched: [], | |
| sections_retrieved: 0, | |
| timing: {}, | |
| token_usage: { total_tokens: 0 }, | |
| }, | |
| }; | |
| writeSavedResponses([newResponse, ...withoutDuplicate]); | |
| } | |
| const stored = readSavedResponses(); | |
| const matchingRecords = stored.filter((r) => r.id === id); | |
| expect(matchingRecords).toHaveLength(1); | |
| } | |
| ) | |
| ); | |
| }); | |
| it("multiple distinct (conversationId, msgIndex) pairs each produce exactly one record", () => { | |
| fc.assert( | |
| fc.property( | |
| fc.array( | |
| fc.tuple(fc.uuid(), fc.nat({ max: 50 })), | |
| { minLength: 1, maxLength: 10 } | |
| ), | |
| (pairs) => { | |
| mockStorage.clear(); | |
| // Deduplicate pairs by id to avoid intentional collisions in the input | |
| const seen = new Set<string>(); | |
| const uniquePairs = pairs.filter(([convId, msgIdx]) => { | |
| const id = savedResponseId(convId, msgIdx); | |
| if (seen.has(id)) return false; | |
| seen.add(id); | |
| return true; | |
| }); | |
| // Save each pair twice | |
| for (const [convId, msgIdx] of uniquePairs) { | |
| const id = savedResponseId(convId, msgIdx); | |
| for (let i = 0; i < 2; i++) { | |
| const existing = readSavedResponses(); | |
| const withoutDuplicate = existing.filter((r) => r.id !== id); | |
| const newResponse: SavedResponse = { | |
| id, | |
| question: `Q${i}`, | |
| answer: `A${i}`, | |
| saved_at: new Date().toISOString(), | |
| source_conversation_id: convId, | |
| source_conversation_title: "Test", | |
| msg_index: msgIdx, | |
| messages_pair: [ | |
| { role: "user", content: "q" }, | |
| { role: "assistant", content: "a" }, | |
| ], | |
| history_pair: [ | |
| { role: "user", content: "q" }, | |
| { role: "assistant", content: "a" }, | |
| ], | |
| result: { | |
| citations: [], | |
| domains_searched: [], | |
| sections_retrieved: 0, | |
| timing: {}, | |
| token_usage: { total_tokens: 0 }, | |
| }, | |
| }; | |
| writeSavedResponses([newResponse, ...withoutDuplicate]); | |
| } | |
| } | |
| const stored = readSavedResponses(); | |
| for (const [convId, msgIdx] of uniquePairs) { | |
| const id = savedResponseId(convId, msgIdx); | |
| const matches = stored.filter((r) => r.id === id); | |
| expect(matches).toHaveLength(1); | |
| } | |
| } | |
| ) | |
| ); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // Property 4: Conversation title truncation | |
| // --------------------------------------------------------------------------- | |
| describe("Property 4: Conversation title truncation", () => { | |
| /** | |
| * **Validates: Requirements 6.4** | |
| * | |
| * For any user message string, the derived title is ≤ 80 characters, | |
| * and if the original exceeds 80 chars the title ends with '…' (U+2026). | |
| */ | |
| it("title length is always ≤ 80 characters (excluding ellipsis)", () => { | |
| fc.assert( | |
| fc.property(fc.string({ minLength: 0, maxLength: 200 }), (content) => { | |
| const messages: Message[] = [{ role: "user", content }]; | |
| const title = titleFromMessages(messages); | |
| if (content.length > 80) { | |
| // Title is the 80-char slice + ellipsis = 81 chars total | |
| expect(title.length).toBe(81); | |
| expect(title.endsWith("\u2026")).toBe(true); | |
| expect(title.slice(0, 80)).toBe(content.slice(0, 80)); | |
| } else { | |
| expect(title).toBe(content); | |
| expect(title.length).toBeLessThanOrEqual(80); | |
| } | |
| }) | |
| ); | |
| }); | |
| it("ellipsis is present iff original content exceeds 80 characters", () => { | |
| fc.assert( | |
| fc.property(fc.string({ minLength: 0, maxLength: 200 }), (content) => { | |
| const messages: Message[] = [{ role: "user", content }]; | |
| const title = titleFromMessages(messages); | |
| const hasEllipsis = title.endsWith("\u2026"); | |
| expect(hasEllipsis).toBe(content.length > 80); | |
| }) | |
| ); | |
| }); | |
| it("returns 'New conversation' when no user message is present", () => { | |
| fc.assert( | |
| fc.property( | |
| fc.array( | |
| fc.record({ | |
| role: fc.constant("assistant" as const), | |
| content: fc.string(), | |
| }), | |
| { maxLength: 5 } | |
| ), | |
| (messages) => { | |
| const title = titleFromMessages(messages); | |
| expect(title).toBe("New conversation"); | |
| } | |
| ) | |
| ); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // Property 5: Saved response sidebar truncation | |
| // --------------------------------------------------------------------------- | |
| describe("Property 5: Saved response sidebar truncation (titleFromMessages)", () => { | |
| /** | |
| * **Validates: Requirements 8.3** | |
| * | |
| * For any user message string exceeding 80 characters, titleFromMessages | |
| * truncates to exactly 80 chars + ellipsis (total 81 chars). | |
| * The sidebar display truncation to ±5 chars is tested in task 10.2. | |
| */ | |
| it("truncated title is exactly 81 chars (80 content + ellipsis) when original > 80", () => { | |
| fc.assert( | |
| fc.property( | |
| fc.string({ minLength: 81, maxLength: 300 }), | |
| (content) => { | |
| const messages: Message[] = [{ role: "user", content }]; | |
| const title = titleFromMessages(messages); | |
| // The truncated title is exactly 80 chars of content + 1 ellipsis char | |
| expect(title.length).toBe(81); | |
| expect(title.endsWith("\u2026")).toBe(true); | |
| } | |
| ) | |
| ); | |
| }); | |
| it("title displayed length is within [75, 85] when original exceeds 80 characters", () => { | |
| fc.assert( | |
| fc.property( | |
| fc.string({ minLength: 81, maxLength: 300 }), | |
| (content) => { | |
| const messages: Message[] = [{ role: "user", content }]; | |
| const title = titleFromMessages(messages); | |
| // The title (80 chars + ellipsis = 81 chars) is within the [75, 85] window | |
| expect(title.length).toBeGreaterThanOrEqual(75); | |
| expect(title.length).toBeLessThanOrEqual(85); | |
| } | |
| ) | |
| ); | |
| }); | |
| }); | |
| // --------------------------------------------------------------------------- | |
| // Property 8: Conversation restore fidelity | |
| // --------------------------------------------------------------------------- | |
| describe("Property 8: Conversation restore fidelity", () => { | |
| /** | |
| * **Validates: Requirements 6.5** | |
| * | |
| * For any Conversation stored in localStorage, restoring it produces a | |
| * chat state whose messages, history, and results fields are deeply equal | |
| * to the stored values. | |
| */ | |
| it("round-trip through localStorage preserves messages, history, and results", () => { | |
| fc.assert( | |
| fc.property(conversationArb, (conversation) => { | |
| mockStorage.clear(); | |
| writeConversations([conversation]); | |
| const restored = readConversations(); | |
| expect(restored).toHaveLength(1); | |
| const r = restored[0]; | |
| expect(r.messages).toEqual(conversation.messages); | |
| expect(r.history).toEqual(conversation.history); | |
| expect(r.results).toEqual(conversation.results); | |
| }) | |
| ); | |
| }); | |
| it("round-trip preserves all fields of a Conversation", () => { | |
| fc.assert( | |
| fc.property(conversationArb, (conversation) => { | |
| mockStorage.clear(); | |
| writeConversations([conversation]); | |
| const [restored] = readConversations(); | |
| expect(restored.id).toBe(conversation.id); | |
| expect(restored.title).toBe(conversation.title); | |
| expect(restored.created_at).toBe(conversation.created_at); | |
| expect(restored.last_active_at).toBe(conversation.last_active_at); | |
| expect(restored.messages).toEqual(conversation.messages); | |
| expect(restored.history).toEqual(conversation.history); | |
| expect(restored.results).toEqual(conversation.results); | |
| }) | |
| ); | |
| }); | |
| it("round-trip preserves multiple conversations", () => { | |
| fc.assert( | |
| fc.property( | |
| fc.array(conversationArb, { minLength: 1, maxLength: 15 }), | |
| (conversations) => { | |
| mockStorage.clear(); | |
| // Deduplicate by ID — keep the last occurrence per ID (most recent write wins) | |
| const byId = new Map<string, Conversation>(); | |
| for (const c of conversations) { | |
| byId.set(c.id, c); | |
| } | |
| const uniqueConversations = Array.from(byId.values()); | |
| writeConversations(uniqueConversations); | |
| const stored = readConversations(); | |
| // Every stored conversation must have its messages/history/results intact | |
| for (const storedConv of stored) { | |
| const original = uniqueConversations.find((c) => c.id === storedConv.id); | |
| expect(original).toBeDefined(); | |
| if (original) { | |
| expect(storedConv.messages).toEqual(original.messages); | |
| expect(storedConv.history).toEqual(original.history); | |
| expect(storedConv.results).toEqual(original.results); | |
| } | |
| } | |
| } | |
| ) | |
| ); | |
| }); | |
| }); | |