@assistant-ui/react-generative-ui 0.0.20 → 0.0.22

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 (189) hide show
  1. package/LICENSE +1 -1
  2. package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
  3. package/dist/JSONGenerativeUI.client.js +30 -13
  4. package/dist/JSONGenerativeUI.client.js.map +1 -1
  5. package/dist/RadioGroupScope.d.ts +6 -0
  6. package/dist/RadioGroupScope.d.ts.map +1 -0
  7. package/dist/RadioGroupScope.js +25 -0
  8. package/dist/RadioGroupScope.js.map +1 -0
  9. package/dist/a2ui/BindingContext.d.ts +11 -0
  10. package/dist/a2ui/BindingContext.d.ts.map +1 -0
  11. package/dist/a2ui/BindingContext.js +12 -0
  12. package/dist/a2ui/BindingContext.js.map +1 -0
  13. package/dist/a2ui/PresentRenderer.d.ts +11 -0
  14. package/dist/a2ui/PresentRenderer.d.ts.map +1 -0
  15. package/dist/a2ui/PresentRenderer.js +39 -0
  16. package/dist/a2ui/PresentRenderer.js.map +1 -0
  17. package/dist/a2ui/convert.d.ts +6 -0
  18. package/dist/a2ui/convert.d.ts.map +1 -1
  19. package/dist/a2ui/convert.js +369 -87
  20. package/dist/a2ui/convert.js.map +1 -1
  21. package/dist/a2ui/dataModel.d.ts +9 -0
  22. package/dist/a2ui/dataModel.d.ts.map +1 -0
  23. package/dist/a2ui/dataModel.js +44 -0
  24. package/dist/a2ui/dataModel.js.map +1 -0
  25. package/dist/a2ui/reducer.d.ts +5 -0
  26. package/dist/a2ui/reducer.d.ts.map +1 -1
  27. package/dist/a2ui/reducer.js +3 -3
  28. package/dist/a2ui/reducer.js.map +1 -1
  29. package/dist/a2ui/types.d.ts +8 -2
  30. package/dist/a2ui/types.d.ts.map +1 -1
  31. package/dist/a2ui/types.js.map +1 -1
  32. package/dist/a2ui/valueFunctions.d.ts +16 -0
  33. package/dist/a2ui/valueFunctions.d.ts.map +1 -0
  34. package/dist/a2ui/valueFunctions.js +332 -0
  35. package/dist/a2ui/valueFunctions.js.map +1 -0
  36. package/dist/actionRegistry.d.ts +1 -1
  37. package/dist/actionRegistry.js.map +1 -1
  38. package/dist/answeredValues.d.ts +16 -0
  39. package/dist/answeredValues.d.ts.map +1 -0
  40. package/dist/answeredValues.js +37 -0
  41. package/dist/answeredValues.js.map +1 -0
  42. package/dist/constants.d.ts +2 -0
  43. package/dist/constants.d.ts.map +1 -1
  44. package/dist/constants.js +3 -1
  45. package/dist/constants.js.map +1 -1
  46. package/dist/fieldReferences.d.ts +9 -0
  47. package/dist/fieldReferences.d.ts.map +1 -0
  48. package/dist/fieldReferences.js +53 -0
  49. package/dist/fieldReferences.js.map +1 -0
  50. package/dist/generativeUIToJSX.js +3 -3
  51. package/dist/generativeUIToJSX.js.map +1 -1
  52. package/dist/index.js +2 -2
  53. package/dist/index.server.js +2 -2
  54. package/dist/index.shared.js +2 -2
  55. package/dist/ir.d.ts +1 -1
  56. package/dist/ir.js.map +1 -1
  57. package/dist/renderGenerativeUI.d.ts.map +1 -1
  58. package/dist/renderGenerativeUI.js +12 -1
  59. package/dist/renderGenerativeUI.js.map +1 -1
  60. package/dist/slack/constants.d.ts +2 -0
  61. package/dist/slack/constants.d.ts.map +1 -1
  62. package/dist/slack/constants.js +3 -1
  63. package/dist/slack/constants.js.map +1 -1
  64. package/dist/slack/decodeBlockAction.d.ts +3 -1
  65. package/dist/slack/decodeBlockAction.d.ts.map +1 -1
  66. package/dist/slack/decodeBlockAction.js +58 -7
  67. package/dist/slack/decodeBlockAction.js.map +1 -1
  68. package/dist/slack/fromSlackBlocks.d.ts.map +1 -1
  69. package/dist/slack/fromSlackBlocks.js +20 -3
  70. package/dist/slack/fromSlackBlocks.js.map +1 -1
  71. package/dist/slack/toSlackBlocks.d.ts.map +1 -1
  72. package/dist/slack/toSlackBlocks.js +187 -19
  73. package/dist/slack/toSlackBlocks.js.map +1 -1
  74. package/dist/slack/types.d.ts +4 -0
  75. package/dist/slack/types.d.ts.map +1 -1
  76. package/dist/teams/constants.d.ts +1 -1
  77. package/dist/teams/constants.d.ts.map +1 -1
  78. package/dist/teams/constants.js +1 -1
  79. package/dist/teams/constants.js.map +1 -1
  80. package/dist/teams/decodeSubmitData.d.ts +4 -0
  81. package/dist/teams/decodeSubmitData.d.ts.map +1 -1
  82. package/dist/teams/decodeSubmitData.js +6 -1
  83. package/dist/teams/decodeSubmitData.js.map +1 -1
  84. package/dist/teams/toAdaptiveCard.d.ts +5 -4
  85. package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
  86. package/dist/teams/toAdaptiveCard.js +56 -17
  87. package/dist/teams/toAdaptiveCard.js.map +1 -1
  88. package/dist/teams/types.d.ts +4 -1
  89. package/dist/teams/types.d.ts.map +1 -1
  90. package/dist/vocabulary/alert.d.ts +9 -2
  91. package/dist/vocabulary/alert.d.ts.map +1 -1
  92. package/dist/vocabulary/alert.js +116 -19
  93. package/dist/vocabulary/alert.js.map +1 -1
  94. package/dist/vocabulary/collectFormValues.d.ts +2 -1
  95. package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
  96. package/dist/vocabulary/collectFormValues.js +10 -3
  97. package/dist/vocabulary/collectFormValues.js.map +1 -1
  98. package/dist/vocabulary/data.d.ts +22 -5
  99. package/dist/vocabulary/data.d.ts.map +1 -1
  100. package/dist/vocabulary/data.js +278 -83
  101. package/dist/vocabulary/data.js.map +1 -1
  102. package/dist/vocabulary/dispatch.d.ts +3 -2
  103. package/dist/vocabulary/dispatch.d.ts.map +1 -1
  104. package/dist/vocabulary/dispatch.js +33 -4
  105. package/dist/vocabulary/dispatch.js.map +1 -1
  106. package/dist/vocabulary/fact.d.ts +8 -1
  107. package/dist/vocabulary/fact.d.ts.map +1 -1
  108. package/dist/vocabulary/fact.js +37 -11
  109. package/dist/vocabulary/fact.js.map +1 -1
  110. package/dist/vocabulary/form.d.ts.map +1 -1
  111. package/dist/vocabulary/form.js +3 -3
  112. package/dist/vocabulary/form.js.map +1 -1
  113. package/dist/vocabulary/formatValue.d.ts +7 -0
  114. package/dist/vocabulary/formatValue.d.ts.map +1 -0
  115. package/dist/vocabulary/formatValue.js +76 -0
  116. package/dist/vocabulary/formatValue.js.map +1 -0
  117. package/dist/vocabulary/icon.d.ts +1 -1
  118. package/dist/vocabulary/interactive.d.ts +137 -8
  119. package/dist/vocabulary/interactive.d.ts.map +1 -1
  120. package/dist/vocabulary/interactive.js +404 -103
  121. package/dist/vocabulary/interactive.js.map +1 -1
  122. package/dist/vocabulary/layout.d.ts.map +1 -1
  123. package/dist/vocabulary/layout.js +4 -4
  124. package/dist/vocabulary/layout.js.map +1 -1
  125. package/dist/vocabulary/list.js +2 -2
  126. package/dist/vocabulary/list.js.map +1 -1
  127. package/dist/vocabulary/text.d.ts +1 -1
  128. package/package.json +6 -3
  129. package/src/JSONGenerativeUI.client.tsx +84 -40
  130. package/src/JSONGenerativeUI.test.tsx +964 -68
  131. package/src/RadioGroupScope.server.test.tsx +24 -0
  132. package/src/RadioGroupScope.tsx +34 -0
  133. package/src/a2ui/BindingContext.ts +26 -0
  134. package/src/a2ui/PresentRenderer.tsx +83 -0
  135. package/src/a2ui/convert.test.ts +1982 -145
  136. package/src/a2ui/convert.ts +677 -137
  137. package/src/a2ui/dataModel.ts +69 -0
  138. package/src/a2ui/reducer.test.ts +35 -0
  139. package/src/a2ui/reducer.ts +5 -4
  140. package/src/a2ui/types.ts +9 -2
  141. package/src/a2ui/valueFunctions.test.ts +271 -0
  142. package/src/a2ui/valueFunctions.ts +523 -0
  143. package/src/actionRegistry.ts +1 -1
  144. package/src/answeredValues.test.ts +33 -0
  145. package/src/answeredValues.tsx +70 -0
  146. package/src/constants.ts +4 -0
  147. package/src/fieldReferences.test.ts +66 -0
  148. package/src/fieldReferences.ts +83 -0
  149. package/src/generativeUIToJSX.test.ts +18 -0
  150. package/src/generativeUIToJSX.ts +4 -6
  151. package/src/index.server.test.ts +106 -0
  152. package/src/ir.ts +1 -1
  153. package/src/renderGenerativeUI.test.tsx +14 -5
  154. package/src/renderGenerativeUI.tsx +30 -1
  155. package/src/slack/a2uiSlack.test.ts +198 -0
  156. package/src/slack/constants.ts +3 -0
  157. package/src/slack/decodeBlockAction.test.ts +98 -0
  158. package/src/slack/decodeBlockAction.ts +110 -17
  159. package/src/slack/fromSlackBlocks.test.ts +222 -3
  160. package/src/slack/fromSlackBlocks.ts +53 -2
  161. package/src/slack/toSlackBlocks.test.ts +682 -3
  162. package/src/slack/toSlackBlocks.ts +374 -23
  163. package/src/slack/types.ts +4 -0
  164. package/src/teams/constants.ts +1 -1
  165. package/src/teams/decodeSubmitData.test.ts +33 -0
  166. package/src/teams/decodeSubmitData.ts +9 -1
  167. package/src/teams/toAdaptiveCard.test.ts +219 -0
  168. package/src/teams/toAdaptiveCard.ts +93 -19
  169. package/src/teams/types.ts +4 -1
  170. package/src/vocabulary/alert.test.tsx +288 -7
  171. package/src/vocabulary/alert.tsx +168 -26
  172. package/src/vocabulary/collectFormValues.test.ts +28 -1
  173. package/src/vocabulary/collectFormValues.ts +25 -5
  174. package/src/vocabulary/data.test.tsx +248 -6
  175. package/src/vocabulary/data.tsx +398 -100
  176. package/src/vocabulary/dispatch.ts +63 -2
  177. package/src/vocabulary/fact.test.tsx +67 -0
  178. package/src/vocabulary/fact.tsx +40 -9
  179. package/src/vocabulary/form.tsx +7 -2
  180. package/src/vocabulary/formatValue.test.ts +72 -0
  181. package/src/vocabulary/formatValue.ts +127 -0
  182. package/src/vocabulary/index.test.ts +2 -0
  183. package/src/vocabulary/interactive.dispatch.test.tsx +167 -79
  184. package/src/vocabulary/interactive.dom.test.tsx +1699 -0
  185. package/src/vocabulary/interactive.test.tsx +60 -4
  186. package/src/vocabulary/interactive.tsx +770 -151
  187. package/src/vocabulary/layout.test.tsx +6 -4
  188. package/src/vocabulary/layout.tsx +13 -2
  189. package/src/vocabulary/list.tsx +2 -2
@@ -0,0 +1,69 @@
1
+ import { setAtPointer } from "./reducer";
2
+
3
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
4
+ typeof value === "object" && value !== null && !Array.isArray(value);
5
+
6
+ export const equalData = (left: unknown, right: unknown): boolean => {
7
+ if (Object.is(left, right)) return true;
8
+ if (Array.isArray(left) && Array.isArray(right)) {
9
+ return (
10
+ left.length === right.length &&
11
+ left.every((value, index) => equalData(value, right[index]))
12
+ );
13
+ }
14
+ if (!isRecord(left) || !isRecord(right)) return false;
15
+ const keys = Object.keys(left);
16
+ return (
17
+ keys.length === Object.keys(right).length &&
18
+ keys.every(
19
+ (key) => Object.hasOwn(right, key) && equalData(left[key], right[key]),
20
+ )
21
+ );
22
+ };
23
+
24
+ export const decodePointer = (path: string): string[] => {
25
+ if (path === "" || path === "/") return [];
26
+ return (path.startsWith("/") ? path.slice(1) : path)
27
+ .split("/")
28
+ .map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"));
29
+ };
30
+
31
+ export const resolvePointer = (source: unknown, path: string): unknown => {
32
+ let current = source;
33
+ for (const segment of decodePointer(path)) {
34
+ if (Array.isArray(current)) {
35
+ if (!/^(0|[1-9]\d*)$/.test(segment)) return undefined;
36
+ current = current[Number(segment)];
37
+ continue;
38
+ }
39
+ if (!isRecord(current) || !Object.hasOwn(current, segment)) {
40
+ return undefined;
41
+ }
42
+ current = current[segment];
43
+ }
44
+ return current;
45
+ };
46
+
47
+ export const reconcileDataModel = (
48
+ previous: unknown,
49
+ incoming: unknown,
50
+ local: unknown,
51
+ editedPaths: ReadonlySet<string>,
52
+ ) => {
53
+ let value = incoming;
54
+ const retained = new Set<string>();
55
+ for (const path of editedPaths) {
56
+ if (
57
+ equalData(resolvePointer(previous, path), resolvePointer(incoming, path))
58
+ ) {
59
+ value = setAtPointer(
60
+ value,
61
+ path,
62
+ resolvePointer(local, path),
63
+ false,
64
+ ).value;
65
+ retained.add(path);
66
+ }
67
+ }
68
+ return { incoming, value, editedPaths: retained };
69
+ };
@@ -100,6 +100,41 @@ describe("applyA2uiOperations", () => {
100
100
  expect(result.state.get("properties")?.catalogId).toBe("basic");
101
101
  });
102
102
 
103
+ it("applies v0.9.1 operations the way v0.9 does", () => {
104
+ const result = applyA2uiOperations(new Map(), [
105
+ {
106
+ version: "v0.9.1",
107
+ createSurface: { surfaceId: "main", catalogId: "basic" },
108
+ },
109
+ {
110
+ version: "v0.9.1",
111
+ updateComponents: {
112
+ surfaceId: "main",
113
+ components: [{ id: "root", component: "Text", text: "hello" }],
114
+ },
115
+ },
116
+ {
117
+ version: "v0.9.1",
118
+ updateDataModel: {
119
+ surfaceId: "main",
120
+ path: "/profile/name",
121
+ value: "Ada",
122
+ },
123
+ },
124
+ ]);
125
+
126
+ expect(result.warnings).toEqual([]);
127
+ expect(result.state.get("main")).toMatchObject({
128
+ catalogId: "basic",
129
+ dataModel: { profile: { name: "Ada" } },
130
+ });
131
+ expect(result.state.get("main")?.components.get("root")).toEqual({
132
+ id: "root",
133
+ component: "Text",
134
+ text: "hello",
135
+ });
136
+ });
137
+
103
138
  it.each(["10001", "4294967294"])(
104
139
  "rejects array pointer index %s beyond the auto-vivification limit",
105
140
  (index) => {
@@ -3,6 +3,7 @@ import {
3
3
  type A2uiOperationResult,
4
4
  type A2uiState,
5
5
  type A2uiSurfaceState,
6
+ type A2uiVersion,
6
7
  } from "./types";
7
8
 
8
9
  const OPERATION_KEYS = new Set([
@@ -12,14 +13,14 @@ const OPERATION_KEYS = new Set([
12
13
  "deleteSurface",
13
14
  ]);
14
15
  // This defensive ceiling is well above the renderer's displayed-item limit.
15
- const MAX_AUTO_VIVIFY_ARRAY_INDEX = 10_000;
16
+ export const MAX_AUTO_VIVIFY_ARRAY_INDEX = 10_000;
16
17
  const INVALID_POINTER = Symbol("invalidPointer");
17
18
 
18
19
  const isRecord = (value: unknown): value is Record<string, unknown> =>
19
20
  typeof value === "object" && value !== null && !Array.isArray(value);
20
21
 
21
- const isVersion = (value: unknown): value is "v0.9" | "v1.0" =>
22
- value === "v0.9" || value === "v1.0";
22
+ const isVersion = (value: unknown): value is A2uiVersion =>
23
+ value === "v0.9" || value === "v0.9.1" || value === "v1.0";
23
24
 
24
25
  const surfaceIdOf = (payload: Record<string, unknown>): string | undefined => {
25
26
  const surfaceId = payload["surfaceId"];
@@ -117,7 +118,7 @@ const decodePointer = (path: string): string[] | undefined => {
117
118
  const isArrayIndex = (segment: string): boolean =>
118
119
  segment === "0" || /^[1-9]\d*$/.test(segment);
119
120
 
120
- const setAtPointer = (
121
+ export const setAtPointer = (
121
122
  model: unknown,
122
123
  path: string,
123
124
  value: unknown,
package/src/a2ui/types.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export const A2UI_SURFACE_ID = Symbol("a2uiSurfaceId");
2
2
 
3
- export type A2uiVersion = "v0.9" | "v1.0";
3
+ export type A2uiVersion = "v0.9" | "v0.9.1" | "v1.0";
4
4
 
5
5
  export interface A2uiTemplateChildren {
6
6
  readonly template: {
@@ -12,7 +12,10 @@ export interface A2uiTemplateChildren {
12
12
  export interface ComponentNode extends Record<string, unknown> {
13
13
  readonly id: string;
14
14
  readonly component: string;
15
- readonly children?: readonly string[] | A2uiTemplateChildren;
15
+ readonly children?:
16
+ | readonly string[]
17
+ | { readonly componentId: string; readonly path: string }
18
+ | A2uiTemplateChildren;
16
19
  }
17
20
 
18
21
  export interface A2uiCreateSurfaceV09Payload {
@@ -53,6 +56,10 @@ export type A2uiCreateSurfaceOperation =
53
56
  readonly version: "v0.9";
54
57
  readonly createSurface: A2uiCreateSurfaceV09Payload;
55
58
  }
59
+ | {
60
+ readonly version: "v0.9.1";
61
+ readonly createSurface: A2uiCreateSurfaceV09Payload;
62
+ }
56
63
  | {
57
64
  readonly version: "v1.0";
58
65
  readonly createSurface: A2uiCreateSurfaceV10Payload;
@@ -0,0 +1,271 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ ExpressionSyntaxError,
4
+ evaluateA2uiValueFunction,
5
+ parseExpressionTemplate,
6
+ type ExpressionPart,
7
+ } from "./valueFunctions";
8
+
9
+ const joinLiterals = (parts: ExpressionPart[]) =>
10
+ parts.reduce<ExpressionPart[]>((joined, part) => {
11
+ const last = joined.at(-1);
12
+ if (typeof part === "string" && typeof last === "string") {
13
+ joined[joined.length - 1] = last + part;
14
+ } else {
15
+ joined.push(part);
16
+ }
17
+ return joined;
18
+ }, []);
19
+
20
+ // The parse_expression_template cases of a2ui-project/a2ui conformance/core/expressions.yaml.
21
+ const PARSE_CASES: [string, ExpressionPart[]][] = [
22
+ ["hello world", ["hello world"]],
23
+ ["", []],
24
+ ["${user}", [{ path: "user" }]],
25
+ ["value is ${/user/name}", ["value is ", { path: "/user/name" }]],
26
+ ["${my-path.with_underscores}", [{ path: "my-path.with_underscores" }]],
27
+ ["a ${x} b ${y} c", ["a ", { path: "x" }, " b ", { path: "y" }, " c"]],
28
+ ["${x}${y}", [{ path: "x" }, { path: "y" }]],
29
+ ["${'hello world'}", ["hello world"]],
30
+ ['${"hello world"}', ["hello world"]],
31
+ ["${'line\\nbreak'}", ["line\nbreak"]],
32
+ ["${'it\\'s'}", ["it's"]],
33
+ ["${42}", [42]],
34
+ ["${1.5}", [1.5]],
35
+ ["${007}", [7]],
36
+ ["${true} ${false}", [true, " ", false]],
37
+ ["${trueish}", [{ path: "trueish" }]],
38
+ ["${now()}", [{ call: "now", args: {} }]],
39
+ [
40
+ "sum is ${add(a: 10, b: 20)}",
41
+ ["sum is ", { call: "add", args: { a: 10, b: 20 } }],
42
+ ],
43
+ [
44
+ "${format(value: /amount, suffix: ' USD', fallback: zero())}",
45
+ [
46
+ {
47
+ call: "format",
48
+ args: {
49
+ value: { path: "/amount" },
50
+ suffix: " USD",
51
+ fallback: { call: "zero", args: {} },
52
+ },
53
+ },
54
+ ],
55
+ ],
56
+ [
57
+ "${outer(a: inner(b: 1))}",
58
+ [{ call: "outer", args: { a: { call: "inner", args: { b: 1 } } } }],
59
+ ],
60
+ ["${${'hello'}}", ["hello"]],
61
+ ["${ /user/name }", [{ path: "/user/name" }]],
62
+ ["\\${not_interpolated}", ["${not_interpolated}"]],
63
+ ["before \\${x} after", ["before ${x} after"]],
64
+ ["\\${literal} ${real}", ["${literal} ", { path: "real" }]],
65
+ ["${1.}", [1]],
66
+ ];
67
+
68
+ const MALFORMED_TEMPLATES = [
69
+ "hello ${world",
70
+ "${add(a: 1, b: 2}",
71
+ "${add(a 1)}",
72
+ "${true false}",
73
+ "${1.2.3}",
74
+ ];
75
+
76
+ describe("parseExpressionTemplate", () => {
77
+ it.each(PARSE_CASES)("parses %j", (template, expected) => {
78
+ expect(joinLiterals(parseExpressionTemplate(template))).toEqual(expected);
79
+ });
80
+
81
+ it.each(MALFORMED_TEMPLATES)("rejects %j", (template) => {
82
+ expect(() => parseExpressionTemplate(template)).toThrow(
83
+ ExpressionSyntaxError,
84
+ );
85
+ });
86
+
87
+ it("rejects templates past the length and nesting caps", () => {
88
+ expect(() => parseExpressionTemplate("x".repeat(10_001))).toThrow(
89
+ ExpressionSyntaxError,
90
+ );
91
+ expect(() =>
92
+ parseExpressionTemplate(`${"${".repeat(40)}1${"}".repeat(40)}`),
93
+ ).toThrow(ExpressionSyntaxError);
94
+ });
95
+
96
+ it("accepts an empty interpolation, a trailing argument comma, and any whitespace", () => {
97
+ expect(joinLiterals(parseExpressionTemplate("a${}b"))).toEqual(["ab"]);
98
+ expect(parseExpressionTemplate("${add(a: 1, )}")).toEqual([
99
+ { call: "add", args: { a: 1 } },
100
+ ]);
101
+ expect(parseExpressionTemplate("${ /x }")).toEqual([{ path: "/x" }]);
102
+ expect(() => parseExpressionTemplate("${add(a: 1 x)}")).toThrow(
103
+ ExpressionSyntaxError,
104
+ );
105
+ });
106
+ });
107
+
108
+ const evaluate = (
109
+ name: string,
110
+ args: Record<string, unknown>,
111
+ data: Record<string, unknown> = {},
112
+ ) => {
113
+ const warnings: string[] = [];
114
+ const value = evaluateA2uiValueFunction(name, args, {
115
+ resolve: (part) =>
116
+ typeof part === "object" && part !== null && "path" in part
117
+ ? data[part.path]
118
+ : undefined,
119
+ warn: (message) => warnings.push(message),
120
+ templates: new Map(),
121
+ });
122
+ return { value, warnings };
123
+ };
124
+
125
+ const dateName = (
126
+ date: Date,
127
+ options: Intl.DateTimeFormatOptions,
128
+ type: Intl.DateTimeFormatPartTypes,
129
+ ) =>
130
+ new Intl.DateTimeFormat(undefined, options)
131
+ .formatToParts(date)
132
+ .find((part) => part.type === type)!.value;
133
+
134
+ describe("evaluateA2uiValueFunction", () => {
135
+ it("interpolates formatString paths and coerces values to text", () => {
136
+ expect(
137
+ evaluate(
138
+ "formatString",
139
+ { value: "Hi ${/name}, ${count} new ${/flag} ${/missing}|${/tags}" },
140
+ { "/name": "Ada", count: 3, "/flag": true, "/tags": ["a", "b"] },
141
+ ),
142
+ ).toEqual({ value: 'Hi Ada, 3 new true |["a","b"]', warnings: [] });
143
+ });
144
+
145
+ it("warns and skips a malformed formatString template", () => {
146
+ expect(evaluate("formatString", { value: "Hi ${name" })).toEqual({
147
+ value: undefined,
148
+ warnings: [
149
+ "A2UI formatString template is malformed: an interpolation is not closed.",
150
+ ],
151
+ });
152
+ });
153
+
154
+ it("formats numbers and currencies with the requested decimals and grouping", () => {
155
+ const number = (options: Intl.NumberFormatOptions, value: number) =>
156
+ new Intl.NumberFormat(undefined, options).format(value);
157
+
158
+ expect(evaluate("formatNumber", { value: 1234.5, decimals: 2 })).toEqual({
159
+ value: number(
160
+ { minimumFractionDigits: 2, maximumFractionDigits: 2 },
161
+ 1234.5,
162
+ ),
163
+ warnings: [],
164
+ });
165
+ expect(
166
+ evaluate("formatNumber", { value: "1500", grouping: false }).value,
167
+ ).toBe(number({ useGrouping: false }, 1500));
168
+ expect(
169
+ evaluate("formatCurrency", { value: 12.5, currency: "USD" }).value,
170
+ ).toBe(number({ style: "currency", currency: "USD" }, 12.5));
171
+ });
172
+
173
+ it("formats a missing amount as empty text and warns on invalid arguments", () => {
174
+ expect(evaluate("formatNumber", { value: undefined })).toEqual({
175
+ value: "",
176
+ warnings: [],
177
+ });
178
+ expect(evaluate("formatNumber", { value: "abc" })).toEqual({
179
+ value: undefined,
180
+ warnings: ['A2UI function "formatNumber" received invalid arguments.'],
181
+ });
182
+ expect(
183
+ evaluate("formatNumber", { value: 1, decimals: 1.5 }).warnings,
184
+ ).toEqual(['A2UI function "formatNumber" received invalid arguments.']);
185
+ expect(evaluate("formatCurrency", { value: 1 }).warnings).toEqual([
186
+ 'A2UI function "formatCurrency" received invalid arguments.',
187
+ ]);
188
+ expect(
189
+ evaluate("formatCurrency", { value: 1, currency: "not a code" }).warnings,
190
+ ).toEqual(['A2UI function "formatCurrency" received invalid arguments.']);
191
+ });
192
+
193
+ it("formats dates with TR35 patterns, quoted literals, and padded fields", () => {
194
+ const value = "2026-01-16T14:05:09";
195
+ const date = new Date(value);
196
+ const pm = dateName(date, { hour: "numeric", hour12: true }, "dayPeriod");
197
+ const format = (pattern: string) =>
198
+ evaluate("formatDate", { value, format: pattern }).value;
199
+
200
+ expect(format("h:mm a")).toBe(`2:05 ${pm}`);
201
+ expect(format("HH:mm:ss")).toBe("14:05:09");
202
+ expect(format("yy/M/d, yyyy-MM-dd")).toBe("26/1/16, 2026-01-16");
203
+ expect(format("EEEE, MMM d 'at' h:mm a")).toBe(
204
+ `${dateName(date, { weekday: "long" }, "weekday")}, ${dateName(date, { month: "short" }, "month")} 16 at 2:05 ${pm}`,
205
+ );
206
+ expect(format("MMMM d, E")).toBe(
207
+ `${dateName(date, { month: "long" }, "month")} 16, ${dateName(date, { weekday: "short" }, "weekday")}`,
208
+ );
209
+ expect(format("h 'o''clock'")).toBe("2 o'clock");
210
+ });
211
+
212
+ it("reads a date-only value as a local calendar date and rejects one out of range", () => {
213
+ expect(
214
+ evaluate("formatDate", {
215
+ value: "2026-01-16",
216
+ format: "yyyy-MM-dd HH:mm",
217
+ }).value,
218
+ ).toBe("2026-01-16 00:00");
219
+ expect(
220
+ evaluate("formatDate", { value: "2026-02-30", format: "d" }).warnings,
221
+ ).toEqual(['A2UI function "formatDate" received invalid arguments.']);
222
+ });
223
+
224
+ it("falls back to an ISO date for an unsupported pattern and warns on an invalid date", () => {
225
+ const value = "2026-01-16T14:05:09";
226
+ expect(evaluate("formatDate", { value, format: "YYYY" })).toEqual({
227
+ value: new Date(value).toISOString(),
228
+ warnings: [
229
+ 'A2UI function "formatDate" does not support the pattern "YYYY", so it returned an ISO date.',
230
+ ],
231
+ });
232
+ expect(
233
+ evaluate("formatDate", { value: "not a date", format: "d" }).warnings,
234
+ ).toEqual(['A2UI function "formatDate" received invalid arguments.']);
235
+ expect(evaluate("formatDate", { value: undefined, format: "d" })).toEqual({
236
+ value: "",
237
+ warnings: [],
238
+ });
239
+ });
240
+
241
+ it("selects a plural category and falls back to other", () => {
242
+ const category = new Intl.PluralRules().select(1);
243
+ expect(
244
+ evaluate("pluralize", {
245
+ value: 1,
246
+ [category]: "1 review",
247
+ other: "reviews",
248
+ }).value,
249
+ ).toBe("1 review");
250
+ expect(evaluate("pluralize", { value: 2, other: "reviews" }).value).toBe(
251
+ "reviews",
252
+ );
253
+ });
254
+
255
+ it("evaluates boolean functions by truthiness", () => {
256
+ expect(evaluate("and", { values: [true, 1] }).value).toBe(true);
257
+ expect(evaluate("and", { values: [true, ""] }).value).toBe(false);
258
+ expect(evaluate("or", { values: [false, "x"] }).value).toBe(true);
259
+ expect(evaluate("not", { value: 0 }).value).toBe(true);
260
+ expect(evaluate("not", { value: undefined }).value).toBe(true);
261
+ });
262
+
263
+ it("warns instead of evaluating a function it does not support", () => {
264
+ expect(evaluate("customValue", {})).toEqual({
265
+ value: undefined,
266
+ warnings: [
267
+ 'A2UI function "customValue" is not supported and was skipped.',
268
+ ],
269
+ });
270
+ });
271
+ });