btctax_forms/verify.rs
1//! **Geometric, map-INDEPENDENT read-back.** The naive "re-read each value through the same map"
2//! is circular — a swapped-column map would pass. Instead we re-derive the column-x / row-y bands
3//! straight from the bundled PDF's widget `/Rect`s (structural grouping by `Row{n}` subform, column
4//! order by x-position, row order by y-position) and assert that every value we wrote landed in the
5//! band its LOGICAL cell demands. A mis-mapped cell puts the value in the wrong band → we fail
6//! closed. We also assert NO field outside the authorized set carries a value. **The map is what we
7//! distrust; the PDF's geometry is the oracle.**
8
9use crate::error::FormsError;
10use crate::pdf::{button_on_states, checkbox_on, text_value, Field};
11use lopdf::Document;
12use std::collections::{HashMap, HashSet};
13
14const EPS: f32 = 1.0;
15
16/// Where a written value is supposed to land.
17#[derive(Debug, Clone)]
18pub enum Geo {
19 /// A data-grid cell: 0-based row (top→bottom) and column (a=0 … h=7).
20 Data {
21 /// 0-based data row (Row1 = 0, the topmost).
22 row: usize,
23 /// 0-based column (a=0, b=1, … h=7).
24 col: usize,
25 },
26 /// A per-part totals-row cell in column `col`, which must sit BELOW the data grid.
27 Total {
28 /// 0-based column (d=3, e=4, g=6, h=7).
29 col: usize,
30 },
31 /// A checkbox / non-grid value — excluded from the column geometry, still in the no-unmapped set.
32 Check,
33}
34
35/// One authorized write: the field we set, and the logical cell it must occupy.
36#[derive(Debug, Clone)]
37pub struct Placement {
38 /// Fully-qualified field name that was written.
39 pub fqn: String,
40 /// The logical cell the value must land in.
41 pub geo: Geo,
42}
43
44fn page_of(fqn: &str) -> usize {
45 if fqn.contains("Page2") {
46 1
47 } else {
48 0
49 }
50}
51
52/// The row-group key of a data-grid cell: the subform component **immediately after the table token**
53/// (`Row1[0]` on Form 8949, `Line3[0]`/`Line1b[0]` on the 2017 Schedule D whose grid rows are named
54/// `Line{n}`, not `Row{n}`). Rows are later ordered by geometry (widget y-center), so the key need
55/// only be unique per row, not numeric. Returns `None` for a non-grid field.
56fn row_key(fqn: &str, table_token: &str) -> Option<String> {
57 let after = fqn.split_once(table_token)?.1; // e.g. "[0].Row1[0].f1_3[0]"
58 let mut it = after.split('.');
59 it.next()?; // the table token's own "[0]"
60 let key = it.next()?; // "Row1[0]" / "Line3[0]"
61 it.next()?; // require a leaf beyond the row subform (so the table node itself is skipped)
62 Some(key.to_string())
63}
64
65/// Column-x and row-y bands re-derived from one page's data grid — the geometry oracle.
66struct GridBands {
67 /// Per-column-index (0..=7) x-interval `(min_x0, max_x1)`, ordered left→right by geometry.
68 col_x: Vec<(f32, f32)>,
69 /// Per-row-index (0.., top→bottom by geometry) y-interval `(y0, y1)`.
70 row_y: Vec<(f32, f32)>,
71 /// The lowest data-row bottom edge — the totals row must sit below this.
72 min_row_y0: f32,
73}
74
75fn derive_bands(fields: &[Field], page: usize, table_token: &str) -> Result<GridBands, FormsError> {
76 // Group this page's data-grid widgets by structural row subform (independent of the map).
77 let mut rows: HashMap<String, Vec<&Field>> = HashMap::new();
78 for f in fields {
79 if page_of(&f.fqn) == page && f.fqn.contains(table_token) && f.rect.is_some() {
80 if let Some(k) = row_key(&f.fqn, table_token) {
81 rows.entry(k).or_default().push(f);
82 }
83 }
84 }
85 if rows.is_empty() {
86 return Err(FormsError::Structure(format!(
87 "page {page}: no data-grid widgets found for band derivation"
88 )));
89 }
90 // Order rows top→bottom by widget y-center (geometry, NOT the row subform label).
91 let mut ordered: Vec<(String, Vec<&Field>)> = rows.into_iter().collect();
92 let row_cy =
93 |v: &[&Field]| -> f32 { v.iter().filter_map(|f| f.cy()).sum::<f32>() / (v.len() as f32) };
94 ordered.sort_by(|a, b| row_cy(&b.1).partial_cmp(&row_cy(&a.1)).unwrap());
95
96 let ncols = ordered[0].1.len();
97 let mut col_x: Vec<(f32, f32)> = vec![(f32::INFINITY, f32::NEG_INFINITY); ncols];
98 let mut row_y: Vec<(f32, f32)> = Vec::with_capacity(ordered.len());
99 let mut min_row_y0 = f32::INFINITY;
100
101 for (_n, mut widgets) in ordered {
102 if widgets.len() != ncols {
103 return Err(FormsError::Structure(format!(
104 "page {page}: inconsistent column count ({} vs {ncols})",
105 widgets.len()
106 )));
107 }
108 // Column order is defined by x-position, purely from geometry.
109 widgets.sort_by(|a, b| a.rect.unwrap()[0].partial_cmp(&b.rect.unwrap()[0]).unwrap());
110 let mut y0 = f32::INFINITY;
111 let mut y1 = f32::NEG_INFINITY;
112 for (c, w) in widgets.iter().enumerate() {
113 let r = w.rect.unwrap();
114 col_x[c].0 = col_x[c].0.min(r[0]);
115 col_x[c].1 = col_x[c].1.max(r[2]);
116 y0 = y0.min(r[1]);
117 y1 = y1.max(r[3]);
118 }
119 min_row_y0 = min_row_y0.min(y0);
120 row_y.push((y0, y1));
121 }
122 Ok(GridBands {
123 col_x,
124 row_y,
125 min_row_y0,
126 })
127}
128
129/// Whether a coordinate lies within a band (with float tolerance).
130pub fn in_band(v: f32, band: (f32, f32)) -> bool {
131 v >= band.0 - EPS && v <= band.1 + EPS
132}
133
134/// Left→right x-bands of a table's amount columns, re-derived from the PDF geometry (independent of
135/// any map). Used by the Schedule D read-back to catch a mis-mapped d/e/g/h column.
136pub fn column_x_bands(
137 fields: &[Field],
138 page: usize,
139 table_token: &str,
140) -> Result<Vec<(f32, f32)>, FormsError> {
141 Ok(derive_bands(fields, page, table_token)?.col_x)
142}
143
144/// Verify a Form 8949 fill: (1) every written value lands in the geometrically-expected column/row
145/// band, and (2) no unmapped field carries a value. Fails closed. `table_token` is the per-year
146/// data-grid subform token (`Table_Line1` for 2024, `Table_Line1_Part` for 2025).
147pub fn verify_8949(
148 doc: &Document,
149 fields: &[Field],
150 placements: &[Placement],
151 table_token: &str,
152) -> Result<(), FormsError> {
153 let index: HashMap<&str, &Field> = fields.iter().map(|f| (f.fqn.as_str(), f)).collect();
154 // Independently derive the geometry oracle for every page a placement touches (0 = Part I,
155 // 1 = Part II) — up front, so a mis-mapped cell cannot dodge the check.
156 let mut pages: Vec<usize> = placements
157 .iter()
158 .filter(|p| matches!(p.geo, Geo::Data { .. } | Geo::Total { .. }))
159 .map(|p| page_of(&p.fqn))
160 .collect();
161 pages.sort_unstable();
162 pages.dedup();
163 let mut bands: HashMap<usize, GridBands> = HashMap::new();
164 for page in pages {
165 bands.insert(page, derive_bands(fields, page, table_token)?);
166 }
167
168 for p in placements {
169 let field = index
170 .get(p.fqn.as_str())
171 .ok_or_else(|| FormsError::MapFieldMissing(p.fqn.clone()))?;
172 match &p.geo {
173 Geo::Check => {} // geometry N/A; only participates in the no-unmapped scan below
174 Geo::Data { row, col } => {
175 let page = page_of(&p.fqn);
176 let b = &bands[&page];
177 let cx = field.cx().ok_or_else(|| miss_rect(&p.fqn))?;
178 let cy = field.cy().ok_or_else(|| miss_rect(&p.fqn))?;
179 let colb = *b.col_x.get(*col).ok_or_else(|| {
180 FormsError::Geometry(format!("column {col} out of range on page {page}"))
181 })?;
182 let rowb = *b.row_y.get(*row).ok_or_else(|| {
183 FormsError::Geometry(format!("row {row} out of range on page {page}"))
184 })?;
185 if !in_band(cx, colb) {
186 return Err(FormsError::Geometry(format!(
187 "{}: x-center {cx:.1} not in column {col} band {colb:?} (mis-mapped column)",
188 p.fqn
189 )));
190 }
191 if !in_band(cy, rowb) {
192 return Err(FormsError::Geometry(format!(
193 "{}: y-center {cy:.1} not in row {row} band {rowb:?} (mis-mapped row)",
194 p.fqn
195 )));
196 }
197 }
198 Geo::Total { col } => {
199 let page = page_of(&p.fqn);
200 let b = &bands[&page];
201 let cx = field.cx().ok_or_else(|| miss_rect(&p.fqn))?;
202 let cy = field.cy().ok_or_else(|| miss_rect(&p.fqn))?;
203 let colb = *b.col_x.get(*col).ok_or_else(|| {
204 FormsError::Geometry(format!("total column {col} out of range on page {page}"))
205 })?;
206 if !in_band(cx, colb) {
207 return Err(FormsError::Geometry(format!(
208 "{}: total x-center {cx:.1} not in column {col} band {colb:?}",
209 p.fqn
210 )));
211 }
212 if cy >= b.min_row_y0 {
213 return Err(FormsError::Geometry(format!(
214 "{}: total y-center {cy:.1} is not below the data grid (>= {:.1})",
215 p.fqn, b.min_row_y0
216 )));
217 }
218 }
219 }
220 }
221 no_unmapped_filled(doc, fields, placements)
222}
223
224fn miss_rect(fqn: &str) -> FormsError {
225 FormsError::Geometry(format!("{fqn}: field has no /Rect to verify"))
226}
227
228/// Assert that every field carrying a value is in the authorized (placement) set.
229pub fn no_unmapped_filled(
230 doc: &Document,
231 fields: &[Field],
232 placements: &[Placement],
233) -> Result<(), FormsError> {
234 let allowed: HashSet<&str> = placements.iter().map(|p| p.fqn.as_str()).collect();
235 assert_only_filled(doc, fields, &allowed)
236}
237
238/// The form-agnostic core of the no-unmapped guard: every filled field must be in `allowed`. Shared by
239/// the SP1 [`no_unmapped_filled`] and the SP2 flat-form verifier.
240pub fn assert_only_filled(
241 doc: &Document,
242 fields: &[Field],
243 allowed: &HashSet<&str>,
244) -> Result<(), FormsError> {
245 for f in fields {
246 let filled = if f.is_button {
247 checkbox_on(doc, f.id).is_some()
248 } else {
249 text_value(doc, f.id).is_some_and(|s| !s.is_empty())
250 };
251 if filled && !allowed.contains(f.fqn.as_str()) {
252 return Err(FormsError::UnmappedField(f.fqn.clone()));
253 }
254 }
255 Ok(())
256}
257
258// ── [★ R0-C3] SP2 per-form geometric oracle for the FLAT (non-grid) forms ─────────────────────────
259//
260// Schedule SE / Form 8283 / Form 1040 have no `Row{n}` data-grid subform, so the SP1 grid oracle does
261// not fit. Instead this oracle asserts, map-INDEPENDENTLY, that each written value's widget landed:
262// (1) in its logical column's hand-pinned x-cluster (measured from the blank PDF; catches a
263// cross-column swap, e.g. SE line 12 (amount) ↔ line 13 (mid));
264// (2) in strictly-descending center-y within a logically ordered sequence (catches a same-column
265// swap, e.g. SE 10 ↔ 11) — asserted PER descent GROUP so 8283's two-table columns [R0-M1] each
266// descend within their own field set;
267// (3) for the 1040 Digital-Asset question, that the "Yes" value is the LEFT member of the top-most
268// same-y `/Btn` pair whose on-states are exactly {`/1`,`/2`} (a [`topmost_yes_no_pair`] check);
269// (4) on the expected page; 1-pt preprinted-constant spacer fields are never map targets.
270// The map is what we distrust — a mis-mapped cell lands in the wrong cluster / breaks monotonicity and
271// FAILS CLOSED. `assert_only_filled` still guards against any stray write.
272
273/// One authorized SP2 write. `col` indexes a hand-pinned column-x cluster (`None` = geometry-exempt,
274/// e.g. a wide free-text identity field); `descent` = `(group, ordinal)` for per-group strictly-
275/// descending-y ordering (`None` = not in any ordered sequence); `check` marks a checkbox (only in the
276/// no-unmapped scan + any same-y-pair predicate).
277#[derive(Debug, Clone)]
278pub struct FlatPlacement {
279 /// Fully-qualified field name that was written.
280 pub fqn: String,
281 /// 0-based page the write must land on.
282 pub page: usize,
283 /// Logical column index into the per-form hand-pinned x-cluster table (`None` = not column-checked).
284 pub col: Option<usize>,
285 /// `(descent_group, ordinal)` — within a group, center-y must strictly decrease as ordinal rises.
286 pub descent: Option<(u32, u32)>,
287 /// `true` iff this is a checkbox (geometry-exempt; still in the no-unmapped set).
288 pub check: bool,
289}
290
291impl FlatPlacement {
292 /// A column-checked, descent-participating money/text cell.
293 pub fn cell(fqn: impl Into<String>, page: usize, col: usize, grp: u32, ord: u32) -> Self {
294 Self {
295 fqn: fqn.into(),
296 page,
297 col: Some(col),
298 descent: Some((grp, ord)),
299 check: false,
300 }
301 }
302 /// A column-checked cell that does NOT participate in any descent sequence.
303 pub fn col_only(fqn: impl Into<String>, page: usize, col: usize) -> Self {
304 Self {
305 fqn: fqn.into(),
306 page,
307 col: Some(col),
308 descent: None,
309 check: false,
310 }
311 }
312 /// A geometry-exempt write (wide free-text identity field): page-checked + no-unmapped only.
313 pub fn free(fqn: impl Into<String>, page: usize) -> Self {
314 Self {
315 fqn: fqn.into(),
316 page,
317 col: None,
318 descent: None,
319 check: false,
320 }
321 }
322 /// A geometry-exempt write that ALSO participates in a per-group strictly-descending-y ordinal
323 /// sequence — for a free-text field SEQUENCE with no column geometry to check but a real physical
324 /// top-to-bottom order the fill assumes (e.g. Form 8275's Part IV continuation lines, written in
325 /// `part_iv_continuation` array order): a map that reordered the array, or a bundled asset whose
326 /// lines were not actually laid out top-to-bottom in that order, fails closed here instead of
327 /// silently landing text out of sequence.
328 pub fn free_ordered(fqn: impl Into<String>, page: usize, grp: u32, ord: u32) -> Self {
329 Self {
330 fqn: fqn.into(),
331 page,
332 col: None,
333 descent: Some((grp, ord)),
334 check: false,
335 }
336 }
337 /// A checkbox: no-unmapped only (+ any same-y-pair predicate the caller runs).
338 pub fn check(fqn: impl Into<String>, page: usize) -> Self {
339 Self {
340 fqn: fqn.into(),
341 page,
342 col: None,
343 descent: None,
344 check: true,
345 }
346 }
347}
348
349/// Verify a flat-form fill: page membership + hand-pinned column-x membership + per-group ordinal-y
350/// descent + the no-unmapped scan. `clusters` is the per-form hand-pinned logical-column → `(min_x0,
351/// max_x1)` table (measured from the blank PDF). Fails closed.
352pub fn verify_flat(
353 doc: &Document,
354 fields: &[Field],
355 placements: &[FlatPlacement],
356 clusters: &[(f32, f32)],
357) -> Result<(), FormsError> {
358 let index: HashMap<&str, &Field> = fields.iter().map(|f| (f.fqn.as_str(), f)).collect();
359
360 // (4)+(1) page membership + column-x membership.
361 for p in placements {
362 let field = index
363 .get(p.fqn.as_str())
364 .ok_or_else(|| FormsError::MapFieldMissing(p.fqn.clone()))?;
365 if page_of(&p.fqn) != p.page {
366 return Err(FormsError::Geometry(format!(
367 "{}: field is on page {} but placement expected page {}",
368 p.fqn,
369 page_of(&p.fqn),
370 p.page
371 )));
372 }
373 if let Some(col) = p.col {
374 let cx = field.cx().ok_or_else(|| miss_rect(&p.fqn))?;
375 let cluster = *clusters.get(col).ok_or_else(|| {
376 FormsError::Geometry(format!(
377 "column {col} out of range (clusters={})",
378 clusters.len()
379 ))
380 })?;
381 if !in_band(cx, cluster) {
382 return Err(FormsError::Geometry(format!(
383 "{}: x-center {cx:.1} not in column {col} cluster {cluster:?} (mis-mapped column)",
384 p.fqn
385 )));
386 }
387 }
388 }
389
390 // (2) ordinal-y descent, per group.
391 let mut groups: HashMap<u32, Vec<(u32, f32, &str)>> = HashMap::new();
392 for p in placements {
393 if let Some((grp, ord)) = p.descent {
394 let cy = index[p.fqn.as_str()]
395 .cy()
396 .ok_or_else(|| miss_rect(&p.fqn))?;
397 groups
398 .entry(grp)
399 .or_default()
400 .push((ord, cy, p.fqn.as_str()));
401 }
402 }
403 for seq in groups.values_mut() {
404 seq.sort_by_key(|(ord, _, _)| *ord);
405 for w in seq.windows(2) {
406 // Earlier ordinal must sit strictly ABOVE (higher center-y) the next.
407 if w[0].1 <= w[1].1 + EPS {
408 return Err(FormsError::Geometry(format!(
409 "ordinal-y descent broken: {} (y {:.1}) is not strictly above {} (y {:.1}) — mis-mapped row/line",
410 w[0].2, w[0].1, w[1].2, w[1].1
411 )));
412 }
413 }
414 }
415
416 // (5) /MaxLen — a value the cell physically cannot hold. Checked on the READ-BACK (the serialized
417 // bytes), like every other leg of the oracle, and against the PDF's OWN declared capacity rather
418 // than anything the map asserts. A viewer would silently truncate an over-long value; we refuse.
419 for p in placements {
420 let field = index[p.fqn.as_str()];
421 let (Some(max_len), Some(v)) = (field.max_len, text_value(doc, field.id)) else {
422 continue;
423 };
424 // Count CHARACTERS, not bytes — /MaxLen is in characters, and a name can be non-ASCII.
425 let len = v.chars().count();
426 if len > max_len {
427 return Err(FormsError::CellOverflow {
428 fqn: p.fqn.clone(),
429 max_len,
430 len,
431 });
432 }
433 }
434
435 // (3) no unmapped write.
436 let allowed: HashSet<&str> = placements.iter().map(|p| p.fqn.as_str()).collect();
437 assert_only_filled(doc, fields, &allowed)
438}
439
440/// Maximum horizontal gap (widget-center to widget-center, PDF points) between the two boxes of the
441/// Digital-Asset Yes/No pair for them to count as **adjacent**. The real DA "Yes"/"No" boxes sit ~36pt
442/// apart on both the 2024 and 2025 1040; the 2024 **filing-status** `{/1,/2}` row (Single vs MFJ) is a
443/// same-y pair too but its boxes are ~266pt apart — the trap the top-most-y rule fell into. 80pt
444/// brackets the real gap with margin while excluding that non-adjacent row.
445const DA_PAIR_MAX_DX: f32 = 80.0;
446
447/// Map-INDEPENDENT oracle for the 1040 Digital-Asset Yes/No question: the **top-most horizontally
448/// ADJACENT** page-`page` `/Btn` pair (exactly two widgets sharing a center-y, boxes ≤ [`DA_PAIR_MAX_DX`]
449/// apart) whose on-states are exactly {`/1`,`/2`}. Returns `(yes_fqn, no_fqn)` = (LEFT member, right
450/// member). Derived from the blank PDF's widget geometry + appearance states, never the map.
451///
452/// **[R0-C2]** Selecting by adjacency (not merely top-most-y) is what keeps the 2024 fill off the
453/// FILING-STATUS `{/1,/2}` row (Single @ x≈107 vs MFJ @ x≈373, ~266pt apart) that sits ABOVE the DA
454/// pair; the DA "Yes"/"No" boxes are ~36pt apart. Re-verified against 2025 (its DA pair is the top-most
455/// `{/1,/2}` 2-widget row AND adjacent, so no regression).
456pub fn topmost_yes_no_pair(
457 doc: &Document,
458 fields: &[Field],
459 page: usize,
460) -> Result<(String, String), FormsError> {
461 // Group page buttons (that carry a rect + on-states) by rounded center-y.
462 let mut by_y: HashMap<i32, Vec<(&Field, Vec<String>)>> = HashMap::new();
463 for f in fields {
464 if !f.is_button || page_of(&f.fqn) != page {
465 continue;
466 }
467 let Some(cy) = f.cy() else { continue };
468 let states = button_on_states(doc, f.id);
469 if states.is_empty() {
470 continue;
471 }
472 by_y.entry(cy.round() as i32).or_default().push((f, states));
473 }
474 // A qualifying row: EXACTLY two widgets whose combined on-states are exactly {"1","2"} AND whose
475 // boxes are horizontally ADJACENT (≤ DA_PAIR_MAX_DX apart).
476 let mut candidates: Vec<(f32, &Field, &Field)> = Vec::new();
477 for members in by_y.values() {
478 if members.len() != 2 {
479 continue;
480 }
481 let mut states: Vec<&str> = members
482 .iter()
483 .flat_map(|(_, s)| s.iter().map(|x| x.as_str()))
484 .collect();
485 states.sort_unstable();
486 if states != ["1", "2"] {
487 continue;
488 }
489 let (a, b) = (members[0].0, members[1].0);
490 if (a.cx().unwrap() - b.cx().unwrap()).abs() > DA_PAIR_MAX_DX {
491 continue; // non-adjacent (e.g. the 2024 filing-status row) — not the DA pair.
492 }
493 let cy = a.cy().unwrap();
494 candidates.push((cy, a, b));
495 }
496 // Top-most (largest center-y) AMONG the adjacent pairs.
497 candidates.sort_by(|x, y| y.0.partial_cmp(&x.0).unwrap());
498 let (_, a, b) = candidates.first().ok_or_else(|| {
499 FormsError::Geometry(format!(
500 "no adjacent same-y {{/1,/2}} /Btn pair found on page {page}"
501 ))
502 })?;
503 // Left member = the Yes box.
504 if a.cx().unwrap() <= b.cx().unwrap() {
505 Ok((a.fqn.clone(), b.fqn.clone()))
506 } else {
507 Ok((b.fqn.clone(), a.fqn.clone()))
508 }
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514 use crate::pdf;
515
516 /// ★ A value too long for its `/MaxLen` comb cell FAILS CLOSED. This is the guard that makes the
517 /// hyphens-vs-digits question un-losable: the 1040's SSN cells are 9-character combs, so a
518 /// formatted `123-45-6789` (ELEVEN characters) is not a formatting preference — it is a value the
519 /// cell cannot hold, which a PDF viewer would silently truncate or splay across the wrong teeth.
520 /// Silent truncation on a filed return is exactly the class of defect this crate refuses to ship.
521 #[test]
522 fn a_value_over_its_maxlen_comb_cell_fails_closed() {
523 const SSN_CELL: &str = "topmostSubform[0].Page1[0].f1_06[0]";
524 let fill = |value: &str| -> Result<(), FormsError> {
525 let mut doc = pdf::load(pdf::F1040_PDF_2024).unwrap();
526 let index = pdf::index(&pdf::collect_fields(&doc).unwrap());
527 pdf::apply_writes(
528 &mut doc,
529 &index,
530 &[(SSN_CELL.to_string(), pdf::FieldValue::Text(value.into()))],
531 )
532 .unwrap();
533 let bytes = pdf::save(&mut doc).unwrap();
534 let check = pdf::load(&bytes).unwrap();
535 let fields = pdf::collect_fields(&check).unwrap();
536 verify_flat(
537 &check,
538 &fields,
539 &[FlatPlacement::free(SSN_CELL, 0)],
540 &[(0.0, 612.0)],
541 )
542 };
543
544 // Nine bare digits fit exactly.
545 assert!(fill("123456789").is_ok());
546
547 // The hyphenated form is eleven characters — it does NOT fit, and must not be written.
548 let err = fill("123-45-6789").expect_err("an over-long value must fail closed");
549 assert!(
550 matches!(&err, FormsError::CellOverflow { fqn, max_len, len }
551 if fqn == SSN_CELL && *max_len == 9 && *len == 11),
552 "expected CellOverflow, got {err:?}"
553 );
554 }
555}