/** * 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 = {}; 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 = fc.record({ role: fc.constantFrom("user" as const, "assistant" as const), content: fc.string({ minLength: 0, maxLength: 500 }), }); /** Arbitrary QueryResult */ const queryResultArb: fc.Arbitrary = 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 = 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 = {}; 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 => 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 = 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(); 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(); 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); } } } ) ); }); });