Skip to content

Commit 52fb28f

Browse files
rorygravesRory Graves
andauthored
Layout prop tests (#213)
Co-authored-by: Rory Graves <rory.graves@thetradedesk.com>
1 parent b852a90 commit 52fb28f

3 files changed

Lines changed: 239 additions & 1 deletion

File tree

build.sbt

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,9 @@ lazy val termflowScreen = (project in file("modules/termflow-screen"))
168168
commonSettings,
169169
frameworkScalafixSettings,
170170
libraryDependencies ++= Seq(
171-
Deps.scalatest % Test
171+
Deps.scalatest % Test,
172+
Deps.scalacheck % Test,
173+
Deps.scalatestPlusScalacheck % Test
172174
)
173175
)
174176

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
package termflow.tui
2+
3+
import org.scalacheck.{ Gen, Shrink }
4+
import org.scalatest.funsuite.AnyFunSuite
5+
import org.scalatestplus.scalacheck.ScalaCheckPropertyChecks
6+
import termflow.tui.ScreenPrelude.*
7+
8+
/**
9+
* Property-based companion to [[LayoutSpec]] (issue #142).
10+
*
11+
* `LayoutSpec` pins down hand-picked cases; this suite hammers the resolver
12+
* across arbitrary trees built from the real [[Layout]] surface
13+
* (`Elem` / `Row` / `Column` / `Spacer` / `Fill` / `Zone` / `Grid` /
14+
* `Border`) to flush out edge cases — empty children, negative gaps,
15+
* extreme/zero budgets, deeply nested wrappers — that are easy to miss by
16+
* enumeration.
17+
*
18+
* The brief's invariant sketch named some constructors that don't exist in
19+
* the v1 DSL (`Pad` / `Clip` / `Scroll` / `Overlay`); the properties below
20+
* are the faithful equivalents over the constructors that do exist. A
21+
* custom [[Shrink]] reduces a failing tree toward a minimal subtree.
22+
*/
23+
class LayoutPropSpec extends AnyFunSuite with ScalaCheckPropertyChecks:
24+
25+
// Run a healthy number of cases per property — layout composition has a
26+
// wide combinatorial surface and the trees are cheap to resolve.
27+
implicit override val generatorDrivenConfig: PropertyCheckConfiguration =
28+
PropertyCheckConfiguration(minSuccessful = 300, maxDiscardedFactor = 5.0)
29+
30+
// --- leaf VNode generators ---------------------------------------------
31+
// Leaves are authored at (1,1) with no nested children: their own
32+
// coordinate is the minimum coordinate in the subtree, which keeps the
33+
// origin-containment property (P3) clean to reason about.
34+
35+
private val genText: Gen[VNode] =
36+
Gen.alphaStr.map(s => TextNode(1.x, 1.y, List(Text(s.take(8), Style()))))
37+
38+
private val genBox: Gen[VNode] =
39+
for
40+
w <- Gen.choose(-3, 16)
41+
h <- Gen.choose(-3, 8)
42+
yield BoxNode(1.x, 1.y, w, h, children = Nil)
43+
44+
private val genInput: Gen[VNode] =
45+
for
46+
p <- Gen.alphaStr.map(_.take(6))
47+
lw <- Gen.choose(0, 16)
48+
yield InputNode(1.x, 1.y, p, Style(), lineWidth = lw)
49+
50+
private val genVNode: Gen[VNode] = Gen.oneOf(genText, genBox, genInput)
51+
52+
// --- Layout tree generator (bounded depth) -----------------------------
53+
54+
private val genElem: Gen[Layout] = genVNode.map(Layout.Elem.apply)
55+
56+
private val genSpacer: Gen[Layout] =
57+
for
58+
w <- Gen.choose(-4, 12)
59+
h <- Gen.choose(-4, 8)
60+
yield Layout.Spacer(w, h)
61+
62+
private val genLeaf: Gen[Layout] = Gen.oneOf(genElem, genSpacer)
63+
64+
private def genGridCell(depth: Int): Gen[GridCell] =
65+
for
66+
content <- genLayout(depth - 1)
67+
cs <- Gen.choose(1, 3)
68+
rs <- Gen.choose(1, 2)
69+
yield GridCell(content, cs, rs)
70+
71+
/** Arbitrary layout tree with depth bounded by `depth`. */
72+
private def genLayout(depth: Int): Gen[Layout] =
73+
if depth <= 0 then genLeaf
74+
else
75+
Gen.frequency(
76+
4 -> genLeaf,
77+
3 -> genContainer(genLayout(depth - 1), Layout.Row.apply),
78+
3 -> genContainer(genLayout(depth - 1), Layout.Column.apply),
79+
2 -> genLayout(depth - 1).map(Layout.Fill.apply),
80+
1 -> (for
81+
id <- Gen.alphaStr.map(_.take(4))
82+
c <- genLayout(depth - 1)
83+
yield Layout.Zone(id, c)),
84+
2 -> (for
85+
cols <- Gen.choose(1, 4)
86+
rg <- Gen.choose(-1, 3)
87+
cg <- Gen.choose(-1, 3)
88+
n <- Gen.choose(0, 5)
89+
cells <- Gen.listOfN(n, genGridCell(depth))
90+
yield Layout.Grid(cols, rg, cg, cells)),
91+
2 -> genBorder(depth)
92+
)
93+
94+
private def genContainer(childGen: Gen[Layout], mk: (Int, List[Layout]) => Layout): Gen[Layout] =
95+
for
96+
gap <- Gen.choose(-3, 5)
97+
n <- Gen.choose(0, 4)
98+
cs <- Gen.listOfN(n, childGen)
99+
yield mk(gap, cs)
100+
101+
private def genBorder(depth: Int): Gen[Layout] =
102+
def zone: Gen[Option[Layout]] = Gen.option(genLayout(depth - 1))
103+
for
104+
t <- zone
105+
l <- zone
106+
c <- zone
107+
r <- zone
108+
b <- zone
109+
gap <- Gen.choose(-1, 4)
110+
yield Layout.Border(t, l, c, r, b, gap)
111+
112+
private val genTree: Gen[Layout] = genLayout(depth = 3)
113+
114+
/**
115+
* Flow-only tree: `Row` / `Column` / `Fill` / `Zone` over leaves, with no
116+
* `Grid` or `Border`. These primitives only ever advance their layout
117+
* cursors forward from the origin, so they guarantee origin-containment
118+
* (P3) — unlike `Grid` (no column compaction for spanning cells) and
119+
* `Border` (right/bottom-edge pinning), which can emit coordinates before
120+
* the origin under a starving budget. See DECISIONS.md.
121+
*/
122+
private def genFlow(depth: Int): Gen[Layout] =
123+
if depth <= 0 then genLeaf
124+
else
125+
Gen.frequency(
126+
4 -> genLeaf,
127+
3 -> genContainer(genFlow(depth - 1), Layout.Row.apply),
128+
3 -> genContainer(genFlow(depth - 1), Layout.Column.apply),
129+
2 -> genFlow(depth - 1).map(Layout.Fill.apply),
130+
1 -> (for
131+
id <- Gen.alphaStr.map(_.take(4))
132+
c <- genFlow(depth - 1)
133+
yield Layout.Zone(id, c))
134+
)
135+
136+
private val genFlowTree: Gen[Layout] = genFlow(depth = 4)
137+
138+
// --- shrinking ----------------------------------------------------------
139+
// Reduce a failing tree toward a minimal subtree: prefer replacing a
140+
// container with one of its children, then dropping a single child.
141+
142+
private def dropOne[A](xs: List[A]): LazyList[List[A]] =
143+
xs.indices.to(LazyList).map(i => xs.patch(i, Nil, 1))
144+
145+
given Shrink[Layout] = Shrink.withLazyList {
146+
case Layout.Row(g, cs) => cs.to(LazyList) #::: dropOne(cs).map(Layout.Row(g, _))
147+
case Layout.Column(g, cs) => cs.to(LazyList) #::: dropOne(cs).map(Layout.Column(g, _))
148+
case Layout.Fill(c) => LazyList(c)
149+
case Layout.Zone(_, c) => LazyList(c)
150+
case Layout.Grid(cols, rg, cg, cells) =>
151+
cells.to(LazyList).map(_.content) #::: dropOne(cells).map(Layout.Grid(cols, rg, cg, _))
152+
case Layout.Border(t, l, c, r, b, _) =>
153+
List(t, l, c, r, b).flatten.to(LazyList)
154+
case _ => LazyList.empty
155+
}
156+
157+
// --- properties ---------------------------------------------------------
158+
159+
test("P1: measure is total and non-negative for any generated tree"):
160+
forAll(genTree) { layout =>
161+
val (w, h) = layout.measure
162+
assert(w >= 0, s"width $w < 0 for $layout")
163+
assert(h >= 0, s"height $h < 0 for $layout")
164+
}
165+
166+
test("P2: resolve / resolveTo / resolveTracked never throw for any tree and budget"):
167+
forAll(genTree, Gen.choose(-2, 200), Gen.choose(-2, 200), Gen.choose(1, 50), Gen.choose(1, 50)) {
168+
(layout, availW, availH, ox, oy) =>
169+
val at = Coord(XCoord(ox), YCoord(oy))
170+
// Unbudgeted and budgeted resolves, plus the hit-test-tracked path.
171+
val a = layout.resolve(at)
172+
val b = Layout.resolveTo(layout, at, availW, availH)
173+
val (c, _) = Layout.resolveTracked[Any](layout, at, availW, availH)
174+
assert(a != null && b != null && c != null)
175+
}
176+
177+
test("P3: a flow layout (Row/Column/Fill/Zone) never places a node before the requested origin"):
178+
// Flow primitives only ever advance their cursors forward from the
179+
// origin (gaps clamp to >= 0, measured sizes are >= 0), so resolved
180+
// nodes are contained on the top/left edge under any budget. Grid and
181+
// Border are deliberately excluded: a spanning Grid cell can be starved
182+
// to near-zero width and Border pins its right/bottom zone to the far
183+
// edge, so either can emit a coordinate before the origin — documented
184+
// overflow / no-compaction territory, out of scope for v1 (DECISIONS.md).
185+
forAll(genFlowTree, Gen.choose(-2, 80), Gen.choose(-2, 80), Gen.choose(1, 50), Gen.choose(1, 50)) {
186+
(layout, availW, availH, ox, oy) =>
187+
val at = Coord(XCoord(ox), YCoord(oy))
188+
189+
def assertContained(nodes: List[VNode]): Unit = nodes.foreach { n =>
190+
assert(n.x.value >= ox, s"node ${n.x.value} placed left of origin $ox: $n")
191+
assert(n.y.value >= oy, s"node ${n.y.value} placed above origin $oy: $n")
192+
}
193+
194+
assertContained(layout.resolve(at)) // unbudgeted
195+
assertContained(Layout.resolveTo(layout, at, availW, availH)) // budgeted
196+
}
197+
198+
// A Row/Column whose children are all Fill: every resolved BoxNode is a
199+
// flex allocation, so the main-axis sizes are directly observable.
200+
private val genFlexBox: Gen[Layout] =
201+
for
202+
w <- Gen.choose(0, 20)
203+
h <- Gen.choose(0, 12)
204+
yield Layout.Fill(Layout.Elem(BoxNode(1.x, 1.y, w, h, children = Nil)))
205+
206+
test("P4: sum of flex (Fill) children's main-axis sizes never exceeds the parent's main-axis budget"):
207+
forAll(Gen.listOf(genFlexBox), Gen.choose(-3, 6), Gen.choose(0, 300)) { (children, gap, budget) =>
208+
// Row: major axis is width.
209+
val rowBoxes = Layout
210+
.resolveTo(Layout.Row(gap, children), Coord(XCoord(1), YCoord(1)), budget, 10)
211+
.collect { case b: BoxNode => b.width }
212+
assert(rowBoxes.sum <= budget, s"Row flex widths ${rowBoxes.sum} > budget $budget")
213+
assert(rowBoxes.forall(_ >= 0))
214+
215+
// Column: major axis is height.
216+
val colBoxes = Layout
217+
.resolveTo(Layout.Column(gap, children), Coord(XCoord(1), YCoord(1)), 10, budget)
218+
.collect { case b: BoxNode => b.height }
219+
assert(colBoxes.sum <= budget, s"Column flex heights ${colBoxes.sum} > budget $budget")
220+
assert(colBoxes.forall(_ >= 0))
221+
}
222+
223+
test("P5: Zone wrapping is transparent — same measure and same resolved nodes as its content"):
224+
forAll(genTree, Gen.choose(-2, 200), Gen.choose(-2, 200)) { (inner, availW, availH) =>
225+
val wrapped = Layout.Zone("id", inner)
226+
assert(wrapped.measure == inner.measure)
227+
val at = Coord(XCoord(1), YCoord(1))
228+
assert(Layout.resolveTo(wrapped, at, availW, availH) == Layout.resolveTo(inner, at, availW, availH))
229+
}

project/Dependencies.scala

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ object Versions {
66
val jline = "3.30.6"
77
val pureconfig = "0.17.10"
88
val scalatest = "3.2.19"
9+
// ScalaCheck and the matching scalatest+scalacheck bridge. The bridge
10+
// version tracks the scalatest version (3.2.19.x) and targets scalacheck 1.18.
11+
val scalacheck = "1.18.1"
12+
val scalatestPlusSc = "3.2.19.0"
913
}
1014

1115
object Deps {
@@ -14,4 +18,7 @@ object Deps {
1418
val pureconfigGenericScala3 =
1519
"com.github.pureconfig" %% "pureconfig-generic-scala3" % Versions.pureconfig
1620
val scalatest = "org.scalatest" %% "scalatest" % Versions.scalatest
21+
val scalacheck = "org.scalacheck" %% "scalacheck" % Versions.scalacheck
22+
val scalatestPlusScalacheck =
23+
"org.scalatestplus" %% "scalacheck-1-18" % Versions.scalatestPlusSc
1724
}

0 commit comments

Comments
 (0)