dirvi-lib 0.11.0-beta.1 → 0.11.0-beta.13

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.
Files changed (164) hide show
  1. package/dist/app/app-methods.d.ts +53 -0
  2. package/dist/app/app-methods.d.ts.map +1 -0
  3. package/dist/app/index.d.ts +1 -1
  4. package/dist/app/index.d.ts.map +1 -1
  5. package/dist/app/index.js +1 -1
  6. package/dist/app/reducer/effect-to-action.d.ts +2 -2
  7. package/dist/app/reducer/effect-to-action.d.ts.map +1 -1
  8. package/dist/app/reducer/effect-to-action.js +87 -32
  9. package/dist/app/reducer/reducer-action.d.ts +12 -6
  10. package/dist/app/reducer/reducer-action.d.ts.map +1 -1
  11. package/dist/app/reducer/reducer.d.ts +4 -2
  12. package/dist/app/reducer/reducer.d.ts.map +1 -1
  13. package/dist/app/reducer/reducer.js +106 -72
  14. package/dist/feedback/feedback.impl.d.ts.map +1 -1
  15. package/dist/feedback/feedback.types.d.ts +1 -1
  16. package/dist/feedback/feedback.types.d.ts.map +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/input/application/intent-to-effect.d.ts +2 -2
  21. package/dist/input/application/intent-to-effect.d.ts.map +1 -1
  22. package/dist/input/application/intent-to-effect.js +10 -11
  23. package/dist/input/application/parse-ex-command.d.ts +17 -0
  24. package/dist/input/application/parse-ex-command.d.ts.map +1 -0
  25. package/dist/input/application/{parse-command.js → parse-ex-command.js} +84 -54
  26. package/dist/input/application/parse-normal-command.d.ts +1 -1
  27. package/dist/input/application/parse-normal-command.d.ts.map +1 -1
  28. package/dist/input/domain/effect.d.ts +20 -9
  29. package/dist/input/domain/effect.d.ts.map +1 -1
  30. package/dist/state-and-nav/cursor.d.ts +89 -0
  31. package/dist/state-and-nav/cursor.d.ts.map +1 -0
  32. package/dist/state-and-nav/cursor.js +84 -0
  33. package/dist/{trees → state-and-nav}/fold-node/deprecated/fold-node.impl.d.ts +1 -1
  34. package/dist/state-and-nav/fold-node/deprecated/fold-node.impl.d.ts.map +1 -0
  35. package/dist/state-and-nav/fold-node/deprecated/fold-node.test.d.ts.map +1 -0
  36. package/dist/{trees → state-and-nav}/fold-node/deprecated/fold-node.test.js +2 -2
  37. package/dist/{trees → state-and-nav}/fold-node/deprecated/fold-node.types.d.ts +2 -2
  38. package/dist/state-and-nav/fold-node/deprecated/fold-node.types.d.ts.map +1 -0
  39. package/dist/{trees → state-and-nav}/fold-node/fold-node.impl.d.ts +3 -3
  40. package/dist/state-and-nav/fold-node/fold-node.impl.d.ts.map +1 -0
  41. package/dist/{trees → state-and-nav}/fold-node/fold-node.impl.js +2 -2
  42. package/dist/{trees → state-and-nav}/fold-node/fold-node.model.d.ts +1 -1
  43. package/dist/state-and-nav/fold-node/fold-node.model.d.ts.map +1 -0
  44. package/dist/{trees → state-and-nav}/fold-node/fold-node.types.d.ts +2 -2
  45. package/dist/state-and-nav/fold-node/fold-node.types.d.ts.map +1 -0
  46. package/dist/state-and-nav/fold-node/index.d.ts.map +1 -0
  47. package/dist/state-and-nav/index.d.ts +16 -0
  48. package/dist/state-and-nav/index.d.ts.map +1 -0
  49. package/dist/state-and-nav/index.js +6 -0
  50. package/dist/state-and-nav/nav/nav.ordered.impl.d.ts +12 -0
  51. package/dist/state-and-nav/nav/nav.ordered.impl.d.ts.map +1 -0
  52. package/dist/state-and-nav/nav/nav.ordered.impl.js +77 -0
  53. package/dist/state-and-nav/nav/nav.traversal.d.ts +11 -0
  54. package/dist/state-and-nav/nav/nav.traversal.d.ts.map +1 -0
  55. package/dist/state-and-nav/nav/nav.traversal.js +204 -0
  56. package/dist/{trees/nav-node/nav-node.types.d.ts → state-and-nav/nav/nav.types.d.ts} +37 -23
  57. package/dist/state-and-nav/nav/nav.types.d.ts.map +1 -0
  58. package/dist/state-and-nav/nav/nav.unordered.impl.d.ts +13 -0
  59. package/dist/state-and-nav/nav/nav.unordered.impl.d.ts.map +1 -0
  60. package/dist/state-and-nav/nav/nav.unordered.impl.js +77 -0
  61. package/dist/state-and-nav/state/state.impl.d.ts +9 -0
  62. package/dist/state-and-nav/state/state.impl.d.ts.map +1 -0
  63. package/dist/{trees → state-and-nav}/state/state.impl.js +62 -9
  64. package/dist/state-and-nav/state/state.schemas.d.ts +2 -0
  65. package/dist/state-and-nav/state/state.schemas.d.ts.map +1 -0
  66. package/dist/state-and-nav/state/state.schemas.js +39 -0
  67. package/dist/state-and-nav/state/state.types.d.ts +72 -0
  68. package/dist/state-and-nav/state/state.types.d.ts.map +1 -0
  69. package/dist/state-and-nav/tree/deprecated/tree-node.arb.d.ts.map +1 -0
  70. package/dist/state-and-nav/tree/deprecated/tree-node.arb.debug.test.d.ts.map +1 -0
  71. package/dist/state-and-nav/tree/deprecated/tree-node.impl.d.ts.map +1 -0
  72. package/dist/state-and-nav/tree/deprecated/tree-node.schemas.d.ts.map +1 -0
  73. package/dist/state-and-nav/tree/deprecated/tree-node.test.d.ts.map +1 -0
  74. package/dist/state-and-nav/tree/deprecated/tree-node.types.d.ts.map +1 -0
  75. package/dist/state-and-nav/tree/index.d.ts +7 -0
  76. package/dist/state-and-nav/tree/index.d.ts.map +1 -0
  77. package/dist/state-and-nav/tree/index.js +1 -0
  78. package/dist/state-and-nav/tree/tree-node.arb.d.ts +94 -0
  79. package/dist/state-and-nav/tree/tree-node.arb.d.ts.map +1 -0
  80. package/dist/state-and-nav/tree/tree-node.arb.debug.test.d.ts +2 -0
  81. package/dist/state-and-nav/tree/tree-node.arb.debug.test.d.ts.map +1 -0
  82. package/dist/state-and-nav/tree/tree-node.arb.debug.test.js +54 -0
  83. package/dist/state-and-nav/tree/tree-node.arb.js +188 -0
  84. package/dist/state-and-nav/tree/tree-node.model.d.ts +194 -0
  85. package/dist/state-and-nav/tree/tree-node.model.d.ts.map +1 -0
  86. package/dist/state-and-nav/tree/tree-node.model.js +110 -0
  87. package/dist/state-and-nav/tree/tree-node.test.d.ts +2 -0
  88. package/dist/state-and-nav/tree/tree-node.test.d.ts.map +1 -0
  89. package/dist/state-and-nav/tree/tree-node.test.js +234 -0
  90. package/dist/{trees/tree-node/tree-node.impl.d.ts → state-and-nav/tree/tree.impl.d.ts} +3 -3
  91. package/dist/state-and-nav/tree/tree.impl.d.ts.map +1 -0
  92. package/dist/{trees/tree-node/tree-node.impl.js → state-and-nav/tree/tree.impl.js} +20 -17
  93. package/dist/{trees/tree-node/tree-node.types.d.ts → state-and-nav/tree/tree.types.d.ts} +67 -47
  94. package/dist/state-and-nav/tree/tree.types.d.ts.map +1 -0
  95. package/dist/state-and-nav/tree/tree.types.js +1 -0
  96. package/package.json +3 -2
  97. package/dist/app/app-api.d.ts +0 -74
  98. package/dist/app/app-api.d.ts.map +0 -1
  99. package/dist/app/app-api.js +0 -11
  100. package/dist/input/application/parse-command.d.ts +0 -14
  101. package/dist/input/application/parse-command.d.ts.map +0 -1
  102. package/dist/trees/cursor.d.ts +0 -59
  103. package/dist/trees/cursor.d.ts.map +0 -1
  104. package/dist/trees/cursor.js +0 -55
  105. package/dist/trees/fold-node/deprecated/fold-node.impl.d.ts.map +0 -1
  106. package/dist/trees/fold-node/deprecated/fold-node.test.d.ts.map +0 -1
  107. package/dist/trees/fold-node/deprecated/fold-node.types.d.ts.map +0 -1
  108. package/dist/trees/fold-node/fold-node.impl.d.ts.map +0 -1
  109. package/dist/trees/fold-node/fold-node.model.d.ts.map +0 -1
  110. package/dist/trees/fold-node/fold-node.types.d.ts.map +0 -1
  111. package/dist/trees/fold-node/index.d.ts.map +0 -1
  112. package/dist/trees/index.d.ts +0 -9
  113. package/dist/trees/index.d.ts.map +0 -1
  114. package/dist/trees/index.js +0 -8
  115. package/dist/trees/nav-node/nav-node.impl.d.ts +0 -29
  116. package/dist/trees/nav-node/nav-node.impl.d.ts.map +0 -1
  117. package/dist/trees/nav-node/nav-node.impl.js +0 -223
  118. package/dist/trees/nav-node/nav-node.types.d.ts.map +0 -1
  119. package/dist/trees/state/state.impl.d.ts +0 -8
  120. package/dist/trees/state/state.impl.d.ts.map +0 -1
  121. package/dist/trees/state/state.schemas.d.ts +0 -25
  122. package/dist/trees/state/state.schemas.d.ts.map +0 -1
  123. package/dist/trees/state/state.schemas.js +0 -17
  124. package/dist/trees/state/state.types.d.ts +0 -36
  125. package/dist/trees/state/state.types.d.ts.map +0 -1
  126. package/dist/trees/tree-node/deprecated/tree-node.arb.d.ts.map +0 -1
  127. package/dist/trees/tree-node/deprecated/tree-node.arb.debug.test.d.ts.map +0 -1
  128. package/dist/trees/tree-node/deprecated/tree-node.impl.d.ts.map +0 -1
  129. package/dist/trees/tree-node/deprecated/tree-node.schemas.d.ts.map +0 -1
  130. package/dist/trees/tree-node/deprecated/tree-node.test.d.ts.map +0 -1
  131. package/dist/trees/tree-node/deprecated/tree-node.types.d.ts.map +0 -1
  132. package/dist/trees/tree-node/index.d.ts +0 -5
  133. package/dist/trees/tree-node/index.d.ts.map +0 -1
  134. package/dist/trees/tree-node/index.js +0 -2
  135. package/dist/trees/tree-node/tree-node.arb.d.ts +0 -104
  136. package/dist/trees/tree-node/tree-node.arb.d.ts.map +0 -1
  137. package/dist/trees/tree-node/tree-node.arb.js +0 -188
  138. package/dist/trees/tree-node/tree-node.impl.d.ts.map +0 -1
  139. package/dist/trees/tree-node/tree-node.model.d.ts +0 -78
  140. package/dist/trees/tree-node/tree-node.model.d.ts.map +0 -1
  141. package/dist/trees/tree-node/tree-node.model.js +0 -37
  142. package/dist/trees/tree-node/tree-node.types.d.ts.map +0 -1
  143. /package/dist/{trees/fold-node/deprecated/fold-node.types.js → app/app-methods.js} +0 -0
  144. /package/dist/{trees → state-and-nav}/fold-node/deprecated/fold-node.impl.js +0 -0
  145. /package/dist/{trees → state-and-nav}/fold-node/deprecated/fold-node.test.d.ts +0 -0
  146. /package/dist/{trees/fold-node → state-and-nav/fold-node/deprecated}/fold-node.types.js +0 -0
  147. /package/dist/{trees → state-and-nav}/fold-node/fold-node.model.js +0 -0
  148. /package/dist/{trees/nav-node/nav-node.types.js → state-and-nav/fold-node/fold-node.types.js} +0 -0
  149. /package/dist/{trees → state-and-nav}/fold-node/index.d.ts +0 -0
  150. /package/dist/{trees → state-and-nav}/fold-node/index.js +0 -0
  151. /package/dist/{trees/state/state.types.js → state-and-nav/nav/nav.types.js} +0 -0
  152. /package/dist/{trees/tree-node/deprecated/tree-node.types.js → state-and-nav/state/state.types.js} +0 -0
  153. /package/dist/{trees/tree-node → state-and-nav/tree}/deprecated/tree-node.arb.d.ts +0 -0
  154. /package/dist/{trees/tree-node → state-and-nav/tree}/deprecated/tree-node.arb.debug.test.d.ts +0 -0
  155. /package/dist/{trees/tree-node → state-and-nav/tree}/deprecated/tree-node.arb.debug.test.js +0 -0
  156. /package/dist/{trees/tree-node → state-and-nav/tree}/deprecated/tree-node.arb.js +0 -0
  157. /package/dist/{trees/tree-node → state-and-nav/tree}/deprecated/tree-node.impl.d.ts +0 -0
  158. /package/dist/{trees/tree-node → state-and-nav/tree}/deprecated/tree-node.impl.js +0 -0
  159. /package/dist/{trees/tree-node → state-and-nav/tree}/deprecated/tree-node.schemas.d.ts +0 -0
  160. /package/dist/{trees/tree-node → state-and-nav/tree}/deprecated/tree-node.schemas.js +0 -0
  161. /package/dist/{trees/tree-node → state-and-nav/tree}/deprecated/tree-node.test.d.ts +0 -0
  162. /package/dist/{trees/tree-node → state-and-nav/tree}/deprecated/tree-node.test.js +0 -0
  163. /package/dist/{trees/tree-node → state-and-nav/tree}/deprecated/tree-node.types.d.ts +0 -0
  164. /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,2 @@
1
+ export {};
2
+ //# sourceMappingURL=tree-node.test.d.ts.map
@@ -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 { TreeNodeApi } from './tree-node.types.js';
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 createTreeNodeApi<Id extends SerializableKey, Value>(): TreeNodeApi<Id, Value>;
15
- //# sourceMappingURL=tree-node.impl.d.ts.map
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 without exposing the internal
27
- * child-array representation.
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 = [...children];
48
+ const copiedChildren = [];
49
49
  const seenIds = new Set();
50
- for (const child of copiedChildren) {
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 createTreeNodeApi() {
91
- function getOpenChildren(node) {
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
- match(node, handlers) {
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 = getOpenChildren(node);
150
+ const children = getLoadedChildrenData(node);
147
151
  return children === undefined ? undefined : asChildrenIterable(children);
148
152
  },
149
153
  findChildById(node, id) {
150
- const children = getOpenChildren(node);
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 = getOpenChildren(node);
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
- * The current representation happens to use an array, so replacing one
274
- * child is efficient internally. The public API makes no ordering promise:
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
- * `withChildren` revalidates sibling-ID uniqueness. This matters when
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);