Skip to main content

max / makenotwork

Add the wave-2 parity harness and the per-screen conversion switch Both are prerequisites for phase 3 that nobody had costed. The conversion's whole safety argument is that a screen is ported, asserted equivalent to what Askama rendered, and kept behind a flag until somebody flips it. Neither the assertion nor the flag existed, so nothing could convert. The harness. Equivalence is a normalized token stream, not bytes and not eyeballs. Byte-identical is the wrong bar: attribute order carries no meaning, whitespace between tags collapses, and a description emits neither in the order a hand-written template happened to, so a strict test would fail on every conversion and get muted. "Looks the same" is the other wrong bar because nothing can assert it. So both sides are tokenized with html5ever, already in the graph as ammonia's parser, and reduced to what a browser acts on: attributes sorted, class compared as a set, whitespace collapsed except inside pre and textarea where it is the content, comments dropped. End tags are kept deliberately, since nesting is most of what a page is and dropping them would let a converted screen close a region in the wrong place and pass. Differences that are the POINT of the conversion are named per test rather than ignored globally: `.ignoring_attr("data-action")` at a call site is the record of what that screen gave up. There is no blanket ignore option, and the failure message says so, along with both sides around the first difference. The switch is runtime and per screen, not a cargo feature and not global. A global flag means the first screen converted cannot ship until the last one is, which turns a hundred-screen conversion into one release. QUASI_SCREENS is a comma-separated list, unset means every screen still serves Askama, and `*` is for local work. Nothing validates a name against a registry: a typo disables a screen rather than enabling a different one, which is the safe direction, and a registry would have to track a list that is still moving. main.rs warns at startup when it is set, because a host converted by a leftover env var otherwise just looks like one page behaving oddly. 22 tests, half of them asserting the harness still FAILS on what it should: a changed attribute value, a dropped class, changed text, different nesting, whitespace inside pre, a missing doctype. A parity harness that quietly passes is worse than none.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 03:03 UTC
Signed with PGP, not checked
Commit: 2899b1d1a2c9bebc7ccf6ea1d9d25a3cc12603e5
Parent: d1da5bf
10 files changed, +619 insertions, -8 deletions
@@ -49,3 +49,11 @@
49 49 # anywhere else and the seed builds the catalog without a buyer. The capture
50 50 # script reads the same value as CAPTURE_BUYER_PASSWORD. See src/seed/buyer.rs.
51 51 # TESTNOT_BUYER_PASSWORD=
52 +
53 + # Optional: the wave-2 conversion switch. A comma-separated list of screens that
54 + # serve from the quasi description layer instead of Askama; `*` converts
55 + # everything, which is a local-work setting. Unset means every screen serves
56 + # Askama, which is where every deployment stays until a screen's parity test is
57 + # green and Max flips it. A name that matches nothing is inert, so a typo
58 + # disables a screen rather than enabling the wrong one. See wiki look-wave-2.
59 + # QUASI_SCREENS=
@@ -5231,6 +5231,7 @@
5231 5231 "governor",
5232 5232 "hex",
5233 5233 "hmac 0.13.0",
5234 + "html5ever",
5234 5235 "http-body-util",
5235 5236 "infer",
5236 5237 "jsonwebtoken",
@@ -10665,14 +10666,6 @@
10665 10666 name = "quasi-webview"
10666 10667 version = "0.1.0"
10667 10668
10668 - [[patch.unused]]
10669 - name = "kberg"
10670 - version = "0.1.0"
10671 -
10672 - [[patch.unused]]
10673 - name = "painhours"
10674 - version = "0.1.0"
10675 -
10676 10669 [[patch.unused]]
10677 10670 name = "synckit-client"
10678 10671 version = "0.8.0"
@@ -10680,3 +10673,11 @@
10680 10673 [[patch.unused]]
10681 10674 name = "synckit-config"
10682 10675 version = "0.2.0"
10676 +
10677 + [[patch.unused]]
10678 + name = "kberg"
10679 + version = "0.1.0"
10680 +
10681 + [[patch.unused]]
10682 + name = "painhours"
10683 + version = "0.1.0"
@@ -234,6 +234,10 @@
234 234 proptest = "1"
235 235 wiremock = "0.6"
236 236 pom-contract = { path = "../shared/pom-contract" }
237 + # The parity harness's normalizer. Already in the graph as ammonia's parser, so
238 + # this pins the same build rather than adding one; a dev-dependency because
239 + # nothing in the served binary parses HTML.
240 + html5ever = "0.39"
237 241
238 242 [profile.release]
239 243 # Drop the symbol table from the shipped binary. Release builds already carry no
@@ -1030,6 +1030,7 @@
1030 1030 access_gate: crate::config::AccessGate::Open,
1031 1031 sso: None,
1032 1032 rate_limits: crate::constants::RateLimits::production(),
1033 + quasi_screens: crate::config::QuasiScreens::default(),
1033 1034 build: BuildConfig {
1034 1035 trigger_token: None,
1035 1036 host_linux: None,
@@ -1114,6 +1115,7 @@
1114 1115 access_gate: crate::config::AccessGate::Open,
1115 1116 sso: None,
1116 1117 rate_limits: crate::constants::RateLimits::production(),
1118 + quasi_screens: crate::config::QuasiScreens::default(),
1117 1119 build: BuildConfig {
1118 1120 trigger_token: None,
1119 1121 host_linux: None,
@@ -68,6 +68,68 @@
68 68 /// Rate-limit profile the router is built with. Production everywhere that
69 69 /// is not a test; see [`crate::constants::RateLimits`].
70 70 pub rate_limits: crate::constants::RateLimits,
71 + /// Which screens serve from the quasi description layer instead of Askama
72 + /// (`QUASI_SCREENS`). See [`QuasiScreens`].
73 + pub quasi_screens: QuasiScreens,
74 + }
75 +
76 + /// The wave-2 conversion switch, one screen at a time.
77 + ///
78 + /// Per screen and not global: a global flag means the first screen converted
79 + /// cannot ship until the last one is, which turns a hundred-screen conversion
80 + /// into one release. Per screen, each conversion ships the moment its parity
81 + /// test is green and reverts by editing an env var rather than by a deploy.
82 + ///
83 + /// Read from `QUASI_SCREENS` as a comma-separated list of screen names. Unset
84 + /// means empty means every screen still serves Askama, which is the state this
85 + /// is meant to be in everywhere until Max flips one:
86 + ///
87 + /// ```text
88 + /// QUASI_SCREENS=user_account,item_pricing
89 + /// QUASI_SCREENS=* # everything, for local work
90 + /// ```
91 + ///
92 + /// The names are the conversion's own, matching the screen inventory in wiki
93 + /// `look-wave-2`. Nothing validates them against a registry: a typo disables a
94 + /// screen rather than enabling the wrong one, which is the safe direction, and
95 + /// a registry would have to be kept in step with a list that is still moving.
96 + #[derive(Clone, Debug, Default, PartialEq, Eq)]
97 + pub struct QuasiScreens {
98 + all: bool,
99 + names: std::collections::HashSet<String>,
100 + }
101 +
102 + impl QuasiScreens {
103 + /// Parse the comma-separated form. Blanks and stray whitespace are ignored.
104 + #[must_use]
105 + pub fn parse(raw: &str) -> Self {
106 + let mut all = false;
107 + let mut names = std::collections::HashSet::new();
108 + for part in raw.split(',') {
109 + match part.trim() {
110 + "" => {}
111 + "*" => all = true,
112 + name => {
113 + names.insert(name.to_owned());
114 + }
115 + }
116 + }
117 + Self { all, names }
118 + }
119 +
120 + /// Whether this screen serves from the description layer.
121 + #[must_use]
122 + pub fn enabled(&self, screen: &str) -> bool {
123 + self.all || self.names.contains(screen)
124 + }
125 +
126 + /// Whether any screen is converted. For the startup log line: a server
127 + /// serving one screen differently from every other deployment is worth
128 + /// saying out loud rather than discovering.
129 + #[must_use]
130 + pub fn any(&self) -> bool {
131 + self.all || !self.names.is_empty()
132 + }
71 133 }
72 134
73 135 /// Native build pipeline configuration (`BUILD_*`, `GIT_*`).
@@ -495,6 +557,7 @@
495 557 access_gate,
496 558 sso,
497 559 rate_limits: crate::constants::RateLimits::production(),
560 + quasi_screens: QuasiScreens::parse(&std::env::var("QUASI_SCREENS").unwrap_or_default()),
498 561 build: BuildConfig {
499 562 trigger_token: build_trigger_token,
500 563 host_linux: build_host_linux,
@@ -1055,6 +1118,7 @@
1055 1118 access_gate: AccessGate::Open,
1056 1119 sso: None,
1057 1120 rate_limits: crate::constants::RateLimits::production(),
1121 + quasi_screens: QuasiScreens::default(),
1058 1122 build: BuildConfig {
1059 1123 trigger_token: None,
1060 1124 host_linux: None,
@@ -132,6 +132,17 @@
132 132 let config = Config::from_env().expect("Failed to load configuration");
133 133 tracing::info!("Configuration loaded");
134 134
135 + // A box serving some screens from the description layer and the rest from
136 + // Askama is worth saying out loud. The failure this guards against is a
137 + // QUASI_SCREENS left set on a host nobody meant to convert, which otherwise
138 + // looks exactly like the site behaving oddly for one page.
139 + if config.quasi_screens.any() {
140 + tracing::warn!(
141 + screens = %std::env::var("QUASI_SCREENS").unwrap_or_default(),
142 + "QUASI_SCREENS is set: these screens serve from the description layer, not Askama"
143 + );
144 + }
145 +
135 146 // Create database connection pool with health checks and lifecycle limits.
136 147 // - test_before_acquire: validates connections before use (catches stale/broken conns)
137 148 // - max_lifetime: rotates connections to prevent long-lived session issues
@@ -5,6 +5,7 @@
5 5 pub(crate) mod email;
6 6 pub(crate) mod faults;
7 7 pub(crate) mod gitfixture;
8 + pub(crate) mod parity;
8 9 pub(crate) mod seed;
9 10 pub(crate) mod storage;
10 11 pub(crate) mod stripe;
@@ -126,6 +127,10 @@
126 127 /// state every pre-existing test was written against. Set it to exercise
127 128 /// the surfaces that only exist while the window is open.
128 129 pub founder_window_open: bool,
130 + /// Which screens serve from the description layer. Empty by default, so a
131 + /// test asserting the served HTML keeps asserting the Askama one until a
132 + /// conversion's own test opts in.
133 + pub quasi_screens: makenotwork::config::QuasiScreens,
129 134 }
130 135
131 136 /// Full test harness: isolated database, in-process app, cookie-aware client.
@@ -417,6 +422,9 @@
417 422 rate_limits: opts
418 423 .rate_limits
419 424 .unwrap_or_else(makenotwork::constants::RateLimits::relaxed),
425 + // Askama everywhere unless a test says otherwise, matching every
426 + // deployment until wave 2 flips a screen.
427 + quasi_screens: opts.quasi_screens.clone(),
420 428 build: BuildConfig {
421 429 trigger_token: opts.build_trigger_token,
422 430 host_linux: None,
@@ -71,6 +71,7 @@
71 71 // The load runner drives thousands of requests from one IP, so the
72 72 // production limiter would measure the limiter rather than the server.
73 73 rate_limits: makenotwork::constants::RateLimits::relaxed(),
74 + quasi_screens: makenotwork::config::QuasiScreens::default(),
74 75 build: BuildConfig {
75 76 trigger_token: None,
76 77 host_linux: None,
@@ -1,0 +1,292 @@
1 + //! The wave-2 parity harness: is the converted screen the same page?
2 + //!
3 + //! Every phase-3 conversion asserts that the screen it just described renders
4 + //! what Askama rendered. The whole safety argument for converting a hundred
5 + //! screens rests on this file, so what it does and does not check is worth
6 + //! stating rather than inferring.
7 + //!
8 + //! # What equivalence means here
9 + //!
10 + //! Byte-identical HTML is the wrong bar. Attribute order carries no meaning,
11 + //! whitespace between tags collapses, and a description layer emits neither in
12 + //! the order a hand-written template happened to. A test that fails on those
13 + //! fails on every conversion and gets muted, which is worse than no test.
14 + //!
15 + //! "Looks the same" is the other wrong bar, because nothing can assert it.
16 + //!
17 + //! So: a normalized token stream. Both sides are tokenized with the same parser
18 + //! the sanitizer already uses, then reduced to what a browser would act on.
19 + //!
20 + //! - Attributes are sorted by name, so order stops being a difference.
21 + //! - `class` is compared as a SET of classes, not a string, for the same reason.
22 + //! - Runs of whitespace in text collapse to one space, and text that is
23 + //! entirely whitespace is dropped. NOT inside `pre` or `textarea`, where
24 + //! whitespace is the content.
25 + //! - Comments are dropped. A comment changes no pixel.
26 + //! - End tags are kept. Nesting is most of what a page IS, and dropping them
27 + //! would let a converted screen close a region in the wrong place and pass.
28 + //!
29 + //! Everything else is a difference and fails.
30 + //!
31 + //! # What it deliberately does not do
32 + //!
33 + //! It does not parse into a tree, so it does not repair malformed markup the
34 + //! way a browser would. Two documents that a browser would reconcile to the
35 + //! same DOM but that tokenize differently will fail here. That is the strict
36 + //! direction and it is the right one for a conversion: if the two sides differ
37 + //! enough that only the parser's error recovery makes them agree, the
38 + //! conversion should say so.
39 + //!
40 + //! # The allowlist
41 + //!
42 + //! Some differences are the POINT of the conversion, and those are named per
43 + //! test rather than blanket-ignored, so that each one is a claim somebody
44 + //! wrote down. Retiring the private `data-action` vocabulary means the
45 + //! converted side does not emit it; that is progress, not a regression, and it
46 + //! is spelled `.ignoring_attr("data-action")` at the call site.
47 + //!
48 + //! A blanket "ignore anything that differs" option is deliberately absent.
49 +
50 + // `tests/harness/mod.rs` is compiled into every test binary, and only the
51 + // parity ones call this. Per-item allows the way `faults.rs` does it would be
52 + // one on nearly every item here.
53 + #![allow(dead_code)]
54 +
55 + use std::cell::RefCell;
56 + use std::collections::BTreeMap;
57 + use std::fmt::Write as _;
58 +
59 + use html5ever::tokenizer::{
60 + BufferQueue, Token, TokenSink, TokenSinkResult, Tokenizer, TokenizerOpts,
61 + };
62 +
63 + /// One normalized piece of a document.
64 + #[derive(Debug, Clone, PartialEq, Eq)]
65 + pub(crate) enum Piece {
66 + /// An open tag: name, then attributes sorted by name. `class` values are
67 + /// sorted within the value so the set is what compares.
68 + Open(String, BTreeMap<String, String>),
69 + /// A close tag.
70 + Close(String),
71 + /// Text, whitespace-collapsed unless it came from a `pre`/`textarea`.
72 + Text(String),
73 + /// A doctype, lowercased. Present or absent is a real difference: it
74 + /// decides standards mode.
75 + Doctype(String),
76 + }
77 +
78 + impl Piece {
79 + /// A one-line rendering for the failure message.
80 + fn show(&self) -> String {
81 + match self {
82 + Self::Open(name, attrs) => {
83 + let attrs: Vec<String> = attrs
84 + .iter()
85 + .map(|(k, v)| {
86 + if v.is_empty() {
87 + k.clone()
88 + } else {
89 + format!("{k}=\"{v}\"")
90 + }
91 + })
92 + .collect();
93 + if attrs.is_empty() {
94 + format!("<{name}>")
95 + } else {
96 + format!("<{name} {}>", attrs.join(" "))
97 + }
98 + }
99 + Self::Close(name) => format!("</{name}>"),
100 + Self::Text(t) => format!("{t:?}"),
101 + Self::Doctype(d) => format!("<!doctype {d}>"),
102 + }
103 + }
104 + }
105 +
106 + /// How two renderings of one screen are allowed to differ.
107 + #[derive(Debug, Clone, Default)]
108 + pub(crate) struct Parity {
109 + ignored_attrs: Vec<String>,
110 + ignored_classes: Vec<String>,
111 + }
112 +
113 + impl Parity {
114 + /// Strict: every difference fails.
115 + pub(crate) fn strict() -> Self {
116 + Self::default()
117 + }
118 +
119 + /// Drop an attribute from both sides before comparing.
120 + ///
121 + /// For the attributes the conversion exists to retire. Name each one; the
122 + /// list at a call site is the record of what that screen gave up.
123 + #[must_use]
124 + pub(crate) fn ignoring_attr(mut self, name: &str) -> Self {
125 + self.ignored_attrs.push(name.to_ascii_lowercase());
126 + self
127 + }
128 +
129 + /// Drop a class from both sides' `class` attributes before comparing.
130 + ///
131 + /// For the generated primitives a converted screen gains and a hand-written
132 + /// one never had.
133 + #[must_use]
134 + pub(crate) fn ignoring_class(mut self, name: &str) -> Self {
135 + self.ignored_classes.push(name.to_owned());
136 + self
137 + }
138 +
139 + /// Reduce a document to its comparable pieces.
140 + pub(crate) fn normalize(&self, html: &str) -> Vec<Piece> {
141 + let raw = tokenize(html);
142 + let mut out = Vec::with_capacity(raw.len());
143 + for piece in raw {
144 + match piece {
145 + Piece::Open(name, attrs) => {
146 + let attrs = attrs
147 + .into_iter()
148 + .filter(|(k, _)| !self.ignored_attrs.contains(k))
149 + .map(|(k, v)| {
150 + if k == "class" {
151 + let mut classes: Vec<&str> = v
152 + .split_ascii_whitespace()
153 + .filter(|c| !self.ignored_classes.iter().any(|i| i == c))
154 + .collect();
155 + classes.sort_unstable();
156 + (k, classes.join(" "))
157 + } else {
158 + (k, v)
159 + }
160 + })
161 + // A class attribute emptied by the allowlist is not the
162 + // same as one that was never there, but for our purpose
163 + // it is: both mean "no classes the test cares about".
164 + .filter(|(k, v)| !(k == "class" && v.is_empty()))
165 + .collect();
166 + out.push(Piece::Open(name, attrs));
167 + }
168 + other => out.push(other),
169 + }
170 + }
171 + out
172 + }
173 +
174 + /// Assert two renderings of one screen are equivalent.
175 + ///
176 + /// `askama` is what the server serves today and `quasi` is what the
177 + /// description emits; the argument order is the direction of the
178 + /// conversion, and it decides which side a difference is reported against.
179 + #[track_caller]
180 + pub(crate) fn assert(&self, screen: &str, askama: &str, quasi: &str) {
181 + let left = self.normalize(askama);
182 + let right = self.normalize(quasi);
183 + if left == right {
184 + return;
185 + }
186 +
187 + let at = left
188 + .iter()
189 + .zip(right.iter())
190 + .position(|(a, b)| a != b)
191 + .unwrap_or_else(|| left.len().min(right.len()));
192 +
193 + let mut msg = format!(
194 + "screen `{screen}` does not render the same page through quasi as through Askama\n\
195 + first difference at piece {at} of {} (Askama) / {} (quasi)\n",
196 + left.len(),
197 + right.len()
198 + );
199 + let from = at.saturating_sub(3);
200 + msg.push_str("\n context, Askama:\n");
201 + for (i, p) in left.iter().enumerate().skip(from).take(7) {
202 + let marker = if i == at { ">>" } else { " " };
203 + let _ = writeln!(msg, " {marker} {i:4} {}", p.show());
204 + }
205 + msg.push_str("\n context, quasi:\n");
206 + for (i, p) in right.iter().enumerate().skip(from).take(7) {
207 + let marker = if i == at { ">>" } else { " " };
208 + let _ = writeln!(msg, " {marker} {i:4} {}", p.show());
209 + }
210 + msg.push_str(
211 + "\nIf the difference is the point of the conversion, name it with \
212 + .ignoring_attr()/.ignoring_class() rather than widening the test.\n",
213 + );
214 + panic!("{msg}");
215 + }
216 + }
217 +
218 + /// The sink: normalization that does not depend on the allowlist.
219 + struct Sink {
220 + pieces: RefCell<Vec<Piece>>,
221 + /// Depth inside an element whose whitespace is content.
222 + literal: RefCell<u32>,
223 + }
224 +
225 + impl TokenSink for Sink {
226 + type Handle = ();
227 +
228 + fn process_token(&self, token: Token, _line: u64) -> TokenSinkResult<()> {
229 + match token {
230 + Token::DoctypeToken(d) => {
231 + let name = d.name.unwrap_or_default().to_ascii_lowercase();
232 + self.pieces.borrow_mut().push(Piece::Doctype(name));
233 + }
234 + Token::TagToken(tag) => {
235 + let name = tag.name.to_string();
236 + let literal = matches!(name.as_str(), "pre" | "textarea");
237 + match tag.kind {
238 + html5ever::tokenizer::TagKind::StartTag => {
239 + if literal {
240 + *self.literal.borrow_mut() += 1;
241 + }
242 + let mut attrs = BTreeMap::new();
243 + for attr in tag.attrs {
244 + attrs.insert(
245 + attr.name.local.to_string().to_ascii_lowercase(),
246 + attr.value.to_string(),
247 + );
248 + }
249 + self.pieces.borrow_mut().push(Piece::Open(name, attrs));
250 + }
251 + html5ever::tokenizer::TagKind::EndTag => {
252 + if literal {
253 + let mut depth = self.literal.borrow_mut();
254 + *depth = depth.saturating_sub(1);
255 + }
256 + self.pieces.borrow_mut().push(Piece::Close(name));
257 + }
258 + }
259 + }
260 + Token::CharacterTokens(text) => {
261 + let text = text.to_string();
262 + if *self.literal.borrow() > 0 {
263 + self.pieces.borrow_mut().push(Piece::Text(text));
264 + } else {
265 + let collapsed = text.split_whitespace().collect::<Vec<_>>().join(" ");
266 + if !collapsed.is_empty() {
267 + self.pieces.borrow_mut().push(Piece::Text(collapsed));
268 + }
269 + }
270 + }
271 + // Comments change no pixel. Parse errors are the tokenizer's
272 + // opinion about malformed input, and both sides get the same
273 + // treatment, so neither is a difference worth failing on.
274 + Token::CommentToken(_) | Token::ParseError(_) => {}
275 + Token::NullCharacterToken | Token::EOFToken => {}
276 + }
277 + TokenSinkResult::Continue
278 + }
279 + }
280 +
281 + fn tokenize(html: &str) -> Vec<Piece> {
282 + let sink = Sink {
283 + pieces: RefCell::new(Vec::new()),
284 + literal: RefCell::new(0),
285 + };
286 + let tok = Tokenizer::new(sink, TokenizerOpts::default());
287 + let input = BufferQueue::default();
288 + input.push_back(html5ever::tendril::StrTendril::from(html));
289 + let _ = tok.feed(&input);
290 + tok.end();
291 + tok.sink.pieces.take()
292 + }
@@ -1,0 +1,220 @@
1 + //! Tests for the parity harness, and for the per-screen conversion switch.
2 + //!
3 + //! The harness is what every phase-3 conversion trusts, so it is tested on both
4 + //! sides: that it ignores what it claims to ignore, and that it still fails on
5 + //! everything else. A parity harness that quietly passes is worse than none,
6 + //! because the conversion's whole safety argument is that this file said yes.
7 +
8 + // Just the one module, not `mod harness;`. These are unit tests for the
9 + // normalizer and the switch: neither serves a request, so pulling the database,
10 + // storage and Stripe harnesses in would cost a much slower build for nothing.
11 + // The conversions' own parity tests go through the full harness.
12 + #[path = "harness/parity.rs"]
13 + mod parity;
14 +
15 + use makenotwork::config::QuasiScreens;
16 + use parity::Parity;
17 +
18 + // --- what it forgives -------------------------------------------------------
19 +
20 + #[test]
21 + fn attribute_order_is_not_a_difference() {
22 + Parity::strict().assert(
23 + "t",
24 + r#"<div id="a" class="card" data-x="1"></div>"#,
25 + r#"<div data-x="1" id="a" class="card"></div>"#,
26 + );
27 + }
28 +
29 + #[test]
30 + fn class_order_is_not_a_difference() {
31 + // A description emits classes in whatever order it composes them; a
32 + // template emits them in the order somebody typed.
33 + Parity::strict().assert(
34 + "t",
35 + r#"<div class="card is-selected raised"></div>"#,
36 + r#"<div class="raised card is-selected"></div>"#,
37 + );
38 + }
39 +
40 + #[test]
41 + fn whitespace_between_tags_collapses() {
42 + Parity::strict().assert(
43 + "t",
44 + "<ul>\n <li>one</li>\n <li>two</li>\n</ul>",
45 + "<ul><li>one</li><li>two</li></ul>",
46 + );
47 + }
48 +
49 + #[test]
50 + fn runs_of_whitespace_inside_text_collapse_to_one_space() {
51 + Parity::strict().assert("t", "<p>one two\n\tthree</p>", "<p>one two three</p>");
52 + }
53 +
54 + #[test]
55 + fn comments_are_not_a_difference() {
56 + Parity::strict().assert(
57 + "t",
58 + "<div><!-- explains the next line --><span>x</span></div>",
59 + "<div><span>x</span></div>",
60 + );
61 + }
62 +
63 + // --- what it still catches --------------------------------------------------
64 +
65 + #[test]
66 + #[should_panic(expected = "does not render the same page")]
67 + fn a_changed_attribute_value_fails() {
68 + Parity::strict().assert("t", r#"<a href="/a">x</a>"#, r#"<a href="/b">x</a>"#);
69 + }
70 +
71 + #[test]
72 + #[should_panic(expected = "does not render the same page")]
73 + fn a_dropped_class_fails() {
74 + Parity::strict().assert(
75 + "t",
76 + r#"<div class="card raised"></div>"#,
77 + r#"<div class="card"></div>"#,
78 + );
79 + }
80 +
81 + #[test]
82 + #[should_panic(expected = "does not render the same page")]
83 + fn changed_text_fails() {
84 + Parity::strict().assert("t", "<p>Saved.</p>", "<p>Saved</p>");
85 + }
86 +
87 + #[test]
88 + #[should_panic(expected = "does not render the same page")]
89 + fn different_nesting_fails() {
90 + // The case dropping end tags would have missed: same tags, same text, and
91 + // the region closes in the wrong place.
92 + Parity::strict().assert(
93 + "t",
94 + "<div><section><p>x</p></section></div>",
95 + "<div><section></section><p>x</p></div>",
96 + );
97 + }
98 +
99 + #[test]
100 + #[should_panic(expected = "does not render the same page")]
101 + fn whitespace_inside_pre_is_content_and_still_compares() {
102 + Parity::strict().assert("t", "<pre>one two</pre>", "<pre>one two</pre>");
103 + }
104 +
105 + #[test]
106 + #[should_panic(expected = "does not render the same page")]
107 + fn a_missing_doctype_fails() {
108 + // Not pedantry: it is the difference between standards and quirks mode.
109 + Parity::strict().assert("t", "<!doctype html><html></html>", "<html></html>");
110 + }
111 +
112 + // --- the allowlist ----------------------------------------------------------
113 +
114 + #[test]
115 + fn a_named_attribute_can_be_forgiven() {
116 + // Retiring the private data-action vocabulary is progress, and it is named
117 + // per screen rather than ignored everywhere.
118 + Parity::strict().ignoring_attr("data-action").assert(
119 + "t",
120 + r#"<button data-action="toggleCart" data-arg="7">Buy</button>"#,
121 + r#"<button data-arg="7">Buy</button>"#,
122 + );
123 + }
124 +
125 + #[test]
126 + #[should_panic(expected = "does not render the same page")]
127 + fn forgiving_one_attribute_does_not_forgive_its_neighbours() {
128 + Parity::strict().ignoring_attr("data-action").assert(
129 + "t",
130 + r#"<button data-action="toggleCart" data-arg="7">Buy</button>"#,
131 + r#"<button data-action="toggleCart">Buy</button>"#,
132 + );
133 + }
134 +
135 + #[test]
136 + fn a_named_class_can_be_forgiven() {
137 + Parity::strict().ignoring_class("raised").assert(
138 + "t",
139 + r#"<div class="card"></div>"#,
140 + r#"<div class="card raised"></div>"#,
141 + );
142 + }
143 +
144 + #[test]
145 + fn a_class_attribute_emptied_by_the_allowlist_matches_having_none() {
146 + Parity::strict().ignoring_class("raised").assert(
147 + "t",
148 + "<div></div>",
149 + r#"<div class="raised"></div>"#,
150 + );
151 + }
152 +
153 + // --- the failure message ----------------------------------------------------
154 +
155 + #[test]
156 + fn the_failure_names_the_screen_and_shows_both_sides() {
157 + let err = std::panic::catch_unwind(|| {
158 + Parity::strict().assert("item_pricing", "<p>a</p>", "<p>b</p>");
159 + })
160 + .expect_err("it fails");
161 + let msg = err
162 + .downcast_ref::<String>()
163 + .expect("the panic carries a message");
164 +
165 + assert!(msg.contains("item_pricing"), "names the screen: {msg}");
166 + assert!(msg.contains("Askama"), "shows the Askama side: {msg}");
167 + assert!(msg.contains("quasi"), "shows the quasi side: {msg}");
168 + assert!(
169 + msg.contains("ignoring_attr"),
170 + "says what to do about an intended difference: {msg}"
171 + );
172 + }
173 +
174 + // --- the per-screen switch --------------------------------------------------
175 +
176 + #[test]
177 + fn no_screen_is_converted_by_default() {
178 + // The state every deployment is in until Max flips one.
179 + let screens = QuasiScreens::default();
180 + assert!(!screens.enabled("item_pricing"));
181 + assert!(!screens.any());
182 + }
183 +
184 + #[test]
185 + fn an_unset_variable_converts_nothing() {
186 + let screens = QuasiScreens::parse("");
187 + assert!(!screens.enabled("item_pricing"));
188 + assert!(!screens.any());
189 + }
190 +
191 + #[test]
192 + fn screens_are_named_one_at_a_time() {
193 + let screens = QuasiScreens::parse("user_account,item_pricing");
194 + assert!(screens.enabled("user_account"));
195 + assert!(screens.enabled("item_pricing"));
196 + assert!(!screens.enabled("project_content"));
197 + assert!(screens.any());
198 + }
199 +
200 + #[test]
201 + fn whitespace_and_blanks_in_the_list_are_ignored() {
202 + let screens = QuasiScreens::parse(" user_account , , item_pricing ,");
203 + assert!(screens.enabled("user_account"));
204 + assert!(screens.enabled("item_pricing"));
205 + assert!(!screens.enabled(""));
206 + }
207 +
208 + #[test]
209 + fn a_star_converts_everything_for_local_work() {
210 + let screens = QuasiScreens::parse("*");
211 + assert!(screens.enabled("anything_at_all"));
212 + assert!(screens.any());
213 + }
214 +
215 + #[test]
216 + fn a_typo_disables_a_screen_rather_than_enabling_another() {
217 + // The safe direction, and the reason nothing validates against a registry.
218 + let screens = QuasiScreens::parse("item_pricng");
219 + assert!(!screens.enabled("item_pricing"));
220 + }