Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions renderers/web_core/src/v0_9/rendering/__tests__/coercion.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
121 changes: 121 additions & 0 deletions renderers/web_core/src/v0_9/rendering/coercion.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
1 change: 1 addition & 0 deletions renderers/web_core/src/v0_9/rendering/data-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down