diff --git a/crates/layout/src/bin/logical_properties_check.rs b/crates/layout/src/bin/logical_properties_check.rs
new file mode 100644
index 00000000..6fe97389
--- /dev/null
+++ b/crates/layout/src/bin/logical_properties_check.rs
@@ -0,0 +1,1009 @@
+//! Logical properties (`margin-inline-start`, `padding-block`, `inline-size`, and the rest
+//! of `css/css-logical`) measured against the three browsers (`specs/logical-properties.md`).
+//! Stylo maps a logical property onto the physical property for the box's `writing-mode`
+//! and `direction` at cascade time, and `crates/dom/src/computed.rs` reads only the
+//! physical ones (`width`, `margin_left`, `padding_top`, `left`, `border_left_width`), so
+//! the engine should already lay these out. Nothing in the repository measured it before
+//! this binary, and the direction-dependent mapping is the part a mistake would hide in:
+//! `margin-inline-start` is the left margin under `direction: ltr` and the right one under
+//! `rtl`.
+//!
+//! This binary adds the measurement and nothing else. If the engine agrees with the
+//! browsers, no engine code changes; if it does not, the disagreements are the finding and
+//! fixing them belongs to a separate spec, since a fix would edit
+//! `crates/dom/src/computed.rs` or `crates/layout/src/fragment.rs`, which the queued
+//! generated-function specs edit and this binary's `touches` leaves alone.
+//!
+//! # Scope
+//!
+//! In: block-level, non-replaced boxes without text, in normal flow or absolutely
+//! positioned, under `writing-mode: horizontal-tb` and both `direction: ltr` and `rtl`
+//! (`crates/layout/src/width.rs` reads `direction`; the engine lays out no other writing
+//! mode, so vertical modes would be a change to the layout substrate, not to this
+//! feature). The longhands `margin-{inline,block}-{start,end}`,
+//! `padding-{inline,block}-{start,end}`, `border-{inline,block}-{start,end}-width` (with a
+//! `solid` style so the width applies), `inset-{inline,block}-{start,end}`, `inline-size`
+//! and `block-size`; the two-value shorthands `margin-inline`, `margin-block`,
+//! `padding-inline`, `padding-block`, `inset-inline`, `inset-block`; declarations that mix
+//! logical and physical properties for the same side, where the later one wins.
+//!
+//! Out: `min-*`/`max-*-size` (the accessors for the block axis do not exist yet),
+//! `border-*-color` and `border-*-style` (paint only), `float` and `clear` with
+//! `inline-start`/`inline-end`, `text-align: start`, `overflow-inline`, `caption-side`,
+//! `resize`, `contain-intrinsic-*` and the `border-*-radius` corner mappings. The 92 WPT
+//! files under `css/css-logical` cover all of those, so the count says how large the
+//! feature is, not how much of it this spec exercises.
+//!
+//! # Result
+//!
+//! Cases written: 150, three under `ltr` and three under `rtl` for each of the 25 groups
+//! (`margin-inline-start`, `margin-inline-end`, `margin-block-start`, `margin-block-end`,
+//! `padding-inline-start`, `padding-inline-end`, `padding-block-start`, `padding-block-end`,
+//! `border-inline-start-width`, `border-inline-end-width`, `border-block-start-width`,
+//! `border-block-end-width`, `inset-inline-start`, `inset-inline-end`, `inset-block-start`,
+//! `inset-block-end`, `inline-size`, `block-size`, the six shorthands `margin-inline`,
+//! `margin-block`, `padding-inline`, `padding-block`, `inset-inline` and `inset-block`, and
+//! `mixed`). Every group's `rtl` cases include the inline-axis properties whose physical
+//! twin is the opposite side, so the swap is in the corpus rather than only in the raw
+//! values.
+//!
+//! All 75 `rtl` cases fail on one engine bug, and the `ltr` runs of the same documents pass:
+//! it is the normal-flow block width equation, not the logical mapping. Stylo computes the
+//! physical property correctly (`computed::margin_left` is `0` and `computed::margin_right`
+//! is `7px` for `margin-inline-start: 7px` under `rtl`), but
+//! `crates/layout/src/generated/width.rs` treats a box as over-constrained only when
+//! `margin-left + width + margin-right` exceeds the containing block. CSS 2.1 10.3.3 makes
+//! every all-non-auto width/margin combination over-constrained regardless of the sum, so
+//! under `rtl` the browsers ignore `margin-left` and solve it (`#p` of `lp-mis-rtl-1` sits at
+//! x=498, `#a` at x=674), while the engine keeps `margin-left: 0` (`#p` at x=0, `#a` at
+//! x=1).
+//!
+//! The three `inset-block` `ltr` cases fail on a second bug, again outside `computed.rs`:
+//! for an absolutely positioned box with a definite `top`, `height` and `bottom`,
+//! `crates/layout/src/generated/absolute.rs` ignores `top` and solves it from `bottom`, so
+//! `inset-block: 10px 20px` puts `#a` at y=130; the browsers ignore `bottom` and keep
+//! `top`, y=10.
+//!
+//! The block below is this binary's stdout from a real lane run, pasted unchanged;
+//! [`recorded_result_adds_up`] re-reads it from this module doc so the recorded numbers
+//! cannot drift from each other, and the run needs no browser, so the test stage catches a
+//! wrong record before review does.
+//!
+//! ```text
+//! engines: chromium, firefox, webkit
+//! cases written: 150
+//!
+//! == summary ==
+//! dropped 0, compared 150, passed 72
+//!
+//! == per-group and direction ==
+//! margin-inline-start/ltr 3/3 passed, 3 written
+//! margin-inline-start/rtl 0/3 passed, 3 written
+//! margin-inline-end/ltr 3/3 passed, 3 written
+//! margin-inline-end/rtl 0/3 passed, 3 written
+//! margin-block-start/ltr 3/3 passed, 3 written
+//! margin-block-start/rtl 0/3 passed, 3 written
+//! margin-block-end/ltr 3/3 passed, 3 written
+//! margin-block-end/rtl 0/3 passed, 3 written
+//! padding-inline-start/ltr 3/3 passed, 3 written
+//! padding-inline-start/rtl 0/3 passed, 3 written
+//! padding-inline-end/ltr 3/3 passed, 3 written
+//! padding-inline-end/rtl 0/3 passed, 3 written
+//! padding-block-start/ltr 3/3 passed, 3 written
+//! padding-block-start/rtl 0/3 passed, 3 written
+//! padding-block-end/ltr 3/3 passed, 3 written
+//! padding-block-end/rtl 0/3 passed, 3 written
+//! border-inline-start-width/ltr 3/3 passed, 3 written
+//! border-inline-start-width/rtl 0/3 passed, 3 written
+//! border-inline-end-width/ltr 3/3 passed, 3 written
+//! border-inline-end-width/rtl 0/3 passed, 3 written
+//! border-block-start-width/ltr 3/3 passed, 3 written
+//! border-block-start-width/rtl 0/3 passed, 3 written
+//! border-block-end-width/ltr 3/3 passed, 3 written
+//! border-block-end-width/rtl 0/3 passed, 3 written
+//! inset-inline-start/ltr 3/3 passed, 3 written
+//! inset-inline-start/rtl 0/3 passed, 3 written
+//! inset-inline-end/ltr 3/3 passed, 3 written
+//! inset-inline-end/rtl 0/3 passed, 3 written
+//! inset-block-start/ltr 3/3 passed, 3 written
+//! inset-block-start/rtl 0/3 passed, 3 written
+//! inset-block-end/ltr 3/3 passed, 3 written
+//! inset-block-end/rtl 0/3 passed, 3 written
+//! inline-size/ltr 3/3 passed, 3 written
+//! inline-size/rtl 0/3 passed, 3 written
+//! block-size/ltr 3/3 passed, 3 written
+//! block-size/rtl 0/3 passed, 3 written
+//! margin-inline/ltr 3/3 passed, 3 written
+//! margin-inline/rtl 0/3 passed, 3 written
+//! margin-block/ltr 3/3 passed, 3 written
+//! margin-block/rtl 0/3 passed, 3 written
+//! padding-inline/ltr 3/3 passed, 3 written
+//! padding-inline/rtl 0/3 passed, 3 written
+//! padding-block/ltr 3/3 passed, 3 written
+//! padding-block/rtl 0/3 passed, 3 written
+//! inset-inline/ltr 3/3 passed, 3 written
+//! inset-inline/rtl 0/3 passed, 3 written
+//! inset-block/ltr 0/3 passed, 3 written
+//! inset-block/rtl 0/3 passed, 3 written
+//! mixed/ltr 3/3 passed, 3 written
+//! mixed/rtl 0/3 passed, 3 written
+//!
+//! == dropped ==
+//! none
+//!
+//! == failures ==
+//! lp-mis-rtl-1: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=673.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=673.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=673.0000px
+//! lp-mis-rtl-2: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=658.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=658.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=658.0000px
+//! lp-mis-rtl-3: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=691.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=691.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=691.0000px
+//! lp-mie-rtl-1: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=673.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=673.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=673.0000px
+//! lp-mie-rtl-2: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=658.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=658.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=658.0000px
+//! lp-mie-rtl-3: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=691.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=691.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=691.0000px
+//! lp-mbs-rtl-1: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px
+//! lp-mbs-rtl-2: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px
+//! lp-mbs-rtl-3: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px
+//! lp-mbe-rtl-1: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px
+//! lp-mbe-rtl-2: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px
+//! lp-mbe-rtl-3: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px
+//! lp-pis-rtl-1: chromium: fail: 2 node(s) differ, worst |delta|=470.0000px, sum |delta|=720.0000px | firefox: fail: 2 node(s) differ, worst |delta|=470.0000px, sum |delta|=720.0000px | webkit: fail: 2 node(s) differ, worst |delta|=470.0000px, sum |delta|=720.0000px
+//! lp-pis-rtl-2: chromium: fail: 2 node(s) differ, worst |delta|=455.0000px, sum |delta|=705.0000px | firefox: fail: 2 node(s) differ, worst |delta|=455.0000px, sum |delta|=705.0000px | webkit: fail: 2 node(s) differ, worst |delta|=455.0000px, sum |delta|=705.0000px
+//! lp-pis-rtl-3: chromium: fail: 2 node(s) differ, worst |delta|=490.0000px, sum |delta|=740.0000px | firefox: fail: 2 node(s) differ, worst |delta|=490.0000px, sum |delta|=740.0000px | webkit: fail: 2 node(s) differ, worst |delta|=490.0000px, sum |delta|=740.0000px
+//! lp-pie-rtl-1: chromium: fail: 2 node(s) differ, worst |delta|=470.0000px, sum |delta|=720.0000px | firefox: fail: 2 node(s) differ, worst |delta|=470.0000px, sum |delta|=720.0000px | webkit: fail: 2 node(s) differ, worst |delta|=470.0000px, sum |delta|=720.0000px
+//! lp-pie-rtl-2: chromium: fail: 2 node(s) differ, worst |delta|=455.0000px, sum |delta|=705.0000px | firefox: fail: 2 node(s) differ, worst |delta|=455.0000px, sum |delta|=705.0000px | webkit: fail: 2 node(s) differ, worst |delta|=455.0000px, sum |delta|=705.0000px
+//! lp-pie-rtl-3: chromium: fail: 2 node(s) differ, worst |delta|=490.0000px, sum |delta|=740.0000px | firefox: fail: 2 node(s) differ, worst |delta|=490.0000px, sum |delta|=740.0000px | webkit: fail: 2 node(s) differ, worst |delta|=490.0000px, sum |delta|=740.0000px
+//! lp-pbs-rtl-1: chromium: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | firefox: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | webkit: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px
+//! lp-pbs-rtl-2: chromium: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | firefox: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | webkit: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px
+//! lp-pbs-rtl-3: chromium: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | firefox: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | webkit: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px
+//! lp-pbe-rtl-1: chromium: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | firefox: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | webkit: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px
+//! lp-pbe-rtl-2: chromium: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | firefox: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | webkit: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px
+//! lp-pbe-rtl-3: chromium: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | firefox: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | webkit: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px
+//! lp-bis-rtl-1: chromium: fail: 2 node(s) differ, worst |delta|=490.0000px, sum |delta|=740.0000px | firefox: fail: 2 node(s) differ, worst |delta|=490.0000px, sum |delta|=740.0000px | webkit: fail: 2 node(s) differ, worst |delta|=490.0000px, sum |delta|=740.0000px
+//! lp-bis-rtl-2: chromium: fail: 2 node(s) differ, worst |delta|=485.0000px, sum |delta|=735.0000px | firefox: fail: 2 node(s) differ, worst |delta|=485.0000px, sum |delta|=735.0000px | webkit: fail: 2 node(s) differ, worst |delta|=485.0000px, sum |delta|=735.0000px
+//! lp-bis-rtl-3: chromium: fail: 2 node(s) differ, worst |delta|=495.0000px, sum |delta|=745.0000px | firefox: fail: 2 node(s) differ, worst |delta|=495.0000px, sum |delta|=745.0000px | webkit: fail: 2 node(s) differ, worst |delta|=495.0000px, sum |delta|=745.0000px
+//! lp-bie-rtl-1: chromium: fail: 2 node(s) differ, worst |delta|=490.0000px, sum |delta|=740.0000px | firefox: fail: 2 node(s) differ, worst |delta|=490.0000px, sum |delta|=740.0000px | webkit: fail: 2 node(s) differ, worst |delta|=490.0000px, sum |delta|=740.0000px
+//! lp-bie-rtl-2: chromium: fail: 2 node(s) differ, worst |delta|=485.0000px, sum |delta|=735.0000px | firefox: fail: 2 node(s) differ, worst |delta|=485.0000px, sum |delta|=735.0000px | webkit: fail: 2 node(s) differ, worst |delta|=485.0000px, sum |delta|=735.0000px
+//! lp-bie-rtl-3: chromium: fail: 2 node(s) differ, worst |delta|=495.0000px, sum |delta|=745.0000px | firefox: fail: 2 node(s) differ, worst |delta|=495.0000px, sum |delta|=745.0000px | webkit: fail: 2 node(s) differ, worst |delta|=495.0000px, sum |delta|=745.0000px
+//! lp-bbs-rtl-1: chromium: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | firefox: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | webkit: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px
+//! lp-bbs-rtl-2: chromium: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | firefox: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | webkit: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px
+//! lp-bbs-rtl-3: chromium: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | firefox: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | webkit: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px
+//! lp-bbe-rtl-1: chromium: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | firefox: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | webkit: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px
+//! lp-bbe-rtl-2: chromium: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | firefox: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | webkit: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px
+//! lp-bbe-rtl-3: chromium: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | firefox: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | webkit: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px
+//! lp-iis-rtl-1: chromium: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | firefox: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | webkit: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px
+//! lp-iis-rtl-2: chromium: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | firefox: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | webkit: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px
+//! lp-iis-rtl-3: chromium: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | firefox: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | webkit: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px
+//! lp-iie-rtl-1: chromium: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | firefox: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | webkit: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px
+//! lp-iie-rtl-2: chromium: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | firefox: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | webkit: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px
+//! lp-iie-rtl-3: chromium: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | firefox: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | webkit: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px
+//! lp-ibs-rtl-1: chromium: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | firefox: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | webkit: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px
+//! lp-ibs-rtl-2: chromium: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | firefox: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | webkit: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px
+//! lp-ibs-rtl-3: chromium: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | firefox: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | webkit: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px
+//! lp-ibe-rtl-1: chromium: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | firefox: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | webkit: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px
+//! lp-ibe-rtl-2: chromium: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | firefox: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | webkit: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px
+//! lp-ibe-rtl-3: chromium: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | firefox: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px | webkit: fail: 1 node(s) differ, worst |delta|=500.0000px, sum |delta|=500.0000px
+//! lp-is-rtl-1: chromium: fail: 2 node(s) differ, worst |delta|=398.0000px, sum |delta|=648.0000px | firefox: fail: 2 node(s) differ, worst |delta|=398.0000px, sum |delta|=648.0000px | webkit: fail: 2 node(s) differ, worst |delta|=398.0000px, sum |delta|=648.0000px
+//! lp-is-rtl-2: chromium: fail: 2 node(s) differ, worst |delta|=398.0000px, sum |delta|=578.0000px | firefox: fail: 2 node(s) differ, worst |delta|=398.0000px, sum |delta|=578.0000px | webkit: fail: 2 node(s) differ, worst |delta|=398.0000px, sum |delta|=578.0000px
+//! lp-is-rtl-3: chromium: fail: 2 node(s) differ, worst |delta|=398.0000px, sum |delta|=718.0000px | firefox: fail: 2 node(s) differ, worst |delta|=398.0000px, sum |delta|=718.0000px | webkit: fail: 2 node(s) differ, worst |delta|=398.0000px, sum |delta|=718.0000px
+//! lp-bs-rtl-1: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=678.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=678.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=678.0000px
+//! lp-bs-rtl-2: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=678.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=678.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=678.0000px
+//! lp-bs-rtl-3: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=678.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=678.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=678.0000px
+//! lp-mi-rtl-1: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=648.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=648.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=648.0000px
+//! lp-mi-rtl-2: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=638.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=638.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=638.0000px
+//! lp-mi-rtl-3: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=679.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=679.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=679.0000px
+//! lp-mb-rtl-1: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px
+//! lp-mb-rtl-2: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px
+//! lp-mb-rtl-3: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px
+//! lp-pi-rtl-1: chromium: fail: 2 node(s) differ, worst |delta|=460.0000px, sum |delta|=710.0000px | firefox: fail: 2 node(s) differ, worst |delta|=460.0000px, sum |delta|=710.0000px | webkit: fail: 2 node(s) differ, worst |delta|=460.0000px, sum |delta|=710.0000px
+//! lp-pi-rtl-2: chromium: fail: 2 node(s) differ, worst |delta|=465.0000px, sum |delta|=715.0000px | firefox: fail: 2 node(s) differ, worst |delta|=465.0000px, sum |delta|=715.0000px | webkit: fail: 2 node(s) differ, worst |delta|=465.0000px, sum |delta|=715.0000px
+//! lp-pi-rtl-3: chromium: fail: 2 node(s) differ, worst |delta|=472.0000px, sum |delta|=722.0000px | firefox: fail: 2 node(s) differ, worst |delta|=472.0000px, sum |delta|=722.0000px | webkit: fail: 2 node(s) differ, worst |delta|=472.0000px, sum |delta|=722.0000px
+//! lp-pb-rtl-1: chromium: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | firefox: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | webkit: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px
+//! lp-pb-rtl-2: chromium: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | firefox: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | webkit: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px
+//! lp-pb-rtl-3: chromium: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | firefox: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px | webkit: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=750.0000px
+//! lp-ii-rtl-1: chromium: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=690.0000px | firefox: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=690.0000px | webkit: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=690.0000px
+//! lp-ii-rtl-2: chromium: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=680.0000px | firefox: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=680.0000px | webkit: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=680.0000px
+//! lp-ii-rtl-3: chromium: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=721.0000px | firefox: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=721.0000px | webkit: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=721.0000px
+//! lp-ib-ltr-1: chromium: fail: 1 node(s) differ, worst |delta|=120.0000px, sum |delta|=120.0000px | firefox: fail: 1 node(s) differ, worst |delta|=120.0000px, sum |delta|=120.0000px | webkit: fail: 1 node(s) differ, worst |delta|=120.0000px, sum |delta|=120.0000px
+//! lp-ib-ltr-2: chromium: fail: 1 node(s) differ, worst |delta|=120.0000px, sum |delta|=120.0000px | firefox: fail: 1 node(s) differ, worst |delta|=120.0000px, sum |delta|=120.0000px | webkit: fail: 1 node(s) differ, worst |delta|=120.0000px, sum |delta|=120.0000px
+//! lp-ib-ltr-3: chromium: fail: 1 node(s) differ, worst |delta|=126.0000px, sum |delta|=126.0000px | firefox: fail: 1 node(s) differ, worst |delta|=126.0000px, sum |delta|=126.0000px | webkit: fail: 1 node(s) differ, worst |delta|=126.0000px, sum |delta|=126.0000px
+//! lp-ib-rtl-1: chromium: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=620.0000px | firefox: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=620.0000px | webkit: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=620.0000px
+//! lp-ib-rtl-2: chromium: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=620.0000px | firefox: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=620.0000px | webkit: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=620.0000px
+//! lp-ib-rtl-3: chromium: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=626.0000px | firefox: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=626.0000px | webkit: fail: 2 node(s) differ, worst |delta|=500.0000px, sum |delta|=626.0000px
+//! lp-mx-rtl-1: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=668.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=668.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=668.0000px
+//! lp-mx-rtl-2: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=688.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=688.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=688.0000px
+//! lp-mx-rtl-3: chromium: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px | firefox: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px | webkit: fail: 2 node(s) differ, worst |delta|=498.0000px, sum |delta|=698.0000px
+//! ```
+
+use std::collections::{BTreeMap, HashMap};
+use std::path::PathBuf;
+
+use anyhow::{bail, Result};
+use dom::node::NodeId;
+use dom::Document;
+use harness::compare::compare;
+use harness::dump::{Dump, Node as DumpNode, Rect, Viewport};
+use harness::oracle::Oracle;
+use layout::boxtree::BoxTree;
+use layout::fragment;
+
+/// Builds one case document from a stylesheet body and a body markup fragment. `concat!`
+/// keeps the result a literal `&'static str` usable in a `const`, so every entry in
+/// [`CASES`] is still a literal document while the boilerplate appears once. The reset is
+/// spelled out here rather than pasted from [`RESET`] because `concat!` takes literals
+/// only; [`every_document_opens_with_the_reset`] is what keeps the two spellings equal.
+macro_rules! doc {
+ ($css:expr, $body:expr) => {
+ concat!(
+ "\n\n
\n\n\n\n\n",
+ $body,
+ "\n\n\n"
+ )
+ };
+}
+
+/// One case tuple: the direction is written into the `html` rule so that
+/// [`direction_of`] can read it back off the literal document, which keeps the tuple the
+/// three fields the spec asks for.
+macro_rules! lp {
+ ($id:expr, $cat:expr, $dir:literal, $css:expr) => {
+ (
+ $id,
+ $cat,
+ doc!(
+ concat!("html { direction: ", $dir, "; }\n", $css),
+ ""
+ ),
+ )
+ };
+}
+
+// The family macros below build one case document each, so the 150 literal documents in
+// `CASES` stay one line apiece and the wrapper shape stays identical across a family.
+
+macro_rules! margin_inline {
+ ($id:expr, $cat:expr, $dir:literal, $prop:literal, $val:literal) => {
+ lp!($id, $cat, $dir,
+ concat!("#p { width: 300px; height: 100px; padding: 1px; }\n#a { ", $prop, ": ", $val, "; width: 100px; height: 30px; }"))
+ };
+}
+
+macro_rules! margin_block {
+ ($id:expr, $cat:expr, $dir:literal, $prop:literal, $val:literal) => {
+ lp!($id, $cat, $dir,
+ concat!("#p { width: 300px; height: 200px; padding: 1px; }\n#a { ", $prop, ": ", $val, "; width: 100px; height: 30px; }"))
+ };
+}
+
+macro_rules! padding_inline {
+ ($id:expr, $cat:expr, $dir:literal, $prop:literal, $val:literal) => {
+ lp!($id, $cat, $dir,
+ concat!("#p { width: 300px; height: 100px; ", $prop, ": ", $val, "; }\n#a { width: 50px; height: 30px; }"))
+ };
+}
+
+macro_rules! padding_block {
+ ($id:expr, $cat:expr, $dir:literal, $prop:literal, $val:literal) => {
+ lp!($id, $cat, $dir,
+ concat!("#p { width: 300px; height: 200px; ", $prop, ": ", $val, "; }\n#a { width: 50px; height: 30px; }"))
+ };
+}
+
+macro_rules! border_inline {
+ ($id:expr, $cat:expr, $dir:literal, $prop:literal, $val:literal) => {
+ lp!($id, $cat, $dir,
+ concat!("#p { width: 300px; height: 100px; border: 0 solid; ", $prop, ": ", $val, "; }\n#a { width: 50px; height: 30px; }"))
+ };
+}
+
+macro_rules! border_block {
+ ($id:expr, $cat:expr, $dir:literal, $prop:literal, $val:literal) => {
+ lp!($id, $cat, $dir,
+ concat!("#p { width: 300px; height: 200px; border: 0 solid; ", $prop, ": ", $val, "; }\n#a { width: 50px; height: 30px; }"))
+ };
+}
+
+macro_rules! inset_inline {
+ ($id:expr, $cat:expr, $dir:literal, $prop:literal, $val:literal) => {
+ lp!($id, $cat, $dir,
+ concat!("#p { position: relative; width: 300px; height: 200px; }\n#a { position: absolute; top: 0; ", $prop, ": ", $val, "; width: 60px; height: 50px; }"))
+ };
+}
+
+macro_rules! inset_block {
+ ($id:expr, $cat:expr, $dir:literal, $prop:literal, $val:literal) => {
+ lp!($id, $cat, $dir,
+ concat!("#p { position: relative; width: 300px; height: 200px; }\n#a { position: absolute; left: 0; ", $prop, ": ", $val, "; width: 60px; height: 50px; }"))
+ };
+}
+
+macro_rules! size_inline {
+ ($id:expr, $cat:expr, $dir:literal, $val:literal) => {
+ lp!($id, $cat, $dir,
+ concat!("#p { width: 400px; height: 200px; padding: 1px; }\n#a { inline-size: ", $val, "; block-size: 40px; }"))
+ };
+}
+
+macro_rules! size_block {
+ ($id:expr, $cat:expr, $dir:literal, $val:literal) => {
+ lp!($id, $cat, $dir,
+ concat!("#p { width: 300px; height: 300px; padding: 1px; }\n#a { inline-size: 120px; block-size: ", $val, "; }"))
+ };
+}
+
+/// The zeroing rule every document opens with, character for character the recipe corpus's
+/// `RESET` (`crates/harness/src/cases.rs`), so a browser's UA stylesheet cannot leak body
+/// margin into the comparison. Duplicated rather than imported because
+/// `harness::cases::RESET` is `pub(crate)`.
+const RESET: &str =
+ "* { margin: 0; padding: 0; border: 0; box-sizing: content-box; }\nhtml, body { display: block; }\n";
+
+/// Every group [`CASES`] draws from, which is the spec's in-scope list. The test module
+/// checks each one is used at least three times under each direction and that no case
+/// names a group outside this list.
+const CATEGORIES: &[&str] = &[
+ "margin-inline-start",
+ "margin-inline-end",
+ "margin-block-start",
+ "margin-block-end",
+ "padding-inline-start",
+ "padding-inline-end",
+ "padding-block-start",
+ "padding-block-end",
+ "border-inline-start-width",
+ "border-inline-end-width",
+ "border-block-start-width",
+ "border-block-end-width",
+ "inset-inline-start",
+ "inset-inline-end",
+ "inset-block-start",
+ "inset-block-end",
+ "inline-size",
+ "block-size",
+ "margin-inline",
+ "margin-block",
+ "padding-inline",
+ "padding-block",
+ "inset-inline",
+ "inset-block",
+ "mixed",
+];
+
+/// (id, category, html). Literal documents rather than `harness::cases::Elem`, which is
+/// `pub(crate)`.
+///
+/// Every group gets three `ltr` cases and three `rtl` cases, so the direction dimension is
+/// measured for every property rather than assumed. `margin-inline-start` under `rtl`
+/// lands on `margin-right`, `margin-inline-end` on `margin-left`, `padding-inline-start`
+/// on `padding-right`, `border-inline-start-width` on `border-right-width`,
+/// `inset-inline-start` on `right` and `inset-inline-end` on `left`; the block-axis
+/// properties keep their physical side under both directions, which the `rtl` cases make
+/// visible beside the `ltr` ones.
+const CASES: &[(&str, &str, &str)] = &[
+ // -- margin-inline-start --
+ margin_inline!("lp-mis-ltr-1", "margin-inline-start", "ltr", "margin-inline-start", "25px"),
+ margin_inline!("lp-mis-ltr-2", "margin-inline-start", "ltr", "margin-inline-start", "40px"),
+ margin_inline!("lp-mis-ltr-3", "margin-inline-start", "ltr", "margin-inline-start", "7px"),
+ margin_inline!("lp-mis-rtl-1", "margin-inline-start", "rtl", "margin-inline-start", "25px"),
+ margin_inline!("lp-mis-rtl-2", "margin-inline-start", "rtl", "margin-inline-start", "40px"),
+ margin_inline!("lp-mis-rtl-3", "margin-inline-start", "rtl", "margin-inline-start", "7px"),
+ // -- margin-inline-end --
+ margin_inline!("lp-mie-ltr-1", "margin-inline-end", "ltr", "margin-inline-end", "25px"),
+ margin_inline!("lp-mie-ltr-2", "margin-inline-end", "ltr", "margin-inline-end", "40px"),
+ margin_inline!("lp-mie-ltr-3", "margin-inline-end", "ltr", "margin-inline-end", "7px"),
+ margin_inline!("lp-mie-rtl-1", "margin-inline-end", "rtl", "margin-inline-end", "25px"),
+ margin_inline!("lp-mie-rtl-2", "margin-inline-end", "rtl", "margin-inline-end", "40px"),
+ margin_inline!("lp-mie-rtl-3", "margin-inline-end", "rtl", "margin-inline-end", "7px"),
+ // -- margin-block-start --
+ margin_block!("lp-mbs-ltr-1", "margin-block-start", "ltr", "margin-block-start", "25px"),
+ margin_block!("lp-mbs-ltr-2", "margin-block-start", "ltr", "margin-block-start", "40px"),
+ margin_block!("lp-mbs-ltr-3", "margin-block-start", "ltr", "margin-block-start", "7px"),
+ margin_block!("lp-mbs-rtl-1", "margin-block-start", "rtl", "margin-block-start", "25px"),
+ margin_block!("lp-mbs-rtl-2", "margin-block-start", "rtl", "margin-block-start", "40px"),
+ margin_block!("lp-mbs-rtl-3", "margin-block-start", "rtl", "margin-block-start", "7px"),
+ // -- margin-block-end --
+ margin_block!("lp-mbe-ltr-1", "margin-block-end", "ltr", "margin-block-end", "25px"),
+ margin_block!("lp-mbe-ltr-2", "margin-block-end", "ltr", "margin-block-end", "40px"),
+ margin_block!("lp-mbe-ltr-3", "margin-block-end", "ltr", "margin-block-end", "7px"),
+ margin_block!("lp-mbe-rtl-1", "margin-block-end", "rtl", "margin-block-end", "25px"),
+ margin_block!("lp-mbe-rtl-2", "margin-block-end", "rtl", "margin-block-end", "40px"),
+ margin_block!("lp-mbe-rtl-3", "margin-block-end", "rtl", "margin-block-end", "7px"),
+ // -- padding-inline-start --
+ padding_inline!("lp-pis-ltr-1", "padding-inline-start", "ltr", "padding-inline-start", "30px"),
+ padding_inline!("lp-pis-ltr-2", "padding-inline-start", "ltr", "padding-inline-start", "45px"),
+ padding_inline!("lp-pis-ltr-3", "padding-inline-start", "ltr", "padding-inline-start", "10px"),
+ padding_inline!("lp-pis-rtl-1", "padding-inline-start", "rtl", "padding-inline-start", "30px"),
+ padding_inline!("lp-pis-rtl-2", "padding-inline-start", "rtl", "padding-inline-start", "45px"),
+ padding_inline!("lp-pis-rtl-3", "padding-inline-start", "rtl", "padding-inline-start", "10px"),
+ // -- padding-inline-end --
+ padding_inline!("lp-pie-ltr-1", "padding-inline-end", "ltr", "padding-inline-end", "30px"),
+ padding_inline!("lp-pie-ltr-2", "padding-inline-end", "ltr", "padding-inline-end", "45px"),
+ padding_inline!("lp-pie-ltr-3", "padding-inline-end", "ltr", "padding-inline-end", "10px"),
+ padding_inline!("lp-pie-rtl-1", "padding-inline-end", "rtl", "padding-inline-end", "30px"),
+ padding_inline!("lp-pie-rtl-2", "padding-inline-end", "rtl", "padding-inline-end", "45px"),
+ padding_inline!("lp-pie-rtl-3", "padding-inline-end", "rtl", "padding-inline-end", "10px"),
+ // -- padding-block-start --
+ padding_block!("lp-pbs-ltr-1", "padding-block-start", "ltr", "padding-block-start", "30px"),
+ padding_block!("lp-pbs-ltr-2", "padding-block-start", "ltr", "padding-block-start", "45px"),
+ padding_block!("lp-pbs-ltr-3", "padding-block-start", "ltr", "padding-block-start", "10px"),
+ padding_block!("lp-pbs-rtl-1", "padding-block-start", "rtl", "padding-block-start", "30px"),
+ padding_block!("lp-pbs-rtl-2", "padding-block-start", "rtl", "padding-block-start", "45px"),
+ padding_block!("lp-pbs-rtl-3", "padding-block-start", "rtl", "padding-block-start", "10px"),
+ // -- padding-block-end --
+ padding_block!("lp-pbe-ltr-1", "padding-block-end", "ltr", "padding-block-end", "30px"),
+ padding_block!("lp-pbe-ltr-2", "padding-block-end", "ltr", "padding-block-end", "45px"),
+ padding_block!("lp-pbe-ltr-3", "padding-block-end", "ltr", "padding-block-end", "10px"),
+ padding_block!("lp-pbe-rtl-1", "padding-block-end", "rtl", "padding-block-end", "30px"),
+ padding_block!("lp-pbe-rtl-2", "padding-block-end", "rtl", "padding-block-end", "45px"),
+ padding_block!("lp-pbe-rtl-3", "padding-block-end", "rtl", "padding-block-end", "10px"),
+ // -- border-inline-start-width --
+ border_inline!("lp-bis-ltr-1", "border-inline-start-width", "ltr", "border-inline-start-width", "10px"),
+ border_inline!("lp-bis-ltr-2", "border-inline-start-width", "ltr", "border-inline-start-width", "15px"),
+ border_inline!("lp-bis-ltr-3", "border-inline-start-width", "ltr", "border-inline-start-width", "5px"),
+ border_inline!("lp-bis-rtl-1", "border-inline-start-width", "rtl", "border-inline-start-width", "10px"),
+ border_inline!("lp-bis-rtl-2", "border-inline-start-width", "rtl", "border-inline-start-width", "15px"),
+ border_inline!("lp-bis-rtl-3", "border-inline-start-width", "rtl", "border-inline-start-width", "5px"),
+ // -- border-inline-end-width --
+ border_inline!("lp-bie-ltr-1", "border-inline-end-width", "ltr", "border-inline-end-width", "10px"),
+ border_inline!("lp-bie-ltr-2", "border-inline-end-width", "ltr", "border-inline-end-width", "15px"),
+ border_inline!("lp-bie-ltr-3", "border-inline-end-width", "ltr", "border-inline-end-width", "5px"),
+ border_inline!("lp-bie-rtl-1", "border-inline-end-width", "rtl", "border-inline-end-width", "10px"),
+ border_inline!("lp-bie-rtl-2", "border-inline-end-width", "rtl", "border-inline-end-width", "15px"),
+ border_inline!("lp-bie-rtl-3", "border-inline-end-width", "rtl", "border-inline-end-width", "5px"),
+ // -- border-block-start-width --
+ border_block!("lp-bbs-ltr-1", "border-block-start-width", "ltr", "border-block-start-width", "10px"),
+ border_block!("lp-bbs-ltr-2", "border-block-start-width", "ltr", "border-block-start-width", "15px"),
+ border_block!("lp-bbs-ltr-3", "border-block-start-width", "ltr", "border-block-start-width", "5px"),
+ border_block!("lp-bbs-rtl-1", "border-block-start-width", "rtl", "border-block-start-width", "10px"),
+ border_block!("lp-bbs-rtl-2", "border-block-start-width", "rtl", "border-block-start-width", "15px"),
+ border_block!("lp-bbs-rtl-3", "border-block-start-width", "rtl", "border-block-start-width", "5px"),
+ // -- border-block-end-width --
+ border_block!("lp-bbe-ltr-1", "border-block-end-width", "ltr", "border-block-end-width", "10px"),
+ border_block!("lp-bbe-ltr-2", "border-block-end-width", "ltr", "border-block-end-width", "15px"),
+ border_block!("lp-bbe-ltr-3", "border-block-end-width", "ltr", "border-block-end-width", "5px"),
+ border_block!("lp-bbe-rtl-1", "border-block-end-width", "rtl", "border-block-end-width", "10px"),
+ border_block!("lp-bbe-rtl-2", "border-block-end-width", "rtl", "border-block-end-width", "15px"),
+ border_block!("lp-bbe-rtl-3", "border-block-end-width", "rtl", "border-block-end-width", "5px"),
+ // -- inset-inline-start --
+ inset_inline!("lp-iis-ltr-1", "inset-inline-start", "ltr", "inset-inline-start", "40px"),
+ inset_inline!("lp-iis-ltr-2", "inset-inline-start", "ltr", "inset-inline-start", "70px"),
+ inset_inline!("lp-iis-ltr-3", "inset-inline-start", "ltr", "inset-inline-start", "15px"),
+ inset_inline!("lp-iis-rtl-1", "inset-inline-start", "rtl", "inset-inline-start", "40px"),
+ inset_inline!("lp-iis-rtl-2", "inset-inline-start", "rtl", "inset-inline-start", "70px"),
+ inset_inline!("lp-iis-rtl-3", "inset-inline-start", "rtl", "inset-inline-start", "15px"),
+ // -- inset-inline-end --
+ inset_inline!("lp-iie-ltr-1", "inset-inline-end", "ltr", "inset-inline-end", "40px"),
+ inset_inline!("lp-iie-ltr-2", "inset-inline-end", "ltr", "inset-inline-end", "70px"),
+ inset_inline!("lp-iie-ltr-3", "inset-inline-end", "ltr", "inset-inline-end", "15px"),
+ inset_inline!("lp-iie-rtl-1", "inset-inline-end", "rtl", "inset-inline-end", "40px"),
+ inset_inline!("lp-iie-rtl-2", "inset-inline-end", "rtl", "inset-inline-end", "70px"),
+ inset_inline!("lp-iie-rtl-3", "inset-inline-end", "rtl", "inset-inline-end", "15px"),
+ // -- inset-block-start --
+ inset_block!("lp-ibs-ltr-1", "inset-block-start", "ltr", "inset-block-start", "30px"),
+ inset_block!("lp-ibs-ltr-2", "inset-block-start", "ltr", "inset-block-start", "50px"),
+ inset_block!("lp-ibs-ltr-3", "inset-block-start", "ltr", "inset-block-start", "10px"),
+ inset_block!("lp-ibs-rtl-1", "inset-block-start", "rtl", "inset-block-start", "30px"),
+ inset_block!("lp-ibs-rtl-2", "inset-block-start", "rtl", "inset-block-start", "50px"),
+ inset_block!("lp-ibs-rtl-3", "inset-block-start", "rtl", "inset-block-start", "10px"),
+ // -- inset-block-end --
+ inset_block!("lp-ibe-ltr-1", "inset-block-end", "ltr", "inset-block-end", "30px"),
+ inset_block!("lp-ibe-ltr-2", "inset-block-end", "ltr", "inset-block-end", "50px"),
+ inset_block!("lp-ibe-ltr-3", "inset-block-end", "ltr", "inset-block-end", "10px"),
+ inset_block!("lp-ibe-rtl-1", "inset-block-end", "rtl", "inset-block-end", "30px"),
+ inset_block!("lp-ibe-rtl-2", "inset-block-end", "rtl", "inset-block-end", "50px"),
+ inset_block!("lp-ibe-rtl-3", "inset-block-end", "rtl", "inset-block-end", "10px"),
+ // -- inline-size --
+ size_inline!("lp-is-ltr-1", "inline-size", "ltr", "150px"),
+ size_inline!("lp-is-ltr-2", "inline-size", "ltr", "220px"),
+ size_inline!("lp-is-ltr-3", "inline-size", "ltr", "80px"),
+ size_inline!("lp-is-rtl-1", "inline-size", "rtl", "150px"),
+ size_inline!("lp-is-rtl-2", "inline-size", "rtl", "220px"),
+ size_inline!("lp-is-rtl-3", "inline-size", "rtl", "80px"),
+ // -- block-size --
+ size_block!("lp-bs-ltr-1", "block-size", "ltr", "120px"),
+ size_block!("lp-bs-ltr-2", "block-size", "ltr", "90px"),
+ size_block!("lp-bs-ltr-3", "block-size", "ltr", "40px"),
+ size_block!("lp-bs-rtl-1", "block-size", "rtl", "120px"),
+ size_block!("lp-bs-rtl-2", "block-size", "rtl", "90px"),
+ size_block!("lp-bs-rtl-3", "block-size", "rtl", "40px"),
+ // -- margin-inline shorthand --
+ margin_inline!("lp-mi-ltr-1", "margin-inline", "ltr", "margin-inline", "20px 30px"),
+ margin_inline!("lp-mi-ltr-2", "margin-inline", "ltr", "margin-inline", "15px 45px"),
+ margin_inline!("lp-mi-ltr-3", "margin-inline", "ltr", "margin-inline", "7px 12px"),
+ margin_inline!("lp-mi-rtl-1", "margin-inline", "rtl", "margin-inline", "20px 30px"),
+ margin_inline!("lp-mi-rtl-2", "margin-inline", "rtl", "margin-inline", "15px 45px"),
+ margin_inline!("lp-mi-rtl-3", "margin-inline", "rtl", "margin-inline", "7px 12px"),
+ // -- margin-block shorthand --
+ margin_block!("lp-mb-ltr-1", "margin-block", "ltr", "margin-block", "15px 25px"),
+ margin_block!("lp-mb-ltr-2", "margin-block", "ltr", "margin-block", "30px 5px"),
+ margin_block!("lp-mb-ltr-3", "margin-block", "ltr", "margin-block", "10px 18px"),
+ margin_block!("lp-mb-rtl-1", "margin-block", "rtl", "margin-block", "15px 25px"),
+ margin_block!("lp-mb-rtl-2", "margin-block", "rtl", "margin-block", "30px 5px"),
+ margin_block!("lp-mb-rtl-3", "margin-block", "rtl", "margin-block", "10px 18px"),
+ // -- padding-inline shorthand --
+ padding_inline!("lp-pi-ltr-1", "padding-inline", "ltr", "padding-inline", "15px 25px"),
+ padding_inline!("lp-pi-ltr-2", "padding-inline", "ltr", "padding-inline", "30px 5px"),
+ padding_inline!("lp-pi-ltr-3", "padding-inline", "ltr", "padding-inline", "10px 18px"),
+ padding_inline!("lp-pi-rtl-1", "padding-inline", "rtl", "padding-inline", "15px 25px"),
+ padding_inline!("lp-pi-rtl-2", "padding-inline", "rtl", "padding-inline", "30px 5px"),
+ padding_inline!("lp-pi-rtl-3", "padding-inline", "rtl", "padding-inline", "10px 18px"),
+ // -- padding-block shorthand --
+ padding_block!("lp-pb-ltr-1", "padding-block", "ltr", "padding-block", "12px 18px"),
+ padding_block!("lp-pb-ltr-2", "padding-block", "ltr", "padding-block", "8px 22px"),
+ padding_block!("lp-pb-ltr-3", "padding-block", "ltr", "padding-block", "5px 14px"),
+ padding_block!("lp-pb-rtl-1", "padding-block", "rtl", "padding-block", "12px 18px"),
+ padding_block!("lp-pb-rtl-2", "padding-block", "rtl", "padding-block", "8px 22px"),
+ padding_block!("lp-pb-rtl-3", "padding-block", "rtl", "padding-block", "5px 14px"),
+ // -- inset-inline shorthand --
+ inset_inline!("lp-ii-ltr-1", "inset-inline", "ltr", "inset-inline", "20px 30px"),
+ inset_inline!("lp-ii-ltr-2", "inset-inline", "ltr", "inset-inline", "15px 45px"),
+ inset_inline!("lp-ii-ltr-3", "inset-inline", "ltr", "inset-inline", "7px 12px"),
+ inset_inline!("lp-ii-rtl-1", "inset-inline", "rtl", "inset-inline", "20px 30px"),
+ inset_inline!("lp-ii-rtl-2", "inset-inline", "rtl", "inset-inline", "15px 45px"),
+ inset_inline!("lp-ii-rtl-3", "inset-inline", "rtl", "inset-inline", "7px 12px"),
+ // -- inset-block shorthand --
+ inset_block!("lp-ib-ltr-1", "inset-block", "ltr", "inset-block", "10px 20px"),
+ inset_block!("lp-ib-ltr-2", "inset-block", "ltr", "inset-block", "25px 5px"),
+ inset_block!("lp-ib-ltr-3", "inset-block", "ltr", "inset-block", "8px 16px"),
+ inset_block!("lp-ib-rtl-1", "inset-block", "rtl", "inset-block", "10px 20px"),
+ inset_block!("lp-ib-rtl-2", "inset-block", "rtl", "inset-block", "25px 5px"),
+ inset_block!("lp-ib-rtl-3", "inset-block", "rtl", "inset-block", "8px 16px"),
+ // -- mixed logical and physical declarations for the same side --
+ lp!(
+ "lp-mx-ltr-1",
+ "mixed",
+ "ltr",
+ "#p { width: 300px; height: 100px; padding: 1px; }\n#a { margin-left: 10px; margin-inline-start: 30px; width: 100px; height: 30px; }"
+ ),
+ lp!(
+ "lp-mx-ltr-2",
+ "mixed",
+ "ltr",
+ "#p { width: 300px; height: 100px; padding: 1px; }\n#a { margin-inline-start: 30px; margin-left: 10px; width: 100px; height: 30px; }"
+ ),
+ lp!(
+ "lp-mx-ltr-3",
+ "mixed",
+ "ltr",
+ "#p { width: 300px; height: 100px; padding: 1px; }\n#a { width: 100px; inline-size: 150px; height: 30px; }"
+ ),
+ lp!(
+ "lp-mx-rtl-1",
+ "mixed",
+ "rtl",
+ "#p { width: 300px; height: 100px; padding: 1px; }\n#a { margin-right: 10px; margin-inline-start: 30px; width: 100px; height: 30px; }"
+ ),
+ lp!(
+ "lp-mx-rtl-2",
+ "mixed",
+ "rtl",
+ "#p { width: 300px; height: 100px; padding: 1px; }\n#a { margin-inline-start: 30px; margin-right: 10px; width: 100px; height: 30px; }"
+ ),
+ lp!(
+ "lp-mx-rtl-3",
+ "mixed",
+ "rtl",
+ "#p { width: 300px; height: 100px; padding: 1px; }\n#a { inline-size: 150px; width: 100px; height: 30px; }"
+ ),
+];
+
+/// The harness's fixed viewport (`docs/dump-format.md`), a literal here the same way
+/// `fragment_check.rs`, `custom_properties_check.rs` and `box_check.rs` each keep their
+/// own.
+const VIEWPORT_WIDTH: f64 = 800.0;
+const VIEWPORT_HEIGHT: f64 = 600.0;
+
+/// Fewer comparisons than this and the run fails, so an oracle that silently produced
+/// nothing cannot pass.
+const MINIMUM_COMPARED: usize = 30;
+
+/// `fragment_check.rs` `engine_dump` copied rather than shared: two binaries in the same
+/// crate cannot import each other, and the spec's `touches` is this file alone.
+fn engine_dump(html: &str) -> Dump {
+ let document = Document::parse(html);
+ let elements = document.elements_in_document_order();
+ let tree = BoxTree::build(&document);
+ let root_fragment = fragment::layout(&document, &tree, (VIEWPORT_WIDTH, VIEWPORT_HEIGHT));
+
+ let mut rects: HashMap = HashMap::new();
+ root_fragment.walk_absolute(0.0, 0.0, &mut |node, x, y, w, h| {
+ rects.insert(node, Rect { x, y, w, h });
+ });
+
+ let mut index_of: HashMap = HashMap::new();
+ for (i, &el) in elements.iter().enumerate() {
+ index_of.insert(el, i);
+ }
+
+ let nodes = elements
+ .iter()
+ .enumerate()
+ .map(|(index, el)| {
+ let parent = el.dom_parent().map(|p| index_of[&p] as i64).unwrap_or(-1);
+ let mut depth = 0u32;
+ let mut cur = *el;
+ while let Some(p) = cur.dom_parent() {
+ depth += 1;
+ cur = p;
+ }
+ // No box (`display: none`, or pruned with it) means no fragment and an empty
+ // `rects` list, zeroed the way a browser zeroes `bounding`.
+ let rect = rects.get(el).copied();
+ DumpNode {
+ index,
+ parent,
+ tag: el.tag(),
+ depth,
+ bounding: rect.unwrap_or(Rect { x: 0.0, y: 0.0, w: 0.0, h: 0.0 }),
+ rects: rect.map(|r| vec![r]).unwrap_or_default(),
+ scroll: None,
+ style: Default::default(),
+ }
+ })
+ .collect();
+
+ Dump {
+ engine: "nev-layout".to_string(),
+ engine_version: env!("CARGO_PKG_VERSION").to_string(),
+ viewport: Viewport { width: 800, height: 600 },
+ overflowed: false,
+ nodes,
+ }
+}
+
+/// Passed/compared for one group within one direction.
+#[derive(Debug, Default, Clone, Copy)]
+struct CategoryTally {
+ passed: usize,
+ compared: usize,
+}
+
+/// The direction a case is measured under. Every document names it on `html`, so the
+/// literal text is the source of truth rather than a second field beside the document.
+fn direction_of(html: &str) -> &'static str {
+ if html.contains("direction: rtl") {
+ "rtl"
+ } else {
+ "ltr"
+ }
+}
+
+/// Why one case was dropped before the engine was compared against it: either a browser
+/// failed to dump, or the browsers that did dump disagree with each other. Both make the
+/// case unusable as a reference, and neither is a statement about the engine.
+fn browser_verdict(
+ engine_names: &[String],
+ dumps: &HashMap,
+ errors: &HashMap,
+) -> Result<(), String> {
+ let mut failed: Vec = errors.iter().map(|(engine, msg)| format!("{engine}: {msg}")).collect();
+ failed.sort();
+ for engine in engine_names {
+ if !dumps.contains_key(engine) && !errors.contains_key(engine) {
+ failed.push(format!("{engine}: no dump returned"));
+ }
+ }
+ if !failed.is_empty() {
+ return Err(format!("browser failed to dump ({})", failed.join("; ")));
+ }
+ if dumps.is_empty() {
+ return Err("no browser dump available".to_string());
+ }
+
+ // Every pair, not only neighbours: agreement within a tolerance is not transitive, so
+ // chromium and webkit can each agree with firefox and still disagree with each other.
+ let mut names: Vec<&String> = dumps.keys().collect();
+ names.sort();
+ for (i, left) in names.iter().enumerate() {
+ for right in &names[i + 1..] {
+ let outcome = compare(&dumps[left.as_str()], &dumps[right.as_str()]);
+ if !outcome.passed() {
+ return Err(format!("browsers disagree ({left} vs {right}: {})", outcome.report()));
+ }
+ }
+ }
+ Ok(())
+}
+
+fn main() -> Result<()> {
+ let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
+ .parent()
+ .and_then(|p| p.parent())
+ .expect("crate sits two levels below the repository root")
+ .to_path_buf();
+
+ let mut oracle = Oracle::start(&repo_root, None, None)?;
+ let engine_names = oracle.engines.clone();
+ println!("engines: {}", engine_names.join(", "));
+ println!("cases written: {}", CASES.len());
+
+ let mut compared = 0usize;
+ let mut passed = 0usize;
+ let mut dropped: Vec<(&str, String)> = Vec::new();
+ let mut failures: Vec<(&str, String)> = Vec::new();
+ let mut per_group: BTreeMap<(&str, &str), CategoryTally> = BTreeMap::new();
+
+ for &(id, category, html) in CASES {
+ let direction = direction_of(html);
+
+ let (browser_dumps, errors) = oracle.dump(html)?;
+ if let Err(reason) = browser_verdict(&engine_names, &browser_dumps, &errors) {
+ dropped.push((id, reason));
+ continue;
+ }
+
+ let engine = engine_dump(html);
+ // The browsers already agree with each other, but tolerance is not transitive, so
+ // the engine is held against every dump that counted rather than one reference.
+ let mut disagreements: Vec = Vec::new();
+ for (name, dump) in &browser_dumps {
+ let outcome = compare(&engine, dump);
+ if !outcome.passed() {
+ disagreements.push(format!("{name}: {}", outcome.report()));
+ }
+ }
+ disagreements.sort();
+
+ compared += 1;
+ let tally = per_group.entry((category, direction)).or_default();
+ tally.compared += 1;
+ if disagreements.is_empty() {
+ passed += 1;
+ tally.passed += 1;
+ } else {
+ failures.push((id, disagreements.join(" | ")));
+ }
+ }
+
+ println!("\n== summary ==");
+ println!("dropped {}, compared {compared}, passed {passed}", dropped.len());
+
+ println!("\n== per-group and direction ==");
+ for category in CATEGORIES {
+ for direction in ["ltr", "rtl"] {
+ let tally = per_group.get(&(category, direction)).copied().unwrap_or_default();
+ let written = CASES
+ .iter()
+ .filter(|(_, c, html)| c == category && direction_of(html) == direction)
+ .count();
+ println!("{category}/{direction} {}/{} passed, {written} written", tally.passed, tally.compared);
+ }
+ }
+
+ println!("\n== dropped ==");
+ if dropped.is_empty() {
+ println!("none");
+ } else {
+ for (id, reason) in &dropped {
+ println!("{id}: {reason}");
+ }
+ }
+
+ println!("\n== failures ==");
+ if failures.is_empty() {
+ println!("none");
+ } else {
+ for (id, reason) in &failures {
+ println!("{id}: {reason}");
+ }
+ }
+
+ oracle.shutdown()?;
+
+ // A case disagreeing with the engine is the result, not a failure of the run; too few
+ // comparisons to stand on means the run measured nothing.
+ if compared < MINIMUM_COMPARED {
+ bail!("only {compared} case(s) compared, fewer than the {MINIMUM_COMPARED} this check needs to mean anything");
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use dom::computed::{self, LengthPercentageOrAuto};
+ use std::collections::BTreeSet;
+
+ /// The element with `id`, found the same way the check binaries correlate nodes:
+ /// walking `elements_in_document_order` and reading the parsed attribute back off
+ /// `Document::nodes()` (`crates/dom/src/node.rs` `ElementData::attr`).
+ fn find(document: &Document, id: &str) -> NodeId {
+ document
+ .elements_in_document_order()
+ .into_iter()
+ .find(|&e| document.nodes()[e.index()].as_element().unwrap().attr("id") == Some(id))
+ .unwrap_or_else(|| panic!("no element with id={id:?}"))
+ }
+
+ #[test]
+ fn ids_are_unique() {
+ let mut seen: BTreeSet<&str> = BTreeSet::new();
+ for &(id, _, _) in CASES {
+ assert!(seen.insert(id), "duplicate case id {id}");
+ }
+ }
+
+ #[test]
+ fn categories_are_non_empty_and_known() {
+ for &(id, category, _) in CASES {
+ assert!(!category.is_empty(), "case {id} has an empty category");
+ assert!(CATEGORIES.contains(&category), "case {id} names unknown category {category}");
+ }
+ }
+
+ #[test]
+ fn every_category_has_three_cases_per_direction() {
+ for category in CATEGORIES {
+ for direction in ["ltr", "rtl"] {
+ let count = CASES
+ .iter()
+ .filter(|(_, c, html)| c == category && direction_of(html) == direction)
+ .count();
+ assert!(count >= 3, "category {category} has only {count} {direction} case(s)");
+ }
+ }
+ }
+
+ #[test]
+ fn the_corpus_is_at_least_sixty_cases() {
+ assert!(CASES.len() >= 60, "only {} case(s) written", CASES.len());
+ }
+
+ #[test]
+ fn every_document_opens_with_the_reset() {
+ for &(id, _, html) in CASES {
+ assert!(html.contains(RESET), "case {id} is missing the reset");
+ }
+ }
+
+ #[test]
+ fn every_document_parses_and_yields_a_box_tree() {
+ for &(id, _, html) in CASES {
+ let document = Document::parse(html);
+ let elements = document.elements_in_document_order();
+ assert!(!elements.is_empty(), "case {id} parsed to no elements");
+ let dump = engine_dump(html);
+ assert_eq!(dump.nodes.len(), elements.len(), "case {id} dumped a different element count");
+ assert!(
+ dump.nodes.iter().any(|node| !node.rects.is_empty()),
+ "case {id} produced no fragment, so no box tree"
+ );
+ }
+ }
+
+ /// The accessor itself swaps the physical side for `margin-inline-start`: `7px` is
+ /// `margin-left` under `ltr` and `margin-right` under `rtl`.
+ #[test]
+ fn logical_inline_start_margin_swaps_sides_under_rtl() {
+ let ltr =
+ Document::parse("");
+ let a = find(<r, "a");
+ assert_eq!(computed::margin_left(<r, a), LengthPercentageOrAuto::Length(7.0));
+ assert_eq!(computed::margin_right(<r, a), LengthPercentageOrAuto::Length(0.0));
+
+ let rtl = Document::parse(
+ "",
+ );
+ let a = find(&rtl, "a");
+ assert_eq!(computed::margin_left(&rtl, a), LengthPercentageOrAuto::Length(0.0));
+ assert_eq!(computed::margin_right(&rtl, a), LengthPercentageOrAuto::Length(7.0));
+ }
+
+ /// The run's record, with the `//!` comment markers stripped. Only the one fenced
+ /// block in this file holds it, so the reader toggles on a fence line.
+ fn recorded_block() -> String {
+ let source = include_str!("logical_properties_check.rs");
+ let mut block = String::new();
+ let mut inside = false;
+ for line in source.lines() {
+ let line = line.strip_prefix("//!").unwrap_or(line);
+ let line = line.strip_prefix(' ').unwrap_or(line);
+ if line.starts_with("```") {
+ inside = !inside;
+ continue;
+ }
+ if inside {
+ block.push_str(line);
+ block.push('\n');
+ }
+ }
+ block
+ }
+
+ /// `dropped N, compared N, passed N` from the summary section.
+ fn recorded_summary(block: &str) -> (usize, usize, usize) {
+ for line in block.lines() {
+ let line = line.trim();
+ if let Some(rest) = line.strip_prefix("dropped ") {
+ let mut parts = rest.split_whitespace();
+ let dropped: usize = parts.next().unwrap().trim_end_matches(',').parse().unwrap();
+ let compared: usize = parts.nth(1).unwrap().trim_end_matches(',').parse().unwrap();
+ let passed: usize = parts.nth(1).unwrap().parse().unwrap();
+ return (dropped, compared, passed);
+ }
+ }
+ panic!("the module doc records no summary line");
+ }
+
+ /// `(group, direction, passed, compared, written)` from the per-group table.
+ fn recorded_rows(block: &str) -> Vec<(String, String, usize, usize, usize)> {
+ let mut rows = Vec::new();
+ let mut inside = false;
+ for line in block.lines() {
+ let line = line.trim();
+ if line == "== per-group and direction ==" {
+ inside = true;
+ continue;
+ }
+ if inside && line.starts_with("==") {
+ break;
+ }
+ if !inside || line.is_empty() {
+ continue;
+ }
+ let mut parts = line.split_whitespace();
+ let Some(head) = parts.next() else { continue };
+ let Some((group, direction)) = head.rsplit_once('/') else { continue };
+ if direction != "ltr" && direction != "rtl" {
+ continue;
+ }
+ let Some(fraction) = parts.next() else { continue };
+ let Some((p, c)) = fraction.split_once('/') else { continue };
+ let passed: usize = p.parse().unwrap();
+ let compared: usize = c.parse().unwrap();
+ parts.next(); // "passed,"
+ let written: usize = parts.next().unwrap().parse().unwrap();
+ rows.push((group.to_string(), direction.to_string(), passed, compared, written));
+ }
+ rows
+ }
+
+ /// The ids under the `== failures ==` section, each read as the token before its
+ /// cause.
+ fn recorded_failures(block: &str) -> Vec {
+ let mut ids = Vec::new();
+ let mut inside = false;
+ for line in block.lines() {
+ let line = line.trim();
+ if line == "== failures ==" {
+ inside = true;
+ continue;
+ }
+ if !inside || line.is_empty() || line == "none" {
+ continue;
+ }
+ if let Some(id) = line.split([':', ' ']).next() {
+ if !id.is_empty() {
+ ids.push(id.to_string());
+ }
+ }
+ }
+ ids
+ }
+
+ /// The recorded table must add up to the summary, and every failing id must be a real
+ /// case. This catches a record that was typed or recomputed by hand.
+ #[test]
+ fn recorded_result_adds_up() {
+ let block = recorded_block();
+ let (dropped, compared, passed) = recorded_summary(&block);
+ let rows = recorded_rows(&block);
+
+ assert_eq!(rows.len(), CATEGORIES.len() * 2, "the per-group table has the wrong number of rows");
+ let sum_passed: usize = rows.iter().map(|r| r.2).sum();
+ let sum_compared: usize = rows.iter().map(|r| r.3).sum();
+ let sum_written: usize = rows.iter().map(|r| r.4).sum();
+ assert_eq!(sum_passed, passed, "per-group passed cells disagree with the summary");
+ assert_eq!(sum_compared, compared, "per-group compared cells disagree with the summary");
+ assert_eq!(sum_written, CASES.len(), "per-group written cells disagree with CASES");
+ assert_eq!(dropped + compared, CASES.len(), "dropped plus compared disagrees with CASES");
+
+ let known: BTreeSet<&str> = CASES.iter().map(|(id, _, _)| *id).collect();
+ let failures = recorded_failures(&block);
+ assert_eq!(compared - passed, failures.len(), "compared minus passed disagrees with the listed failing ids");
+ for id in &failures {
+ assert!(known.contains(id.as_str()), "recorded failing id {id} is not in CASES");
+ }
+ }
+}
+