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