diff --git a/renderers/web_core/src/v0_9/rendering/__tests__/coercion.test.ts b/renderers/web_core/src/v0_9/rendering/__tests__/coercion.test.ts new file mode 100644 index 000000000..4b2d24385 --- /dev/null +++ b/renderers/web_core/src/v0_9/rendering/__tests__/coercion.test.ts @@ -0,0 +1,112 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from "vitest"; +import { coerceToString, coerceToNumber, coerceToBoolean, coerceValue } from "../coercion.js"; + +describe("coerceToString", () => { + it("converts null to empty string", () => { + expect(coerceToString(null)).toBe(""); + }); + + it("converts undefined to empty string", () => { + expect(coerceToString(undefined)).toBe(""); + }); + + it("converts objects to JSON", () => { + expect(coerceToString({ a: 1 })).toBe('{"a":1}'); + }); + + it("converts arrays to comma-separated string", () => { + expect(coerceToString([1, 2, 3])).toBe("1, 2, 3"); + }); + + it("extracts Error message", () => { + expect(coerceToString(new Error("test error"))).toBe("test error"); + }); + + it("converts Date using toString", () => { + const date = new Date("2025-01-01"); + expect(coerceToString(date)).toBe(date.toString()); + }); +}); + +describe("coerceToNumber", () => { + it("converts null to 0", () => { + expect(coerceToNumber(null)).toBe(0); + }); + + it("converts undefined to 0", () => { + expect(coerceToNumber(undefined)).toBe(0); + }); + + it("parses string numbers", () => { + expect(coerceToNumber("42")).toBe(42); + expect(coerceToNumber("3.14")).toBe(3.14); + }); + + it("converts invalid strings to 0", () => { + expect(coerceToNumber("invalid")).toBe(0); + }); + + it("converts booleans", () => { + expect(coerceToNumber(true)).toBe(1); + expect(coerceToNumber(false)).toBe(0); + }); +}); + +describe("coerceToBoolean", () => { + it("converts null to false", () => { + expect(coerceToBoolean(null)).toBe(false); + }); + + it("converts undefined to false", () => { + expect(coerceToBoolean(undefined)).toBe(false); + }); + + it('converts "true" (case-insensitive) to true', () => { + expect(coerceToBoolean("true")).toBe(true); + expect(coerceToBoolean("TRUE")).toBe(true); + expect(coerceToBoolean("True")).toBe(true); + }); + + it('converts "false" to false (not true!)', () => { + expect(coerceToBoolean("false")).toBe(false); + }); + + it("converts non-zero numbers to true", () => { + expect(coerceToBoolean(1)).toBe(true); + expect(coerceToBoolean(-1)).toBe(true); + expect(coerceToBoolean(0)).toBe(false); + }); + + it("converts empty string to false", () => { + expect(coerceToBoolean("")).toBe(false); + }); + + it("converts non-'true' strings to false", () => { + expect(coerceToBoolean("yes")).toBe(false); + expect(coerceToBoolean("1")).toBe(false); + }); +}); + +describe("coerceValue", () => { + it("delegates to appropriate coercer", () => { + expect(coerceValue(null, "string")).toBe(""); + expect(coerceValue(null, "number")).toBe(0); + expect(coerceValue(null, "boolean")).toBe(false); + }); +}); diff --git a/renderers/web_core/src/v0_9/rendering/coercion.ts b/renderers/web_core/src/v0_9/rendering/coercion.ts new file mode 100644 index 000000000..f381d3ef2 --- /dev/null +++ b/renderers/web_core/src/v0_9/rendering/coercion.ts @@ -0,0 +1,121 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Strict Type Coercion Utilities + * + * Implements the A2UI protocol's standard coercion rules to ensure + * consistent handling of null/undefined values and type conversions. + * + * Without central enforcement, component authors must manually handle + * these edge cases, leading to bugs like [object Object] appearing + * in text labels. + */ + +/** + * Coerces any value to a string following A2UI protocol rules: + * - null/undefined → "" + * - objects → JSON string representation (avoids "[object Object]") + * - other types → String(value) + */ +export function coerceToString(value: unknown): string { + if (value === null || value === undefined) { + return ""; + } + if (typeof value === "object") { + if (Array.isArray(value)) { + return value.map(coerceToString).join(", "); + } + if (value instanceof Error) { + return value.message; + } + // Handle objects with custom toString (e.g., Date) + const str = String(value); + if (str !== "[object Object]") { + return str; + } + try { + return JSON.stringify(value); + } catch { + return ""; + } + } + return String(value); +} + +/** + * Coerces any value to a number following A2UI protocol rules: + * - null/undefined → 0 + * - strings → parsed number (NaN becomes 0) + * - booleans → 1 for true, 0 for false + * - other types → Number(value) + */ +export function coerceToNumber(value: unknown): number { + if (value === null || value === undefined) { + return 0; + } + if (typeof value === "string") { + const parsed = parseFloat(value); + return isNaN(parsed) ? 0 : parsed; + } + if (typeof value === "boolean") { + return value ? 1 : 0; + } + const result = Number(value); + return isNaN(result) ? 0 : result; +} + +/** + * Coerces any value to a boolean following A2UI protocol rules: + * - "true" (case-insensitive) → true + * - non-zero numbers → true + * - null/undefined/empty string → false + * - all other values → false + */ +export function coerceToBoolean(value: unknown): boolean { + if (value === null || value === undefined) { + return false; + } + if (typeof value === "string") { + return value.toLowerCase() === "true"; + } + if (typeof value === "number") { + return value !== 0; + } + if (typeof value === "boolean") { + return value; + } + return false; +} + +/** + * Coerces a value to a specific target type. + */ +export function coerceValue(value: unknown, targetType: "string"): string; +export function coerceValue(value: unknown, targetType: "number"): number; +export function coerceValue(value: unknown, targetType: "boolean"): boolean; +export function coerceValue(value: unknown, targetType: string): unknown { + switch (targetType) { + case "string": + return coerceToString(value); + case "number": + return coerceToNumber(value); + case "boolean": + return coerceToBoolean(value); + default: + return value; + } +} diff --git a/renderers/web_core/src/v0_9/rendering/data-context.ts b/renderers/web_core/src/v0_9/rendering/data-context.ts index a69e3a49e..2b9399af4 100644 --- a/renderers/web_core/src/v0_9/rendering/data-context.ts +++ b/renderers/web_core/src/v0_9/rendering/data-context.ts @@ -23,6 +23,7 @@ import type { } from "../schema/common-types.js"; import { A2uiExpressionError } from "../errors.js"; import type { FunctionInvoker } from "../catalog/types.js"; +import { coerceToString, coerceToNumber, coerceToBoolean } from "./coercion.js"; /** * A contextual view of the main DataModel, serving as the unified interface for resolving