From 635b8f3fef933c52ab3415c9328c1c23256415af Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Mon, 21 Sep 2026 00:01:08 +0900 Subject: [PATCH] fix(core): compare numeric strings in visibility gt/lt gt/gte/lt/lte required both sides to be typeof number. YAML-quoted and LLM-generated counts are strings, so a gt: 3 condition hid the element when count was "5". Coerce numeric strings the same way lessThan/greaterThan validation already does. Empty and non-numeric strings still fail the comparison. --- packages/core/src/visibility.test.ts | 51 ++++++++++++++++++++++++++++ packages/core/src/visibility.ts | 47 ++++++++++++++++--------- 2 files changed, 82 insertions(+), 16 deletions(-) diff --git a/packages/core/src/visibility.test.ts b/packages/core/src/visibility.test.ts index 99ac44c9..3ab6f0d1 100644 --- a/packages/core/src/visibility.test.ts +++ b/packages/core/src/visibility.test.ts @@ -316,6 +316,57 @@ describe("evaluateVisibility", () => { ), ).toBe(false); }); + + it("compares numeric strings with numbers", () => { + expect( + evaluateVisibility( + { $state: "/count", gt: 3 }, + { stateModel: { count: "5" } }, + ), + ).toBe(true); + expect( + evaluateVisibility( + { $state: "/count", gte: 5 }, + { stateModel: { count: "5" } }, + ), + ).toBe(true); + expect( + evaluateVisibility( + { $state: "/count", lt: 5 }, + { stateModel: { count: "3" } }, + ), + ).toBe(true); + expect( + evaluateVisibility( + { $state: "/count", lte: 5 }, + { stateModel: { count: "5" } }, + ), + ).toBe(true); + }); + + it("compares two numeric strings", () => { + expect( + evaluateVisibility( + { $state: "/count", gt: { $state: "/limit" } }, + { stateModel: { count: "10", limit: "3" } }, + ), + ).toBe(true); + }); + + it("still returns false for empty and non-numeric strings", () => { + expect( + evaluateVisibility( + { $state: "/count", gt: 0 }, + { stateModel: { count: "" } }, + ), + ).toBe(false); + expect( + evaluateVisibility( + { $state: "/count", gt: 0 }, + { stateModel: { count: "10px" } }, + ), + ).toBe(false); + }); }); describe("dynamic path references in comparison", () => { diff --git a/packages/core/src/visibility.ts b/packages/core/src/visibility.ts index daaed0bb..2f3fade4 100644 --- a/packages/core/src/visibility.ts +++ b/packages/core/src/visibility.ts @@ -225,6 +225,33 @@ function resolveConditionValue( return getByPath(ctx.stateModel, (cond as StateCondition).$state); } +/** + * Coerce a comparison operand to a finite number. + * Accepts numbers and numeric strings (`"10"`) so LLM/YAML quoted counts + * still work with gt/gte/lt/lte. Empty strings, booleans, and `"10px"` + * are not numbers. + */ +function toComparableNumber(value: unknown): number | undefined { + if (typeof value === "number") { + return Number.isFinite(value) ? value : undefined; + } + if (typeof value === "string" && value.trim() !== "") { + const n = Number(value); + return Number.isFinite(n) ? n : undefined; + } + return undefined; +} + +function compareNumbers( + value: unknown, + rhs: unknown, + op: (a: number, b: number) => boolean, +): boolean { + const left = toComparableNumber(value); + const right = toComparableNumber(rhs); + return left !== undefined && right !== undefined ? op(left, right) : false; +} + /** * Evaluate a single condition against the context. * @@ -254,34 +281,22 @@ function evaluateCondition( // Greater than else if (cond.gt !== undefined) { const rhs = resolveComparisonValue(cond.gt, ctx); - result = - typeof value === "number" && typeof rhs === "number" - ? value > rhs - : false; + result = compareNumbers(value, rhs, (a, b) => a > b); } // Greater than or equal else if (cond.gte !== undefined) { const rhs = resolveComparisonValue(cond.gte, ctx); - result = - typeof value === "number" && typeof rhs === "number" - ? value >= rhs - : false; + result = compareNumbers(value, rhs, (a, b) => a >= b); } // Less than else if (cond.lt !== undefined) { const rhs = resolveComparisonValue(cond.lt, ctx); - result = - typeof value === "number" && typeof rhs === "number" - ? value < rhs - : false; + result = compareNumbers(value, rhs, (a, b) => a < b); } // Less than or equal else if (cond.lte !== undefined) { const rhs = resolveComparisonValue(cond.lte, ctx); - result = - typeof value === "number" && typeof rhs === "number" - ? value <= rhs - : false; + result = compareNumbers(value, rhs, (a, b) => a <= b); } // Truthiness (no operator) else {