From 6be14d212220ca16416ba5b9ee81fa3658665524 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 23 Jul 2026 14:58:28 -0400 Subject: [PATCH] Editor T1a: pixel goldens over the score raster the GUI displays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo's last unverified surface gets a gate. A decoded-RGBA golden comparator (dimensions first, then raw pixels — never encoded PNG bytes, proven by a re-encode-invariance test) with three-artifact failure output and a reviewed-bless mechanism; four golden states over the real Engraver — as opened, after a scripted pencil insert (target derived through the session's own inverses, pitch/beat value-asserted exactly), after undo (asserted equal to the as-opened baseline byte-for-byte, bypassing the bless path so a broken undo can never bless itself green), and the slurred casting-off fixture (system count value-asserted). Every state renders twice and must match itself. Three user-reviewed baselines; rasterize() split into a pixmap core so the goldens lock the exact surface the GUI displays; one additive CI step uploads failure artifacts; DECISIONS.md records the calls, including two engraving gaps locked knowingly (no clef restatement on later systems; uneven system spacing). Seven mutations executed and independently re-verified by the coordinator; full gate green (fmt, clippy 0, workspace 0 failed, docs, conformance 8/8, requirement counts unchanged). Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 13 + Cargo.lock | 3 +- crates/epiphany-editor-gui/Cargo.toml | 13 + crates/epiphany-editor-gui/DECISIONS.md | 155 +++++ .../goldens/ten_measure_insert.png | Bin 0 -> 54590 bytes .../goldens/ten_measure_open.png | Bin 0 -> 53638 bytes .../goldens/ten_measure_slurs_castoff.png | Bin 0 -> 57891 bytes crates/epiphany-editor-gui/src/goldens.rs | 605 ++++++++++++++++++ crates/epiphany-editor-gui/src/main.rs | 28 +- 9 files changed, 811 insertions(+), 6 deletions(-) create mode 100644 crates/epiphany-editor-gui/DECISIONS.md create mode 100644 crates/epiphany-editor-gui/goldens/ten_measure_insert.png create mode 100644 crates/epiphany-editor-gui/goldens/ten_measure_open.png create mode 100644 crates/epiphany-editor-gui/goldens/ten_measure_slurs_castoff.png create mode 100644 crates/epiphany-editor-gui/src/goldens.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b96714e..ea9f92c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,6 +130,19 @@ jobs: - name: Test (GUI) run: cargo test -p epiphany-editor-gui + # Assertion-message artifact paths (`target/golden-failures//actual.png` + # etc.) name the ephemeral runner's filesystem — a local-reproduction aid, not + # a reviewable record on CI. This is the reviewable record: on a golden + # mismatch, the three per-test images (actual/expected/diff) upload so a + # reviewer can see the failure without reproducing it locally. + - name: Upload golden failure artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: golden-failures + path: target/golden-failures/ + if-no-files-found: ignore + conformance: name: conformance suite (testkit) runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 2bc63e0..4626ca2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "ab_glyph" @@ -1231,6 +1231,7 @@ dependencies = [ "epiphany-ops", "epiphany-render-svg", "epiphany-testkit", + "png 0.17.16", "resvg", ] diff --git a/crates/epiphany-editor-gui/Cargo.toml b/crates/epiphany-editor-gui/Cargo.toml index deedaa4..2d1566f 100644 --- a/crates/epiphany-editor-gui/Cargo.toml +++ b/crates/epiphany-editor-gui/Cargo.toml @@ -31,3 +31,16 @@ epiphany-testkit.workspace = true eframe = "0.29" # Rasterizes the rendered SVG to a pixmap for display (bundles usvg + tiny-skia). resvg = "0.45" + +[dev-dependencies] +# Used only by `goldens.rs`'s own comparator unit tests, to produce two +# byte-different PNG encodings of identical pixels (different filter/compression +# settings) and prove the golden comparator's decoded-pixel contract ignores +# that difference. A caret requirement on the version `tiny-skia` already +# resolves (Cargo.lock), so this is a dev-only edge that unifies with the +# existing dependency-tree node — today and after any future `tiny-skia` bump +# (an exact `=` pin would fork a duplicate node the day `tiny-skia` moves). +# The comparator's own encode/decode path uses +# `resvg::tiny_skia::Pixmap::{encode_png,decode_png}` exclusively; `png` is +# never used at runtime. +png = "0.17.16" diff --git a/crates/epiphany-editor-gui/DECISIONS.md b/crates/epiphany-editor-gui/DECISIONS.md new file mode 100644 index 0000000..00ad46e --- /dev/null +++ b/crates/epiphany-editor-gui/DECISIONS.md @@ -0,0 +1,155 @@ +# epiphany-editor-gui — Decisions + +This crate's first `DECISIONS.md`. Records the calls made standing up the T1a +visual golden harness (`spec/CONTRACT_EDITOR_T1A_GOLDENS.md`; plan +`spec/PLAN_EDITOR_APP.md` §Ruling C, granted as amended 2026-07-23) — pixel-level +golden baselines of the resvg-rasterized score, as ordinary `#[test]`s in this +crate, comparing decoded pixels rather than encoded PNG files. The harness lives +in `src/goldens.rs` (`#[cfg(test)]`-only; never ships in the built binary) and +`goldens/*.png`. + +## 1. The comparison contract: decoded pixels, never encoded PNG bytes + +A golden test decodes the committed baseline PNG and compares **dimensions +first, then raw RGBA bytes** exactly — never the encoded file bytes. Plan +§Ruling C (granted as amended 2026-07-23) is explicit about why: comparing +encoded bytes would also lock the PNG encoder's own behavior (compression +level, filter choice, chunk layout, …), so the goldens would churn on an +encoder change even when every rendered pixel is identical — exactly the +defect the amendment exists to prevent. `goldens.rs`'s +`reencoding_with_different_settings_still_passes` test makes this guarantee +executable rather than merely asserted: it encodes one pixmap twice with +deliberately different `png` encoder settings (filter type, compression +level) via the `png` crate directly (decision 7), confirms the two encoded +byte strings differ, and confirms the comparator accepts either as a baseline +for the same pixels. + +On a mismatch the comparator writes failure artifacts to +`target/golden-failures//` and names them in the panic message: a +dimension mismatch writes `actual.png` + `expected.png` (no `diff.png` — a +per-pixel map is not meaningful across differing dimensions, and the +comparator fails there *before* any pixel is compared); a pixel mismatch +writes all three, `diff.png` being a per-pixel highlight of exactly the +differing pixels. Decision 8 covers how that record survives past the +ephemeral CI runner. + +## 2. The bless mechanism and its policy + +`EPIPHANY_BLESS_GOLDENS=1` makes `assert_golden` write/overwrite the baseline +unconditionally instead of comparing (creating `goldens/` if needed). This is +a **reviewed decision**, stated at the function's definition: never a +mechanism for turning a red test green, only for accepting a new or +deliberately-changed raster after a human has looked at it. The three initial +baselines (`ten_measure_open.png`, `ten_measure_insert.png`, +`ten_measure_slurs_castoff.png`) are the first visual record of this editor's +output in the project's history — an unreviewed baseline is an unverified +claim wearing a checkmark (plan §Ruling C, user deep-dive point 2) — and were +**visually reviewed and approved by the user on 2026-07-23** before being +committed. Any future re-bless is the same kind of event: a diff is a finding, +reviewed before it is accepted, never a fix applied to make a failing test +pass. + +## 3. G3 reuses G1's baseline and deliberately bypasses the bless path + +There is no `ten_measure_undo.png`. G3 (post-undo) asserts the raster equals +**G1's own baseline file** byte-for-byte in decoded RGBA — undo must return +the pixels, not just the model — by calling `assert_golden_at` directly +against `baseline_path("ten_measure_open")`, never `assert_golden`. Routing G3 +through `assert_golden` would let `EPIPHANY_BLESS_GOLDENS=1` overwrite +`ten_measure_open.png`: an initial bless run performed before undo is known to +be correct would silently bless a broken undo's post-undo pixels as the new +"as opened" baseline, after which every future run would compare undo against +its own bug instead of against G1. Bypassing `assert_golden` removes that +failure mode entirely — this comparison always compares, and can never bless +itself green. (G3 replays G2's scripted insert on its own fresh session +rather than continuing G2's, so the two tests stay independent of each +other's mutations.) + +## 4. The casting-off fixture, and what its system-count assertion actually guards + +G4 uses `ten_measure_with_slurs(0)` (`fixtures.rs:777`) specifically for its +three slurs, one of which is forced across a system break — the cross-system +slur-split path (`casting.rs:2262`) — the layout path real documents take, +not exercised by any single-system fixture. + +An empirical finding changed the contract's original framing: at +`px_per_staff_space: 12.0`, **`ten_measure_single_staff(0)` itself already +casts off into two systems** (ten measures of quarter notes don't fit one +line at this scale) — casting-off is not unique to the slurred fixture. All +three baselines (G1/G2/G3's shared raster and G4's) are therefore +multi-system layouts. Consequently G4's `system_count > 1` assertion does not +guard "this is the slurred fixture, not the plain one" — a mutation +substituting `ten_measure_single_staff(0)` for the slurs fixture leaves the +system count at 2 either way, so that assertion still passes under the +mutation. What it guards is the more durable claim it was written for: +*casting-off itself has not stopped triggering* at this geometry — if it ever +did, this would fail as a named system-count value error rather than a +mystery pixel diff. The mutation instead dies on the golden pixel comparison +(dimensions differ: G4's baseline is taller, carrying the slur curves and the +wider slurred content), which is where the fixture-identity guarantee +actually lives. + +## 5. Known engraving gaps, locked knowingly + +The goldens lock the **current, real** output of the Minimal-tier engraver — +bugs and rough edges included, by design (plan §Ruling C: "a golden locks +whatever it sees"). Two are worth naming explicitly, both blessed by the user +with this record in hand on 2026-07-23: + +- **No clef restatement on second and subsequent systems.** A new system does + not redraw the governing clef at its start, unlike conventional engraving + practice. +- **The second system's spacing is noticeably denser than the first's.** The + casting-off balance between systems is not yet even. + +Both are engraving-track items (`epiphany-engrave`'s casting/spacing passes; +plan §3.7), not `epiphany-editor-gui` work — this crate only observes the +rendered score, it does not engrave it. When engraving improves, the fix will +change these three PNGs' pixels, and the golden harness will surface that as +a reviewable diff to bless deliberately. That is the harness doing its job, +not a defect of it. + +## 6. The baselines pin the raster stack + +Determinism basis: `GlyphMode::PathOutline` uses no fonts (inlined Bravura +outline paths), and `resvg`/`tiny-skia` are pure Rust with deterministic +rasterization; CI and dev are both Linux. Standing consequence: **the +baselines pin the raster stack**, so any `Cargo.lock` movement of +`resvg`, `tiny-skia`, or `png` is a golden-review event — the diff must be +inspected and deliberately re-blessed — never a silent re-bless folded into +an unrelated dependency bump. If cross-platform rasterization drift is ever +observed, the fallback is a bounded per-pixel tolerance, recorded as that +decision when it happens, not pre-engineered here. + +## 7. The `png` dev-dependency + +`tiny_skia::Pixmap::encode_png`/`decode_png` (used throughout the comparator +and the bless path) expose no encoder configuration — every call from a given +pixmap produces byte-identical output. Proving decision 1 executable (that +two *differently*-encoded PNGs of the same pixels both compare equal) needs +an encoder with configurable filter/compression settings, which only the +underlying `png` crate exposes directly. `png` is therefore a **dev-only** +dependency, used exclusively inside `goldens.rs`'s own test module — never in +the comparator or bless code paths, which stay on +`resvg::tiny_skia::Pixmap::{encode_png,decode_png}` exclusively, and never at +runtime. + +Declared as a caret requirement (`png = "0.17.16"`), not an exact `=` pin (a +W1-review amendment): `tiny-skia` 0.11.4 already resolves `png 0.17.16` in +`Cargo.lock`, so the caret requirement is a dev-only edge onto that same +dependency-tree node today — and stays unified with it after any future +`tiny-skia` bump that moves its own `png` requirement forward, rather than +forking a second `png` version into the tree (which an exact pin would force +the day `tiny-skia` moves). + +## 8. CI failure artifacts + +A comparator panic's assertion message names `actual.png`/`expected.png`/ +`diff.png` paths under `target/golden-failures//` — useful for local +reproduction, but those paths name the CI runner's own ephemeral filesystem, +gone the moment the job ends. The reviewable record is the `editor-gui` job's +one additive step (`.github/workflows/ci.yml`): an `if: failure()` +`actions/upload-artifact@v4` step uploading `target/golden-failures/` as the +`golden-failures` artifact (`if-no-files-found: ignore`, since it produces +nothing on a green run). This is the tranche's only CI change; nothing else +in `ci.yml` moves. diff --git a/crates/epiphany-editor-gui/goldens/ten_measure_insert.png b/crates/epiphany-editor-gui/goldens/ten_measure_insert.png new file mode 100644 index 0000000000000000000000000000000000000000..58b67bafcd9f805678de8ee80255f1c57244f0e7 GIT binary patch literal 54590 zcmeHw3wTu3x%MDpw1}xfO~p#I)ncodwu+Z>8EH$A!*k$pRPrk&Q-4oOHDI(Hn;T@K zR5_3$ibt^^qz8|MLNT^H5Rru8B6z_#-a=3!13?llk_$taOfs4MfA70y*4mQ{7tyMH z{^?VJN%ri$_FCWld%y3S^>3p`^!i%guen^VUL$Y$*}uD7XWk?Kj+}j#{5k8!|MOFq ztE$JypAEY$uiN1b-T!p?9ZzpbDME`%!9~M9TFQtorbnUkr zLcc4!x8Tn+2W3}{SyOp?=7a~UI{#r$a6LYGN|ztwQY+2%A3J~1^(&bZTKqj+A6%d4 zc9|Wy-EK}kO6_NI12yp|vala03EWh&IoAs22JR?nxuZN-=)EBk8QC*jm+q{ymwoin zgk5!$cRlKl9QM~0`CBIVw-kDVg^B33#eJsh?~@~Q3l^5;UtH9-)LM}8MtFbaT)fa5K`PIiR*<0anueeFx z-P^I#TiFv$3_1di7{dauy(Y~ULmVeLKl4BpsnG$Y& zpdq+Dr(=8gO<(xj^p>&dTQX%w6YjtNY>^4}i14u-`})$rk4raqWF34yYc}7ywR!L0 zw^x?!dgG`4j(k1q(CVyW{Z|>!; zNhf-C`LS&8_1?fBZ}$y_71f2!AIU+MUz(c#+D~6<**^Yn36IqJj!3z1`UADEob$Wf z+-`Yo@0Gj-ddd3wNY{?K6Rhy@GdGN?cz;xLwbfBweregRf~)W#SL;fD+kmY0)tKh$(4-Od3jYH*c9cqFgntE)aMSa#Y_<>v?l3?n2 zUSXFl+1_K>pty?r=jE-eE*QXl1LHo@uJ5b&3KAgerRt)?)o1N4v)aq>Ia1YiO>3tG zbFCX114A1(ckg(AOh+GKu$;;-eoZlkbWIA}EJSQoVn1G7w{Ba=V=1kjdANp}7`*Nc@Mk+xEM0f_Ui+}!yoZOi7iUAew(g?E8{k0D!- z9%aOdpK^Kgouw^z@+}li9q)MY2E9l*IPQ)W-JpNY77=ik;?+gf`Im@%$iHul)%IR~ z(~R^t?d!f^8hT}v_vom=4OZZW!H|d)Atk~5bS{f5LdEKSN7MgQP4GOMX`pgyU=UpM zhw{!m%x5o;6UoAi?W^1ieR67h%H-7Y4p$dVR@)3c*G(U;HISl{HxexqK96)f#y&bG z5dPMn-~9oCV(+VS17G}#-bwFxXKH2j)Lt2HW=^ZhY{uvCn3kP#VnX8O=LyS~Yyk-? z-ZuJwyl_3W2&tw2tm=Z5)wMBR&U%S*dWy)Zcq#u#A-2e+g@dOTRAI_g&E`D=nhfnH zJG6%ULU`)8JaO{trnpo2;ESb`{%uf8w1g5`K68wKaMK3p{SkBs_RxN3+=_3wk9^0y z2_nn;@ycBXSLPj^fAD2ZmKV0MVXW)HH*3 zn4;=jxtK&PSMDl?5>he_7L}_BdP+Xq18HT!G>G(ubg=#?)+t}d8#&~y^BkEV_9-n| zd;AwHHov`Y-8#}WrMUOwb?XnU%WDi4=}q^w{nb|sX{^-L@ksf}SD>t$!73+~ z^c66+T$F*>*-~1%S3np+R#Jvruj_k-a{^y1_l+ulb6fej+>oAuaXBsHXu|U3VdB}> zf3ecryP>_@*WN^n^aon}1a_ympP4v#??t~T&wB!*())S>rnE1F_2tXkdM#~`NLlM4 zZzS85)%Lt+fjxkfKEL2E^J~S*4(Hr|Xl2=vKTI4vgS_D0mOxS47t#uw(eLP`{ldFb z0^W_H>qV_tn_!wud)EVY&f+QpwVG#r5jD}R4fMIU%J?#&4oh;!?;feO#N1ujg}zl&G$ zEqBqgb}u2Z&_ZwMVk#|r04S~R^!Y^x=GVEm!Cvd$1WF6%{7ozwf4I```09NXE#nq9 z|6yVi#c>X8cUSq*QQqr>XQ9F9`ZVu-HI#7%%-%h8)PngX!96{0`rRkr^mJ5tD&c{4 zZ-VyZSG1jYyu;Jl<(CU>dInLm$LcxK7*FL(F=`VZ0<;~h8wl6l4Oxq0MiN-l|Jey9i zG7chTkH2Cr&*6DcKCKBUUpac&Dymu~Lxd>duF@no?|dTA22CaOI^TdEtcx0T@*qo#SrM6-K2FS|p3FxD-$ zKpSIW2f;d+d!rUqJL)Xu$iN(?e;HYIi}8CzKF5@9iijba$1d{GDX_4wsjG()W#yP$ zyKI(&>*#1wzZi%cgI`*L50mpKg8#M;X^lL7A|mz&G28G5f?thDJhE1xq^vd_3y4*Hqt zc;~LNJrO}Dz4Z{xu-AUtZoUz{-m?+o>}?!DF`#v*JoYd9=SKLHFt$1i<_j$n zYh`I@K`Fu`^~Xjx9vwZn?VY(2KZI2jX`O37Bhk2-^%IAd1on?`FQ9Em^Xt8?QMu-* zc?FY+$7PRa*v`d(f_)*KM+Q@2s@lDbUq!z59qAgR6Z%EG$2erxj9AEUk9Nj{Aao=U z=7z}0A^yM+1_T*F@*P2RiJ$SMR)6jG@j3et`lI}phrsJ|(Pa}ofJiYJoL~0uj$Oj zVeNIbiccmsE=s7uJK3ns?eh`@)mT+iSIVyxHMy zcXBw4C2wK|oelmw9N8F$spWX*I2Y>pp4PXiz5YWClivMDH{Gl=_JNvR?G^roNE_~P zzo9i+IJYgyQ&j3WZJjw5Gb`q=bs~z-*#u27;o+2mSmwLmei%=erRJaQ^C3p4PA<~W zSa*>x?<^iEqTn<}k(Tp{-MJ?OzyWe&_}iYK*S{y}{^`7@!#n9Dfj(#VZAL}!LuX)WU^`+wLK$Avt6@GA_q$99#R>0e z+QMNP$SZ=lcI`9)yA4ba{4jlUcgf*JU9BX;>Wt8r5;^_m$eKniSH+u;0cTuHi$T?f zo;Y**)z-;k->%BJ=V76QD$FZ=iDuofae(FACxket8P;Nzm9 zIE`zN)P|1NG3H=Btrtl^gvtkRAr=;yLq=@ui*?-45gcn>_z5;we2R#^pq0jaaoXL{#qe^5@C}P<&{Rv<*os2ntx#G7S=tWA~QS+~9W-1Id1V97!VE6%I7aq_QuV(4i7aH5Hp^?>I5yN%% z+Tc*B$=pmr;FA_(|JXS4%(ycJf|OZl+DgM)y4tv_#3*`o>YJ|;535XSMknnv4NSy< z5Xb8@N9T;7WMXvY&W^(huF~IerbbQ*=!whzD1=TW)d$`oJn;zwjuvl11Dazw9)o@~ z8IFWtvXVWC05b%YB~H$3h*J!E7QM`O1YTMUU8Xaq&UfybiZaX8qB5L@vR(TO0Lg*t z1{%2g2EIAYfj_h78T%HpS;<~tW5p}&v3rGlZrwjO@|(y~jekofO234pP8#b78Po@~ z+(itHn6oPx?e8bh1Pa~3Zwoj~AS?dO3^3kI=rb#b6guIECU4`KCcz|M6qqFAC`tvw zFh<>hYu%f@TN&(!7*fpWh)g+z4J-5rW9llC4710jr+r7Xe0IQrPL8TekP!x)rjh~9 zP89;j6JG*Bau8P{AgA@H&TKeX=>#x(tw1{AisEYnjO=#2vl9ZP5Wb0ovFA(_cYT%$ z+OqV}6X|=-8IyNL&e5OeymFQI$W>FStiS3=TnGT?L;yD;&r~6WDm?&V9;g36)s(i+ zj=6j3;0Lkjq!QeTqH8Ab*h^om*OfVBXV<76D(a(N!V@`>6!?ToxQX;H`8gC97- z8SynE$0K&f15X!1oWk%`S^mPZJ*J|!ZLTrqPF29HJ$Tc|&s4Y~M0NV|<96RMr-gJ& z?J0y1oew-sETEf(4J<)(tyz1}y{^W2p^y6c0SNiwiIWUcdj%t);mjh?B8GRVuTGY|BA?7ZN#6l;r~Lx=##>x z;sP|CVX8tf+VJU0;to}7(3@I?OsQJssEX_)P>$z{IUlI+BsF=bL@`PkjiHGLbdJ8k zlq&D8XkA|S4S&1)RhG-T970WM`vH9vbjKOHhou&L9TK{m6GM9s^GfZ6+=*|Va zT(L?;d4FT7xY<_aelKb)&oIajgYdVX(>Ku7k1hw=ICg_r-KNVanEC?+kQ1rPaos32 zhp5_!R*3+dEb?OkvQ^5ok5yJW?`>B>Yo1_n);y_PmfJ_pntu1IkI@-$IXP`<2oaiV zCcJ#+hFFcLs&|a~neWl~2o|CtMniu`HIfS6{fe4{*+29sh$cS)aHlKz16qkx?7Ct) zv*I^^aV{tg_2rkjc@H5D1mEOt_975<-J5wxHs^rlgX~`S3dD3-)dz-&3M>V!W(NSu zK%s=xqr0Sln_>@rS{%mYImiUgWPKBRD*Oo$Qb4(P)J4If{h7_Hmislm=k9n9MXWdT zEf|0TjMyPg&!kU9M+$;ua2f5A!@C*|=M|Ub^)xi%-6_iwSchjb6Md|NdmFWK{(TV6`|u>)H=hA%@$oq0q3GUYYfD!!G|d|}Q*lOt^N zlfAw!yA>U0aQlAyVODY*5n7GkMznE}*LGKO8(C0Vl{xJj^4b|Hm&@SDm?jiJuwI{g zKBW*9=vMF z7b7kdy@bqeS`Rb-yg~xSB;S7fC0ac7=m_gNW|SdcVtZ+}hSeEe*GXLQk(JCUv5<~tjM6l@+dr#o_NNa!k#!1lM@<5O~+fa z`gl{hAhCYzwcEWu<8NC{fH$bM%$r}hcK+Sj*{Yz;`<`q>PT=|+)e~uxf(D=$kNLLB zSD-J{Oi)o;9b`%CtdiDqCI$S5$;J%3T@k-@H@gJzYZRxCFrmWzHC2U;M?u|EP*k4E zx2HoYCs!)Gavzao5C?%xsJx#!1K3PSHT?l_bp2x3W_!RuXPu&$+d zEwm3Ious=`q*WO!E|;=`W%PK|ZHBr0ir^t2v-$)=&sN?0h|n1x#1bLcHyN`Jy|C`F z)V4pRsv;0?gT`7$2FlqSAj_ybj2}Oq5|~`l`MsnYO|%D<6FKeKt9L7su~Vpyy}g(` z>9cf9N!=FN3hr%W9ZfmehtkbLP8rJ`=X-=5Cvd)YUm^CRpk)e-jM-3g=&C^Gu&YVP ziSMBUM7+n}g}-U8vGduLqf3TX7FX;jj?%?(H&jWhQS6XH;o6fez_=KnPg}c2lir@O zgIbjAm;FJVS3Q;1`LepeO{GWwrSuhghJI8+MiYHUN*yVION=5Mt#e0}-mMNI zAg9ELATrP)b5yUwge#zRGVT2lR9In;@fz$qWuuSiS}?mW6_8r!zF%e4{fdu^3^Z^a z@8&p4cD}*<3Y;Cux%gfw==>LXYjt33M-{E{wfi~XoDD8EmXMgId|K0G_4{7 zi5MgE*nfvCnJ*PI*gI>EgJe{b5|Rwxg!GP{MH$OOQ{TF(;@zvFM-3GMjU}gs9S~J=74yIA4+95FRg+D-pMi__(=-7q6NKF!u3q>8tD8#O_ z6VcO?BZGJv7q0HPIG2f6?NP7q6xy;khSy+xXQ*R z>U7v(&Tn`o%dz43kbKz(;#OEZzMki~?Qg88_;7_fC(ki`f^m0Xzzy_>b5wUg+f0Uh zJbT4@qU)ms)=V0)tiE+_8_Rt>Ke@o32Tw>9(iiQLeO*uz`lBSdgv7ZIFT8BZQXgp&7Mx67}7ufF@E^;d!%9kCns?u4zHTS6L zzr|Xog$%a3-!(!Y=a`Xl?@Wpo@= z<7|dw!7MNKup18>3f;vl-rUIkxpg6S3;Z_N>jkuvS=6r_Y3f^Z#F_TnPOj?o3Roq8vl z@s@4JGlK6mPOQ8!d?E2QSM0v!;<9 z=c=XbydVQxGR2E{hiWyZx|y_p6l=F>_Z3@ZOxypCsa0+R5ib*YYA{3^f*Sv8_hml_ zzR%5!t=PY#p22%|(BHIH&Xw+q=qpvz$n_!kA0@;fQ~rDdYmT;qoDZdK!cA=Ncx`U@ z>@j(pdbd_H#6&%m(nfK*iWUImZ?GLcVPv(2D?7H>{xuQ7Xw2JT0MDMi@60W_P8uLI zzVg{lZS|eaS9p$G5zdy=8oGbR(Ek#p6NR4;|LG$-oAli{*KVB~ddd^}Mr0rflwjny zJ5}&|YLRgjAsD(S>v4&)phEyUx-QRSv5 z9fh>}(%og2)^9p(WZf3mH7BAH_mn-w(s=ja^UBO9)v1S>)|`^Izm%wjH6GDwC1bf` zeUWvy)Oz-!!5&qxn3lR*p?53&b9^uCAum(vWGvUpO4a^d-dx%Y!lyee2CYM>7=68t z-H6Hd#Rn08WQYaP5AP=oT+mz}XrzeDv=^zGBGh$uSrw!MyExxwsM6l3gAJV@o{J{j zC?Oo)?05~M*@jC`?^xT!MLImar_n5Oy^XG9i-O%kNO}XC^lz6nA1`yjxa^D{2 z${9nDNIhgV7%8*jmpbCzlo3J~T<#CdVgF$UNZ1AM8Jr!eHI+j62%44W2Xx&gMxW^r z0J0NV*8xNU0}iT1~B%q2CIyQ2tyk4jU#BEsj;11mZmd<;Zam7RK!e?duSn09`ak-_Sn@fF`7Pm_3#X`yM)Gv=Mm)->>h?0J-h35RuwEZLRRdN3)G)Y%ga~JOvzU12tZF|mr(k17_p_kQ20kSg8 zTiGW1wrGa{4z0o2Q@2AsvBTx}_!L@^p?svL=*p5b#-OPsw-k2VU^g`ue@)Pq6@XM@ z)SG47Lf9%c#dC}F=U)te*VmKtV1cRl?e{+8qJNIg4<0oc0Trr1ZoAkelYAIN0E>n^ z0$45{2Sbn8@63w*#@I$E81j$APK#aP(OP2Og|bMH=cBs-Z<9oXd>=_2w1!*(t0?hh z+8Xw)G?LLzIA&|Gk2iNy?H-HX>0ThbN0*_~BOnPut5d|BLmZ5cFmBDQ#<<5Kz!nt6 zq*03Ol7rw%2FXkjHslqF4MN8L1RlM<3bH~kf+E9OPOCzDMSKE1f)+`QC}#bQxFIp0 zWFLd;Fx_dcg6c_Trkxnr2nG-u%5tf+d2NO>k*gpoo4@4(=nAj}`p+cvL&?M)g$WA@ z%1t42jd>7_v8ZXWqp)lAyYecx3VcA8@mTqN{kn8stl!1%fs+B>@<{2fLD}24(V3zm zz}rTMrC-Q@LjJ1dtRdV3wwnKKjk34Oz~r1h6bbF_bH}8wrnQs z^U>G@-%_e1XySIe{Zn?5pAIkY*K#QBj1VNevL_lXCK|`E+`dMbE75!r8JE#hj0?1} z?$T4ctr0D3yZjiJM%E=C72C7q25RC-l7-=-Bw2hV$ijLO`bfcWjR0)B0Y%o1rkVD4 zp{&jP4%I`mB?q%>91<|{uMUilIcrWXa){)Pg)~_d62Lr#lZj$tL%!X11MOy&xHnHx z1BLpx!OoF(xkDc{zp{&=^P|nm*AiJp8`<#;7s18r1>UkKwRL*xZm21?@HuFe(ie@z z0f_RT$B1#q*U~km zLX9n|#qQUsTd5A3%QNRYnzSyrD|9$xaxv&BjIBDKfPg%L6-pmUo{{ zp5dE3*A8zAM-p+B487~bfjsjH)7p2f>>14&g1ACgG3q@

_+JL#8Xb}Lgz%BHvb`-R!A$zBr7}1c=#|wNp`rU#bzMt zv8q~!*-7nkI(}AvOs6(93EPD*#>X7hAw&(ez%w1)=T=05>>-NSS;|h3kja{f0e2xy zV0pw-!RLX`skk2QP&b7g20#-!jZP#gR`k!?UB8?Sc&#vH(-fsV2kX*X^%2q*mDxPhQM zPG@Cf%^rWgy5CxD+@C1YBMOaHC>C0`QMiDPLei!Te8{@d)a!><19wsydC*ciS5l$!Q@WR8<=zHH!sz65nm&U2m=2ouQ6oR+y)=97*AI?^XW z`-o_oaQqME26CC>!zr&U-7z@D;e>p4$0VHT80u&{Ck3cxy>wxji&DQdZ6XG{9Uhi_ zuXgbSQhoJFKJehcXM^1&2H=zAYxnY0>FOpDhmHvh*OGgWd(J6xW4(kXbFV7r%rBV} zrX6Yw4Lkk^a|5}|d@zFgWGfr-j|v1N)n12;H2gGLx#X=X~YJnNgdZ9aXHgBaJ6VL z;yKI;#=O7GFZiA;wbCE%5LpFq0T7r(VT2g?#3=$+8Cfym<^P;TuqASPz(v>>0m(pQ z0#b^=~u@3ZHUv^$e21e`t$pWL^kKvlL z#-E^)$Kh@Kddie1@4O0*siP;24zxRdHn>dJC~DwN#E&8wYYFKl4)P%}E_mtD@5hb9 zQh`$w8|@r^s~>TJ#)yc@)s0nA!+DYq2!9g$B4}uVH_-8#B=_KZqX6mDjhcj9{L6*) z^?C{BUfu3?Wi;wSztuNpvoaNJlsf#ZN3A(4R}RQ(*q8Mf-v2YG z%w$mY%L}T8LhW9=@U2CIUiJRmADlF4QWK(!$?)nwZ+KM(uS*(rvv)gcR!|(-kQz zGh{ZQUV^bE?R%XaJ+D6H4`wm)9rNKNzOPxCqk}^Evbak3!EbB+OL`=U;|gCjo~)NN z;4dUEXT8ooB&ZYwO4Y%u8U~a!Vym4Igs+?ttFc6BA0w@)Yr_K%8%&A@0x1+5aVk>^ zb;~1NDPs>ojeGw#eNSVHmpc1-69pH=*LKD)+MOVB`9+sE>rx53iqIXD$xB~Z)kNp` zA1Fx~6fjfbS;9kHLUn5`4V z_)tzJW#>PG?2N&%-a1r%Uea_)96KR)HnDA5_Ii7-bHvseLcF!;6I`raTjH-B49=AVU+AY zCHgd09w?wjB!BhIPicvF#NOjHY6yvURPDQn7oWp^E2N2U$v#-UIHLDQRrImngOh6Z`*gvhcJGeU zfQItO(87K*qeE}k<`dio!m>DUqowL{Tq{Pv=)4R z0ntc!V6$80Q4E6DDCzd`K?)%o*8O{rbxXt*;{xm43Q0ClPQO|M6+?DE2*{ zUuY>*a?7dKQfh|`U1kkd1xW5}C>z>~_{}29;nk4L$hW z(AkJqskC>fjL|*uo*dz6R&|D zaID&Z<-|dsY_uAUJ&t3OYs}=OM2B4(*YJ)9#IzL#%Iv7~AB0J&+MP>hAxn??>fK!f4O9nyRJG|XP0RAZ?S4n=p;KsQzPGIiP>Pz2-oX;x+dJp|JDI$Utmb(K zqw~b~^0|8p2Q@VxOY_MVRcXBfJMcr9 zY@{0MzKP)k%>(>L2JEQw90Emih3L?l^$aKbj9FnEm7SzZ0IMOhyEd__0b&1jJ1K_SG3dT z1JmlB9D3l%)_EDB3k2VIfF0&B?MvZ0Dethklt)T9uB0TzX0W_&7^NW3!6ds^v$Z=1 zdtv8#FgL)T8FgIUhM|7EC*K+ZzK+k2b^BUqtz@fPC`!JyYEp~Y5RSL*bQ^Lid!!zB z^YYh~6%Hz@?O*h;^EP@*)M^UXmF?{>7nww~&Ot4=hq3ABo0PXW+VU%3Sl965el5#J zsKF}P?0~~5S;e(~UU*;aU8#lNq@eI_eRb-auQsuv@Rf9S+5DV^WPQz8ttEM_o73M~ zSFv-Q8X}ZQ9JL-qbcGPv#N6DpoibgA4QZnTp6q$Tqtf8Ec(TkYLU(;I4afMr{$TZl zSBC^^#7P)Py@JD0ztM%6Y|fDhDl+q=9CNRhe0UnaVY53kYRvO@86CHAV>jsBeJphprNyYrgi>s5jD|KSsBaa2>Bb5<#1_!8||QW+P~q> zQ%*VV)#P@F1N}>Ep2oXIU z%`plpe=LM0bpgoFIb>=HR)pC>)Ycf)_6Q|6S+bhry~*=wRg{_R0GzEs8I{Ec^=LHb z9IW9jU^7yS-PahSOA3g^#;nM=)j6kmnb%oHBqcY>F+nY>&+*>Bi~k-it;K954sp>V zw=!n)t{mZ2z2n0VYxfCMQ)#n4+|_L|@VTiJvu>zKdFI2_Tm|gB-D!~2sv#d=mKvMi zlrb9;2_R5ZJ6i*Gs&o0{{Cv*J!AvV|x*oXYfD5fLZSx(|nbNlCsSM%l4sDec*Rr)e zRGjEsv2cqi|A#ko`V__kVE&cw7`t;yZFAB9)eNhyd@<{3dJK7GVYe(GH*3?42K&vE-q}?bIzE&Gwit_`=0#rL~6VID2B11?GyFw6bkdlt0frm_iWJs?KFX%ddb`08sK=}ck3M5IEId8DDSuE=gm zwKtFzb%%w9AWoLnSFjysFfVWKK$8i0McKN!La-3;LfUQTxELB29Tv#oMvql3bp&DTCJPcHgCOntsf^CfNd8pUFV zyM&gqW~H=21S*+LVFX>V*1srs*Xy?~KGKJi;W$0C`!tMvW&p!PtE_7|V1+Cy3cdVX zyRR7xwZvf3tU1&NFnRLFn*Lhet$Msr`7-*nY9`}ej(@?zB3BKLDgNOtpgpb;jh&b2 zNnmj^wKBdxQU=r^5k>C;zUA1FGN#K+`MQb&2pD(pZp9IDL|yqZ#y}Y7^6Z7Mr>M@l zjn#C_B`x`}a4_#ms$Qbo0nbdULaz}b@E~pMNsKdKz;jcn=?NthrNE^$=c-$mm$rSM zG@MpNQiG!%&Ze9tlZaI>HZT2BaZnDeTVG7!GTbzZw)@IBD8I5?mT*9vnnY(tmx;v; zKZ^awyj%6C!t!M_IO#w;>^@!Ov?7v%rs4J(jCIMV!@RK-@jFUh<_ys2{4~5fdJPpb z$|m+;{zX~0=|U?u8oG^JM`K_~0bAW53}=6ziU?zLIGIX$2T(NLt?Koi>y3WoaT74;L85Ub2v%cD{QDD6}+-QUw^2ScXfN);s z-3q5kO$}RS&cnM&k0TWll3-Oa581O9j(a{P%VynX5cH+ZFg579Wh~b$5vt~BDLRGD ziCa#m*)hy(pgFzaEoORLU(>B@iA=kXQkOMHn~Ki2F)VEV8w1XmT&U2+Y}yTr7BMwz zrKrv+sNlGQj1A+9J)BnKv?0kl&3&ql4Kec^p&uWV88Y@$+*q1oo+IF5`d!fVq3Qwz zjv0`)UPntHdg%=NdJZ9Gt%Bfcnifqz$mDP~XWKIl=>3?y4c$gvZ-1YPi_TOAdKXbl z)4ng|U>t70JuiM1*qywJhIW@PL6p(}QD5Q_iZMRWzFiE}n;xh=Gh;TLRLJH~HK%=J z>}_$a5o4<C8Zpkr@XPo6ZCFpP!&^g#a+-6!E=Z`n89)uQ9xQR!#J)DWTMmTgeIqe8Z7xD=KLjV7jXg^#65Mf&U{4d6Q zAtu1vKA*_iJeWRQDnhV1m>5YH1z)bO$ACLT3RGxdCgW%va5t>e+_P~o!Yh3e8g6=+ zJ|ZcH9m7_tD4x_g@9Dv&SbQ%mFg%{-5AhQ{6_Mj5us*@Z82)|@)J$`V3-BK#c_5US z^*F|;(RZhI@m6WSt^88X^dd0>n_{gn>^qEIzxV(GWyVoYR8Os}j;@|0Ox={zf)QAf zeVLk?@mi;WUE_?@Q!jl=Uvt>Po6*AGdIG*T7e_LI0LQ@~Jw;8*mlmNCF61mJoCk>= zEx|CyivS+bg_<&UJ{g4fBRO}uABD1e6O%n)NQU`1mluu0_}1obi{L@RexNi#6SZ!; zQOwIM6_>}8EW{*i-VDAxPPF?5?iTL^7U!u35$B^ehMI#xeAaYU>_a{=1Pfv4tw!9N zCkYQhwQ*y)JrxHI1QOLAO(LTxL$hDyv62pW9lY4^2r2}U96X3_yCLG#-okgFcyw^o#7ZY#YmOe&NS9<#Yk2`_^^r%+McCg&3wmIME>mU=pkOTBn*ha>EL zg}8+VNDv!x0ba4v#4kn;hS%-xPS6pFV5PrF8cp>|9;t1zwj*k@#W{W zgnnB&qv*fqjm@pgURQg|l-nPuOZzAsnp+byT7K`+gY59UOSw}JN`N=vZ!cjW#L(+-7DNnhW)fOe6_n{ zio0fRM)-~wUp=s@BH)P_(kIHMCOWtX24Oi6{SEu`XclwjEi{)^t!T!apr8JTvEMfA?yC<62kG+Vy?!|6+IEi4V?PYTcF7{6Nk;zQiMip3b7ojXfK@ zOA}Xv#aaK(2YjL*5V`P;MH$VFHD$>dvG=N>SpT_!-d%y(&Jtf|>ospYcJ|(Ce^2#K zrKv%o2ojddB-H(?S&!(g6=oxpE~-(ojdoHW_J8*W=)=TByV=O z>%NxG?RmZ1!*Bb-Cuelrm{BuDb~Ne!`@bPF!I~I8l4o7%3I5cxxi{y)3poq;)?ICT zv))}@x$AE~A9eWKIS1F~l=07f4n(%V`yC!**Jr-S%<;{$$It5-*ZXO9^J{6PKlClS zz%jGGXICA`^j`a@RR9szO+_qB=vg(|)!Z&~ZO2_tTFDE@x_xU2_!Ws89ZaL|< z`T0W%y5BE<2lSHj?MaRgoBCPdZi1wt9DOebqUYyNWKxgB)F}{oR-5^sIFy zKi}{3e0S^fb&Nl_W_+11@|mycgiuQ$a?h^jIlG2ESeBl7zifU2z4BvZDS6{T?%)GL zS++-}e}X@B9r8zJ^}AE^OM5EaOX~}tt>2UE4c*Ucst#oz4+_2x??<+9ubY-v{l`FP zSzyTSHM01EYd7wjw(*hTmi5CP9OmqLjSOV{p`qyDY9YvyG*9oxp4!~%?YZ}fP^`We zr?VNf70d~A-5zTRi1W_;{Kdn%S1#(lXk&MUcZqek zAzP3hWkml^xu9*Tr(-JLLebRGju&q*f|P^f?yVRC{qqeG0VgV6eYU>vY>^Lz_hh@f z-!E*PoAI`F#aB#2TPAzkCkL-`2d~P4M5G8Q?<~yVvdAJ-tj_l|{g-Hh=h@5wl~V(Q z;MzWxcjjR}b3vR)7G~^O<6P>KQ`=K1r&e&NzI2w_X6U&g`f%N2C^~r~(K6w0k&d&i z_UvHzdt-n5dkBiXZ!8La^(%TWqxZd%+WL|anQu>-Q#YjzpTpT5JLJTK#H+p~EMHy& z5>~uz^#3&BdTJ3;%jo&_MXT!@W4xU63gz@{kyY_h{*hvAkwXgy*9fS>VI`W)hX*wo zT7T%(8uBaQsrz~2`4E>%ChmPIn$Y z-}yR3miLp@yAG@__;hjU-I1%H*hLqGMp5PRxTsG`oAXPR#Z|rQV08f)R`CEtds=Dh zT-srZs`KSy617~ps|-p=$v9Y4j#lU?`D~4$l?Brvaw?>Q`%hw>@^!qCgWe|B;oHSN zrA2E^`-;Wpc^futAYD_6dq3H*@!*DnKxb)Qe{=%DN4# z(!ZpyhOyY}7?qSZSr)TXw^H)^`Pe4?9-%7%i))}zAd<9!CrwtOR z>^kU;D*spDu z4sHE=X7As_d2O)avi4t_)<^^Gtj}6n4nu=geK5KDG6nRx^d2I z&!!WsjDtw!<6F$-c{~rwr*$FaD@QL|LshF}h!7>*Rhs1HozLRZp}~?Y(_A(IMz-@q z5NYtonZX}dodZ7@db<#6qQzMCu8vz=H51+K6H})ZGv&Un)7mByx)t7YmNV|OM(y9= z?b#qaSC}#W!1Lo5cp?WpP3}Kvf4A!|zV1tCqFR?~OZBxYEoHdHsOd>#qFE#Cm))j6 z80!{WppCJxjbLre{hAh3A2zv_BLj1o{$*s%^~UcJ`5aTa!$b_xJa&+e2EoF<*1jG} zl$B$0?cDh`uA`$#jT__m=w;IjjZG>_Li_vLq8AYob77M#(_B^txWT=Wr*PN!rn?GC z;T=*Vq*Jvh_>zKqIp%2lv3Zbxk+^yX~!ic8XD<-q*CsJeoLFcnp0{3P|61 z#;_NOL^%PYC1DHm&YiD~CXucBGMk~ATzeU3GU$Ee0&}^0uHLJcYAt%r%I6H0?=!BI zjeh3Y-np-APeu?*Z#@Jv?9HE#dREGC1cK9UYe_AIFQo7TOr)}yiv$>^i1sDR|O4vEsU<0AqHrU4gCnwdW zak?i?T4>r$5GwAc47Ot@8{0e2)n;|yWtz*p-vGvWs&NFxfYwlX?7yskGQy{v*y?62bM{WqT-;mY)-Xg&dVHHKX7FkaV8aJR#EGWXmVWVlB=V?q!b z352;Ja&nwMIF5iI5hUMXpiBIWA9eW~w@=I42k4LTUmgOl&q0?>^Z<}zGB|(i-yOS{ zFrhM9_w@DiqEB@w&~h4aKMgk8oJ&%eb*Vgj~ z4d`uuy4h`4;qIrjKDXHu1AEAM#)^-*yfnutxU5TTu_T*mp0It^83W4O%e8mB+2(F1 zI2^{3H<&@9!GD`A8e=oHZ0{WBLLJ@HI*;1x-^4IEb@UCb*XW3SuwhqEwSOtXhI^cU z(;6+D-<{$qDs`N;jvR}b74z2;h~i5&L5CT5cvw*^@;z!ljHh$c3cum=0V7l=7ikGJ zo#`t$kw*#?oW>}^a$d1J_k@sefY=!RZQZWdzbEDX>A0uOJ4uj0U$Xmmt=az4w4!(C zB1lqr55xCFp_wJJ!ri?>VFbpU(udX3StnR0#mnN$Zf{&~!qo1?+K087gDD`!zyk>= z!j}xhJg&F@6~B(IWL0WC^@<-nO9DV7t0AE683~&0T2LPOw7lsd6=6p#{_ihL&U#lT z%Y^fdKuFm*UxPgUNb;|?%s;gu@Vr2uT?ago8OwGMtsdhFj%f(2C_S*EK;%j8sQuc) zVHn6Og1B-097%Q?lM(z;#^#|C!;89FDTdV%p|1rw{qCfOfR?M`&BqdF91M#=)rOup z{=r$x$_ZZpGsf#3Z0Z?m3U?Wed*F)vr0Q-m)@yFJQ`eK7Uo@@|9oja(WI%IbgB7}2i5I^s0 zNEg9lnB*4^_T6R!^W0^F1}Kw5g$~-t(6`~y#~~&6hjdaA;d0z7QS-}ks@@FhU_S%S zOiT_2b+pGoK;i2fOC?_+uZEda{(V}~r+nG3!I$07ARlPmAm&<7602YFn?$sfkrmAw`g&ygFpW7xv3y#{rHofl8ouD_ zejxl7@`bZ^&lgZ?x zu0O@GuJ9fm9CWlYO{_iJy25(aoUpdtM41jMU>+9*NyV>K#!s@`q-1er6~F#Jh+hNJ zWwI6`k92TQM&e`+7OPM`{riO*4^(Vizyg3$ruHFYO$+3PJi#I7Wqoq4WSt$?+P=d1 zEH!irbNqV6Zx6B;DQzb&zO0R*Fw_tr8}J0e4-mWXfM$C&OLx5(=)R0bR!?;-uXDsE zn@UZ^W|Ag8X)*SX#gXU5ohdR%8I`83G@+xfjk`vSqBlz3euMe2%A_VbX&rCUL`)Lm zG#%zhj0j33Mn~?fxLm`^5DEqaf;=hMKALmhL;x0F4K`y`#bjyMVV!4Q5jB0*{*dQ63LO* zjcnkdoA~B92mZ}^(%83<%}VwnH&(pT6Lwd~=dS&WBEN~O(D`qvKI)JbO@Awhkl zmOGe3Bj)UCqWz;JHGx7m>s?6>lav+zW&(`YF!h-gL@GPsuqJQgnx>LTzN%!B%yyIt zgkg+2gO@uud$$tohZs`KXhWuK!iE)kOk?URlMJ)Rg{OUowS2av0}Y&2mn0&LbedWM zoCy^IM-yL?f@CAEWQm;aM|EVw#!CI9(Q5^gkXIC68(?I&?Var@P%6VWnJ{)8kK(Q` zQbAjp5qctH&q>(@$K|#EQ{I+~y@xNJUFZIbM&d$9a88!s#>_KSNTEs(kTH+bzprj~ z_ZQE(d*#4KF?3Q1?quki0Uk%_i}kvujN8!{)k8&nG$k$uU-nJmt5|j@_N5)bW9i4B zaudJ%U_u?RwMj$m%*vyIaf|h`j&>%PLYHBd77AcF0`U4-? zlQZILMvg}8wg(<-3h^+)SCxfJEBBa+-tI-lm>aBsS%2UslfF>liV)SY%a2=pN9-2T z^^Jpo5bX~<##}%r3maI1=2*A>fOEU1#RLQH=t-EXnlBhA{k&gm^;aD;k47F+c{D(d zrECF=zhoY|EfGFG(rtgBt!ydIEKjU7GS)jwc&y)D;2^>Vm%k#iR~zwzHvC^Kx?#Yy zsW^ZpF-=tnMjJkTN!+GtP4=c%A+zh&*s3BE1j^A|G5Z7counpjP!yw-Q8rCHWasD` z%&zn9iq_=~-SqNZZ?Ig}VH0Xn+mF;o$?iCA_qAz7--d)9V$Y$yn{lNcrre2dKI|G> zwy^W9{_pMT)%Siux`13QIYAElvBd6;Ahq&a6Y9R$KSGRGpJTUFFEC)@&_JDN_Pd$E zAo#(MR%F@&*Kw%ukv2iB_o8c*MkLa4WVmUVA;~va#40Ks1@F!swFSto!!Bl*u%=g% zBOI|xMR|W?syNwJy5|g3^?qZwjubXnC0hKUL+m0HaX z0F;4332E)Sq=1`Z4}Dr}#^gze1kPi96MHH=hZItza(~zqO%@%^XkNYCul0Rr@B1iX zy@qc=019cuHgS49eJVOq5F}ZZv`Y@{YB^L;R#`CI(1>@3EKAZlT$>r_Vo&y}Y(F@*bKM zVVj@ajZL{-=s1JhkJ=BjlG_MqHGUh>#zkJ+S<7u?L22ETIj75OXR25(!IA7%6hN?E zpL;&L7!~O9+PfaBlsDgP6t9;>`|JsVG)GYLup$Jx^XJs((}Rf;+NlIIxJ9r4fEo?0 z?vO4lT?mHA%?QKHk+BZbdjRdjyozA2?A z>AzqPHpVcW}V*Q%Nb|EJg|{73;iPjltq6-LPj_`ssB^m%!G11y@??>NFj5;-uHPL zUL~1WKlNHYUZ3%|-K|J(P-}T|aq;@acje})f;R8_vJrW~EAv!Oq}Nqy0DAG5 zU#oZp`a;bE6{R;pmUPW8?>cE_(0_<*OxW$h_@z79C4fJpIDLcx74EO8Dh#xPx}~6~ zDvfVXhg43^BfN4CGsz$hg0G|Ue%`px?wmPm7G39X{;Qy2)aa66lb;yN+!h-&Fuh3& zfo0{!uh;dw(mhF+nReB`RO?6o@lp+XXdAG^eyUgUe zdhT3m9Yi=uccn>4tw`n$p*r^V zV({d&71`xYHL?}l+sFo*anl@zSc+~_IA)R1xDrqs5x|1Aahvt zB;@4x&;Swd@mJw*nrrNQcF_&x<7>;RKP-#V#Zfm@Nvp~1kV4@)oGrk(7@tpDyGE1V zlKCOEDA_Ofqb9F_!{J$XcPoea}0)pQmXR z8N-Y*GLQ9F$dbiUL4&<>FSL=2YEnXy;hT`&(X%LXRjA~hi>v>BarCI6LZFdEa)fh@ zqpYgoGTR=Xm-Ra(urz>>oa>i)9Kr>e6q(`i(hlTl6pT{72EQHBt>N-3}+pHb%P)TD7)KSTZYOi)gz4OK*2bMF8oM zSx+2gGi(cHd9jP#c-T0ZzL zHu@kv;*Fn7-uOHBLXjB5=Da%$A>o!zxDG0MaVD{2bV2Q* z-igLI;D@VZ)gm|#(YkH&0qVqBI$_V^3E^Dj?V}6P&^hR9XYJs>I#CzMhG3B~q9fR{ zv}Am9Dcv>3Gt0>_(A4Yw8eW$B!H;CxW%Pcxar-GOOR2BO@mr@=*G;3#+z?=HD_XC* zpQ6JhoOl9EU0Uz1yF^;wD9?YpdA9w{molG>WYb&PG`;ju8fUJ(NHZ+N;g8y{dQ|A$ zYzVO9T)mW?7iD5gW_y8ms8(aDn@P{kSi4PYq}VFi-T#%{sN4o-yiDh*!4PTb)cL>G zNcMx^`&>h8#riGv4BoSs{-%5NBI&+}zETYVt`FdUln^@^^5+}47iv4m{!rQ`+{B*V zHy4G!kzMfmsa^Gin5c(_byJ+Kp#=c>+t~x3FtS#sE8Di%`gdjoqcLx5(xc zP$nb4n^3{;YDC0Ugkb2*oW})aL5BczbX~SXHz`6hKG9Y$7QJ@EGgqSpSLUk9qKe8! zsBbYKGzw{rq`S)~t>1Lo$hkSLYfeN3?kRi9r19>77nGUd(V>TV?uF&ue=b)G3mn#J zC3BT+eUS~?wWUkW6O4a^d(B^3a;nSTKgVv^0 zjK1E$Zp38!vID>$39$hE@P0<%g68@dBb~galUdcuxDGVoeBvLZiu5|&lQ*3I)A~=EbsZVpBGTEw^c3TGumvW?2b|f$Qd%=~znQEKZj_^^^Gszl zp-mg(us6Khgd#@Y@XYQB()_j~hfHEUYxe~17HyWl;IvqQC}QV2VtS$TfQu6v#6 zGaUk?>_j&7BBOwSgKDEm1Q4!_#TYGH{tcfkq(w^OHhOo&&OCA`p8Xsv zbP-F5u7R!rjD4)ZT}eZPkj7%;2wKPM+|E8r(~-gOWGWRZVur|Fv=Ar{`73T7@2_5n z8HSQVn)CFqar>`T_zoH*xhu(}vxHx}7FX3xt3I7PP2xGCg{JkSG4Cw4rhzA9JxN(e z7(neejS@D|@qVIR2=%VyTfqDUcS4>KXrXY;XKw@iUNHDA!|9}xM5wUcr=rVSFl^Y92o>2?E1k6p+z5qiBs=V>h=4Oa=;&Bv9RxPz2OiBcGc863K z{BEfp2RS=yw4gMhp%ob_M0kp>EJ-69O)a^lu;a3v)L8tQNn7q9 zq#99gmTe1RD{PAA78%Wd5&mv&Cg;HdQ~2!w|6 z;>WZ#tea>gqn~ih)?goR?xxy37QNHG0K7++A>k2_grL>gV$J~v<0Fh)bCWUdu?VmQ zMKNiVBD?$mxRM~5DZ+-lBC$cpSf9b8*H=MS=tWRuxL47t&|VRrK#!n9LL-Vt@Vn<=u=y&B+P6d2Gmho8md;Pj}UfjQp-2*2BzU7h9U4yc>Zl*Iu zMS%MdK0!BFE|XGtMK`Q1C`M&?pzvL<1AAVs_X~Rkgr9N{Kc@Jx&P9V!?*|l9AX_#Q z_W5XRf^R8Rk~DF<#rior$v+4$@89K6dWaAty>d7jEhZbsu*$kjm@CQHm`7!DL}wN1cHxkbBj*EOe3gzQvRZ zNO*LgS8}p99b=l@%OT>MhmPMpl;!kDO?k1~e-Pj?5{&4Y++r2?*80OE5X=P1Jf%0# zfoA9oFhD7YpNJb3|B7OLAw-=xotOwp;W`YoJIgcf+l_%>3q^u zsQjwKTTlB5rlO28vZ{@Lt$`SbLAST>y`pUnasPsQbO6f?U+Tz6OoKLHf46_K=?_1< z&dQ-B1+4Xsr4^A*!hR~BMQk@cJ1f#_Pyu}}l@-CD#(H|q)b*xas{7k;hHRrHv{xp( zo@r}1DTJZ1`MI+|H%TgRg#Eia4PK`2r3z+JgaP_V5k`)wYDy8t1G)*=U?~KPd7OraX*&$Wcf#cwYD)+y?knN^pt1rW*xMflfd2seoe) zD7X@qfi7uS23oenLZ1-hC>zHzpboGMzyTUz18$CKQ9ZZ6`XBQd$Kfjhz(DJ!Sk_5O zjHkr-+5hk8_ecdbsY;ZLYe=bL(ZqX*cd0Mwh%>2mS%ys`yVq)?=p~7u4)8&v9kxDM z^0T$Za!DGL2e`xaShkb`ZTv(oS6GrHPR*4DTzUz{NV8_x)7CE;^aryT`HlHtOBd*W zWy?&-i^joiN#I$PIsw4I0^MnxS#uptB&H;ogo-*_a(i4Qoa#*CB1^ zBB^D74@k106)y0wS0b04&VIX;CTUM6BdgSRHqrMT(ur#^v`DZsjwVhz($KvY;AyhTe3f%VXqB&@ zC@6#H+Xj+&OeWGgNvhFN+N2-IVC489nO3=+A&+@8d*0D8oa0NiwkBofUanu-%3-A1 zSMOi2-w);na+&P|lilg_4sedMzg+js`!PgQwA>-CQ!8f!*n2;a!Hl2`*?U1u(bHh1 zzSFe)N&dlvIzl>eL9U!)-pnLp`jS+c1~;}Ihe|dLD{(La45-Gv7KZkTyBSUK zF$x-;;C3550fd4Wo4$njlA!yH@&q}4P{#(mAIJO&s-|)_Rz%!|V$XL!vUL!4Ac`{` zF%TUTDCJ`S6d^yrnt|+BW`iU2fnb6U1rUsP0>)yf<>Y=Y!XvVr$Zlc|YV0nGgXz=2 zsjyT!n)Y*?+o(qIri_wIXN-mc9`*K=2{C6WvW9Yk(Urk2vGAVQqV?Vbn?nGS#zlj% z3k+=PRFiGyNL|m^jMgC;n-I%ku+Mdf6>SB7UR?A8Sq?xt2O&H6Q*0nEP8V|(oSfN* zriuJnm;w!fBc?QYj>OPB8bHo%+8@JEaqAM~G$Mh5k>bc21e=kO$EbXW^Ksymf#4z) z+GztRAx>_ciO9A&ImyqmPS!XffXj5I;Wl*0~WBB355Fn9?yGZ+!U z=#0_EFx?d8$~pt=CUd=P&58y($909JdOfzeLlyQ012T$F?;qy!9sPq@j9g|u7^iT6 zl@;DY8jW|gY~{@TNk~J*hNPwxBS;@D1hmg4O0@8;1mh&jX`d)EgT8mPc=VD1K9Iq{ z@mWrK6bhxcW|%kY-%+)>&`Eb|nA}(|!PXU^&OT*SCX*UhwcihBF>;ytFo1J!R%V-4 z__={gL&b)qQc#FYikE>ldNcceF#dcB3K7I21%;gbdGYWR6cYK&mx4l4PzX!gG^CY+ zLUR9qf-kxc-uPy_2E=Ev?M_*s~U8-Xi~6YwC5IRR2+8yHnmiPUENPsJ*4LxzFQ$1jiN z4l{n>{a8R8LYo@gyBrA$1f-BWfk!&g2jXZKp=QMAi{YR8E(%!DSf8L&kwbo_N#k!4 zh@h8UtoM;d~=HUf{u_eUNK6zEt zsT2G~H#F-KzJrEd%l8cikJKn=BL4Wwe|&n1p(QB|i(LMzSiVc_N4`TbR8qs7sAobI z#(-dP3JMu4`Vj|`81y3rh1jR3rLscMVN4fozy#qvZbF2vhlOa8rU~7`% z1ShllOJ~#wpuUKY2%=*K@lSn$$}!PcU%47|%yL}xNl___1K^P?7bD`S@*(xj_0#*w zdE{_VzF91dvIKq2Q9F$Ew|>*V`7?BWkO}R_f-{D*HH`Q9Ga21WV(ESA{RhGgxd-u? zf_$hH5u@I1fJ{VE7aGYpx3OQMMpzRw6BW#uBu}y*a5FQ6N;%THE&KO4WdAxpJJzH_ zry4?qrrV#VPDMsy=w^4exY3|9_-uLEGlR}223y7uSM6X+e?PXG2HP&OT;2WQv1Yit zm*|2h!PEv_pcPr6+Qq~fBB5hUWOtu8*!rEgG9R0Ve{97(wxWO5dsr_WYiWmdg-(By zp6Y{6#}oOWy_^9&seyfU>$?6Dv~>G`K6zYUG zC8g>-)KGcTF_gR{E!dO8n?L9@#V-}8x3zvy6@HtKwV2O(Une{#&$&h_wEl+n1oK0k zwfxs0urq6b3P(A-Q@@^MT^k~03!gsNio<*NMggNbuagBpm^YaUC|OVEg$ z(J%>CilxBNFYP3-k?mmo2Vh01PQ=|FYl(yb5z&QtgUAhA12fu7^N59fiBFK_e3Jzf z?1a}T{eRgk!&LfNY+KGIsH?2t$h^mvsdy+1OnHGm@RZ(Q$H!31c!+$;Ck1n*t~X zv-SF6A~KH6O@&p0_RS}3Vg%8IyUsve%G@ybXtQbFj?8DDi2CBJ1~v6}D*G z5N8*w);Aj1|G%VkM86ISfN-QVuhEqa)i7x5%joonP2SBuSrx0Im?QuTTa_)LXCitU z+o!I1>;BD`(sk82*wkltefn(IQ+c5?W(GZLzwEEs9+!i2Fj~p_1&do^q4?p4 z@60%IXUXQPN`@Mn!qdUwG?;w^&*z--_DSVi7MHxW*p{t7HQ_dsfO%4VJPE;5PDb*BgNch{j5G3{@3b2nSBEmc*i=6Z9WQ+nO@?Pp|m-kL^2 z94QRuFUxI{EM~?Xu6L^@{Hlv$T9;&F8$(lgV=V8B%G5sKW_hf?k7{r)la^e2^l{k7 z>P+a+(~hi>Mv5#i0{8Ik-3xUQbm5I=(vGcdeAP;BD|@Nh1~TQ`%y!+}T-&%F>4lGx zEVTUUjT@0(w@pUDawM#MP>yM1rpldN>?0kU$TDF}xv%hrDMc5mgRFrNLgV$w7@_cj z^^1#t68(Zl!?7|P9mA0g=rT}vqbd`{m_F}Tl>O=+;?_*gy5TQ9zn0^*t?#3z38?YK zUpVvt-`9wx$X{R)(NW}3e&Ojoy63Z7yv1lcx569Qf|!H*yb^4WmT6X9lyr7a&S~rg zPO&Wyvu08_kfS|@Z!;H|w%qlGZ_{h|J2g4S@NHS67L$>+hHodZQ92cfeH6}ewrire zh<^(RcB;O(x6rPreF9M*U-w_w9orwS_226qowetrhS|Px;oSeY{S7f9U`&kI#CFwa z>)a6Fj3(fSEuFs`o%K+0%X*pC@Z!M_8yY@Airo;-#OChxR6|OpwRgI$&S%EvKjvPz zF|dBmqi3Pq_+guk&&d6PnOKG)cMz7vId5 zE4@3C%i&6VAFXXn>w}x{$DhF#;XX4D+> zjfPUXkh0U-d*syr`TZ^4?Xmn@2j^(O3p;VQ-lI|bK|_D~@A>Z4^v@)b*+BqYQ7451 z&8WO3-{<=7UeZ-xM0hvD`K?WGf7I+)iWzFv-v7S45jPE0^87^M*sil#IahI5hT;%B zXSUbbHP?BEJZB7#6phuqq))V~kJ1U$dqaS;qN2-?YW!a18nP#=7DK=Yr&#Ut>0UH6NODDZ#Sk_UVPy>&m4NAkgx;VeH7~9GYq&;&mvy@g)uZ*IMJS@gebkGcP zdwbNp+uk}?t+$>>gdjvi;GMC7PZaye{GTaBFHC8Ci0VO&SE4BDZK(?0uvUeW5o zXivO(+clK6y~-QH5UA2v2Zd0*ZmK$z8MfCoHX<3SlDM#TKxQD_rAh z^>koj9cLiv_WGTybN-yy+?c!Xk2#;0U(fTMCKO2$-!-j?dloMj>nTRrmRx|~CG z1<&c{Dr7@##m?eDwMfG3-5mIYTV}mGzzu&CRqUho>owZ6$5J#5cRxfgnbQK*v@MR& zNw1^5M<6bjvnlAvh`B5|pKPBK8AI7ldu0M0R9e;6NSPrMP-7NcdoZek{)&C_PtMIU zZ3fLU((BL;mzxI2U^&L<^!*D{>2c(2a59q=Mn@;k93l^^v2k7UvKN!P)9Z+U5st|X z3t5~_=eq0waWsxx8xs+rXaU8NbB9gC83~?Nl!PbT4~epdpB%6xS;?LPR`95$#32s_*)9dG`<4 zGTs)4d5F#9H^mINoSWJA1SiP>0XLJ`X@M;-LeT*kms{V>X`fBM&UrLC*eJH)HbSUc z89(l43gr))lH{BgKOstut3vfadJUq1bU09Dz!{6PF$*Q}7Z=-Q*l zN#@)vlY9I%9AV>WgB?R~)IMXVz5t^jWDHNKynxr!UDz;fjBWaJ&JzFlΞX=q9>Q$CB|IAjpB?~r}Hwe8m|I%CKkP5ab;19UUwrC zi(Yexr%gBA%3MWz8jKpM=U5VUE>5qbBL!NsskO8dq~pGIA^a>JyahXh2&y4 zQNAqjAlE_Byr`&fzr}XXz&OBE?$tsAIF-nSEc^f)fM6mOoHoFjbly+UB8I~`(KyJv z-Eh|A_!%44RM_FQL>)5=(T9d?1+f>-ai1&q6bL{|t6lDf~P7WPmryh)9Y#W1GPSqu$ej9L*5;bPif+*YvfyHKk%zy>T?3oyt8iA#|vRdUDe#-`Ddq>%+M zGn`KqPBufsupzre@==fQ^k4`E1Aqd_%&^PR{L3-Ot0449$JAy#-(pM@CKbZIhgs|> zfY{`dpzTzx=CfQ81q)o0SNt5P3DAR5+WHP96ltTM@A-Q)NIS~!_4kgeMYwRKl=N6f zcp`_m(R(mEgDOul1>Y!x$R?c@6eXVcAX8oSNlnL$CP*Yc1ltjNnQj@` z#p|$GF2+0r*VzPP=8)9H+NfU0k?!}+o H@$mlzHvYKt literal 0 HcmV?d00001 diff --git a/crates/epiphany-editor-gui/goldens/ten_measure_slurs_castoff.png b/crates/epiphany-editor-gui/goldens/ten_measure_slurs_castoff.png new file mode 100644 index 0000000000000000000000000000000000000000..fa79bade70978fd8d0072dc3e49307de6a7f6ce7 GIT binary patch literal 57891 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 Qf7egE;o4Q-z5SQ}A5s3iZ2$lO literal 0 HcmV?d00001 diff --git a/crates/epiphany-editor-gui/src/goldens.rs b/crates/epiphany-editor-gui/src/goldens.rs new file mode 100644 index 0000000..5dd421f --- /dev/null +++ b/crates/epiphany-editor-gui/src/goldens.rs @@ -0,0 +1,605 @@ +//! Pixel-level golden-image comparator and bless machinery for the score raster +//! this GUI displays (`spec/CONTRACT_EDITOR_T1A_GOLDENS.md`; plan +//! `spec/PLAN_EDITOR_APP.md` §Ruling C, granted 2026-07-23 as amended). +//! +//! **Comparison contract (Ruling C, amended):** a golden is compared as +//! *decoded* RGBA pixels — dimensions first, then raw bytes — never as encoded +//! PNG file bytes. Comparing encoded bytes would also lock the PNG encoder's own +//! behavior (compression level, filter choice, …) and churn on an encoder +//! change even when every pixel is identical; `reencoding_with_different_settings_still_passes` +//! below makes that guarantee executable, not just asserted. +//! +//! This module is declared `#[cfg(test)]`-only at its `mod goldens;` site in +//! `main.rs`, so none of it ships in the built binary. It carries two kinds of +//! test: the comparator's **own** unit tests (T1a W1), which never touch +//! `goldens/` — they point [`assert_golden_at`] at private temp locations, so +//! they hold even before any baseline exists — and the **four golden-state** +//! tests (T1a W2: fixture-as-opened, after a scripted insert, after undo, after +//! casting-off), which call [`assert_golden`] against the three committed +//! baselines under `goldens/` (G3 reuses G1's file rather than owning one; see +//! its test's doc comment). + +use std::fs; +use std::path::{Path, PathBuf}; + +use resvg::tiny_skia::Pixmap; + +/// An opaque highlight color painted over every differing pixel in a `diff.png`. +const DIFF_HIGHLIGHT: [u8; 4] = [255, 0, 0, 255]; + +/// The committed baseline path for a named golden: `{CARGO_MANIFEST_DIR}/goldens/{name}.png`. +fn baseline_path(name: &str) -> PathBuf { + PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/goldens")).join(format!("{name}.png")) +} + +/// The failure-artifact directory for a named golden, resolved repo-root-relative +/// (`{CARGO_MANIFEST_DIR}/../../target/golden-failures/{name}/`, this crate being +/// two levels under the workspace root) so the path is the same regardless of the +/// working directory `cargo test` was invoked from — matching CI's +/// `target/golden-failures/**` upload path. +fn failure_dir_path(name: &str) -> PathBuf { + PathBuf::from(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../target/golden-failures" + )) + .join(name) +} + +/// Compares `pixmap` against the committed baseline `goldens/{name}.png`, +/// writing any failure artifacts under `target/golden-failures/{name}/` (see +/// [`baseline_path`] / [`failure_dir_path`]). +/// +/// **Bless policy:** setting `EPIPHANY_BLESS_GOLDENS=1` writes/overwrites the +/// baseline unconditionally instead of comparing, creating `goldens/` if +/// needed. This is a *reviewed decision* — the tranche's named user deep-dive +/// point on new baselines (plan §Ruling C) — **never** a mechanism for turning a +/// red test green. Do not set it to make a failing test pass; set it only after +/// visually inspecting the new PNG and deciding it is correct. +fn assert_golden(name: &str, pixmap: &Pixmap) { + let baseline = baseline_path(name); + if std::env::var("EPIPHANY_BLESS_GOLDENS").as_deref() == Ok("1") { + write_pixmap(&baseline, pixmap); + return; + } + assert_golden_at(&baseline, pixmap, &failure_dir_path(name)); +} + +/// The comparator's parameterized core: compares `pixmap` against the PNG +/// decoded from `baseline_path`, panicking with the mismatch description (which +/// names every artifact written) on failure. `assert_golden` is the thin +/// default-path wrapper above; tests call this directly with temp-directory +/// paths so they never depend on a committed baseline. +fn assert_golden_at(baseline_path: &Path, pixmap: &Pixmap, failure_dir: &Path) { + if let Err(message) = compare(baseline_path, pixmap, failure_dir) { + panic!("{message}"); + } +} + +/// Result-returning core of the comparison, so tests can observe a mismatch +/// without needing to catch a panic. Reads and decodes the baseline PNG, then: +/// +/// 1. compares dimensions — a mismatch fails here, before any pixel is looked +/// at, and writes `actual.png` + `expected.png` (no `diff.png`: a per-pixel +/// map is not meaningful across differing dimensions); +/// 2. compares decoded RGBA bytes exactly — a mismatch writes all three +/// artifacts (`actual.png`, `expected.png`, `diff.png`) and names them in the +/// returned message. +/// +/// A missing or undecodable baseline file is a harness/setup error, not a +/// reviewable visual diff, so it panics directly rather than returning `Err`. +fn compare(baseline_path: &Path, actual: &Pixmap, failure_dir: &Path) -> Result<(), String> { + let baseline_bytes = fs::read(baseline_path).unwrap_or_else(|err| { + panic!( + "no golden baseline at {} ({err}); run with EPIPHANY_BLESS_GOLDENS=1, after visually \ + reviewing the new image, to create it", + baseline_path.display() + ) + }); + let expected = Pixmap::decode_png(&baseline_bytes).unwrap_or_else(|err| { + panic!( + "golden baseline at {} is not a decodable PNG: {err}", + baseline_path.display() + ) + }); + + if actual.width() != expected.width() || actual.height() != expected.height() { + let actual_path = failure_dir.join("actual.png"); + let expected_path = failure_dir.join("expected.png"); + write_pixmap(&actual_path, actual); + write_pixmap(&expected_path, &expected); + return Err(format!( + "golden mismatch: dimensions differ (actual {}x{} vs expected {}x{}); no pixel \ + comparison performed. Artifacts: {}, {}", + actual.width(), + actual.height(), + expected.width(), + expected.height(), + actual_path.display(), + expected_path.display(), + )); + } + + // The comparison contract itself (Ruling C, amended): decoded pixels, never + // encoded file bytes. An encoder-settings change that leaves every pixel + // identical must never fail this comparison. + let pixels_equal = actual.data() == expected.data(); + if pixels_equal { + return Ok(()); + } + + let (diff, differing) = diff_pixmap(actual, &expected); + let actual_path = failure_dir.join("actual.png"); + let expected_path = failure_dir.join("expected.png"); + let diff_path = failure_dir.join("diff.png"); + write_pixmap(&actual_path, actual); + write_pixmap(&expected_path, &expected); + write_pixmap(&diff_path, &diff); + Err(format!( + "golden mismatch: {differing} of {} decoded pixels differ. Artifacts: {}, {}, {}", + u64::from(actual.width()) * u64::from(actual.height()), + actual_path.display(), + expected_path.display(), + diff_path.display(), + )) +} + +/// Builds a per-pixel highlight image: [`DIFF_HIGHLIGHT`] where `actual` and +/// `expected` disagree, `actual`'s own pixel where they agree (so the diff stays +/// legible against the surrounding score). Returns the image and the count of +/// differing pixels. Callers must have already established equal dimensions. +fn diff_pixmap(actual: &Pixmap, expected: &Pixmap) -> (Pixmap, usize) { + let mut diff = Pixmap::new(actual.width(), actual.height()) + .expect("dimensions already validated equal and nonzero"); + let mut differing = 0usize; + for ((out, a), b) in diff + .data_mut() + .chunks_exact_mut(4) + .zip(actual.data().chunks_exact(4)) + .zip(expected.data().chunks_exact(4)) + { + if a == b { + out.copy_from_slice(a); + } else { + differing += 1; + out.copy_from_slice(&DIFF_HIGHLIGHT); + } + } + (diff, differing) +} + +/// Writes `pixmap` to `path` as a PNG, creating parent directories as needed. +fn write_pixmap(path: &Path, pixmap: &Pixmap) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .unwrap_or_else(|err| panic!("creating {}: {err}", parent.display())); + } + let bytes = pixmap + .encode_png() + .unwrap_or_else(|err| panic!("encoding {}: {err}", path.display())); + fs::write(path, bytes).unwrap_or_else(|err| panic!("writing {}: {err}", path.display())); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::{SystemTime, UNIX_EPOCH}; + + use epiphany_core::{ + CmnNominal, MusicalDuration, MusicalPosition, RationalTime, TypedObjectId, + }; + use epiphany_editor_core::{EditorSession, GridResolution}; + use epiphany_engrave::Engraver; + use epiphany_layout_ir::{HitShape, Point}; + use epiphany_render_svg::{render, RenderOptions}; + use epiphany_testkit::fixtures; + + /// A private, per-call-unique scratch directory under the OS temp dir — W1 + /// has no committed baselines, so every test exercises the comparator + /// against its own throwaway files rather than `goldens/`. + fn unique_temp_dir(tag: &str) -> PathBuf { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock is after the epoch") + .as_nanos(); + std::env::temp_dir().join(format!("epiphany-goldens-test-{tag}-{nanos}-{n}")) + } + + /// A small pixmap with varied (non-uniform) content, alpha 255 throughout so + /// premultiply/demultiply round-trips through PNG encode/decode exactly — + /// deterministic pseudo-noise, not a flat fill, so PNG row filtering and + /// compression have something to actually differ over. + fn varied_pixmap(width: u32, height: u32) -> Pixmap { + let mut pm = Pixmap::new(width, height).expect("nonzero test dimensions"); + for y in 0..height { + for x in 0..width { + let idx = ((y * width + x) * 4) as usize; + let r = ((x.wrapping_mul(37)).wrapping_add(y.wrapping_mul(11)) % 256) as u8; + let g = ((x.wrapping_mul(59)).wrapping_add(y.wrapping_mul(3)) % 256) as u8; + let b = ((x.wrapping_mul(13)).wrapping_add(y.wrapping_mul(91)) % 256) as u8; + pm.data_mut()[idx..idx + 4].copy_from_slice(&[r, g, b, 255]); + } + } + pm + } + + #[test] + fn identical_pixmaps_pass() { + let pixmap = varied_pixmap(5, 4); + let dir = unique_temp_dir("identical"); + let baseline = dir.join("baseline.png"); + write_pixmap(&baseline, &pixmap); + let failure_dir = dir.join("failures"); + + assert_golden_at(&baseline, &pixmap, &failure_dir); + + assert!( + !failure_dir.exists(), + "a passing comparison must not write failure artifacts" + ); + } + + #[test] + fn altered_pixel_fails_and_writes_all_three_artifacts() { + let baseline_pixmap = varied_pixmap(5, 4); + let mut actual_pixmap = baseline_pixmap.clone(); + // Alter exactly one pixel (the second one) in the actual image. + actual_pixmap.data_mut()[4..8].copy_from_slice(&[9, 8, 7, 255]); + + let dir = unique_temp_dir("altered"); + let baseline = dir.join("baseline.png"); + write_pixmap(&baseline, &baseline_pixmap); + let failure_dir = dir.join("failures"); + + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + assert_golden_at(&baseline, &actual_pixmap, &failure_dir); + })); + let payload = result.expect_err("a single-pixel diff must panic"); + let message = payload + .downcast_ref::() + .cloned() + .or_else(|| payload.downcast_ref::<&str>().map(|s| (*s).to_string())) + .expect("panic payload is a string message"); + + let actual_path = failure_dir.join("actual.png"); + let expected_path = failure_dir.join("expected.png"); + let diff_path = failure_dir.join("diff.png"); + assert!(actual_path.is_file(), "actual.png was written"); + assert!(expected_path.is_file(), "expected.png was written"); + assert!(diff_path.is_file(), "diff.png was written"); + assert!( + message.contains(&actual_path.display().to_string()), + "panic message names actual.png's path: {message}" + ); + assert!( + message.contains(&expected_path.display().to_string()), + "panic message names expected.png's path: {message}" + ); + assert!( + message.contains(&diff_path.display().to_string()), + "panic message names diff.png's path: {message}" + ); + } + + #[test] + fn reencoding_with_different_settings_still_passes() { + // Encode the *same* pixels twice, with deliberately different PNG + // encoder settings (filter type and compression level), via the `png` + // crate directly — `tiny_skia::Pixmap::encode_png` exposes no such + // knobs, so this is the only way to prove the comparator does not + // secretly depend on encoder behavior. Pinned to the exact `png` version + // already resolved via `tiny-skia` (Cargo.lock): a dev-only edge to an + // existing node, not a new dependency. + let pixmap = varied_pixmap(6, 5); + + let encode_with = |filter: png::FilterType, compression: png::Compression| -> Vec { + let mut bytes = Vec::new(); + let mut encoder = png::Encoder::new(&mut bytes, pixmap.width(), pixmap.height()); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.set_filter(filter); + encoder.set_compression(compression); + let mut writer = encoder.write_header().expect("valid PNG header"); + writer + .write_image_data(pixmap.data()) + .expect("valid image data"); + drop(writer); + bytes + }; + let bytes_a = encode_with(png::FilterType::NoFilter, png::Compression::Fast); + let bytes_b = encode_with(png::FilterType::Paeth, png::Compression::Best); + assert_ne!( + bytes_a, bytes_b, + "the two encodings must differ byte-wise, or this test is vacuous" + ); + + let dir = unique_temp_dir("reencode"); + let failure_dir = dir.join("failures"); + + let baseline_a = dir.join("a.png"); + fs::create_dir_all(&dir).expect("temp dir created"); + fs::write(&baseline_a, &bytes_a).expect("baseline a written"); + assert_golden_at(&baseline_a, &pixmap, &failure_dir); + + let baseline_b = dir.join("b.png"); + fs::write(&baseline_b, &bytes_b).expect("baseline b written"); + assert_golden_at(&baseline_b, &pixmap, &failure_dir); + + assert!( + !failure_dir.exists(), + "both differently-encoded baselines must decode to the same pixels and pass" + ); + } + + #[test] + fn mismatched_dimensions_fail_before_any_pixel_comparison() { + let baseline_pixmap = varied_pixmap(4, 3); + let differently_sized = varied_pixmap(5, 3); + + let dir = unique_temp_dir("dims"); + let baseline = dir.join("baseline.png"); + write_pixmap(&baseline, &baseline_pixmap); + let failure_dir = dir.join("failures"); + + let err = compare(&baseline, &differently_sized, &failure_dir) + .expect_err("differing dimensions must fail the comparison"); + + assert!( + err.contains("dimensions"), + "failure mentions dimensions: {err}" + ); + assert!( + !err.contains("decoded pixels"), + "failure must not describe a pixel diff: {err}" + ); + assert!( + !failure_dir.join("diff.png").exists(), + "no pixel diff is computable across differing dimensions, so none is written" + ); + } + + // ---- The four golden states (T1a W2) ------------------------------------ + + /// Renders `session`'s resolved layout at `px_per_staff_space` (the demo's + /// default is `12.0`; `RenderOptions`'s other fields default to + /// `GlyphMode::PathOutline`, no fonts) and rasterizes it through + /// `crate::rasterize_pixmap` — the exact pixmap `main.rs`'s + /// `EditorApp::rerender` displays (`main.rs:247`). Returns the SVG string + /// alongside the pixmap so callers can run the determinism double. + fn render_pixmap(session: &EditorSession, px_per_staff_space: f32) -> (String, Pixmap) { + let options = RenderOptions { + px_per_staff_space, + ..Default::default() + }; + let output = render(session.resolved(), &options); + let (pixmap, _logical) = + crate::rasterize_pixmap(&output.svg).expect("a rendered score's SVG rasterizes"); + (output.svg, pixmap) + } + + /// The click point G2 scripts (and G3 replays before undoing) — derived from + /// `session`'s own rendered geometry, never a magic screen constant. + /// + /// At this geometry, ten measures of quarter notes at `px_per_staff_space: + /// 12.0` don't fit one line, so `ten_measure_single_staff` itself casts off + /// into two systems (not only the slurred G4 fixture). That means the + /// score's *temporally* last note is not simply "the rightmost notehead + /// box": the first system happens to render wider than the second, so its + /// notes reach further right on the page even though they come first in + /// time. The last note is instead the rightmost `Pitch`-sourced notehead + /// **within the system with the lowest `bounding_box.origin.y`** — systems + /// stack top-to-bottom in this y-up world, so the lowest one is the last. + /// + /// The click point sits half a staff space past that notehead's right edge + /// (clearly past it, still read as the same system) and two staff spaces + /// above its vertical center — four diatonic steps (a fifth) above the + /// existing all-C4 content under treble clef, landing on a different, + /// mid-staff pitch rather than repeating the fixture's own notes. `staff_pitch_at` / + /// `default_grid_at` / `position_at` resolve this point to exact values, + /// asserted in `g2_ten_measure_insert_matches_baseline`. + fn scripted_insert_target(session: &EditorSession) -> Point { + let last_system = session + .resolved() + .pages + .iter() + .flat_map(|page| &page.systems) + .min_by(|a, b| { + a.bounding_box + .origin + .y + .0 + .total_cmp(&b.bounding_box.origin.y.0) + }) + .expect("casting-off produced at least one system"); + let sys_bottom = last_system.bounding_box.origin.y.0; + let sys_top = sys_bottom + last_system.bounding_box.size.height.0; + + let last_notehead = session + .hit_test() + .regions + .iter() + .filter_map(|r| match (&r.source, r.shape) { + (TypedObjectId::Pitch(_), HitShape::Box(b)) => Some(b), + _ => None, + }) + .filter(|b| { + let mid_y = (b.bottom.0 + b.top.0) / 2.0; + (sys_bottom..=sys_top).contains(&mid_y) + }) + .max_by(|a, b| a.right.0.total_cmp(&b.right.0)) + .expect("the last system renders at least one notehead"); + + Point::new( + last_notehead.right.0 + 0.5, + (last_notehead.bottom.0 + last_notehead.top.0) / 2.0 + 2.0, + ) + } + + /// **G1 — as opened.** `ten_measure_single_staff(0)`, exactly the demo's + /// open path (`main.rs:197`), locked against `goldens/ten_measure_open.png`. + /// This is also the file G3 (below) compares its post-undo raster against. + #[test] + fn g1_ten_measure_open_matches_baseline() { + let score = fixtures::ten_measure_single_staff(0); + let session = EditorSession::open(score, Box::new(Engraver::default())) + .expect("the ten-measure fixture renders under the real engraver"); + + let (svg1, pixmap1) = render_pixmap(&session, 12.0); + let (svg2, pixmap2) = render_pixmap(&session, 12.0); + assert_eq!(svg1, svg2, "G1 determinism double: SVG bytes must match"); + assert_eq!( + pixmap1.data(), + pixmap2.data(), + "G1 determinism double: rasterized pixels must match" + ); + + assert_golden("ten_measure_open", &pixmap1); + } + + /// **G2 — after a scripted pencil insert.** See [`scripted_insert_target`] + /// for the click-point derivation. `staff_pitch_at` / `default_grid_at` / + /// `position_at` are asserted to their exact values *before* the insert + /// runs, then the insert is applied and the result locked against + /// `goldens/ten_measure_insert.png`. + #[test] + fn g2_ten_measure_insert_matches_baseline() { + let score = fixtures::ten_measure_single_staff(0); + let mut session = EditorSession::open(score, Box::new(Engraver::default())) + .expect("the ten-measure fixture renders under the real engraver"); + + let target = scripted_insert_target(&session); + + let pitch = session + .staff_pitch_at(target) + .expect("the target point sits over the staff"); + assert_eq!( + (pitch.nominal, pitch.octave), + (CmnNominal::G, 4), + "the target resolves to G4 — a fifth above the fixture's all-C4 content" + ); + + let grid = session + .default_grid_at(target) + .expect("the target point sits over a metric region"); + assert_eq!( + grid, + GridResolution { + step: MusicalDuration(RationalTime::new(1, 4).expect("1/4 is valid")) + }, + "the 4/4 meter's default grid is a quarter-note step" + ); + + let placed = session + .position_at(target, &grid) + .expect("the target snaps to a musical position"); + assert_eq!( + placed.position, + MusicalPosition(RationalTime::new(10, 1).expect("10/1 is valid")), + "the insert lands exactly at whole-note 10 — immediately after the fixture's \ + last note ends (measure 10, beat 4 of 4), with no gap and nothing to overwrite" + ); + + let outcome = session + .insert_note_at(target, &grid) + .expect("the target is a clean, unoccupied insert slot"); + assert!( + outcome.graph_changed, + "the insert must change the score graph" + ); + + let (svg1, pixmap1) = render_pixmap(&session, 12.0); + let (svg2, pixmap2) = render_pixmap(&session, 12.0); + assert_eq!(svg1, svg2, "G2 determinism double: SVG bytes must match"); + assert_eq!( + pixmap1.data(), + pixmap2.data(), + "G2 determinism double: rasterized pixels must match" + ); + + assert_golden("ten_measure_insert", &pixmap1); + } + + /// **G3 — after undo of G2's insert. No third baseline:** this compares the + /// post-undo raster against **G1's own baseline file**, reached by calling + /// [`assert_golden_at`] directly rather than [`assert_golden`] — deliberately + /// bypassing the bless path. Going through `assert_golden` would let + /// `EPIPHANY_BLESS_GOLDENS=1` overwrite `ten_measure_open.png`: an initial + /// bless run performed before undo is known to be correct would silently + /// bless a broken undo's post-undo pixels as the new "as opened" baseline, + /// after which every future run would compare undo against its own bug + /// instead of against G1. Bypassing `assert_golden` makes that impossible — + /// this comparison always compares, never blesses. A fresh session (rather + /// than continuing G2's) keeps the two tests independent of each other's + /// mutations. + #[test] + fn g3_undo_matches_g1_baseline() { + let score = fixtures::ten_measure_single_staff(0); + let mut session = EditorSession::open(score, Box::new(Engraver::default())) + .expect("the ten-measure fixture renders under the real engraver"); + + let target = scripted_insert_target(&session); + let grid = session + .default_grid_at(target) + .expect("the target point sits over a metric region"); + session + .insert_note_at(target, &grid) + .expect("the target is a clean, unoccupied insert slot"); + + let outcome = session.undo().expect("there is one edit to undo"); + assert!(outcome.graph_changed, "undo must revert the inserted note"); + + let (svg1, pixmap1) = render_pixmap(&session, 12.0); + let (svg2, pixmap2) = render_pixmap(&session, 12.0); + assert_eq!(svg1, svg2, "G3 determinism double: SVG bytes must match"); + assert_eq!( + pixmap1.data(), + pixmap2.data(), + "G3 determinism double: rasterized pixels must match" + ); + + assert_golden_at( + &baseline_path("ten_measure_open"), + &pixmap1, + &failure_dir_path("ten_measure_undo"), + ); + } + + /// **G4 — casting-off.** `ten_measure_with_slurs(0)` (`fixtures.rs:777`): + /// the same ten-measure content as G1–G3 plus three slurs, which at this + /// geometry casts off into multiple systems **and** forces the second slur + /// (events 5..8) across the system break — the cross-system slur-split path + /// (`casting.rs:2262`). The system count is asserted first, as a named + /// value: if casting-off ever stops triggering here, this fails as a + /// reported system count, not a silent pixel diff easily misread as an + /// unrelated rendering regression. + #[test] + fn g4_ten_measure_slurs_castoff_matches_baseline() { + let score = fixtures::ten_measure_with_slurs(0); + let session = EditorSession::open(score, Box::new(Engraver::default())) + .expect("the slurred ten-measure fixture renders under the real engraver"); + + let system_count: usize = session + .resolved() + .pages + .iter() + .map(|page| page.systems.len()) + .sum(); + assert!( + system_count > 1, + "casting-off must produce more than one system, got {system_count}" + ); + + let (svg1, pixmap1) = render_pixmap(&session, 12.0); + let (svg2, pixmap2) = render_pixmap(&session, 12.0); + assert_eq!(svg1, svg2, "G4 determinism double: SVG bytes must match"); + assert_eq!( + pixmap1.data(), + pixmap2.data(), + "G4 determinism double: rasterized pixels must match" + ); + + assert_golden("ten_measure_slurs_castoff", &pixmap1); + } +} diff --git a/crates/epiphany-editor-gui/src/main.rs b/crates/epiphany-editor-gui/src/main.rs index 06c4345..750bfe8 100644 --- a/crates/epiphany-editor-gui/src/main.rs +++ b/crates/epiphany-editor-gui/src/main.rs @@ -150,14 +150,16 @@ fn payload_label(payload: &OperationPayload) -> &'static str { } } -/// Rasterizes a rendered SVG string to an `egui` image, returning the image and the +/// Rasterizes a rendered SVG string to a `tiny_skia` pixmap, returning it and the /// SVG's **logical** (sub-pixel, pre-`ceil`) size. The pixmap dimensions are the /// logical size rounded up to whole pixels; the logical size is what the image must /// be *displayed* at so the click plane maps back to the layout exactly (displaying /// the rounded-up pixmap size would stretch the mapping past the content). The score /// is drawn over an opaque white background so the pixmap's premultiplied alpha is -/// fully opaque, matching `from_rgba_unmultiplied`. -fn rasterize(svg: &str) -> Option<(egui::ColorImage, egui::Vec2)> { +/// fully opaque, matching `from_rgba_unmultiplied` (this crate's only consumer of the +/// pixmap's raw bytes, [`rasterize`], and the golden-image tests in `goldens.rs`, +/// which lock this exact pixmap — the surface the GUI displays). +fn rasterize_pixmap(svg: &str) -> Option<(resvg::tiny_skia::Pixmap, egui::Vec2)> { let tree = resvg::usvg::Tree::from_str(svg, &resvg::usvg::Options::default()).ok()?; let size = tree.size(); let logical = egui::vec2(size.width(), size.height()); @@ -170,8 +172,18 @@ fn rasterize(svg: &str) -> Option<(egui::ColorImage, egui::Vec2)> { resvg::tiny_skia::Transform::identity(), &mut pixmap.as_mut(), ); - let image = - egui::ColorImage::from_rgba_unmultiplied([width as usize, height as usize], pixmap.data()); + Some((pixmap, logical)) +} + +/// Rasterizes a rendered SVG string to an `egui` image (the GUI's display path): a +/// thin conversion over [`rasterize_pixmap`]'s pixmap, see its doc for the sizing and +/// background contract. +fn rasterize(svg: &str) -> Option<(egui::ColorImage, egui::Vec2)> { + let (pixmap, logical) = rasterize_pixmap(svg)?; + let image = egui::ColorImage::from_rgba_unmultiplied( + [pixmap.width() as usize, pixmap.height() as usize], + pixmap.data(), + ); Some((image, logical)) } @@ -527,6 +539,12 @@ impl eframe::App for EditorApp { } } +// The pixel-golden comparator, bless machinery, and the four golden-state tests +// (T1a); see `goldens.rs`'s module doc. Test-only: never compiled into the +// shipped binary. +#[cfg(test)] +mod goldens; + #[cfg(test)] mod tests { use super::*;