@cosmicdrift/kumiko-framework 0.133.0 → 0.135.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/package.json +2 -2
- package/src/engine/__tests__/boot-validator-dashboard.test.ts +136 -2
- package/src/engine/__tests__/boot-validator-located-timestamps.test.ts +3 -19
- package/src/engine/__tests__/factories-time.test.ts +2 -66
- package/src/engine/__tests__/{visual-tree-patterns.test.ts → tree-actions-patterns.test.ts} +4 -84
- package/src/engine/boot-validator/entity-handler.ts +5 -4
- package/src/engine/boot-validator/screens-nav.ts +112 -16
- package/src/engine/define-feature.ts +3 -17
- package/src/engine/factories.ts +2 -49
- package/src/engine/feature-ast/extractors/index.ts +1 -4
- package/src/engine/feature-ast/extractors/round6.ts +2 -36
- package/src/engine/feature-ast/parse.ts +1 -4
- package/src/engine/feature-ast/patch.ts +2 -5
- package/src/engine/feature-ast/patterns.ts +0 -14
- package/src/engine/feature-ast/render.ts +1 -9
- package/src/engine/index.ts +0 -1
- package/src/engine/pattern-library/__tests__/library.test.ts +0 -3
- package/src/engine/pattern-library/library.ts +0 -19
- package/src/engine/registry.ts +6 -16
- package/src/engine/types/feature.ts +3 -33
- package/src/engine/types/fields.ts +5 -4
- package/src/engine/types/index.ts +5 -0
- package/src/engine/types/screen.ts +64 -1
- package/src/engine/types/workspace.ts +0 -7
- package/src/errors/classes.ts +1 -1
- package/src/errors/field-issue.ts +0 -3
- package/src/errors/index.ts +0 -1
- package/src/i18n/required-surface-keys.ts +29 -10
- package/src/observability/__tests__/recording-tracer.test.ts +6 -4
- package/src/observability/noop-provider.ts +0 -9
- package/src/observability/recording-tracer.ts +0 -12
- package/src/observability/types/span.ts +0 -6
- package/src/time/tz-context.ts +1 -1
- package/src/ui-types/index.ts +5 -0
- package/src/bun-db/__tests__/bun-test-stack.ts +0 -6
- package/src/db/row-helpers.ts +0 -4
- package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +0 -184
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.135.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -189,7 +189,7 @@
|
|
|
189
189
|
"zod": "^4.4.3"
|
|
190
190
|
},
|
|
191
191
|
"devDependencies": {
|
|
192
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
192
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.135.0",
|
|
193
193
|
"bun-types": "^1.3.13",
|
|
194
194
|
"pino-pretty": "^13.1.3"
|
|
195
195
|
},
|
|
@@ -12,15 +12,25 @@ const STAT_PANEL = {
|
|
|
12
12
|
valueField: "count",
|
|
13
13
|
} as const;
|
|
14
14
|
|
|
15
|
-
function dashboardFeature(
|
|
15
|
+
function dashboardFeature(
|
|
16
|
+
panels: DashboardScreenDefinition["panels"],
|
|
17
|
+
filter?: DashboardScreenDefinition["filter"],
|
|
18
|
+
) {
|
|
16
19
|
return defineFeature("demo", (r) => {
|
|
17
|
-
r.screen({
|
|
20
|
+
r.screen({
|
|
21
|
+
id: "overview",
|
|
22
|
+
type: "dashboard",
|
|
23
|
+
panels,
|
|
24
|
+
...(filter !== undefined && { filter }),
|
|
25
|
+
});
|
|
18
26
|
r.translations({
|
|
19
27
|
keys: {
|
|
20
28
|
"screen:overview.title": { de: "Übersicht", en: "Overview" },
|
|
21
29
|
"demo:dashboard:panel:open-incidents": { de: "Offene Vorfälle", en: "Open incidents" },
|
|
22
30
|
"demo:dashboard:panel:latest": { de: "Neueste", en: "Latest" },
|
|
23
31
|
"demo:dashboard:col:name": { de: "Name", en: "Name" },
|
|
32
|
+
"demo:dashboard:group:net-worth": { de: "Net Worth", en: "Net Worth" },
|
|
33
|
+
"demo:dashboard:filter:region": { de: "Region", en: "Region" },
|
|
24
34
|
},
|
|
25
35
|
});
|
|
26
36
|
});
|
|
@@ -90,4 +100,128 @@ describe("validateBoot — dashboard screens", () => {
|
|
|
90
100
|
expect(keys).toContain("demo:dashboard:panel:latest");
|
|
91
101
|
expect(keys).toContain("demo:dashboard:col:name");
|
|
92
102
|
});
|
|
103
|
+
|
|
104
|
+
test("accepts a stat-group, feed, progress-list and custom panel", () => {
|
|
105
|
+
const feature = dashboardFeature(
|
|
106
|
+
[
|
|
107
|
+
{
|
|
108
|
+
kind: "stat-group",
|
|
109
|
+
id: "net-worth",
|
|
110
|
+
label: "demo:dashboard:group:net-worth",
|
|
111
|
+
stats: [STAT_PANEL],
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
kind: "feed",
|
|
115
|
+
id: "upcoming",
|
|
116
|
+
label: "demo:dashboard:panel:latest",
|
|
117
|
+
query: "demo:query:incident:latest",
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
kind: "progress-list",
|
|
121
|
+
id: "progress",
|
|
122
|
+
label: "demo:dashboard:panel:latest",
|
|
123
|
+
query: "demo:query:incident:latest",
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
kind: "custom",
|
|
127
|
+
id: "custom-panel",
|
|
128
|
+
component: { react: { __component: "demo-custom" } },
|
|
129
|
+
},
|
|
130
|
+
],
|
|
131
|
+
{
|
|
132
|
+
id: "region",
|
|
133
|
+
label: "demo:dashboard:filter:region",
|
|
134
|
+
kind: "select",
|
|
135
|
+
options: [{ value: "eu", label: "demo:dashboard:filter:region" }],
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("rejects a stat-group with an empty stats list", () => {
|
|
142
|
+
const feature = dashboardFeature([
|
|
143
|
+
{ kind: "stat-group", id: "net-worth", label: "demo:dashboard:group:net-worth", stats: [] },
|
|
144
|
+
]);
|
|
145
|
+
expect(() => validateBoot([feature])).toThrow(/empty stats list/);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("rejects a duplicate id nested inside a stat-group", () => {
|
|
149
|
+
const feature = dashboardFeature([
|
|
150
|
+
STAT_PANEL,
|
|
151
|
+
{
|
|
152
|
+
kind: "stat-group",
|
|
153
|
+
id: "net-worth",
|
|
154
|
+
label: "demo:dashboard:group:net-worth",
|
|
155
|
+
stats: [STAT_PANEL],
|
|
156
|
+
},
|
|
157
|
+
]);
|
|
158
|
+
expect(() => validateBoot([feature])).toThrow(/duplicate panel id/);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("rejects a custom panel without a react/native component", () => {
|
|
162
|
+
const feature = dashboardFeature([{ kind: "custom", id: "custom-panel", component: {} }]);
|
|
163
|
+
expect(() => validateBoot([feature])).toThrow(/has no component/);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("rejects a filter that sets neither options nor optionsQuery", () => {
|
|
167
|
+
const feature = dashboardFeature([STAT_PANEL], {
|
|
168
|
+
id: "region",
|
|
169
|
+
label: "demo:dashboard:filter:region",
|
|
170
|
+
kind: "select",
|
|
171
|
+
});
|
|
172
|
+
expect(() => validateBoot([feature])).toThrow(/exactly one of/);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("rejects a filter that sets both options and optionsQuery", () => {
|
|
176
|
+
const feature = dashboardFeature([STAT_PANEL], {
|
|
177
|
+
id: "region",
|
|
178
|
+
label: "demo:dashboard:filter:region",
|
|
179
|
+
kind: "select",
|
|
180
|
+
options: [{ value: "eu", label: "demo:dashboard:filter:region" }],
|
|
181
|
+
optionsQuery: "demo:query:folder:list",
|
|
182
|
+
});
|
|
183
|
+
expect(() => validateBoot([feature])).toThrow(/exactly one of/);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("rejects a filter with an empty options list", () => {
|
|
187
|
+
const feature = dashboardFeature([STAT_PANEL], {
|
|
188
|
+
id: "region",
|
|
189
|
+
label: "demo:dashboard:filter:region",
|
|
190
|
+
kind: "select",
|
|
191
|
+
options: [],
|
|
192
|
+
});
|
|
193
|
+
expect(() => validateBoot([feature])).toThrow(/filter.options is empty/);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("requiredKeysFromScreen sammelt stat-group-Kinder- und Filter-Labels, aber keine custom-Panel-Keys", () => {
|
|
197
|
+
const screen: DashboardScreenDefinition = {
|
|
198
|
+
id: "overview",
|
|
199
|
+
type: "dashboard",
|
|
200
|
+
filter: {
|
|
201
|
+
id: "region",
|
|
202
|
+
label: "demo:dashboard:filter:region",
|
|
203
|
+
kind: "select",
|
|
204
|
+
options: [{ value: "eu", label: "demo:dashboard:col:name" }],
|
|
205
|
+
},
|
|
206
|
+
panels: [
|
|
207
|
+
{
|
|
208
|
+
kind: "stat-group",
|
|
209
|
+
id: "net-worth",
|
|
210
|
+
label: "demo:dashboard:group:net-worth",
|
|
211
|
+
stats: [STAT_PANEL],
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
kind: "custom",
|
|
215
|
+
id: "custom-panel",
|
|
216
|
+
component: { react: { __component: "demo-custom" } },
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
};
|
|
220
|
+
const keys = requiredKeysFromScreen("demo", screen);
|
|
221
|
+
expect(keys).toContain("demo:dashboard:group:net-worth");
|
|
222
|
+
expect(keys).toContain("demo:dashboard:panel:open-incidents");
|
|
223
|
+
expect(keys).toContain("demo:dashboard:filter:region");
|
|
224
|
+
expect(keys).toContain("demo:dashboard:col:name");
|
|
225
|
+
expect(keys).not.toContain("custom-panel");
|
|
226
|
+
});
|
|
93
227
|
});
|
|
@@ -7,25 +7,9 @@
|
|
|
7
7
|
import { describe, expect, test } from "bun:test";
|
|
8
8
|
import { validateBoot } from "../boot-validator";
|
|
9
9
|
import { defineFeature } from "../define-feature";
|
|
10
|
-
import { createEntity, createTimestampField, createTzField
|
|
10
|
+
import { createEntity, createTimestampField, createTzField } from "../factories";
|
|
11
11
|
|
|
12
12
|
describe("validateBoot — locatedBy markers", () => {
|
|
13
|
-
test("locatedTimestamp(name) Helper-Pair passiert validiert (positive case)", () => {
|
|
14
|
-
const feature = defineFeature("test", (r) => {
|
|
15
|
-
r.entity(
|
|
16
|
-
"order",
|
|
17
|
-
createEntity({
|
|
18
|
-
fields: {
|
|
19
|
-
...locatedTimestamp("pickup"),
|
|
20
|
-
...locatedTimestamp("delivery"),
|
|
21
|
-
},
|
|
22
|
-
}),
|
|
23
|
-
);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
expect(() => validateBoot([feature])).not.toThrow();
|
|
27
|
-
});
|
|
28
|
-
|
|
29
13
|
test("manuelle Konstruktion mit korrektem Pair passiert (positive case)", () => {
|
|
30
14
|
const feature = defineFeature("test", (r) => {
|
|
31
15
|
r.entity(
|
|
@@ -75,7 +59,7 @@ describe("validateBoot — locatedBy markers", () => {
|
|
|
75
59
|
expect(() => validateBoot([feature])).toThrow(/expected "tz"/);
|
|
76
60
|
});
|
|
77
61
|
|
|
78
|
-
test("Fehlermeldung verweist auf
|
|
62
|
+
test("Fehlermeldung verweist auf createLocatedTimestampField als Fix", () => {
|
|
79
63
|
const feature = defineFeature("test", (r) => {
|
|
80
64
|
r.entity(
|
|
81
65
|
"order",
|
|
@@ -87,7 +71,7 @@ describe("validateBoot — locatedBy markers", () => {
|
|
|
87
71
|
);
|
|
88
72
|
});
|
|
89
73
|
|
|
90
|
-
expect(() => validateBoot([feature])).toThrow(/
|
|
74
|
+
expect(() => validateBoot([feature])).toThrow(/createLocatedTimestampField/);
|
|
91
75
|
});
|
|
92
76
|
|
|
93
77
|
test("Timestamp ohne locatedBy ist OK (reiner UTC-Instant)", () => {
|
|
@@ -1,17 +1,12 @@
|
|
|
1
1
|
// Unit-Tests für die neuen Time-Field-Factories
|
|
2
|
-
// (createTimestampField, createTzField,
|
|
2
|
+
// (createTimestampField, createTzField, createLocatedTimestampField).
|
|
3
3
|
//
|
|
4
4
|
// Test-Fokus: korrektes Field-Shape + locatedBy-Marker-Verdrahtung. Die
|
|
5
5
|
// echte TZ-Konvertierung (Wall-Clock ↔ UTC) testen wir später beim
|
|
6
6
|
// DB-Wrapper-Schritt.
|
|
7
7
|
|
|
8
8
|
import { describe, expect, test } from "bun:test";
|
|
9
|
-
import {
|
|
10
|
-
createLocatedTimestampField,
|
|
11
|
-
createTimestampField,
|
|
12
|
-
createTzField,
|
|
13
|
-
locatedTimestamp,
|
|
14
|
-
} from "../factories";
|
|
9
|
+
import { createLocatedTimestampField, createTimestampField, createTzField } from "../factories";
|
|
15
10
|
|
|
16
11
|
describe("createTimestampField", () => {
|
|
17
12
|
test("default-Form ist nicht-required UTC-Instant ohne locatedBy", () => {
|
|
@@ -57,65 +52,6 @@ describe("createTzField", () => {
|
|
|
57
52
|
});
|
|
58
53
|
});
|
|
59
54
|
|
|
60
|
-
describe("locatedTimestamp(name) Helper", () => {
|
|
61
|
-
test("erzeugt korrektes Pair aus <name>At + <name>Tz mit locatedBy-Verdrahtung", () => {
|
|
62
|
-
const fields = locatedTimestamp("pickup");
|
|
63
|
-
expect(fields).toEqual({
|
|
64
|
-
pickupAt: { type: "timestamp", locatedBy: "pickupTz" },
|
|
65
|
-
pickupTz: { type: "tz" },
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
test("required-Override propagiert auf BEIDE Felder", () => {
|
|
70
|
-
const fields = locatedTimestamp("delivery", { required: true });
|
|
71
|
-
expect(fields).toEqual({
|
|
72
|
-
deliveryAt: { type: "timestamp", locatedBy: "deliveryTz", required: true },
|
|
73
|
-
deliveryTz: { type: "tz", required: true },
|
|
74
|
-
});
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
test("access-Override propagiert auf BEIDE Felder (Field-Level Read-Access)", () => {
|
|
78
|
-
const fields = locatedTimestamp("internal", {
|
|
79
|
-
access: { read: ["Dispatcher"] },
|
|
80
|
-
});
|
|
81
|
-
expect(fields).toEqual({
|
|
82
|
-
internalAt: {
|
|
83
|
-
type: "timestamp",
|
|
84
|
-
locatedBy: "internalTz",
|
|
85
|
-
access: { read: ["Dispatcher"] },
|
|
86
|
-
},
|
|
87
|
-
internalTz: { type: "tz", access: { read: ["Dispatcher"] } },
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test("locatedBy-Marker zeigt immer auf das EIGENE Tz-Feld (nicht auf einen anderen Namen)", () => {
|
|
92
|
-
// Das ist der Kern des Patterns — wenn die zwei Felder nicht
|
|
93
|
-
// konsistent verdrahtet sind, fliegt der Boot-Validator (kommt in
|
|
94
|
-
// späterer Iteration). Hier prüfen wir die Helper-Garantie.
|
|
95
|
-
for (const name of ["a", "x_y", "long_field_name"]) {
|
|
96
|
-
const fields = locatedTimestamp(name);
|
|
97
|
-
const at = fields[`${name}At`];
|
|
98
|
-
if (at?.type !== "timestamp") throw new Error("at field missing");
|
|
99
|
-
expect(at.locatedBy).toBe(`${name}Tz`);
|
|
100
|
-
}
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
test("Spread in createEntity-fields Kompositions-tauglich", () => {
|
|
104
|
-
// Realer Use-Case: pickup + delivery in einer Entity, plus normale Felder.
|
|
105
|
-
const entityFields = {
|
|
106
|
-
...locatedTimestamp("pickup"),
|
|
107
|
-
...locatedTimestamp("delivery"),
|
|
108
|
-
// Kein Konflikt zwischen den beiden Pairs.
|
|
109
|
-
};
|
|
110
|
-
expect(Object.keys(entityFields).sort()).toEqual([
|
|
111
|
-
"deliveryAt",
|
|
112
|
-
"deliveryTz",
|
|
113
|
-
"pickupAt",
|
|
114
|
-
"pickupTz",
|
|
115
|
-
]);
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
|
|
119
55
|
describe("createLocatedTimestampField (Phase A — atomarer Field-Type)", () => {
|
|
120
56
|
test("default-Form ist nicht-required mit type 'locatedTimestamp'", () => {
|
|
121
57
|
expect(createLocatedTimestampField()).toEqual({
|
|
@@ -1,15 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { buildTarget, createRegistry, defineFeature } from "../index";
|
|
3
|
-
import type { TreeChildrenSubscribe, TreeNode } from "../types/tree-node";
|
|
4
|
-
|
|
5
|
-
// Stub-Provider für Tests. Form: (ctx) => (emit) => unsubscribe.
|
|
6
|
-
// Emittet einmal initial, kein Cleanup nötig (no-op unsubscribe).
|
|
7
|
-
function makeStubProvider(nodes: readonly TreeNode[]): TreeChildrenSubscribe {
|
|
8
|
-
return () => (emit) => {
|
|
9
|
-
emit(nodes);
|
|
10
|
-
return () => {};
|
|
11
|
-
};
|
|
12
|
-
}
|
|
13
3
|
|
|
14
4
|
describe("r.treeActions — registrar slot", () => {
|
|
15
5
|
test("feature without r.treeActions leaves the slot undefined", () => {
|
|
@@ -63,80 +53,12 @@ describe("r.treeActions — registrar slot", () => {
|
|
|
63
53
|
});
|
|
64
54
|
});
|
|
65
55
|
|
|
66
|
-
describe("
|
|
67
|
-
test("
|
|
68
|
-
const feature = defineFeature("empty", () => {});
|
|
69
|
-
expect(feature.treeProvider).toBeUndefined();
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test("single r.tree call stores the provider function on the FeatureDefinition", () => {
|
|
73
|
-
const provider = makeStubProvider([{ label: "Marketing" }]);
|
|
74
|
-
const feature = defineFeature("text-content", (r) => {
|
|
75
|
-
r.tree(provider);
|
|
76
|
-
});
|
|
77
|
-
expect(feature.treeProvider).toBe(provider);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test("second r.tree call throws — only-once-guard", () => {
|
|
81
|
-
expect(() =>
|
|
82
|
-
defineFeature("dupe", (r) => {
|
|
83
|
-
r.tree(makeStubProvider([]));
|
|
84
|
-
r.tree(makeStubProvider([]));
|
|
85
|
-
}),
|
|
86
|
-
).toThrow(/r\.tree\(\) already called/);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test("treeActions and tree are independent slots — can declare one without the other", () => {
|
|
90
|
-
const onlyActions = defineFeature("a", (r) => {
|
|
91
|
-
r.treeActions({ list: {} });
|
|
92
|
-
});
|
|
93
|
-
expect(onlyActions.treeActions).toBeDefined();
|
|
94
|
-
expect(onlyActions.treeProvider).toBeUndefined();
|
|
95
|
-
|
|
96
|
-
const onlyProvider = defineFeature("b", (r) => {
|
|
97
|
-
r.tree(makeStubProvider([]));
|
|
98
|
-
});
|
|
99
|
-
expect(onlyProvider.treeActions).toBeUndefined();
|
|
100
|
-
expect(onlyProvider.treeProvider).toBeDefined();
|
|
101
|
-
});
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
describe("Registry.getTreeProviders + getTreeActions", () => {
|
|
105
|
-
test("empty registry returns empty providers map and undefined actions", () => {
|
|
56
|
+
describe("Registry.getTreeActions", () => {
|
|
57
|
+
test("empty registry returns undefined actions", () => {
|
|
106
58
|
const reg = createRegistry([]);
|
|
107
|
-
expect(reg.getTreeProviders().size).toBe(0);
|
|
108
59
|
expect(reg.getTreeActions("nonexistent")).toBeUndefined();
|
|
109
60
|
});
|
|
110
61
|
|
|
111
|
-
test("aggregates providers from multiple features keyed by feature name", () => {
|
|
112
|
-
const providerA = makeStubProvider([{ label: "A-root" }]);
|
|
113
|
-
const providerB = makeStubProvider([{ label: "B-root" }]);
|
|
114
|
-
const featureA = defineFeature("text-content", (r) => {
|
|
115
|
-
r.tree(providerA);
|
|
116
|
-
});
|
|
117
|
-
const featureB = defineFeature("legal-pages", (r) => {
|
|
118
|
-
r.tree(providerB);
|
|
119
|
-
});
|
|
120
|
-
const reg = createRegistry([featureA, featureB]);
|
|
121
|
-
|
|
122
|
-
const providers = reg.getTreeProviders();
|
|
123
|
-
expect(providers.size).toBe(2);
|
|
124
|
-
expect(providers.get("text-content")).toBe(providerA);
|
|
125
|
-
expect(providers.get("legal-pages")).toBe(providerB);
|
|
126
|
-
});
|
|
127
|
-
|
|
128
|
-
test("features without r.tree are absent from the providers map (Zero-Whitelist-Filter)", () => {
|
|
129
|
-
const featureWithProvider = defineFeature("text-content", (r) => {
|
|
130
|
-
r.tree(makeStubProvider([]));
|
|
131
|
-
});
|
|
132
|
-
const featureWithoutProvider = defineFeature("schema-editor", () => {});
|
|
133
|
-
const reg = createRegistry([featureWithProvider, featureWithoutProvider]);
|
|
134
|
-
|
|
135
|
-
const providers = reg.getTreeProviders();
|
|
136
|
-
expect(providers.has("text-content")).toBe(true);
|
|
137
|
-
expect(providers.has("schema-editor")).toBe(false);
|
|
138
|
-
});
|
|
139
|
-
|
|
140
62
|
test("getTreeActions returns the erased map for a feature that declared r.treeActions", () => {
|
|
141
63
|
const feature = defineFeature("text-content", (r) => {
|
|
142
64
|
r.treeActions({
|
|
@@ -153,9 +75,7 @@ describe("Registry.getTreeProviders + getTreeActions", () => {
|
|
|
153
75
|
});
|
|
154
76
|
|
|
155
77
|
test("getTreeActions returns undefined for a feature without r.treeActions", () => {
|
|
156
|
-
const feature = defineFeature("no-actions", (
|
|
157
|
-
r.tree(makeStubProvider([]));
|
|
158
|
-
});
|
|
78
|
+
const feature = defineFeature("no-actions", () => {});
|
|
159
79
|
const reg = createRegistry([feature]);
|
|
160
80
|
|
|
161
81
|
expect(reg.getTreeActions("no-actions")).toBeUndefined();
|
|
@@ -169,7 +89,7 @@ describe("Registry.getTreeProviders + getTreeActions", () => {
|
|
|
169
89
|
// returnt einen typed Handle, der via setup-export durch FeatureDefinition
|
|
170
90
|
// fließt und compile-time-typisiert vom Schicht-1-buildTarget konsumiert
|
|
171
91
|
// wird. Ohne diese Tests würde ein Type-Drift in einer der zwei Schichten
|
|
172
|
-
// erst
|
|
92
|
+
// erst spät sichtbar — siehe advisor-Verdict + Memory `[EventDef-
|
|
173
93
|
// Exports-Pattern]`.
|
|
174
94
|
// =============================================================================
|
|
175
95
|
|
|
@@ -178,8 +178,9 @@ export function validateMultiStreamProjections(feature: FeatureDefinition): void
|
|
|
178
178
|
//
|
|
179
179
|
// Die häufigste Quelle von Konflikten ist Hand-Konstruktion:
|
|
180
180
|
// { foo: { type: "timestamp", locatedBy: "fooTz" } }
|
|
181
|
-
// ohne das `fooTz`-Feld zu deklarieren.
|
|
182
|
-
//
|
|
181
|
+
// ohne das `fooTz`-Feld zu deklarieren. `createLocatedTimestampField()`
|
|
182
|
+
// erzeugt stattdessen EIN Feld vom Typ "locatedTimestamp" — wer den nutzt,
|
|
183
|
+
// fliegt nicht durch diesen Validator.
|
|
183
184
|
export function validateLocatedTimestamps(feature: FeatureDefinition): void {
|
|
184
185
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
185
186
|
const fields = entity.fields;
|
|
@@ -190,8 +191,8 @@ export function validateLocatedTimestamps(feature: FeatureDefinition): void {
|
|
|
190
191
|
throw new Error(
|
|
191
192
|
`Feature "${feature.name}", entity "${entityName}": field "${fieldName}" has ` +
|
|
192
193
|
`locatedBy: "${field.locatedBy}" but no field with that name exists in the entity. ` +
|
|
193
|
-
`Either declare the tz-field, or use
|
|
194
|
-
`to create
|
|
194
|
+
`Either declare the tz-field, or use createLocatedTimestampField() ` +
|
|
195
|
+
`to create a single located-timestamp field instead.`,
|
|
195
196
|
);
|
|
196
197
|
}
|
|
197
198
|
if (referenced.type !== "tz") {
|
|
@@ -4,7 +4,11 @@ import { qualifyEntityName } from "../qualified-name";
|
|
|
4
4
|
import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
|
|
5
5
|
import type { FeatureDefinition, NavDefinition, WorkspaceDefinition } from "../types";
|
|
6
6
|
import type {
|
|
7
|
+
DashboardCustomPanel,
|
|
8
|
+
DashboardFilterDefinition,
|
|
9
|
+
DashboardPanelDefinition,
|
|
7
10
|
DashboardScreenDefinition,
|
|
11
|
+
DashboardStatGroupPanel,
|
|
8
12
|
FieldCondition,
|
|
9
13
|
RowAction,
|
|
10
14
|
RowFieldExtractor,
|
|
@@ -655,36 +659,128 @@ function validateDashboardScreen(
|
|
|
655
659
|
);
|
|
656
660
|
}
|
|
657
661
|
const panelIds = new Set<string>();
|
|
662
|
+
const addPanelId = (id: string, context: string): void => {
|
|
663
|
+
if (panelIds.has(id)) {
|
|
664
|
+
throw new Error(
|
|
665
|
+
`[Feature ${featureName}] Screen "${screenId}" (dashboard) has duplicate panel id "${id}" (${context}).`,
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
panelIds.add(id);
|
|
669
|
+
};
|
|
670
|
+
|
|
658
671
|
for (const panel of screen.panels) {
|
|
659
|
-
|
|
672
|
+
addPanelId(panel.id, panel.kind);
|
|
673
|
+
if (panel.kind === "stat-group") {
|
|
674
|
+
validateDashboardStatGroupPanel(featureName, screenId, panel, addPanelId);
|
|
675
|
+
} else if (panel.kind === "custom") {
|
|
676
|
+
validateDashboardCustomPanel(featureName, screenId, panel);
|
|
677
|
+
} else {
|
|
678
|
+
validateDashboardQueryPanel(featureName, screenId, panel);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
if (screen.filter !== undefined) {
|
|
683
|
+
validateDashboardFilterDefinition(featureName, screenId, screen.filter);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function validateDashboardStatGroupPanel(
|
|
688
|
+
featureName: string,
|
|
689
|
+
screenId: string,
|
|
690
|
+
panel: DashboardStatGroupPanel,
|
|
691
|
+
addPanelId: (id: string, context: string) => void,
|
|
692
|
+
): void {
|
|
693
|
+
if (panel.stats.length === 0) {
|
|
694
|
+
throw new Error(
|
|
695
|
+
`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}" has an empty stats list.`,
|
|
696
|
+
);
|
|
697
|
+
}
|
|
698
|
+
for (const stat of panel.stats) {
|
|
699
|
+
addPanelId(stat.id, "stat-group child");
|
|
700
|
+
if (!stat.query || typeof stat.query !== "string") {
|
|
660
701
|
throw new Error(
|
|
661
|
-
`[Feature ${featureName}] Screen "${screenId}" (dashboard)
|
|
702
|
+
`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}" child "${stat.id}" has empty or non-string query.`,
|
|
662
703
|
);
|
|
663
704
|
}
|
|
664
|
-
|
|
665
|
-
if (!panel.query || typeof panel.query !== "string") {
|
|
705
|
+
if (stat.valueField.length === 0) {
|
|
666
706
|
throw new Error(
|
|
667
|
-
`[Feature ${featureName}] Screen "${screenId}" (dashboard)
|
|
707
|
+
`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-group "${panel.id}" child "${stat.id}" has empty valueField.`,
|
|
668
708
|
);
|
|
669
709
|
}
|
|
670
|
-
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function validateDashboardCustomPanel(
|
|
714
|
+
featureName: string,
|
|
715
|
+
screenId: string,
|
|
716
|
+
panel: DashboardCustomPanel,
|
|
717
|
+
): void {
|
|
718
|
+
if (panel.component.react === undefined && panel.component.native === undefined) {
|
|
719
|
+
throw new Error(
|
|
720
|
+
`[Feature ${featureName}] Screen "${screenId}" (dashboard) custom-panel "${panel.id}" has no component — ` +
|
|
721
|
+
`declare a react/native component marker.`,
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function validateDashboardQueryPanel(
|
|
727
|
+
featureName: string,
|
|
728
|
+
screenId: string,
|
|
729
|
+
panel: Exclude<DashboardPanelDefinition, DashboardStatGroupPanel | DashboardCustomPanel>,
|
|
730
|
+
): void {
|
|
731
|
+
if (!panel.query || typeof panel.query !== "string") {
|
|
732
|
+
throw new Error(
|
|
733
|
+
`[Feature ${featureName}] Screen "${screenId}" (dashboard) panel "${panel.id}" has empty or non-string query.`,
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
if (panel.kind === "stat" && panel.valueField.length === 0) {
|
|
737
|
+
throw new Error(
|
|
738
|
+
`[Feature ${featureName}] Screen "${screenId}" (dashboard) stat-panel "${panel.id}" has empty valueField.`,
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
if (panel.kind === "list") {
|
|
742
|
+
if (panel.columns.length === 0) {
|
|
671
743
|
throw new Error(
|
|
672
|
-
`[Feature ${featureName}] Screen "${screenId}" (dashboard)
|
|
744
|
+
`[Feature ${featureName}] Screen "${screenId}" (dashboard) list-panel "${panel.id}" has an empty columns list.`,
|
|
673
745
|
);
|
|
674
746
|
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
throw new Error(
|
|
678
|
-
`[Feature ${featureName}] Screen "${screenId}" (dashboard) list-panel "${panel.id}" has an empty columns list.`,
|
|
679
|
-
);
|
|
680
|
-
}
|
|
681
|
-
for (const col of panel.columns) {
|
|
682
|
-
validateColumnRendererForm(featureName, screenId, normalizeListColumn(col));
|
|
683
|
-
}
|
|
747
|
+
for (const col of panel.columns) {
|
|
748
|
+
validateColumnRendererForm(featureName, screenId, normalizeListColumn(col));
|
|
684
749
|
}
|
|
685
750
|
}
|
|
686
751
|
}
|
|
687
752
|
|
|
753
|
+
function validateDashboardFilterDefinition(
|
|
754
|
+
featureName: string,
|
|
755
|
+
screenId: string,
|
|
756
|
+
filter: DashboardFilterDefinition,
|
|
757
|
+
): void {
|
|
758
|
+
if (filter.id.length === 0) {
|
|
759
|
+
throw new Error(
|
|
760
|
+
`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter has an empty id.`,
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
if (filter.label.length === 0) {
|
|
764
|
+
throw new Error(
|
|
765
|
+
`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter has an empty label.`,
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
const hasOptions = filter.options !== undefined;
|
|
769
|
+
const hasOptionsQuery = filter.optionsQuery !== undefined;
|
|
770
|
+
if (hasOptions === hasOptionsQuery) {
|
|
771
|
+
throw new Error(
|
|
772
|
+
`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter must set exactly one of ` +
|
|
773
|
+
`options/optionsQuery.`,
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
if (hasOptions && (filter.options?.length ?? 0) === 0) {
|
|
777
|
+
throw new Error(
|
|
778
|
+
`[Feature ${featureName}] Screen "${screenId}" (dashboard) filter.options is empty — ` +
|
|
779
|
+
`declare at least one option or use optionsQuery instead.`,
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
688
784
|
// Form-check für ListColumn-Renderer in der PlatformComponent-Form
|
|
689
785
|
// (`{ react: { __component: "Name" } }`). Der Server kennt die client-
|
|
690
786
|
// seitige columnRenderers-Map nicht — also nur prüfen ob die Struktur
|
|
@@ -64,7 +64,6 @@ import type {
|
|
|
64
64
|
TranslationsDef,
|
|
65
65
|
TreeActionDef,
|
|
66
66
|
TreeActionsHandle,
|
|
67
|
-
TreeChildrenSubscribe,
|
|
68
67
|
UiHints,
|
|
69
68
|
UnmanagedTableEntry,
|
|
70
69
|
UnmanagedTableOptions,
|
|
@@ -163,14 +162,13 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
163
162
|
let toggleableDefault: boolean | undefined;
|
|
164
163
|
let description: string | undefined;
|
|
165
164
|
let uiHints: UiHints | undefined;
|
|
166
|
-
//
|
|
167
|
-
// registrar (siehe r.treeActions
|
|
168
|
-
//
|
|
165
|
+
// Tree-Action-Slot — at-most-one per feature, only-once-guard im
|
|
166
|
+
// registrar (siehe r.treeActions). Undefined wenn das Feature keine
|
|
167
|
+
// Tree-Actions liefert (Zero-Whitelist-Filter).
|
|
169
168
|
// Name-Collision-Sicherheit: object-literal-method-Names im registrar
|
|
170
169
|
// sind keine bindings im closure-scope, daher kollidiert die `treeActions`
|
|
171
170
|
// closure-let-var nicht mit der `treeActions(...)` registrar-Methode.
|
|
172
171
|
let treeActions: Readonly<Record<string, TreeActionDef>> | undefined;
|
|
173
|
-
let treeProvider: TreeChildrenSubscribe | undefined;
|
|
174
172
|
// Optional Zod-schema for env-vars this feature reads at runtime,
|
|
175
173
|
// declared via r.envSchema(). At-most-one per feature.
|
|
176
174
|
let envSchema: z.ZodObject<z.ZodRawShape> | undefined;
|
|
@@ -969,17 +967,6 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
969
967
|
treeActions: actions,
|
|
970
968
|
});
|
|
971
969
|
},
|
|
972
|
-
|
|
973
|
-
tree(provider: TreeChildrenSubscribe): void {
|
|
974
|
-
// Only-once-guard analog zu r.treeActions.
|
|
975
|
-
if (treeProvider !== undefined) {
|
|
976
|
-
throw new Error(
|
|
977
|
-
`[Feature ${name}] r.tree() already called. ` +
|
|
978
|
-
`Each feature may declare a single tree-provider.`,
|
|
979
|
-
);
|
|
980
|
-
}
|
|
981
|
-
treeProvider = provider;
|
|
982
|
-
},
|
|
983
970
|
};
|
|
984
971
|
|
|
985
972
|
const exports = setup(registrar) as TExports; // @cast-boundary engine-bridge
|
|
@@ -1049,7 +1036,6 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
1049
1036
|
rawTables,
|
|
1050
1037
|
unmanagedTables,
|
|
1051
1038
|
...(treeActions !== undefined && { treeActions }),
|
|
1052
|
-
...(treeProvider !== undefined && { treeProvider }),
|
|
1053
1039
|
...(envSchema !== undefined && { envSchema }),
|
|
1054
1040
|
};
|
|
1055
1041
|
}
|