@ai2070/memex 0.9.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/.github/workflows/ci.yml +31 -0
- package/.github/workflows/release.yml +35 -0
- package/API.md +1078 -0
- package/LICENSE +190 -0
- package/README.md +574 -0
- package/package.json +30 -0
- package/src/bulk.ts +128 -0
- package/src/envelope.ts +52 -0
- package/src/errors.ts +27 -0
- package/src/graph.ts +15 -0
- package/src/helpers.ts +51 -0
- package/src/index.ts +142 -0
- package/src/integrity.ts +378 -0
- package/src/intent.ts +311 -0
- package/src/query.ts +357 -0
- package/src/reducer.ts +177 -0
- package/src/replay.ts +32 -0
- package/src/retrieval.ts +306 -0
- package/src/serialization.ts +34 -0
- package/src/stats.ts +62 -0
- package/src/task.ts +373 -0
- package/src/transplant.ts +488 -0
- package/src/types.ts +248 -0
- package/tests/bugfix-and-coverage.test.ts +958 -0
- package/tests/bugfix-holes.test.ts +856 -0
- package/tests/bulk.test.ts +256 -0
- package/tests/edge-cases-v2.test.ts +355 -0
- package/tests/edge-cases.test.ts +661 -0
- package/tests/envelope.test.ts +92 -0
- package/tests/graph.test.ts +41 -0
- package/tests/helpers.test.ts +120 -0
- package/tests/integrity.test.ts +371 -0
- package/tests/intent.test.ts +276 -0
- package/tests/query-advanced.test.ts +252 -0
- package/tests/query.test.ts +623 -0
- package/tests/reducer.test.ts +342 -0
- package/tests/replay.test.ts +145 -0
- package/tests/retrieval.test.ts +691 -0
- package/tests/serialization.test.ts +118 -0
- package/tests/setup.test.ts +7 -0
- package/tests/stats.test.ts +163 -0
- package/tests/task.test.ts +322 -0
- package/tests/transplant.test.ts +385 -0
- package/tests/types.test.ts +231 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createIntentState,
|
|
4
|
+
createIntent,
|
|
5
|
+
applyIntentCommand,
|
|
6
|
+
getIntents,
|
|
7
|
+
getIntentById,
|
|
8
|
+
IntentNotFoundError,
|
|
9
|
+
DuplicateIntentError,
|
|
10
|
+
InvalidIntentTransitionError,
|
|
11
|
+
} from "../src/intent.js";
|
|
12
|
+
import type { Intent, IntentState } from "../src/intent.js";
|
|
13
|
+
|
|
14
|
+
const makeIntent = (overrides: Partial<Intent> = {}): Intent => ({
|
|
15
|
+
id: "i1",
|
|
16
|
+
label: "find_kati",
|
|
17
|
+
priority: 0.8,
|
|
18
|
+
owner: "user:laz",
|
|
19
|
+
status: "active",
|
|
20
|
+
...overrides,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("intent.create", () => {
|
|
24
|
+
it("creates an intent", () => {
|
|
25
|
+
const intent = makeIntent();
|
|
26
|
+
const { state, events } = applyIntentCommand(createIntentState(), {
|
|
27
|
+
type: "intent.create",
|
|
28
|
+
intent,
|
|
29
|
+
});
|
|
30
|
+
expect(state.intents.get("i1")).toEqual(intent);
|
|
31
|
+
expect(events).toHaveLength(1);
|
|
32
|
+
expect(events[0].type).toBe("intent.created");
|
|
33
|
+
expect(events[0].namespace).toBe("intent");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("throws DuplicateIntentError", () => {
|
|
37
|
+
const state = createIntentState();
|
|
38
|
+
const { state: next } = applyIntentCommand(state, {
|
|
39
|
+
type: "intent.create",
|
|
40
|
+
intent: makeIntent(),
|
|
41
|
+
});
|
|
42
|
+
expect(() =>
|
|
43
|
+
applyIntentCommand(next, { type: "intent.create", intent: makeIntent() }),
|
|
44
|
+
).toThrow(DuplicateIntentError);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("does not mutate original state", () => {
|
|
48
|
+
const state = createIntentState();
|
|
49
|
+
applyIntentCommand(state, { type: "intent.create", intent: makeIntent() });
|
|
50
|
+
expect(state.intents.size).toBe(0);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("intent.update", () => {
|
|
55
|
+
it("updates priority", () => {
|
|
56
|
+
let state = createIntentState();
|
|
57
|
+
state = applyIntentCommand(state, {
|
|
58
|
+
type: "intent.create",
|
|
59
|
+
intent: makeIntent(),
|
|
60
|
+
}).state;
|
|
61
|
+
const { state: next, events } = applyIntentCommand(state, {
|
|
62
|
+
type: "intent.update",
|
|
63
|
+
intent_id: "i1",
|
|
64
|
+
partial: { priority: 0.5 },
|
|
65
|
+
author: "user:laz",
|
|
66
|
+
});
|
|
67
|
+
expect(next.intents.get("i1")!.priority).toBe(0.5);
|
|
68
|
+
expect(events[0].type).toBe("intent.updated");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("cannot change id via partial", () => {
|
|
72
|
+
let state = createIntentState();
|
|
73
|
+
state = applyIntentCommand(state, {
|
|
74
|
+
type: "intent.create",
|
|
75
|
+
intent: makeIntent(),
|
|
76
|
+
}).state;
|
|
77
|
+
const { state: next } = applyIntentCommand(state, {
|
|
78
|
+
type: "intent.update",
|
|
79
|
+
intent_id: "i1",
|
|
80
|
+
partial: { id: "sneaky" } as any,
|
|
81
|
+
author: "test",
|
|
82
|
+
});
|
|
83
|
+
expect(next.intents.get("i1")!.id).toBe("i1");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("throws IntentNotFoundError", () => {
|
|
87
|
+
expect(() =>
|
|
88
|
+
applyIntentCommand(createIntentState(), {
|
|
89
|
+
type: "intent.update",
|
|
90
|
+
intent_id: "nope",
|
|
91
|
+
partial: { priority: 0.1 },
|
|
92
|
+
author: "test",
|
|
93
|
+
}),
|
|
94
|
+
).toThrow(IntentNotFoundError);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("status transitions", () => {
|
|
99
|
+
let state: IntentState;
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
state = applyIntentCommand(createIntentState(), {
|
|
102
|
+
type: "intent.create",
|
|
103
|
+
intent: makeIntent({ status: "active" }),
|
|
104
|
+
}).state;
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("active -> paused", () => {
|
|
108
|
+
const { state: next, events } = applyIntentCommand(state, {
|
|
109
|
+
type: "intent.pause",
|
|
110
|
+
intent_id: "i1",
|
|
111
|
+
author: "user:laz",
|
|
112
|
+
});
|
|
113
|
+
expect(next.intents.get("i1")!.status).toBe("paused");
|
|
114
|
+
expect(events[0].type).toBe("intent.paused");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("paused -> active (resume)", () => {
|
|
118
|
+
state = applyIntentCommand(state, {
|
|
119
|
+
type: "intent.pause",
|
|
120
|
+
intent_id: "i1",
|
|
121
|
+
author: "test",
|
|
122
|
+
}).state;
|
|
123
|
+
const { state: next } = applyIntentCommand(state, {
|
|
124
|
+
type: "intent.resume",
|
|
125
|
+
intent_id: "i1",
|
|
126
|
+
author: "test",
|
|
127
|
+
});
|
|
128
|
+
expect(next.intents.get("i1")!.status).toBe("active");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("active -> completed", () => {
|
|
132
|
+
const { state: next, events } = applyIntentCommand(state, {
|
|
133
|
+
type: "intent.complete",
|
|
134
|
+
intent_id: "i1",
|
|
135
|
+
author: "test",
|
|
136
|
+
});
|
|
137
|
+
expect(next.intents.get("i1")!.status).toBe("completed");
|
|
138
|
+
expect(events[0].type).toBe("intent.completed");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("active -> cancelled", () => {
|
|
142
|
+
const { state: next } = applyIntentCommand(state, {
|
|
143
|
+
type: "intent.cancel",
|
|
144
|
+
intent_id: "i1",
|
|
145
|
+
author: "test",
|
|
146
|
+
});
|
|
147
|
+
expect(next.intents.get("i1")!.status).toBe("cancelled");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("paused -> completed", () => {
|
|
151
|
+
state = applyIntentCommand(state, {
|
|
152
|
+
type: "intent.pause",
|
|
153
|
+
intent_id: "i1",
|
|
154
|
+
author: "test",
|
|
155
|
+
}).state;
|
|
156
|
+
const { state: next } = applyIntentCommand(state, {
|
|
157
|
+
type: "intent.complete",
|
|
158
|
+
intent_id: "i1",
|
|
159
|
+
author: "test",
|
|
160
|
+
});
|
|
161
|
+
expect(next.intents.get("i1")!.status).toBe("completed");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("completed -> pause throws InvalidIntentTransitionError", () => {
|
|
165
|
+
state = applyIntentCommand(state, {
|
|
166
|
+
type: "intent.complete",
|
|
167
|
+
intent_id: "i1",
|
|
168
|
+
author: "test",
|
|
169
|
+
}).state;
|
|
170
|
+
expect(() =>
|
|
171
|
+
applyIntentCommand(state, {
|
|
172
|
+
type: "intent.pause",
|
|
173
|
+
intent_id: "i1",
|
|
174
|
+
author: "test",
|
|
175
|
+
}),
|
|
176
|
+
).toThrow(InvalidIntentTransitionError);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("cancelled -> resume throws InvalidIntentTransitionError", () => {
|
|
180
|
+
state = applyIntentCommand(state, {
|
|
181
|
+
type: "intent.cancel",
|
|
182
|
+
intent_id: "i1",
|
|
183
|
+
author: "test",
|
|
184
|
+
}).state;
|
|
185
|
+
expect(() =>
|
|
186
|
+
applyIntentCommand(state, {
|
|
187
|
+
type: "intent.resume",
|
|
188
|
+
intent_id: "i1",
|
|
189
|
+
author: "test",
|
|
190
|
+
}),
|
|
191
|
+
).toThrow(InvalidIntentTransitionError);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("createIntent factory", () => {
|
|
196
|
+
it("generates id and defaults status to active", () => {
|
|
197
|
+
const intent = createIntent({
|
|
198
|
+
label: "test",
|
|
199
|
+
priority: 0.5,
|
|
200
|
+
owner: "user:laz",
|
|
201
|
+
});
|
|
202
|
+
expect(intent.id).toBeDefined();
|
|
203
|
+
expect(intent.status).toBe("active");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("getIntents", () => {
|
|
208
|
+
let state: IntentState;
|
|
209
|
+
beforeEach(() => {
|
|
210
|
+
state = createIntentState();
|
|
211
|
+
state = applyIntentCommand(state, {
|
|
212
|
+
type: "intent.create",
|
|
213
|
+
intent: makeIntent({
|
|
214
|
+
id: "i1",
|
|
215
|
+
owner: "user:laz",
|
|
216
|
+
status: "active",
|
|
217
|
+
priority: 0.9,
|
|
218
|
+
root_memory_ids: ["m1"],
|
|
219
|
+
}),
|
|
220
|
+
}).state;
|
|
221
|
+
state = applyIntentCommand(state, {
|
|
222
|
+
type: "intent.create",
|
|
223
|
+
intent: makeIntent({
|
|
224
|
+
id: "i2",
|
|
225
|
+
owner: "agent:reasoner",
|
|
226
|
+
status: "paused",
|
|
227
|
+
priority: 0.3,
|
|
228
|
+
}),
|
|
229
|
+
}).state;
|
|
230
|
+
state = applyIntentCommand(state, {
|
|
231
|
+
type: "intent.create",
|
|
232
|
+
intent: makeIntent({
|
|
233
|
+
id: "i3",
|
|
234
|
+
owner: "user:laz",
|
|
235
|
+
status: "completed",
|
|
236
|
+
priority: 0.7,
|
|
237
|
+
}),
|
|
238
|
+
}).state;
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("returns all intents with no filter", () => {
|
|
242
|
+
expect(getIntents(state)).toHaveLength(3);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it("filters by owner", () => {
|
|
246
|
+
const result = getIntents(state, { owner: "user:laz" });
|
|
247
|
+
expect(result).toHaveLength(2);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("filters by status", () => {
|
|
251
|
+
const result = getIntents(state, { status: "active" });
|
|
252
|
+
expect(result).toHaveLength(1);
|
|
253
|
+
expect(result[0].id).toBe("i1");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("filters by statuses array", () => {
|
|
257
|
+
const result = getIntents(state, { statuses: ["active", "paused"] });
|
|
258
|
+
expect(result).toHaveLength(2);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("filters by min_priority", () => {
|
|
262
|
+
const result = getIntents(state, { min_priority: 0.5 });
|
|
263
|
+
expect(result).toHaveLength(2);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("filters by has_memory_id", () => {
|
|
267
|
+
const result = getIntents(state, { has_memory_id: "m1" });
|
|
268
|
+
expect(result).toHaveLength(1);
|
|
269
|
+
expect(result[0].id).toBe("i1");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("getIntentById works", () => {
|
|
273
|
+
expect(getIntentById(state, "i2")?.owner).toBe("agent:reasoner");
|
|
274
|
+
expect(getIntentById(state, "nope")).toBeUndefined();
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { getItems } from "../src/query.js";
|
|
3
|
+
import { createGraphState } from "../src/graph.js";
|
|
4
|
+
import type { MemoryItem, GraphState } from "../src/types.js";
|
|
5
|
+
|
|
6
|
+
const makeItem = (
|
|
7
|
+
id: string,
|
|
8
|
+
overrides: Partial<MemoryItem> = {},
|
|
9
|
+
): MemoryItem => ({
|
|
10
|
+
id,
|
|
11
|
+
scope: "project:cyberdeck",
|
|
12
|
+
kind: "observation",
|
|
13
|
+
content: {},
|
|
14
|
+
author: "agent:a",
|
|
15
|
+
source_kind: "observed",
|
|
16
|
+
authority: 0.5,
|
|
17
|
+
...overrides,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
function stateWith(items: MemoryItem[]): GraphState {
|
|
21
|
+
const s = createGraphState();
|
|
22
|
+
for (const i of items) s.items.set(i.id, i);
|
|
23
|
+
return s;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ============================================================
|
|
27
|
+
// ids filter
|
|
28
|
+
// ============================================================
|
|
29
|
+
|
|
30
|
+
describe("ids filter", () => {
|
|
31
|
+
const state = stateWith([
|
|
32
|
+
makeItem("m1"),
|
|
33
|
+
makeItem("m2"),
|
|
34
|
+
makeItem("m3"),
|
|
35
|
+
makeItem("m4"),
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
it("returns only items with matching ids", () => {
|
|
39
|
+
const result = getItems(state, { ids: ["m1", "m3"] });
|
|
40
|
+
expect(result).toHaveLength(2);
|
|
41
|
+
expect(result.map((i) => i.id).sort()).toEqual(["m1", "m3"]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("ignores ids that don't exist", () => {
|
|
45
|
+
const result = getItems(state, { ids: ["m1", "nonexistent"] });
|
|
46
|
+
expect(result).toHaveLength(1);
|
|
47
|
+
expect(result[0].id).toBe("m1");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("empty ids array returns nothing", () => {
|
|
51
|
+
const result = getItems(state, { ids: [] });
|
|
52
|
+
expect(result).toHaveLength(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("combines ids with other filters", () => {
|
|
56
|
+
const state2 = stateWith([
|
|
57
|
+
makeItem("m1", { authority: 0.9 }),
|
|
58
|
+
makeItem("m2", { authority: 0.3 }),
|
|
59
|
+
makeItem("m3", { authority: 0.8 }),
|
|
60
|
+
]);
|
|
61
|
+
const result = getItems(state2, {
|
|
62
|
+
ids: ["m1", "m2", "m3"],
|
|
63
|
+
range: { authority: { min: 0.5 } },
|
|
64
|
+
});
|
|
65
|
+
expect(result).toHaveLength(2);
|
|
66
|
+
expect(result.map((i) => i.id).sort()).toEqual(["m1", "m3"]);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ============================================================
|
|
71
|
+
// scope_prefix filter
|
|
72
|
+
// ============================================================
|
|
73
|
+
|
|
74
|
+
describe("scope_prefix filter", () => {
|
|
75
|
+
const state = stateWith([
|
|
76
|
+
makeItem("m1", { scope: "project:cyberdeck" }),
|
|
77
|
+
makeItem("m2", { scope: "project:memex" }),
|
|
78
|
+
makeItem("m3", { scope: "user:laz/general" }),
|
|
79
|
+
makeItem("m4", { scope: "user:laz/settings" }),
|
|
80
|
+
]);
|
|
81
|
+
|
|
82
|
+
it("matches scopes starting with prefix", () => {
|
|
83
|
+
const result = getItems(state, { scope_prefix: "project:" });
|
|
84
|
+
expect(result).toHaveLength(2);
|
|
85
|
+
expect(result.map((i) => i.id).sort()).toEqual(["m1", "m2"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("matches user scopes", () => {
|
|
89
|
+
const result = getItems(state, { scope_prefix: "user:laz/" });
|
|
90
|
+
expect(result).toHaveLength(2);
|
|
91
|
+
expect(result.map((i) => i.id).sort()).toEqual(["m3", "m4"]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("no match returns empty", () => {
|
|
95
|
+
const result = getItems(state, { scope_prefix: "system:" });
|
|
96
|
+
expect(result).toHaveLength(0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("combines with other filters", () => {
|
|
100
|
+
const result = getItems(state, {
|
|
101
|
+
scope_prefix: "project:",
|
|
102
|
+
not: { scope: "project:memex" },
|
|
103
|
+
});
|
|
104
|
+
expect(result).toHaveLength(1);
|
|
105
|
+
expect(result[0].id).toBe("m1");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ============================================================
|
|
110
|
+
// parents (advanced)
|
|
111
|
+
// ============================================================
|
|
112
|
+
|
|
113
|
+
describe("parents filter (advanced)", () => {
|
|
114
|
+
const state = stateWith([
|
|
115
|
+
makeItem("m1"),
|
|
116
|
+
makeItem("m2"),
|
|
117
|
+
makeItem("m3", { parents: ["m1"] }),
|
|
118
|
+
makeItem("m4", { parents: ["m1", "m2"] }),
|
|
119
|
+
makeItem("m5", { parents: ["m2"] }),
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
it("includes: matches single parent", () => {
|
|
123
|
+
const result = getItems(state, { parents: { includes: "m1" } });
|
|
124
|
+
expect(result).toHaveLength(2);
|
|
125
|
+
expect(result.map((i) => i.id).sort()).toEqual(["m3", "m4"]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("includes_any: matches any of the listed parents", () => {
|
|
129
|
+
const result = getItems(state, {
|
|
130
|
+
parents: { includes_any: ["m1", "m2"] },
|
|
131
|
+
});
|
|
132
|
+
expect(result).toHaveLength(3);
|
|
133
|
+
expect(result.map((i) => i.id).sort()).toEqual(["m3", "m4", "m5"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("includes_all: requires all listed parents", () => {
|
|
137
|
+
const result = getItems(state, {
|
|
138
|
+
parents: { includes_all: ["m1", "m2"] },
|
|
139
|
+
});
|
|
140
|
+
expect(result).toHaveLength(1);
|
|
141
|
+
expect(result[0].id).toBe("m4");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("count min: items with at least N parents", () => {
|
|
145
|
+
const result = getItems(state, { parents: { count: { min: 2 } } });
|
|
146
|
+
expect(result).toHaveLength(1);
|
|
147
|
+
expect(result[0].id).toBe("m4");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("count max: items with at most N parents", () => {
|
|
151
|
+
const result = getItems(state, { parents: { count: { max: 0 } } });
|
|
152
|
+
expect(result).toHaveLength(2);
|
|
153
|
+
expect(result.map((i) => i.id).sort()).toEqual(["m1", "m2"]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("count range: items with 1 parent", () => {
|
|
157
|
+
const result = getItems(state, {
|
|
158
|
+
parents: { count: { min: 1, max: 1 } },
|
|
159
|
+
});
|
|
160
|
+
expect(result).toHaveLength(2);
|
|
161
|
+
expect(result.map((i) => i.id).sort()).toEqual(["m3", "m5"]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("combines includes with count", () => {
|
|
165
|
+
const result = getItems(state, {
|
|
166
|
+
parents: { includes: "m1", count: { min: 2 } },
|
|
167
|
+
});
|
|
168
|
+
expect(result).toHaveLength(1);
|
|
169
|
+
expect(result[0].id).toBe("m4");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("has_parent sugar still works", () => {
|
|
173
|
+
const result = getItems(state, { has_parent: "m2" });
|
|
174
|
+
expect(result).toHaveLength(2);
|
|
175
|
+
expect(result.map((i) => i.id).sort()).toEqual(["m4", "m5"]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("is_root sugar still works", () => {
|
|
179
|
+
const result = getItems(state, { is_root: true });
|
|
180
|
+
expect(result).toHaveLength(2);
|
|
181
|
+
expect(result.map((i) => i.id).sort()).toEqual(["m1", "m2"]);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// ============================================================
|
|
186
|
+
// multi-sort
|
|
187
|
+
// ============================================================
|
|
188
|
+
|
|
189
|
+
describe("multi-sort", () => {
|
|
190
|
+
const state = stateWith([
|
|
191
|
+
makeItem("m1", { authority: 0.5, importance: 0.9 }),
|
|
192
|
+
makeItem("m2", { authority: 0.5, importance: 0.3 }),
|
|
193
|
+
makeItem("m3", { authority: 0.9, importance: 0.1 }),
|
|
194
|
+
makeItem("m4", { authority: 0.5, importance: 0.6 }),
|
|
195
|
+
]);
|
|
196
|
+
|
|
197
|
+
it("single sort still works (backwards compat)", () => {
|
|
198
|
+
const result = getItems(
|
|
199
|
+
state,
|
|
200
|
+
{},
|
|
201
|
+
{ sort: { field: "authority", order: "desc" } },
|
|
202
|
+
);
|
|
203
|
+
expect(result[0].id).toBe("m3");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("multi-sort: primary authority desc, secondary importance desc", () => {
|
|
207
|
+
const result = getItems(
|
|
208
|
+
state,
|
|
209
|
+
{},
|
|
210
|
+
{
|
|
211
|
+
sort: [
|
|
212
|
+
{ field: "authority", order: "desc" },
|
|
213
|
+
{ field: "importance", order: "desc" },
|
|
214
|
+
],
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
// m3 (authority 0.9) first
|
|
218
|
+
// then m1, m4, m2 (all authority 0.5, sorted by importance desc: 0.9, 0.6, 0.3)
|
|
219
|
+
expect(result.map((i) => i.id)).toEqual(["m3", "m1", "m4", "m2"]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("multi-sort: primary importance asc, secondary authority desc", () => {
|
|
223
|
+
const result = getItems(
|
|
224
|
+
state,
|
|
225
|
+
{},
|
|
226
|
+
{
|
|
227
|
+
sort: [
|
|
228
|
+
{ field: "importance", order: "asc" },
|
|
229
|
+
{ field: "authority", order: "desc" },
|
|
230
|
+
],
|
|
231
|
+
},
|
|
232
|
+
);
|
|
233
|
+
// importance asc: m3(0.1), m2(0.3), m4(0.6), m1(0.9)
|
|
234
|
+
expect(result.map((i) => i.id)).toEqual(["m3", "m2", "m4", "m1"]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("multi-sort with recency as tiebreaker", () => {
|
|
238
|
+
// items with same authority, recency breaks the tie
|
|
239
|
+
const result = getItems(
|
|
240
|
+
state,
|
|
241
|
+
{},
|
|
242
|
+
{
|
|
243
|
+
sort: [
|
|
244
|
+
{ field: "authority", order: "desc" },
|
|
245
|
+
{ field: "recency", order: "desc" },
|
|
246
|
+
],
|
|
247
|
+
},
|
|
248
|
+
);
|
|
249
|
+
// m3 first (highest authority), then m1/m4/m2 by recency (creation order)
|
|
250
|
+
expect(result[0].id).toBe("m3");
|
|
251
|
+
});
|
|
252
|
+
});
|