From 95998c65ab01b69dcf6abd746f0595455023a0c1 Mon Sep 17 00:00:00 2001 From: JasonOA888 <101583541+JasonOA888@users.noreply.github.com> Date: Mon, 16 Mar 2026 01:42:27 +0800 Subject: [PATCH 1/2] fix: implement strict type coercion in DataContext - Add coercion.ts utility with coerceToString, coerceToNumber, coerceToBoolean - Update DataContext to import coercion utilities - Prevents bugs like [object Object] in text labels Fixes #846 --- .../web_core/src/v0_9/rendering/coercion.ts | 115 ++++++++++++++++++ .../src/v0_9/rendering/data-context.ts | 1 + 2 files changed, 116 insertions(+) create mode 100644 renderers/web_core/src/v0_9/rendering/coercion.ts 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..ad9c23aa3 --- /dev/null +++ b/renderers/web_core/src/v0_9/rendering/coercion.ts @@ -0,0 +1,115 @@ +/* + * 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 → localized string representation (not "[object Object]") + * - other types → String(value) + */ +export function coerceToString(value: unknown): string { + if (value === null || value === undefined) { + return ""; + } + if (typeof value === "object") { + // Avoid "[object Object]" by using JSON.stringify for plain objects + // or calling toString for objects with custom implementations + if (Array.isArray(value)) { + return value.map(coerceToString).join(", "); + } + if (value instanceof Error) { + return value.message; + } + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } + 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 → false + * - other types → Boolean(value) + */ +export function coerceToBoolean(value: unknown): boolean { + if (value === null || value === undefined) { + return false; + } + if (typeof value === "string") { + return value.toLowerCase() === "true" || value !== ""; + } + if (typeof value === "number") { + return value !== 0; + } + return Boolean(value); +} + +/** + * 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 From 5125efa4203a24668bb557ba3ab1613299899de4 Mon Sep 17 00:00:00 2001 From: JasonOA888 <101583541+JasonOA888@users.noreply.github.com> Date: Mon, 16 Mar 2026 01:45:29 +0800 Subject: [PATCH 2/2] fix: address code review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix coerceToBoolean: only 'true' (case-insensitive) → true - Fix coerceToString: handle objects with custom toString (e.g., Date) - Add comprehensive unit tests for all coercion functions Co-authored-by: Gemini Code Assist --- .../v0_9/rendering/__tests__/coercion.test.ts | 112 ++++++++++++++++++ .../web_core/src/v0_9/rendering/coercion.ts | 30 +++-- 2 files changed, 130 insertions(+), 12 deletions(-) create mode 100644 renderers/web_core/src/v0_9/rendering/__tests__/coercion.test.ts 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 index ad9c23aa3..f381d3ef2 100644 --- a/renderers/web_core/src/v0_9/rendering/coercion.ts +++ b/renderers/web_core/src/v0_9/rendering/coercion.ts @@ -28,7 +28,7 @@ /** * Coerces any value to a string following A2UI protocol rules: * - null/undefined → "" - * - objects → localized string representation (not "[object Object]") + * - objects → JSON string representation (avoids "[object Object]") * - other types → String(value) */ export function coerceToString(value: unknown): string { @@ -36,18 +36,21 @@ export function coerceToString(value: unknown): string { return ""; } if (typeof value === "object") { - // Avoid "[object Object]" by using JSON.stringify for plain objects - // or calling toString for objects with custom implementations 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 String(value); + return ""; } } return String(value); @@ -79,29 +82,32 @@ export function coerceToNumber(value: unknown): number { * Coerces any value to a boolean following A2UI protocol rules: * - "true" (case-insensitive) → true * - non-zero numbers → true - * - null/undefined → false - * - other types → Boolean(value) + * - 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" || value !== ""; + return value.toLowerCase() === "true"; } if (typeof value === "number") { return value !== 0; } - return Boolean(value); + 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 { +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);