@player-ui/context-plugin 0.16.0--canary.891.38194
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/ContextPlugin.native.js +1407 -0
- package/dist/ContextPlugin.native.js.map +1 -0
- package/dist/cjs/index.cjs +621 -0
- package/dist/cjs/index.cjs.map +1 -0
- package/dist/index.legacy-esm.js +578 -0
- package/dist/index.mjs +578 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +34 -0
- package/src/__tests__/history.test.ts +27 -0
- package/src/__tests__/key.test.ts +22 -0
- package/src/__tests__/plugin.test.ts +376 -0
- package/src/__tests__/state-plugin.test.ts +346 -0
- package/src/__tests__/store.test.ts +107 -0
- package/src/history.ts +25 -0
- package/src/index.ts +6 -0
- package/src/key.ts +35 -0
- package/src/plugin.ts +261 -0
- package/src/state-plugin.ts +286 -0
- package/src/store.ts +205 -0
- package/src/symbols.ts +1 -0
- package/src/types.ts +57 -0
- package/src/utils.ts +17 -0
- package/types/history.d.ts +13 -0
- package/types/index.d.ts +7 -0
- package/types/key.d.ts +20 -0
- package/types/plugin.d.ts +64 -0
- package/types/state-plugin.d.ts +92 -0
- package/types/store.d.ts +26 -0
- package/types/symbols.d.ts +2 -0
- package/types/types.d.ts +45 -0
- package/types/utils.d.ts +7 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { test, expect, vitest } from "vitest";
|
|
2
|
+
import { Player } from "@player-ui/player";
|
|
3
|
+
import type { DataController } from "@player-ui/player";
|
|
4
|
+
import { CommonTypesPlugin } from "@player-ui/common-types-plugin";
|
|
5
|
+
import { ReferenceAssetsPlugin } from "@player-ui/reference-assets-plugin";
|
|
6
|
+
import { ContextPlugin } from "../plugin";
|
|
7
|
+
import { ContextPluginSymbol } from "../symbols";
|
|
8
|
+
import {
|
|
9
|
+
StateContextPlugin,
|
|
10
|
+
dataContextKey,
|
|
11
|
+
flowIdContextKey,
|
|
12
|
+
flowStateContextKey,
|
|
13
|
+
playerStateContextKey,
|
|
14
|
+
playerStatusContextKey,
|
|
15
|
+
setDataActionKey,
|
|
16
|
+
transitionActionKey,
|
|
17
|
+
validationContextKey,
|
|
18
|
+
viewContextKey,
|
|
19
|
+
viewIdContextKey,
|
|
20
|
+
} from "../state-plugin";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A flow with a required `data.name` binding rendered by an input asset.
|
|
24
|
+
* Bindings are tracked by the reference-assets renderer and the `required`
|
|
25
|
+
* validator comes from the common-types plugin, so validation activates on a
|
|
26
|
+
* `change` trigger when the value is cleared.
|
|
27
|
+
*/
|
|
28
|
+
const validationFlow = {
|
|
29
|
+
id: "flow-validation",
|
|
30
|
+
views: [
|
|
31
|
+
{
|
|
32
|
+
id: "view-1",
|
|
33
|
+
type: "input",
|
|
34
|
+
binding: "data.name",
|
|
35
|
+
label: { asset: { id: "label", type: "text", value: "Name" } },
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
data: { name: "Ada" },
|
|
39
|
+
schema: {
|
|
40
|
+
ROOT: { data: { type: "DataType" } },
|
|
41
|
+
DataType: {
|
|
42
|
+
name: {
|
|
43
|
+
type: "StringType",
|
|
44
|
+
validation: [{ type: "required", trigger: "change" }],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
navigation: {
|
|
49
|
+
BEGIN: "FLOW_1",
|
|
50
|
+
FLOW_1: {
|
|
51
|
+
startState: "VIEW_1",
|
|
52
|
+
VIEW_1: {
|
|
53
|
+
ref: "view-1",
|
|
54
|
+
state_type: "VIEW",
|
|
55
|
+
transitions: { "*": "END_Done" },
|
|
56
|
+
},
|
|
57
|
+
END_Done: { state_type: "END", outcome: "done" },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const minimalFlow = {
|
|
63
|
+
id: "flow-state-test",
|
|
64
|
+
views: [{ id: "view-1", type: "info" }],
|
|
65
|
+
data: { name: "Ada" },
|
|
66
|
+
navigation: {
|
|
67
|
+
BEGIN: "FLOW_1",
|
|
68
|
+
FLOW_1: {
|
|
69
|
+
startState: "VIEW_1",
|
|
70
|
+
VIEW_1: {
|
|
71
|
+
ref: "view-1",
|
|
72
|
+
state_type: "VIEW",
|
|
73
|
+
transitions: { "*": "END_Done" },
|
|
74
|
+
},
|
|
75
|
+
END_Done: { state_type: "END", outcome: "done" },
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const multiViewFlow = {
|
|
81
|
+
id: "flow-multi",
|
|
82
|
+
views: [
|
|
83
|
+
{ id: "view-1", type: "info" },
|
|
84
|
+
{ id: "view-2", type: "info" },
|
|
85
|
+
],
|
|
86
|
+
navigation: {
|
|
87
|
+
BEGIN: "FLOW_1",
|
|
88
|
+
FLOW_1: {
|
|
89
|
+
startState: "VIEW_1",
|
|
90
|
+
VIEW_1: {
|
|
91
|
+
ref: "view-1",
|
|
92
|
+
state_type: "VIEW",
|
|
93
|
+
transitions: { Next: "VIEW_2", "*": "END_Done" },
|
|
94
|
+
},
|
|
95
|
+
VIEW_2: {
|
|
96
|
+
ref: "view-2",
|
|
97
|
+
state_type: "VIEW",
|
|
98
|
+
transitions: { "*": "END_Done" },
|
|
99
|
+
},
|
|
100
|
+
END_Done: { state_type: "END", outcome: "done" },
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
test("StateContextPlugin auto-registers a ContextPlugin if absent", () => {
|
|
106
|
+
const state = new StateContextPlugin();
|
|
107
|
+
const player = new Player({ plugins: [state] });
|
|
108
|
+
const ctx = player.findPlugin<ContextPlugin>(ContextPluginSymbol);
|
|
109
|
+
expect(ctx).toBeInstanceOf(ContextPlugin);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("StateContextPlugin reuses an existing ContextPlugin", () => {
|
|
113
|
+
const ctx = new ContextPlugin();
|
|
114
|
+
const state = new StateContextPlugin();
|
|
115
|
+
const player = new Player({ plugins: [ctx, state] });
|
|
116
|
+
expect(player.findPlugin<ContextPlugin>(ContextPluginSymbol)).toBe(ctx);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("publishes flow id, view id, view, data, status on flow start", () => {
|
|
120
|
+
const ctx = new ContextPlugin();
|
|
121
|
+
const state = new StateContextPlugin();
|
|
122
|
+
const player = new Player({ plugins: [ctx, state] });
|
|
123
|
+
|
|
124
|
+
player.start(minimalFlow as any);
|
|
125
|
+
|
|
126
|
+
expect(ctx.get(flowIdContextKey)).toBe("flow-state-test");
|
|
127
|
+
expect(ctx.get(playerStatusContextKey)).toBe("in-progress");
|
|
128
|
+
expect(ctx.get(flowStateContextKey)).toBe("VIEW_1");
|
|
129
|
+
expect(ctx.get(viewIdContextKey)).toBe("view-1");
|
|
130
|
+
expect(ctx.get(viewContextKey)).toMatchObject({
|
|
131
|
+
id: "view-1",
|
|
132
|
+
type: "info",
|
|
133
|
+
});
|
|
134
|
+
expect(ctx.get(dataContextKey)).toEqual({ name: "Ada" });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("data context entry updates when the data controller updates", () => {
|
|
138
|
+
const ctx = new ContextPlugin();
|
|
139
|
+
const state = new StateContextPlugin();
|
|
140
|
+
const player = new Player({ plugins: [ctx, state] });
|
|
141
|
+
|
|
142
|
+
let dataController: DataController | undefined;
|
|
143
|
+
player.hooks.dataController.tap("test", (dc) => {
|
|
144
|
+
dataController = dc;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
player.start(minimalFlow as any);
|
|
148
|
+
dataController!.set([["name", "Grace"]]);
|
|
149
|
+
|
|
150
|
+
expect(ctx.get(dataContextKey)).toEqual({ name: "Grace" });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("list() reports all six state-context descriptors after StateContextPlugin applies", () => {
|
|
154
|
+
const ctx = new ContextPlugin();
|
|
155
|
+
const state = new StateContextPlugin();
|
|
156
|
+
new Player({ plugins: [ctx, state] });
|
|
157
|
+
|
|
158
|
+
const descriptions = ctx.list().map((d) => d.description);
|
|
159
|
+
expect(descriptions).toEqual(
|
|
160
|
+
expect.arrayContaining([
|
|
161
|
+
flowIdContextKey.description,
|
|
162
|
+
flowStateContextKey.description,
|
|
163
|
+
viewIdContextKey.description,
|
|
164
|
+
viewContextKey.description,
|
|
165
|
+
dataContextKey.description,
|
|
166
|
+
playerStatusContextKey.description,
|
|
167
|
+
]),
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("aggregate playerStateContextKey composes every published source", () => {
|
|
172
|
+
const ctx = new ContextPlugin();
|
|
173
|
+
const state = new StateContextPlugin();
|
|
174
|
+
const player = new Player({ plugins: [ctx, state] });
|
|
175
|
+
|
|
176
|
+
player.start(minimalFlow as any);
|
|
177
|
+
|
|
178
|
+
const snapshot = ctx.get(playerStateContextKey);
|
|
179
|
+
expect(snapshot).toMatchObject({
|
|
180
|
+
status: "in-progress",
|
|
181
|
+
flow: { id: "flow-state-test", state: "VIEW_1" },
|
|
182
|
+
view: {
|
|
183
|
+
id: "view-1",
|
|
184
|
+
resolved: expect.objectContaining({ id: "view-1", type: "info" }),
|
|
185
|
+
},
|
|
186
|
+
data: { model: { name: "Ada" } },
|
|
187
|
+
});
|
|
188
|
+
// Actions are scoped to the construct they operate on.
|
|
189
|
+
expect(typeof snapshot!.flow.transition).toBe("function");
|
|
190
|
+
expect(typeof snapshot!.data.set).toBe("function");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("aggregate player.state exposes invokable actions scoped to their constructs", () => {
|
|
194
|
+
const ctx = new ContextPlugin();
|
|
195
|
+
const state = new StateContextPlugin();
|
|
196
|
+
const player = new Player({ plugins: [ctx, state] });
|
|
197
|
+
|
|
198
|
+
player.start(multiViewFlow as any);
|
|
199
|
+
const before = ctx.get(playerStateContextKey)!;
|
|
200
|
+
expect(before.flow.state).toBe("VIEW_1");
|
|
201
|
+
|
|
202
|
+
// The scoped flow.transition action advances the running flow.
|
|
203
|
+
before.flow.transition!("Next");
|
|
204
|
+
expect(ctx.get(flowStateContextKey)).toBe("VIEW_2");
|
|
205
|
+
|
|
206
|
+
// The scoped data.set action drives the data model; reading the aggregate
|
|
207
|
+
// back reflects the write through data.model.
|
|
208
|
+
ctx.get(playerStateContextKey)!.data.set!("name", "Grace");
|
|
209
|
+
expect(ctx.get(playerStateContextKey)!.data.model).toEqual({ name: "Grace" });
|
|
210
|
+
|
|
211
|
+
// Transitioning again drives the flow to its terminal END state.
|
|
212
|
+
ctx.get(playerStateContextKey)!.flow.transition!("Next");
|
|
213
|
+
expect(ctx.get(flowStateContextKey)).toBe("END_Done");
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("aggregate subscribers fire when any source updates", () => {
|
|
217
|
+
const ctx = new ContextPlugin();
|
|
218
|
+
const state = new StateContextPlugin();
|
|
219
|
+
const player = new Player({ plugins: [ctx, state] });
|
|
220
|
+
|
|
221
|
+
let dataController: DataController | undefined;
|
|
222
|
+
player.hooks.dataController.tap("test", (dc) => {
|
|
223
|
+
dataController = dc;
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const handler = vitest.fn();
|
|
227
|
+
ctx.subscribe(playerStateContextKey, handler);
|
|
228
|
+
|
|
229
|
+
player.start(minimalFlow as any);
|
|
230
|
+
handler.mockClear();
|
|
231
|
+
dataController!.set([["name", "Grace"]]);
|
|
232
|
+
|
|
233
|
+
expect(handler).toHaveBeenCalled();
|
|
234
|
+
const lastCall = handler.mock.calls[handler.mock.calls.length - 1];
|
|
235
|
+
expect(lastCall[0]).toMatchObject({ data: { model: { name: "Grace" } } });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("setData action is a function-valued context entry that writes to the data model", () => {
|
|
239
|
+
const ctx = new ContextPlugin();
|
|
240
|
+
const state = new StateContextPlugin();
|
|
241
|
+
const player = new Player({ plugins: [ctx, state] });
|
|
242
|
+
|
|
243
|
+
player.start(minimalFlow as any);
|
|
244
|
+
const setData = ctx.get(setDataActionKey);
|
|
245
|
+
expect(typeof setData).toBe("function");
|
|
246
|
+
setData!("name", "Grace");
|
|
247
|
+
|
|
248
|
+
expect(ctx.get(dataContextKey)).toEqual({ name: "Grace" });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("transition action is a function-valued context entry that advances the flow", () => {
|
|
252
|
+
const ctx = new ContextPlugin();
|
|
253
|
+
const state = new StateContextPlugin();
|
|
254
|
+
const player = new Player({ plugins: [ctx, state] });
|
|
255
|
+
|
|
256
|
+
player.start(multiViewFlow as any);
|
|
257
|
+
expect(ctx.get(flowStateContextKey)).toBe("VIEW_1");
|
|
258
|
+
|
|
259
|
+
ctx.get(transitionActionKey)!("Next");
|
|
260
|
+
|
|
261
|
+
expect(ctx.get(flowStateContextKey)).toBe("VIEW_2");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("actions are absent until their controller instances exist", () => {
|
|
265
|
+
const ctx = new ContextPlugin();
|
|
266
|
+
const state = new StateContextPlugin();
|
|
267
|
+
new Player({ plugins: [ctx, state] });
|
|
268
|
+
|
|
269
|
+
// Before a flow starts there is no data/flow controller bound, so the
|
|
270
|
+
// action entries hold no callable.
|
|
271
|
+
expect(ctx.get(setDataActionKey)).toBeUndefined();
|
|
272
|
+
expect(ctx.get(transitionActionKey)).toBeUndefined();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("action entries are re-bound to the live controllers when a flow starts", () => {
|
|
276
|
+
const ctx = new ContextPlugin();
|
|
277
|
+
const state = new StateContextPlugin();
|
|
278
|
+
const player = new Player({ plugins: [ctx, state] });
|
|
279
|
+
|
|
280
|
+
player.start(minimalFlow as any);
|
|
281
|
+
expect(typeof ctx.get(setDataActionKey)).toBe("function");
|
|
282
|
+
expect(typeof ctx.get(transitionActionKey)).toBe("function");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("validation context is empty and transitionable with no failing validations", () => {
|
|
286
|
+
const ctx = new ContextPlugin();
|
|
287
|
+
const state = new StateContextPlugin();
|
|
288
|
+
const player = new Player({
|
|
289
|
+
plugins: [ctx, state, new CommonTypesPlugin(), new ReferenceAssetsPlugin()],
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
player.start(validationFlow as any);
|
|
293
|
+
|
|
294
|
+
expect(ctx.get(validationContextKey)).toEqual({
|
|
295
|
+
canTransition: true,
|
|
296
|
+
byBinding: {},
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("validation context reflects a failing binding and blocks transition", async () => {
|
|
301
|
+
const ctx = new ContextPlugin();
|
|
302
|
+
const state = new StateContextPlugin();
|
|
303
|
+
const player = new Player({
|
|
304
|
+
plugins: [ctx, state, new CommonTypesPlugin(), new ReferenceAssetsPlugin()],
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
player.start(validationFlow as any);
|
|
308
|
+
const dataController = (player.getState() as any).controllers.data;
|
|
309
|
+
dataController.set([["data.name", ""]]); // required → fails when empty
|
|
310
|
+
|
|
311
|
+
await vitest.waitFor(() => {
|
|
312
|
+
const validation = ctx.get(validationContextKey)!;
|
|
313
|
+
expect(validation.canTransition).toBe(false);
|
|
314
|
+
expect(validation.byBinding["data.name"]?.[0]).toMatchObject({
|
|
315
|
+
severity: "error",
|
|
316
|
+
message: expect.any(String),
|
|
317
|
+
blocking: true,
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// The aggregate surfaces it too.
|
|
322
|
+
expect(ctx.get(playerStateContextKey)!.validation.canTransition).toBe(false);
|
|
323
|
+
|
|
324
|
+
// Fixing the value clears the validation and re-enables transition.
|
|
325
|
+
dataController.set([["data.name", "Ada"]]);
|
|
326
|
+
await vitest.waitFor(() => {
|
|
327
|
+
expect(ctx.get(validationContextKey)).toEqual({
|
|
328
|
+
canTransition: true,
|
|
329
|
+
byBinding: {},
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("status flips to completed after the flow ends", () => {
|
|
335
|
+
const ctx = new ContextPlugin();
|
|
336
|
+
const state = new StateContextPlugin();
|
|
337
|
+
const player = new Player({ plugins: [ctx, state] });
|
|
338
|
+
|
|
339
|
+
player.start(minimalFlow as any);
|
|
340
|
+
player.hooks.state.call({
|
|
341
|
+
status: "completed",
|
|
342
|
+
flow: minimalFlow as any,
|
|
343
|
+
} as any);
|
|
344
|
+
|
|
345
|
+
expect(ctx.get(playerStatusContextKey)).toBe("completed");
|
|
346
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { test, expect } from "vitest";
|
|
2
|
+
import { ContextStore } from "../store";
|
|
3
|
+
import { defineContextKey } from "../key";
|
|
4
|
+
|
|
5
|
+
test("set / get / has literal", () => {
|
|
6
|
+
const store = new ContextStore();
|
|
7
|
+
const key = defineContextKey<number>("count", "A count");
|
|
8
|
+
|
|
9
|
+
expect(store.has(key)).toBe(false);
|
|
10
|
+
store.set(key, 42);
|
|
11
|
+
expect(store.has(key)).toBe(true);
|
|
12
|
+
expect(store.get(key)).toBe(42);
|
|
13
|
+
|
|
14
|
+
const descriptors = store.list();
|
|
15
|
+
expect(descriptors).toHaveLength(1);
|
|
16
|
+
expect(descriptors[0]).toMatchObject({
|
|
17
|
+
description: "A count",
|
|
18
|
+
hasValue: true,
|
|
19
|
+
hasTransform: false,
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("transform aggregates from sources on get", () => {
|
|
24
|
+
const store = new ContextStore();
|
|
25
|
+
const a = defineContextKey<number>("a", "A");
|
|
26
|
+
const b = defineContextKey<number>("b", "B");
|
|
27
|
+
const sum = defineContextKey<number>("sum", "A + B");
|
|
28
|
+
|
|
29
|
+
store.set(a, 2);
|
|
30
|
+
store.set(b, 3);
|
|
31
|
+
store.registerTransform(sum, {
|
|
32
|
+
sources: [a, b],
|
|
33
|
+
compute: (read) => (read(a) ?? 0) + (read(b) ?? 0),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
expect(store.get(sum)).toBe(5);
|
|
37
|
+
expect(store.has(sum)).toBe(true);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("literal overrides transform on get", () => {
|
|
41
|
+
const store = new ContextStore();
|
|
42
|
+
const a = defineContextKey<number>("a", "A");
|
|
43
|
+
const target = defineContextKey<number>("t", "T");
|
|
44
|
+
|
|
45
|
+
store.set(a, 10);
|
|
46
|
+
store.registerTransform(target, {
|
|
47
|
+
sources: [a],
|
|
48
|
+
compute: (read) => (read(a) ?? 0) * 2,
|
|
49
|
+
});
|
|
50
|
+
expect(store.get(target)).toBe(20);
|
|
51
|
+
|
|
52
|
+
store.set(target, 99);
|
|
53
|
+
expect(store.get(target)).toBe(99);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("dependentsOf tracks reverse index from transform sources", () => {
|
|
57
|
+
const store = new ContextStore();
|
|
58
|
+
const src = defineContextKey<number>("src", "src");
|
|
59
|
+
const t1 = defineContextKey<number>("t1", "t1");
|
|
60
|
+
const t2 = defineContextKey<number>("t2", "t2");
|
|
61
|
+
|
|
62
|
+
store.registerTransform(t1, { sources: [src], compute: () => 1 });
|
|
63
|
+
store.registerTransform(t2, { sources: [src], compute: () => 2 });
|
|
64
|
+
|
|
65
|
+
const deps = store.dependentsOf(src.symbol);
|
|
66
|
+
const depSymbols = new Set(deps.map((k) => k.symbol));
|
|
67
|
+
expect(depSymbols.has(t1.symbol)).toBe(true);
|
|
68
|
+
expect(depSymbols.has(t2.symbol)).toBe(true);
|
|
69
|
+
expect(deps).toHaveLength(2);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("re-registering a transform updates the reverse index", () => {
|
|
73
|
+
const store = new ContextStore();
|
|
74
|
+
const oldSrc = defineContextKey<number>("old", "old");
|
|
75
|
+
const newSrc = defineContextKey<number>("new", "new");
|
|
76
|
+
const target = defineContextKey<number>("target", "target");
|
|
77
|
+
|
|
78
|
+
store.registerTransform(target, { sources: [oldSrc], compute: () => 1 });
|
|
79
|
+
store.registerTransform(target, { sources: [newSrc], compute: () => 2 });
|
|
80
|
+
|
|
81
|
+
expect(store.dependentsOf(oldSrc.symbol)).toHaveLength(0);
|
|
82
|
+
expect(store.dependentsOf(newSrc.symbol)).toHaveLength(1);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("freeze captures literal and transform-computed values, deep-freezes the snapshot", () => {
|
|
86
|
+
const store = new ContextStore();
|
|
87
|
+
const a = defineContextKey<number>("a", "A");
|
|
88
|
+
const doubled = defineContextKey<number>("doubled", "A doubled");
|
|
89
|
+
|
|
90
|
+
store.set(a, 7);
|
|
91
|
+
store.registerTransform(doubled, {
|
|
92
|
+
sources: [a],
|
|
93
|
+
compute: (read) => (read(a) ?? 0) * 2,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
const snapshot = store.freeze({ endedAt: 1000 });
|
|
97
|
+
expect(snapshot.endedAt).toBe(1000);
|
|
98
|
+
expect(snapshot.entries).toHaveLength(2);
|
|
99
|
+
const byDesc = Object.fromEntries(
|
|
100
|
+
snapshot.entries.map((e) => [e.description, e.value]),
|
|
101
|
+
);
|
|
102
|
+
expect(byDesc).toEqual({ A: 7, "A doubled": 14 });
|
|
103
|
+
|
|
104
|
+
expect(Object.isFrozen(snapshot)).toBe(true);
|
|
105
|
+
expect(Object.isFrozen(snapshot.entries)).toBe(true);
|
|
106
|
+
expect(Object.isFrozen(snapshot.entries[0])).toBe(true);
|
|
107
|
+
});
|
package/src/history.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { FrozenContextSnapshot } from "./types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Append-only stack of frozen per-flow snapshots. The plugin pushes one
|
|
5
|
+
* snapshot on each flow end; consumers read via `entries()`.
|
|
6
|
+
*/
|
|
7
|
+
export class ContextHistory {
|
|
8
|
+
private stack: FrozenContextSnapshot[] = [];
|
|
9
|
+
|
|
10
|
+
push(snapshot: FrozenContextSnapshot): void {
|
|
11
|
+
this.stack.push(snapshot);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
entries(): ReadonlyArray<FrozenContextSnapshot> {
|
|
15
|
+
return this.stack;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
size(): number {
|
|
19
|
+
return this.stack.length;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
clear(): void {
|
|
23
|
+
this.stack = [];
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/index.ts
ADDED
package/src/key.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ContextKey } from "./types";
|
|
2
|
+
|
|
3
|
+
const KEY_NAMESPACE = "player-ui.context.";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create a typed, globally-identifiable context key.
|
|
7
|
+
*
|
|
8
|
+
* Identity is backed by `Symbol.for`, so two keys created with the same `name`
|
|
9
|
+
* in different bundles refer to the same store entry. The `description` is the
|
|
10
|
+
* human-readable label used by introspection consumers.
|
|
11
|
+
*/
|
|
12
|
+
export const defineContextKey = <Value>(
|
|
13
|
+
name: string,
|
|
14
|
+
description: string,
|
|
15
|
+
): ContextKey<Value> => ({
|
|
16
|
+
symbol: Symbol.for(`${KEY_NAMESPACE}${name}`),
|
|
17
|
+
description,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve the global symbol for a context key name. Used by native wrappers
|
|
22
|
+
* that cross the JS bridge with string names instead of JS symbols.
|
|
23
|
+
*/
|
|
24
|
+
export const resolveContextKeySymbol = (name: string): symbol =>
|
|
25
|
+
Symbol.for(`${KEY_NAMESPACE}${name}`);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Reverse-derive the name from a key created via `defineContextKey`. Returns
|
|
29
|
+
* `undefined` if the key's symbol was not created in the context namespace.
|
|
30
|
+
*/
|
|
31
|
+
export const nameOfContextKey = (key: ContextKey): string | undefined => {
|
|
32
|
+
const k = Symbol.keyFor(key.symbol);
|
|
33
|
+
if (!k || !k.startsWith(KEY_NAMESPACE)) return undefined;
|
|
34
|
+
return k.slice(KEY_NAMESPACE.length);
|
|
35
|
+
};
|