dirvi-lib 0.10.0 → 0.11.0-beta.12
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/dist/app/app-methods.d.ts +53 -0
- package/dist/app/app-methods.d.ts.map +1 -0
- package/dist/app/index.d.ts +1 -1
- package/dist/app/index.d.ts.map +1 -1
- package/dist/app/index.js +1 -1
- package/dist/app/reducer/effect-to-action.d.ts +2 -2
- package/dist/app/reducer/effect-to-action.d.ts.map +1 -1
- package/dist/app/reducer/effect-to-action.js +87 -32
- package/dist/app/reducer/reducer-action.d.ts +12 -6
- package/dist/app/reducer/reducer-action.d.ts.map +1 -1
- package/dist/app/reducer/reducer.d.ts +4 -2
- package/dist/app/reducer/reducer.d.ts.map +1 -1
- package/dist/app/reducer/reducer.js +106 -72
- package/dist/feedback/feedback.impl.d.ts.map +1 -1
- package/dist/feedback/feedback.types.d.ts +1 -1
- package/dist/feedback/feedback.types.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/input/application/intent-to-effect.d.ts +2 -2
- package/dist/input/application/intent-to-effect.d.ts.map +1 -1
- package/dist/input/application/intent-to-effect.js +10 -11
- package/dist/input/application/parse-ex-command.d.ts +17 -0
- package/dist/input/application/parse-ex-command.d.ts.map +1 -0
- package/dist/input/application/{parse-command.js → parse-ex-command.js} +84 -54
- package/dist/input/application/parse-normal-command.d.ts +1 -1
- package/dist/input/application/parse-normal-command.d.ts.map +1 -1
- package/dist/input/domain/effect.d.ts +20 -9
- package/dist/input/domain/effect.d.ts.map +1 -1
- package/dist/state-and-nav/cursor.d.ts +89 -0
- package/dist/state-and-nav/cursor.d.ts.map +1 -0
- package/dist/state-and-nav/cursor.js +84 -0
- package/dist/{trees → state-and-nav}/fold-node/deprecated/fold-node.impl.d.ts +1 -1
- package/dist/state-and-nav/fold-node/deprecated/fold-node.impl.d.ts.map +1 -0
- package/dist/state-and-nav/fold-node/deprecated/fold-node.test.d.ts.map +1 -0
- package/dist/{trees → state-and-nav}/fold-node/deprecated/fold-node.test.js +2 -2
- package/dist/{trees → state-and-nav}/fold-node/deprecated/fold-node.types.d.ts +2 -2
- package/dist/state-and-nav/fold-node/deprecated/fold-node.types.d.ts.map +1 -0
- package/dist/{trees → state-and-nav}/fold-node/fold-node.impl.d.ts +3 -3
- package/dist/state-and-nav/fold-node/fold-node.impl.d.ts.map +1 -0
- package/dist/{trees → state-and-nav}/fold-node/fold-node.impl.js +2 -2
- package/dist/{trees → state-and-nav}/fold-node/fold-node.model.d.ts +1 -1
- package/dist/state-and-nav/fold-node/fold-node.model.d.ts.map +1 -0
- package/dist/{trees → state-and-nav}/fold-node/fold-node.types.d.ts +2 -2
- package/dist/state-and-nav/fold-node/fold-node.types.d.ts.map +1 -0
- package/dist/state-and-nav/fold-node/index.d.ts.map +1 -0
- package/dist/state-and-nav/index.d.ts +16 -0
- package/dist/state-and-nav/index.d.ts.map +1 -0
- package/dist/state-and-nav/index.js +6 -0
- package/dist/state-and-nav/nav/nav.ordered.impl.d.ts +12 -0
- package/dist/state-and-nav/nav/nav.ordered.impl.d.ts.map +1 -0
- package/dist/state-and-nav/nav/nav.ordered.impl.js +77 -0
- package/dist/state-and-nav/nav/nav.traversal.d.ts +11 -0
- package/dist/state-and-nav/nav/nav.traversal.d.ts.map +1 -0
- package/dist/state-and-nav/nav/nav.traversal.js +204 -0
- package/dist/{trees/nav-node/nav-node.types.d.ts → state-and-nav/nav/nav.types.d.ts} +37 -23
- package/dist/state-and-nav/nav/nav.types.d.ts.map +1 -0
- package/dist/state-and-nav/nav/nav.unordered.impl.d.ts +13 -0
- package/dist/state-and-nav/nav/nav.unordered.impl.d.ts.map +1 -0
- package/dist/state-and-nav/nav/nav.unordered.impl.js +77 -0
- package/dist/state-and-nav/state/state.impl.d.ts +9 -0
- package/dist/state-and-nav/state/state.impl.d.ts.map +1 -0
- package/dist/{trees → state-and-nav}/state/state.impl.js +62 -9
- package/dist/state-and-nav/state/state.schemas.d.ts +2 -0
- package/dist/state-and-nav/state/state.schemas.d.ts.map +1 -0
- package/dist/state-and-nav/state/state.schemas.js +39 -0
- package/dist/state-and-nav/state/state.types.d.ts +72 -0
- package/dist/state-and-nav/state/state.types.d.ts.map +1 -0
- package/dist/state-and-nav/tree/deprecated/tree-node.arb.d.ts.map +1 -0
- package/dist/state-and-nav/tree/deprecated/tree-node.arb.debug.test.d.ts.map +1 -0
- package/dist/state-and-nav/tree/deprecated/tree-node.impl.d.ts.map +1 -0
- package/dist/state-and-nav/tree/deprecated/tree-node.schemas.d.ts.map +1 -0
- package/dist/state-and-nav/tree/deprecated/tree-node.test.d.ts.map +1 -0
- package/dist/state-and-nav/tree/deprecated/tree-node.types.d.ts.map +1 -0
- package/dist/state-and-nav/tree/index.d.ts +7 -0
- package/dist/state-and-nav/tree/index.d.ts.map +1 -0
- package/dist/state-and-nav/tree/index.js +1 -0
- package/dist/state-and-nav/tree/tree-node.arb.d.ts +94 -0
- package/dist/state-and-nav/tree/tree-node.arb.d.ts.map +1 -0
- package/dist/state-and-nav/tree/tree-node.arb.debug.test.d.ts +2 -0
- package/dist/state-and-nav/tree/tree-node.arb.debug.test.d.ts.map +1 -0
- package/dist/state-and-nav/tree/tree-node.arb.debug.test.js +54 -0
- package/dist/state-and-nav/tree/tree-node.arb.js +188 -0
- package/dist/state-and-nav/tree/tree-node.model.d.ts +194 -0
- package/dist/state-and-nav/tree/tree-node.model.d.ts.map +1 -0
- package/dist/state-and-nav/tree/tree-node.model.js +110 -0
- package/dist/state-and-nav/tree/tree-node.test.d.ts +2 -0
- package/dist/state-and-nav/tree/tree-node.test.d.ts.map +1 -0
- package/dist/state-and-nav/tree/tree-node.test.js +234 -0
- package/dist/{trees/tree-node/tree-node.impl.d.ts → state-and-nav/tree/tree.impl.d.ts} +3 -3
- package/dist/state-and-nav/tree/tree.impl.d.ts.map +1 -0
- package/dist/{trees/tree-node/tree-node.impl.js → state-and-nav/tree/tree.impl.js} +20 -17
- package/dist/{trees/tree-node/tree-node.types.d.ts → state-and-nav/tree/tree.types.d.ts} +67 -47
- package/dist/state-and-nav/tree/tree.types.d.ts.map +1 -0
- package/dist/state-and-nav/tree/tree.types.js +1 -0
- package/package.json +4 -3
- package/dist/app/app-api.d.ts +0 -74
- package/dist/app/app-api.d.ts.map +0 -1
- package/dist/app/app-api.js +0 -11
- package/dist/input/application/parse-command.d.ts +0 -14
- package/dist/input/application/parse-command.d.ts.map +0 -1
- package/dist/trees/cursor.d.ts +0 -59
- package/dist/trees/cursor.d.ts.map +0 -1
- package/dist/trees/cursor.js +0 -55
- package/dist/trees/fold-node/deprecated/fold-node.impl.d.ts.map +0 -1
- package/dist/trees/fold-node/deprecated/fold-node.test.d.ts.map +0 -1
- package/dist/trees/fold-node/deprecated/fold-node.types.d.ts.map +0 -1
- package/dist/trees/fold-node/fold-node.impl.d.ts.map +0 -1
- package/dist/trees/fold-node/fold-node.model.d.ts.map +0 -1
- package/dist/trees/fold-node/fold-node.types.d.ts.map +0 -1
- package/dist/trees/fold-node/index.d.ts.map +0 -1
- package/dist/trees/index.d.ts +0 -9
- package/dist/trees/index.d.ts.map +0 -1
- package/dist/trees/index.js +0 -8
- package/dist/trees/nav-node/nav-node.impl.d.ts +0 -29
- package/dist/trees/nav-node/nav-node.impl.d.ts.map +0 -1
- package/dist/trees/nav-node/nav-node.impl.js +0 -223
- package/dist/trees/nav-node/nav-node.types.d.ts.map +0 -1
- package/dist/trees/state/state.impl.d.ts +0 -8
- package/dist/trees/state/state.impl.d.ts.map +0 -1
- package/dist/trees/state/state.schemas.d.ts +0 -25
- package/dist/trees/state/state.schemas.d.ts.map +0 -1
- package/dist/trees/state/state.schemas.js +0 -17
- package/dist/trees/state/state.types.d.ts +0 -36
- package/dist/trees/state/state.types.d.ts.map +0 -1
- package/dist/trees/tree-node/deprecated/tree-node.arb.d.ts.map +0 -1
- package/dist/trees/tree-node/deprecated/tree-node.arb.debug.test.d.ts.map +0 -1
- package/dist/trees/tree-node/deprecated/tree-node.impl.d.ts.map +0 -1
- package/dist/trees/tree-node/deprecated/tree-node.schemas.d.ts.map +0 -1
- package/dist/trees/tree-node/deprecated/tree-node.test.d.ts.map +0 -1
- package/dist/trees/tree-node/deprecated/tree-node.types.d.ts.map +0 -1
- package/dist/trees/tree-node/index.d.ts +0 -5
- package/dist/trees/tree-node/index.d.ts.map +0 -1
- package/dist/trees/tree-node/index.js +0 -2
- package/dist/trees/tree-node/tree-node.arb.d.ts +0 -104
- package/dist/trees/tree-node/tree-node.arb.d.ts.map +0 -1
- package/dist/trees/tree-node/tree-node.arb.js +0 -188
- package/dist/trees/tree-node/tree-node.impl.d.ts.map +0 -1
- package/dist/trees/tree-node/tree-node.model.d.ts +0 -78
- package/dist/trees/tree-node/tree-node.model.d.ts.map +0 -1
- package/dist/trees/tree-node/tree-node.model.js +0 -37
- package/dist/trees/tree-node/tree-node.types.d.ts.map +0 -1
- /package/dist/{trees/fold-node/deprecated/fold-node.types.js → app/app-methods.js} +0 -0
- /package/dist/{trees → state-and-nav}/fold-node/deprecated/fold-node.impl.js +0 -0
- /package/dist/{trees → state-and-nav}/fold-node/deprecated/fold-node.test.d.ts +0 -0
- /package/dist/{trees/fold-node → state-and-nav/fold-node/deprecated}/fold-node.types.js +0 -0
- /package/dist/{trees → state-and-nav}/fold-node/fold-node.model.js +0 -0
- /package/dist/{trees/nav-node/nav-node.types.js → state-and-nav/fold-node/fold-node.types.js} +0 -0
- /package/dist/{trees → state-and-nav}/fold-node/index.d.ts +0 -0
- /package/dist/{trees → state-and-nav}/fold-node/index.js +0 -0
- /package/dist/{trees/state/state.types.js → state-and-nav/nav/nav.types.js} +0 -0
- /package/dist/{trees/tree-node/deprecated/tree-node.types.js → state-and-nav/state/state.types.js} +0 -0
- /package/dist/{trees/tree-node → state-and-nav/tree}/deprecated/tree-node.arb.d.ts +0 -0
- /package/dist/{trees/tree-node → state-and-nav/tree}/deprecated/tree-node.arb.debug.test.d.ts +0 -0
- /package/dist/{trees/tree-node → state-and-nav/tree}/deprecated/tree-node.arb.debug.test.js +0 -0
- /package/dist/{trees/tree-node → state-and-nav/tree}/deprecated/tree-node.arb.js +0 -0
- /package/dist/{trees/tree-node → state-and-nav/tree}/deprecated/tree-node.impl.d.ts +0 -0
- /package/dist/{trees/tree-node → state-and-nav/tree}/deprecated/tree-node.impl.js +0 -0
- /package/dist/{trees/tree-node → state-and-nav/tree}/deprecated/tree-node.schemas.d.ts +0 -0
- /package/dist/{trees/tree-node → state-and-nav/tree}/deprecated/tree-node.schemas.js +0 -0
- /package/dist/{trees/tree-node → state-and-nav/tree}/deprecated/tree-node.test.d.ts +0 -0
- /package/dist/{trees/tree-node → state-and-nav/tree}/deprecated/tree-node.test.js +0 -0
- /package/dist/{trees/tree-node → state-and-nav/tree}/deprecated/tree-node.types.d.ts +0 -0
- /package/dist/{trees/tree-node → state-and-nav/tree/deprecated}/tree-node.types.js +0 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Validates a serializable primitive suitable for use as a tree-node ID.
|
|
4
|
+
*
|
|
5
|
+
* Strings are accepted without additional restrictions. Numbers must be
|
|
6
|
+
* finite: `NaN`, `Infinity`, and `-Infinity` are rejected.
|
|
7
|
+
*
|
|
8
|
+
* Applications can derive narrower schemas to impose domain-specific
|
|
9
|
+
* restrictions or produce branded ID types.
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
*
|
|
13
|
+
* Requires Zod v4, where `z.number()` rejects non-finite values.
|
|
14
|
+
*/
|
|
15
|
+
export const serializableKeySchema = z.union([
|
|
16
|
+
z.string(),
|
|
17
|
+
z.number(),
|
|
18
|
+
]);
|
|
19
|
+
/**
|
|
20
|
+
* Creates a recursive schema for validating structural tree-node models.
|
|
21
|
+
*
|
|
22
|
+
* @remarks
|
|
23
|
+
*
|
|
24
|
+
* The supplied schemas validate every node's ID and application-owned value.
|
|
25
|
+
* The returned schema validates the three node shapes recursively:
|
|
26
|
+
*
|
|
27
|
+
* - Leaves omit `children`.
|
|
28
|
+
* - Unloaded branches have `children: null`.
|
|
29
|
+
* - Loaded branches have an ordered array of valid child models.
|
|
30
|
+
*
|
|
31
|
+
* Node objects are strict: properties other than `id`, `value`, and the
|
|
32
|
+
* appropriate `children` property are rejected. Validation of properties
|
|
33
|
+
* inside `value` is controlled by `valueSchema`.
|
|
34
|
+
*
|
|
35
|
+
* Child arrays are parsed in their existing order; this schema does not sort
|
|
36
|
+
* or reorder children.
|
|
37
|
+
*
|
|
38
|
+
* This schema validates shape and field values, but does not enforce
|
|
39
|
+
* sibling-ID uniqueness. That invariant must be checked when constructing
|
|
40
|
+
* runtime tree nodes or by an additional validation step.
|
|
41
|
+
*
|
|
42
|
+
* The supplied ID schema is responsible for enforcing the runtime ID
|
|
43
|
+
* constraints, including finite numeric IDs. The generic constraint alone
|
|
44
|
+
* cannot enforce those restrictions.
|
|
45
|
+
*
|
|
46
|
+
* Inputs must describe finite trees. Cyclic object graphs are not supported.
|
|
47
|
+
*
|
|
48
|
+
* @typeParam Id - The validated node-ID type.
|
|
49
|
+
* @typeParam Value - The validated application-value type.
|
|
50
|
+
* @param idSchema - Schema used to validate every node's ID.
|
|
51
|
+
* @param valueSchema - Schema used to validate every node's application data.
|
|
52
|
+
* @returns A recursive schema for the application's tree-node models.
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
*
|
|
56
|
+
* ```ts
|
|
57
|
+
* const myIdSchema = serializableKeySchema
|
|
58
|
+
* .pipe(z.string().min(1))
|
|
59
|
+
* .brand<'MyId'>();
|
|
60
|
+
*
|
|
61
|
+
* type MyId = z.output<typeof myIdSchema>;
|
|
62
|
+
*
|
|
63
|
+
* const modelSchema = createTreeNodeModelSchema(
|
|
64
|
+
* myIdSchema,
|
|
65
|
+
* z.object({ label: z.string() }).strict(),
|
|
66
|
+
* );
|
|
67
|
+
*
|
|
68
|
+
* const model = modelSchema.parse({
|
|
69
|
+
* id: 'root',
|
|
70
|
+
* value: { label: 'My' },
|
|
71
|
+
* children: [
|
|
72
|
+
* {
|
|
73
|
+
* id: 'home',
|
|
74
|
+
* value: { label: 'Home' },
|
|
75
|
+
* },
|
|
76
|
+
* {
|
|
77
|
+
* id: 'settings',
|
|
78
|
+
* value: { label: 'Settings' },
|
|
79
|
+
* children: null,
|
|
80
|
+
* },
|
|
81
|
+
* ],
|
|
82
|
+
* });
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export function createTreeNodeModelSchema(idSchema, valueSchema) {
|
|
86
|
+
let nodeSchema;
|
|
87
|
+
nodeSchema = z.lazy(() => z.union([
|
|
88
|
+
z
|
|
89
|
+
.object({
|
|
90
|
+
id: idSchema,
|
|
91
|
+
value: valueSchema,
|
|
92
|
+
})
|
|
93
|
+
.strict(),
|
|
94
|
+
z
|
|
95
|
+
.object({
|
|
96
|
+
id: idSchema,
|
|
97
|
+
value: valueSchema,
|
|
98
|
+
children: z.null(),
|
|
99
|
+
})
|
|
100
|
+
.strict(),
|
|
101
|
+
z
|
|
102
|
+
.object({
|
|
103
|
+
id: idSchema,
|
|
104
|
+
value: valueSchema,
|
|
105
|
+
children: z.array(nodeSchema),
|
|
106
|
+
})
|
|
107
|
+
.strict(),
|
|
108
|
+
]));
|
|
109
|
+
return nodeSchema;
|
|
110
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tree-node.test.d.ts","sourceRoot":"","sources":["../../../src/state-and-nav/tree/tree-node.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import * as fc from 'fast-check';
|
|
3
|
+
import { createTreeMethods } from './tree.impl.js';
|
|
4
|
+
import { childrenStateModelArb, modelAndBranchPathArb, modelAndReachablePathArb, } from './tree-node.arb.js';
|
|
5
|
+
const api = createTreeMethods();
|
|
6
|
+
/**
|
|
7
|
+
* Fail rather than silently skipping assertions when an operation that should
|
|
8
|
+
* succeed returns undefined.
|
|
9
|
+
*/
|
|
10
|
+
function requireDefined(value) {
|
|
11
|
+
if (value === undefined) {
|
|
12
|
+
throw new Error('Expected a defined result');
|
|
13
|
+
}
|
|
14
|
+
return value;
|
|
15
|
+
}
|
|
16
|
+
function fromModel(model) {
|
|
17
|
+
return requireDefined(api.fromModel(model));
|
|
18
|
+
}
|
|
19
|
+
function loadedBranch(id, children) {
|
|
20
|
+
return requireDefined(api.createLoadedBranch(id, 0, children));
|
|
21
|
+
}
|
|
22
|
+
describe('findChildById', () => {
|
|
23
|
+
it('returns the existing matching direct child', () => {
|
|
24
|
+
const child = api.createLeaf('child', 1);
|
|
25
|
+
const root = loadedBranch('root', [child]);
|
|
26
|
+
expect(api.findChildById(root, 'child')).toBe(child);
|
|
27
|
+
});
|
|
28
|
+
it('does not search descendants', () => {
|
|
29
|
+
const descendant = api.createLeaf('descendant', 1);
|
|
30
|
+
const child = loadedBranch('child', [descendant]);
|
|
31
|
+
const root = loadedBranch('root', [child]);
|
|
32
|
+
expect(api.findChildById(root, 'descendant')).toBeUndefined();
|
|
33
|
+
});
|
|
34
|
+
it.each([
|
|
35
|
+
{
|
|
36
|
+
name: 'an empty loaded branch',
|
|
37
|
+
root: loadedBranch('root', []),
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: 'a leaf',
|
|
41
|
+
root: api.createLeaf('root', 0),
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'an unloaded branch',
|
|
45
|
+
root: api.createUnloadedBranch('root', 0),
|
|
46
|
+
},
|
|
47
|
+
])('returns undefined for a missing child in $name', ({ root }) => {
|
|
48
|
+
expect(api.findChildById(root, 'missing')).toBeUndefined();
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
describe('findAtPath and selectAtPath', () => {
|
|
52
|
+
it('resolves generated reachable paths', () => {
|
|
53
|
+
fc.assert(fc.property(modelAndReachablePathArb, ({ root: rootModel, path, expected }) => {
|
|
54
|
+
const root = fromModel(rootModel);
|
|
55
|
+
const selected = requireDefined(api.findAtPath(root, path));
|
|
56
|
+
expect(api.toModel(selected)).toStrictEqual(expected);
|
|
57
|
+
expect(api.selectAtPath(root, path, (node) => node)).toBe(selected);
|
|
58
|
+
expect(api.selectAtPath(root, path, (node) => api.id(node))).toBe(expected.id);
|
|
59
|
+
}));
|
|
60
|
+
});
|
|
61
|
+
it('calls the selector once with the existing resolved node', () => {
|
|
62
|
+
const child = api.createLeaf('child', 1);
|
|
63
|
+
const root = loadedBranch('root', [child]);
|
|
64
|
+
const selector = vi.fn((node) => api.id(node));
|
|
65
|
+
expect(api.selectAtPath(root, ['child'], selector)).toBe('child');
|
|
66
|
+
expect(selector).toHaveBeenCalledOnce();
|
|
67
|
+
expect(selector).toHaveBeenCalledWith(child);
|
|
68
|
+
});
|
|
69
|
+
it('resolves the root for an empty path', () => {
|
|
70
|
+
const root = api.createLeaf('root', 0);
|
|
71
|
+
const selector = vi.fn((node) => node);
|
|
72
|
+
expect(api.findAtPath(root, [])).toBe(root);
|
|
73
|
+
expect(api.selectAtPath(root, [], selector)).toBe(root);
|
|
74
|
+
expect(selector).toHaveBeenCalledOnce();
|
|
75
|
+
expect(selector).toHaveBeenCalledWith(root);
|
|
76
|
+
});
|
|
77
|
+
it.each([
|
|
78
|
+
{
|
|
79
|
+
name: 'a missing child',
|
|
80
|
+
root: loadedBranch('root', []),
|
|
81
|
+
path: ['missing'],
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'a missing descendant',
|
|
85
|
+
root: loadedBranch('root', [loadedBranch('child', [])]),
|
|
86
|
+
path: ['child', 'missing'],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'a path through a leaf',
|
|
90
|
+
root: loadedBranch('root', [api.createLeaf('child', 1)]),
|
|
91
|
+
path: ['child', 'descendant'],
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
name: 'a path through an unloaded branch',
|
|
95
|
+
root: loadedBranch('root', [api.createUnloadedBranch('child', 1)]),
|
|
96
|
+
path: ['child', 'descendant'],
|
|
97
|
+
},
|
|
98
|
+
])('returns undefined for $name without calling the selector', ({ root, path }) => {
|
|
99
|
+
const selector = vi.fn((node) => node);
|
|
100
|
+
expect(api.findAtPath(root, path)).toBeUndefined();
|
|
101
|
+
expect(api.selectAtPath(root, path, selector)).toBeUndefined();
|
|
102
|
+
expect(selector).not.toHaveBeenCalled();
|
|
103
|
+
});
|
|
104
|
+
it('returns undefined when the selector returns undefined', () => {
|
|
105
|
+
const root = api.createLeaf('root', 0);
|
|
106
|
+
const selector = vi.fn((_node) => undefined);
|
|
107
|
+
expect(api.selectAtPath(root, [], selector)).toBeUndefined();
|
|
108
|
+
expect(selector).toHaveBeenCalledOnce();
|
|
109
|
+
expect(selector).toHaveBeenCalledWith(root);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
describe('updateAtPath', () => {
|
|
113
|
+
it('replaces a generated node without changing the original tree', () => {
|
|
114
|
+
fc.assert(fc.property(modelAndReachablePathArb, ({ root: rootModel, path, expected }) => {
|
|
115
|
+
const root = fromModel(rootModel);
|
|
116
|
+
const originalNode = requireDefined(api.findAtPath(root, path));
|
|
117
|
+
const replacement = api.createLeaf(expected.id, expected.value);
|
|
118
|
+
const updatedRoot = requireDefined(api.updateAtPath(root, path, () => replacement));
|
|
119
|
+
expect(api.findAtPath(updatedRoot, path)).toBe(replacement);
|
|
120
|
+
expect(api.findAtPath(root, path)).toBe(originalNode);
|
|
121
|
+
expect(api.toModel(root)).toStrictEqual(rootModel);
|
|
122
|
+
}));
|
|
123
|
+
});
|
|
124
|
+
it('replaces child loading state at a generated branch path', () => {
|
|
125
|
+
fc.assert(fc.property(modelAndBranchPathArb, childrenStateModelArb, ({ root: rootModel, path, expected }, newChildren) => {
|
|
126
|
+
const root = fromModel(rootModel);
|
|
127
|
+
const originalNode = requireDefined(api.findAtPath(root, path));
|
|
128
|
+
const updatedRoot = requireDefined(api.updateAtPath(root, path, (node) => newChildren === null
|
|
129
|
+
? api.toUnloadedBranch(node)
|
|
130
|
+
: api.withLoadedChildren(node, newChildren.map(fromModel))));
|
|
131
|
+
const updatedNode = requireDefined(api.findAtPath(updatedRoot, path));
|
|
132
|
+
expect(updatedNode).not.toBe(originalNode);
|
|
133
|
+
expect(api.toModel(updatedNode)).toStrictEqual({
|
|
134
|
+
id: expected.id,
|
|
135
|
+
value: expected.value,
|
|
136
|
+
children: newChildren,
|
|
137
|
+
});
|
|
138
|
+
expect(api.findAtPath(root, path)).toBe(originalNode);
|
|
139
|
+
expect(api.toModel(root)).toStrictEqual(rootModel);
|
|
140
|
+
}));
|
|
141
|
+
});
|
|
142
|
+
it('calls the updater once and preserves position when the ID changes', () => {
|
|
143
|
+
const before = api.createLeaf('before', 1);
|
|
144
|
+
const child = api.createLeaf('child', 2);
|
|
145
|
+
const after = api.createLeaf('after', 3);
|
|
146
|
+
const root = loadedBranch('root', [before, child, after]);
|
|
147
|
+
const replacement = api.createLeaf('replacement', 4);
|
|
148
|
+
const update = vi.fn(() => replacement);
|
|
149
|
+
const updatedRoot = requireDefined(api.updateAtPath(root, ['child'], update));
|
|
150
|
+
expect(update).toHaveBeenCalledOnce();
|
|
151
|
+
expect(update).toHaveBeenCalledWith(child);
|
|
152
|
+
expect(api.findAtPath(updatedRoot, ['replacement'])).toBe(replacement);
|
|
153
|
+
expect(api.findAtPath(updatedRoot, ['child'])).toBeUndefined();
|
|
154
|
+
const children = [...requireDefined(api.getLoadedChildren(updatedRoot))];
|
|
155
|
+
expect(children.map((node) => api.id(node))).toStrictEqual([
|
|
156
|
+
'before',
|
|
157
|
+
'replacement',
|
|
158
|
+
'after',
|
|
159
|
+
]);
|
|
160
|
+
expect(children[0]).toBe(before);
|
|
161
|
+
expect(children[1]).toBe(replacement);
|
|
162
|
+
expect(children[2]).toBe(after);
|
|
163
|
+
expect(api.findAtPath(root, ['child'])).toBe(child);
|
|
164
|
+
});
|
|
165
|
+
it('rejects a replacement ID that duplicates a sibling ID', () => {
|
|
166
|
+
const child = api.createLeaf('child', 1);
|
|
167
|
+
const sibling = api.createLeaf('sibling', 2);
|
|
168
|
+
const root = loadedBranch('root', [child, sibling]);
|
|
169
|
+
const replacement = api.createLeaf('sibling', 3);
|
|
170
|
+
const update = vi.fn(() => replacement);
|
|
171
|
+
expect(api.updateAtPath(root, ['child'], update)).toBeUndefined();
|
|
172
|
+
expect(update).toHaveBeenCalledOnce();
|
|
173
|
+
expect(update).toHaveBeenCalledWith(child);
|
|
174
|
+
expect(api.findAtPath(root, ['child'])).toBe(child);
|
|
175
|
+
expect(api.findAtPath(root, ['sibling'])).toBe(sibling);
|
|
176
|
+
});
|
|
177
|
+
it('rebuilds ancestors while preserving unrelated node identities', () => {
|
|
178
|
+
const target = api.createLeaf('target', 1);
|
|
179
|
+
const sibling = api.createLeaf('sibling', 2);
|
|
180
|
+
const branch = loadedBranch('branch', [target, sibling]);
|
|
181
|
+
const otherBranch = loadedBranch('other', []);
|
|
182
|
+
const root = loadedBranch('root', [branch, otherBranch]);
|
|
183
|
+
const replacement = api.createLeaf('target', 3);
|
|
184
|
+
const updatedRoot = requireDefined(api.updateAtPath(root, ['branch', 'target'], () => replacement));
|
|
185
|
+
expect(updatedRoot).not.toBe(root);
|
|
186
|
+
expect(api.findAtPath(updatedRoot, ['branch'])).not.toBe(branch);
|
|
187
|
+
expect(api.findAtPath(updatedRoot, ['branch', 'target'])).toBe(replacement);
|
|
188
|
+
expect(api.findAtPath(updatedRoot, ['branch', 'sibling'])).toBe(sibling);
|
|
189
|
+
expect(api.findAtPath(updatedRoot, ['other'])).toBe(otherBranch);
|
|
190
|
+
expect(api.findAtPath(root, ['branch', 'target'])).toBe(target);
|
|
191
|
+
});
|
|
192
|
+
it('replaces the root for an empty path', () => {
|
|
193
|
+
const root = api.createLeaf('root', 0);
|
|
194
|
+
const replacement = api.createLeaf('replacement', 1);
|
|
195
|
+
const update = vi.fn(() => replacement);
|
|
196
|
+
expect(api.updateAtPath(root, [], update)).toBe(replacement);
|
|
197
|
+
expect(update).toHaveBeenCalledOnce();
|
|
198
|
+
expect(update).toHaveBeenCalledWith(root);
|
|
199
|
+
});
|
|
200
|
+
it.each([
|
|
201
|
+
{
|
|
202
|
+
name: 'a missing child',
|
|
203
|
+
root: loadedBranch('root', []),
|
|
204
|
+
path: ['missing'],
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
name: 'a path through a leaf',
|
|
208
|
+
root: api.createLeaf('root', 0),
|
|
209
|
+
path: ['child'],
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
name: 'a path through an unloaded branch',
|
|
213
|
+
root: api.createUnloadedBranch('root', 0),
|
|
214
|
+
path: ['child'],
|
|
215
|
+
},
|
|
216
|
+
])('returns undefined for $name without calling the updater', ({ root, path }) => {
|
|
217
|
+
const update = vi.fn((node) => node);
|
|
218
|
+
expect(api.updateAtPath(root, path, update)).toBeUndefined();
|
|
219
|
+
expect(update).not.toHaveBeenCalled();
|
|
220
|
+
});
|
|
221
|
+
it.each([
|
|
222
|
+
{ name: 'the root', path: [] },
|
|
223
|
+
{ name: 'a descendant', path: ['child'] },
|
|
224
|
+
])('returns undefined when updating $name is aborted', ({ path }) => {
|
|
225
|
+
const child = api.createLeaf('child', 1);
|
|
226
|
+
const root = loadedBranch('root', [child]);
|
|
227
|
+
const selected = requireDefined(api.findAtPath(root, path));
|
|
228
|
+
const update = vi.fn(() => undefined);
|
|
229
|
+
expect(api.updateAtPath(root, path, update)).toBeUndefined();
|
|
230
|
+
expect(update).toHaveBeenCalledOnce();
|
|
231
|
+
expect(update).toHaveBeenCalledWith(selected);
|
|
232
|
+
expect(api.findAtPath(root, ['child'])).toBe(child);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { TreeMethods } from './tree.types.js';
|
|
2
2
|
import type { SerializableKey } from './tree-node.model.js';
|
|
3
3
|
/**
|
|
4
4
|
* Creates an API for constructing, inspecting, and immutably updating opaque
|
|
@@ -11,5 +11,5 @@ import type { SerializableKey } from './tree-node.model.js';
|
|
|
11
11
|
* Tree updates preserve object identity for unrelated nodes. New ancestor
|
|
12
12
|
* nodes and child collections are created only along an updated path.
|
|
13
13
|
*/
|
|
14
|
-
export declare function
|
|
15
|
-
//# sourceMappingURL=tree
|
|
14
|
+
export declare function createTreeMethods<Id extends SerializableKey, Value>(): TreeMethods<Id, Value>;
|
|
15
|
+
//# sourceMappingURL=tree.impl.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tree.impl.d.ts","sourceRoot":"","sources":["../../../src/state-and-nav/tree/tree.impl.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,WAAW,EAGZ,MAAM,iBAAiB,CAAC;AACzB,OAAO,KAAK,EAAE,eAAe,EAAiB,MAAM,sBAAsB,CAAC;AA2J3E;;;;;;;;;;GAUG;AACH,wBAAgB,iBAAiB,CAC/B,EAAE,SAAS,eAAe,EAC1B,KAAK,KACF,WAAW,CAAC,EAAE,EAAE,KAAK,CAAC,CAgR1B"}
|
|
@@ -13,6 +13,9 @@ function toData(node) {
|
|
|
13
13
|
function fromData(data) {
|
|
14
14
|
return data;
|
|
15
15
|
}
|
|
16
|
+
// =============================================================================
|
|
17
|
+
// Helpers
|
|
18
|
+
// =============================================================================
|
|
16
19
|
function isLeafData(node) {
|
|
17
20
|
return !('children' in node);
|
|
18
21
|
}
|
|
@@ -23,11 +26,8 @@ function isLoadedBranchData(node) {
|
|
|
23
26
|
return 'children' in node && node.children !== null;
|
|
24
27
|
}
|
|
25
28
|
/**
|
|
26
|
-
* Returns a reusable iterable over child nodes
|
|
27
|
-
* child
|
|
28
|
-
*
|
|
29
|
-
* The implementation currently stores children in an array, but callers only
|
|
30
|
-
* receive an `Iterable` and must not rely on iteration order.
|
|
29
|
+
* Returns a reusable iterable over child nodes in their stored order without
|
|
30
|
+
* exposing the internal child array.
|
|
31
31
|
*/
|
|
32
32
|
function asChildrenIterable(children) {
|
|
33
33
|
return Object.freeze({
|
|
@@ -45,14 +45,15 @@ function asChildrenIterable(children) {
|
|
|
45
45
|
* for `createLoadedBranch` and `withLoadedChildren`.
|
|
46
46
|
*/
|
|
47
47
|
function copyUniqueChildren(children, getId) {
|
|
48
|
-
const copiedChildren = [
|
|
48
|
+
const copiedChildren = [];
|
|
49
49
|
const seenIds = new Set();
|
|
50
|
-
for (const child of
|
|
50
|
+
for (const child of children) {
|
|
51
51
|
const id = getId(child);
|
|
52
52
|
if (seenIds.has(id)) {
|
|
53
53
|
return undefined;
|
|
54
54
|
}
|
|
55
55
|
seenIds.add(id);
|
|
56
|
+
copiedChildren.push(child);
|
|
56
57
|
}
|
|
57
58
|
return Object.freeze(copiedChildren);
|
|
58
59
|
}
|
|
@@ -76,6 +77,9 @@ function createLoadedBranchData(id, value, children) {
|
|
|
76
77
|
children,
|
|
77
78
|
});
|
|
78
79
|
}
|
|
80
|
+
// =============================================================================
|
|
81
|
+
// Generic Tree Methods Init
|
|
82
|
+
// =============================================================================
|
|
79
83
|
/**
|
|
80
84
|
* Creates an API for constructing, inspecting, and immutably updating opaque
|
|
81
85
|
* tree nodes.
|
|
@@ -87,8 +91,8 @@ function createLoadedBranchData(id, value, children) {
|
|
|
87
91
|
* Tree updates preserve object identity for unrelated nodes. New ancestor
|
|
88
92
|
* nodes and child collections are created only along an updated path.
|
|
89
93
|
*/
|
|
90
|
-
export function
|
|
91
|
-
function
|
|
94
|
+
export function createTreeMethods() {
|
|
95
|
+
function getLoadedChildrenData(node) {
|
|
92
96
|
const data = toData(node);
|
|
93
97
|
return isLoadedBranchData(data) ? data.children : undefined;
|
|
94
98
|
}
|
|
@@ -122,7 +126,7 @@ export function createTreeNodeApi() {
|
|
|
122
126
|
}
|
|
123
127
|
return 'loaded-branch';
|
|
124
128
|
},
|
|
125
|
-
|
|
129
|
+
matchByKind(node, handlers) {
|
|
126
130
|
const data = toData(node);
|
|
127
131
|
if (isLeafData(data)) {
|
|
128
132
|
return handlers.leaf({
|
|
@@ -143,11 +147,11 @@ export function createTreeNodeApi() {
|
|
|
143
147
|
});
|
|
144
148
|
},
|
|
145
149
|
getLoadedChildren(node) {
|
|
146
|
-
const children =
|
|
150
|
+
const children = getLoadedChildrenData(node);
|
|
147
151
|
return children === undefined ? undefined : asChildrenIterable(children);
|
|
148
152
|
},
|
|
149
153
|
findChildById(node, id) {
|
|
150
|
-
const children =
|
|
154
|
+
const children = getLoadedChildrenData(node);
|
|
151
155
|
if (children === undefined) {
|
|
152
156
|
return undefined;
|
|
153
157
|
}
|
|
@@ -245,7 +249,7 @@ export function createTreeNodeApi() {
|
|
|
245
249
|
};
|
|
246
250
|
function modifyChildAtPath(node, path, modifier) {
|
|
247
251
|
const childId = path[0];
|
|
248
|
-
const children =
|
|
252
|
+
const children = getLoadedChildrenData(node);
|
|
249
253
|
if (childId === undefined || children === undefined) {
|
|
250
254
|
return undefined;
|
|
251
255
|
}
|
|
@@ -270,14 +274,13 @@ export function createTreeNodeApi() {
|
|
|
270
274
|
return undefined;
|
|
271
275
|
}
|
|
272
276
|
/*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
* this is solely an implementation detail.
|
|
277
|
+
* Replace the child at its existing position, preserving sibling order even
|
|
278
|
+
* when the replacement has a different ID.
|
|
276
279
|
*/
|
|
277
280
|
const updatedChildren = [...children];
|
|
278
281
|
updatedChildren[childIndex] = updatedChild;
|
|
279
282
|
/*
|
|
280
|
-
* `
|
|
283
|
+
* `withLoadedChildren` revalidates sibling-ID uniqueness. This matters when
|
|
281
284
|
* `modifier` replaces a node with another node having a different ID.
|
|
282
285
|
*/
|
|
283
286
|
return api.withLoadedChildren(node, updatedChildren);
|