From eec6d8b6b597e8af2853cb563392a734e2495e0d Mon Sep 17 00:00:00 2001 From: Samuel Gagarin <66745577+Lavmee@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:00:32 +0300 Subject: [PATCH 01/13] test: add the outcome types for a ConstraintSetParser harness --- .../constraintset/ConstraintSetOutcome.kt | 72 +++++++++++++++++++ .../constraintset/ConstraintSetOutcomeTest.kt | 43 +++++++++++ 2 files changed, 115 insertions(+) create mode 100644 parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetOutcome.kt create mode 100644 parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetOutcomeTest.kt diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetOutcome.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetOutcome.kt new file mode 100644 index 0000000..2a27b31 --- /dev/null +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetOutcome.kt @@ -0,0 +1,72 @@ +// Copyright 2023, Sergei Gagarin and the project contributors +// SPDX-License-Identifier: Apache-2.0 + +package tech.annexflow.parity.constraintset + +/** One widget's laid-out box, named without reference to either implementation's classes. */ +data class GeometryRow(val id: String, val left: Int, val top: Int, val width: Int, val height: Int) + +/** One custom attribute, already stringified by whichever subject read it. */ +data class CustomRow(val widgetId: String, val name: String, val value: String) + +/** One design element produced by `parseDesignElementsJSON`. */ +data class ElementRow(val id: String, val type: String, val params: Map) + +fun renderGeometry(rows: List): String = + rows.joinToString(separator = "") { "${it.id} l=${it.left} t=${it.top} w=${it.width} h=${it.height}\n" } + +// Sorted, unlike geometry: custom attributes come out of a HashMap, and the two implementations +// have no reason to iterate one in the same order. Geometry keeps the caller's order because the +// subjects walk the container's children, which is a list on both sides. +fun renderCustom(rows: List): String = + rows.sortedWith(compareBy({ it.widgetId }, { it.name })) + .joinToString(separator = "") { "${it.widgetId}.${it.name}=${it.value}\n" } + +fun renderElements(rows: List): String = + rows.sortedBy { it.id }.joinToString(separator = "") { row -> + val params = row.params.entries.sortedBy { it.key }.joinToString(" ") { "${it.key}=${it.value}" } + "${row.id} type=${row.type}${if (params.isEmpty()) "" else " $params"}\n" + } + +/** + * Everything observable about parsing one document, normalised so the two implementations become + * comparable despite living in different packages. Deliberately the same shape as `LayoutOutcome`. + */ +sealed interface ConstraintSetOutcome { + /** The layout entry point: a document parsed, applied to a container and laid out. */ + data class Populated(val geometry: String, val custom: String) : ConstraintSetOutcome + + /** + * The `parseDesignElementsJSON` entry point, which yields a list and no geometry at all. A + * separate case rather than a third field on [Populated]: the two entry points never run + * together, so a shared shape would leave half of it empty on every scenario. + */ + data class Elements(val rendered: String) : ConstraintSetOutcome + + /** + * An exception the parser is documented to raise — `CLParsingException` above all. + * + * Compared by portable category, not exception class: the port is multiplatform and cannot + * raise JVM-specific classes on Native or JS, so class equality would demand something no + * correct port could deliver. + */ + data class Leaked(val category: String) : ConstraintSetOutcome + + /** + * Anything else escaping. Distinct from [Leaked] on purpose: a parsing exception on malformed + * input is the parser working, while an `IndexOutOfBoundsException` is a defect on whichever + * side raised it. Collapsing the two would let a port that crashes match an oracle that rejects. + */ + data class Crashed(val error: String) : ConstraintSetOutcome + + companion object { + fun categorise(throwable: Throwable): String = + when (throwable) { + is IndexOutOfBoundsException -> "IndexOutOfBounds" + is NullPointerException -> "NullPointer" + is ArithmeticException -> "Arithmetic" + is NumberFormatException -> "NumberFormat" + else -> throwable::class.simpleName ?: "Unknown" + } + } +} diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetOutcomeTest.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetOutcomeTest.kt new file mode 100644 index 0000000..362e162 --- /dev/null +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetOutcomeTest.kt @@ -0,0 +1,43 @@ +// Copyright 2023, Sergei Gagarin and the project contributors +// SPDX-License-Identifier: Apache-2.0 + +package tech.annexflow.parity.constraintset + +import kotlin.test.Test +import kotlin.test.assertEquals + +class ConstraintSetOutcomeTest { + @Test + fun geometryRendersOneRowPerWidgetInGivenOrder() { + val rendered = renderGeometry( + listOf(GeometryRow("b", 10, 20, 30, 40), GeometryRow("a", 0, 0, 5, 5)), + ) + assertEquals("b l=10 t=20 w=30 h=40\na l=0 t=0 w=5 h=5\n", rendered) + } + + @Test + fun customRowsAreSortedSoIterationOrderCannotLeakIn() { + val rendered = renderCustom( + listOf( + CustomRow("w", "zeta", "1.0"), + CustomRow("w", "alpha", "2.0"), + CustomRow("a", "beta", "3.0"), + ), + ) + assertEquals("a.beta=3.0\nw.alpha=2.0\nw.zeta=1.0\n", rendered) + } + + @Test + fun elementParamsAreSorted() { + val rendered = renderElements( + listOf(ElementRow("e", "button", mapOf("b" to "2", "a" to "1"))), + ) + assertEquals("e type=button a=1 b=2\n", rendered) + } + + @Test + fun categoriseMapsToPortableNames() { + assertEquals("IndexOutOfBounds", ConstraintSetOutcome.categorise(IndexOutOfBoundsException())) + assertEquals("NullPointer", ConstraintSetOutcome.categorise(NullPointerException())) + } +} From 6442ab89819e204722494c51e29a47316c94a35a Mon Sep 17 00:00:00 2001 From: Samuel Gagarin <66745577+Lavmee@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:12:13 +0300 Subject: [PATCH 02/13] test: model and emit ConstraintSet documents for the parity harness --- .../parity/constraintset/ConstraintSetSpec.kt | 170 ++++++++++++ .../parity/constraintset/JsonEmitter.kt | 253 ++++++++++++++++++ .../parity/constraintset/JsonEmitterTest.kt | 72 +++++ 3 files changed, 495 insertions(+) create mode 100644 parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSpec.kt create mode 100644 parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitter.kt create mode 100644 parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitterTest.kt diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSpec.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSpec.kt new file mode 100644 index 0000000..f045de0 --- /dev/null +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSpec.kt @@ -0,0 +1,170 @@ +// Copyright 2023, Sergei Gagarin and the project contributors +// SPDX-License-Identifier: Apache-2.0 + +package tech.annexflow.parity.constraintset + +/** + * A ConstraintSet document described without reference to either implementation, and without JSON + * syntax. [JsonEmitter] owns the syntax; this owns the vocabulary. + */ + +/** The anchors `parseConstraint` accepts. */ +enum class Anchor { START, END, LEFT, RIGHT, TOP, BOTTOM, BASELINE } + +/** The three values `applyAttribute` accepts for `visibility`. */ +enum class Visibility { VISIBLE, INVISIBLE, GONE } + +/** The four bare-string forms `parseDimensionMode` accepts. */ +enum class DimensionMode { WRAP, PREFER_WRAP, SPREAD, PARENT } + +/** `min` and `max` inside the object dimension form: a number, or any string meaning wrap. */ +sealed interface Bound { + data class Pixels(val dp: Int) : Bound + + data object Wrap : Bound +} + +/** The four shapes `parseDimension` branches on: number, string, percent/ratio string, object. */ +sealed interface DimensionSpec { + data class Fixed(val dp: Int) : DimensionSpec + + data class Mode(val mode: DimensionMode) : DimensionSpec + + /** Rendered as `"50%"`; `parseDimensionMode` divides by 100. */ + data class Percent(val percent: Float) : DimensionSpec + + /** Rendered verbatim, e.g. `"16:9"`; recognised by containing a colon. */ + data class Ratio(val ratio: String) : DimensionSpec + + data class Bounded(val value: DimensionMode?, val min: Bound?, val max: Bound?) : DimensionSpec +} + +sealed interface AnchorTarget { + data object Parent : AnchorTarget + + data class Widget(val id: String) : AnchorTarget +} + +/** `from: [target, toAnchor, margin, goneMargin]` — the trailing two are optional in the DSL. */ +data class AnchorSpec( + val from: Anchor, + val target: AnchorTarget, + val to: Anchor, + val margin: Int?, + val goneMargin: Int?, +) + +data class CircularSpec(val target: String, val angle: Float, val distance: Int) + +/** A custom property is a number or a colour string; a malformed colour is deliberately allowed. */ +sealed interface CustomValue { + data class Num(val value: Float) : CustomValue + + data class Color(val literal: String) : CustomValue +} + +data class WidgetSpec( + val id: String, + val width: DimensionSpec, + val height: DimensionSpec, + val anchors: List, + val circular: CircularSpec?, + val centerHorizontally: AnchorTarget?, + val centerVertically: AnchorTarget?, + val center: AnchorTarget?, + val hBias: Float?, + val vBias: Float?, + val hRtlBias: Float?, + val hWeight: Float?, + val vWeight: Float?, + val visibility: Visibility?, + val alpha: Float?, + val rotationX: Float?, + val rotationY: Float?, + val rotationZ: Float?, + val scaleX: Float?, + val scaleY: Float?, + val translationX: Float?, + val translationY: Float?, + val translationZ: Float?, + val pivotX: Float?, + val pivotY: Float?, + val custom: Map, +) + +enum class ChainStyle { SPREAD, SPREAD_INSIDE, PACKED } + +/** + * A chain, always emitted as a typed top-level element (`type: 'hChain'`) rather than through the + * `Helpers` array, so it can carry a style and a bias. Guidelines cover the `Helpers` path. + */ +data class ChainSpec( + val id: String, + val horizontal: Boolean, + val refs: List, + val style: ChainStyle?, +) + +sealed interface GuidelinePosition { + /** `start` for a vertical guideline, `top` for a horizontal one. */ + data class FromStart(val dp: Int) : GuidelinePosition + + data class FromEnd(val dp: Int) : GuidelinePosition + + data class Percent(val fraction: Float) : GuidelinePosition +} + +/** + * [inHelpers] chooses between the two declaration paths the parser supports for the same object: + * an entry in the `Helpers` array (`parseHelpers` → `parseGuideline`) or a typed top-level element + * (`populateState` → `parseGuidelineParams`). Both are generated; they are different code. + */ +data class GuidelineSpec( + val id: String, + val horizontal: Boolean, + val position: GuidelinePosition, + val inHelpers: Boolean, +) + +enum class BarrierDirection { START, END, LEFT, RIGHT, TOP, BOTTOM } + +/** Barriers are never in `Helpers` — `parseHelpers` has no case for them. Always typed elements. */ +data class BarrierSpec( + val id: String, + val direction: BarrierDirection, + val margin: Int?, + val refs: List, +) + +sealed interface VariableSpec { + val name: String + + data class Num(override val name: String, val value: Float) : VariableSpec + + /** `{from: x, step: y}` — `LayoutVariables.put(name, start, incrementBy)`. */ + data class Generator(override val name: String, val from: Float, val step: Float) : VariableSpec + + /** An id list, the only variable kind `Generate` can consume. */ + data class IdList(override val name: String, val ids: List) : VariableSpec +} + +/** One `Generate` entry: a widget body stamped across every id in the named list variable. */ +data class GenerateSpec(val listName: String, val body: WidgetSpec) + +data class ConstraintSetSpec( + val seed: Long, + val rootWidth: Int, + val rootHeight: Int, + val isRtl: Boolean, + val widgets: List, + val chains: List, + val guidelines: List, + val barriers: List, + val variables: List, + val generate: GenerateSpec?, +) + +/** The separate `parseDesignElementsJSON` entry point. */ +data class DesignElementSpec(val id: String, val type: String, val params: Map) + +data class DesignElementsSpec(val seed: Long, val elements: List) diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitter.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitter.kt new file mode 100644 index 0000000..40cd283 --- /dev/null +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitter.kt @@ -0,0 +1,253 @@ +// Copyright 2023, Sergei Gagarin and the project contributors +// SPDX-License-Identifier: Apache-2.0 + +package tech.annexflow.parity.constraintset + +/** + * Renders a [ConstraintSetSpec] into the JSON5-ish dialect `CLParser` reads: unquoted keys, + * single-quoted strings, bare numbers. This is the one file that knows that syntax — every shape + * below was checked against `ConstraintSetParser` itself, not against the JSON5 spec. + * + * [ConstraintSetSpec.seed], [ConstraintSetSpec.rootWidth], [ConstraintSetSpec.rootHeight] and + * [ConstraintSetSpec.isRtl] are not part of the emitted document: the parser never reads a root + * size or a layout direction out of the JSON body, those are properties the caller sets on the + * `State` before parsing. They exist on the spec for the harness driver, not for [emit]. + */ + +/** + * A single site for float formatting, per the brief: strips the trailing `.0` a bare + * `Float.toString()` would add, since the given test requires `'50%'`, not `'50.0%'`. + */ +private fun formatFloat(value: Float): String { + val whole = value.toLong() + return if (value == whole.toFloat()) whole.toString() else value.toString() +} + +private fun quote(value: String): String = "'$value'" + +private fun obj(entries: List>): String = + entries.joinToString(separator = ", ", prefix = "{", postfix = "}") { (key, value) -> "$key: $value" } + +private fun arr(items: List): String = items.joinToString(separator = ", ", prefix = "[", postfix = "]") + +private fun anchorKey(anchor: Anchor): String = when (anchor) { + Anchor.START -> "start" + Anchor.END -> "end" + Anchor.LEFT -> "left" + Anchor.RIGHT -> "right" + Anchor.TOP -> "top" + Anchor.BOTTOM -> "bottom" + Anchor.BASELINE -> "baseline" +} + +private fun renderAnchor(anchor: Anchor): String = quote(anchorKey(anchor)) + +private fun renderAnchorTarget(target: AnchorTarget): String = when (target) { + AnchorTarget.Parent -> quote("parent") + is AnchorTarget.Widget -> quote(target.id) +} + +/** `parseConstraint`'s array form: `[target, anchor, margin, goneMargin]`, trailing nulls dropped. */ +private fun renderAnchorSpec(anchor: AnchorSpec): Pair { + val items = mutableListOf(renderAnchorTarget(anchor.target), renderAnchor(anchor.to)) + if (anchor.margin != null) { + items += anchor.margin.toString() + if (anchor.goneMargin != null) { + items += anchor.goneMargin.toString() + } + } + return anchorKey(anchor.from) to arr(items) +} + +private fun dimensionModeWord(mode: DimensionMode): String = when (mode) { + DimensionMode.WRAP -> "wrap" + DimensionMode.PREFER_WRAP -> "preferWrap" + DimensionMode.SPREAD -> "spread" + DimensionMode.PARENT -> "parent" +} + +private fun renderDimensionMode(mode: DimensionMode): String = quote(dimensionModeWord(mode)) + +private fun renderBound(bound: Bound): String = when (bound) { + is Bound.Pixels -> bound.dp.toString() + Bound.Wrap -> quote("wrap") +} + +private fun renderDimension(dimension: DimensionSpec): String = when (dimension) { + is DimensionSpec.Fixed -> dimension.dp.toString() + is DimensionSpec.Mode -> renderDimensionMode(dimension.mode) + is DimensionSpec.Percent -> quote("${formatFloat(dimension.percent)}%") + is DimensionSpec.Ratio -> quote(dimension.ratio) + is DimensionSpec.Bounded -> { + val entries = mutableListOf>() + dimension.value?.let { entries += "value" to renderDimensionMode(it) } + dimension.min?.let { entries += "min" to renderBound(it) } + dimension.max?.let { entries += "max" to renderBound(it) } + obj(entries) + } +} + +private fun renderVisibility(visibility: Visibility): String = quote( + when (visibility) { + Visibility.VISIBLE -> "visible" + Visibility.INVISIBLE -> "invisible" + Visibility.GONE -> "gone" + }, +) + +private fun renderCustomValue(value: CustomValue): String = when (value) { + is CustomValue.Num -> formatFloat(value.value) + is CustomValue.Color -> quote(value.literal) +} + +/** Attributes shared by an ordinary widget object and a `Generate` body — same `applyAttribute` loop. */ +private fun renderWidgetAttributes(widget: WidgetSpec): List> { + val entries = mutableListOf>() + entries += "width" to renderDimension(widget.width) + entries += "height" to renderDimension(widget.height) + for (anchor in widget.anchors) { + entries += renderAnchorSpec(anchor) + } + widget.circular?.let { + entries += "circular" to arr(listOf(quote(it.target), formatFloat(it.angle), it.distance.toString())) + } + widget.center?.let { entries += "center" to renderAnchorTarget(it) } + widget.centerHorizontally?.let { entries += "centerHorizontally" to renderAnchorTarget(it) } + widget.centerVertically?.let { entries += "centerVertically" to renderAnchorTarget(it) } + widget.hBias?.let { entries += "hBias" to formatFloat(it) } + widget.vBias?.let { entries += "vBias" to formatFloat(it) } + widget.hRtlBias?.let { entries += "hRtlBias" to formatFloat(it) } + widget.hWeight?.let { entries += "hWeight" to formatFloat(it) } + widget.vWeight?.let { entries += "vWeight" to formatFloat(it) } + widget.visibility?.let { entries += "visibility" to renderVisibility(it) } + widget.alpha?.let { entries += "alpha" to formatFloat(it) } + widget.rotationX?.let { entries += "rotationX" to formatFloat(it) } + widget.rotationY?.let { entries += "rotationY" to formatFloat(it) } + widget.rotationZ?.let { entries += "rotationZ" to formatFloat(it) } + widget.scaleX?.let { entries += "scaleX" to formatFloat(it) } + widget.scaleY?.let { entries += "scaleY" to formatFloat(it) } + widget.translationX?.let { entries += "translationX" to formatFloat(it) } + widget.translationY?.let { entries += "translationY" to formatFloat(it) } + widget.translationZ?.let { entries += "translationZ" to formatFloat(it) } + widget.pivotX?.let { entries += "pivotX" to formatFloat(it) } + widget.pivotY?.let { entries += "pivotY" to formatFloat(it) } + if (widget.custom.isNotEmpty()) { + entries += "custom" to obj(widget.custom.map { (name, value) -> name to renderCustomValue(value) }) + } + return entries +} + +private fun renderWidget(widget: WidgetSpec): Pair = widget.id to obj(renderWidgetAttributes(widget)) + +private fun renderGuidelinePosition(position: GuidelinePosition): Pair = when (position) { + is GuidelinePosition.FromStart -> "start" to position.dp.toString() + is GuidelinePosition.FromEnd -> "end" to position.dp.toString() + // Bare number, not a percent-string: `parseGuidelineParams` reads this with `getFloat`, unlike + // the dimension `Percent` form which is a quoted "n%" string read by `parseDimensionMode`. + is GuidelinePosition.Percent -> "percent" to formatFloat(position.fraction) +} + +private fun guidelineTypeWord(horizontal: Boolean): String = if (horizontal) "hGuideline" else "vGuideline" + +/** + * `parseHelpers` -> `parseGuideline`: `helper[1]` must be a `CLObject` carrying the `id` itself, so + * the shape is `['hGuideline', {id: 'gid', start: 40}]` — a two-element array, not the three-element + * `['hGuideline', 'id', {start: 40}]` the brief describes. See CLParserTest.testConstraints2, which + * round-trips exactly this two-element shape. The parser is the authority; this follows the parser. + */ +private fun renderGuidelineInHelpers(guideline: GuidelineSpec): String { + val (posKey, posValue) = renderGuidelinePosition(guideline.position) + val params = obj(listOf("id" to quote(guideline.id), posKey to posValue)) + return arr(listOf(quote(guidelineTypeWord(guideline.horizontal)), params)) +} + +/** `populateState` -> `parseGuidelineParams`: a typed top-level element, `id: {type: ..., ...}`. */ +private fun renderGuidelineTyped(guideline: GuidelineSpec): Pair { + val (posKey, posValue) = renderGuidelinePosition(guideline.position) + val entries = listOf("type" to quote(guidelineTypeWord(guideline.horizontal)), posKey to posValue) + return guideline.id to obj(entries) +} + +private fun barrierDirectionWord(direction: BarrierDirection): String = when (direction) { + BarrierDirection.START -> "start" + BarrierDirection.END -> "end" + BarrierDirection.LEFT -> "left" + BarrierDirection.RIGHT -> "right" + BarrierDirection.TOP -> "top" + BarrierDirection.BOTTOM -> "bottom" +} + +private fun renderBarrier(barrier: BarrierSpec): Pair { + val entries = mutableListOf>() + entries += "type" to quote("barrier") + entries += "direction" to quote(barrierDirectionWord(barrier.direction)) + barrier.margin?.let { entries += "margin" to it.toString() } + entries += "contains" to arr(barrier.refs.map { quote(it) }) + return barrier.id to obj(entries) +} + +private fun chainStyleWord(style: ChainStyle): String = when (style) { + ChainStyle.SPREAD -> "spread" + ChainStyle.SPREAD_INSIDE -> "spread_inside" + ChainStyle.PACKED -> "packed" +} + +private fun renderChain(chain: ChainSpec): Pair { + val entries = mutableListOf>() + entries += "type" to quote(if (chain.horizontal) "hChain" else "vChain") + entries += "contains" to arr(chain.refs.map { quote(it) }) + chain.style?.let { entries += "style" to quote(chainStyleWord(it)) } + return chain.id to obj(entries) +} + +private fun renderVariable(variable: VariableSpec): Pair = when (variable) { + is VariableSpec.Num -> variable.name to formatFloat(variable.value) + is VariableSpec.Generator -> + variable.name to obj(listOf("from" to formatFloat(variable.from), "step" to formatFloat(variable.step))) + is VariableSpec.IdList -> variable.name to obj(listOf("ids" to arr(variable.ids.map { quote(it) }))) +} + +fun emit(spec: ConstraintSetSpec): String { + val topLevel = mutableListOf>() + + if (spec.variables.isNotEmpty()) { + topLevel += "Variables" to obj(spec.variables.map { renderVariable(it) }) + } + + val (helperGuidelines, typedGuidelines) = spec.guidelines.partition { it.inHelpers } + if (helperGuidelines.isNotEmpty()) { + topLevel += "Helpers" to arr(helperGuidelines.map { renderGuidelineInHelpers(it) }) + } + + for (guideline in typedGuidelines) topLevel += renderGuidelineTyped(guideline) + for (barrier in spec.barriers) topLevel += renderBarrier(barrier) + for (chain in spec.chains) topLevel += renderChain(chain) + for (widget in spec.widgets) topLevel += renderWidget(widget) + + spec.generate?.let { generate -> + // `parseGenerate` stamps `generate.body` onto every id in the `listName` variable; the + // body's own `WidgetSpec.id` has no destination in the JSON and is not emitted here. + topLevel += "Generate" to obj(listOf(generate.listName to obj(renderWidgetAttributes(generate.body)))) + } + + return obj(topLevel) +} + +/** + * `parseDesignElementsJSON` only ever looks at the very first top-level key, and only proceeds if + * it is literally `"Design"` — it `break`s out of the outer loop unconditionally after one + * iteration. The brief's shape (`{ id: { type: 'button', params… } }`, no wrapper) would therefore + * be silently ignored; this wraps in `Design` so the document is actually read. See report for + * detail. + */ +fun emitDesignElements(spec: DesignElementsSpec): String { + val elements = spec.elements.map { element -> + val entries = mutableListOf>() + entries += "type" to quote(element.type) + for ((name, value) in element.params) { + entries += name to quote(value) + } + element.id to obj(entries) + } + return obj(listOf("Design" to obj(elements))) +} diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitterTest.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitterTest.kt new file mode 100644 index 0000000..5e50792 --- /dev/null +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitterTest.kt @@ -0,0 +1,72 @@ +// Copyright 2023, Sergei Gagarin and the project contributors +// SPDX-License-Identifier: Apache-2.0 + +package tech.annexflow.parity.constraintset + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class JsonEmitterTest { + private fun widget(id: String) = WidgetSpec( + id = id, + width = DimensionSpec.Fixed(40), + height = DimensionSpec.Fixed(40), + anchors = emptyList(), + circular = null, + centerHorizontally = null, + centerVertically = null, + center = null, + hBias = null, vBias = null, hRtlBias = null, + hWeight = null, vWeight = null, + visibility = null, alpha = null, + rotationX = null, rotationY = null, rotationZ = null, + scaleX = null, scaleY = null, + translationX = null, translationY = null, translationZ = null, + pivotX = null, pivotY = null, + custom = emptyMap(), + ) + + private fun spec(vararg w: WidgetSpec) = ConstraintSetSpec( + seed = 1, rootWidth = 1000, rootHeight = 1000, isRtl = false, + widgets = w.toList(), chains = emptyList(), guidelines = emptyList(), + barriers = emptyList(), variables = emptyList(), generate = null, + ) + + @Test + fun emitsAnObjectPerWidget() { + val json = emit(spec(widget("a"))) + assertTrue(json.contains("a: {"), json) + assertTrue(json.contains("width: 40"), json) + } + + @Test + fun emitsEveryDimensionForm() { + val forms = listOf( + DimensionSpec.Fixed(12) to "12", + DimensionSpec.Mode(DimensionMode.PREFER_WRAP) to "'preferWrap'", + DimensionSpec.Percent(50f) to "'50%'", + DimensionSpec.Ratio("16:9") to "'16:9'", + ) + for ((form, expected) in forms) { + val json = emit(spec(widget("a").copy(width = form))) + assertTrue(json.contains("width: $expected"), "$form -> $json") + } + } + + @Test + fun anchorRendersAsAnArrayOfTargetAnchorMargin() { + val w = widget("a").copy( + anchors = listOf( + AnchorSpec(Anchor.START, AnchorTarget.Parent, Anchor.START, margin = 16, goneMargin = null), + ), + ) + assertTrue(emit(spec(w)).contains("start: ['parent', 'start', 16]"), emit(spec(w))) + } + + @Test + fun emissionIsDeterministic() { + val s = spec(widget("a"), widget("b")) + assertEquals(emit(s), emit(s)) + } +} From dcb2bf3d0dd0ecb300b50bab92469eb9abbe507d Mon Sep 17 00:00:00 2001 From: Samuel Gagarin <66745577+Lavmee@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:22:18 +0300 Subject: [PATCH 03/13] fix: make goneMargin-without-margin unconstructable in AnchorSpec --- .../parity/constraintset/ConstraintSetSpec.kt | 21 +++++++++++++++--- .../parity/constraintset/JsonEmitter.kt | 16 +++++++++----- .../parity/constraintset/JsonEmitterTest.kt | 22 ++++++++++++++++++- 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSpec.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSpec.kt index f045de0..3108e7f 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSpec.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSpec.kt @@ -45,13 +45,28 @@ sealed interface AnchorTarget { data class Widget(val id: String) : AnchorTarget } -/** `from: [target, toAnchor, margin, goneMargin]` — the trailing two are optional in the DSL. */ +/** + * `parseConstraint`'s anchor array is positional: margin sits at index 2, goneMargin at index 3, + * and the parser only reads index 3 when the array is at least 4 elements long — which means it + * only ever reads a goneMargin when a margin is present too. Modelling margin and goneMargin as + * two independently-nullable `Int`s would let a caller build `margin = null, goneMargin = 20`, a + * state the wire format cannot express; the emitter would then have to choose between silently + * dropping it or refusing to render it. Folding them into one nullable holder, with goneMargin only + * reachable through the variant that also carries a margin, makes that state unconstructable + * instead of merely unrendered. + */ +sealed interface AnchorMargin { + data class Margin(val dp: Int) : AnchorMargin + + data class MarginAndGone(val dp: Int, val goneDp: Int) : AnchorMargin +} + +/** `from: [target, toAnchor, margin?, goneMargin?]` — a null [margin] renders the two-element form. */ data class AnchorSpec( val from: Anchor, val target: AnchorTarget, val to: Anchor, - val margin: Int?, - val goneMargin: Int?, + val margin: AnchorMargin?, ) data class CircularSpec(val target: String, val angle: Float, val distance: Int) diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitter.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitter.kt index 40cd283..2652310 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitter.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitter.kt @@ -47,13 +47,19 @@ private fun renderAnchorTarget(target: AnchorTarget): String = when (target) { is AnchorTarget.Widget -> quote(target.id) } -/** `parseConstraint`'s array form: `[target, anchor, margin, goneMargin]`, trailing nulls dropped. */ +/** + * `parseConstraint`'s array form: `[target, anchor, margin, goneMargin]`. `AnchorMargin` already + * makes "goneMargin without margin" unconstructable (see its kdoc); this just walks the three + * remaining shapes positionally — 2, 3, or 4 elements — with no null-checking left to get wrong. + */ private fun renderAnchorSpec(anchor: AnchorSpec): Pair { val items = mutableListOf(renderAnchorTarget(anchor.target), renderAnchor(anchor.to)) - if (anchor.margin != null) { - items += anchor.margin.toString() - if (anchor.goneMargin != null) { - items += anchor.goneMargin.toString() + when (val margin = anchor.margin) { + null -> Unit + is AnchorMargin.Margin -> items += margin.dp.toString() + is AnchorMargin.MarginAndGone -> { + items += margin.dp.toString() + items += margin.goneDp.toString() } } return anchorKey(anchor.from) to arr(items) diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitterTest.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitterTest.kt index 5e50792..230f534 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitterTest.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitterTest.kt @@ -58,12 +58,32 @@ class JsonEmitterTest { fun anchorRendersAsAnArrayOfTargetAnchorMargin() { val w = widget("a").copy( anchors = listOf( - AnchorSpec(Anchor.START, AnchorTarget.Parent, Anchor.START, margin = 16, goneMargin = null), + AnchorSpec(Anchor.START, AnchorTarget.Parent, Anchor.START, margin = AnchorMargin.Margin(16)), ), ) assertTrue(emit(spec(w)).contains("start: ['parent', 'start', 16]"), emit(spec(w))) } + @Test + fun anchorMarginRendersPositionally() { + fun anchorJson(margin: AnchorMargin?): String { + val w = widget("a").copy( + anchors = listOf(AnchorSpec(Anchor.TOP, AnchorTarget.Widget("b"), Anchor.BOTTOM, margin)), + ) + return emit(spec(w)) + } + + assertTrue(anchorJson(null).contains("top: ['b', 'bottom']"), anchorJson(null)) + assertTrue( + anchorJson(AnchorMargin.Margin(8)).contains("top: ['b', 'bottom', 8]"), + anchorJson(AnchorMargin.Margin(8)), + ) + assertTrue( + anchorJson(AnchorMargin.MarginAndGone(8, 4)).contains("top: ['b', 'bottom', 8, 4]"), + anchorJson(AnchorMargin.MarginAndGone(8, 4)), + ) + } + @Test fun emissionIsDeterministic() { val s = spec(widget("a"), widget("b")) From a6510a3c0aa359a4d20f9432a36466e8ae0ad38b Mon Sep 17 00:00:00 2001 From: Samuel Gagarin <66745577+Lavmee@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:29:48 +0300 Subject: [PATCH 04/13] test: drive both ConstraintSetParser implementations from one document --- .../constraintset/ConstraintSetSubject.kt | 20 ++++++ .../constraintset/OracleConstraintSet.kt | 65 +++++++++++++++++++ .../parity/constraintset/PortConstraintSet.kt | 65 +++++++++++++++++++ .../parity/constraintset/SubjectTest.kt | 59 +++++++++++++++++ 4 files changed, 209 insertions(+) create mode 100644 parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSubject.kt create mode 100644 parity/src/test/kotlin/tech/annexflow/parity/constraintset/OracleConstraintSet.kt create mode 100644 parity/src/test/kotlin/tech/annexflow/parity/constraintset/PortConstraintSet.kt create mode 100644 parity/src/test/kotlin/tech/annexflow/parity/constraintset/SubjectTest.kt diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSubject.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSubject.kt new file mode 100644 index 0000000..92b3054 --- /dev/null +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSubject.kt @@ -0,0 +1,20 @@ +// Copyright 2023, Sergei Gagarin and the project contributors +// SPDX-License-Identifier: Apache-2.0 + +package tech.annexflow.parity.constraintset + +/** + * One side of the comparison: parses the same emitted document with its own package's + * `ConstraintSetParser` and reports a comparable outcome. + * + * Implementations must never throw out of [parse] or [designElements]. An exception on one side + * against a success on the other is precisely the finding this module exists to surface, so it has + * to arrive as a value rather than abort the run before the remaining inputs are tried. + */ +interface ConstraintSetSubject { + val name: String + + fun parse(spec: ConstraintSetSpec): ConstraintSetOutcome + + fun designElements(spec: DesignElementsSpec): ConstraintSetOutcome +} diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/OracleConstraintSet.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/OracleConstraintSet.kt new file mode 100644 index 0000000..ce975e4 --- /dev/null +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/OracleConstraintSet.kt @@ -0,0 +1,65 @@ +// Copyright 2023, Sergei Gagarin and the project contributors +// SPDX-License-Identifier: Apache-2.0 + +package tech.annexflow.parity.constraintset + +import androidx.constraintlayout.core.parser.CLParser +import androidx.constraintlayout.core.parser.CLParsingException +import androidx.constraintlayout.core.state.ConstraintSetParser +import androidx.constraintlayout.core.state.CorePixelDp +import androidx.constraintlayout.core.state.State +import androidx.constraintlayout.core.widgets.ConstraintWidgetContainer + +/** + * Drives the vendored upstream Java. `PortConstraintSet` performs the same sequence against the + * shaded port; keeping the two in step is the whole contract, so any edit here needs the mirror + * edit there. + * + * Calls `ConstraintSetParser.populateState` directly rather than the public `parseJSON` wrapper: + * the wrapper swallows `CLParsingException` and prints it, leaving the `State` half-populated with + * no signal at all. That is faithful to upstream, but blind as an observation point. + */ +object OracleConstraintSet : ConstraintSetSubject { + override val name: String = "oracle" + + override fun parse(spec: ConstraintSetSpec): ConstraintSetOutcome = + try { + val json = emit(spec) + val state = State() + state.setDpToPixel(CorePixelDp { dp -> dp }) + state.setRtl(spec.isRtl) + val variables = ConstraintSetParser.LayoutVariables() + ConstraintSetParser.populateState(CLParser.parse(json), state, variables) + val root = ConstraintWidgetContainer(0, 0, spec.rootWidth, spec.rootHeight) + root.debugName = "root" + state.apply(root) + root.layout() + val geometry = mutableListOf() + val custom = mutableListOf() + for (child in root.children) { + val id = child.stringId ?: "?" + geometry += GeometryRow(id, child.left, child.top, child.width, child.height) + val frame = child.frame + for (attrName in frame.getCustomAttributeNames()) { + custom += CustomRow(id, attrName, frame.getCustomAttribute(attrName)?.toString() ?: "null") + } + } + ConstraintSetOutcome.Populated(renderGeometry(geometry), renderCustom(custom)) + } catch (e: CLParsingException) { + ConstraintSetOutcome.Leaked("CLParsing") + } catch (e: Throwable) { + ConstraintSetOutcome.Crashed(ConstraintSetOutcome.categorise(e)) + } + + override fun designElements(spec: DesignElementsSpec): ConstraintSetOutcome = + try { + val list = ArrayList() + ConstraintSetParser.parseDesignElementsJSON(emitDesignElements(spec), list) + val rows = list.map { ElementRow(it.getId(), it.getType(), it.getParams()) } + ConstraintSetOutcome.Elements(renderElements(rows)) + } catch (e: CLParsingException) { + ConstraintSetOutcome.Leaked("CLParsing") + } catch (e: Throwable) { + ConstraintSetOutcome.Crashed(ConstraintSetOutcome.categorise(e)) + } +} diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/PortConstraintSet.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/PortConstraintSet.kt new file mode 100644 index 0000000..a1134f7 --- /dev/null +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/PortConstraintSet.kt @@ -0,0 +1,65 @@ +// Copyright 2023, Sergei Gagarin and the project contributors +// SPDX-License-Identifier: Apache-2.0 + +package tech.annexflow.parity.constraintset + +import tech.annexflow.constraintlayout.core.parser.CLParser +import tech.annexflow.constraintlayout.core.parser.CLParsingException +import tech.annexflow.constraintlayout.core.state.ConstraintSetParser +import tech.annexflow.constraintlayout.core.state.CorePixelDp +import tech.annexflow.constraintlayout.core.state.State +import tech.annexflow.constraintlayout.core.widgets.ConstraintWidgetContainer + +/** + * Drives the shaded port. `OracleConstraintSet` performs the same sequence against the vendored + * upstream Java; keeping the two in step is the whole contract, so any edit here needs the mirror + * edit there. + * + * Calls `ConstraintSetParser.populateState` directly rather than the public `parseJSON` wrapper: + * the wrapper swallows `CLParsingException` and prints it, leaving the `State` half-populated with + * no signal at all. That is faithful to upstream, but blind as an observation point. + */ +object PortConstraintSet : ConstraintSetSubject { + override val name: String = "port" + + override fun parse(spec: ConstraintSetSpec): ConstraintSetOutcome = + try { + val json = emit(spec) + val state = State() + state.setDpToPixel(CorePixelDp { dp -> dp }) + state.isRtl = spec.isRtl + val variables = ConstraintSetParser.LayoutVariables() + ConstraintSetParser.populateState(CLParser.parse(json), state, variables) + val root = ConstraintWidgetContainer(0, 0, spec.rootWidth, spec.rootHeight) + root.debugName = "root" + state.apply(root) + root.layout() + val geometry = mutableListOf() + val custom = mutableListOf() + for (child in root.children) { + val id = child.stringId ?: "?" + geometry += GeometryRow(id, child.left, child.top, child.width, child.height) + val frame = child.frame + for (attrName in frame.getCustomAttributeNames()) { + custom += CustomRow(id, attrName, frame.getCustomAttribute(attrName)?.toString() ?: "null") + } + } + ConstraintSetOutcome.Populated(renderGeometry(geometry), renderCustom(custom)) + } catch (e: CLParsingException) { + ConstraintSetOutcome.Leaked("CLParsing") + } catch (e: Throwable) { + ConstraintSetOutcome.Crashed(ConstraintSetOutcome.categorise(e)) + } + + override fun designElements(spec: DesignElementsSpec): ConstraintSetOutcome = + try { + val list = ArrayList() + ConstraintSetParser.parseDesignElementsJSON(emitDesignElements(spec), list) + val rows = list.map { ElementRow(it.id, it.type, it.params) } + ConstraintSetOutcome.Elements(renderElements(rows)) + } catch (e: CLParsingException) { + ConstraintSetOutcome.Leaked("CLParsing") + } catch (e: Throwable) { + ConstraintSetOutcome.Crashed(ConstraintSetOutcome.categorise(e)) + } +} diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/SubjectTest.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/SubjectTest.kt new file mode 100644 index 0000000..8fec4d9 --- /dev/null +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/SubjectTest.kt @@ -0,0 +1,59 @@ +// Copyright 2023, Sergei Gagarin and the project contributors +// SPDX-License-Identifier: Apache-2.0 + +package tech.annexflow.parity.constraintset + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SubjectTest { + private val subjects = listOf(OracleConstraintSet, PortConstraintSet) + + private fun oneWidget() = ConstraintSetSpec( + seed = 1, rootWidth = 1000, rootHeight = 1000, isRtl = false, + widgets = listOf( + WidgetSpec( + id = "id1", + width = DimensionSpec.Fixed(40), + height = DimensionSpec.Fixed(40), + anchors = listOf( + AnchorSpec(Anchor.START, AnchorTarget.Parent, Anchor.START, AnchorMargin.Margin(16)), + AnchorSpec(Anchor.TOP, AnchorTarget.Parent, Anchor.TOP, AnchorMargin.Margin(16)), + ), + circular = null, centerHorizontally = null, centerVertically = null, center = null, + hBias = null, vBias = null, hRtlBias = null, hWeight = null, vWeight = null, + visibility = null, alpha = null, + rotationX = null, rotationY = null, rotationZ = null, + scaleX = null, scaleY = null, + translationX = null, translationY = null, translationZ = null, + pivotX = null, pivotY = null, + custom = mapOf("shade" to CustomValue.Num(0.5f)), + ), + ), + chains = emptyList(), guidelines = emptyList(), barriers = emptyList(), + variables = emptyList(), generate = null, + ) + + @Test + fun bothSubjectsLayOutTheSameDocumentIdentically() { + val outcomes = subjects.map { it.parse(oneWidget()) } + assertEquals(outcomes[0], outcomes[1]) + assertTrue(outcomes[0] is ConstraintSetOutcome.Populated, "got ${outcomes[0]}") + } + + @Test + fun theCustomPropertyIsVisibleInTheOutcome() { + val populated = OracleConstraintSet.parse(oneWidget()) as ConstraintSetOutcome.Populated + assertTrue(populated.custom.contains("id1.shade="), populated.custom) + } + + @Test + fun bothSubjectsAgreeOnDesignElements() { + val spec = DesignElementsSpec(1, listOf(DesignElementSpec("e1", "button", mapOf("text" to "hi")))) + assertEquals( + OracleConstraintSet.designElements(spec), + PortConstraintSet.designElements(spec), + ) + } +} From 31577d12eae36f883ec85b89b22cd8d815929f84 Mon Sep 17 00:00:00 2001 From: Samuel Gagarin <66745577+Lavmee@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:45:07 +0300 Subject: [PATCH 05/13] test: generate ConstraintSet documents from a seed --- .../parity/constraintset/Scenarios.kt | 309 ++++++++++++++++++ .../parity/constraintset/ScenariosTest.kt | 51 +++ 2 files changed, 360 insertions(+) create mode 100644 parity/src/test/kotlin/tech/annexflow/parity/constraintset/Scenarios.kt create mode 100644 parity/src/test/kotlin/tech/annexflow/parity/constraintset/ScenariosTest.kt diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/Scenarios.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/Scenarios.kt new file mode 100644 index 0000000..d07d6e3 --- /dev/null +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/Scenarios.kt @@ -0,0 +1,309 @@ +// Copyright 2023, Sergei Gagarin and the project contributors +// SPDX-License-Identifier: Apache-2.0 + +package tech.annexflow.parity.constraintset + +import kotlin.random.Random + +/** + * Generates [ConstraintSetSpec] and [DesignElementsSpec] documents from a seed. + * + * Generation is a pure function of the seed, so a divergence reported by CI reproduces exactly. + * Widgets are generated first, `w0` through `wn`; every element that comes after — guidelines, + * barriers, chains — is generated in a later pass, so a widget anchor can only ever target `parent` + * or a lower-indexed widget. That keeps the constraint graph acyclic, the same property + * `solver/Scenarios.kt` relies on for the same reason: a cycle would leave the parser applying + * constraints the two implementations have no reason to resolve the same way, and the harness would + * be measuring that instead of the port. + * + * A widget that draws a circular constraint gets no ordinary anchors: `ConstraintReference` doesn't + * refuse the combination, but circular positioning and edge-to-edge constraints answer the same + * question two different ways, and there is nothing this harness wants to learn from watching them + * fight. + */ +object Scenarios { + private const val MIN_WIDGETS = 2 + private const val MAX_WIDGETS = 6 + + /** `parseDimensionMode` only recognises a ratio string by finding a colon in it. */ + private val RATIOS = listOf("16:9", "4:3", "1:1", "3:2", "W,16:9", "H,2:3") + + private val HORIZONTAL_ANCHORS = listOf(Anchor.START, Anchor.END, Anchor.LEFT, Anchor.RIGHT) + private val VERTICAL_ANCHORS = listOf(Anchor.TOP, Anchor.BOTTOM, Anchor.BASELINE) + + private val DESIGN_TYPES = listOf("button", "text", "image", "spinner") + + fun generate(seed: Long): ConstraintSetSpec { + val random = Random(seed) + val count = random.nextInt(MIN_WIDGETS, MAX_WIDGETS + 1) + val widgets = (0 until count).map { index -> widget(random, index) } + + val guidelines = (0 until random.nextInt(0, 3)).map { index -> + GuidelineSpec( + id = "g$index", + horizontal = random.nextBoolean(), + position = guidelinePosition(random), + // Half through `Helpers` (`parseHelpers` -> `parseGuideline`), half as a typed + // top-level element (`populateState` -> `parseGuidelineParams`) — different code. + inHelpers = random.nextBoolean(), + ) + } + + val barriers = if (random.nextInt(2) == 0) { + val refCount = random.nextInt(1, minOf(3, widgets.size) + 1) + listOf( + BarrierSpec( + id = "b0", + direction = BarrierDirection.entries[random.nextInt(BarrierDirection.entries.size)], + margin = if (random.nextInt(2) == 0) random.nextInt(0, 30) else null, + refs = widgets.map { it.id }.shuffled(random).take(refCount), + ), + ) + } else { + emptyList() + } + + // A contiguous run, not an arbitrary subset: a chain is the widgets between two points on + // one axis, and a scattered `refs` list would not describe that shape. + val chains = if (widgets.size >= 2 && random.nextInt(2) == 0) { + val length = random.nextInt(2, minOf(3, widgets.size) + 1) + val start = random.nextInt(0, widgets.size - length + 1) + listOf( + ChainSpec( + id = "c0", + horizontal = random.nextBoolean(), + refs = widgets.subList(start, start + length).map { it.id }, + style = if (random.nextInt(2) == 0) { + ChainStyle.entries[random.nextInt(ChainStyle.entries.size)] + } else { + null + }, + ), + ) + } else { + emptyList() + } + + val variables = if (random.nextInt(3) == 0) { + (0 until random.nextInt(1, 4)).map { index -> variable(random, index) } + } else { + emptyList() + } + + // `parseGenerate` takes ids from the named `IdList` variable, not from the body's own `id` + // — a `Generate` block only does anything when one exists. + val idLists = variables.filterIsInstance() + val generate = if (idLists.isNotEmpty() && random.nextInt(2) == 0) { + GenerateSpec(listName = idLists[random.nextInt(idLists.size)].name, body = generateBody(random)) + } else { + null + } + + return ConstraintSetSpec( + seed = seed, + rootWidth = random.nextInt(400, 1601), + rootHeight = random.nextInt(400, 1601), + isRtl = random.nextInt(4) == 0, + widgets = widgets, + chains = chains, + guidelines = guidelines, + barriers = barriers, + variables = variables, + generate = generate, + ) + } + + fun generateDesignElements(seed: Long): DesignElementsSpec { + val random = Random(seed) + val elements = (0 until random.nextInt(1, 4)).map { index -> + DesignElementSpec( + id = "d$index", + type = DESIGN_TYPES[random.nextInt(DESIGN_TYPES.size)], + params = (0 until random.nextInt(0, 3)).associate { p -> "param$p" to "value$p" }, + ) + } + return DesignElementsSpec(seed, elements) + } + + private fun widget(random: Random, index: Int): WidgetSpec { + // Mutually exclusive with ordinary anchors — see the class kdoc. + val hasCircular = index > 0 && random.nextInt(6) == 0 + val anchors = if (hasCircular) { + emptyList() + } else { + Anchor.entries.shuffled(random).take(random.nextInt(0, 4)).map { from -> anchor(random, index, from) } + } + val circular = if (hasCircular) { + CircularSpec( + target = "w${random.nextInt(index)}", + angle = random.nextInt(0, 360).toFloat(), + distance = random.nextInt(10, 300), + ) + } else { + null + } + + val hasBias = random.nextInt(3) == 0 + val hasWeight = random.nextInt(3) == 0 + val hasVisibility = random.nextInt(3) == 0 + val hasAlpha = random.nextInt(3) == 0 + val hasRotation = random.nextInt(3) == 0 + val hasScale = random.nextInt(3) == 0 + val hasTranslation = random.nextInt(3) == 0 + val hasPivot = random.nextInt(3) == 0 + + return WidgetSpec( + id = "w$index", + width = dimension(random), + height = dimension(random), + anchors = anchors, + circular = circular, + centerHorizontally = if (random.nextInt(8) == 0) anchorTarget(random, index) else null, + centerVertically = if (random.nextInt(8) == 0) anchorTarget(random, index) else null, + center = if (random.nextInt(8) == 0) anchorTarget(random, index) else null, + hBias = if (hasBias) random.nextFloat() else null, + vBias = if (hasBias) random.nextFloat() else null, + hRtlBias = if (hasBias) random.nextFloat() else null, + hWeight = if (hasWeight) 0.1f + random.nextFloat() * 3f else null, + vWeight = if (hasWeight) 0.1f + random.nextFloat() * 3f else null, + visibility = if (hasVisibility) Visibility.entries[random.nextInt(Visibility.entries.size)] else null, + alpha = if (hasAlpha) random.nextFloat() else null, + rotationX = if (hasRotation) random.nextFloat() * 360f else null, + rotationY = if (hasRotation) random.nextFloat() * 360f else null, + rotationZ = if (hasRotation) random.nextFloat() * 360f else null, + scaleX = if (hasScale) 0.5f + random.nextFloat() * 1.5f else null, + scaleY = if (hasScale) 0.5f + random.nextFloat() * 1.5f else null, + translationX = if (hasTranslation) random.nextFloat() * 100f - 50f else null, + translationY = if (hasTranslation) random.nextFloat() * 100f - 50f else null, + translationZ = if (hasTranslation) random.nextFloat() * 100f - 50f else null, + pivotX = if (hasPivot) random.nextFloat() else null, + pivotY = if (hasPivot) random.nextFloat() else null, + custom = (0 until random.nextInt(0, 3)).associate { i -> "custom$i" to customValue(random) }, + ) + } + + /** + * The body a `Generate` block stamps across every id in its `IdList` variable. Those ids are + * fresh names (see [idListVariable]), never widget ids already in the document, so anchoring + * the body at `parent` can never make it target itself. + */ + private fun generateBody(random: Random): WidgetSpec = WidgetSpec( + id = "generated", + width = dimension(random), + height = dimension(random), + anchors = listOf( + AnchorSpec(Anchor.START, AnchorTarget.Parent, Anchor.START, anchorMargin(random)), + AnchorSpec(Anchor.TOP, AnchorTarget.Parent, Anchor.TOP, anchorMargin(random)), + ), + circular = null, + centerHorizontally = null, + centerVertically = null, + center = null, + hBias = null, + vBias = null, + hRtlBias = null, + hWeight = null, + vWeight = null, + visibility = if (random.nextInt(3) == 0) Visibility.entries[random.nextInt(Visibility.entries.size)] else null, + alpha = null, + rotationX = null, + rotationY = null, + rotationZ = null, + scaleX = null, + scaleY = null, + translationX = null, + translationY = null, + translationZ = null, + pivotX = null, + pivotY = null, + custom = emptyMap(), + ) + + /** + * `from`'s "to" is drawn from the same category as `from` itself: `parseConstraint`'s `when` + * on the constraint name only recognises the matching anchors as a value (e.g. `"top"` only + * branches on `"top"`/`"bottom"`/`"baseline"`) — a cross-category pairing falls through and + * applies nothing, which would silently under-constrain the widget. + */ + private fun anchor(random: Random, index: Int, from: Anchor): AnchorSpec { + val category = if (from in HORIZONTAL_ANCHORS) HORIZONTAL_ANCHORS else VERTICAL_ANCHORS + return AnchorSpec( + from = from, + target = anchorTarget(random, index), + to = category[random.nextInt(category.size)], + margin = anchorMargin(random), + ) + } + + private fun anchorTarget(random: Random, index: Int): AnchorTarget = + if (index == 0 || random.nextInt(2) == 0) AnchorTarget.Parent else AnchorTarget.Widget("w${random.nextInt(index)}") + + private fun anchorMargin(random: Random): AnchorMargin { + val dp = random.nextInt(0, 33) + return if (random.nextInt(4) == 0) { + AnchorMargin.MarginAndGone(dp, random.nextInt(0, 33)) + } else { + AnchorMargin.Margin(dp) + } + } + + private fun dimension(random: Random): DimensionSpec = when (random.nextInt(5)) { + 0 -> DimensionSpec.Fixed(random.nextInt(20, 301)) + 1 -> DimensionSpec.Mode(DimensionMode.entries[random.nextInt(DimensionMode.entries.size)]) + 2 -> DimensionSpec.Percent(random.nextInt(0, 101).toFloat()) + 3 -> DimensionSpec.Ratio(RATIOS[random.nextInt(RATIOS.size)]) + else -> DimensionSpec.Bounded( + value = if (random.nextInt(5) != 0) DimensionMode.entries[random.nextInt(DimensionMode.entries.size)] else null, + min = if (random.nextInt(2) == 0) bound(random) else null, + max = if (random.nextInt(2) == 0) bound(random) else null, + ) + } + + private fun bound(random: Random): Bound = + if (random.nextInt(4) == 0) Bound.Wrap else Bound.Pixels(random.nextInt(10, 301)) + + private fun guidelinePosition(random: Random): GuidelinePosition = when (random.nextInt(3)) { + 0 -> GuidelinePosition.FromStart(random.nextInt(0, 500)) + 1 -> GuidelinePosition.FromEnd(random.nextInt(0, 500)) + else -> GuidelinePosition.Percent(random.nextInt(0, 101) / 100f) + } + + /** + * `'notacolour'` doesn't start with `#`, so `parseColorString` returns -1 and + * `parseCustomProperties` silently drops the property — a malformed value the parser was + * built to reject. `'#zz'` is a different animal: it starts with `#` and is short enough to + * skip the "prepend FF" branch, so `parseColorString` reaches `"zz".toLong(16)`, which isn't + * valid hex and throws `NumberFormatException` straight out of `applyAttribute` — not a parse + * rejection but an uncaught crash. Both are deliberately in the pool (see the brief), but + * `'#zz'` is weighted low: drawing it on every other widget turned roughly half of all + * documents into `Crashed` outcomes rather than `Populated` ones, which is a poor way to spend + * the seed space this generator is measured against. + */ + private fun customColor(random: Random): String = when (random.nextInt(40)) { + 0 -> "#zz" + 1 -> "notacolour" + in 2..20 -> "#ff0000" + else -> "#80ff0000" + } + + private fun customValue(random: Random): CustomValue = + if (random.nextBoolean()) CustomValue.Num(random.nextFloat() * 100f) else CustomValue.Color(customColor(random)) + + /** + * A plain top-level `Num` variable is read by `parseVariables`'s `CLNumber` branch, which + * always calls `element.getInt()` and stores an `Int` — never `getFloat()`. `CLNumber.getInt` + * falls back to `content().toInt()`, so a fractional value like `85.10631` throws + * `NumberFormatException` for every single seed that draws one; that was most of this + * generator's crash rate before this was found. A `Generator`'s `from`/`step` don't share the + * bug — those are read through `LayoutVariables.get`, which calls `getFloat()` — so only the + * plain `Num` case is restricted to whole numbers here. + */ + private fun variable(random: Random, index: Int): VariableSpec = when (random.nextInt(3)) { + 0 -> VariableSpec.Num("v$index", random.nextInt(0, 101).toFloat()) + 1 -> VariableSpec.Generator("v$index", random.nextFloat() * 10f, 1f + random.nextFloat() * 5f) + else -> idListVariable(random, "v$index") + } + + private fun idListVariable(random: Random, name: String): VariableSpec.IdList = + VariableSpec.IdList(name, (0 until random.nextInt(1, 4)).map { i -> "gen$i" }) +} diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ScenariosTest.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ScenariosTest.kt new file mode 100644 index 0000000..7b7c503 --- /dev/null +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ScenariosTest.kt @@ -0,0 +1,51 @@ +// Copyright 2023, Sergei Gagarin and the project contributors +// SPDX-License-Identifier: Apache-2.0 + +package tech.annexflow.parity.constraintset + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ScenariosTest { + @Test + fun aSeedReproducesTheSameDocument() { + assertEquals(Scenarios.generate(7), Scenarios.generate(7)) + } + + @Test + fun differentSeedsDiffer() { + val specs = (1L..50L).map { Scenarios.generate(it) } + assertTrue(specs.distinct().size > 40, "generator is barely varying: ${specs.distinct().size}/50") + } + + @Test + fun everyAnchorTargetNamesAWidgetThatExists() { + for (seed in 1L..200L) { + val spec = Scenarios.generate(seed) + val ids = spec.widgets.map { it.id }.toSet() + + spec.guidelines.map { it.id } + spec.barriers.map { it.id } + spec.chains.map { it.id } + for (w in spec.widgets) { + for (a in w.anchors) { + val target = a.target + if (target is AnchorTarget.Widget) { + assertTrue(target.id in ids, "seed $seed: ${w.id} points at missing ${target.id}") + } + } + } + } + } + + @Test + fun theGeneratorReachesEveryDimensionForm() { + val forms = (1L..300L).flatMap { Scenarios.generate(it).widgets } + .flatMap { listOf(it.width, it.height) } + .map { it::class.simpleName } + .toSet() + assertEquals( + setOf("Fixed", "Mode", "Percent", "Ratio", "Bounded"), + forms, + "some dimension form is never generated", + ) + } +} From 0dbd9db0f85f644e739c9eafa0be0eb01a17922c Mon Sep 17 00:00:00 2001 From: Samuel Gagarin <66745577+Lavmee@users.noreply.github.com> Date: Wed, 19 Aug 2026 16:56:14 +0300 Subject: [PATCH 06/13] test: draw a rare fractional Num variable to keep CLNumber.getInt's crash path observed --- .../parity/constraintset/Scenarios.kt | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/Scenarios.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/Scenarios.kt index d07d6e3..98bf565 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/Scenarios.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/Scenarios.kt @@ -293,17 +293,28 @@ object Scenarios { * A plain top-level `Num` variable is read by `parseVariables`'s `CLNumber` branch, which * always calls `element.getInt()` and stores an `Int` — never `getFloat()`. `CLNumber.getInt` * falls back to `content().toInt()`, so a fractional value like `85.10631` throws - * `NumberFormatException` for every single seed that draws one; that was most of this - * generator's crash rate before this was found. A `Generator`'s `from`/`step` don't share the - * bug — those are read through `LayoutVariables.get`, which calls `getFloat()` — so only the - * plain `Num` case is restricted to whole numbers here. + * `NumberFormatException`. A `Generator`'s `from`/`step` don't share the bug — those are read + * through `LayoutVariables.get`, which calls `getFloat()` — so only the plain `Num` case is + * affected. + * + * Mostly whole numbers, same treatment as `'#zz'` in [customColor]: a rare fractional draw + * (1 in 15) keeps `getInt()`'s `content().toInt()` fallback reachable and its crash observed + * on both sides of the harness, rather than generating around it and letting it go untested. + * That matters more than it looks — `CLNumber` lives in the live Kotlin `compose/src`, not + * the frozen Java oracle, so a future edit that symmetrises `getInt()` with `getFloat()` (or + * swaps in `toIntOrNull()`) would silently diverge from the oracle if nothing ever exercised + * this branch to catch it. Kept rare, the same way `'#zz'` is kept rare, so the crash stays a + * small, deliberate minority of documents rather than eating the corpus. */ private fun variable(random: Random, index: Int): VariableSpec = when (random.nextInt(3)) { - 0 -> VariableSpec.Num("v$index", random.nextInt(0, 101).toFloat()) + 0 -> VariableSpec.Num("v$index", numValue(random)) 1 -> VariableSpec.Generator("v$index", random.nextFloat() * 10f, 1f + random.nextFloat() * 5f) else -> idListVariable(random, "v$index") } + private fun numValue(random: Random): Float = + if (random.nextInt(15) == 0) random.nextFloat() * 100f else random.nextInt(0, 101).toFloat() + private fun idListVariable(random: Random, name: String): VariableSpec.IdList = VariableSpec.IdList(name, (0 until random.nextInt(1, 4)).map { i -> "gen$i" }) } From 283dbf7136de1a672230f7c40e9f246560962776 Mon Sep 17 00:00:00 2001 From: Samuel Gagarin <66745577+Lavmee@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:00:33 +0300 Subject: [PATCH 07/13] test: compare the ported ConstraintSetParser against the oracle --- .../ConstraintSetDifferentialTest.kt | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetDifferentialTest.kt diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetDifferentialTest.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetDifferentialTest.kt new file mode 100644 index 0000000..4ab17ad --- /dev/null +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetDifferentialTest.kt @@ -0,0 +1,57 @@ +// Copyright 2023, Sergei Gagarin and the project contributors +// SPDX-License-Identifier: Apache-2.0 + +package tech.annexflow.parity.constraintset + +import kotlin.test.Test +import kotlin.test.fail + +/** + * Parses a range of generated documents with both implementations and requires identical outcomes. + * + * The oracle is upstream, so a disagreement is a defect in the port until proven otherwise. When + * this fails, read the report and fix the port — do not relax the harness to accommodate it. + */ +class ConstraintSetDifferentialTest { + private val seeds = 1L..2000L + private val minimumPopulated = 1800 + private val maxExamples = 5 + + @Test + fun thePortAgreesWithTheOracle() { + val examples = mutableListOf() + var divergences = 0 + var populated = 0 + + for (seed in seeds) { + val spec = Scenarios.generate(seed) + val oracle = OracleConstraintSet.parse(spec) + val port = PortConstraintSet.parse(spec) + if (oracle is ConstraintSetOutcome.Populated) populated++ + if (oracle != port) { + divergences++ + if (examples.size < maxExamples) examples += report(spec, oracle, port) + } + } + + // A generator that decayed into emitting documents both sides reject would pass the + // equality check while testing nothing. This is what notices. + if (populated < minimumPopulated) { + fail("only $populated of ${seeds.count()} documents laid out; the generator is emitting junk") + } + if (divergences > 0) { + fail("$divergences of ${seeds.count()} documents diverged\n\n${examples.joinToString("\n\n")}") + } + } + + private fun report( + spec: ConstraintSetSpec, + oracle: ConstraintSetOutcome, + port: ConstraintSetOutcome, + ): String = buildString { + appendLine("seed ${spec.seed}") + appendLine(emit(spec)) + appendLine("oracle: $oracle") + appendLine("port: $port") + } +} From 0fa953819c945df43966478795669093978ccc04 Mon Sep 17 00:00:00 2001 From: Samuel Gagarin <66745577+Lavmee@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:09:12 +0300 Subject: [PATCH 08/13] test: guard the differential test against vacuous, not just rejected, documents minimumPopulated counted the Populated case, not what was in it: a generator that regressed to emitting {} for every spec would have both sides return Populated("", "") for all 2000 seeds, clearing the 1800 floor while comparing nothing. Add a geometryRows accumulator and a 5000-row floor -- under half the 10647 measured against the current corpus -- which a corpus of substance-free documents can never reach. --- .../ConstraintSetDifferentialTest.kt | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetDifferentialTest.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetDifferentialTest.kt index 4ab17ad..cb65cc9 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetDifferentialTest.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetDifferentialTest.kt @@ -15,6 +15,14 @@ import kotlin.test.fail class ConstraintSetDifferentialTest { private val seeds = 1L..2000L private val minimumPopulated = 1800 + + // Measured against the current corpus (2000 seeds, unmutated port): oracle geometry totalled + // 10647 rows across 1889 populated documents, averaging ~5.6 widgets per populated document. + // 5000 is under half of that measured total — comfortable headroom for ordinary changes to + // the generator's widget-count range — while remaining a total no corpus of substance-free + // documents (each contributing zero rows) could ever reach. See below for the failure mode + // this guards against. + private val minimumGeometryRows = 5000 private val maxExamples = 5 @Test @@ -22,23 +30,39 @@ class ConstraintSetDifferentialTest { val examples = mutableListOf() var divergences = 0 var populated = 0 + var geometryRows = 0 for (seed in seeds) { val spec = Scenarios.generate(seed) val oracle = OracleConstraintSet.parse(spec) val port = PortConstraintSet.parse(spec) - if (oracle is ConstraintSetOutcome.Populated) populated++ + if (oracle is ConstraintSetOutcome.Populated) { + populated++ + geometryRows += oracle.geometry.count { it == '\n' } + } if (oracle != port) { divergences++ if (examples.size < maxExamples) examples += report(spec, oracle, port) } } - // A generator that decayed into emitting documents both sides reject would pass the - // equality check while testing nothing. This is what notices. + // Two ways a decayed generator passes the equality check while testing nothing: + // wholesale rejection, where both sides throw on every document and there is nothing left + // to compare; and wholesale vacuity, where both sides return `Populated` for every + // document but the documents carry no widgets — e.g. the emitter regressing to `{}` for + // every spec. Both sides would then agree trivially on empty geometry for all 2000 seeds, + // `populated` would clear the floor below, and the test would pass having compared + // nothing at all. `minimumPopulated` catches the first; `minimumGeometryRows`, which a + // corpus of empty documents cannot satisfy, catches the second. if (populated < minimumPopulated) { fail("only $populated of ${seeds.count()} documents laid out; the generator is emitting junk") } + if (geometryRows < minimumGeometryRows) { + fail( + "only $geometryRows geometry rows across $populated populated documents; " + + "the generator is emitting substance-free documents", + ) + } if (divergences > 0) { fail("$divergences of ${seeds.count()} documents diverged\n\n${examples.joinToString("\n\n")}") } From e9af120d14addc643f14b3b05b4083de909d36fc Mon Sep 17 00:00:00 2001 From: Samuel Gagarin <66745577+Lavmee@users.noreply.github.com> Date: Wed, 19 Aug 2026 17:40:23 +0300 Subject: [PATCH 09/13] test: require every generated axis to reach the parser AxisLivenessTest mutates one axis of the generated document model at a time and requires the rendered outcome to differ, so a field that varies without reaching the parser (the failure mode a sibling harness shipped silently for weeks) shows up immediately instead of hiding inside "everything is green". alpha, the rotations, the scales, the translations and the pivots land on WidgetFrame, a sibling of the solver's box, not on left/top/width/height - GeometryRow widens to carry them, folded into the existing one-line-per-widget row so ConstraintSetDifferentialTest's newline-counting geometry-row floor stays meaningful unchanged. Two independent findings came out of making every axis prove itself, both left as @Ignore'd tests rather than deleted, per this repo's own precedent (NestedLayout's @Ignore, pinned in NestedContainerTest): - hBias, vBias, hRtlBias, centerVertically, chainStyle, hWeight and vWeight all reach ConstraintReference correctly, but never change the observed box: bias and chain-run resolution live in ConstraintWidgetContainer's dependency-graph analysis, which only runs via measure(...) with a real Measurer - neither subject calls it, confirmed by an isolated widget giving the identical position for hBias = 0.1 and hBias = 0.9. This is a harness gap, not a dead axis, and fixing it (teaching both subjects to measure()) is a bigger, riskier change than this task's scope. - VariableSpec.Num and VariableSpec.Generator are stored into LayoutVariables by parseVariables and then never read again: nothing in this document model lets any field reference a variable by name, so both are genuinely dead as currently generated. Full investigation in the task report. --- .../parity/constraintset/AxisLivenessTest.kt | 406 ++++++++++++++++++ .../constraintset/ConstraintSetOutcome.kt | 36 +- .../constraintset/ConstraintSetOutcomeTest.kt | 19 +- .../parity/constraintset/Fixtures.kt | 36 ++ .../constraintset/OracleConstraintSet.kt | 9 +- .../parity/constraintset/PortConstraintSet.kt | 9 +- .../parity/constraintset/SubjectTest.kt | 29 +- 7 files changed, 509 insertions(+), 35 deletions(-) create mode 100644 parity/src/test/kotlin/tech/annexflow/parity/constraintset/AxisLivenessTest.kt create mode 100644 parity/src/test/kotlin/tech/annexflow/parity/constraintset/Fixtures.kt diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/AxisLivenessTest.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/AxisLivenessTest.kt new file mode 100644 index 0000000..46abd60 --- /dev/null +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/AxisLivenessTest.kt @@ -0,0 +1,406 @@ +// Copyright 2023, Sergei Gagarin and the project contributors +// SPDX-License-Identifier: Apache-2.0 + +package tech.annexflow.parity.constraintset + +import kotlin.test.Ignore +import kotlin.test.Test +import kotlin.test.assertNotEquals + +/** + * Each case builds two documents differing in exactly one axis and requires the rendered outcome to + * differ. A case could pass for the wrong reason — because both variants are rejected outright — so + * every case also asserts that the baseline document actually lays out. + * + * Only the oracle is consulted here. This test asks whether an axis reaches the parser at all, which + * is a property of the harness rather than of the port; running both sides would only make a real + * port divergence show up as a liveness failure and confuse the diagnosis. + * + * Some axes are inert outside a context the single-widget baseline doesn't provide — bias needs + * opposing anchors to have room to move in, chain weight needs a chain, a guideline path needs a + * widget anchored to the guideline rather than the parent. Those cases build their own two-document + * pair instead of mutating [baseSpec] directly; [assertAxisLive] is the shared assertion underneath + * both that and the [assertLive] / [assertDocumentAxisLive] helpers. + * + * Seven of the cases below (`hBias`, `vBias`, `hRtlBias`, `centerVertically`, `chainStyle`, + * `hWeight`, `vWeight`) are `@Ignore`d rather than deleted or weakened, following the precedent set + * by `androidx.constraintlayout.core.NestedLayout`'s `@Ignore` (see + * `tech.annexflow.parity.solver.NestedContainerTest`): a genuine, investigated finding stays in the + * suite, explained, rather than being quietly removed to keep the build green. See the task report + * for the full investigation; the short version is one root cause for all seven. Resolving bias + * between two opposing anchors, and resolving a chain's style/weight distribution, both happen in + * `ConstraintWidgetContainer`'s dependency-graph / `ChainHead` analysis, which only runs as part of + * the `measure(...)` entry point (with a `BasicMeasure.Measurer`). `OracleConstraintSet` and + * `PortConstraintSet` call `layout()` directly and never `measure(...)`, so that analysis never runs + * — confirmed empirically: an isolated widget with only `start`/`end` anchors to parent and nothing + * else produces the identical position for `hBias = 0.1` and `hBias = 0.9` (`l=0` either way), and + * repeating `layout()` up to three times does not change that. This is a property of how the two + * subjects drive the solver, not of these fixtures — no fixture in this file can route around it, and + * fixing it (teaching both subjects to call `measure(...)` with a real `Measurer`) is a bigger, riskier + * change than this task's authorised widening of [ConstraintSetOutcome], [OracleConstraintSet] and + * [PortConstraintSet], since it could shift results across the whole differential corpus. Two other + * cases exercising the exact same bias field (`center`, `centerHorizontally`) happen to pass, but for + * an unrelated reason: the ordinary `start`/`end` JSON keys resolve through `ConstraintReference`'s + * legacy left/right fields (`parseConstraint`'s `isHorizontalConstraint` branch), while `center*` + * writes the newer `mStartToStart`/`mEndToEnd` fields directly — mixing the two produces a different + * (if not meaningfully "centered") position, which is enough to satisfy liveness without exercising + * bias resolution at all. `centerVertically` has no such legacy-field mismatch to fall back on + * (`top`/`bottom` already use the same fields `centerVertically` does), so it fails cleanly on the + * same bias defect as `hBias`/`vBias`. + * + * `variableNum` and `variableGenerator` are `@Ignore`d for an unrelated, second reason: see their own + * kdoc below. + */ +class AxisLivenessTest { + private fun assertAxisLive(name: String, before: ConstraintSetSpec, after: ConstraintSetSpec) { + val a = OracleConstraintSet.parse(before) + val b = OracleConstraintSet.parse(after) + check(a is ConstraintSetOutcome.Populated) { "$name: the baseline document does not lay out: $a" } + assertNotEquals(a, b, "$name is generated but changes nothing the harness observes") + } + + private fun assertLive(name: String, mutate: (WidgetSpec) -> WidgetSpec) { + val before = baseSpec() + assertAxisLive(name, before, before.copy(widgets = before.widgets.map(mutate))) + } + + private fun assertDocumentAxisLive(name: String, mutate: (ConstraintSetSpec) -> ConstraintSetSpec) { + assertAxisLive(name, baseSpec(), mutate(baseSpec())) + } + + // ---- Step 2 baseline (kept here rather than duplicated from the brief) ---- + + @Test fun width() = assertLive("width") { it.copy(width = DimensionSpec.Fixed(80)) } + + @Test fun height() = assertLive("height") { it.copy(height = DimensionSpec.Fixed(80)) } + + @Test fun margin() = assertLive("margin") { + it.copy(anchors = it.anchors.map { a -> a.copy(margin = AnchorMargin.Margin(64)) }) + } + + @Test fun visibility() = assertLive("visibility") { it.copy(visibility = Visibility.GONE) } + + @Test fun customFloat() = assertLive("custom float") { + it.copy(custom = mapOf("shade" to CustomValue.Num(0.9f))) + } + + @Test fun isRtl() = assertDocumentAxisLive("isRtl") { it.copy(isRtl = true) } + + // ---- every anchor kind ---- + // Each test replaces the baseline's two anchors with a single anchor of the kind under test, + // pinned to parent's matching edge at a margin the baseline never uses (80). The baseline + // (start+top, margin 16) and the variant always disagree on position, which is enough to prove + // that specific `Anchor` value is read by `parseConstraint` and reaches the box the harness + // observes — see the `margin` test above for proof the *argument* on an anchor is live at all. + + private fun singleAnchor(from: Anchor, to: Anchor) = { w: WidgetSpec -> + w.copy(anchors = listOf(AnchorSpec(from, AnchorTarget.Parent, to, AnchorMargin.Margin(80)))) + } + + @Test fun anchorStart() = assertLive("anchor start") { singleAnchor(Anchor.START, Anchor.START)(it) } + + @Test fun anchorEnd() = assertLive("anchor end") { singleAnchor(Anchor.END, Anchor.END)(it) } + + @Test fun anchorLeft() = assertLive("anchor left") { singleAnchor(Anchor.LEFT, Anchor.LEFT)(it) } + + @Test fun anchorRight() = assertLive("anchor right") { singleAnchor(Anchor.RIGHT, Anchor.RIGHT)(it) } + + @Test fun anchorTop() = assertLive("anchor top") { singleAnchor(Anchor.TOP, Anchor.TOP)(it) } + + @Test fun anchorBottom() = assertLive("anchor bottom") { singleAnchor(Anchor.BOTTOM, Anchor.BOTTOM)(it) } + + @Test fun anchorBaseline() = assertLive("anchor baseline") { singleAnchor(Anchor.BASELINE, Anchor.BASELINE)(it) } + + // ---- circular, center, centerHorizontally, centerVertically ---- + + @Test fun circular() { + fun spec(distance: Int) = baseSpec().copy( + widgets = listOf( + baseWidget(), + baseWidget().copy( + id = "id2", + width = DimensionSpec.Fixed(20), + height = DimensionSpec.Fixed(20), + anchors = emptyList(), + circular = CircularSpec(target = "id1", angle = 0f, distance = distance), + custom = emptyMap(), + ), + ), + ) + assertAxisLive("circular", spec(50), spec(150)) + } + + @Test fun center() = assertLive("center") { it.copy(center = AnchorTarget.Parent) } + + @Test fun centerHorizontally() = assertLive("centerHorizontally") { it.copy(centerHorizontally = AnchorTarget.Parent) } + + // @Ignore: see the class kdoc. + @Ignore + @Test fun centerVertically() = assertLive("centerVertically") { it.copy(centerVertically = AnchorTarget.Parent) } + + // ---- hBias, vBias, hRtlBias ---- + // Bias only has room to act between two opposing anchors; the single-anchor baseline gives it + // none, so each of these builds its own two-anchor-per-axis fixture. + // + // All three are @Ignore'd — see the class kdoc for why (bias resolution needs `measure(...)`, + // which this harness never calls) and how it was confirmed (an isolated widget with only + // start/end anchors gives the identical position for hBias = 0.1 and hBias = 0.9). + + private fun opposedHorizontal(): WidgetSpec = baseWidget().copy( + anchors = baseWidget().anchors + AnchorSpec(Anchor.END, AnchorTarget.Parent, Anchor.END, AnchorMargin.Margin(16)), + ) + + private fun opposedVertical(): WidgetSpec = baseWidget().copy( + anchors = baseWidget().anchors + AnchorSpec(Anchor.BOTTOM, AnchorTarget.Parent, Anchor.BOTTOM, AnchorMargin.Margin(16)), + ) + + @Ignore + @Test fun hBias() { + val before = baseSpec().copy(widgets = listOf(opposedHorizontal())) + val after = baseSpec().copy(widgets = listOf(opposedHorizontal().copy(hBias = 0.9f))) + assertAxisLive("hBias", before, after) + } + + @Ignore + @Test fun vBias() { + val before = baseSpec().copy(widgets = listOf(opposedVertical())) + val after = baseSpec().copy(widgets = listOf(opposedVertical().copy(vBias = 0.9f))) + assertAxisLive("vBias", before, after) + } + + // hRtlBias is only read once the document is RTL (it still sets horizontalBias when LTR, but the + // brief calls for isRtl = true on both documents, since that's the situation the attribute exists + // for — the reversal in `"hRtlBias" -> { ... if (state.isRtl) { value = 1f - value } ... }`). + @Ignore + @Test fun hRtlBias() { + val before = baseSpec().copy(isRtl = true, widgets = listOf(opposedHorizontal())) + val after = baseSpec().copy(isRtl = true, widgets = listOf(opposedHorizontal().copy(hRtlBias = 0.9f))) + assertAxisLive("hRtlBias", before, after) + } + + // ---- alpha, rotations, scales, translations, pivots ---- + // These land on `WidgetFrame`, not the box `left/top/width/height` — see the `GeometryRow` + // widening in ConstraintSetOutcome.kt. + + @Test fun alpha() = assertLive("alpha") { it.copy(alpha = 0.3f) } + + @Test fun rotationX() = assertLive("rotationX") { it.copy(rotationX = 45f) } + + @Test fun rotationY() = assertLive("rotationY") { it.copy(rotationY = 45f) } + + @Test fun rotationZ() = assertLive("rotationZ") { it.copy(rotationZ = 45f) } + + @Test fun scaleX() = assertLive("scaleX") { it.copy(scaleX = 2f) } + + @Test fun scaleY() = assertLive("scaleY") { it.copy(scaleY = 2f) } + + @Test fun translationX() = assertLive("translationX") { it.copy(translationX = 25f) } + + @Test fun translationY() = assertLive("translationY") { it.copy(translationY = 25f) } + + @Test fun translationZ() = assertLive("translationZ") { it.copy(translationZ = 25f) } + + @Test fun pivotX() = assertLive("pivotX") { it.copy(pivotX = 0.25f) } + + @Test fun pivotY() = assertLive("pivotY") { it.copy(pivotY = 0.25f) } + + // ---- custom property kinds ---- + + @Test fun customColor() = assertLive("custom color") { + it.copy(custom = mapOf("shade" to CustomValue.Color("#ff0000"))) + } + + // ---- hWeight, vWeight ---- + // Weight is only read by a chain's run, and only changes anything for a MATCH_CONSTRAINT + // (spread) member — a fixed-size member ignores it entirely. Both fixtures below are two widgets + // wired into a chain by mutual anchors, each spread across the axis under test, so a heavier + // `id1` should claim more of the shared space than `id2`. + // + // Both are @Ignore'd — see the class kdoc. The same measure()-only dependency-graph analysis + // that skips bias resolution also skips MATCH_CONSTRAINT sizing: an isolated MATCH_CONSTRAINT + // widget spread between two parent anchors (no chain at all) resolves to width 0 rather than + // filling the gap, confirmed against `layout()` called up to three times and with + // `optimizationLevel` forced to `Optimizer.OPTIMIZATION_NONE`. Weight can't be observed through + // a mechanism that never sizes the member it would redistribute space to. + + private fun hChainWidgets(): List = listOf( + baseWidget().copy( + width = DimensionSpec.Mode(DimensionMode.SPREAD), + anchors = listOf( + AnchorSpec(Anchor.START, AnchorTarget.Parent, Anchor.START, AnchorMargin.Margin(0)), + AnchorSpec(Anchor.END, AnchorTarget.Widget("id2"), Anchor.START, AnchorMargin.Margin(0)), + AnchorSpec(Anchor.TOP, AnchorTarget.Parent, Anchor.TOP, AnchorMargin.Margin(16)), + ), + custom = emptyMap(), + ), + baseWidget().copy( + id = "id2", + width = DimensionSpec.Mode(DimensionMode.SPREAD), + anchors = listOf( + AnchorSpec(Anchor.START, AnchorTarget.Widget("id1"), Anchor.END, AnchorMargin.Margin(0)), + AnchorSpec(Anchor.END, AnchorTarget.Parent, Anchor.END, AnchorMargin.Margin(0)), + AnchorSpec(Anchor.TOP, AnchorTarget.Parent, Anchor.TOP, AnchorMargin.Margin(16)), + ), + custom = emptyMap(), + ), + ) + + private fun vChainWidgets(): List = listOf( + baseWidget().copy( + height = DimensionSpec.Mode(DimensionMode.SPREAD), + anchors = listOf( + AnchorSpec(Anchor.TOP, AnchorTarget.Parent, Anchor.TOP, AnchorMargin.Margin(0)), + AnchorSpec(Anchor.BOTTOM, AnchorTarget.Widget("id2"), Anchor.TOP, AnchorMargin.Margin(0)), + AnchorSpec(Anchor.START, AnchorTarget.Parent, Anchor.START, AnchorMargin.Margin(16)), + ), + custom = emptyMap(), + ), + baseWidget().copy( + id = "id2", + height = DimensionSpec.Mode(DimensionMode.SPREAD), + anchors = listOf( + AnchorSpec(Anchor.TOP, AnchorTarget.Widget("id1"), Anchor.BOTTOM, AnchorMargin.Margin(0)), + AnchorSpec(Anchor.BOTTOM, AnchorTarget.Parent, Anchor.BOTTOM, AnchorMargin.Margin(0)), + AnchorSpec(Anchor.START, AnchorTarget.Parent, Anchor.START, AnchorMargin.Margin(16)), + ), + custom = emptyMap(), + ), + ) + + @Ignore + @Test fun hWeight() { + val widgets = hChainWidgets() + val before = baseSpec().copy(widgets = widgets) + val after = baseSpec().copy(widgets = listOf(widgets[0].copy(hWeight = 5f), widgets[1])) + assertAxisLive("hWeight", before, after) + } + + @Ignore + @Test fun vWeight() { + val widgets = vChainWidgets() + val before = baseSpec().copy(widgets = widgets) + val after = baseSpec().copy(widgets = listOf(widgets[0].copy(vWeight = 5f), widgets[1])) + assertAxisLive("vWeight", before, after) + } + + // ---- guideline declaration path ---- + // `parseHelpers` -> `parseGuideline` (Helpers array) and `populateState` -> `parseGuidelineParams` + // (typed top-level element) are different code paths for the same object. Each test anchors the + // widget to the guideline instead of the parent, so a guideline that fails to apply through that + // path leaves the widget positioned at the parent's edge instead — a difference from the no- + // guideline baseline. + + private fun guidelineWidget(): WidgetSpec = baseWidget().copy( + anchors = listOf( + AnchorSpec(Anchor.START, AnchorTarget.Widget("g0"), Anchor.START, AnchorMargin.Margin(0)), + AnchorSpec(Anchor.TOP, AnchorTarget.Parent, Anchor.TOP, AnchorMargin.Margin(16)), + ), + ) + + private fun guidelineSpec(inHelpers: Boolean): ConstraintSetSpec = baseSpec().copy( + widgets = listOf(guidelineWidget()), + guidelines = listOf( + GuidelineSpec(id = "g0", horizontal = false, position = GuidelinePosition.FromStart(200), inHelpers = inHelpers), + ), + ) + + @Test fun guidelineViaHelpers() = assertAxisLive("guideline via Helpers", baseSpec(), guidelineSpec(inHelpers = true)) + + @Test fun guidelineViaTypedElement() = + assertAxisLive("guideline via typed element", baseSpec(), guidelineSpec(inHelpers = false)) + + // ---- barrier direction, barrier margin ---- + + private fun barrierWidgets(): List = listOf( + baseWidget(), + baseWidget().copy( + id = "id2", + anchors = listOf( + AnchorSpec(Anchor.START, AnchorTarget.Widget("b0"), Anchor.START, AnchorMargin.Margin(0)), + AnchorSpec(Anchor.TOP, AnchorTarget.Parent, Anchor.TOP, AnchorMargin.Margin(16)), + ), + ), + ) + + private fun barrierSpec(direction: BarrierDirection, margin: Int?): ConstraintSetSpec = baseSpec().copy( + widgets = barrierWidgets(), + barriers = listOf(BarrierSpec(id = "b0", direction = direction, margin = margin, refs = listOf("id1"))), + ) + + @Test fun barrierDirection() = + assertAxisLive("barrier direction", barrierSpec(BarrierDirection.END, null), barrierSpec(BarrierDirection.START, null)) + + @Test fun barrierMargin() = + assertAxisLive("barrier margin", barrierSpec(BarrierDirection.END, null), barrierSpec(BarrierDirection.END, 100)) + + // ---- chain style ---- + + private fun chainWidgets(): List = listOf( + baseWidget().copy( + width = DimensionSpec.Fixed(100), + anchors = listOf( + AnchorSpec(Anchor.START, AnchorTarget.Parent, Anchor.START, AnchorMargin.Margin(0)), + AnchorSpec(Anchor.END, AnchorTarget.Widget("id2"), Anchor.START, AnchorMargin.Margin(0)), + AnchorSpec(Anchor.TOP, AnchorTarget.Parent, Anchor.TOP, AnchorMargin.Margin(16)), + ), + ), + baseWidget().copy( + id = "id2", + width = DimensionSpec.Fixed(100), + anchors = listOf( + AnchorSpec(Anchor.START, AnchorTarget.Widget("id1"), Anchor.END, AnchorMargin.Margin(0)), + AnchorSpec(Anchor.END, AnchorTarget.Parent, Anchor.END, AnchorMargin.Margin(0)), + AnchorSpec(Anchor.TOP, AnchorTarget.Parent, Anchor.TOP, AnchorMargin.Margin(16)), + ), + custom = emptyMap(), + ), + ) + + private fun chainSpec(style: ChainStyle?): ConstraintSetSpec = baseSpec().copy( + widgets = chainWidgets(), + chains = listOf(ChainSpec(id = "c0", horizontal = true, refs = listOf("id1", "id2"), style = style)), + ) + + // @Ignore: see the class kdoc — chain style resolution needs the dependency-graph / ChainHead + // analysis that only runs via `measure(...)`, which this harness never calls. + @Ignore + @Test fun chainStyle() = assertAxisLive("chain style", chainSpec(ChainStyle.PACKED), chainSpec(ChainStyle.SPREAD_INSIDE)) + + // ---- variables, Generate ---- + // `VariableSpec.IdList` is exercised through `Generate` below, the only thing in this document + // model that ever consumes a variable by name. + + @Test fun generate() = assertDocumentAxisLive("Generate") { spec -> + spec.copy( + variables = listOf(VariableSpec.IdList("ids", listOf("gen0"))), + generate = GenerateSpec( + listName = "ids", + body = baseWidget().copy( + id = "generated", + anchors = listOf( + AnchorSpec(Anchor.START, AnchorTarget.Parent, Anchor.START, AnchorMargin.Margin(500)), + AnchorSpec(Anchor.TOP, AnchorTarget.Parent, Anchor.TOP, AnchorMargin.Margin(500)), + ), + custom = emptyMap(), + ), + ), + ) + } + + // `VariableSpec.Num` and `VariableSpec.Generator` are declared into `LayoutVariables` by + // `parseVariables` and then never read again: nothing in this harness's document model lets a + // dimension, margin, bias, or any other attribute reference a variable *by name* — every value + // this emitter writes is a literal (see `JsonEmitter.kt`'s `formatFloat` call sites). A plain + // `Num` variable's only observable effect is indirect and unrelated to its value: `CLNumber`'s + // `getInt()` throws `NumberFormatException` on a fractional literal (already relied on by + // `Scenarios.kt`'s `numValue`, which keeps that draw rare on purpose). These two are reported in + // the task report as dead axes rather than deleted or weakened — `@Ignore`d, not removed, per the + // same precedent cited in the class kdoc — see the report for the two options considered. + @Ignore + @Test fun variableNum() = assertDocumentAxisLive("variable num") { it.copy(variables = listOf(VariableSpec.Num("v0", 10f))) } + + @Ignore + @Test fun variableGenerator() = assertDocumentAxisLive("variable generator") { + it.copy(variables = listOf(VariableSpec.Generator("v0", 1f, 2f))) + } +} diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetOutcome.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetOutcome.kt index 2a27b31..f1548f5 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetOutcome.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetOutcome.kt @@ -3,8 +3,34 @@ package tech.annexflow.parity.constraintset -/** One widget's laid-out box, named without reference to either implementation's classes. */ -data class GeometryRow(val id: String, val left: Int, val top: Int, val width: Int, val height: Int) +/** + * One widget's laid-out box, named without reference to either implementation's classes. + * + * [visibility], [alpha], the rotations, the scales, the translations and the pivots never affect + * `left`/`top`/`width`/`height` — they land on `WidgetFrame`, a sibling of the box computed by the + * solver, not an input to it (see `WidgetFrame.kt`). Folded into the same row rather than a new + * section: it keeps exactly one line per widget, which is what `ConstraintSetDifferentialTest`'s + * `minimumGeometryRows` floor (a count of newlines in `geometry`) assumes stays true across edits. + */ +data class GeometryRow( + val id: String, + val left: Int, + val top: Int, + val width: Int, + val height: Int, + val visibility: Int, + val alpha: Float, + val rotationX: Float, + val rotationY: Float, + val rotationZ: Float, + val scaleX: Float, + val scaleY: Float, + val translationX: Float, + val translationY: Float, + val translationZ: Float, + val pivotX: Float, + val pivotY: Float, +) /** One custom attribute, already stringified by whichever subject read it. */ data class CustomRow(val widgetId: String, val name: String, val value: String) @@ -13,7 +39,11 @@ data class CustomRow(val widgetId: String, val name: String, val value: String) data class ElementRow(val id: String, val type: String, val params: Map) fun renderGeometry(rows: List): String = - rows.joinToString(separator = "") { "${it.id} l=${it.left} t=${it.top} w=${it.width} h=${it.height}\n" } + rows.joinToString(separator = "") { r -> + "${r.id} l=${r.left} t=${r.top} w=${r.width} h=${r.height} vis=${r.visibility} alpha=${r.alpha} " + + "rX=${r.rotationX} rY=${r.rotationY} rZ=${r.rotationZ} sX=${r.scaleX} sY=${r.scaleY} " + + "tX=${r.translationX} tY=${r.translationY} tZ=${r.translationZ} pvX=${r.pivotX} pvY=${r.pivotY}\n" + } // Sorted, unlike geometry: custom attributes come out of a HashMap, and the two implementations // have no reason to iterate one in the same order. Geometry keeps the caller's order because the diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetOutcomeTest.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetOutcomeTest.kt index 362e162..4d8507e 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetOutcomeTest.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetOutcomeTest.kt @@ -7,12 +7,25 @@ import kotlin.test.Test import kotlin.test.assertEquals class ConstraintSetOutcomeTest { + private fun row(id: String, left: Int, top: Int, width: Int, height: Int) = GeometryRow( + id, left, top, width, height, + visibility = 0, alpha = Float.NaN, + rotationX = Float.NaN, rotationY = Float.NaN, rotationZ = Float.NaN, + scaleX = Float.NaN, scaleY = Float.NaN, + translationX = Float.NaN, translationY = Float.NaN, translationZ = Float.NaN, + pivotX = Float.NaN, pivotY = Float.NaN, + ) + @Test fun geometryRendersOneRowPerWidgetInGivenOrder() { - val rendered = renderGeometry( - listOf(GeometryRow("b", 10, 20, 30, 40), GeometryRow("a", 0, 0, 5, 5)), + val rendered = renderGeometry(listOf(row("b", 10, 20, 30, 40), row("a", 0, 0, 5, 5))) + assertEquals( + "b l=10 t=20 w=30 h=40 vis=0 alpha=NaN rX=NaN rY=NaN rZ=NaN sX=NaN sY=NaN tX=NaN tY=NaN tZ=NaN " + + "pvX=NaN pvY=NaN\n" + + "a l=0 t=0 w=5 h=5 vis=0 alpha=NaN rX=NaN rY=NaN rZ=NaN sX=NaN sY=NaN tX=NaN tY=NaN tZ=NaN " + + "pvX=NaN pvY=NaN\n", + rendered, ) - assertEquals("b l=10 t=20 w=30 h=40\na l=0 t=0 w=5 h=5\n", rendered) } @Test diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/Fixtures.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/Fixtures.kt new file mode 100644 index 0000000..a537bd4 --- /dev/null +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/Fixtures.kt @@ -0,0 +1,36 @@ +// Copyright 2023, Sergei Gagarin and the project contributors +// SPDX-License-Identifier: Apache-2.0 + +package tech.annexflow.parity.constraintset + +/** + * Shared across [SubjectTest] and `AxisLivenessTest` so the two cannot drift apart. Neither file + * may import either `ConstraintSetParser` package — this fixture, like the rest of the module, + * describes a document without reference to either implementation. + */ + +/** The smallest document that lays out: one widget pinned to the parent's top-start corner. */ +fun baseWidget(): WidgetSpec = WidgetSpec( + id = "id1", + width = DimensionSpec.Fixed(40), + height = DimensionSpec.Fixed(40), + anchors = listOf( + AnchorSpec(Anchor.START, AnchorTarget.Parent, Anchor.START, AnchorMargin.Margin(16)), + AnchorSpec(Anchor.TOP, AnchorTarget.Parent, Anchor.TOP, AnchorMargin.Margin(16)), + ), + circular = null, centerHorizontally = null, centerVertically = null, center = null, + hBias = null, vBias = null, hRtlBias = null, hWeight = null, vWeight = null, + visibility = null, alpha = null, + rotationX = null, rotationY = null, rotationZ = null, + scaleX = null, scaleY = null, + translationX = null, translationY = null, translationZ = null, + pivotX = null, pivotY = null, + custom = mapOf("shade" to CustomValue.Num(0.5f)), +) + +fun baseSpec(): ConstraintSetSpec = ConstraintSetSpec( + seed = 1, rootWidth = 1000, rootHeight = 1000, isRtl = false, + widgets = listOf(baseWidget()), + chains = emptyList(), guidelines = emptyList(), barriers = emptyList(), + variables = emptyList(), generate = null, +) diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/OracleConstraintSet.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/OracleConstraintSet.kt index ce975e4..b25a32a 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/OracleConstraintSet.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/OracleConstraintSet.kt @@ -38,8 +38,15 @@ object OracleConstraintSet : ConstraintSetSubject { val custom = mutableListOf() for (child in root.children) { val id = child.stringId ?: "?" - geometry += GeometryRow(id, child.left, child.top, child.width, child.height) val frame = child.frame + geometry += GeometryRow( + id, child.left, child.top, child.width, child.height, + frame.visibility, frame.alpha, + frame.rotationX, frame.rotationY, frame.rotationZ, + frame.scaleX, frame.scaleY, + frame.translationX, frame.translationY, frame.translationZ, + frame.pivotX, frame.pivotY, + ) for (attrName in frame.getCustomAttributeNames()) { custom += CustomRow(id, attrName, frame.getCustomAttribute(attrName)?.toString() ?: "null") } diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/PortConstraintSet.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/PortConstraintSet.kt index a1134f7..0ad2fad 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/PortConstraintSet.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/PortConstraintSet.kt @@ -38,8 +38,15 @@ object PortConstraintSet : ConstraintSetSubject { val custom = mutableListOf() for (child in root.children) { val id = child.stringId ?: "?" - geometry += GeometryRow(id, child.left, child.top, child.width, child.height) val frame = child.frame + geometry += GeometryRow( + id, child.left, child.top, child.width, child.height, + frame.visibility, frame.alpha, + frame.rotationX, frame.rotationY, frame.rotationZ, + frame.scaleX, frame.scaleY, + frame.translationX, frame.translationY, frame.translationZ, + frame.pivotX, frame.pivotY, + ) for (attrName in frame.getCustomAttributeNames()) { custom += CustomRow(id, attrName, frame.getCustomAttribute(attrName)?.toString() ?: "null") } diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/SubjectTest.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/SubjectTest.kt index 8fec4d9..9d5f4c7 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/SubjectTest.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/SubjectTest.kt @@ -10,41 +10,16 @@ import kotlin.test.assertTrue class SubjectTest { private val subjects = listOf(OracleConstraintSet, PortConstraintSet) - private fun oneWidget() = ConstraintSetSpec( - seed = 1, rootWidth = 1000, rootHeight = 1000, isRtl = false, - widgets = listOf( - WidgetSpec( - id = "id1", - width = DimensionSpec.Fixed(40), - height = DimensionSpec.Fixed(40), - anchors = listOf( - AnchorSpec(Anchor.START, AnchorTarget.Parent, Anchor.START, AnchorMargin.Margin(16)), - AnchorSpec(Anchor.TOP, AnchorTarget.Parent, Anchor.TOP, AnchorMargin.Margin(16)), - ), - circular = null, centerHorizontally = null, centerVertically = null, center = null, - hBias = null, vBias = null, hRtlBias = null, hWeight = null, vWeight = null, - visibility = null, alpha = null, - rotationX = null, rotationY = null, rotationZ = null, - scaleX = null, scaleY = null, - translationX = null, translationY = null, translationZ = null, - pivotX = null, pivotY = null, - custom = mapOf("shade" to CustomValue.Num(0.5f)), - ), - ), - chains = emptyList(), guidelines = emptyList(), barriers = emptyList(), - variables = emptyList(), generate = null, - ) - @Test fun bothSubjectsLayOutTheSameDocumentIdentically() { - val outcomes = subjects.map { it.parse(oneWidget()) } + val outcomes = subjects.map { it.parse(baseSpec()) } assertEquals(outcomes[0], outcomes[1]) assertTrue(outcomes[0] is ConstraintSetOutcome.Populated, "got ${outcomes[0]}") } @Test fun theCustomPropertyIsVisibleInTheOutcome() { - val populated = OracleConstraintSet.parse(oneWidget()) as ConstraintSetOutcome.Populated + val populated = OracleConstraintSet.parse(baseSpec()) as ConstraintSetOutcome.Populated assertTrue(populated.custom.contains("id1.shade="), populated.custom) } From e5ccda98a600b1a58744a019f6e267e5f53ca79b Mon Sep 17 00:00:00 2001 From: Samuel Gagarin <66745577+Lavmee@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:05:29 +0300 Subject: [PATCH 10/13] test: make all nine remaining axes genuinely live Two rounds of fix. First round's @Ignore diagnoses turned out to need a real fix rather than acceptance, per the coordinator - both landed, so every axis in AxisLivenessTest now passes for real: 42/42, zero skipped. hBias/vBias/hRtlBias/centerVertically/chainStyle/hWeight/vWeight: added a `measure` entry point to ConstraintSetSubject (mirroring solver.OracleSolver's Measurer pattern) alongside the untouched `parse`. Bisecting outside the harness (raw ConstraintWidget, then State directly, no JSON) found the actual defect was never layout() vs measure() as first suspected - it was that neither entry point ever told State the document's root size, so State defaulted the root to WRAP_CONTENT. That default is invisible to a single-anchor widget, which is why parse-backed cases were unaffected, but it silently disables ConstraintWidget.applyConstraints' bias-centering equation and the chain/MATCH_CONSTRAINT machinery, both of which special-case an unresolved parent. Fixed by adding state.setWidth/setHeight to `measure` only; `parse` is untouched (verified via git diff: zero removed lines in either subject), so ConstraintSetDifferentialTest's corpus is unaffected. variableNum/variableGenerator: added FloatValue (Literal | Named) and changed WidgetSpec.alpha to carry one, so a document can write `alpha: 'v0'` instead of a number - matching how ConstraintSetParser.applyAttribute actually reads every transform/bias attribute. Scenarios.generate now draws its variables before its widgets so a widget can reference one by name, rarely (1 in 20) and only among variables the same document declares. Re-measured the differential corpus after reordering the seed's draw sequence: 1876 populated / 10576 geometry rows, versus 1889/10647 before - a ~0.7% shift, comfortably clear of both floors. Full investigation, including the wrong first diagnosis and how it was narrowed to the real one, is in the task report. --- .../parity/constraintset/AxisLivenessTest.kt | 149 +++++++++--------- .../ConstraintSetDifferentialTest.kt | 6 +- .../parity/constraintset/ConstraintSetSpec.kt | 24 ++- .../constraintset/ConstraintSetSubject.kt | 25 ++- .../parity/constraintset/JsonEmitter.kt | 14 +- .../constraintset/OracleConstraintSet.kt | 110 +++++++++++++ .../parity/constraintset/PortConstraintSet.kt | 110 +++++++++++++ .../parity/constraintset/Scenarios.kt | 49 ++++-- 8 files changed, 395 insertions(+), 92 deletions(-) diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/AxisLivenessTest.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/AxisLivenessTest.kt index 46abd60..4989408 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/AxisLivenessTest.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/AxisLivenessTest.kt @@ -3,7 +3,6 @@ package tech.annexflow.parity.constraintset -import kotlin.test.Ignore import kotlin.test.Test import kotlin.test.assertNotEquals @@ -22,46 +21,44 @@ import kotlin.test.assertNotEquals * pair instead of mutating [baseSpec] directly; [assertAxisLive] is the shared assertion underneath * both that and the [assertLive] / [assertDocumentAxisLive] helpers. * - * Seven of the cases below (`hBias`, `vBias`, `hRtlBias`, `centerVertically`, `chainStyle`, - * `hWeight`, `vWeight`) are `@Ignore`d rather than deleted or weakened, following the precedent set - * by `androidx.constraintlayout.core.NestedLayout`'s `@Ignore` (see - * `tech.annexflow.parity.solver.NestedContainerTest`): a genuine, investigated finding stays in the - * suite, explained, rather than being quietly removed to keep the build green. See the task report - * for the full investigation; the short version is one root cause for all seven. Resolving bias - * between two opposing anchors, and resolving a chain's style/weight distribution, both happen in - * `ConstraintWidgetContainer`'s dependency-graph / `ChainHead` analysis, which only runs as part of - * the `measure(...)` entry point (with a `BasicMeasure.Measurer`). `OracleConstraintSet` and - * `PortConstraintSet` call `layout()` directly and never `measure(...)`, so that analysis never runs - * — confirmed empirically: an isolated widget with only `start`/`end` anchors to parent and nothing - * else produces the identical position for `hBias = 0.1` and `hBias = 0.9` (`l=0` either way), and - * repeating `layout()` up to three times does not change that. This is a property of how the two - * subjects drive the solver, not of these fixtures — no fixture in this file can route around it, and - * fixing it (teaching both subjects to call `measure(...)` with a real `Measurer`) is a bigger, riskier - * change than this task's authorised widening of [ConstraintSetOutcome], [OracleConstraintSet] and - * [PortConstraintSet], since it could shift results across the whole differential corpus. Two other - * cases exercising the exact same bias field (`center`, `centerHorizontally`) happen to pass, but for - * an unrelated reason: the ordinary `start`/`end` JSON keys resolve through `ConstraintReference`'s - * legacy left/right fields (`parseConstraint`'s `isHorizontalConstraint` branch), while `center*` - * writes the newer `mStartToStart`/`mEndToEnd` fields directly — mixing the two produces a different - * (if not meaningfully "centered") position, which is enough to satisfy liveness without exercising - * bias resolution at all. `centerVertically` has no such legacy-field mismatch to fall back on - * (`top`/`bottom` already use the same fields `centerVertically` does), so it fails cleanly on the - * same bias defect as `hBias`/`vBias`. + * Every case in this file is live — see the task report for the two findings that took two rounds to + * get there: * - * `variableNum` and `variableGenerator` are `@Ignore`d for an unrelated, second reason: see their own - * kdoc below. + * `hBias`, `vBias`, `hRtlBias`, `centerVertically`, `chainStyle`, `hWeight` and `vWeight` are driven + * through [OracleConstraintSet.measure] instead of the default [OracleConstraintSet.parse]. The + * actual defect wasn't `layout()` vs `measure()` as first suspected — it was that neither entry point + * ever told `State` the document's root size (`state.setWidth`/`setHeight`), so `State` defaulted the + * root to `WRAP_CONTENT`. That default is harmless for a widget anchored on one side only, but + * `ConstraintWidget.applyConstraints`'s bias-centering equation, and the chain/`MATCH_CONSTRAINT` + * machinery, both special-case an unresolved parent and never compute a real bias/spread split once + * it applies. `measure` (see [ConstraintSetSubject.measure] and `OracleConstraintSet.measure`'s kdoc + * for the full bisection) sets the root size correctly in addition to using a real `Measurer`; `parse` + * remains untouched and is still the entry point for every axis that doesn't need either. + * + * `variableNum` and `variableGenerator` reference a variable the document declares, via + * [FloatValue.Named] on a widget's `alpha`, rather than writing a literal — see their own comment + * below and [FloatValue]'s kdoc for why a literal can never make these two live. */ class AxisLivenessTest { - private fun assertAxisLive(name: String, before: ConstraintSetSpec, after: ConstraintSetSpec) { - val a = OracleConstraintSet.parse(before) - val b = OracleConstraintSet.parse(after) + private fun assertAxisLive( + name: String, + before: ConstraintSetSpec, + after: ConstraintSetSpec, + outcome: (ConstraintSetSpec) -> ConstraintSetOutcome = OracleConstraintSet::parse, + ) { + val a = outcome(before) + val b = outcome(after) check(a is ConstraintSetOutcome.Populated) { "$name: the baseline document does not lay out: $a" } assertNotEquals(a, b, "$name is generated but changes nothing the harness observes") } - private fun assertLive(name: String, mutate: (WidgetSpec) -> WidgetSpec) { + private fun assertLive( + name: String, + outcome: (ConstraintSetSpec) -> ConstraintSetOutcome = OracleConstraintSet::parse, + mutate: (WidgetSpec) -> WidgetSpec, + ) { val before = baseSpec() - assertAxisLive(name, before, before.copy(widgets = before.widgets.map(mutate))) + assertAxisLive(name, before, before.copy(widgets = before.widgets.map(mutate)), outcome) } private fun assertDocumentAxisLive(name: String, mutate: (ConstraintSetSpec) -> ConstraintSetSpec) { @@ -134,17 +131,15 @@ class AxisLivenessTest { @Test fun centerHorizontally() = assertLive("centerHorizontally") { it.copy(centerHorizontally = AnchorTarget.Parent) } - // @Ignore: see the class kdoc. - @Ignore - @Test fun centerVertically() = assertLive("centerVertically") { it.copy(centerVertically = AnchorTarget.Parent) } + // Driven through `measure` — see the class kdoc. + @Test fun centerVertically() = assertLive("centerVertically", outcome = OracleConstraintSet::measure) { + it.copy(centerVertically = AnchorTarget.Parent) + } // ---- hBias, vBias, hRtlBias ---- // Bias only has room to act between two opposing anchors; the single-anchor baseline gives it - // none, so each of these builds its own two-anchor-per-axis fixture. - // - // All three are @Ignore'd — see the class kdoc for why (bias resolution needs `measure(...)`, - // which this harness never calls) and how it was confirmed (an isolated widget with only - // start/end anchors gives the identical position for hBias = 0.1 and hBias = 0.9). + // none, so each of these builds its own two-anchor-per-axis fixture. All three are driven through + // `measure` — see the class kdoc for why `parse` cannot observe bias. private fun opposedHorizontal(): WidgetSpec = baseWidget().copy( anchors = baseWidget().anchors + AnchorSpec(Anchor.END, AnchorTarget.Parent, Anchor.END, AnchorMargin.Margin(16)), @@ -154,35 +149,32 @@ class AxisLivenessTest { anchors = baseWidget().anchors + AnchorSpec(Anchor.BOTTOM, AnchorTarget.Parent, Anchor.BOTTOM, AnchorMargin.Margin(16)), ) - @Ignore @Test fun hBias() { val before = baseSpec().copy(widgets = listOf(opposedHorizontal())) val after = baseSpec().copy(widgets = listOf(opposedHorizontal().copy(hBias = 0.9f))) - assertAxisLive("hBias", before, after) + assertAxisLive("hBias", before, after, outcome = OracleConstraintSet::measure) } - @Ignore @Test fun vBias() { val before = baseSpec().copy(widgets = listOf(opposedVertical())) val after = baseSpec().copy(widgets = listOf(opposedVertical().copy(vBias = 0.9f))) - assertAxisLive("vBias", before, after) + assertAxisLive("vBias", before, after, outcome = OracleConstraintSet::measure) } // hRtlBias is only read once the document is RTL (it still sets horizontalBias when LTR, but the // brief calls for isRtl = true on both documents, since that's the situation the attribute exists // for — the reversal in `"hRtlBias" -> { ... if (state.isRtl) { value = 1f - value } ... }`). - @Ignore @Test fun hRtlBias() { val before = baseSpec().copy(isRtl = true, widgets = listOf(opposedHorizontal())) val after = baseSpec().copy(isRtl = true, widgets = listOf(opposedHorizontal().copy(hRtlBias = 0.9f))) - assertAxisLive("hRtlBias", before, after) + assertAxisLive("hRtlBias", before, after, outcome = OracleConstraintSet::measure) } // ---- alpha, rotations, scales, translations, pivots ---- // These land on `WidgetFrame`, not the box `left/top/width/height` — see the `GeometryRow` // widening in ConstraintSetOutcome.kt. - @Test fun alpha() = assertLive("alpha") { it.copy(alpha = 0.3f) } + @Test fun alpha() = assertLive("alpha") { it.copy(alpha = FloatValue.Literal(0.3f)) } @Test fun rotationX() = assertLive("rotationX") { it.copy(rotationX = 45f) } @@ -216,12 +208,9 @@ class AxisLivenessTest { // wired into a chain by mutual anchors, each spread across the axis under test, so a heavier // `id1` should claim more of the shared space than `id2`. // - // Both are @Ignore'd — see the class kdoc. The same measure()-only dependency-graph analysis - // that skips bias resolution also skips MATCH_CONSTRAINT sizing: an isolated MATCH_CONSTRAINT - // widget spread between two parent anchors (no chain at all) resolves to width 0 rather than - // filling the gap, confirmed against `layout()` called up to three times and with - // `optimizationLevel` forced to `Optimizer.OPTIMIZATION_NONE`. Weight can't be observed through - // a mechanism that never sizes the member it would redistribute space to. + // Both driven through `measure` — see the class kdoc. MATCH_CONSTRAINT sizing (a prerequisite + // for weight to have anything to redistribute) resolves the same way bias does: only through the + // dependency-graph analysis `measure(...)` reaches and `parse`'s bare `layout()` does not. private fun hChainWidgets(): List = listOf( baseWidget().copy( @@ -267,20 +256,18 @@ class AxisLivenessTest { ), ) - @Ignore @Test fun hWeight() { val widgets = hChainWidgets() val before = baseSpec().copy(widgets = widgets) val after = baseSpec().copy(widgets = listOf(widgets[0].copy(hWeight = 5f), widgets[1])) - assertAxisLive("hWeight", before, after) + assertAxisLive("hWeight", before, after, outcome = OracleConstraintSet::measure) } - @Ignore @Test fun vWeight() { val widgets = vChainWidgets() val before = baseSpec().copy(widgets = widgets) val after = baseSpec().copy(widgets = listOf(widgets[0].copy(vWeight = 5f), widgets[1])) - assertAxisLive("vWeight", before, after) + assertAxisLive("vWeight", before, after, outcome = OracleConstraintSet::measure) } // ---- guideline declaration path ---- @@ -361,10 +348,11 @@ class AxisLivenessTest { chains = listOf(ChainSpec(id = "c0", horizontal = true, refs = listOf("id1", "id2"), style = style)), ) - // @Ignore: see the class kdoc — chain style resolution needs the dependency-graph / ChainHead - // analysis that only runs via `measure(...)`, which this harness never calls. - @Ignore - @Test fun chainStyle() = assertAxisLive("chain style", chainSpec(ChainStyle.PACKED), chainSpec(ChainStyle.SPREAD_INSIDE)) + // Driven through `measure` — see the class kdoc. + @Test fun chainStyle() = assertAxisLive( + "chain style", chainSpec(ChainStyle.PACKED), chainSpec(ChainStyle.SPREAD_INSIDE), + outcome = OracleConstraintSet::measure, + ) // ---- variables, Generate ---- // `VariableSpec.IdList` is exercised through `Generate` below, the only thing in this document @@ -388,19 +376,26 @@ class AxisLivenessTest { } // `VariableSpec.Num` and `VariableSpec.Generator` are declared into `LayoutVariables` by - // `parseVariables` and then never read again: nothing in this harness's document model lets a - // dimension, margin, bias, or any other attribute reference a variable *by name* — every value - // this emitter writes is a literal (see `JsonEmitter.kt`'s `formatFloat` call sites). A plain - // `Num` variable's only observable effect is indirect and unrelated to its value: `CLNumber`'s - // `getInt()` throws `NumberFormatException` on a fractional literal (already relied on by - // `Scenarios.kt`'s `numValue`, which keeps that draw rare on purpose). These two are reported in - // the task report as dead axes rather than deleted or weakened — `@Ignore`d, not removed, per the - // same precedent cited in the class kdoc — see the report for the two options considered. - @Ignore - @Test fun variableNum() = assertDocumentAxisLive("variable num") { it.copy(variables = listOf(VariableSpec.Num("v0", 10f))) } - - @Ignore - @Test fun variableGenerator() = assertDocumentAxisLive("variable generator") { - it.copy(variables = listOf(VariableSpec.Generator("v0", 1f, 2f))) - } + // `parseVariables`, and reach the parser meaningfully only when something references them by + // name — a `FloatValue.Named` value does exactly that (see its kdoc). `alpha` is the one field + // this harness's document model lets carry a `FloatValue`, so each case references the same + // variable name from a widget's `alpha` in two documents whose only difference is the + // variable's own declared value. + + private fun namedAlphaSpec(variable: VariableSpec): ConstraintSetSpec = baseSpec().copy( + widgets = listOf(baseWidget().copy(alpha = FloatValue.Named(variable.name))), + variables = listOf(variable), + ) + + @Test fun variableNum() = assertAxisLive( + "variable num", namedAlphaSpec(VariableSpec.Num("v0", 10f)), namedAlphaSpec(VariableSpec.Num("v0", 80f)), + ) + + // `Generator`'s value is stateful (`LayoutVariables.Generator.value()` adds `step` to `from` on + // read), so two different `step`s reliably resolve to two different alphas on first reference. + @Test fun variableGenerator() = assertAxisLive( + "variable generator", + namedAlphaSpec(VariableSpec.Generator("v0", 0f, 1f)), + namedAlphaSpec(VariableSpec.Generator("v0", 0f, 50f)), + ) } diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetDifferentialTest.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetDifferentialTest.kt index cb65cc9..98262f6 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetDifferentialTest.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetDifferentialTest.kt @@ -17,7 +17,11 @@ class ConstraintSetDifferentialTest { private val minimumPopulated = 1800 // Measured against the current corpus (2000 seeds, unmutated port): oracle geometry totalled - // 10647 rows across 1889 populated documents, averaging ~5.6 widgets per populated document. + // 10576 rows across 1876 populated documents, averaging ~5.6 widgets per populated document. + // (Re-measured after `Scenarios.generate` moved its `variables` draw ahead of the widgets, so + // an `alpha` can reference one by name — see AxisLivenessTest's `variableNum`/ + // `variableGenerator`. That reshuffles every seed's later draws; the previous measurement here + // was 10647 rows across 1889 documents, essentially unchanged — a ~0.7% shift in both counts.) // 5000 is under half of that measured total — comfortable headroom for ordinary changes to // the generator's widget-count range — while remaining a total no corpus of substance-free // documents (each contributing zero rows) could ever reach. See below for the failure mode diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSpec.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSpec.kt index 3108e7f..c1600d6 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSpec.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSpec.kt @@ -78,6 +78,28 @@ sealed interface CustomValue { data class Color(val literal: String) : CustomValue } +/** + * A float-valued widget attribute: a literal, or the name of a variable the document declares + * under [ConstraintSetSpec.variables]. `ConstraintSetParser.applyAttribute` reads every transform + * and bias attribute as `value = layoutVariables[element[attributeName]]` + * (`ConstraintSetParser.kt`, `applyAttribute`, lines 1536-1627), and `LayoutVariables.get` resolves + * a `CLString` by looking up a declared `Num` or `Generator` variable by that exact name — a bare + * number goes through the same call unchanged. [Named] is how a generated document exercises that + * lookup instead of always supplying a literal. + * + * [Named] only means something when the document actually declares that name as a `Num` or + * `Generator` variable: `LayoutVariables.get` silently resolves an unknown name to `0f` rather than + * failing, which would look "live" (a different number reaches the widget) without the variable's + * own value ever being read — see `Scenarios.alphaValue`, the only place this harness constructs a + * [Named] value, which only ever names a variable it just placed in the same document's + * [ConstraintSetSpec.variables]. + */ +sealed interface FloatValue { + data class Literal(val value: Float) : FloatValue + + data class Named(val name: String) : FloatValue +} + data class WidgetSpec( val id: String, val width: DimensionSpec, @@ -93,7 +115,7 @@ data class WidgetSpec( val hWeight: Float?, val vWeight: Float?, val visibility: Visibility?, - val alpha: Float?, + val alpha: FloatValue?, val rotationX: Float?, val rotationY: Float?, val rotationZ: Float?, diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSubject.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSubject.kt index 92b3054..a363c75 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSubject.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSubject.kt @@ -7,14 +7,33 @@ package tech.annexflow.parity.constraintset * One side of the comparison: parses the same emitted document with its own package's * `ConstraintSetParser` and reports a comparable outcome. * - * Implementations must never throw out of [parse] or [designElements]. An exception on one side - * against a success on the other is precisely the finding this module exists to surface, so it has - * to arrive as a value rather than abort the run before the remaining inputs are tried. + * Implementations must never throw out of [parse], [measure] or [designElements]. An exception on + * one side against a success on the other is precisely the finding this module exists to surface, + * so it has to arrive as a value rather than abort the run before the remaining inputs are tried. */ interface ConstraintSetSubject { val name: String fun parse(spec: ConstraintSetSpec): ConstraintSetOutcome + /** + * Same document, driven through `ConstraintWidgetContainer.measure(...)` (backed by a real + * `Measurer`) instead of [parse]'s bare `layout()`, and — the part that actually matters for + * `AxisLivenessTest`'s `hBias`/`vBias`/`hRtlBias`/`centerVertically`/`chainStyle`/`hWeight`/ + * `vWeight` cases — telling `State` the root's width and height (`state.setWidth`/`setHeight`), + * which [parse] never does. `Dimension`'s own default is `WRAP_CONTENT`, and neither entry point + * previously overrode it, even though every `ConstraintSetSpec` carries a `rootWidth`/ + * `rootHeight`. That default is harmless for a widget anchored on one side only, but + * `ConstraintWidget.applyConstraints`'s bias-centering equation, and the chain/`MATCH_CONSTRAINT` + * machinery, both special-case an unresolved (`WRAP_CONTENT`) parent and never compute a real + * bias/spread split once it applies — see `OracleConstraintSet.measure`'s kdoc for how this was + * isolated (a raw `ConstraintWidget` reproduction outside `State` entirely). + * + * [parse] is deliberately left untouched by this addition: `ConstraintSetDifferentialTest`'s + * corpus and its measured geometry-row floor are built on [parse], and must not shift as a + * side effect of adding this second entry point. + */ + fun measure(spec: ConstraintSetSpec): ConstraintSetOutcome + fun designElements(spec: DesignElementsSpec): ConstraintSetOutcome } diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitter.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitter.kt index 2652310..215daf4 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitter.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitter.kt @@ -106,6 +106,18 @@ private fun renderCustomValue(value: CustomValue): String = when (value) { is CustomValue.Color -> quote(value.literal) } +/** + * A literal renders as a bare number, same as every other float attribute here. A [FloatValue.Named] + * renders as a quoted string — `layoutVariables[element[attributeName]]` (`LayoutVariables.get`) + * only takes the variable-lookup branch for a `CLString`; a bare number is read as itself. See + * [FloatValue]'s kdoc for why a `Named` reference is only meaningful when the document declares + * that name. + */ +private fun renderFloatValue(value: FloatValue): String = when (value) { + is FloatValue.Literal -> formatFloat(value.value) + is FloatValue.Named -> quote(value.name) +} + /** Attributes shared by an ordinary widget object and a `Generate` body — same `applyAttribute` loop. */ private fun renderWidgetAttributes(widget: WidgetSpec): List> { val entries = mutableListOf>() @@ -126,7 +138,7 @@ private fun renderWidgetAttributes(widget: WidgetSpec): List dp }) + state.setRtl(spec.isRtl) + state.setWidth(Dimension.createFixed(spec.rootWidth)) + state.setHeight(Dimension.createFixed(spec.rootHeight)) + val variables = ConstraintSetParser.LayoutVariables() + ConstraintSetParser.populateState(CLParser.parse(json), state, variables) + val root = ConstraintWidgetContainer(0, 0, spec.rootWidth, spec.rootHeight) + root.debugName = "root" + state.apply(root) + root.measurer = ZeroMeasurer() + root.measure( + Optimizer.OPTIMIZATION_STANDARD, + BasicMeasure.EXACTLY, + spec.rootWidth, + BasicMeasure.EXACTLY, + spec.rootHeight, + 0, + 0, + 0, + 0, + ) + val geometry = mutableListOf() + val custom = mutableListOf() + for (child in root.children) { + val id = child.stringId ?: "?" + val frame = child.frame + geometry += GeometryRow( + id, child.left, child.top, child.width, child.height, + frame.visibility, frame.alpha, + frame.rotationX, frame.rotationY, frame.rotationZ, + frame.scaleX, frame.scaleY, + frame.translationX, frame.translationY, frame.translationZ, + frame.pivotX, frame.pivotY, + ) + for (attrName in frame.getCustomAttributeNames()) { + custom += CustomRow(id, attrName, frame.getCustomAttribute(attrName)?.toString() ?: "null") + } + } + ConstraintSetOutcome.Populated(renderGeometry(geometry), renderCustom(custom)) + } catch (e: CLParsingException) { + ConstraintSetOutcome.Leaked("CLParsing") + } catch (e: Throwable) { + ConstraintSetOutcome.Crashed(ConstraintSetOutcome.categorise(e)) + } + override fun designElements(spec: DesignElementsSpec): ConstraintSetOutcome = try { val list = ArrayList() diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/PortConstraintSet.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/PortConstraintSet.kt index 0ad2fad..ebab200 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/PortConstraintSet.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/PortConstraintSet.kt @@ -7,8 +7,12 @@ import tech.annexflow.constraintlayout.core.parser.CLParser import tech.annexflow.constraintlayout.core.parser.CLParsingException import tech.annexflow.constraintlayout.core.state.ConstraintSetParser import tech.annexflow.constraintlayout.core.state.CorePixelDp +import tech.annexflow.constraintlayout.core.state.Dimension import tech.annexflow.constraintlayout.core.state.State +import tech.annexflow.constraintlayout.core.widgets.ConstraintWidget import tech.annexflow.constraintlayout.core.widgets.ConstraintWidgetContainer +import tech.annexflow.constraintlayout.core.widgets.Optimizer +import tech.annexflow.constraintlayout.core.widgets.analyzer.BasicMeasure /** * Drives the shaded port. `OracleConstraintSet` performs the same sequence against the vendored @@ -58,6 +62,112 @@ object PortConstraintSet : ConstraintSetSubject { ConstraintSetOutcome.Crashed(ConstraintSetOutcome.categorise(e)) } + /** + * Every widget here is a synthetic solver primitive with no real content to wrap around, so a + * `WRAP_CONTENT` axis measures to 0 — there is nothing to report beyond "no intrinsic size." + * That is enough: none of `AxisLivenessTest`'s `measure`-backed cases put a `WRAP_CONTENT` + * widget on the axis under test, they use `MATCH_CONSTRAINT`, which this measurer passes + * straight through as `measure.horizontalDimension`/`verticalDimension` untouched. + * + * Stateless like `solver.OracleSolver`'s `SpecMeasurer`, for the same reason: `solverMeasure` + * calls back repeatedly across re-measure passes, and a measurer that remembered anything would + * make the outcome depend on call order. + */ + private class ZeroMeasurer : BasicMeasure.Measurer { + override fun measure(widget: ConstraintWidget, measure: BasicMeasure.Measure) { + measure.measuredWidth = + if (measure.horizontalBehavior == ConstraintWidget.DimensionBehaviour.WRAP_CONTENT) { + 0 + } else { + measure.horizontalDimension + } + measure.measuredHeight = + if (measure.verticalBehavior == ConstraintWidget.DimensionBehaviour.WRAP_CONTENT) { + 0 + } else { + measure.verticalDimension + } + measure.measuredHasBaseline = false + measure.measuredNeedsSolverPass = false + } + + override fun didMeasures() = Unit + } + + /** + * [parse] deliberately untouched by this addition — see [ConstraintSetSubject.measure]'s kdoc. + * This is a fully independent copy of [parse]'s setup rather than a shared helper, so a future + * edit to one can never accidentally reach into the other. + * + * The two extra lines [parse] doesn't have — `state.setWidth`/`setHeight` — are the actual fix, + * found by bisecting against a raw `ConstraintWidget` reproduction outside `State` entirely + * (see the task report): `State`'s root dimension defaults to `WRAP_CONTENT` (`Dimension`'s own + * default, per `Dimension.kt`) whenever nothing sets it, which [parse] never does either — every + * `ConstraintSetSpec` here has a `rootWidth`/`rootHeight`, but neither entry point ever tells + * `State` about it, only the real `ConstraintWidgetContainer`. That default is harmless for a + * widget anchored on one side only (`begin = target + margin`, independent of the parent's own + * resolution), which is why `parse`-backed cases were never affected — but + * `ConstraintWidget.applyConstraints`'s bias-centering equation, and the chain/`MATCH_CONSTRAINT` + * machinery built on the same "parent bounds are known" assumption, both special-case + * `parentWrapContent`, and never receive a real bias/spread computation once it's true. Marking + * the root `Dimension.createFixed(...)` here is what actually unlocks + * `hBias`/`vBias`/`hRtlBias`/`centerVertically`/`chainStyle`/`hWeight`/`vWeight` — confirmed by + * reproducing the bug with a raw `ConstraintWidget` + plain `.connect()` (bias worked with no + * `State` involved at all), then narrowing it to exactly this by adding `State` back piece by + * piece. `measure(...)` itself (this entry point's other difference from [parse]) turned out not + * to be the fix; it's kept because a real `Measurer` is still the correct, honest way to drive + * `MATCH_CONSTRAINT` sizing, and because it's this module's parallel to + * `solver.OracleSolver`/`PortSolver`'s existing `layout`/`measure` pair. + */ + override fun measure(spec: ConstraintSetSpec): ConstraintSetOutcome = + try { + val json = emit(spec) + val state = State() + state.setDpToPixel(CorePixelDp { dp -> dp }) + state.isRtl = spec.isRtl + state.setWidth(Dimension.createFixed(spec.rootWidth)) + state.setHeight(Dimension.createFixed(spec.rootHeight)) + val variables = ConstraintSetParser.LayoutVariables() + ConstraintSetParser.populateState(CLParser.parse(json), state, variables) + val root = ConstraintWidgetContainer(0, 0, spec.rootWidth, spec.rootHeight) + root.debugName = "root" + state.apply(root) + root.measurer = ZeroMeasurer() + root.measure( + Optimizer.OPTIMIZATION_STANDARD, + BasicMeasure.EXACTLY, + spec.rootWidth, + BasicMeasure.EXACTLY, + spec.rootHeight, + 0, + 0, + 0, + 0, + ) + val geometry = mutableListOf() + val custom = mutableListOf() + for (child in root.children) { + val id = child.stringId ?: "?" + val frame = child.frame + geometry += GeometryRow( + id, child.left, child.top, child.width, child.height, + frame.visibility, frame.alpha, + frame.rotationX, frame.rotationY, frame.rotationZ, + frame.scaleX, frame.scaleY, + frame.translationX, frame.translationY, frame.translationZ, + frame.pivotX, frame.pivotY, + ) + for (attrName in frame.getCustomAttributeNames()) { + custom += CustomRow(id, attrName, frame.getCustomAttribute(attrName)?.toString() ?: "null") + } + } + ConstraintSetOutcome.Populated(renderGeometry(geometry), renderCustom(custom)) + } catch (e: CLParsingException) { + ConstraintSetOutcome.Leaked("CLParsing") + } catch (e: Throwable) { + ConstraintSetOutcome.Crashed(ConstraintSetOutcome.categorise(e)) + } + override fun designElements(spec: DesignElementsSpec): ConstraintSetOutcome = try { val list = ArrayList() diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/Scenarios.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/Scenarios.kt index 98bf565..45e1578 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/Scenarios.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/Scenarios.kt @@ -35,8 +35,30 @@ object Scenarios { fun generate(seed: Long): ConstraintSetSpec { val random = Random(seed) + + // Drawn before the widgets, not after, so a widget can reference one of these by name (see + // `alphaValue`) — the only way `VariableSpec.Num`/`VariableSpec.Generator` ever reach the + // parser meaningfully, per `AxisLivenessTest`'s `variableNum`/`variableGenerator`. Moving + // this draw earlier in the seed's random sequence reshuffles every widget/guideline/barrier + // draw that follows it for every seed — an accepted, one-time shift in exactly which + // documents each seed produces, not a change to the generator's overall shape. + val variables = if (random.nextInt(3) == 0) { + (0 until random.nextInt(1, 4)).map { index -> variable(random, index) } + } else { + emptyList() + } + // Only `Num` and `Generator` resolve to a float through `LayoutVariables.get` — an `IdList` + // variable would still parse as a reference, but silently resolve to 0f regardless of its + // declared ids, which would look live without exercising anything. + val referenceableVariables = variables.mapNotNull { + when (it) { + is VariableSpec.Num, is VariableSpec.Generator -> it.name + is VariableSpec.IdList -> null + } + } + val count = random.nextInt(MIN_WIDGETS, MAX_WIDGETS + 1) - val widgets = (0 until count).map { index -> widget(random, index) } + val widgets = (0 until count).map { index -> widget(random, index, referenceableVariables) } val guidelines = (0 until random.nextInt(0, 3)).map { index -> GuidelineSpec( @@ -84,12 +106,6 @@ object Scenarios { emptyList() } - val variables = if (random.nextInt(3) == 0) { - (0 until random.nextInt(1, 4)).map { index -> variable(random, index) } - } else { - emptyList() - } - // `parseGenerate` takes ids from the named `IdList` variable, not from the body's own `id` // — a `Generate` block only does anything when one exists. val idLists = variables.filterIsInstance() @@ -125,7 +141,7 @@ object Scenarios { return DesignElementsSpec(seed, elements) } - private fun widget(random: Random, index: Int): WidgetSpec { + private fun widget(random: Random, index: Int, referenceableVariables: List): WidgetSpec { // Mutually exclusive with ordinary anchors — see the class kdoc. val hasCircular = index > 0 && random.nextInt(6) == 0 val anchors = if (hasCircular) { @@ -167,7 +183,7 @@ object Scenarios { hWeight = if (hasWeight) 0.1f + random.nextFloat() * 3f else null, vWeight = if (hasWeight) 0.1f + random.nextFloat() * 3f else null, visibility = if (hasVisibility) Visibility.entries[random.nextInt(Visibility.entries.size)] else null, - alpha = if (hasAlpha) random.nextFloat() else null, + alpha = if (hasAlpha) alphaValue(random, referenceableVariables) else null, rotationX = if (hasRotation) random.nextFloat() * 360f else null, rotationY = if (hasRotation) random.nextFloat() * 360f else null, rotationZ = if (hasRotation) random.nextFloat() * 360f else null, @@ -247,6 +263,21 @@ object Scenarios { } } + /** + * Almost always a literal. Rarely (1 in 20), and only when the document declared at least one + * `Num`/`Generator` variable (see `generate`'s `referenceableVariables`), references one of + * those by name instead — the only way either variable kind ever reaches the parser + * meaningfully; see [FloatValue]'s kdoc. Kept rare for the same reason `customColor`'s `'#zz'` + * and `numValue`'s fractional draw are: so it stays a small, deliberate minority of documents + * rather than moving the differential test's aggregate corpus stats. + */ + private fun alphaValue(random: Random, referenceableVariables: List): FloatValue = + if (referenceableVariables.isNotEmpty() && random.nextInt(20) == 0) { + FloatValue.Named(referenceableVariables[random.nextInt(referenceableVariables.size)]) + } else { + FloatValue.Literal(random.nextFloat()) + } + private fun dimension(random: Random): DimensionSpec = when (random.nextInt(5)) { 0 -> DimensionSpec.Fixed(random.nextInt(20, 301)) 1 -> DimensionSpec.Mode(DimensionMode.entries[random.nextInt(DimensionMode.entries.size)]) From fe4612f3e2979a40445de7a437dfdd838b386981 Mon Sep 17 00:00:00 2001 From: Samuel Gagarin <66745577+Lavmee@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:09:47 +0300 Subject: [PATCH 11/13] test: exercise measure, not just parse, in the differential corpus AxisLivenessTest certified hBias/vBias/hRtlBias/centerVertically/chainStyle/ hWeight/vWeight live through ConstraintSetSubject.measure, but the 2000-seed differential test - the only thing that actually compares oracle against port at scale - only ever called parse. A liveness certificate for a path nothing checks for correctness said nothing about whether the port agrees with the oracle on bias or chain resolution, which is exactly the region Fix 1 just finished unlocking. Rewrote ConstraintSetDifferentialTest following solver.SolverDifferentialTest's Entry-enum structure: both parse and measure run for every seed, each compared only against its own counterpart (never parse against measure, a different contract), with per-entry populated/geometry-row floors and per-entry-keyed divergence examples so one entry's failures can't crowd the other's out of the report. parse is untouched - this is the only file this fix needed to change. Measured both entries before setting floors, per seed 1..2000: populated=1876, geometryRows=10576, divergences=0 for BOTH parse and measure. The identical counts aren't a mistake - row count depends only on how many widgets/guidelines/barriers a document materialises, which parse and measure agree on; they disagree (or, per this run, don't) on the numbers inside each row. Zero divergences on measure is the first corpus-scale confirmation that the port's bias, chain-style and chain-weight resolution actually agrees with the oracle - previously only spot-checked one hand-built document at a time. Full investigation in the task report. --- .../ConstraintSetDifferentialTest.kt | 126 ++++++++++++------ 1 file changed, 88 insertions(+), 38 deletions(-) diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetDifferentialTest.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetDifferentialTest.kt index 98262f6..61272f5 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetDifferentialTest.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetDifferentialTest.kt @@ -7,46 +7,81 @@ import kotlin.test.Test import kotlin.test.fail /** - * Parses a range of generated documents with both implementations and requires identical outcomes. + * Parses/measures a range of generated documents with both implementations and requires identical + * outcomes. * * The oracle is upstream, so a disagreement is a defect in the port until proven otherwise. When * this fails, read the report and fix the port — do not relax the harness to accommodate it. + * + * Both [ConstraintSetSubject.parse] and [ConstraintSetSubject.measure] entry points are exercised, + * following `solver.SolverDifferentialTest`'s structure — and each is compared only against its own + * counterpart on the other side, never `parse` against `measure`. The two are different contracts + * (`measure` resolves a fixed root via a real `Measurer`; `parse` does not — see + * `ConstraintSetSubject.measure`'s kdoc) and a cross comparison would fail for reasons that say + * nothing about the port. Before this addition, `measure` — the only entry point that resolves bias + * or a chain's style/weight (see `AxisLivenessTest`'s `hBias`/`chainStyle`/`hWeight` cases) — was + * never run against the 2000-seed corpus at all: a liveness suite certifying those axes live through + * `measure` said nothing about whether the port agrees with the oracle on them, because this test, + * the one thing that actually compares the two implementations at scale, never called it. */ class ConstraintSetDifferentialTest { private val seeds = 1L..2000L - private val minimumPopulated = 1800 + private val maxExamplesPerEntry = 5 + + /** + * Both floors are measured against the current corpus (2000 seeds, unmutated port), not guessed + * — see the task report for the instrumented run each number came from. `parse` and `measure` + * land on the *same* populated count and geometry-row total: row count only depends on how many + * widgets/guidelines/barriers a document materialises, which is identical either way — the two + * entry points disagree (or, per this test, don't) on the *numbers inside* each row (a + * `MATCH_CONSTRAINT` widget spread to width 0 under `parse` but to a real span under `measure`; + * see `ConstraintSetSubject.measure`'s kdoc), not on how many rows exist. That distinction is + * exactly why a separate divergence check per entry point matters even though the floors turned + * out identical: two entry points can agree on shape while disagreeing on content, and it's the + * content this test exists to compare. + */ + private enum class Entry(val label: String, val minimumPopulated: Int, val minimumGeometryRows: Int) { + // Measured: 1876 populated, 10576 geometry rows. 5000 stays under half of that, per the + // rationale `minimumGeometryRows` originally carried (headroom for ordinary generator + // changes, unreachable by a corpus of substance-free documents). + PARSE("parse", 1800, 5000), - // Measured against the current corpus (2000 seeds, unmutated port): oracle geometry totalled - // 10576 rows across 1876 populated documents, averaging ~5.6 widgets per populated document. - // (Re-measured after `Scenarios.generate` moved its `variables` draw ahead of the widgets, so - // an `alpha` can reference one by name — see AxisLivenessTest's `variableNum`/ - // `variableGenerator`. That reshuffles every seed's later draws; the previous measurement here - // was 10647 rows across 1889 documents, essentially unchanged — a ~0.7% shift in both counts.) - // 5000 is under half of that measured total — comfortable headroom for ordinary changes to - // the generator's widget-count range — while remaining a total no corpus of substance-free - // documents (each contributing zero rows) could ever reach. See below for the failure mode - // this guards against. - private val minimumGeometryRows = 5000 - private val maxExamples = 5 + // Measured: 1876 populated, 10576 geometry rows — identical to PARSE (see the class kdoc for + // why). Same floors as PARSE follow directly from that. + MEASURE("measure", 1800, 5000), + } + + private fun run(entry: Entry, subject: ConstraintSetSubject, spec: ConstraintSetSpec): ConstraintSetOutcome = + when (entry) { + Entry.PARSE -> subject.parse(spec) + Entry.MEASURE -> subject.measure(spec) + } @Test fun thePortAgreesWithTheOracle() { - val examples = mutableListOf() - var divergences = 0 - var populated = 0 - var geometryRows = 0 + // Keyed per entry point rather than one shared list: PARSE runs before MEASURE for every + // seed, so a global cap would let early parse divergences crowd out every measure + // divergence from the reported examples while the counts stayed correct and the diagnostic + // went blind. + val divergences = mutableMapOf(Entry.PARSE to mutableListOf(), Entry.MEASURE to mutableListOf()) + var totalDivergences = 0 + val populated = mutableMapOf(Entry.PARSE to 0, Entry.MEASURE to 0) + val geometryRows = mutableMapOf(Entry.PARSE to 0, Entry.MEASURE to 0) for (seed in seeds) { val spec = Scenarios.generate(seed) - val oracle = OracleConstraintSet.parse(spec) - val port = PortConstraintSet.parse(spec) - if (oracle is ConstraintSetOutcome.Populated) { - populated++ - geometryRows += oracle.geometry.count { it == '\n' } - } - if (oracle != port) { - divergences++ - if (examples.size < maxExamples) examples += report(spec, oracle, port) + for (entry in Entry.entries) { + val oracle = run(entry, OracleConstraintSet, spec) + val port = run(entry, PortConstraintSet, spec) + if (oracle is ConstraintSetOutcome.Populated) { + populated[entry] = populated.getValue(entry) + 1 + geometryRows[entry] = geometryRows.getValue(entry) + oracle.geometry.count { it == '\n' } + } + if (oracle != port) { + totalDivergences++ + val examples = divergences.getValue(entry) + if (examples.size < maxExamplesPerEntry) examples += report(entry, spec, oracle, port) + } } } @@ -55,29 +90,44 @@ class ConstraintSetDifferentialTest { // to compare; and wholesale vacuity, where both sides return `Populated` for every // document but the documents carry no widgets — e.g. the emitter regressing to `{}` for // every spec. Both sides would then agree trivially on empty geometry for all 2000 seeds, - // `populated` would clear the floor below, and the test would pass having compared + // the populated count would clear the floor below, and the test would pass having compared // nothing at all. `minimumPopulated` catches the first; `minimumGeometryRows`, which a - // corpus of empty documents cannot satisfy, catches the second. - if (populated < minimumPopulated) { - fail("only $populated of ${seeds.count()} documents laid out; the generator is emitting junk") + // corpus of empty documents cannot satisfy, catches the second — checked per entry point, + // because one of them failing to run at all (e.g. `measure` throwing on every document) is + // exactly the failure worth catching, and a shared total could hide it behind the other + // entry's healthy numbers. + for (entry in Entry.entries) { + if (populated.getValue(entry) < entry.minimumPopulated) { + fail( + "only ${populated.getValue(entry)} of ${seeds.count()} documents populated through " + + "${entry.label}; the generator is emitting junk", + ) + } + if (geometryRows.getValue(entry) < entry.minimumGeometryRows) { + fail( + "only ${geometryRows.getValue(entry)} geometry rows across ${populated.getValue(entry)} " + + "populated documents through ${entry.label}; the generator is emitting substance-free " + + "documents", + ) + } } - if (geometryRows < minimumGeometryRows) { + + if (totalDivergences > 0) { + val examples = Entry.entries.flatMap { divergences.getValue(it) } fail( - "only $geometryRows geometry rows across $populated populated documents; " + - "the generator is emitting substance-free documents", + "$totalDivergences of ${seeds.count() * Entry.entries.size} comparisons diverged " + + "(showing up to $maxExamplesPerEntry per entry point):\n\n${examples.joinToString("\n\n")}", ) } - if (divergences > 0) { - fail("$divergences of ${seeds.count()} documents diverged\n\n${examples.joinToString("\n\n")}") - } } private fun report( + entry: Entry, spec: ConstraintSetSpec, oracle: ConstraintSetOutcome, port: ConstraintSetOutcome, ): String = buildString { - appendLine("seed ${spec.seed}") + appendLine("seed ${spec.seed} via ${entry.label}") appendLine(emit(spec)) appendLine("oracle: $oracle") appendLine("port: $port") From a412848eab25d2f3c1988f219be516a900dbd80a Mon Sep 17 00:00:00 2001 From: Samuel Gagarin <66745577+Lavmee@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:22:54 +0300 Subject: [PATCH 12/13] test: compare design-element parsing against the oracle parseDesignElementsJSON is broken identically on both sides (two indexing bugs present verbatim in the vendored upstream Java and reproduced line-for-line in the port), so the differential equality check here is a real but weak signal: it proves the port matches upstream, not that the entry point does anything useful. Floors are calibrated against an instrumented 500-seed run and cover both failure directions (generator decaying toward "nothing produces" or "nothing throws"), documented in the class kdoc. --- .../DesignElementsDifferentialTest.kt | 106 ++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 parity/src/test/kotlin/tech/annexflow/parity/constraintset/DesignElementsDifferentialTest.kt diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/DesignElementsDifferentialTest.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/DesignElementsDifferentialTest.kt new file mode 100644 index 0000000..006d716 --- /dev/null +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/DesignElementsDifferentialTest.kt @@ -0,0 +1,106 @@ +// Copyright 2023, Sergei Gagarin and the project contributors +// SPDX-License-Identifier: Apache-2.0 + +package tech.annexflow.parity.constraintset + +import kotlin.test.Test +import kotlin.test.fail + +/** + * `parseDesignElementsJSON` is `ConstraintSetParser`'s other public entry point: it doesn't lay out + * a container, it walks a `Design` block and returns a flat list of `DesignElement`s. It is + * exercised here the same way [ConstraintSetDifferentialTest] exercises `parse`/`measure` — generate + * a document, run it through both implementations, and fail on any disagreement. + * + * Read this kdoc before touching the floors below. `parseDesignElementsJSON` carries two indexing + * bugs, present verbatim in the vendored upstream Java (`ConstraintSetParser.java`) and reproduced + * line-for-line in the port (`ConstraintSetParser.kt`) — same function, same mistakes. This is not a + * port defect to fix: a "correction" here would diverge from upstream, which is the one thing this + * harness exists to catch. What follows was confirmed by instrumented runs over the 500-seed corpus + * this test uses, not guessed: + * + * - the constructed `DesignElement`'s id is always `elementName`, the *outer* loop variable, which + * for every document this generator emits is the literal string `"Design"` — the document's only + * top-level key — never the per-element key (`d0`, `d1`, ...) the inner loop actually walks. + * Across all 531 rows produced over seeds 1..500, the id is `"Design"` on every single one; the + * ids `Scenarios.generateDesignElements` assigns never survive into the output at all. + * + * - the inner parameter loop reads `designElement[j]`, where `j` is the *outer* per-element index + * (0 for the first design element in the `Design` block, 1 for the second, ...), not the inner + * loop counter `k` it iterates with. Three consequences, all confirmed by instrumentation: + * - every surviving element yields *at most one* parameter, never however many it declared: the + * `while (k < size)` loop re-reads the same fixed index `j` on every pass, so the map + * converges to one entry regardless of `size`. + * - that one entry is not fabricated garbage, but it is not that element's own parameter + * either. For the first element in a block (`j == 0`) it is always `"type" -> ` — position 0 in every element's own key list is `type`, per the + * emitter's field order (330 of 531 rows). For a later element (`j >= 1`) position `j` lands + * on a real `paramN`/`valueN` pair *only* because the emitter always writes `type` first, + * so it looks right by coincidence of field order, not because the indexing is (201 of 531 + * rows). Either way it is never "this element's declared parameters." + * - when position `j` does not exist in that element's own object — a later element whose own + * key count does not reach its outer index — `CLObject.get(Int)` throws `CLParsingException` + * (`"no element at index $j"`), which unwinds the *whole* `parseDesignElementsJSON` call, not + * just that element. A document with two or more design elements throws whenever some + * non-first element's own key count falls short of its position. Measured: 170 of 500 + * documents throw this way — all `CLParsingException` (`Leaked`), on both `oracle` and `port`, + * with zero mismatched outcome types anywhere in the corpus. + * + * Net effect: the equality check below (`oracle != port`) is genuinely comparing two implementations + * that both do the broken thing, so a pass here is a real, honest statement that the port matches + * upstream — it is not a statement that `parseDesignElementsJSON` does anything useful. The two + * floors exist only to keep the generator honest (catch it decaying toward "nothing ever produces" + * or "nothing ever throws"), the same role [ConstraintSetDifferentialTest]'s `minimumPopulated`/ + * `minimumGeometryRows` floors play for `parse`/`measure` — they say nothing about whether the + * *content* is meaningful, because for this entry point it structurally can't be. + */ +class DesignElementsDifferentialTest { + private val seeds = 1L..500L + + /** + * Measured on the current corpus: 330 of 500 documents produce a non-empty `Elements` outcome, + * 170 throw during the per-element loop (see the class kdoc). `seeds.count() / 2` — the brief's + * original floor — is 250, comfortably below the measured 330, so it stays: a real, satisfiable + * floor that only fails if the generator regresses toward "almost everything throws," not a + * number chosen to always pass regardless of what the generator does. + */ + private val minimumProduced = seeds.count() / 2 + + /** + * The brief's floor only watched one failure direction. The other is just as real here as + * `minimumGeometryRows` is for `parse`/`measure`: the `CLParsingException` path (170/500, 34%, + * see the class kdoc) is over a third of this corpus's behaviour and the only place this entry + * point's exception handling gets exercised at all. Nothing forces that number toward "half" — a + * document only throws when it declares two or more design elements *and* some non-first + * element's own key count falls short of its position — so without a floor here, a change to + * `Scenarios.generateDesignElements` (e.g. capping every document at one element) could silently + * delete this entire code path from the corpus and nothing here would notice. Set well below the + * measured 170 for the same headroom [minimumProduced] uses. + */ + private val minimumLeaked = 80 + + @Test + fun thePortAgreesWithTheOracle() { + val examples = mutableListOf() + var produced = 0 + var leaked = 0 + for (seed in seeds) { + val spec = Scenarios.generateDesignElements(seed) + val oracle = OracleConstraintSet.designElements(spec) + val port = PortConstraintSet.designElements(spec) + if (oracle is ConstraintSetOutcome.Elements && oracle.rendered.isNotEmpty()) produced++ + if (oracle is ConstraintSetOutcome.Leaked) leaked++ + if (oracle != port && examples.size < 5) { + examples += "seed $seed\n${emitDesignElements(spec)}\noracle: $oracle\nport: $port" + } + } + if (produced < minimumProduced) fail("only $produced of ${seeds.count()} documents produced elements") + if (leaked < minimumLeaked) { + fail( + "only $leaked of ${seeds.count()} documents leaked through the oracle; the generator may have " + + "stopped exercising parseDesignElementsJSON's CLParsingException path (see class kdoc)", + ) + } + if (examples.isNotEmpty()) fail(examples.joinToString("\n\n")) + } +} From e0eac427f725dab6c9bcbda7b9c557ce005b59dd Mon Sep 17 00:00:00 2001 From: Samuel Gagarin <66745577+Lavmee@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:41:44 +0300 Subject: [PATCH 13/13] test: apply the differential harness's final review fixes Closes out the review's cleanup wave on the constraintset differential harness: strengthens the liveness check to also require the mutated document lay out (so a leak/crash can't masquerade as proof an axis is live), corrects four kdocs that stated something false about the parser or the type they described, notes a real coverage gap in the generator's anchor targeting, pins JsonEmitter's Bounded dimension shape with a test, extracts the byte-identical observation loop duplicated across both subjects, removes the one unforced asymmetry between the two subject files, and puts the previously-dead ConstraintSetSubject.name to use in an assertion message. No production code changes; parity module only. --- .../parity/constraintset/AxisLivenessTest.kt | 1 + .../constraintset/ConstraintSetOutcome.kt | 5 +- .../parity/constraintset/ConstraintSetSpec.kt | 6 +- .../constraintset/ConstraintSetSubject.kt | 7 +- .../DesignElementsDifferentialTest.kt | 17 ++++- .../parity/constraintset/JsonEmitterTest.kt | 21 ++++++ .../constraintset/OracleConstraintSet.kt | 69 +++++++++---------- .../parity/constraintset/PortConstraintSet.kt | 67 +++++++++--------- .../parity/constraintset/Scenarios.kt | 18 ++++- .../parity/constraintset/SubjectTest.kt | 6 +- 10 files changed, 133 insertions(+), 84 deletions(-) diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/AxisLivenessTest.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/AxisLivenessTest.kt index 4989408..8bc36bd 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/AxisLivenessTest.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/AxisLivenessTest.kt @@ -49,6 +49,7 @@ class AxisLivenessTest { val a = outcome(before) val b = outcome(after) check(a is ConstraintSetOutcome.Populated) { "$name: the baseline document does not lay out: $a" } + check(b is ConstraintSetOutcome.Populated) { "$name: the mutated document does not lay out: $b" } assertNotEquals(a, b, "$name is generated but changes nothing the harness observes") } diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetOutcome.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetOutcome.kt index f1548f5..d462fd0 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetOutcome.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetOutcome.kt @@ -60,7 +60,10 @@ fun renderElements(rows: List): String = /** * Everything observable about parsing one document, normalised so the two implementations become - * comparable despite living in different packages. Deliberately the same shape as `LayoutOutcome`. + * comparable despite living in different packages. The failure side is deliberately the same shape + * as `LayoutOutcome` ([Leaked], [Crashed]); the success side is not — this type splits into + * [Populated] and [Elements] for its two entry points, where `LayoutOutcome` has only one + * (`LaidOut`). */ sealed interface ConstraintSetOutcome { /** The layout entry point: a document parsed, applied to a container and laid out. */ diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSpec.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSpec.kt index c1600d6..16533ca 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSpec.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSpec.kt @@ -143,7 +143,11 @@ data class ChainSpec( ) sealed interface GuidelinePosition { - /** `start` for a vertical guideline, `top` for a horizontal one. */ + /** + * Rendered as `start` regardless of orientation — `parseGuidelineParams` only recognises + * `left`/`right`/`start`/`end`/`percent`; there is no `top` (or `bottom`) key for a horizontal + * guideline, so `start` is the correct key for both orientations, not just the vertical one. + */ data class FromStart(val dp: Int) : GuidelinePosition data class FromEnd(val dp: Int) : GuidelinePosition diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSubject.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSubject.kt index a363c75..fa05aa2 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSubject.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/ConstraintSetSubject.kt @@ -29,9 +29,10 @@ interface ConstraintSetSubject { * bias/spread split once it applies — see `OracleConstraintSet.measure`'s kdoc for how this was * isolated (a raw `ConstraintWidget` reproduction outside `State` entirely). * - * [parse] is deliberately left untouched by this addition: `ConstraintSetDifferentialTest`'s - * corpus and its measured geometry-row floor are built on [parse], and must not shift as a - * side effect of adding this second entry point. + * [parse] is deliberately left untouched by this addition. `ConstraintSetDifferentialTest` + * exercises both entry points now, each against its own corpus-measured floors — see that + * class's kdoc — but [parse]'s existing floors, measured before [measure] existed, must not + * shift as a side effect of adding this second entry point. */ fun measure(spec: ConstraintSetSpec): ConstraintSetOutcome diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/DesignElementsDifferentialTest.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/DesignElementsDifferentialTest.kt index 006d716..ac3ef15 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/DesignElementsDifferentialTest.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/DesignElementsDifferentialTest.kt @@ -79,19 +79,25 @@ class DesignElementsDifferentialTest { */ private val minimumLeaked = 80 + private val maxExamplesPerEntry = 5 + @Test fun thePortAgreesWithTheOracle() { val examples = mutableListOf() var produced = 0 var leaked = 0 + var totalDivergences = 0 for (seed in seeds) { val spec = Scenarios.generateDesignElements(seed) val oracle = OracleConstraintSet.designElements(spec) val port = PortConstraintSet.designElements(spec) if (oracle is ConstraintSetOutcome.Elements && oracle.rendered.isNotEmpty()) produced++ if (oracle is ConstraintSetOutcome.Leaked) leaked++ - if (oracle != port && examples.size < 5) { - examples += "seed $seed\n${emitDesignElements(spec)}\noracle: $oracle\nport: $port" + if (oracle != port) { + totalDivergences++ + if (examples.size < maxExamplesPerEntry) { + examples += "seed $seed\n${emitDesignElements(spec)}\noracle: $oracle\nport: $port" + } } } if (produced < minimumProduced) fail("only $produced of ${seeds.count()} documents produced elements") @@ -101,6 +107,11 @@ class DesignElementsDifferentialTest { "stopped exercising parseDesignElementsJSON's CLParsingException path (see class kdoc)", ) } - if (examples.isNotEmpty()) fail(examples.joinToString("\n\n")) + if (totalDivergences > 0) { + fail( + "$totalDivergences of ${seeds.count()} diverged (showing up to $maxExamplesPerEntry):\n\n" + + examples.joinToString("\n\n"), + ) + } } } diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitterTest.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitterTest.kt index 230f534..fc1b44a 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitterTest.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/JsonEmitterTest.kt @@ -54,6 +54,27 @@ class JsonEmitterTest { } } + // `DimensionSpec.Bounded` isn't one of `emitsEveryDimensionForm`'s forms — it renders as its own + // `{value: …, min: …, max: …}` object rather than a bare scalar or quoted string, and it's + // roughly a fifth of every generated width/height draw (see `Scenarios.dimension`), so a wrong + // key name here would quietly degrade a large slice of the corpus to default dimensions with + // both sides agreeing and nothing catching it. + @Test + fun emitsBoundedDimensionShape() { + val json = emit( + spec( + widget("a").copy( + width = DimensionSpec.Bounded( + value = DimensionMode.SPREAD, + min = Bound.Pixels(10), + max = Bound.Wrap, + ), + ), + ), + ) + assertTrue(json.contains("width: {value: 'spread', min: 10, max: 'wrap'}"), json) + } + @Test fun anchorRendersAsAnArrayOfTargetAnchorMargin() { val w = widget("a").copy( diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/OracleConstraintSet.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/OracleConstraintSet.kt index 14dfdda..03a34e3 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/OracleConstraintSet.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/OracleConstraintSet.kt @@ -38,30 +38,42 @@ object OracleConstraintSet : ConstraintSetSubject { root.debugName = "root" state.apply(root) root.layout() - val geometry = mutableListOf() - val custom = mutableListOf() - for (child in root.children) { - val id = child.stringId ?: "?" - val frame = child.frame - geometry += GeometryRow( - id, child.left, child.top, child.width, child.height, - frame.visibility, frame.alpha, - frame.rotationX, frame.rotationY, frame.rotationZ, - frame.scaleX, frame.scaleY, - frame.translationX, frame.translationY, frame.translationZ, - frame.pivotX, frame.pivotY, - ) - for (attrName in frame.getCustomAttributeNames()) { - custom += CustomRow(id, attrName, frame.getCustomAttribute(attrName)?.toString() ?: "null") - } - } - ConstraintSetOutcome.Populated(renderGeometry(geometry), renderCustom(custom)) + observe(root) } catch (e: CLParsingException) { ConstraintSetOutcome.Leaked("CLParsing") } catch (e: Throwable) { ConstraintSetOutcome.Crashed(ConstraintSetOutcome.categorise(e)) } + /** + * Walks the laid-out container's children into a [ConstraintSetOutcome.Populated]. Shared by + * [parse] and [measure] on purpose, unlike the setup above each of them: the two setups differ + * by design (see [measure]'s kdoc), but this walk must read the exact same fields off the exact + * same shape for both entry points, or a difference here — not in the parser — could look like a + * divergence between the two implementations. `solver.OracleSolver`'s `render` is the same idea + * for `layout`/`measure` there. + */ + private fun observe(root: ConstraintWidgetContainer): ConstraintSetOutcome.Populated { + val geometry = mutableListOf() + val custom = mutableListOf() + for (child in root.children) { + val id = child.stringId ?: "?" + val frame = child.frame + geometry += GeometryRow( + id, child.left, child.top, child.width, child.height, + frame.visibility, frame.alpha, + frame.rotationX, frame.rotationY, frame.rotationZ, + frame.scaleX, frame.scaleY, + frame.translationX, frame.translationY, frame.translationZ, + frame.pivotX, frame.pivotY, + ) + for (attrName in frame.getCustomAttributeNames()) { + custom += CustomRow(id, attrName, frame.getCustomAttribute(attrName)?.toString() ?: "null") + } + } + return ConstraintSetOutcome.Populated(renderGeometry(geometry), renderCustom(custom)) + } + /** * Every widget here is a synthetic solver primitive with no real content to wrap around, so a * `WRAP_CONTENT` axis measures to 0 — there is nothing to report beyond "no intrinsic size." @@ -144,24 +156,7 @@ object OracleConstraintSet : ConstraintSetSubject { 0, 0, ) - val geometry = mutableListOf() - val custom = mutableListOf() - for (child in root.children) { - val id = child.stringId ?: "?" - val frame = child.frame - geometry += GeometryRow( - id, child.left, child.top, child.width, child.height, - frame.visibility, frame.alpha, - frame.rotationX, frame.rotationY, frame.rotationZ, - frame.scaleX, frame.scaleY, - frame.translationX, frame.translationY, frame.translationZ, - frame.pivotX, frame.pivotY, - ) - for (attrName in frame.getCustomAttributeNames()) { - custom += CustomRow(id, attrName, frame.getCustomAttribute(attrName)?.toString() ?: "null") - } - } - ConstraintSetOutcome.Populated(renderGeometry(geometry), renderCustom(custom)) + observe(root) } catch (e: CLParsingException) { ConstraintSetOutcome.Leaked("CLParsing") } catch (e: Throwable) { @@ -172,7 +167,7 @@ object OracleConstraintSet : ConstraintSetSubject { try { val list = ArrayList() ConstraintSetParser.parseDesignElementsJSON(emitDesignElements(spec), list) - val rows = list.map { ElementRow(it.getId(), it.getType(), it.getParams()) } + val rows = list.map { ElementRow(it.id, it.type, it.params) } ConstraintSetOutcome.Elements(renderElements(rows)) } catch (e: CLParsingException) { ConstraintSetOutcome.Leaked("CLParsing") diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/PortConstraintSet.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/PortConstraintSet.kt index ebab200..5526f12 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/PortConstraintSet.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/PortConstraintSet.kt @@ -38,30 +38,42 @@ object PortConstraintSet : ConstraintSetSubject { root.debugName = "root" state.apply(root) root.layout() - val geometry = mutableListOf() - val custom = mutableListOf() - for (child in root.children) { - val id = child.stringId ?: "?" - val frame = child.frame - geometry += GeometryRow( - id, child.left, child.top, child.width, child.height, - frame.visibility, frame.alpha, - frame.rotationX, frame.rotationY, frame.rotationZ, - frame.scaleX, frame.scaleY, - frame.translationX, frame.translationY, frame.translationZ, - frame.pivotX, frame.pivotY, - ) - for (attrName in frame.getCustomAttributeNames()) { - custom += CustomRow(id, attrName, frame.getCustomAttribute(attrName)?.toString() ?: "null") - } - } - ConstraintSetOutcome.Populated(renderGeometry(geometry), renderCustom(custom)) + observe(root) } catch (e: CLParsingException) { ConstraintSetOutcome.Leaked("CLParsing") } catch (e: Throwable) { ConstraintSetOutcome.Crashed(ConstraintSetOutcome.categorise(e)) } + /** + * Walks the laid-out container's children into a [ConstraintSetOutcome.Populated]. Shared by + * [parse] and [measure] on purpose, unlike the setup above each of them: the two setups differ + * by design (see [measure]'s kdoc), but this walk must read the exact same fields off the exact + * same shape for both entry points, or a difference here — not in the parser — could look like a + * divergence between the two implementations. `solver.OracleSolver`'s `render` is the same idea + * for `layout`/`measure` there. + */ + private fun observe(root: ConstraintWidgetContainer): ConstraintSetOutcome.Populated { + val geometry = mutableListOf() + val custom = mutableListOf() + for (child in root.children) { + val id = child.stringId ?: "?" + val frame = child.frame + geometry += GeometryRow( + id, child.left, child.top, child.width, child.height, + frame.visibility, frame.alpha, + frame.rotationX, frame.rotationY, frame.rotationZ, + frame.scaleX, frame.scaleY, + frame.translationX, frame.translationY, frame.translationZ, + frame.pivotX, frame.pivotY, + ) + for (attrName in frame.getCustomAttributeNames()) { + custom += CustomRow(id, attrName, frame.getCustomAttribute(attrName)?.toString() ?: "null") + } + } + return ConstraintSetOutcome.Populated(renderGeometry(geometry), renderCustom(custom)) + } + /** * Every widget here is a synthetic solver primitive with no real content to wrap around, so a * `WRAP_CONTENT` axis measures to 0 — there is nothing to report beyond "no intrinsic size." @@ -144,24 +156,7 @@ object PortConstraintSet : ConstraintSetSubject { 0, 0, ) - val geometry = mutableListOf() - val custom = mutableListOf() - for (child in root.children) { - val id = child.stringId ?: "?" - val frame = child.frame - geometry += GeometryRow( - id, child.left, child.top, child.width, child.height, - frame.visibility, frame.alpha, - frame.rotationX, frame.rotationY, frame.rotationZ, - frame.scaleX, frame.scaleY, - frame.translationX, frame.translationY, frame.translationZ, - frame.pivotX, frame.pivotY, - ) - for (attrName in frame.getCustomAttributeNames()) { - custom += CustomRow(id, attrName, frame.getCustomAttribute(attrName)?.toString() ?: "null") - } - } - ConstraintSetOutcome.Populated(renderGeometry(geometry), renderCustom(custom)) + observe(root) } catch (e: CLParsingException) { ConstraintSetOutcome.Leaked("CLParsing") } catch (e: Throwable) { diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/Scenarios.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/Scenarios.kt index 45e1578..8481063 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/Scenarios.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/Scenarios.kt @@ -20,6 +20,12 @@ import kotlin.random.Random * refuse the combination, but circular positioning and edge-to-edge constraints answer the same * question two different ways, and there is nothing this harness wants to learn from watching them * fight. + * + * Because widgets are generated first, [anchorTarget] can only point an anchor at `parent` or a + * lower-indexed widget — never at a guideline, a barrier, or a chain. Guidelines and barriers do + * appear in the corpus and their own positions are compared, but no generated document ever + * anchors a widget TO one, so the interaction that anchoring-to-a-helper exists for is not + * exercised by this generator at all; "zero divergences over N documents" says nothing about it. */ object Scenarios { private const val MIN_WIDGETS = 2 @@ -238,8 +244,16 @@ object Scenarios { /** * `from`'s "to" is drawn from the same category as `from` itself: `parseConstraint`'s `when` * on the constraint name only recognises the matching anchors as a value (e.g. `"top"` only - * branches on `"top"`/`"bottom"`/`"baseline"`) — a cross-category pairing falls through and - * applies nothing, which would silently under-constrain the widget. + * branches on `"top"`/`"bottom"`/`"baseline"`). A cross-category pairing doesn't fail the same + * way on both sides. A vertical constraint name (`top`/`bottom`/`baseline`) paired with a + * horizontal anchor falls through the inner `when` and applies nothing, silently + * under-constraining the widget. A horizontal constraint name (`start`/`end`/`left`/`right`) + * paired with a vertical anchor is worse: `isHorizontalConstraint` is still set, the + * "resolve horizontal target anchor" `when` also falls through, and `isHorTargetLeft` is left + * at its `true` initialiser (see `ConstraintSetParser.kt`'s `parseConstraint`), so a + * `leftToLeft`/`rightToLeft` constraint is applied anyway — a wrongly-resolved constraint, not + * a no-op. Restricting every generated anchor to same-category pairs means the corpus + * deliberately never exercises that upstream mis-resolution path. */ private fun anchor(random: Random, index: Int, from: Anchor): AnchorSpec { val category = if (from in HORIZONTAL_ANCHORS) HORIZONTAL_ANCHORS else VERTICAL_ANCHORS diff --git a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/SubjectTest.kt b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/SubjectTest.kt index 9d5f4c7..4c2dd3f 100644 --- a/parity/src/test/kotlin/tech/annexflow/parity/constraintset/SubjectTest.kt +++ b/parity/src/test/kotlin/tech/annexflow/parity/constraintset/SubjectTest.kt @@ -13,7 +13,11 @@ class SubjectTest { @Test fun bothSubjectsLayOutTheSameDocumentIdentically() { val outcomes = subjects.map { it.parse(baseSpec()) } - assertEquals(outcomes[0], outcomes[1]) + assertEquals( + outcomes[0], + outcomes[1], + "${subjects[0].name} and ${subjects[1].name} disagree on the same document", + ) assertTrue(outcomes[0] is ConstraintSetOutcome.Populated, "got ${outcomes[0]}") }