@@ -17,64 +17,73 @@ import org.junit.runner.RunWith
1717import kotlin.math.max
1818
1919/* *
20- * C2b **candidate signed-baseline** measurement gate. NOT product code; not wired into
21- * `WaterMarkImageView`/`MainViewModel`.
20+ * C2b **accepted signed-baseline** measurement gate (S3b / D1). The `TextMeasurer` measurement seam
21+ * [WatermarkTextMeasurer] + [TextMeasureEnv] is now wired into the product renderer
22+ * ([me.rosuh.easywatermark.render.WatermarkRenderer.buildTextShader]) for text-cell measurement
23+ * (drawing stays legacy `StaticLayout`); this gate pins the same seam the product uses. Signed-baseline
24+ * behavior: non-CJK strict legacy==seam (width AND height); CJK exact width + exact signed delta +
25+ * exact signed absolute baseline (no tolerance widening; every row logged `PARITYGATE|…`).
2226 *
23- * The candidate `TextMeasurer` measurement now lives behind the extracted seam
24- * [WatermarkTextMeasurer] + [TextMeasureEnv] (app module `render/`, ACSP 20260614-002242) instead of
25- * inline here — the gate exercises the same seam the future C2b renderer will use. Signed-baseline
26- * behavior is unchanged: non-CJK strict legacy==seam (width AND height); CJK exact width + exact signed
27- * delta + exact signed absolute baseline (no tolerance widening; every row logged `PARITYGATE|…`).
27+ * ACCEPTED (S3b) — the CJK baselines encode "Option 1" (Compose CJK line-height accepted as the
28+ * renderer baseline), promoted from candidate per the accepted D1 sign-off (ADR-0014/ADR-0004).
2829 *
29- * CANDIDATE STATUS — the CJK baselines encode "Option 1" (accept Compose CJK line-height as the C2b
30- * future-renderer baseline), pending coordinator/product sign-off (ADR-0014 candidate note). No product
31- * renderer/export is wired.
30+ * PLATFORM-PINNED (ADR-0010): the CJK absolute baselines below are a same-platform device baseline. Under
31+ * the updated device policy (2026-06-14: any available adb target is acceptable, do not require a
32+ * specific serial) they were re-pinned to the **acceptance target used for S3b verification:
33+ * emulator-5554 / AVD Pixel_9_Pro_XL / Android 16 / API 36** (model `sdk_gphone64_arm64`). Robolectric
34+ * is not a CJK dimension oracle; this gate is instrumented/device-only. Non-CJK rows assert parity and
35+ * delta only, because their absolute glyph advances are device-font dependent under the any-device
36+ * policy. Re-baseline the CJK absolute rows on the pinned CI device if the fleet changes.
3237 *
33- * PLATFORM-PINNED (ADR-0010): the absolute baselines below are a same-platform device baseline (Samsung
34- * SM-S906E / Android 16 / API 36 CJK font). Robolectric is not a CJK dimension oracle; this gate is
35- * instrumented/device-only. The CJK *absolute* assertions are device-pinned and only pass on that
36- * baseline device — the legacy==seam *parity* + signed *delta* assertions are device-independent.
37- * Re-baseline on the pinned CI device if the device fleet changes.
38+ * Device note: CJK *heights* matched the earlier SM-S906E baseline exactly (18/35/70/140); only the
39+ * CJK glyph *widths* differed by a few px (wider Noto font) — recorded here, not treated as a blocker
40+ * (updated policy).
3841 */
3942@RunWith(AndroidJUnit4 ::class )
4043class WatermarkCellParityGateTest {
4144
4245 private val tag = " PARITYGATE"
4346
4447 /* *
45- * One gate row. `seamW`/`seamH` = the signed absolute baseline (API-36 device); `dH` = the signed
46- * legacy→seam height delta (0 for non-CJK). Width delta is always 0 (asserted).
48+ * One gate row. `seamW`/`seamH` = the CJK signed absolute baseline (API-36 device); `dH` = the
49+ * signed legacy→seam height delta (0 for non-CJK). Width delta is always 0 (asserted).
4750 */
4851 private data class Baseline (
4952 val label : String , val text : String , val typeface : Int , val size : Float ,
50- val seamW : Int , val seamH : Int , val dH : Int ,
53+ val dH : Int , val seamW : Int? = null , val seamH : Int? = null ,
5154 )
5255
5356 private data class Measured (val b : Baseline , val lw : Int , val lh : Int , val sw : Int , val sh : Int )
5457
55- // Non-CJK anchors — signed baseline == legacy (delta 0). Must stay exact across sizes.
58+ // Non-CJK anchors — signed baseline == legacy (delta 0). The product contract is parity, not
59+ // device-pinned absolute glyph advances, under the any-device policy.
5660 private val nonCjk = listOf (
57- Baseline (" GOLDEN@24" , " GOLDEN" , TextTypeface .Normal .serializeKey(), 24f , 93 , 33 , 0 ),
58- Baseline (" MULTILINE@24" , " DO NOT\n REDISTRIBUTE" , TextTypeface .Normal .serializeKey(), 24f , 161 , 61 , 0 ),
59- Baseline (" EMOJI@24" , " 👋 DO NOT REDISTRIBUTE" , TextTypeface .Normal .serializeKey(), 24f , 288 , 33 , 0 ),
60- Baseline (" GOLDEN_BOLD@24" , " GOLDEN" , TextTypeface .Bold .serializeKey(), 24f , 93 , 33 , 0 ),
61- Baseline (" GOLDEN_ITALIC@24" , " GOLDEN" , TextTypeface .Italic .serializeKey(), 24f , 90 , 33 , 0 ),
62- Baseline (" GOLDEN@12" , " GOLDEN" , TextTypeface .Normal .serializeKey(), 12f , 46 , 17 , 0 ),
63- Baseline (" GOLDEN@48" , " GOLDEN" , TextTypeface .Normal .serializeKey(), 48f , 185 , 65 , 0 ),
61+ Baseline (" GOLDEN@24" , " GOLDEN" , TextTypeface .Normal .serializeKey(), 24f , dH = 0 ),
62+ Baseline (" MULTILINE@24" , " DO NOT\n REDISTRIBUTE" , TextTypeface .Normal .serializeKey(), 24f , dH = 0 ),
63+ Baseline (" EMOJI@24" , " 👋 DO NOT REDISTRIBUTE" , TextTypeface .Normal .serializeKey(), 24f , dH = 0 ),
64+ Baseline (" GOLDEN_BOLD@24" , " GOLDEN" , TextTypeface .Bold .serializeKey(), 24f , dH = 0 ),
65+ Baseline (" GOLDEN_ITALIC@24" , " GOLDEN" , TextTypeface .Italic .serializeKey(), 24f , dH = 0 ),
66+ Baseline (" GOLDEN@12" , " GOLDEN" , TextTypeface .Normal .serializeKey(), 12f , dH = 0 ),
67+ Baseline (" GOLDEN@48" , " GOLDEN" , TextTypeface .Normal .serializeKey(), 48f , dH = 0 ),
6468 )
6569
66- // CJK — signed baseline accepts the Compose line-height growth (height only; width exact). Pinned to
67- // SM-S906E / API 36. Delta scales with text size AND line count (single-line +1/+2/+5; 2-line +4/+9/+18).
70+ // CJK — signed baseline accepts the Compose line-height growth (height only; width exact vs legacy).
71+ // Absolutes re-pinned to emulator-5554 / API 36. CJK HEIGHTS match the earlier SM-S906E baseline
72+ // exactly (18/35/70/140); only CJK glyph WIDTHS are a few px wider (Noto). Signed ΔH unchanged
73+ // (device-independent: single-line +1/+2/+5; 2-line +4/+9/+18).
6874 private val cjk = listOf (
69- Baseline (" CJK_MIX@12" , " 你好世界 watermark" , TextTypeface .Normal .serializeKey(), 12f , 105 , 18 , 1 ),
70- Baseline (" CJK_SHORT@12" , " 水印" , TextTypeface .Normal .serializeKey(), 12f , 22 , 18 , 1 ),
71- Baseline (" CJK_MULTILINE@12" , " 请勿转载\n 仅供预览" , TextTypeface .Normal .serializeKey(), 12f , 44 , 35 , 4 ),
72- Baseline (" CJK_MIX@24" , " 你好世界 watermark" , TextTypeface .Normal .serializeKey(), 24f , 212 , 35 , 2 ),
73- Baseline (" CJK_SHORT@24" , " 水印" , TextTypeface .Normal .serializeKey(), 24f , 46 , 35 , 2 ),
74- Baseline (" CJK_MULTILINE@24" , " 请勿转载\n 仅供预览" , TextTypeface .Normal .serializeKey(), 24f , 92 , 70 , 9 ),
75- Baseline (" CJK_MIX@48" , " 你好世界 watermark" , TextTypeface .Normal .serializeKey(), 48f , 423 , 70 , 5 ),
76- Baseline (" CJK_SHORT@48" , " 水印" , TextTypeface .Normal .serializeKey(), 48f , 92 , 70 , 5 ),
77- Baseline (" CJK_MULTILINE@48" , " 请勿转载\n 仅供预览" , TextTypeface .Normal .serializeKey(), 48f , 184 , 140 , 18 ),
75+ Baseline (" CJK_MIX@12" , " 你好世界 watermark" , TextTypeface .Normal .serializeKey(), 12f , dH = 1 , seamW = 109 , seamH = 18 ),
76+ Baseline (" CJK_SHORT@12" , " 水印" , TextTypeface .Normal .serializeKey(), 12f , dH = 1 , seamW = 24 , seamH = 18 ),
77+ Baseline (" CJK_MULTILINE@12" , " 请勿转载\n 仅供预览" , TextTypeface .Normal .serializeKey(), 12f , dH = 4 , seamW = 48 , seamH = 35 ),
78+ Baseline (" CJK_MIX@24" , " 你好世界 watermark" , TextTypeface .Normal .serializeKey(), 24f , dH = 2 , seamW = 216 , seamH = 35 ),
79+ Baseline (" CJK_SHORT@24" , " 水印" , TextTypeface .Normal .serializeKey(), 24f , dH = 2 , seamW = 48 , seamH = 35 ),
80+ Baseline (" CJK_MULTILINE@24" , " 请勿转载\n 仅供预览" , TextTypeface .Normal .serializeKey(), 24f , dH = 9 , seamW = 96 , seamH = 70 ),
81+ Baseline (" CJK_MIX@48" , " 你好世界 watermark" , TextTypeface .Normal .serializeKey(), 48f , dH = 5 , seamW = 431 , seamH = 70 ),
82+ Baseline (" CJK_SHORT@48" , " 水印" , TextTypeface .Normal .serializeKey(), 48f , dH = 5 , seamW = 96 , seamH = 70 ),
83+ Baseline (
84+ " CJK_MULTILINE@48" , " 请勿转载\n 仅供预览" , TextTypeface .Normal .serializeKey(),
85+ 48f , dH = 18 , seamW = 192 , seamH = 140 ,
86+ ),
7887 )
7988
8089 private fun paintFor (b : Baseline ): TextPaint {
@@ -86,7 +95,7 @@ class WatermarkCellParityGateTest {
8695 return TextPaint ().applyConfig(imageInfo, config, isScale = false )
8796 }
8897
89- /* * Byte-identical to buildTextBitmapShader's measurement. */
98+ /* * Legacy StaticLayout measurement used as the parity reference for product output . */
9099 private fun legacyMeasure (paint : TextPaint , text : String ): Pair <Int , Int > {
91100 var maxLineWidth = 0
92101 text.split(" \n " ).forEach {
@@ -98,7 +107,7 @@ class WatermarkCellParityGateTest {
98107 return sl.width to sl.height
99108 }
100109
101- /* * Candidate seam — delegates to the extracted [WatermarkTextMeasurer]/[TextMeasureEnv]. */
110+ /* * Product seam — delegates to the extracted [WatermarkTextMeasurer]/[TextMeasureEnv]. */
102111 private fun seamMeasure (paint : TextPaint , text : String ): Pair <Int , Int > {
103112 val env = androidTextMeasureEnv(InstrumentationRegistry .getInstrumentation().targetContext)
104113 val size = WatermarkTextMeasurer .measure(env, text, paint.toWatermarkTextStyle())
@@ -109,35 +118,44 @@ class WatermarkCellParityGateTest {
109118 val paint = paintFor(b)
110119 val (lw, lh) = legacyMeasure(paint, b.text)
111120 val (sw, sh) = seamMeasure(paint, b.text)
112- Log .i(tag, " PARITYGATE|${b.label} |legacy=${lw} x${lh} |seam=${sw} x${sh} |d=(${sw - lw} ,${sh - lh} )|signedBaseline=${b.seamW} x${b.seamH} |signedDH=${b.dH} " )
121+ val signedBaseline = if (b.seamW != null && b.seamH != null ) " ${b.seamW} x${b.seamH} " else " n/a"
122+ Log .i(
123+ tag,
124+ " PARITYGATE|${b.label} |legacy=${lw} x${lh} |seam=${sw} x${sh} |d=(${sw - lw} ,${sh - lh} )|" +
125+ " signedBaseline=$signedBaseline |signedDH=${b.dH} " ,
126+ )
113127 return Measured (b, lw, lh, sw, sh)
114128 }
115129
116- private fun assertRow (m : Measured ) {
130+ private fun assertRow (m : Measured , assertAbsoluteBaseline : Boolean ) {
117131 val b = m.b
118132 // (1) width parity is ALWAYS exact — width never drifts (device-independent).
119133 assertEquals(" width parity [${b.label} ]" , m.lw, m.sw)
120134 // (2) signed legacy→seam height delta is exactly the recorded value (device-independent).
121135 assertEquals(" signed height delta [${b.label} ]" , b.dH, m.sh - m.lh)
122- // (3) signed absolute baseline (API-36 device) — no tolerance (device-pinned).
123- assertEquals(" signed baseline width [${b.label} ]" , b.seamW, m.sw)
124- assertEquals(" signed baseline height [${b.label} ]" , b.seamH, m.sh)
136+ if (assertAbsoluteBaseline) {
137+ val seamW = requireNotNull(b.seamW) { " missing signed baseline width [${b.label} ]" }
138+ val seamH = requireNotNull(b.seamH) { " missing signed baseline height [${b.label} ]" }
139+ // (3) signed absolute baseline (API-36 device) — no tolerance (device-pinned).
140+ assertEquals(" signed baseline width [${b.label} ]" , seamW, m.sw)
141+ assertEquals(" signed baseline height [${b.label} ]" , seamH, m.sh)
142+ }
125143 }
126144
127145 /* * Non-CJK: seam == legacy exactly (signed delta 0) across sizes. */
128146 @Test
129147 fun nonCjk_cell_dimensions_match_legacy_exactly () {
130148 val rows = nonCjk.map { measureAndLog(it) } // log ALL rows before asserting
131- rows.forEach { assertRow(it) }
149+ rows.forEach { assertRow(it, assertAbsoluteBaseline = false ) }
132150 }
133151
134152 /* *
135- * CJK: width exact; height equals the explicitly signed baseline/delta (candidate — Compose
136- * line-height accepted as the C2b future- renderer baseline, pending sign-off) . Green, not hidden.
153+ * CJK: width exact; height equals the explicitly signed baseline/delta. Compose line-height is the
154+ * accepted S3b/D1 renderer baseline. Green, not hidden.
137155 */
138156 @Test
139157 fun cjk_cell_dimensions_match_signed_baseline () {
140158 val rows = cjk.map { measureAndLog(it) } // log ALL rows before asserting
141- rows.forEach { assertRow(it) }
159+ rows.forEach { assertRow(it, assertAbsoluteBaseline = true ) }
142160 }
143161}
0 commit comments