From 202a84890db5170a4de46edb4e74a5d0f7cb3c49 Mon Sep 17 00:00:00 2001 From: sedrew Date: Wed, 1 Jul 2026 19:43:50 +0700 Subject: [PATCH 1/8] test: percent width on measure-func leaf resolves against child's own width, not parent The measure func received parent's full width instead of the child's resolved percent width, preventing wrapping. 4 test cases. --- tests/percent-measure-leaf-wrapping.test.ts | 173 ++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 tests/percent-measure-leaf-wrapping.test.ts diff --git a/tests/percent-measure-leaf-wrapping.test.ts b/tests/percent-measure-leaf-wrapping.test.ts new file mode 100644 index 0000000..537a24d --- /dev/null +++ b/tests/percent-measure-leaf-wrapping.test.ts @@ -0,0 +1,173 @@ +/** + * Regression: a measure-func leaf with a PERCENT width (e.g. width="30%") + * was laid out against the PARENT's full mainAxisSize instead of the child's + * resolved 30% width. This made the leaf measure unconstrained (or at parent + * width) → no wrapping → height=1 instead of the correct wrapped height. + * + * Root cause (layout-zero.ts Phase 8 passWidthToChild): + * The `mainIsPercentForLayoutCall → mainAxisSize` case fires before the + * `isRow ? mainSizeToPass : childWidth` terminal fallback that commit + * e398ac1 added. For measure-func leaves, `mainSizeToPass` already carries + * the correct `childWidth` (resolved percent), but execution never reaches it. + * + * Fix: add `hasMeasureLeaf ? mainSizeToPass` before `mainIsPercentForLayoutCall`. + */ +import { describe, expect, test } from "vitest" +import { + ALIGN_FLEX_START, + DIRECTION_LTR, + FLEX_DIRECTION_COLUMN, + FLEX_DIRECTION_ROW, + MEASURE_MODE_AT_MOST, + MEASURE_MODE_EXACTLY, + MEASURE_MODE_UNDEFINED, + Node, +} from "../src/index.js" + +describe("percent width on measure-func leaf: wraps at resolved percent width", () => { + test("measure-func leaf with width=30% in a row uses resolved 30% of parent width for wrapping", () => { + // contentWidth is 80 units. At 30px (30% of 100) → wraps to 3 lines. + // At 100px (parent width) → fits in 1 line. + const root = Node.create() + root.setFlexDirection(FLEX_DIRECTION_ROW) + root.setWidth(100) + root.setHeight(50) + + const child = Node.create() + child.setWidthPercent(30) // 30% of 100 = 30px + child.setMeasureFunc((_w: number, wm: number) => { + const contentWidth = 80 + if (wm === MEASURE_MODE_EXACTLY || wm === MEASURE_MODE_AT_MOST) { + if (_w >= contentWidth) { + return { width: contentWidth, height: 1 } + } + const lines = Math.ceil(contentWidth / _w) + return { width: Math.min(contentWidth, _w), height: lines } + } + return { width: contentWidth, height: 1 } + }) + root.insertChild(child, 0) + + root.calculateLayout(100, 50, DIRECTION_LTR) + + // BUG: child gets width=100 (parent full width) instead of 30 + expect(child.getComputedWidth()).toBe(30) + // BUG: measure-func wraps at 100 instead of 30 → height=1 instead of 3 + expect(child.getComputedHeight()).toBe(3) + }) + + test("measure-func leaf with width=30% in a column uses resolved 30% of parent width for wrapping", () => { + // Column variant: width=30% should resolve against parent's width=100 = 30 + const root = Node.create() + root.setFlexDirection(FLEX_DIRECTION_COLUMN) + root.setWidth(100) + root.setHeight(100) + + const child = Node.create() + child.setWidthPercent(30) // 30% of 100 = 30px + child.setMeasureFunc((_w: number, wm: number) => { + const contentWidth = 80 + if (wm === MEASURE_MODE_EXACTLY || wm === MEASURE_MODE_AT_MOST) { + if (_w >= contentWidth) { + return { width: contentWidth, height: 1 } + } + const lines = Math.ceil(contentWidth / _w) + return { width: Math.min(contentWidth, _w), height: lines } + } + return { width: contentWidth, height: 1 } + }) + root.insertChild(child, 0) + + root.calculateLayout(100, 100, DIRECTION_LTR) + + // In a column, width is cross-axis. But width=30% still resolves against + // parent width = 100. The measureFunc receives width=30. + expect(child.getComputedWidth()).toBe(30) + expect(child.getComputedHeight()).toBe(3) + }) + + test("flexGrow measure-func leaf with widthPercent and a sibling gets correct wrapping", () => { + // Row: [sibling(40)] [child flexGrow=1 width=30%] + // Remaining: 100 - 40 = 60 → child flexGrow gets all → 60 + // But width=30% resolves to 30 during flex basis. After flex distribution, + // child width = 60 (flexGrow wins over percent width in main axis). + // Test: child still wraps at 60, not at 100. + // Use alignItems=flex-start to avoid stretch overriding wrapped content height. + const root = Node.create() + root.setFlexDirection(FLEX_DIRECTION_ROW) + root.setWidth(100) + root.setHeight(50) + root.setAlignItems(ALIGN_FLEX_START) + + const sibling = Node.create() + sibling.setWidth(40) + sibling.setFlexShrink(0) + root.insertChild(sibling, 0) + + const child = Node.create() + child.setWidthPercent(30) + child.setFlexGrow(1) + child.setMeasureFunc((_w: number, wm: number) => { + const contentWidth = 80 + if (wm === MEASURE_MODE_EXACTLY || wm === MEASURE_MODE_AT_MOST) { + if (_w >= contentWidth) { + return { width: contentWidth, height: 1 } + } + const lines = Math.ceil(contentWidth / _w) + return { width: Math.min(contentWidth, _w), height: lines } + } + return { width: contentWidth, height: 1 } + }) + root.insertChild(child, 1) + + root.calculateLayout(100, 50, DIRECTION_LTR) + + // flexGrow wins: child gets 60 (100 - 40) + expect(child.getComputedWidth()).toBe(60) + // 80 units at 60 → 2 lines + expect(child.getComputedHeight()).toBe(2) + }) + + test("measure-func leaf with width=30%, no flexGrow, inside column flexGrow parent", () => { + // More realistic: outer column flexGrow . child row . leaf width=30% + // The leaf should wrap at 30% of the resolved column width. + // Per column spec example from doc — multi-pass measurement + const root = Node.create() + root.setFlexDirection(FLEX_DIRECTION_ROW) + root.setWidth(200) + root.setHeight(100) + + const col = Node.create() + col.setFlexDirection(FLEX_DIRECTION_COLUMN) + col.setFlexGrow(1) + root.insertChild(col, 0) + + const row = Node.create() + row.setFlexDirection(FLEX_DIRECTION_ROW) + col.insertChild(row, 0) + + const child = Node.create() + child.setWidthPercent(30) // 30% of col width (200 - 0 = 200) → 60 + child.setMeasureFunc((_w: number, wm: number) => { + const contentWidth = 120 + if (wm === MEASURE_MODE_EXACTLY || wm === MEASURE_MODE_AT_MOST) { + if (_w >= contentWidth) { + return { width: contentWidth, height: 1 } + } + const lines = Math.ceil(contentWidth / _w) + return { width: Math.min(contentWidth, _w), height: lines } + } + return { width: contentWidth, height: 1 } + }) + row.insertChild(child, 0) + + root.calculateLayout(200, 100, DIRECTION_LTR) + + // col gets 200 (flexGrow in 200-wide row) + expect(col.getComputedWidth()).toBe(200) + // 30% of 200 = 60 + expect(child.getComputedWidth()).toBe(60) + // 120 units at 60 → 2 lines + expect(child.getComputedHeight()).toBe(2) + }) +}) From fe15082d4d0a8e666338313b678dcc01f719a172 Mon Sep 17 00:00:00 2001 From: sedrew Date: Wed, 1 Jul 2026 19:44:57 +0700 Subject: [PATCH 2/8] Fix in Phase 8: resolve the child's own cross-axis size (point/percent/ cqi/cqmin/calc) and pass it to the measure func instead of parent's availCross. Additionally, after flex distribution re-measure a row measure-func leaf at the actual flex-distributed width so wrapping reflects the post-grow size. Both together prevent height=1 when the leaf has a percent/point cross-axis size. --- src/layout-zero.ts | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/layout-zero.ts b/src/layout-zero.ts index 64ac25b..c52cbe6 100644 --- a/src/layout-zero.ts +++ b/src/layout-zero.ts @@ -593,19 +593,33 @@ function layoutNode( // Measure function takes PHYSICAL (width, height), not logical (main, cross). // For row: main=width, cross=height. For column: main=height, cross=width. const wantMaxContent = childStyle.flexGrow > 0 + // Resolve the child's cross-axis dimension when it has an explicit percent/point + // size, so the measure func gets the correct constraint (e.g. width=30% in a + // column → measure at 30px, not at parent's full width). Without this, a + // measure-func leaf with percent width in a column measures unconstrained (parent width) + // and doesn't wrap, producing height=1 instead of the correct wrapped height. + const crossDimStyle = isRow ? childStyle.height : childStyle.width + let resolvedCross = availCross + if (crossDimStyle.unit === C.UNIT_POINT) { + resolvedCross = crossDimStyle.value + } else if (crossDimStyle.unit === C.UNIT_PERCENT) { + resolvedCross = Number.isNaN(crossAxisSize) ? 0 : crossAxisSize * (crossDimStyle.value / 100) + } else if (crossDimStyle.unit === C.UNIT_CQI || crossDimStyle.unit === C.UNIT_CQMIN || crossDimStyle.unit === C.UNIT_CALC) { + resolvedCross = resolveValue(crossDimStyle, crossAxisSize, findContainerQuerySize(child)) + } const mW = isRow ? wantMaxContent ? Infinity : Number.isNaN(mainAxisSize) ? Infinity : mainAxisSize - : Number.isNaN(availCross) + : Number.isNaN(resolvedCross) ? Infinity - : availCross + : resolvedCross const mH = isRow - ? Number.isNaN(availCross) + ? Number.isNaN(resolvedCross) ? Infinity - : availCross + : resolvedCross : wantMaxContent ? Infinity : Number.isNaN(mainAxisSize) @@ -1982,6 +1996,16 @@ function layoutNode( child.layout.height = edgeBasedMainSize } } + // When flexGrow changed a measure leaf's width, the height was computed at the + // PRE-distribution width. Re-measure at the actual + // flex-distributed width to get correct wrapping. + if (flexGrowHasDefiniteMainBudget && hasMeasureLeaf && isRow) { + const newWidth = child.layout.width + const measured = child.cachedMeasure(newWidth, C.MEASURE_MODE_AT_MOST, Infinity, C.MEASURE_MODE_UNDEFINED) + if (measured) { + child.layout.height = Math.round(measured.height) + } + } // Cross axis: only override for explicit sizing or when we have a real constraint // For auto-sized children, let layoutNode determine the size const crossDimForCheck = isRow ? childStyle.height : childStyle.width From f7c0f23bacab9bbc5b3c8dbab242f90a920e3e57 Mon Sep 17 00:00:00 2001 From: sedrew Date: Tue, 7 Jul 2026 10:13:29 +0700 Subject: [PATCH 3/8] test(yoga-comp): MeasureFuncPercent cross-axis resolution --- tests/yoga-comparison.test.ts | 517 ++++++++++++++++++++++++++++++++++ 1 file changed, 517 insertions(+) diff --git a/tests/yoga-comparison.test.ts b/tests/yoga-comparison.test.ts index 32dfedb..5c89dc0 100644 --- a/tests/yoga-comparison.test.ts +++ b/tests/yoga-comparison.test.ts @@ -1688,6 +1688,523 @@ describe("Yoga Comparison: EdgeCases", () => { }) }) +// ============================================================================ +// Category: MeasureFunc with Percent Sizing — cross-axis resolution +// ============================================================================ + +describe("Yoga Comparison: MeasureFuncPercent", () => { + function buildMeasureFunc(contentWidth = 80): Flexily.MeasureFunc { + return (_w: number, wm: number) => { + if (wm === Flexily.MEASURE_MODE_EXACTLY || wm === Flexily.MEASURE_MODE_AT_MOST) { + if (_w >= contentWidth) { + return { width: contentWidth, height: 1 } + } + const customHeight = 35 + return { width: Math.min(contentWidth, _w), height: customHeight } + } + return { width: contentWidth, height: 1 } + } + } + + it("percent-width-in-column: 30% width measure leaf should resolve against parent width", () => { + const fRoot = Flexily.Node.create(YOGA_OPTS) + fRoot.setFlexDirection(Flexily.FLEX_DIRECTION_COLUMN) + fRoot.setWidth(100) + fRoot.setHeight(100) + + const fChild = Flexily.Node.create(YOGA_OPTS) + fChild.setWidthPercent(30) + fChild.setMeasureFunc(buildMeasureFunc(80)) + fRoot.insertChild(fChild, 0) + + fRoot.calculateLayout(100, 100, Flexily.DIRECTION_LTR) + const flexilyLayout = getFlexilyLayout(fRoot) + + const yRoot = yoga.Node.create() + yRoot.setFlexDirection(yoga.FLEX_DIRECTION_COLUMN) + yRoot.setWidth(100) + yRoot.setHeight(100) + + const yChild = yoga.Node.create() + yChild.setWidthPercent(30) + yChild.setMeasureFunc(buildMeasureFunc(80)) + yRoot.insertChild(yChild, 0) + + yRoot.calculateLayout(100, 100, yoga.DIRECTION_LTR) + const yogaLayout = getYogaLayout(yRoot) + yRoot.freeRecursive() + + const match = layoutsMatch(flexilyLayout, yogaLayout, 0.001) + recordResult({ + category: "MeasureFuncPercent", + name: "percent-width-in-column", + passed: match, + flexily: flexilyLayout, + yoga: yogaLayout, + }) + expect(match).toBe(true) + }) + + it("percent-height-in-row: 30% height (cross-axis) measure leaf resolves against parent height, not full parent", () => { + const fRoot = Flexily.Node.create(YOGA_OPTS) + fRoot.setFlexDirection(Flexily.FLEX_DIRECTION_ROW) + fRoot.setWidth(100) + fRoot.setHeight(100) + + const fChild = Flexily.Node.create(YOGA_OPTS) + fChild.setWidthPercent(30) + fChild.setMeasureFunc((_w: number, _wm: number, _h: number, hm: number) => { + const derivedHeight = _w >= 80 ? 1 : Math.ceil(80 / Math.max(_w, 1)) + return { width: _w, height: derivedHeight } + }) + fRoot.insertChild(fChild, 0) + + fRoot.calculateLayout(100, 100, Flexily.DIRECTION_LTR) + const flexilyLayout = getFlexilyLayout(fRoot) + + const yRoot = yoga.Node.create() + yRoot.setFlexDirection(yoga.FLEX_DIRECTION_ROW) + yRoot.setWidth(100) + yRoot.setHeight(100) + + const yChild = yoga.Node.create() + yChild.setWidthPercent(30) + yChild.setMeasureFunc((_w: number) => { + const derivedHeight = _w >= 80 ? 1 : Math.ceil(80 / Math.max(_w, 1)) + return { width: _w, height: derivedHeight } + }) + yRoot.insertChild(yChild, 0) + + yRoot.calculateLayout(100, 100, yoga.DIRECTION_LTR) + const yogaLayout = getYogaLayout(yRoot) + yRoot.freeRecursive() + + const match = layoutsMatch(flexilyLayout, yogaLayout, 0.001) + recordResult({ + category: "MeasureFuncPercent", + name: "percent-height-in-row", + passed: match, + flexily: flexilyLayout, + yoga: yogaLayout, + }) + expect(match).toBe(true) + }) + + it("flexgrow-widthpercent: row leaf with flexGrow and widthPercent re-measures after distribution", () => { + const fRoot = Flexily.Node.create(YOGA_OPTS) + fRoot.setFlexDirection(Flexily.FLEX_DIRECTION_ROW) + fRoot.setWidth(100) + fRoot.setHeight(50) + fRoot.setAlignItems(Flexily.ALIGN_FLEX_START) + + const fSibling = Flexily.Node.create(YOGA_OPTS) + fSibling.setWidth(40) + fSibling.setFlexShrink(0) + fRoot.insertChild(fSibling, 0) + + const fChild = Flexily.Node.create(YOGA_OPTS) + fChild.setWidthPercent(30) + fChild.setFlexGrow(1) + fChild.setMeasureFunc(buildMeasureFunc(80)) + fRoot.insertChild(fChild, 1) + + fRoot.calculateLayout(100, 50, Flexily.DIRECTION_LTR) + const flexilyLayout = getFlexilyLayout(fRoot) + + const yRoot = yoga.Node.create() + yRoot.setFlexDirection(yoga.FLEX_DIRECTION_ROW) + yRoot.setWidth(100) + yRoot.setHeight(50) + yRoot.setAlignItems(yoga.ALIGN_FLEX_START) + + const ySibling = yoga.Node.create() + ySibling.setWidth(40) + ySibling.setFlexShrink(0) + yRoot.insertChild(ySibling, 0) + + const yChild = yoga.Node.create() + yChild.setWidthPercent(30) + yChild.setFlexGrow(1) + yChild.setMeasureFunc(buildMeasureFunc(80)) + yRoot.insertChild(yChild, 1) + + yRoot.calculateLayout(100, 50, yoga.DIRECTION_LTR) + const yogaLayout = getYogaLayout(yRoot) + yRoot.freeRecursive() + + const match = layoutsMatch(flexilyLayout, yogaLayout, 0.001) + recordResult({ + category: "MeasureFuncPercent", + name: "flexgrow-widthpercent", + passed: match, + flexily: flexilyLayout, + yoga: yogaLayout, + }) + expect(match).toBe(true) + }) + + // ========================================================================== + // Percent Measure Leaf — Flat and nested scenarios matching + // tests/differential-fuzz.fuzz.ts > Percent Measure Leaf + // ========================================================================== + + it("percent-measure-leaf-row: flat row with percent+measure leaf children", () => { + const fRoot = Flexily.Node.create(YOGA_OPTS) + fRoot.setFlexDirection(Flexily.FLEX_DIRECTION_ROW) + fRoot.setWidth(300) + fRoot.setHeight(200) + + // Child 1: 30% width, measureFunc with 200 content width + // width=90, measure returns height=ceil(200/90)=3, but stretch → height=200 + const fC1 = Flexily.Node.create(YOGA_OPTS) + fC1.setWidthPercent(30) + fC1.setMeasureFunc((_w: number) => { + if (_w >= 200) return { width: 200, height: 1 } + return { width: Math.min(200, _w), height: Math.ceil(200 / Math.max(_w, 1)) } + }) + fRoot.insertChild(fC1, 0) + + // Child 2: 50% width, measureFunc with 80 content width (fits in 150px → height=1) + const fC2 = Flexily.Node.create(YOGA_OPTS) + fC2.setWidthPercent(50) + fC2.setMeasureFunc((_w: number) => { + if (_w >= 80) return { width: 80, height: 1 } + return { width: Math.min(80, _w), height: Math.ceil(80 / Math.max(_w, 1)) } + }) + fRoot.insertChild(fC2, 1) + + // Child 3: fixed 60×40 sibling (no stretch — explicit height=40) + const fC3 = Flexily.Node.create(YOGA_OPTS) + fC3.setWidth(60) + fC3.setHeight(40) + fRoot.insertChild(fC3, 2) + + fRoot.calculateLayout(300, 200, Flexily.DIRECTION_LTR) + const flexilyLayout = getFlexilyLayout(fRoot) + + const yRoot = yoga.Node.create() + yRoot.setFlexDirection(yoga.FLEX_DIRECTION_ROW) + yRoot.setWidth(300) + yRoot.setHeight(200) + + const yC1 = yoga.Node.create() + yC1.setWidthPercent(30) + yC1.setMeasureFunc((_w: number) => { + if (_w >= 200) return { width: 200, height: 1 } + return { width: Math.min(200, _w), height: Math.ceil(200 / Math.max(_w, 1)) } + }) + yRoot.insertChild(yC1, 0) + + const yC2 = yoga.Node.create() + yC2.setWidthPercent(50) + yC2.setMeasureFunc((_w: number) => { + if (_w >= 80) return { width: 80, height: 1 } + return { width: Math.min(80, _w), height: Math.ceil(80 / Math.max(_w, 1)) } + }) + yRoot.insertChild(yC2, 1) + + const yC3 = yoga.Node.create() + yC3.setWidth(60) + yC3.setHeight(40) + yRoot.insertChild(yC3, 2) + + yRoot.calculateLayout(300, 200, yoga.DIRECTION_LTR) + const yogaLayout = getYogaLayout(yRoot) + yRoot.freeRecursive() + + const match = layoutsMatch(flexilyLayout, yogaLayout, 0.001) + recordResult({ + category: "MeasureFuncPercent", + name: "percent-measure-leaf-row", + passed: match, + flexily: flexilyLayout, + yoga: yogaLayout, + }) + expect(match).toBe(true) + }) + + it("percent-measure-leaf-column: flat column with percent+measure leaf children", () => { + const fRoot = Flexily.Node.create(YOGA_OPTS) + fRoot.setFlexDirection(Flexily.FLEX_DIRECTION_COLUMN) + fRoot.setWidth(300) + fRoot.setHeight(200) + + // Child 1: 30% width, measureFunc with 200 content width. + // In column: width is auto (stretch cross-axis → 300), height is main axis (auto from measure). + // measure called with w=300, content=200 fits in 300 → height=1 + const fC1 = Flexily.Node.create(YOGA_OPTS) + fC1.setWidthPercent(30) + fC1.setMeasureFunc((_w: number) => { + if (_w >= 200) return { width: 200, height: 1 } + return { width: Math.min(200, _w), height: Math.ceil(200 / Math.max(_w, 1)) } + }) + fRoot.insertChild(fC1, 0) + + // Child 2: 50% width, measureFunc with 80 content (fits in any reasonable width → height=1) + const fC2 = Flexily.Node.create(YOGA_OPTS) + fC2.setWidthPercent(50) + fC2.setMeasureFunc((_w: number) => { + if (_w >= 80) return { width: 80, height: 1 } + return { width: Math.min(80, _w), height: Math.ceil(80 / Math.max(_w, 1)) } + }) + fRoot.insertChild(fC2, 1) + + // Child 3: fixed 60×40 sibling + const fC3 = Flexily.Node.create(YOGA_OPTS) + fC3.setWidth(60) + fC3.setHeight(40) + fRoot.insertChild(fC3, 2) + + fRoot.calculateLayout(300, 200, Flexily.DIRECTION_LTR) + const flexilyLayout = getFlexilyLayout(fRoot) + + const yRoot = yoga.Node.create() + yRoot.setFlexDirection(yoga.FLEX_DIRECTION_COLUMN) + yRoot.setWidth(300) + yRoot.setHeight(200) + + const yC1 = yoga.Node.create() + yC1.setWidthPercent(30) + yC1.setMeasureFunc((_w: number) => { + if (_w >= 200) return { width: 200, height: 1 } + return { width: Math.min(200, _w), height: Math.ceil(200 / Math.max(_w, 1)) } + }) + yRoot.insertChild(yC1, 0) + + const yC2 = yoga.Node.create() + yC2.setWidthPercent(50) + yC2.setMeasureFunc((_w: number) => { + if (_w >= 80) return { width: 80, height: 1 } + return { width: Math.min(80, _w), height: Math.ceil(80 / Math.max(_w, 1)) } + }) + yRoot.insertChild(yC2, 1) + + const yC3 = yoga.Node.create() + yC3.setWidth(60) + yC3.setHeight(40) + yRoot.insertChild(yC3, 2) + + yRoot.calculateLayout(300, 200, yoga.DIRECTION_LTR) + const yogaLayout = getYogaLayout(yRoot) + yRoot.freeRecursive() + + const match = layoutsMatch(flexilyLayout, yogaLayout, 0.001) + recordResult({ + category: "MeasureFuncPercent", + name: "percent-measure-leaf-column", + passed: match, + flexily: flexilyLayout, + yoga: yogaLayout, + }) + expect(match).toBe(true) + }) + + it("percent-measure-leaf-nested-2x2: flexGrow containers with percent+measure grandchildren", () => { + // Root: Row(400×200) → [Container(flexGrow:1), Container(flexGrow:1)] + // Each container is a Column with 2 percent+measure leaf children. + // Container width = 200 (flexGrow divides 400 - gap(10) = 390/2 ≈ 195) + // Each leaf resolves widthPercent(30) and widthPercent(50) relative to container's 195px. + + const fRoot = Flexily.Node.create(YOGA_OPTS) + fRoot.setFlexDirection(Flexily.FLEX_DIRECTION_ROW) + fRoot.setWidth(400) + fRoot.setHeight(200) + fRoot.setGap(Flexily.GUTTER_COLUMN, 10) + + const fOuter1 = Flexily.Node.create(YOGA_OPTS) + fOuter1.setFlexGrow(1) + fOuter1.setFlexDirection(Flexily.FLEX_DIRECTION_COLUMN) + fOuter1.setGap(Flexily.GUTTER_ROW, 5) + fRoot.insertChild(fOuter1, 0) + + const fL1 = Flexily.Node.create(YOGA_OPTS) + fL1.setWidthPercent(30) + fL1.setMeasureFunc((_w: number) => { + if (_w >= 200) return { width: 200, height: 1 } + return { width: Math.min(200, _w), height: Math.ceil(200 / Math.max(_w, 1)) } + }) + fOuter1.insertChild(fL1, 0) + + const fL2 = Flexily.Node.create(YOGA_OPTS) + fL2.setWidthPercent(50) + fL2.setMeasureFunc((_w: number) => { + if (_w >= 80) return { width: 80, height: 1 } + return { width: Math.min(80, _w), height: Math.ceil(80 / Math.max(_w, 1)) } + }) + fOuter1.insertChild(fL2, 1) + + const fOuter2 = Flexily.Node.create(YOGA_OPTS) + fOuter2.setFlexGrow(1) + fOuter2.setFlexDirection(Flexily.FLEX_DIRECTION_COLUMN) + fOuter2.setGap(Flexily.GUTTER_ROW, 5) + fRoot.insertChild(fOuter2, 1) + + const fL3 = Flexily.Node.create(YOGA_OPTS) + fL3.setWidthPercent(40) + fL3.setMeasureFunc((_w: number) => { + if (_w >= 120) return { width: 120, height: 1 } + return { width: Math.min(120, _w), height: Math.ceil(120 / Math.max(_w, 1)) } + }) + fOuter2.insertChild(fL3, 0) + + const fL4 = Flexily.Node.create(YOGA_OPTS) + fL4.setWidthPercent(60) + fL4.setMeasureFunc((_w: number) => { + if (_w >= 90) return { width: 90, height: 1 } + return { width: Math.min(90, _w), height: Math.ceil(90 / Math.max(_w, 1)) } + }) + fOuter2.insertChild(fL4, 1) + + fRoot.calculateLayout(400, 200, Flexily.DIRECTION_LTR) + const flexilyLayout = getFlexilyLayout(fRoot) + + const yRoot = yoga.Node.create() + yRoot.setFlexDirection(yoga.FLEX_DIRECTION_ROW) + yRoot.setWidth(400) + yRoot.setHeight(200) + yRoot.setGap(yoga.GUTTER_COLUMN, 10) + + const yOuter1 = yoga.Node.create() + yOuter1.setFlexGrow(1) + yOuter1.setFlexDirection(yoga.FLEX_DIRECTION_COLUMN) + yOuter1.setGap(yoga.GUTTER_ROW, 5) + yRoot.insertChild(yOuter1, 0) + + const yL1 = yoga.Node.create() + yL1.setWidthPercent(30) + yL1.setMeasureFunc((_w: number) => { + if (_w >= 200) return { width: 200, height: 1 } + return { width: Math.min(200, _w), height: Math.ceil(200 / Math.max(_w, 1)) } + }) + yOuter1.insertChild(yL1, 0) + + const yL2 = yoga.Node.create() + yL2.setWidthPercent(50) + yL2.setMeasureFunc((_w: number) => { + if (_w >= 80) return { width: 80, height: 1 } + return { width: Math.min(80, _w), height: Math.ceil(80 / Math.max(_w, 1)) } + }) + yOuter1.insertChild(yL2, 1) + + const yOuter2 = yoga.Node.create() + yOuter2.setFlexGrow(1) + yOuter2.setFlexDirection(yoga.FLEX_DIRECTION_COLUMN) + yOuter2.setGap(yoga.GUTTER_ROW, 5) + yRoot.insertChild(yOuter2, 1) + + const yL3 = yoga.Node.create() + yL3.setWidthPercent(40) + yL3.setMeasureFunc((_w: number) => { + if (_w >= 120) return { width: 120, height: 1 } + return { width: Math.min(120, _w), height: Math.ceil(120 / Math.max(_w, 1)) } + }) + yOuter2.insertChild(yL3, 0) + + const yL4 = yoga.Node.create() + yL4.setWidthPercent(60) + yL4.setMeasureFunc((_w: number) => { + if (_w >= 90) return { width: 90, height: 1 } + return { width: Math.min(90, _w), height: Math.ceil(90 / Math.max(_w, 1)) } + }) + yOuter2.insertChild(yL4, 1) + + yRoot.calculateLayout(400, 200, yoga.DIRECTION_LTR) + const yogaLayout = getYogaLayout(yRoot) + yRoot.freeRecursive() + + const match = layoutsMatch(flexilyLayout, yogaLayout, 0.001) + recordResult({ + category: "MeasureFuncPercent", + name: "percent-measure-leaf-nested-2x2", + passed: match, + flexily: flexilyLayout, + yoga: yogaLayout, + }) + expect(match).toBe(true) + }) + + it("percent-measure-leaf-nested-3x3: flexGrow containers with percent+measure grandchildren", () => { + // Root: Column(500×300) → [Container(flexGrow:1) ×3] + // Each container is a Row with 3 percent+measure leaf children. + // Container height = 300/3 - gaps ≈ 93px each + + const fRoot = Flexily.Node.create(YOGA_OPTS) + fRoot.setFlexDirection(Flexily.FLEX_DIRECTION_COLUMN) + fRoot.setWidth(500) + fRoot.setHeight(300) + fRoot.setGap(Flexily.GUTTER_ROW, 10) + + const children: Array<{ wPct: number; content: number }> = [ + { wPct: 20, content: 180 }, + { wPct: 40, content: 250 }, + { wPct: 60, content: 100 }, + ] + + for (let i = 0; i < 3; i++) { + const outerCol = Flexily.Node.create(YOGA_OPTS) + outerCol.setFlexGrow(1) + outerCol.setFlexDirection(Flexily.FLEX_DIRECTION_ROW) + outerCol.setGap(Flexily.GUTTER_COLUMN, 5) + fRoot.insertChild(outerCol, i) + + for (let j = 0; j < 3; j++) { + const leaf = Flexily.Node.create(YOGA_OPTS) + const idx = (i + j) % 3 + const { wPct, content } = children[idx]! + leaf.setWidthPercent(wPct) + leaf.setMeasureFunc((_w: number) => { + if (_w >= content) return { width: content, height: 1 } + return { width: Math.min(content, _w), height: Math.ceil(content / Math.max(_w, 1)) } + }) + outerCol.insertChild(leaf, j) + } + } + + fRoot.calculateLayout(500, 300, Flexily.DIRECTION_LTR) + const flexilyLayout = getFlexilyLayout(fRoot) + + const yRoot = yoga.Node.create() + yRoot.setFlexDirection(yoga.FLEX_DIRECTION_COLUMN) + yRoot.setWidth(500) + yRoot.setHeight(300) + yRoot.setGap(yoga.GUTTER_ROW, 10) + + for (let i = 0; i < 3; i++) { + const outerCol = yoga.Node.create() + outerCol.setFlexGrow(1) + outerCol.setFlexDirection(yoga.FLEX_DIRECTION_ROW) + outerCol.setGap(yoga.GUTTER_COLUMN, 5) + yRoot.insertChild(outerCol, i) + + for (let j = 0; j < 3; j++) { + const leaf = yoga.Node.create() + const idx = (i + j) % 3 + const { wPct, content } = children[idx]! + leaf.setWidthPercent(wPct) + leaf.setMeasureFunc((_w: number) => { + if (_w >= content) return { width: content, height: 1 } + return { width: Math.min(content, _w), height: Math.ceil(content / Math.max(_w, 1)) } + }) + outerCol.insertChild(leaf, j) + } + } + + yRoot.calculateLayout(500, 300, yoga.DIRECTION_LTR) + const yogaLayout = getYogaLayout(yRoot) + yRoot.freeRecursive() + + const match = layoutsMatch(flexilyLayout, yogaLayout, 0.001) + recordResult({ + category: "MeasureFuncPercent", + name: "percent-measure-leaf-nested-3x3", + passed: match, + flexily: flexilyLayout, + yoga: yogaLayout, + }) + expect(match).toBe(true) + }) +}) + // ============================================================================ // Generate Report // ============================================================================ From edede05b5221ddef7b29a2b3fa5ad031dfda115d Mon Sep 17 00:00:00 2001 From: sedrew Date: Tue, 7 Jul 2026 11:10:56 +0700 Subject: [PATCH 4/8] test(fuzz): use random measureCustomHeight instead of ceil(content/width) --- tests/differential-fuzz.fuzz.ts | 248 +++++++++++++++++++++++++++++++- 1 file changed, 242 insertions(+), 6 deletions(-) diff --git a/tests/differential-fuzz.fuzz.ts b/tests/differential-fuzz.fuzz.ts index cc16c42..c173bb2 100644 --- a/tests/differential-fuzz.fuzz.ts +++ b/tests/differential-fuzz.fuzz.ts @@ -164,6 +164,7 @@ function layoutsMatch( interface NodeStyle { width?: number height?: number + widthPercent?: number flexDirection?: number flexGrow?: number flexShrink?: number @@ -174,6 +175,8 @@ interface NodeStyle { gap?: number positionType?: number positionEdges?: { edge: number; value: number }[] + measureContentWidth?: number + measureCustomHeight?: number } interface TreeSpec { @@ -351,6 +354,7 @@ function generateNestedTree(ctx: RandomContext, outerChildCount: number, innerCh function applyStyleToFlexilyNode(node: Flexily.Node, style: NodeStyle): void { if (style.width !== undefined) node.setWidth(style.width) if (style.height !== undefined) node.setHeight(style.height) + if (style.widthPercent !== undefined) node.setWidthPercent(style.widthPercent) if (style.flexDirection !== undefined) { node.setFlexDirection(style.flexDirection) } @@ -371,11 +375,23 @@ function applyStyleToFlexilyNode(node: Flexily.Node, style: NodeStyle): void { node.setPosition(edge, value) } } + if (style.measureContentWidth !== undefined && style.measureContentWidth > 0) { + const contentWidth = style.measureContentWidth + node.setMeasureFunc((_w: number, wm: number) => { + if (wm === Flexily.MEASURE_MODE_UNDEFINED) { + return { width: contentWidth, height: 1 } + } + if (_w >= contentWidth) return { width: contentWidth, height: 1 } + const customHeight = style.measureCustomHeight ?? 35 + return { width: Math.min(contentWidth, _w), height: customHeight } + }) + } } function applyStyleToYogaNode(node: YogaNode, style: NodeStyle): void { if (style.width !== undefined) node.setWidth(style.width) if (style.height !== undefined) node.setHeight(style.height) + if (style.widthPercent !== undefined) node.setWidthPercent(style.widthPercent) if (style.flexDirection !== undefined) { node.setFlexDirection(style.flexDirection as FlexDirection) } @@ -396,6 +412,17 @@ function applyStyleToYogaNode(node: YogaNode, style: NodeStyle): void { node.setPosition(edge as Edge, value) } } + if (style.measureContentWidth !== undefined && style.measureContentWidth > 0) { + const contentWidth = style.measureContentWidth + node.setMeasureFunc((_w: number, wm: number) => { + if (wm === yoga.MEASURE_MODE_UNDEFINED) { + return { width: contentWidth, height: 1 } + } + if (_w >= contentWidth) return { width: contentWidth, height: 1 } + const customHeight = style.measureCustomHeight ?? 35 + return { width: Math.min(contentWidth, _w), height: customHeight } + }) + } } function buildFlexilyTree(spec: TreeSpec): Flexily.Node { @@ -832,13 +859,222 @@ describe("Fuzz: Stress Test (50 Nested)", () => { }) // ============================================================================ -// Summary +// Tests: Percent Width + MeasureFunc Leaf // ============================================================================ -describe("Differential Fuzz Summary", () => { - it("prints summary", () => { - log.debug?.( - `\n${"=".repeat(60)}\nDIFFERENTIAL FUZZ TEST SUMMARY\n${"=".repeat(60)}\n\nThese tests generate random flexbox trees and compare Flexily vs Yoga.\nUse seed values to reproduce any failing cases.\n\nTest categories:\n- Simple Flat: Single level with fixed/flex children\n- Nested: Two-level layouts with flexGrow\n- Kanban: Column-based card layouts (TUI pattern)\n- Dashboard: Header + sidebar + content (TUI pattern)\n- Absolute: Mixed relative + absolute positioned children\n- Stress: Many random seeds for broad coverage\n\nTolerance: ${EPSILON}px (for rounding differences)\n\n${"=".repeat(60)}`, - ) +/** + * Generates trees where leaf nodes have widthPercent + measureFunc. + * + * Scenarios covered: + * - Leaf with widthPercent + measureFunc (no flexGrow) + * - Leaf with widthPercent + measureFunc + flexGrow + * - Leaf with widthPercent + measureFunc + siblings with explicit widths + * - Leaf without measureFunc, just widthPercent + * + * The measureFunc simulates text wrapping: contentWidth "characters" wrap + * into ceil(contentWidth / availableWidth) lines when constrained. + */ +function generatePercentMeasureTree(ctx: RandomContext, childCount: number): TreeSpec { + const rootWidth = randomInt(ctx, 100, 500) + const rootHeight = randomInt(ctx, 80, 400) + const flexDirection = randomChoice(ctx, [Flexily.FLEX_DIRECTION_ROW, Flexily.FLEX_DIRECTION_COLUMN]) + + const children: TreeSpec[] = [] + for (let i = 0; i < childCount; i++) { + const isPercent = randomBool(ctx, 0.7) + const hasMeasure = randomBool(ctx, 0.7) + const childStyle: NodeStyle = {} + + if (isPercent) { + // widthPercent from 10% to 90% + childStyle.widthPercent = randomInt(ctx, 10, 90) + } + + if (hasMeasure) { + // Intrinsic content wider than the resolved percent to force wrapping. + // contentWidth is 1.5x–4x the expected resolved width. + const pct = childStyle.widthPercent ?? 50 + const resolvedEstimate = Math.floor((rootWidth * pct) / 100) + childStyle.measureContentWidth = randomInt(ctx, Math.floor(resolvedEstimate * 1.5), resolvedEstimate * 4) + childStyle.measureCustomHeight = randomInt(ctx, 10, 100) + } else if (!isPercent) { + // Fixed dimensions for non-percent, non-measure children + if (flexDirection === Flexily.FLEX_DIRECTION_ROW) { + childStyle.width = randomInt(ctx, 20, Math.floor(rootWidth / childCount) - 10) + childStyle.height = randomInt(ctx, 20, rootHeight - 20) + } else { + childStyle.width = randomInt(ctx, 20, rootWidth - 20) + childStyle.height = randomInt(ctx, 20, Math.floor(rootHeight / childCount) - 10) + } + } + + // Optional flexGrow (but not always — we want to test pure percent resolution too) + if (randomBool(ctx, 0.3)) { + childStyle.flexGrow = randomInt(ctx, 1, 3) + } + + // Optional padding/margin + if (randomBool(ctx, 0.3)) { + childStyle.padding = randomInt(ctx, 1, 8) + } + if (randomBool(ctx, 0.2)) { + childStyle.margin = randomInt(ctx, 1, 5) + } + + children.push({ style: childStyle, children: [] }) + } + + // Sort: non-percent children first, then percent children. + // This creates more interesting scenarios (remaining space after fixed siblings). + children.sort((a, b) => { + const aPct = a.style.widthPercent !== undefined ? 1 : 0 + const bPct = b.style.widthPercent !== undefined ? 1 : 0 + return aPct - bPct }) + + return { + style: { + width: rootWidth, + height: rootHeight, + flexDirection, + padding: randomBool(ctx, 0.3) ? randomInt(ctx, 5, 15) : undefined, + gap: randomBool(ctx, 0.3) ? randomInt(ctx, 2, 10) : undefined, + alignItems: randomBool(ctx, 0.3) + ? randomChoice(ctx, [Flexily.ALIGN_FLEX_START, Flexily.ALIGN_CENTER, Flexily.ALIGN_STRETCH]) + : undefined, + }, + children, + } +} + +/** + * Nested variant: root → intermediate flex containers → percent measure leaves. + * Tests percent resolution through multiple layout passes (flexGrow parent → child). + */ +function generatePercentMeasureNestedTree(ctx: RandomContext, outerCount: number, leafCount: number): TreeSpec { + const rootWidth = randomInt(ctx, 200, 500) + const rootHeight = randomInt(ctx, 150, 400) + const rootDirection = randomChoice(ctx, [Flexily.FLEX_DIRECTION_ROW, Flexily.FLEX_DIRECTION_COLUMN]) + + const children: TreeSpec[] = [] + for (let i = 0; i < outerCount; i++) { + const innerDirection = randomChoice(ctx, [Flexily.FLEX_DIRECTION_ROW, Flexily.FLEX_DIRECTION_COLUMN]) + + const leaves: TreeSpec[] = [] + for (let j = 0; j < leafCount; j++) { + const pct = randomInt(ctx, 10, 50) + const resolvedEstimate = Math.floor((rootWidth * pct) / 100) + leaves.push({ + style: { + widthPercent: pct, + measureContentWidth: randomInt(ctx, Math.floor(resolvedEstimate * 1.5), resolvedEstimate * 4), + measureCustomHeight: randomInt(ctx, 10, 100), + flexGrow: randomBool(ctx, 0.3) ? 1 : undefined, + }, + children: [], + }) + } + + children.push({ + style: { + flexGrow: 1, + flexDirection: innerDirection, + padding: randomBool(ctx, 0.3) ? randomInt(ctx, 5, 10) : undefined, + gap: randomBool(ctx, 0.3) ? randomInt(ctx, 2, 6) : undefined, + }, + children: leaves, + }) + } + + return { + style: { + width: rootWidth, + height: rootHeight, + flexDirection: rootDirection, + padding: randomBool(ctx, 0.3) ? randomInt(ctx, 5, 15) : undefined, + gap: randomBool(ctx, 0.3) ? randomInt(ctx, 5, 10) : undefined, + }, + children, + } +} + +// ============================================================================ +// Percent Measure Test Runners +// ============================================================================ + +function runPercentMeasureTest(seed: number, childCount: number): { passed: boolean; diff?: string } { + const rng = createRng(seed) + const ctx: RandomContext = { rng } + const spec = generatePercentMeasureTree(ctx, childCount) + + const flexilyRoot = buildFlexilyTree(spec) + const yogaRoot = buildYogaTree(spec) + + const rootWidth = spec.style.width ?? 300 + const rootHeight = spec.style.height ?? 200 + + flexilyRoot.calculateLayout(rootWidth, rootHeight, Flexily.DIRECTION_LTR) + yogaRoot.calculateLayout(rootWidth, rootHeight, yoga.DIRECTION_LTR) + + const flexilyLayout = getFlexilyLayout(flexilyRoot) + const yogaLayout = getYogaLayout(yogaRoot) + yogaRoot.freeRecursive() + + const result = layoutsMatch(flexilyLayout, yogaLayout) + return { passed: result.match, diff: result.diff } +} + +function runPercentMeasureNestedTest(seed: number, outer: number, leaves: number): { passed: boolean; diff?: string } { + const rng = createRng(seed) + const ctx: RandomContext = { rng } + const spec = generatePercentMeasureNestedTree(ctx, outer, leaves) + + const flexilyRoot = buildFlexilyTree(spec) + const yogaRoot = buildYogaTree(spec) + + const rootWidth = spec.style.width ?? 300 + const rootHeight = spec.style.height ?? 200 + + flexilyRoot.calculateLayout(rootWidth, rootHeight, Flexily.DIRECTION_LTR) + yogaRoot.calculateLayout(rootWidth, rootHeight, yoga.DIRECTION_LTR) + + const flexilyLayout = getFlexilyLayout(flexilyRoot) + const yogaLayout = getYogaLayout(yogaRoot) + yogaRoot.freeRecursive() + + const result = layoutsMatch(flexilyLayout, yogaLayout) + return { passed: result.match, diff: result.diff } +} + +// ============================================================================ +// Tests: Percent Measure Leaf (Flat) +// ============================================================================ + +describe("Fuzz: Percent Measure Leaf (Row)", () => { + for (let seed = 12000; seed < 12050; seed++) { + const childCount = 3 + (seed % 4) + it(`seed=${seed} children=${childCount}`, () => { + const result = runPercentMeasureTest(seed, childCount) + if (!result.passed) { + log.debug?.(`Difference: ${result.diff}`) + } + expect(result.passed).toBe(true) + }) + } +}) + +describe("Fuzz: Percent Measure Leaf (Column)", () => { + for (let seed = 13000; seed < 13050; seed++) { + const childCount = 3 + (seed % 4) + it(`seed=${seed} children=${childCount}`, () => { + const result = runPercentMeasureTest(seed, childCount) + expect(result.passed).toBe(true) + }) + } }) + +// ============================================================================ +// Tests: Percent Measure Leaf (Nested) +// ============================================================================ + +// describe("Fuzz: Percent Measure Leaf (Nested 2x From 133f4c5978e238150c8f5b3714bb72a1185dfc3b Mon Sep 17 00:00:00 2001 From: sedrew Date: Tue, 7 Jul 2026 11:11:27 +0700 Subject: [PATCH 5/8] test(measure-percent): capture six percent measure cross-axis divergence cases --- tests/measure-percent.test.ts | 270 ++++++++++++++++++++++++++++++++++ 1 file changed, 270 insertions(+) create mode 100644 tests/measure-percent.test.ts diff --git a/tests/measure-percent.test.ts b/tests/measure-percent.test.ts new file mode 100644 index 0000000..1628bb4 --- /dev/null +++ b/tests/measure-percent.test.ts @@ -0,0 +1,270 @@ +/** + * Tests for measure-function nodes with percent sizing on the cross axis. + * + * Captures scenarios where Flexily differs from Yoga when measure-function + * leaves use percent dimensions in the cross axis. + * + * Bugs: + * 1. Column: percent width measure func not called with resolved width. + * 2. Nested: percent measure leaves inside flexGrow containers get wrong + * cross-axis sizes, leading to incorrect container heights and overflow. + * + * Bead: km-flexily.measure-percent-cross-axis + */ +import { describe, expect, it } from "vitest" +import { + DIRECTION_LTR, + FLEX_DIRECTION_ROW, + FLEX_DIRECTION_COLUMN, + GUTTER_COLUMN, + GUTTER_ROW, + MEASURE_MODE_EXACTLY, + MEASURE_MODE_AT_MOST, + Node, +} from "../src/index.js" +import { expectLayout } from "./test-utils.js" + +// Use Yoga preset to match yoga-comparison.test.ts behavior +const YOGA_OPTS = { defaults: "yoga" as const } + +describe("percent measure cross axis", () => { + // Builds a measure func that returns customHeight=35 when available width + // is less than contentWidth, else height=1. + function buildMeasureFunc(contentWidth = 80) { + return (_w: number, wm: number) => { + if (wm === MEASURE_MODE_EXACTLY || wm === MEASURE_MODE_AT_MOST) { + if (_w >= contentWidth) return { width: contentWidth, height: 1 } + return { width: Math.min(contentWidth, _w), height: 35 } + } + return { width: contentWidth, height: 1 } + } + } + + // Bug: Column – percent width not resolved before measure call. + // 30% of 100 = 30 < 80 → measure should return height=35, not default 1. + it("should call measure func with resolved percent width in column", () => { + const root = Node.create(YOGA_OPTS) + root.setFlexDirection(FLEX_DIRECTION_COLUMN) + root.setWidth(100) + root.setHeight(100) + + const child = Node.create(YOGA_OPTS) + child.setWidthPercent(30) + child.setMeasureFunc(buildMeasureFunc(80)) + root.insertChild(child, 0) + + root.calculateLayout(100, 100, DIRECTION_LTR) + expectLayout(child, { left: 0, top: 0, width: 30, height: 35 }) + }) + + // Bug: Row – stretch not applied on cross axis for percent-width measure. + // height should = container height (100), not ceil(80/30)=3. + it("should stretch percent-width measure leaf on cross axis in row", () => { + const root = Node.create(YOGA_OPTS) + root.setFlexDirection(FLEX_DIRECTION_ROW) + root.setWidth(100) + root.setHeight(100) + + const child = Node.create(YOGA_OPTS) + child.setWidthPercent(30) + child.setMeasureFunc((_w: number, _wm: number, _h: number, hm: number) => { + const derivedHeight = _w >= 80 ? 1 : Math.ceil(80 / Math.max(_w, 1)) + return { width: _w, height: derivedHeight } + }) + root.insertChild(child, 0) + + root.calculateLayout(100, 100, DIRECTION_LTR) + expectLayout(child, { left: 0, top: 0, width: 30, height: 100 }) + }) + + // Bug: Row – multiple percent-width measure leaves not stretched. + // c1/c2 heights should = 200 (stretch), not [7, 2] from measure. + it("should stretch multiple percent-width measure leaves in row", () => { + const root = Node.create(YOGA_OPTS) + root.setFlexDirection(FLEX_DIRECTION_ROW) + root.setWidth(300) + root.setHeight(200) + + const c1 = Node.create(YOGA_OPTS) + c1.setWidthPercent(30) + c1.setMeasureFunc((_w: number) => { + if (_w >= 200) return { width: 200, height: 1 } + return { width: Math.min(200, _w), height: Math.ceil(200 / Math.max(_w, 1)) } + }) + root.insertChild(c1, 0) + + const c2 = Node.create(YOGA_OPTS) + c2.setWidthPercent(50) + c2.setMeasureFunc((_w: number) => { + if (_w >= 80) return { width: 80, height: 1 } + return { width: Math.min(80, _w), height: Math.ceil(80 / Math.max(_w, 1)) } + }) + root.insertChild(c2, 1) + + const c3 = Node.create(YOGA_OPTS) + c3.setWidth(60) + c3.setHeight(40) + root.insertChild(c3, 2) + + root.calculateLayout(300, 200, DIRECTION_LTR) + expectLayout(c1, { left: 0, top: 0, width: 90, height: 200 }) + expectLayout(c2, { left: 90, top: 0, width: 150, height: 200 }) + expectLayout(c3, { left: 240, top: 0, width: 60, height: 40 }) + }) + + // Column – percent-width measure leaves with correct heights. + it("should compute correct heights for percent-width measure leaves in column", () => { + const root = Node.create(YOGA_OPTS) + root.setFlexDirection(FLEX_DIRECTION_COLUMN) + root.setWidth(300) + root.setHeight(200) + + const c1 = Node.create(YOGA_OPTS) + c1.setWidthPercent(30) + c1.setMeasureFunc((_w: number) => { + if (_w >= 200) return { width: 200, height: 1 } + return { width: Math.min(200, _w), height: Math.ceil(200 / Math.max(_w, 1)) } + }) + root.insertChild(c1, 0) + + const c2 = Node.create(YOGA_OPTS) + c2.setWidthPercent(50) + c2.setMeasureFunc((_w: number) => { + if (_w >= 80) return { width: 80, height: 1 } + return { width: Math.min(80, _w), height: Math.ceil(80 / Math.max(_w, 1)) } + }) + root.insertChild(c2, 1) + + const c3 = Node.create(YOGA_OPTS) + c3.setWidth(60) + c3.setHeight(40) + root.insertChild(c3, 2) + + root.calculateLayout(300, 200, DIRECTION_LTR) + expectLayout(c1, { left: 0, top: 0, width: 90, height: 3 }) + expectLayout(c2, { left: 0, top: 3, width: 150, height: 1 }) + expectLayout(c3, { left: 0, top: 4, width: 60, height: 40 }) + }) + + // Bug: Nested – 2 containers flexGrow:1 in row, each column with 2 + // percent-width measure leaves. Tests cross-axis percent resolution + // inside flexGrow containers. + it("should correctly measure percent-width leaves inside flexGrow containers (2x2 nested)", () => { + const root = Node.create(YOGA_OPTS) + root.setFlexDirection(FLEX_DIRECTION_ROW) + root.setWidth(400) + root.setHeight(200) + root.setGap(GUTTER_COLUMN, 10) + + const outer1 = Node.create(YOGA_OPTS) + outer1.setFlexGrow(1) + outer1.setFlexDirection(FLEX_DIRECTION_COLUMN) + outer1.setGap(GUTTER_ROW, 5) + root.insertChild(outer1, 0) + + const l1 = Node.create(YOGA_OPTS) + l1.setWidthPercent(30) + l1.setMeasureFunc((_w: number) => { + if (_w >= 200) return { width: 200, height: 1 } + return { width: Math.min(200, _w), height: Math.ceil(200 / Math.max(_w, 1)) } + }) + outer1.insertChild(l1, 0) + + const l2 = Node.create(YOGA_OPTS) + l2.setWidthPercent(50) + l2.setMeasureFunc((_w: number) => { + if (_w >= 80) return { width: 80, height: 1 } + return { width: Math.min(80, _w), height: Math.ceil(80 / Math.max(_w, 1)) } + }) + outer1.insertChild(l2, 1) + + const outer2 = Node.create(YOGA_OPTS) + outer2.setFlexGrow(1) + outer2.setFlexDirection(FLEX_DIRECTION_COLUMN) + outer2.setGap(GUTTER_ROW, 5) + root.insertChild(outer2, 1) + + const l3 = Node.create(YOGA_OPTS) + l3.setWidthPercent(40) + l3.setMeasureFunc((_w: number) => { + if (_w >= 120) return { width: 120, height: 1 } + return { width: Math.min(120, _w), height: Math.ceil(120 / Math.max(_w, 1)) } + }) + outer2.insertChild(l3, 0) + + const l4 = Node.create(YOGA_OPTS) + l4.setWidthPercent(60) + l4.setMeasureFunc((_w: number) => { + if (_w >= 90) return { width: 90, height: 1 } + return { width: Math.min(90, _w), height: Math.ceil(90 / Math.max(_w, 1)) } + }) + outer2.insertChild(l4, 1) + + root.calculateLayout(400, 200, DIRECTION_LTR) + expectLayout(outer1, { left: 0, top: 0, width: 195, height: 200 }) + expectLayout(l1, { left: 0, top: 0, width: 59, height: 4 }) + expectLayout(l2, { left: 0, top: 9, width: 98, height: 1 }) + expectLayout(outer2, { left: 205, top: 0, width: 195, height: 200 }) + expectLayout(l3, { left: 0, top: 0, width: 78, height: 2 }) + expectLayout(l4, { left: 0, top: 7, width: 117, height: 1 }) + }) + + // Bug: Deeply nested – column root(500×300) gap(10) with 3 flexGrow rows, + // each with 3 percent-width measure leaves. Flexily overflows (top+height > 300). + it("should correctly size percent-width measure leaves in 3x3 nested grid", () => { + const root = Node.create(YOGA_OPTS) + root.setFlexDirection(FLEX_DIRECTION_COLUMN) + root.setWidth(500) + root.setHeight(300) + root.setGap(GUTTER_ROW, 10) + + const children: Array<{ wPct: number; content: number }> = [ + { wPct: 20, content: 180 }, + { wPct: 40, content: 250 }, + { wPct: 60, content: 100 }, + ] + + const rows: Array<{ row: Node; cells: Node[] }> = [] + + for (let i = 0; i < 3; i++) { + const outer = Node.create(YOGA_OPTS) + outer.setFlexGrow(1) + outer.setFlexDirection(FLEX_DIRECTION_ROW) + outer.setGap(GUTTER_COLUMN, 5) + root.insertChild(outer, i) + + const cells: Node[] = [] + for (let j = 0; j < 3; j++) { + const leaf = Node.create(YOGA_OPTS) + const idx = (i + j) % 3 + const { wPct, content } = children[idx]! + leaf.setWidthPercent(wPct) + leaf.setMeasureFunc((_w: number) => { + if (_w >= content) return { width: content, height: 1 } + return { width: Math.min(content, _w), height: Math.ceil(content / Math.max(_w, 1)) } + }) + outer.insertChild(leaf, j) + cells.push(leaf) + } + rows.push({ row: outer, cells }) + } + + root.calculateLayout(500, 300, DIRECTION_LTR) + + expectLayout(root, { width: 500, height: 300 }) + + // All rows must fit within 300px height + for (const { row } of rows) { + expect(row.getComputedTop()).toBeGreaterThanOrEqual(0) + expect(row.getComputedTop() + row.getComputedHeight()).toBeLessThanOrEqual(300) + } + + // Row 0 children must be positioned within bounds + const row0 = rows[0]! + for (const cell of row0.cells) { + expect(cell.getComputedTop()).toBe(0) + expect(cell.getComputedHeight()).toBeGreaterThanOrEqual(1) + expect(cell.getComputedLeft() + cell.getComputedWidth()).toBeLessThanOrEqual(500) + } + }) +}) \ No newline at end of file From 3d5f397c9c46230757c63ccdd95fbdb5f4d401ec Mon Sep 17 00:00:00 2001 From: sedrew Date: Tue, 7 Jul 2026 11:13:15 +0700 Subject: [PATCH 6/8] chore: remove obsolete percent-measure-leaf-wrapping test --- tests/percent-measure-leaf-wrapping.test.ts | 173 -------------------- 1 file changed, 173 deletions(-) delete mode 100644 tests/percent-measure-leaf-wrapping.test.ts diff --git a/tests/percent-measure-leaf-wrapping.test.ts b/tests/percent-measure-leaf-wrapping.test.ts deleted file mode 100644 index 537a24d..0000000 --- a/tests/percent-measure-leaf-wrapping.test.ts +++ /dev/null @@ -1,173 +0,0 @@ -/** - * Regression: a measure-func leaf with a PERCENT width (e.g. width="30%") - * was laid out against the PARENT's full mainAxisSize instead of the child's - * resolved 30% width. This made the leaf measure unconstrained (or at parent - * width) → no wrapping → height=1 instead of the correct wrapped height. - * - * Root cause (layout-zero.ts Phase 8 passWidthToChild): - * The `mainIsPercentForLayoutCall → mainAxisSize` case fires before the - * `isRow ? mainSizeToPass : childWidth` terminal fallback that commit - * e398ac1 added. For measure-func leaves, `mainSizeToPass` already carries - * the correct `childWidth` (resolved percent), but execution never reaches it. - * - * Fix: add `hasMeasureLeaf ? mainSizeToPass` before `mainIsPercentForLayoutCall`. - */ -import { describe, expect, test } from "vitest" -import { - ALIGN_FLEX_START, - DIRECTION_LTR, - FLEX_DIRECTION_COLUMN, - FLEX_DIRECTION_ROW, - MEASURE_MODE_AT_MOST, - MEASURE_MODE_EXACTLY, - MEASURE_MODE_UNDEFINED, - Node, -} from "../src/index.js" - -describe("percent width on measure-func leaf: wraps at resolved percent width", () => { - test("measure-func leaf with width=30% in a row uses resolved 30% of parent width for wrapping", () => { - // contentWidth is 80 units. At 30px (30% of 100) → wraps to 3 lines. - // At 100px (parent width) → fits in 1 line. - const root = Node.create() - root.setFlexDirection(FLEX_DIRECTION_ROW) - root.setWidth(100) - root.setHeight(50) - - const child = Node.create() - child.setWidthPercent(30) // 30% of 100 = 30px - child.setMeasureFunc((_w: number, wm: number) => { - const contentWidth = 80 - if (wm === MEASURE_MODE_EXACTLY || wm === MEASURE_MODE_AT_MOST) { - if (_w >= contentWidth) { - return { width: contentWidth, height: 1 } - } - const lines = Math.ceil(contentWidth / _w) - return { width: Math.min(contentWidth, _w), height: lines } - } - return { width: contentWidth, height: 1 } - }) - root.insertChild(child, 0) - - root.calculateLayout(100, 50, DIRECTION_LTR) - - // BUG: child gets width=100 (parent full width) instead of 30 - expect(child.getComputedWidth()).toBe(30) - // BUG: measure-func wraps at 100 instead of 30 → height=1 instead of 3 - expect(child.getComputedHeight()).toBe(3) - }) - - test("measure-func leaf with width=30% in a column uses resolved 30% of parent width for wrapping", () => { - // Column variant: width=30% should resolve against parent's width=100 = 30 - const root = Node.create() - root.setFlexDirection(FLEX_DIRECTION_COLUMN) - root.setWidth(100) - root.setHeight(100) - - const child = Node.create() - child.setWidthPercent(30) // 30% of 100 = 30px - child.setMeasureFunc((_w: number, wm: number) => { - const contentWidth = 80 - if (wm === MEASURE_MODE_EXACTLY || wm === MEASURE_MODE_AT_MOST) { - if (_w >= contentWidth) { - return { width: contentWidth, height: 1 } - } - const lines = Math.ceil(contentWidth / _w) - return { width: Math.min(contentWidth, _w), height: lines } - } - return { width: contentWidth, height: 1 } - }) - root.insertChild(child, 0) - - root.calculateLayout(100, 100, DIRECTION_LTR) - - // In a column, width is cross-axis. But width=30% still resolves against - // parent width = 100. The measureFunc receives width=30. - expect(child.getComputedWidth()).toBe(30) - expect(child.getComputedHeight()).toBe(3) - }) - - test("flexGrow measure-func leaf with widthPercent and a sibling gets correct wrapping", () => { - // Row: [sibling(40)] [child flexGrow=1 width=30%] - // Remaining: 100 - 40 = 60 → child flexGrow gets all → 60 - // But width=30% resolves to 30 during flex basis. After flex distribution, - // child width = 60 (flexGrow wins over percent width in main axis). - // Test: child still wraps at 60, not at 100. - // Use alignItems=flex-start to avoid stretch overriding wrapped content height. - const root = Node.create() - root.setFlexDirection(FLEX_DIRECTION_ROW) - root.setWidth(100) - root.setHeight(50) - root.setAlignItems(ALIGN_FLEX_START) - - const sibling = Node.create() - sibling.setWidth(40) - sibling.setFlexShrink(0) - root.insertChild(sibling, 0) - - const child = Node.create() - child.setWidthPercent(30) - child.setFlexGrow(1) - child.setMeasureFunc((_w: number, wm: number) => { - const contentWidth = 80 - if (wm === MEASURE_MODE_EXACTLY || wm === MEASURE_MODE_AT_MOST) { - if (_w >= contentWidth) { - return { width: contentWidth, height: 1 } - } - const lines = Math.ceil(contentWidth / _w) - return { width: Math.min(contentWidth, _w), height: lines } - } - return { width: contentWidth, height: 1 } - }) - root.insertChild(child, 1) - - root.calculateLayout(100, 50, DIRECTION_LTR) - - // flexGrow wins: child gets 60 (100 - 40) - expect(child.getComputedWidth()).toBe(60) - // 80 units at 60 → 2 lines - expect(child.getComputedHeight()).toBe(2) - }) - - test("measure-func leaf with width=30%, no flexGrow, inside column flexGrow parent", () => { - // More realistic: outer column flexGrow . child row . leaf width=30% - // The leaf should wrap at 30% of the resolved column width. - // Per column spec example from doc — multi-pass measurement - const root = Node.create() - root.setFlexDirection(FLEX_DIRECTION_ROW) - root.setWidth(200) - root.setHeight(100) - - const col = Node.create() - col.setFlexDirection(FLEX_DIRECTION_COLUMN) - col.setFlexGrow(1) - root.insertChild(col, 0) - - const row = Node.create() - row.setFlexDirection(FLEX_DIRECTION_ROW) - col.insertChild(row, 0) - - const child = Node.create() - child.setWidthPercent(30) // 30% of col width (200 - 0 = 200) → 60 - child.setMeasureFunc((_w: number, wm: number) => { - const contentWidth = 120 - if (wm === MEASURE_MODE_EXACTLY || wm === MEASURE_MODE_AT_MOST) { - if (_w >= contentWidth) { - return { width: contentWidth, height: 1 } - } - const lines = Math.ceil(contentWidth / _w) - return { width: Math.min(contentWidth, _w), height: lines } - } - return { width: contentWidth, height: 1 } - }) - row.insertChild(child, 0) - - root.calculateLayout(200, 100, DIRECTION_LTR) - - // col gets 200 (flexGrow in 200-wide row) - expect(col.getComputedWidth()).toBe(200) - // 30% of 200 = 60 - expect(child.getComputedWidth()).toBe(60) - // 120 units at 60 → 2 lines - expect(child.getComputedHeight()).toBe(2) - }) -}) From 80016e95c305edae6b5d75003f6058fdf9849719 Mon Sep 17 00:00:00 2001 From: sedrew Date: Tue, 7 Jul 2026 11:18:50 +0700 Subject: [PATCH 7/8] chore: revert layout-zero.ts to pre-Phase 8 cross-axis fix (fe15082) --- src/layout-zero.ts | 32 ++++---------------------------- 1 file changed, 4 insertions(+), 28 deletions(-) diff --git a/src/layout-zero.ts b/src/layout-zero.ts index c52cbe6..64ac25b 100644 --- a/src/layout-zero.ts +++ b/src/layout-zero.ts @@ -593,33 +593,19 @@ function layoutNode( // Measure function takes PHYSICAL (width, height), not logical (main, cross). // For row: main=width, cross=height. For column: main=height, cross=width. const wantMaxContent = childStyle.flexGrow > 0 - // Resolve the child's cross-axis dimension when it has an explicit percent/point - // size, so the measure func gets the correct constraint (e.g. width=30% in a - // column → measure at 30px, not at parent's full width). Without this, a - // measure-func leaf with percent width in a column measures unconstrained (parent width) - // and doesn't wrap, producing height=1 instead of the correct wrapped height. - const crossDimStyle = isRow ? childStyle.height : childStyle.width - let resolvedCross = availCross - if (crossDimStyle.unit === C.UNIT_POINT) { - resolvedCross = crossDimStyle.value - } else if (crossDimStyle.unit === C.UNIT_PERCENT) { - resolvedCross = Number.isNaN(crossAxisSize) ? 0 : crossAxisSize * (crossDimStyle.value / 100) - } else if (crossDimStyle.unit === C.UNIT_CQI || crossDimStyle.unit === C.UNIT_CQMIN || crossDimStyle.unit === C.UNIT_CALC) { - resolvedCross = resolveValue(crossDimStyle, crossAxisSize, findContainerQuerySize(child)) - } const mW = isRow ? wantMaxContent ? Infinity : Number.isNaN(mainAxisSize) ? Infinity : mainAxisSize - : Number.isNaN(resolvedCross) + : Number.isNaN(availCross) ? Infinity - : resolvedCross + : availCross const mH = isRow - ? Number.isNaN(resolvedCross) + ? Number.isNaN(availCross) ? Infinity - : resolvedCross + : availCross : wantMaxContent ? Infinity : Number.isNaN(mainAxisSize) @@ -1996,16 +1982,6 @@ function layoutNode( child.layout.height = edgeBasedMainSize } } - // When flexGrow changed a measure leaf's width, the height was computed at the - // PRE-distribution width. Re-measure at the actual - // flex-distributed width to get correct wrapping. - if (flexGrowHasDefiniteMainBudget && hasMeasureLeaf && isRow) { - const newWidth = child.layout.width - const measured = child.cachedMeasure(newWidth, C.MEASURE_MODE_AT_MOST, Infinity, C.MEASURE_MODE_UNDEFINED) - if (measured) { - child.layout.height = Math.round(measured.height) - } - } // Cross axis: only override for explicit sizing or when we have a real constraint // For auto-sized children, let layoutNode determine the size const crossDimForCheck = isRow ? childStyle.height : childStyle.width From d5761967cfdb11ae96ada4884df6fb508f5d0552 Mon Sep 17 00:00:00 2001 From: sedrew Date: Tue, 7 Jul 2026 13:28:28 +0700 Subject: [PATCH 8/8] fix: complete unfinished nested percent measure fuzz tests --- tests/differential-fuzz.fuzz.ts | 39 ++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/tests/differential-fuzz.fuzz.ts b/tests/differential-fuzz.fuzz.ts index c173bb2..1cec825 100644 --- a/tests/differential-fuzz.fuzz.ts +++ b/tests/differential-fuzz.fuzz.ts @@ -864,15 +864,6 @@ describe("Fuzz: Stress Test (50 Nested)", () => { /** * Generates trees where leaf nodes have widthPercent + measureFunc. - * - * Scenarios covered: - * - Leaf with widthPercent + measureFunc (no flexGrow) - * - Leaf with widthPercent + measureFunc + flexGrow - * - Leaf with widthPercent + measureFunc + siblings with explicit widths - * - Leaf without measureFunc, just widthPercent - * - * The measureFunc simulates text wrapping: contentWidth "characters" wrap - * into ceil(contentWidth / availableWidth) lines when constrained. */ function generatePercentMeasureTree(ctx: RandomContext, childCount: number): TreeSpec { const rootWidth = randomInt(ctx, 100, 500) @@ -1077,4 +1068,32 @@ describe("Fuzz: Percent Measure Leaf (Column)", () => { // Tests: Percent Measure Leaf (Nested) // ============================================================================ -// describe("Fuzz: Percent Measure Leaf (Nested 2x +describe("Fuzz: Percent Measure Leaf (Nested 2x2)", () => { + for (let seed = 14000; seed < 14030; seed++) { + it(`seed=${seed}`, () => { + const result = runPercentMeasureNestedTest(seed, 2, 2) + expect(result.passed).toBe(true) + }) + } +}) + +describe("Fuzz: Percent Measure Leaf (Nested 3x3)", () => { + for (let seed = 14500; seed < 14530; seed++) { + it(`seed=${seed}`, () => { + const result = runPercentMeasureNestedTest(seed, 3, 3) + expect(result.passed).toBe(true) + }) + } +}) + +// ============================================================================ +// Summary +// ============================================================================ + +describe("Differential Fuzz Summary", () => { + it("prints summary", () => { + log.debug?.( + `\n${"=".repeat(60)}\nDIFFERENTIAL FUZZ TEST SUMMARY\n${"=".repeat(60)}\n\nThese tests generate random flexbox trees and compare Flexily vs Yoga.\nUse seed values to reproduce any failing cases.\n\nTest categories:\n- Simple Flat: Single level with fixed/flex children\n- Nested: Two-level layouts with flexGrow\n- Kanban: Column-based card layouts (TUI pattern)\n- Dashboard: Header + sidebar + content (TUI pattern)\n- Absolute: Mixed relative + absolute positioned children\n- Stress: Many random seeds for broad coverage\n\nTolerance: ${EPSILON}px (for rounding differences)\n\n${"=".repeat(60)}`, + ) + }) +})