hmc-rag / frontend /src /lib /storage.test.ts
webmuppet
Refactor: replace Streamlit with React+Vite SPA + FastAPI server
b5c0df4
Raw
History Blame Contribute Delete
21.5 kB
/**
* 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);
}
}
}
)
);
});
});