mod common;
use common::*;
use btctax_core::{Form8949Part, ScheduleDPart, ScheduleDTotals};
use btctax_forms::testonly::*;
use btctax_forms::FormsError;
use sha2::{Digest, Sha256};
fn f8949_map_field_names() -> Vec<String> {
let m = Form8949Map::ty2025();
let mut names = Vec::new();
for p in &m.parts {
names.push(p.box_field.clone());
names.push(p.totals.proceeds_d.clone());
names.push(p.totals.cost_e.clone());
names.push(p.totals.adj_g.clone());
names.push(p.totals.gain_h.clone());
for row in &p.rows {
names.extend(row.iter().cloned());
}
}
names
}
fn schedule_d_map_field_names() -> Vec<String> {
let m = ScheduleDMap::ty2025();
let a = |c: &btctax_forms::testonly::AmountCols| {
vec![
c.proceeds_d.clone(),
c.cost_e.clone(),
c.adj_g.clone(),
c.gain_h.clone(),
]
};
let mut names = a(&m.line3);
names.extend(a(&m.line10));
names.push(m.line7_h.clone());
names.push(m.line15_h.clone());
names.push(m.line16_h.clone());
names.push(m.qof_yes.as_ref().unwrap().field.clone());
names.push(m.qof_no.as_ref().unwrap().field.clone());
names
}
fn fieldset(pdf: &[u8]) -> std::collections::HashSet<String> {
let doc = load(pdf).unwrap();
collect_fields(&doc)
.unwrap()
.into_iter()
.map(|f| f.fqn)
.collect()
}
#[test]
fn output_has_no_xfa() {
let bytes = btctax_forms::fill_form_8949(&mixed_rows(), 2025).unwrap();
let doc = load(&bytes).unwrap();
assert!(
!pdf_has_xfa(&doc).unwrap(),
"the /XFA layer must be removed"
);
let sd = btctax_forms::fill_schedule_d(&totals_for(&mixed_rows()), 2025).unwrap();
assert!(!pdf_has_xfa(&load(&sd).unwrap()).unwrap());
}
#[test]
fn filled_value_persists_after_xfa_drop() {
let bytes = btctax_forms::fill_form_8949(&mixed_rows(), 2025).unwrap();
let doc = load(&bytes).unwrap();
let idx = index(&collect_fields(&doc).unwrap());
let a1 = &idx["topmostSubform[0].Page1[0].Table_Line1_Part1[0].Row1[0].f1_03[0]"];
assert_eq!(text_value(&doc, a1.id).as_deref(), Some("0.53000000 BTC"));
}
#[test]
fn ty2025_bitcoin_uses_box_i_and_l() {
let bytes = btctax_forms::fill_form_8949(&mixed_rows(), 2025).unwrap();
let doc = load(&bytes).unwrap();
let idx = index(&collect_fields(&doc).unwrap());
assert_eq!(
checkbox_on(&doc, idx["topmostSubform[0].Page1[0].c1_1[5]"].id).as_deref(),
Some("6"),
"Box I must be checked for short-term BTC"
);
assert_eq!(
checkbox_on(&doc, idx["topmostSubform[0].Page2[0].c2_1[5]"].id).as_deref(),
Some("6"),
"Box L must be checked for long-term BTC"
);
assert_eq!(
checkbox_on(&doc, idx["topmostSubform[0].Page1[0].c1_1[2]"].id),
None,
"Box C must stay OFF — BTC is a digital asset"
);
assert_eq!(
checkbox_on(&doc, idx["topmostSubform[0].Page2[0].c2_1[2]"].id),
None,
"Box F must stay OFF"
);
}
#[test]
fn filled_values_land_in_expected_geometry() {
let bytes = btctax_forms::fill_form_8949(&mixed_rows(), 2025).unwrap();
let doc = load(&bytes).unwrap();
let idx = index(&collect_fields(&doc).unwrap());
let d1 = &idx["topmostSubform[0].Page1[0].Table_Line1_Part1[0].Row1[0].f1_06[0]"];
assert_eq!(text_value(&doc, d1.id).as_deref(), Some("30000.50"));
let cx = d1.cx().unwrap();
assert!(
(273.6..=337.65).contains(&cx),
"row-1 proceeds x-center {cx} must be in column (d) band"
);
}
#[test]
fn fault_injected_column_swap_is_red() {
let rows = mixed_rows();
let (st, lt) = split_parts(&rows);
let short = part_data(&st).unwrap();
let long = part_data(<).unwrap();
let mut map = Form8949Map::ty2025();
let sp = map.parts.iter_mut().find(|p| p.term == "short").unwrap();
for row in &mut sp.rows {
row.swap(3, 4); }
let err = fill_8949_parts(&short, &long, &map).unwrap_err();
assert!(
matches!(err, FormsError::Geometry(_)),
"swapped columns must fail the geometric read-back, got {err:?}"
);
}
#[test]
fn no_unmapped_field_filled() {
let bytes = btctax_forms::fill_form_8949(&mixed_rows(), 2025).unwrap();
let doc = load(&bytes).unwrap();
let fields = collect_fields(&doc).unwrap();
let allowed: std::collections::HashSet<String> = f8949_map_field_names().into_iter().collect();
for f in &fields {
let filled = if f.is_button {
checkbox_on(&doc, f.id).is_some()
} else {
text_value(&doc, f.id).is_some_and(|s| !s.is_empty())
};
if filled {
assert!(
allowed.contains(&f.fqn),
"unexpected filled field outside the map: {}",
f.fqn
);
}
}
let err = no_unmapped_filled(&doc, &fields, &[]).unwrap_err();
assert!(matches!(err, FormsError::UnmappedField(_)), "got {err:?}");
}
#[test]
fn map_2025_matches_bundled_pdf_fieldset() {
let set = fieldset(F8949_PDF_2025);
for name in f8949_map_field_names() {
assert!(
set.contains(&name),
"8949 map field absent from PDF: {name}"
);
}
let sd_set = fieldset(SCHEDULE_D_PDF_2025);
for name in schedule_d_map_field_names() {
assert!(
sd_set.contains(&name),
"schedule_d map field absent from PDF: {name}"
);
}
}
#[test]
fn rows_per_page_is_map_data() {
let m = Form8949Map::ty2025();
assert_eq!(m.rows_per_page, 11, "TY2025 8949 is 11 rows/part/page");
for p in &m.parts {
assert_eq!(
p.rows.len(),
m.rows_per_page,
"part {} must enumerate exactly {} rows",
p.term,
m.rows_per_page
);
for row in &p.rows {
assert_eq!(row.len(), 8, "each 8949 data row has 8 columns (a..h)");
}
}
}
#[test]
fn fill_is_byte_deterministic() {
let rows = mixed_rows();
let a = btctax_forms::fill_form_8949(&rows, 2025).unwrap();
let b = btctax_forms::fill_form_8949(&rows, 2025).unwrap();
assert_eq!(a, b, "same (data, form) must produce byte-identical output");
let hash = hex(&Sha256::digest(&a));
assert_eq!(
hash, GOLDEN_F8949_SHA256,
"fill output changed — if intentional, update GOLDEN_F8949_SHA256"
);
}
const GOLDEN_F8949_SHA256: &str =
"d981a64548a9a2971ed35cd953e1dbda643665242e252cf3721012800b580906";
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
#[test]
fn schedule_d_fills_3_7_10_15_16_and_qof() {
let totals = ScheduleDTotals {
st: ScheduleDPart {
proceeds: rust_decimal_macros::dec!(36000.50),
cost_basis: rust_decimal_macros::dec!(30500),
gain: rust_decimal_macros::dec!(5500.50),
},
lt: ScheduleDPart {
proceeds: rust_decimal_macros::dec!(60000),
cost_basis: rust_decimal_macros::dec!(20000),
gain: rust_decimal_macros::dec!(40000),
},
};
let bytes = btctax_forms::fill_schedule_d(&totals, 2025).unwrap();
let doc = load(&bytes).unwrap();
let idx = index(&collect_fields(&doc).unwrap());
let v = |fqn: &str| text_value(&doc, idx[fqn].id);
assert_eq!(
v("topmostSubform[0].Page1[0].Table_PartI[0].Row3[0].f1_15[0]").as_deref(),
Some("36000.50")
);
assert_eq!(
v("topmostSubform[0].Page1[0].Table_PartI[0].Row3[0].f1_18[0]").as_deref(),
Some("5500.50")
);
assert_eq!(
v("topmostSubform[0].Page1[0].f1_22[0]").as_deref(),
Some("5500.50")
);
assert_eq!(
v("topmostSubform[0].Page1[0].Table_PartII[0].Row10[0].f1_35[0]").as_deref(),
Some("60000")
);
assert_eq!(
v("topmostSubform[0].Page1[0].f1_43[0]").as_deref(),
Some("40000")
);
assert_eq!(
v("topmostSubform[0].Page2[0].f2_1[0]").as_deref(),
Some("45500.50")
);
assert_eq!(
checkbox_on(&doc, idx["topmostSubform[0].Page1[0].c1_1[1]"].id).as_deref(),
Some("2")
);
assert_eq!(
checkbox_on(&doc, idx["topmostSubform[0].Page1[0].c1_1[0]"].id),
None
);
}
#[test]
fn schedule_d_totals_match_form8949_and_csv() {
let rows = mixed_rows();
let totals = totals_for(&rows);
let sd = btctax_forms::fill_schedule_d(&totals, 2025).unwrap();
let sdoc = load(&sd).unwrap();
let sidx = index(&collect_fields(&sdoc).unwrap());
let sd_line3_proceeds = text_value(
&sdoc,
sidx["topmostSubform[0].Page1[0].Table_PartI[0].Row3[0].f1_15[0]"].id,
);
let f8949 = btctax_forms::fill_form_8949(&rows, 2025).unwrap();
let fdoc = load(&f8949).unwrap();
let fidx = index(&collect_fields(&fdoc).unwrap());
let f8949_st_proceeds = text_value(&fdoc, fidx["topmostSubform[0].Page1[0].f1_91[0]"].id);
let csv_st_proceeds = totals.st.proceeds.to_string();
assert_eq!(sd_line3_proceeds.as_deref(), Some(csv_st_proceeds.as_str()));
assert_eq!(f8949_st_proceeds.as_deref(), Some(csv_st_proceeds.as_str()));
assert_eq!(
totals.st.proceeds,
sum_part(&rows, Form8949Part::ShortTerm).proceeds
);
}
#[test]
fn schedule_d_line3_10_accept_i_l() {
let totals = totals_for(&mixed_rows());
let bytes = btctax_forms::fill_schedule_d(&totals, 2025).unwrap();
let doc = load(&bytes).unwrap();
let idx = index(&collect_fields(&doc).unwrap());
assert!(text_value(
&doc,
idx["topmostSubform[0].Page1[0].Table_PartI[0].Row3[0].f1_15[0]"].id
)
.is_some());
assert!(text_value(
&doc,
idx["topmostSubform[0].Page1[0].Table_PartII[0].Row10[0].f1_35[0]"].id
)
.is_some());
}
#[test]
fn rows_possibly_broker_reported_counts_exchange_rows() {
assert_eq!(
btctax_forms::rows_possibly_broker_reported(&mixed_rows()),
1
);
}
#[test]
fn unsupported_year_is_rejected() {
let err = btctax_forms::fill_form_8949(&mixed_rows(), 2023).unwrap_err();
assert!(
matches!(err, FormsError::UnsupportedYear(2023)),
"got {err:?}"
);
}