@cosmicdrift/kumiko-framework 0.184.0 → 0.185.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 +3 -3
- package/src/db/__tests__/compound-types.test.ts +2 -2
- package/src/db/__tests__/money.test.ts +30 -14
- package/src/db/money.ts +6 -3
- package/src/engine/__tests__/boot-validator.test.ts +112 -16
- package/src/engine/__tests__/engine.test.ts +183 -0
- package/src/engine/__tests__/schema-builder.test.ts +218 -0
- package/src/engine/boot-validator/entity-handler.ts +247 -65
- package/src/engine/boot-validator/index.ts +1 -1
- package/src/engine/embedded-derived.ts +51 -0
- package/src/engine/schema-builder.ts +81 -6
- package/src/engine/types/index.ts +1 -0
- package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +34 -0
- package/src/pipeline/dispatch-shared.ts +19 -14
- package/src/ui-types/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.185.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>",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"./package.json": "./package.json"
|
|
183
183
|
},
|
|
184
184
|
"dependencies": {
|
|
185
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.185.0",
|
|
186
186
|
"bullmq": "^5.76.7",
|
|
187
187
|
"bun-types": "^1.3.13",
|
|
188
188
|
"hono": "^4.12.27",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"zod": "^4.4.3"
|
|
199
199
|
},
|
|
200
200
|
"devDependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.185.0",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
|
@@ -85,7 +85,7 @@ describe("rehydrateCompoundTypes — Pipeline", () => {
|
|
|
85
85
|
label: "ACME",
|
|
86
86
|
pickup: { at: "2026-04-15T10:00:00", tz: "Europe/Lisbon", utc: "2026-04-15T09:00:00Z" },
|
|
87
87
|
// rehydrateMoney converts minor units (DB) → major units (API, ÷100).
|
|
88
|
-
buyingPrice: { amount: 45_000, currency: "EUR" },
|
|
88
|
+
buyingPrice: { amount: 45_000, currency: "EUR", amountMinor: 4_500_000 },
|
|
89
89
|
});
|
|
90
90
|
});
|
|
91
91
|
|
|
@@ -98,7 +98,7 @@ describe("rehydrateCompoundTypes — Pipeline", () => {
|
|
|
98
98
|
const round = rehydrateCompoundTypes(flattenCompoundTypes(original, mixedEntity), mixedEntity);
|
|
99
99
|
// pickup bekommt utc dazu beim Read (war beim Insert nicht gesetzt)
|
|
100
100
|
expect((round["pickup"] as { utc: string }).utc).toBe("2026-04-15T09:00:00Z");
|
|
101
|
-
expect(round["buyingPrice"]).toEqual({ amount: 100, currency: "EUR" });
|
|
101
|
+
expect(round["buyingPrice"]).toEqual({ amount: 100, currency: "EUR", amountMinor: 10_000 });
|
|
102
102
|
expect(round["label"]).toBe("ACME");
|
|
103
103
|
});
|
|
104
104
|
|
|
@@ -99,20 +99,33 @@ describe("flattenMoney — Insert/Update Convert (major units → minor units)",
|
|
|
99
99
|
});
|
|
100
100
|
|
|
101
101
|
describe("rehydrateMoney — Read Convert (minor units → major units)", () => {
|
|
102
|
-
test("{ <name>: minorUnits, <name>Currency: string } → { <name>: { amount: majorUnits, currency } }", () => {
|
|
102
|
+
test("{ <name>: minorUnits, <name>Currency: string } → { <name>: { amount: majorUnits, currency, amountMinor } }", () => {
|
|
103
103
|
const out = rehydrateMoney({ buyingPrice: 45000, buyingPriceCurrency: "EUR" }, orderEntity);
|
|
104
|
-
expect(out).toEqual({ buyingPrice: { amount: 450, currency: "EUR" } });
|
|
104
|
+
expect(out).toEqual({ buyingPrice: { amount: 450, currency: "EUR", amountMinor: 45000 } });
|
|
105
105
|
});
|
|
106
106
|
|
|
107
107
|
test("PG-BIGINT als String wird zu number gecastet", () => {
|
|
108
108
|
// Postgres-driver liefert BIGINT manchmal als String (>2^53 sicher).
|
|
109
109
|
const out = rehydrateMoney({ buyingPrice: "45000", buyingPriceCurrency: "EUR" }, orderEntity);
|
|
110
|
-
expect(out["buyingPrice"]).toEqual({ amount: 450, currency: "EUR" });
|
|
110
|
+
expect(out["buyingPrice"]).toEqual({ amount: 450, currency: "EUR", amountMinor: 45000 });
|
|
111
111
|
});
|
|
112
112
|
|
|
113
113
|
test("fehlende Currency-Spalte fällt auf entity.defaultCurrency", () => {
|
|
114
114
|
const out = rehydrateMoney({ buyingPrice: 45000 }, orderEntity);
|
|
115
|
-
expect(out["buyingPrice"]).toEqual({ amount: 450, currency: "EUR" });
|
|
115
|
+
expect(out["buyingPrice"]).toEqual({ amount: 450, currency: "EUR", amountMinor: 45000 });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("amountMinor bleibt exakter Integer, amount kann Float-Drift haben (fw#1830)", () => {
|
|
119
|
+
const rows = [10, 20, 30].map(
|
|
120
|
+
(minor) =>
|
|
121
|
+
rehydrateMoney({ buyingPrice: minor, buyingPriceCurrency: "EUR" }, orderEntity)[
|
|
122
|
+
"buyingPrice"
|
|
123
|
+
] as { amount: number; amountMinor: number },
|
|
124
|
+
);
|
|
125
|
+
const [a, b, c] = rows;
|
|
126
|
+
|
|
127
|
+
expect(a!.amountMinor + b!.amountMinor).toBe(c!.amountMinor);
|
|
128
|
+
expect(a!.amount + b!.amount).not.toBe(c!.amount);
|
|
116
129
|
});
|
|
117
130
|
|
|
118
131
|
test("null/undefined amount → Field wird aus Output entfernt", () => {
|
|
@@ -131,25 +144,28 @@ describe("rehydrateMoney — Read Convert (minor units → major units)", () =>
|
|
|
131
144
|
orderEntity,
|
|
132
145
|
);
|
|
133
146
|
expect(out).toEqual({
|
|
134
|
-
buyingPrice: { amount: 450, currency: "EUR" },
|
|
135
|
-
sellingPrice: { amount: 600, currency: "USD" },
|
|
147
|
+
buyingPrice: { amount: 450, currency: "EUR", amountMinor: 45000 },
|
|
148
|
+
sellingPrice: { amount: 600, currency: "USD", amountMinor: 60000 },
|
|
136
149
|
});
|
|
137
150
|
});
|
|
138
151
|
|
|
139
|
-
test("Round-Trip: flatten dann rehydrate ergibt dasselbe,
|
|
152
|
+
test("Round-Trip: flatten dann rehydrate ergibt dasselbe amount/currency, plus amountMinor", () => {
|
|
140
153
|
const original = {
|
|
141
154
|
buyingPrice: { amount: 450.5, currency: "EUR" },
|
|
142
155
|
sellingPrice: { amount: 56799.16, currency: "USD" },
|
|
143
156
|
};
|
|
144
157
|
const flat = flattenMoney(original, orderEntity);
|
|
145
158
|
const rehydrated = rehydrateMoney(flat, orderEntity);
|
|
146
|
-
expect(rehydrated).toEqual(
|
|
159
|
+
expect(rehydrated).toEqual({
|
|
160
|
+
buyingPrice: { amount: 450.5, currency: "EUR", amountMinor: 45050 },
|
|
161
|
+
sellingPrice: { amount: 56799.16, currency: "USD", amountMinor: 5679916 },
|
|
162
|
+
});
|
|
147
163
|
});
|
|
148
164
|
|
|
149
|
-
test("Round-Trip primitive-Insert: flatten(450) → rehydrate → { amount:450, currency:EUR }", () => {
|
|
165
|
+
test("Round-Trip primitive-Insert: flatten(450) → rehydrate → { amount:450, currency:EUR, amountMinor:45000 }", () => {
|
|
150
166
|
const flat = flattenMoney({ buyingPrice: 450 }, orderEntity);
|
|
151
167
|
const out = rehydrateMoney(flat, orderEntity);
|
|
152
|
-
expect(out["buyingPrice"]).toEqual({ amount: 450, currency: "EUR" });
|
|
168
|
+
expect(out["buyingPrice"]).toEqual({ amount: 450, currency: "EUR", amountMinor: 45000 });
|
|
153
169
|
});
|
|
154
170
|
|
|
155
171
|
test("ist pure — input wird nicht mutiert", () => {
|
|
@@ -181,7 +197,7 @@ describe("Round-Trip im Update-Pfad (Helper-Verkettung wie im Executor)", () =>
|
|
|
181
197
|
|
|
182
198
|
// DB liefert dieselben Spalten zurück
|
|
183
199
|
const out = rehydrateMoney(flat, orderEntity);
|
|
184
|
-
expect(out).toEqual({ buyingPrice: { amount: 990, currency: "USD" } });
|
|
200
|
+
expect(out).toEqual({ buyingPrice: { amount: 990, currency: "USD", amountMinor: 99_000 } });
|
|
185
201
|
});
|
|
186
202
|
|
|
187
203
|
test("List-Pfad: mehrere Rows hintereinander rehydraten", () => {
|
|
@@ -192,9 +208,9 @@ describe("Round-Trip im Update-Pfad (Helper-Verkettung wie im Executor)", () =>
|
|
|
192
208
|
];
|
|
193
209
|
const apiRows = dbRows.map((r) => rehydrateMoney(r, orderEntity));
|
|
194
210
|
expect(apiRows).toEqual([
|
|
195
|
-
{ buyingPrice: { amount: 1, currency: "EUR" } },
|
|
196
|
-
{ buyingPrice: { amount: 2, currency: "USD" } },
|
|
197
|
-
{ buyingPrice: { amount: 3, currency: "GBP" } },
|
|
211
|
+
{ buyingPrice: { amount: 1, currency: "EUR", amountMinor: 100 } },
|
|
212
|
+
{ buyingPrice: { amount: 2, currency: "USD", amountMinor: 200 } },
|
|
213
|
+
{ buyingPrice: { amount: 3, currency: "GBP", amountMinor: 300 } },
|
|
198
214
|
]);
|
|
199
215
|
});
|
|
200
216
|
});
|
package/src/db/money.ts
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
// Vertrag (siehe auch db/located-timestamp.ts — gleicher Compound-Type-Pattern):
|
|
4
4
|
// API-Form: { amount, currency } | number — amount in MAJOR units (56799.16 EUR)
|
|
5
5
|
// DB-Form: <name> BIGINT (minor units, e.g. cents) + <name>Currency TEXT
|
|
6
|
-
// Read-Form: { amount, currency } — amount in MAJOR units again
|
|
6
|
+
// Read-Form: { amount, currency, amountMinor } — amount in MAJOR units again,
|
|
7
|
+
// amountMinor sits alongside as exact integer cents (fw#1830) for
|
|
8
|
+
// callers that need cent-exact comparisons (e.g. invoice sums)
|
|
9
|
+
// instead of round-tripping the float amount through /100·*100.
|
|
7
10
|
//
|
|
8
11
|
// table-builder.ts's moneyAmount column has always documented BIGINT as
|
|
9
12
|
// "the integer minor unit" — this file used to just pass the API amount
|
|
@@ -33,7 +36,7 @@ const FRAMEWORK_DEFAULT_CURRENCY = DEFAULT_CURRENCIES[0]; // "EUR"
|
|
|
33
36
|
// upgrade path once a currency needing a different scale actually lands.
|
|
34
37
|
const MINOR_UNIT_SCALE = 100;
|
|
35
38
|
|
|
36
|
-
function toMinorUnits(amount: number): number {
|
|
39
|
+
export function toMinorUnits(amount: number): number {
|
|
37
40
|
return Math.round(amount * MINOR_UNIT_SCALE);
|
|
38
41
|
}
|
|
39
42
|
|
|
@@ -151,7 +154,7 @@ export function rehydrateMoney(
|
|
|
151
154
|
const currency =
|
|
152
155
|
typeof currencyRaw === "string" && currencyRaw !== "" ? currencyRaw : fallbackCurrency;
|
|
153
156
|
|
|
154
|
-
result[name] = { amount: toMajorUnits(amountMinor), currency };
|
|
157
|
+
result[name] = { amount: toMajorUnits(amountMinor), currency, amountMinor };
|
|
155
158
|
}
|
|
156
159
|
|
|
157
160
|
return result;
|
|
@@ -7,6 +7,7 @@ import { validateBoot as validateBootRaw } from "../boot-validator";
|
|
|
7
7
|
import { createSystemConfig, createTenantConfig } from "../config-helpers";
|
|
8
8
|
import {
|
|
9
9
|
createDerivedField,
|
|
10
|
+
createEmbeddedListField,
|
|
10
11
|
createEntity,
|
|
11
12
|
createMultiSelectField,
|
|
12
13
|
createTextField,
|
|
@@ -19,6 +20,23 @@ function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
|
|
|
19
20
|
validateBootRaw(withBootValidatorFixture(features));
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
// Registriert einen Stub-Query-Handler `<entity>:list` damit der Boot-
|
|
24
|
+
// Validator den Audit-Fix-#2-Check (Handler-Existenz auf der target-
|
|
25
|
+
// Entity) durch lässt — used by both top-level and embedded-list
|
|
26
|
+
// reference-field tests.
|
|
27
|
+
function stubListHandler(
|
|
28
|
+
// biome-ignore lint/suspicious/noExplicitAny: Registrar-Typ ist generisch, hier reicht das.
|
|
29
|
+
r: any,
|
|
30
|
+
entityName: string,
|
|
31
|
+
): void {
|
|
32
|
+
r.queryHandler({
|
|
33
|
+
name: `${entityName}:list`,
|
|
34
|
+
schema: z.object({}),
|
|
35
|
+
handler: async () => ({ rows: [], nextCursor: null }) as never,
|
|
36
|
+
access: { openToAll: true },
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
22
40
|
describe("boot-validator", () => {
|
|
23
41
|
test("passes for valid features with no issues", () => {
|
|
24
42
|
const features = [
|
|
@@ -2030,22 +2048,6 @@ describe("boot-validator", () => {
|
|
|
2030
2048
|
|
|
2031
2049
|
// --- Tier 2.7e-3: ReferenceFieldDef ---
|
|
2032
2050
|
describe("reference field (Tier 2.7e-3)", () => {
|
|
2033
|
-
// Helper: registriert einen Stub-Query-Handler `<entity>:list`
|
|
2034
|
-
// damit der Boot-Validator den Audit-Fix-#2-Check (Handler-
|
|
2035
|
-
// Existenz auf der target-Entity) durch lässt.
|
|
2036
|
-
function stubListHandler(
|
|
2037
|
-
// biome-ignore lint/suspicious/noExplicitAny: Registrar-Typ ist generisch, hier reicht das.
|
|
2038
|
-
r: any,
|
|
2039
|
-
entityName: string,
|
|
2040
|
-
): void {
|
|
2041
|
-
r.queryHandler({
|
|
2042
|
-
name: `${entityName}:list`,
|
|
2043
|
-
schema: z.object({}),
|
|
2044
|
-
handler: async () => ({ rows: [], nextCursor: null }) as never,
|
|
2045
|
-
access: { openToAll: true },
|
|
2046
|
-
});
|
|
2047
|
-
}
|
|
2048
|
-
|
|
2049
2051
|
test("reference auf bestehende Entity → kein Throw", () => {
|
|
2050
2052
|
const features = [
|
|
2051
2053
|
defineFeature("shop", (r) => {
|
|
@@ -2269,6 +2271,100 @@ describe("boot-validator", () => {
|
|
|
2269
2271
|
});
|
|
2270
2272
|
});
|
|
2271
2273
|
|
|
2274
|
+
// --- fw#1839: reference sub-fields of an embedded-list field get the
|
|
2275
|
+
// same target/labelField/query-handler checks as a top-level reference
|
|
2276
|
+
// field, so a bad "lines.productId" fails at boot instead of crashing
|
|
2277
|
+
// the Combobox at runtime.
|
|
2278
|
+
describe("embedded-list reference sub-field (fw#1839)", () => {
|
|
2279
|
+
test("reference sub-field targeting an unknown entity → Throw with the parent.child field path", () => {
|
|
2280
|
+
const features = [
|
|
2281
|
+
defineFeature("shop", (r) => {
|
|
2282
|
+
r.entity(
|
|
2283
|
+
"invoice",
|
|
2284
|
+
createEntity({
|
|
2285
|
+
fields: {
|
|
2286
|
+
lines: createEmbeddedListField({
|
|
2287
|
+
productId: { type: "reference", entity: "ghost-entity" },
|
|
2288
|
+
}),
|
|
2289
|
+
},
|
|
2290
|
+
}),
|
|
2291
|
+
);
|
|
2292
|
+
}),
|
|
2293
|
+
];
|
|
2294
|
+
expect(() => validateBoot(features)).toThrow(
|
|
2295
|
+
/Reference field "lines\.productId" on entity "invoice" targets unknown entity "ghost-entity"/,
|
|
2296
|
+
);
|
|
2297
|
+
});
|
|
2298
|
+
|
|
2299
|
+
test("reference sub-field with a valid target + registered list-handler → kein Throw", () => {
|
|
2300
|
+
const features = [
|
|
2301
|
+
defineFeature("shop", (r) => {
|
|
2302
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
2303
|
+
stubListHandler(r, "product");
|
|
2304
|
+
r.entity(
|
|
2305
|
+
"invoice",
|
|
2306
|
+
createEntity({
|
|
2307
|
+
fields: {
|
|
2308
|
+
lines: createEmbeddedListField({
|
|
2309
|
+
productId: { type: "reference", entity: "product", labelField: "name" },
|
|
2310
|
+
}),
|
|
2311
|
+
},
|
|
2312
|
+
}),
|
|
2313
|
+
);
|
|
2314
|
+
}),
|
|
2315
|
+
];
|
|
2316
|
+
expect(() => validateBoot(features)).not.toThrow();
|
|
2317
|
+
});
|
|
2318
|
+
|
|
2319
|
+
test("reference sub-field with a valid target but no registered list-handler → Throw", () => {
|
|
2320
|
+
const features = [
|
|
2321
|
+
defineFeature("shop", (r) => {
|
|
2322
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
2323
|
+
// KEIN stubListHandler — das ist der Punkt des Tests
|
|
2324
|
+
r.entity(
|
|
2325
|
+
"invoice",
|
|
2326
|
+
createEntity({
|
|
2327
|
+
fields: {
|
|
2328
|
+
lines: createEmbeddedListField({
|
|
2329
|
+
productId: { type: "reference", entity: "product" },
|
|
2330
|
+
}),
|
|
2331
|
+
},
|
|
2332
|
+
}),
|
|
2333
|
+
);
|
|
2334
|
+
}),
|
|
2335
|
+
];
|
|
2336
|
+
expect(() => validateBoot(features)).toThrow(
|
|
2337
|
+
/Reference field "lines\.productId" on entity "invoice" targets entity "product" but no list-query-handler is registered/,
|
|
2338
|
+
);
|
|
2339
|
+
});
|
|
2340
|
+
|
|
2341
|
+
test("reference sub-field labelField referencing an unknown field → Throw with the parent.child field path", () => {
|
|
2342
|
+
const features = [
|
|
2343
|
+
defineFeature("shop", (r) => {
|
|
2344
|
+
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|
|
2345
|
+
stubListHandler(r, "product");
|
|
2346
|
+
r.entity(
|
|
2347
|
+
"invoice",
|
|
2348
|
+
createEntity({
|
|
2349
|
+
fields: {
|
|
2350
|
+
lines: createEmbeddedListField({
|
|
2351
|
+
productId: {
|
|
2352
|
+
type: "reference",
|
|
2353
|
+
entity: "product",
|
|
2354
|
+
labelField: "ghost-field",
|
|
2355
|
+
},
|
|
2356
|
+
}),
|
|
2357
|
+
},
|
|
2358
|
+
}),
|
|
2359
|
+
);
|
|
2360
|
+
}),
|
|
2361
|
+
];
|
|
2362
|
+
expect(() => validateBoot(features)).toThrow(
|
|
2363
|
+
/Reference field "lines\.productId" on entity "invoice" references labelField "ghost-field" which does not exist on entity "product"/,
|
|
2364
|
+
);
|
|
2365
|
+
});
|
|
2366
|
+
});
|
|
2367
|
+
|
|
2272
2368
|
// --- Tier 2.7e-1: rowAction kind="navigate" target-existenz ---
|
|
2273
2369
|
describe("entityList rowAction kind=navigate (Tier 2.7e-1)", () => {
|
|
2274
2370
|
function makeFeature(targetScreen: string, withTarget: boolean) {
|
|
@@ -8,6 +8,7 @@ import { defineQueryHandler, defineWriteHandler } from "../define-handler";
|
|
|
8
8
|
import {
|
|
9
9
|
createBooleanField,
|
|
10
10
|
createEmbeddedField,
|
|
11
|
+
createEmbeddedListField,
|
|
11
12
|
createEntity,
|
|
12
13
|
createMoneyField,
|
|
13
14
|
createSelectField,
|
|
@@ -961,6 +962,188 @@ describe("createApp", () => {
|
|
|
961
962
|
expect(() => createApp({ roles: ["Admin"], features: [feature] })).not.toThrow();
|
|
962
963
|
});
|
|
963
964
|
|
|
965
|
+
test("rejects embedded select sub-field with empty options", () => {
|
|
966
|
+
const feature = defineFeature("test", (r) => {
|
|
967
|
+
r.entity(
|
|
968
|
+
"doc",
|
|
969
|
+
createEntity({
|
|
970
|
+
table: "Docs",
|
|
971
|
+
fields: {
|
|
972
|
+
lines: createEmbeddedListField({
|
|
973
|
+
status: { type: "select", options: [] },
|
|
974
|
+
}),
|
|
975
|
+
},
|
|
976
|
+
}),
|
|
977
|
+
);
|
|
978
|
+
});
|
|
979
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow("has empty options");
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
test("rejects embedded-list minItems greater than maxItems", () => {
|
|
983
|
+
const feature = defineFeature("test", (r) => {
|
|
984
|
+
r.entity(
|
|
985
|
+
"doc",
|
|
986
|
+
createEntity({
|
|
987
|
+
table: "Docs",
|
|
988
|
+
fields: {
|
|
989
|
+
lines: createEmbeddedListField(
|
|
990
|
+
{ accountId: { type: "text" } },
|
|
991
|
+
{ minItems: 3, maxItems: 1 },
|
|
992
|
+
),
|
|
993
|
+
},
|
|
994
|
+
}),
|
|
995
|
+
);
|
|
996
|
+
});
|
|
997
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
|
|
998
|
+
"greater than maxItems",
|
|
999
|
+
);
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
test("rejects derived cell referencing an unknown sub-field", () => {
|
|
1003
|
+
const feature = defineFeature("test", (r) => {
|
|
1004
|
+
r.entity(
|
|
1005
|
+
"doc",
|
|
1006
|
+
createEntity({
|
|
1007
|
+
table: "Docs",
|
|
1008
|
+
fields: {
|
|
1009
|
+
lines: createEmbeddedListField(
|
|
1010
|
+
{ qty: { type: "number" }, price: { type: "number" }, total: { type: "number" } },
|
|
1011
|
+
{ derived: { total: { op: "multiply", from: ["qty", "unknownField"] } } },
|
|
1012
|
+
),
|
|
1013
|
+
},
|
|
1014
|
+
}),
|
|
1015
|
+
);
|
|
1016
|
+
});
|
|
1017
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
|
|
1018
|
+
'derived cell "total" reading unknown sub-field "unknownField"',
|
|
1019
|
+
);
|
|
1020
|
+
});
|
|
1021
|
+
|
|
1022
|
+
test("rejects totals referencing a non-numeric sub-field", () => {
|
|
1023
|
+
const feature = defineFeature("test", (r) => {
|
|
1024
|
+
r.entity(
|
|
1025
|
+
"doc",
|
|
1026
|
+
createEntity({
|
|
1027
|
+
table: "Docs",
|
|
1028
|
+
fields: {
|
|
1029
|
+
lines: createEmbeddedListField(
|
|
1030
|
+
{ label: { type: "text" }, amount: { type: "number" } },
|
|
1031
|
+
{ totals: ["label"] },
|
|
1032
|
+
),
|
|
1033
|
+
},
|
|
1034
|
+
}),
|
|
1035
|
+
);
|
|
1036
|
+
});
|
|
1037
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
|
|
1038
|
+
'lists "label" in totals, but it is not a number/money/decimal sub-field',
|
|
1039
|
+
);
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
test("rejects totalsMatch entry whose sub-field is not a money sub-field (fw#1839)", () => {
|
|
1043
|
+
const feature = defineFeature("test", (r) => {
|
|
1044
|
+
r.entity(
|
|
1045
|
+
"invoice",
|
|
1046
|
+
createEntity({
|
|
1047
|
+
table: "Invoices",
|
|
1048
|
+
fields: {
|
|
1049
|
+
total: createMoneyField({ required: true }),
|
|
1050
|
+
lines: createEmbeddedListField(
|
|
1051
|
+
{ qty: { type: "number" } },
|
|
1052
|
+
{ totalsMatch: { qty: "total" } },
|
|
1053
|
+
),
|
|
1054
|
+
},
|
|
1055
|
+
defaultCurrency: "EUR",
|
|
1056
|
+
}),
|
|
1057
|
+
);
|
|
1058
|
+
});
|
|
1059
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
|
|
1060
|
+
'has a totalsMatch entry for "qty", which is not a money sub-field',
|
|
1061
|
+
);
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
test("rejects totalsMatch entry whose sibling field is not a money field on the entity (fw#1839)", () => {
|
|
1065
|
+
const feature = defineFeature("test", (r) => {
|
|
1066
|
+
r.entity(
|
|
1067
|
+
"invoice",
|
|
1068
|
+
createEntity({
|
|
1069
|
+
table: "Invoices",
|
|
1070
|
+
fields: {
|
|
1071
|
+
title: createTextField(),
|
|
1072
|
+
lines: createEmbeddedListField(
|
|
1073
|
+
{ amount: { type: "money" } },
|
|
1074
|
+
{ totalsMatch: { amount: "title" } },
|
|
1075
|
+
),
|
|
1076
|
+
},
|
|
1077
|
+
}),
|
|
1078
|
+
);
|
|
1079
|
+
});
|
|
1080
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
|
|
1081
|
+
'mapping "amount" to sibling field "title", which is not a money field on entity "invoice"',
|
|
1082
|
+
);
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
test("rejects totalsMatch entry whose sibling field does not exist on the entity (fw#1839)", () => {
|
|
1086
|
+
const feature = defineFeature("test", (r) => {
|
|
1087
|
+
r.entity(
|
|
1088
|
+
"invoice",
|
|
1089
|
+
createEntity({
|
|
1090
|
+
table: "Invoices",
|
|
1091
|
+
fields: {
|
|
1092
|
+
lines: createEmbeddedListField(
|
|
1093
|
+
{ amount: { type: "money" } },
|
|
1094
|
+
{ totalsMatch: { amount: "ghostTotal" } },
|
|
1095
|
+
),
|
|
1096
|
+
},
|
|
1097
|
+
}),
|
|
1098
|
+
);
|
|
1099
|
+
});
|
|
1100
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
|
|
1101
|
+
'mapping "amount" to sibling field "ghostTotal", which is not a money field on entity "invoice"',
|
|
1102
|
+
);
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
test("accepts a valid totalsMatch entry (fw#1839)", () => {
|
|
1106
|
+
const feature = defineFeature("test", (r) => {
|
|
1107
|
+
r.entity(
|
|
1108
|
+
"invoice",
|
|
1109
|
+
createEntity({
|
|
1110
|
+
table: "Invoices",
|
|
1111
|
+
fields: {
|
|
1112
|
+
total: createMoneyField({ required: true }),
|
|
1113
|
+
lines: createEmbeddedListField(
|
|
1114
|
+
{ amount: { type: "money" } },
|
|
1115
|
+
{ totalsMatch: { amount: "total" } },
|
|
1116
|
+
),
|
|
1117
|
+
},
|
|
1118
|
+
defaultCurrency: "EUR",
|
|
1119
|
+
}),
|
|
1120
|
+
);
|
|
1121
|
+
});
|
|
1122
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).not.toThrow();
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
test("rejects totalsMatch on a plain (non-list) embedded field", () => {
|
|
1126
|
+
const feature = defineFeature("test", (r) => {
|
|
1127
|
+
r.entity(
|
|
1128
|
+
"invoice",
|
|
1129
|
+
createEntity({
|
|
1130
|
+
table: "Invoices",
|
|
1131
|
+
fields: {
|
|
1132
|
+
total: createMoneyField({ required: true }),
|
|
1133
|
+
meta: createEmbeddedField(
|
|
1134
|
+
{ amount: { type: "money" } },
|
|
1135
|
+
{ totalsMatch: { amount: "total" } },
|
|
1136
|
+
),
|
|
1137
|
+
},
|
|
1138
|
+
defaultCurrency: "EUR",
|
|
1139
|
+
}),
|
|
1140
|
+
);
|
|
1141
|
+
});
|
|
1142
|
+
expect(() => createApp({ roles: ["Admin"], features: [feature] })).toThrow(
|
|
1143
|
+
"which is only valid on an embedded LIST field (multiple: true)",
|
|
1144
|
+
);
|
|
1145
|
+
});
|
|
1146
|
+
|
|
964
1147
|
test("rejects transitions on non-select field", () => {
|
|
965
1148
|
const feature = defineFeature("test", (r) => {
|
|
966
1149
|
r.entity(
|