@vheins/local-memory-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DASHBOARD.md +129 -0
- package/HYBRID_SEARCH.md +204 -0
- package/IMPLEMENTATION.md +159 -0
- package/README.md +175 -0
- package/dist/capabilities.d.ts +22 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +23 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/dashboard/dashboard.test.d.ts +2 -0
- package/dist/dashboard/dashboard.test.d.ts.map +1 -0
- package/dist/dashboard/dashboard.test.js +362 -0
- package/dist/dashboard/dashboard.test.js.map +1 -0
- package/dist/dashboard/public/app.js +1187 -0
- package/dist/dashboard/public/chart.js +0 -0
- package/dist/dashboard/public/index.html +967 -0
- package/dist/dashboard/server.d.ts +3 -0
- package/dist/dashboard/server.d.ts.map +1 -0
- package/dist/dashboard/server.js +297 -0
- package/dist/dashboard/server.js.map +1 -0
- package/dist/mcp/client.d.ts +34 -0
- package/dist/mcp/client.d.ts.map +1 -0
- package/dist/mcp/client.js +181 -0
- package/dist/mcp/client.js.map +1 -0
- package/dist/mcp/client.test.d.ts +2 -0
- package/dist/mcp/client.test.d.ts.map +1 -0
- package/dist/mcp/client.test.js +130 -0
- package/dist/mcp/client.test.js.map +1 -0
- package/dist/prompts/registry.d.ts +39 -0
- package/dist/prompts/registry.d.ts.map +1 -0
- package/dist/prompts/registry.js +90 -0
- package/dist/prompts/registry.js.map +1 -0
- package/dist/resources/index.d.ts +17 -0
- package/dist/resources/index.d.ts.map +1 -0
- package/dist/resources/index.js +100 -0
- package/dist/resources/index.js.map +1 -0
- package/dist/resources/index.test.d.ts +2 -0
- package/dist/resources/index.test.d.ts.map +1 -0
- package/dist/resources/index.test.js +96 -0
- package/dist/resources/index.test.js.map +1 -0
- package/dist/router.d.ts +4 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.js +60 -0
- package/dist/router.js.map +1 -0
- package/dist/router.test.d.ts +2 -0
- package/dist/router.test.d.ts.map +1 -0
- package/dist/router.test.js +113 -0
- package/dist/router.test.js.map +1 -0
- package/dist/search_memory_example.d.ts +3 -0
- package/dist/search_memory_example.d.ts.map +1 -0
- package/dist/search_memory_example.js +56 -0
- package/dist/search_memory_example.js.map +1 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +91 -0
- package/dist/server.js.map +1 -0
- package/dist/storage/sqlite.d.ts +95 -0
- package/dist/storage/sqlite.d.ts.map +1 -0
- package/dist/storage/sqlite.js +537 -0
- package/dist/storage/sqlite.js.map +1 -0
- package/dist/storage/sqlite.test.d.ts +2 -0
- package/dist/storage/sqlite.test.d.ts.map +1 -0
- package/dist/storage/sqlite.test.js +358 -0
- package/dist/storage/sqlite.test.js.map +1 -0
- package/dist/storage/vectors.stub.d.ts +12 -0
- package/dist/storage/vectors.stub.d.ts.map +1 -0
- package/dist/storage/vectors.stub.js +88 -0
- package/dist/storage/vectors.stub.js.map +1 -0
- package/dist/store_memory_example.d.ts +3 -0
- package/dist/store_memory_example.d.ts.map +1 -0
- package/dist/store_memory_example.js +69 -0
- package/dist/store_memory_example.js.map +1 -0
- package/dist/test_quotes_client.d.ts +3 -0
- package/dist/test_quotes_client.d.ts.map +1 -0
- package/dist/test_quotes_client.js +72 -0
- package/dist/test_quotes_client.js.map +1 -0
- package/dist/tools/memory.delete.d.ts +9 -0
- package/dist/tools/memory.delete.d.ts.map +1 -0
- package/dist/tools/memory.delete.js +22 -0
- package/dist/tools/memory.delete.js.map +1 -0
- package/dist/tools/memory.recap.d.ts +4 -0
- package/dist/tools/memory.recap.d.ts.map +1 -0
- package/dist/tools/memory.recap.js +42 -0
- package/dist/tools/memory.recap.js.map +1 -0
- package/dist/tools/memory.search.d.ts +5 -0
- package/dist/tools/memory.search.d.ts.map +1 -0
- package/dist/tools/memory.search.js +192 -0
- package/dist/tools/memory.search.js.map +1 -0
- package/dist/tools/memory.search.test.d.ts +2 -0
- package/dist/tools/memory.search.test.d.ts.map +1 -0
- package/dist/tools/memory.search.test.js +181 -0
- package/dist/tools/memory.search.test.js.map +1 -0
- package/dist/tools/memory.store.d.ts +5 -0
- package/dist/tools/memory.store.d.ts.map +1 -0
- package/dist/tools/memory.store.js +41 -0
- package/dist/tools/memory.store.js.map +1 -0
- package/dist/tools/memory.summarize.d.ts +4 -0
- package/dist/tools/memory.summarize.d.ts.map +1 -0
- package/dist/tools/memory.summarize.js +13 -0
- package/dist/tools/memory.summarize.js.map +1 -0
- package/dist/tools/memory.update.d.ts +5 -0
- package/dist/tools/memory.update.d.ts.map +1 -0
- package/dist/tools/memory.update.js +31 -0
- package/dist/tools/memory.update.js.map +1 -0
- package/dist/tools/schemas.d.ts +334 -0
- package/dist/tools/schemas.d.ts.map +1 -0
- package/dist/tools/schemas.js +251 -0
- package/dist/tools/schemas.js.map +1 -0
- package/dist/types.d.ts +31 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/git-scope.d.ts +8 -0
- package/dist/utils/git-scope.d.ts.map +1 -0
- package/dist/utils/git-scope.js +38 -0
- package/dist/utils/git-scope.js.map +1 -0
- package/dist/utils/logger.d.ts +7 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +40 -0
- package/dist/utils/logger.js.map +1 -0
- package/dist/utils/logger.test.d.ts +2 -0
- package/dist/utils/logger.test.d.ts.map +1 -0
- package/dist/utils/logger.test.js +84 -0
- package/dist/utils/logger.test.js.map +1 -0
- package/dist/utils/mcp-response.d.ts +44 -0
- package/dist/utils/mcp-response.d.ts.map +1 -0
- package/dist/utils/mcp-response.js +81 -0
- package/dist/utils/mcp-response.js.map +1 -0
- package/dist/utils/normalize.d.ts +4 -0
- package/dist/utils/normalize.d.ts.map +1 -0
- package/dist/utils/normalize.js +51 -0
- package/dist/utils/normalize.js.map +1 -0
- package/dist/utils/normalize.test.d.ts +2 -0
- package/dist/utils/normalize.test.d.ts.map +1 -0
- package/dist/utils/normalize.test.js +159 -0
- package/dist/utils/normalize.test.js.map +1 -0
- package/dist/utils/query-expander.d.ts +2 -0
- package/dist/utils/query-expander.d.ts.map +1 -0
- package/dist/utils/query-expander.js +50 -0
- package/dist/utils/query-expander.js.map +1 -0
- package/dist/utils/query-expander.test.d.ts +2 -0
- package/dist/utils/query-expander.test.d.ts.map +1 -0
- package/dist/utils/query-expander.test.js +35 -0
- package/dist/utils/query-expander.test.js.map +1 -0
- package/docs/PRD.md +199 -0
- package/docs/PROMPT-agent.md +139 -0
- package/docs/SPEC-git-scope.md +172 -0
- package/docs/SPEC-heuristics.md +199 -0
- package/docs/SPEC-server.md +243 -0
- package/docs/SPEC-skeleton.md +255 -0
- package/docs/SPEC-sqlite-schema.md +183 -0
- package/docs/SPEC-tool-schema.md +201 -0
- package/docs/SPEC-vector-search.md +198 -0
- package/docs/TEST-scenarios.md +179 -0
- package/package.json +43 -0
- package/scripts/update-null-titles-ai.mjs +272 -0
- package/scripts/update-titles-batch.mjs +71 -0
- package/scripts/update-titles.mjs +66 -0
- package/seed-data.mjs +151 -0
- package/src/capabilities.ts +22 -0
- package/src/dashboard/dashboard.test.ts +546 -0
- package/src/dashboard/public/app.js +1187 -0
- package/src/dashboard/public/chart.js +0 -0
- package/src/dashboard/public/index.html +967 -0
- package/src/dashboard/server.ts +347 -0
- package/src/mcp/client.test.ts +164 -0
- package/src/mcp/client.ts +212 -0
- package/src/prompts/registry.ts +89 -0
- package/src/resources/index.test.ts +132 -0
- package/src/resources/index.ts +113 -0
- package/src/router.test.ts +145 -0
- package/src/router.ts +80 -0
- package/src/server.ts +99 -0
- package/src/storage/sqlite.test.ts +504 -0
- package/src/storage/sqlite.ts +688 -0
- package/src/storage/vectors.stub.ts +101 -0
- package/src/tools/memory.delete.ts +37 -0
- package/src/tools/memory.recap.ts +61 -0
- package/src/tools/memory.search.test.ts +276 -0
- package/src/tools/memory.search.ts +244 -0
- package/src/tools/memory.store.ts +56 -0
- package/src/tools/memory.summarize.ts +23 -0
- package/src/tools/memory.update.ts +46 -0
- package/src/tools/schemas.ts +261 -0
- package/src/types.ts +36 -0
- package/src/utils/git-scope.ts +42 -0
- package/src/utils/logger.test.ts +125 -0
- package/src/utils/logger.ts +53 -0
- package/src/utils/mcp-response.ts +116 -0
- package/src/utils/normalize.test.ts +203 -0
- package/src/utils/normalize.ts +53 -0
- package/src/utils/query-expander.test.ts +40 -0
- package/src/utils/query-expander.ts +60 -0
- package/storage/.gitkeep +5 -0
- package/test.sh +48 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
// Feature: Dashboard Filter & Export Property Tests
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
4
|
+
import * as fc from "fast-check";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { MemoryTypeSchema } from "../tools/schemas.js";
|
|
7
|
+
|
|
8
|
+
describe("Property 12: Dashboard filter logic correctness", () => {
|
|
9
|
+
// Mock memory data generator
|
|
10
|
+
function generateMemories(count: number): any[] {
|
|
11
|
+
const types = ["code_fact", "decision", "mistake", "pattern"] as const;
|
|
12
|
+
return Array.from({ length: count }, (_, i) => ({
|
|
13
|
+
id: `test-${i}`,
|
|
14
|
+
type: types[i % types.length],
|
|
15
|
+
content: `Test memory content ${i} with some keywords`,
|
|
16
|
+
importance: (i % 5) + 1,
|
|
17
|
+
hit_count: i * 2,
|
|
18
|
+
recall_rate: i > 0 ? i / (i + 1) : 0,
|
|
19
|
+
created_at: new Date(Date.now() - i * 86400000).toISOString(),
|
|
20
|
+
}));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Filter function (same as in dashboard)
|
|
24
|
+
function applyFilters(
|
|
25
|
+
memories: any[],
|
|
26
|
+
searchQuery: string,
|
|
27
|
+
typeFilter: string,
|
|
28
|
+
minImportance: number,
|
|
29
|
+
maxImportance: number
|
|
30
|
+
): any[] {
|
|
31
|
+
let filtered = [...memories];
|
|
32
|
+
|
|
33
|
+
if (searchQuery) {
|
|
34
|
+
const q = searchQuery.toLowerCase();
|
|
35
|
+
filtered = filtered.filter(
|
|
36
|
+
(m) =>
|
|
37
|
+
m.content.toLowerCase().includes(q) ||
|
|
38
|
+
m.type.toLowerCase().includes(q)
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (typeFilter) {
|
|
43
|
+
filtered = filtered.filter((m) => m.type === typeFilter);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (minImportance > 0) {
|
|
47
|
+
filtered = filtered.filter((m) => m.importance >= minImportance);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (maxImportance < 5) {
|
|
51
|
+
filtered = filtered.filter((m) => m.importance <= maxImportance);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return filtered;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
it("property: search filter returns subset containing search term", () => {
|
|
58
|
+
fc.assert(
|
|
59
|
+
fc.property(
|
|
60
|
+
fc.array(
|
|
61
|
+
fc.record({
|
|
62
|
+
id: fc.uuid(),
|
|
63
|
+
type: fc.constantFrom("code_fact", "decision", "mistake", "pattern"),
|
|
64
|
+
content: fc.string({ minLength: 10, maxLength: 100 }),
|
|
65
|
+
importance: fc.integer({ min: 1, max: 5 }),
|
|
66
|
+
hit_count: fc.integer({ min: 0, max: 100 }),
|
|
67
|
+
})
|
|
68
|
+
),
|
|
69
|
+
fc.string({ minLength: 1, maxLength: 20 }),
|
|
70
|
+
(memories, query) => {
|
|
71
|
+
if (memories.length === 0) return true;
|
|
72
|
+
|
|
73
|
+
const filtered = applyFilters(memories, query, "", 0, 5);
|
|
74
|
+
|
|
75
|
+
// All results should contain query (if any returned)
|
|
76
|
+
for (const m of filtered) {
|
|
77
|
+
const containsQuery =
|
|
78
|
+
m.content.toLowerCase().includes(query.toLowerCase()) ||
|
|
79
|
+
m.type.toLowerCase().includes(query.toLowerCase());
|
|
80
|
+
expect(containsQuery).toBe(true);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Filtered should not be larger than original
|
|
84
|
+
expect(filtered.length).toBeLessThanOrEqual(memories.length);
|
|
85
|
+
}
|
|
86
|
+
),
|
|
87
|
+
{ numRuns: 50 }
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("property: type filter returns only matching types", () => {
|
|
92
|
+
fc.assert(
|
|
93
|
+
fc.property(
|
|
94
|
+
fc.array(
|
|
95
|
+
fc.record({
|
|
96
|
+
id: fc.uuid(),
|
|
97
|
+
type: fc.constantFrom("code_fact", "decision", "mistake", "pattern"),
|
|
98
|
+
content: fc.string({ minLength: 10, maxLength: 100 }),
|
|
99
|
+
importance: fc.integer({ min: 1, max: 5 }),
|
|
100
|
+
})
|
|
101
|
+
),
|
|
102
|
+
fc.constantFrom("", "code_fact", "decision", "mistake", "pattern"),
|
|
103
|
+
(memories, typeFilter) => {
|
|
104
|
+
const filtered = applyFilters(memories, "", typeFilter, 0, 5);
|
|
105
|
+
|
|
106
|
+
if (typeFilter === "") {
|
|
107
|
+
// No filter should return all
|
|
108
|
+
expect(filtered.length).toBe(memories.length);
|
|
109
|
+
} else {
|
|
110
|
+
// All results should have matching type
|
|
111
|
+
for (const m of filtered) {
|
|
112
|
+
expect(m.type).toBe(typeFilter);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
),
|
|
117
|
+
{ numRuns: 50 }
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("property: importance range filter returns correct subset", () => {
|
|
122
|
+
fc.assert(
|
|
123
|
+
fc.property(
|
|
124
|
+
fc.array(
|
|
125
|
+
fc.record({
|
|
126
|
+
id: fc.uuid(),
|
|
127
|
+
type: fc.constantFrom("code_fact", "decision", "mistake", "pattern"),
|
|
128
|
+
content: fc.string({ minLength: 10 }),
|
|
129
|
+
importance: fc.integer({ min: 1, max: 5 }),
|
|
130
|
+
})
|
|
131
|
+
),
|
|
132
|
+
fc.integer({ min: 0, max: 5 }),
|
|
133
|
+
fc.integer({ min: 0, max: 5 }),
|
|
134
|
+
(memories, minImp, maxImp) => {
|
|
135
|
+
const safeMin = Math.min(minImp, maxImp);
|
|
136
|
+
const safeMax = Math.max(minImp, maxImp);
|
|
137
|
+
|
|
138
|
+
const filtered = applyFilters(memories, "", "", safeMin, safeMax);
|
|
139
|
+
|
|
140
|
+
for (const m of filtered) {
|
|
141
|
+
expect(m.importance).toBeGreaterThanOrEqual(safeMin);
|
|
142
|
+
expect(m.importance).toBeLessThanOrEqual(safeMax);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
),
|
|
146
|
+
{ numRuns: 50 }
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("property: combination filters are additive (AND logic)", () => {
|
|
151
|
+
fc.assert(
|
|
152
|
+
fc.property(
|
|
153
|
+
fc.array(
|
|
154
|
+
fc.record({
|
|
155
|
+
id: fc.uuid(),
|
|
156
|
+
type: fc.constantFrom("code_fact", "decision", "mistake", "pattern"),
|
|
157
|
+
content: fc.string({ minLength: 10 }),
|
|
158
|
+
importance: fc.integer({ min: 1, max: 5 }),
|
|
159
|
+
})
|
|
160
|
+
),
|
|
161
|
+
fc.string({ maxLength: 10 }),
|
|
162
|
+
fc.constantFrom("", "code_fact", "decision"),
|
|
163
|
+
fc.integer({ min: 1, max: 3 }),
|
|
164
|
+
fc.integer({ min: 3, max: 5 }),
|
|
165
|
+
(memories, search, type, minImp, maxImp) => {
|
|
166
|
+
const safeMin = Math.min(minImp, maxImp);
|
|
167
|
+
const safeMax = Math.max(minImp, maxImp);
|
|
168
|
+
|
|
169
|
+
const filtered = applyFilters(memories, search, type, safeMin, safeMax);
|
|
170
|
+
|
|
171
|
+
// Verify each result matches ALL criteria
|
|
172
|
+
for (const m of filtered) {
|
|
173
|
+
if (search) {
|
|
174
|
+
expect(
|
|
175
|
+
m.content.toLowerCase().includes(search.toLowerCase()) ||
|
|
176
|
+
m.type.toLowerCase().includes(search.toLowerCase())
|
|
177
|
+
).toBe(true);
|
|
178
|
+
}
|
|
179
|
+
if (type) {
|
|
180
|
+
expect(m.type).toBe(type);
|
|
181
|
+
}
|
|
182
|
+
expect(m.importance).toBeGreaterThanOrEqual(safeMin);
|
|
183
|
+
expect(m.importance).toBeLessThanOrEqual(safeMax);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
),
|
|
187
|
+
{ numRuns: 30 }
|
|
188
|
+
);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("Property 13: Pagination non-overlapping", () => {
|
|
193
|
+
function paginate(items: any[], page: number, pageSize: number): any[] {
|
|
194
|
+
const start = (page - 1) * pageSize;
|
|
195
|
+
return items.slice(start, start + pageSize);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
it("property: different pages have no overlapping IDs", () => {
|
|
199
|
+
fc.assert(
|
|
200
|
+
fc.property(
|
|
201
|
+
fc.array(fc.uuid(), { minLength: 10, maxLength: 100 }),
|
|
202
|
+
fc.integer({ min: 1, max: 10 }),
|
|
203
|
+
(ids, pageSize) => {
|
|
204
|
+
if (pageSize < 1) return true;
|
|
205
|
+
|
|
206
|
+
const page1 = paginate(ids, 1, pageSize);
|
|
207
|
+
const page2 = paginate(ids, 2, pageSize);
|
|
208
|
+
|
|
209
|
+
// No common IDs between pages
|
|
210
|
+
const overlap = page1.filter((id) => page2.includes(id));
|
|
211
|
+
expect(overlap.length).toBe(0);
|
|
212
|
+
}
|
|
213
|
+
),
|
|
214
|
+
{ numRuns: 50 }
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("property: all items appear in exactly one page", () => {
|
|
219
|
+
fc.assert(
|
|
220
|
+
fc.property(
|
|
221
|
+
fc.array(fc.integer(), { minLength: 1, maxLength: 50 }),
|
|
222
|
+
fc.integer({ min: 1, max: 10 }),
|
|
223
|
+
(items, pageSize) => {
|
|
224
|
+
if (pageSize < 1) return true;
|
|
225
|
+
if (items.length === 0) return true;
|
|
226
|
+
|
|
227
|
+
const totalPages = Math.ceil(items.length / pageSize);
|
|
228
|
+
const allPages: number[] = [];
|
|
229
|
+
|
|
230
|
+
for (let page = 1; page <= totalPages; page++) {
|
|
231
|
+
const pageItems = paginate(items, page, pageSize);
|
|
232
|
+
allPages.push(...pageItems);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Should have same total count
|
|
236
|
+
expect(allPages.length).toBe(items.length);
|
|
237
|
+
}
|
|
238
|
+
),
|
|
239
|
+
{ numRuns: 30 }
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("Property 14: Export format correctness", () => {
|
|
245
|
+
interface Memory {
|
|
246
|
+
id: string;
|
|
247
|
+
type: string;
|
|
248
|
+
content: string;
|
|
249
|
+
importance: number;
|
|
250
|
+
hit_count: number;
|
|
251
|
+
created_at: string;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function exportToCsv(memories: Memory[]): string {
|
|
255
|
+
const headers = ["id", "type", "content", "importance", "hit_count", "created_at"];
|
|
256
|
+
const csvRows = [headers.join(",")];
|
|
257
|
+
for (const m of memories) {
|
|
258
|
+
const row = [
|
|
259
|
+
m.id,
|
|
260
|
+
m.type,
|
|
261
|
+
`"${m.content.replace(/"/g, '""')}"`,
|
|
262
|
+
m.importance,
|
|
263
|
+
m.hit_count,
|
|
264
|
+
m.created_at,
|
|
265
|
+
];
|
|
266
|
+
csvRows.push(row.join(","));
|
|
267
|
+
}
|
|
268
|
+
return csvRows.join("\n");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function exportToJson(memories: Memory[]): string {
|
|
272
|
+
return JSON.stringify(memories, null, 2);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
it("property: CSV export contains all filtered data", () => {
|
|
276
|
+
fc.assert(
|
|
277
|
+
fc.property(
|
|
278
|
+
fc.array(
|
|
279
|
+
fc.record({
|
|
280
|
+
id: fc.uuid(),
|
|
281
|
+
type: fc.constantFrom("code_fact", "decision", "mistake", "pattern"),
|
|
282
|
+
content: fc.string({ minLength: 5, maxLength: 50 }),
|
|
283
|
+
importance: fc.integer({ min: 1, max: 5 }),
|
|
284
|
+
hit_count: fc.integer({ min: 0, max: 20 }),
|
|
285
|
+
created_at: fc.date().map((d) => d.toISOString()),
|
|
286
|
+
})
|
|
287
|
+
),
|
|
288
|
+
(memories) => {
|
|
289
|
+
if (memories.length === 0) return;
|
|
290
|
+
|
|
291
|
+
const csv = exportToCsv(memories);
|
|
292
|
+
const lines = csv.split("\n");
|
|
293
|
+
|
|
294
|
+
// Header + data rows
|
|
295
|
+
expect(lines.length).toBe(memories.length + 1);
|
|
296
|
+
|
|
297
|
+
// Each row has 6 columns
|
|
298
|
+
for (let i = 1; i < lines.length; i++) {
|
|
299
|
+
const cols = lines[i].split(",");
|
|
300
|
+
expect(cols.length).toBeGreaterThanOrEqual(5);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
),
|
|
304
|
+
{ numRuns: 30, endOnFailure: true }
|
|
305
|
+
);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("property: JSON export is valid parseable JSON", () => {
|
|
309
|
+
fc.assert(
|
|
310
|
+
fc.property(
|
|
311
|
+
fc.array(
|
|
312
|
+
fc.record({
|
|
313
|
+
id: fc.uuid(),
|
|
314
|
+
type: fc.constantFrom("code_fact", "decision", "mistake", "pattern"),
|
|
315
|
+
content: fc.string({ minLength: 5 }),
|
|
316
|
+
importance: fc.integer({ min: 1, max: 5 }),
|
|
317
|
+
hit_count: fc.integer({ min: 0, max: 10 }),
|
|
318
|
+
created_at: fc.string(),
|
|
319
|
+
})
|
|
320
|
+
),
|
|
321
|
+
(memories) => {
|
|
322
|
+
const json = exportToJson(memories as Memory[]);
|
|
323
|
+
const parsed = JSON.parse(json);
|
|
324
|
+
|
|
325
|
+
expect(Array.isArray(parsed)).toBe(true);
|
|
326
|
+
expect(parsed.length).toBe(memories.length);
|
|
327
|
+
}
|
|
328
|
+
),
|
|
329
|
+
{ numRuns: 30 }
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("property: export respects filter (matches displayed data)", () => {
|
|
334
|
+
fc.assert(
|
|
335
|
+
fc.property(
|
|
336
|
+
fc.array(
|
|
337
|
+
fc.record({
|
|
338
|
+
id: fc.uuid(),
|
|
339
|
+
type: fc.constantFrom("decision", "mistake", "code_fact", "pattern"),
|
|
340
|
+
content: fc.string({ minLength: 10 }),
|
|
341
|
+
importance: fc.integer({ min: 1, max: 5 }),
|
|
342
|
+
hit_count: fc.integer({ min: 0, max: 100 }),
|
|
343
|
+
created_at: fc.date({ min: new Date("2020-01-01"), max: new Date("2030-12-31") }).map((d) => d.toISOString()),
|
|
344
|
+
})
|
|
345
|
+
),
|
|
346
|
+
fc.constantFrom("decision", "mistake", "code_fact", "pattern"),
|
|
347
|
+
(memories, typeFilter) => {
|
|
348
|
+
// Apply filter (same as dashboard)
|
|
349
|
+
const filtered = memories.filter((m) => m.type === typeFilter);
|
|
350
|
+
|
|
351
|
+
const csv = exportToCsv(filtered);
|
|
352
|
+
const json = exportToJson(filtered);
|
|
353
|
+
|
|
354
|
+
// CSV should have filtered count + 1 header
|
|
355
|
+
expect(csv.split("\n").length).toBe(filtered.length + 1);
|
|
356
|
+
|
|
357
|
+
// JSON should parse to filtered array
|
|
358
|
+
const parsed = JSON.parse(json);
|
|
359
|
+
expect(parsed.length).toBe(filtered.length);
|
|
360
|
+
}
|
|
361
|
+
),
|
|
362
|
+
{ numRuns: 30 }
|
|
363
|
+
);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
describe("Property 15: localStorage preferences round-trip", () => {
|
|
368
|
+
// Mock localStorage for Node.js environment
|
|
369
|
+
let mockStorage: Record<string, string> = {};
|
|
370
|
+
|
|
371
|
+
beforeEach(() => {
|
|
372
|
+
mockStorage = {};
|
|
373
|
+
global.localStorage = {
|
|
374
|
+
getItem: (key: string) => mockStorage[key] || null,
|
|
375
|
+
setItem: (key: string, value: string) => { mockStorage[key] = value; },
|
|
376
|
+
removeItem: (key: string) => { delete mockStorage[key]; },
|
|
377
|
+
clear: () => { mockStorage = {}; },
|
|
378
|
+
get length() { return Object.keys(mockStorage).length; },
|
|
379
|
+
key: (i: number) => Object.keys(mockStorage)[i] || null,
|
|
380
|
+
} as unknown as Storage;
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
afterEach(() => {
|
|
384
|
+
delete (global as unknown as Record<string, unknown>).localStorage;
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
interface DashboardPrefs {
|
|
388
|
+
theme: "light" | "dark";
|
|
389
|
+
pageSize: number;
|
|
390
|
+
columnVisibility: Record<string, boolean>;
|
|
391
|
+
columnWidths: Record<string, number>;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function savePrefs(prefs: DashboardPrefs): void {
|
|
395
|
+
localStorage.setItem("dashboard_prefs", JSON.stringify(prefs));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function loadPrefs(): DashboardPrefs | null {
|
|
399
|
+
const stored = localStorage.getItem("dashboard_prefs");
|
|
400
|
+
return stored ? JSON.parse(stored) : null;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
it("property: saved preferences can be loaded identically", () => {
|
|
404
|
+
fc.assert(
|
|
405
|
+
fc.property(
|
|
406
|
+
fc.record({
|
|
407
|
+
theme: fc.constantFrom("light", "dark"),
|
|
408
|
+
pageSize: fc.constantFrom(10, 25, 50, 100),
|
|
409
|
+
columnVisibility: fc.record({ id: fc.boolean(), type: fc.boolean(), content: fc.boolean() }),
|
|
410
|
+
columnWidths: fc.record({ id: fc.integer({ min: 50, max: 300 }), type: fc.integer({ min: 50, max: 300 }) }),
|
|
411
|
+
}),
|
|
412
|
+
(prefs) => {
|
|
413
|
+
savePrefs(prefs as unknown as DashboardPrefs);
|
|
414
|
+
const loaded = loadPrefs();
|
|
415
|
+
|
|
416
|
+
expect(loaded).not.toBeNull();
|
|
417
|
+
expect(loaded?.theme).toBe(prefs.theme);
|
|
418
|
+
expect(loaded?.pageSize).toBe(prefs.pageSize);
|
|
419
|
+
}
|
|
420
|
+
),
|
|
421
|
+
{ numRuns: 30 }
|
|
422
|
+
);
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
describe("Property 16: Recent Queries feature", () => {
|
|
427
|
+
let mockStorage: Record<string, string> = {};
|
|
428
|
+
|
|
429
|
+
beforeEach(() => {
|
|
430
|
+
mockStorage = {};
|
|
431
|
+
global.localStorage = {
|
|
432
|
+
getItem: (key: string) => mockStorage[key] || null,
|
|
433
|
+
setItem: (key: string, value: string) => { mockStorage[key] = value; },
|
|
434
|
+
removeItem: (key: string) => { delete mockStorage[key]; },
|
|
435
|
+
clear: () => { mockStorage = {}; },
|
|
436
|
+
get length() { return Object.keys(mockStorage).length; },
|
|
437
|
+
key: (i: number) => Object.keys(mockStorage)[i] || null,
|
|
438
|
+
} as unknown as Storage;
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
afterEach(() => {
|
|
442
|
+
delete (global as unknown as Record<string, unknown>).localStorage;
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// Simulate recent queries stored in localStorage (fallback)
|
|
446
|
+
function getRecentQueriesFromStorage(): string[] {
|
|
447
|
+
const stored = localStorage.getItem("recentQueries");
|
|
448
|
+
return stored ? JSON.parse(stored) : [];
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function addRecentQueryToStorage(query: string): void {
|
|
452
|
+
if (!query.trim()) return;
|
|
453
|
+
const queries = getRecentQueriesFromStorage();
|
|
454
|
+
const filtered = queries.filter(q => q !== query);
|
|
455
|
+
filtered.unshift(query);
|
|
456
|
+
const limited = filtered.slice(0, 20);
|
|
457
|
+
localStorage.setItem("recentQueries", JSON.stringify(limited));
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
it("property: adding query moves it to front and removes duplicates", () => {
|
|
461
|
+
addRecentQueryToStorage("database");
|
|
462
|
+
addRecentQueryToStorage("authentication");
|
|
463
|
+
addRecentQueryToStorage("database"); // duplicate - should move to front
|
|
464
|
+
|
|
465
|
+
const queries = getRecentQueriesFromStorage();
|
|
466
|
+
|
|
467
|
+
expect(queries[0]).toBe("database");
|
|
468
|
+
expect(queries[1]).toBe("authentication");
|
|
469
|
+
expect(queries.length).toBe(2);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("property: recent queries are limited to 20", () => {
|
|
473
|
+
for (let i = 0; i < 25; i++) {
|
|
474
|
+
addRecentQueryToStorage(`query-${i}`);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const queries = getRecentQueriesFromStorage();
|
|
478
|
+
|
|
479
|
+
expect(queries.length).toBe(20);
|
|
480
|
+
expect(queries[0]).toBe("query-24");
|
|
481
|
+
expect(queries[19]).toBe("query-5");
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("property: empty query is not added", () => {
|
|
485
|
+
addRecentQueryToStorage("valid-query");
|
|
486
|
+
addRecentQueryToStorage("");
|
|
487
|
+
addRecentQueryToStorage(" ");
|
|
488
|
+
|
|
489
|
+
const queries = getRecentQueriesFromStorage();
|
|
490
|
+
|
|
491
|
+
expect(queries.length).toBe(1);
|
|
492
|
+
expect(queries[0]).toBe("valid-query");
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
it("property: queries maintain insertion order with unique constraint", () => {
|
|
496
|
+
addRecentQueryToStorage("first");
|
|
497
|
+
addRecentQueryToStorage("second");
|
|
498
|
+
addRecentQueryToStorage("third");
|
|
499
|
+
addRecentQueryToStorage("first"); // move to front
|
|
500
|
+
addRecentQueryToStorage("second"); // move to front
|
|
501
|
+
|
|
502
|
+
const queries = getRecentQueriesFromStorage();
|
|
503
|
+
|
|
504
|
+
expect(queries).toEqual(["second", "first", "third"]);
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
describe("Property 17: Recent Queries API response format", () => {
|
|
509
|
+
// Simulate API response parsing
|
|
510
|
+
interface RecentQueriesResponse {
|
|
511
|
+
queries: string[];
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function parseRecentQueriesResponse(json: string): RecentQueriesResponse {
|
|
515
|
+
const parsed = JSON.parse(json);
|
|
516
|
+
if (!Array.isArray(parsed.queries)) {
|
|
517
|
+
throw new Error("Invalid response: queries must be an array");
|
|
518
|
+
}
|
|
519
|
+
return parsed;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
it("property: valid API response parses correctly", () => {
|
|
523
|
+
const apiResponse = JSON.stringify({
|
|
524
|
+
queries: ["database", "authentication", "user management"]
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
const result = parseRecentQueriesResponse(apiResponse);
|
|
528
|
+
|
|
529
|
+
expect(result.queries).toHaveLength(3);
|
|
530
|
+
expect(result.queries[0]).toBe("database");
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
it("property: empty queries array is valid", () => {
|
|
534
|
+
const apiResponse = JSON.stringify({ queries: [] });
|
|
535
|
+
|
|
536
|
+
const result = parseRecentQueriesResponse(apiResponse);
|
|
537
|
+
|
|
538
|
+
expect(result.queries).toHaveLength(0);
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("property: malformed response throws error", () => {
|
|
542
|
+
expect(() => parseRecentQueriesResponse("not json")).toThrow();
|
|
543
|
+
expect(() => parseRecentQueriesResponse("{}")).toThrow();
|
|
544
|
+
expect(() => parseRecentQueriesResponse('{"results": []}')).toThrow();
|
|
545
|
+
});
|
|
546
|
+
});
|