//! The hit-test probe table (`ROUND2_TEXT_RECIPE.md` §7 closing paragraph): //! a committed, per-fixture table of `(device point) -> (byte offset, //! affinity)` probes, derived from `SpikeResolvedText`'s own caret-stop data //! via recipe §3's transform. //! //! **This is candidate-testing apparatus, not part of the candidate-neutral //! §3E mirror** — `crate::fixtures`/`crate::output` build and validate //! `SpikeResolvedText` itself; this module only *reads* an already-built, //! already-validated [`crate::output::FixtureFile`] and derives a table from //! it. That is why it lives in a sibling file, `hittest_probes.json`, rather //! than as extra fields on `fixtures.json`'s own records — see //! `bin/generate_hittest.rs`'s doc comment for the full reasoning. //! //! ## The hit-test semantics this table commits to //! //! The recipe names the *rule for generating probes* ("midpoint of each //! adjacent grapheme... at least 4 device px from any stop") but not the //! *hit-test semantics a probe's expected answer assumes*. This module picks //! the simplest one that is well-defined everywhere a probe can legally sit //! (i.e. everywhere the 4 px floor allows a probe at all): **a device point //! maps to the caret stop that begins the grapheme whose box contains it** — //! graphemes' own `Downstream` caret stops, sorted by device x, partition the //! whole line into non-overlapping boxes with no gaps, so this is a pure //! interval lookup ("floor" to the nearest stop at or before the point), //! never a nearest-neighbour vote. A point exactly at the *literal* midpoint //! between two stops (rather than a stop-to-stop interval interior) would be //! a genuine 50/50 tie under a nearest-caret rule; the interval-floor //! semantics used here have no such tie anywhere strictly inside a box, which //! is exactly what lets every probe carry one unambiguous expected answer. //! //! **Consequence, stated plainly: every probe's expected affinity is //! `Downstream`.** `Upstream` caret stops (the direction-boundary extras //! `crate::shape::inject_direction_boundary_stops` adds — F-D bytes 8 and 14) //! are deliberately excluded from probe generation. Measured on F-D's own //! data: every `Upstream` stop's device position coincides *exactly* with //! some other grapheme's own `Downstream` box boundary elsewhere in the //! fixture (byte 8's and byte 14's `Upstream` positions both equal byte 12's //! `Downstream` position, 4.609375 staff-space x — segment 1's own //! `start_pen`, referenced from both sides of the RTL run). A probe placed //! near an `Upstream` stop's position is therefore a probe placed near an //! *already-covered* `Downstream` box boundary — exactly the kind of position //! the 4 px separation rule exists to keep every probe away from. This table //! cannot exercise affinity disambiguation at a direction boundary without //! violating its own separation invariant; that property is committed and //! checked elsewhere (`invariants::assert_direction_boundary_stops_differ`, //! already asserted on every loaded fixture by `output::FixtureFile::validate`). //! Recorded here as a finding, not silently worked around. use std::path::Path; use serde::{Deserialize, Serialize}; use crate::output::FixtureFile; use crate::types::{SpikeCaretAffinity, SpikePoint, SpikeResolvedText}; /// Device pixels per staff space (recipe §3): `scale = 100`. pub const DEVICE_SCALE: f64 = crate::DEVICE_SCALE; /// Recipe §7: "Points are placed at least 4 device px from any stop /// position." Not a tunable — a probe violating this is dropped, never /// accepted with a smaller margin (see [`build_probe_table`]'s doc comment). pub const MIN_STOP_SEPARATION_DEVICE_PX: f64 = 4.0; /// How far past the first/last caret stop the two edge probes sit. Not /// pinned by the recipe (which only requires clearing the 4 px floor); fixed /// here at 20 px — five times the floor — so the edge probes are nowhere /// near a rounding tie on any fixture in this set (every measured interior /// gap is at least 31.9 device px; see this module's generation output). pub const EDGE_MARGIN_DEVICE_PX: f64 = 20.0; /// The five fixture ids, in order — restated (not read back from /// `crate::fixtures::FIXTURES`), the same discipline /// `output::EXPECTED_FIXTURES` uses and for the same reason: a validator /// built from a different copy of this crate must still catch drift. pub const EXPECTED_FIXTURE_IDS: [&str; 5] = ["F-A", "F-B", "F-C", "F-D", "F-E"]; #[derive(Copy, Clone, PartialEq, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct DevicePoint { pub x: f64, pub y: f64, } fn distance(a: &DevicePoint, b: &DevicePoint) -> f64 { ((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt() } /// Converts a position **relative to `rt.origin`** (the convention every /// `SpikePositionedGlyph::offset` and `SpikeCaretStop::position` already /// uses) to device pixels, via recipe §3's transform: `device = (staff.x * /// scale, target_height/2 - staff.y * scale)`. /// /// This adds `rt.origin` first rather than using the recipe's nominal /// `(160, 540)` as an additive device-space constant — the two are *not* /// quite the same thing. `rt.origin` is the honestly-**quantized** origin /// (`crate::quantize`'s doc comment: `1638/1024 = 1.599609375`, not the raw /// literal `1.6`), so the run's baseline actually sits at device /// `(159.9609375, 540)`, not `(160, 540)`. The discrepancy is under /// 0.04 device px — far below anything that matters for an 8+ px probe /// margin — but this module computes it exactly rather than silently /// re-introducing the same rounded-literal shortcut `crate::quantize`'s /// module doc comment names as a bug shape to avoid. pub fn to_device(rt: &SpikeResolvedText, relative: &SpikePoint) -> DevicePoint { let staff_x = rt.origin.x + relative.x; let staff_y = rt.origin.y + relative.y; DevicePoint { x: staff_x * DEVICE_SCALE, y: crate::TARGET_HEIGHT / 2.0 - staff_y * DEVICE_SCALE, } } /// Every caret stop in `rt`, in device space, **both affinities** — this is /// what a probe must clear the [`MIN_STOP_SEPARATION_DEVICE_PX`] floor /// against (recipe §7: "at least 4 device px from any stop position" — any, /// not just the two stops bounding the interior interval a probe was /// generated from). fn all_stop_device_points(rt: &SpikeResolvedText) -> Vec { rt.clusters .clusters .iter() .flat_map(|c| c.caret_stops.iter()) .map(|s| to_device(rt, &s.position)) .collect() } /// One grapheme's own leading-edge (`Downstream`) caret stop, resolved to /// device space, kept in **device-x-sorted (visual) order** — see this /// module's doc comment for why this order, not source-byte order, is the /// one probe generation needs (an RTL segment's clusters are byte-ascending /// but device-x-descending). struct GraphemeEntry { source_offset: u32, device: DevicePoint, } fn downstream_sequence(rt: &SpikeResolvedText) -> Vec { let mut v: Vec = rt .clusters .clusters .iter() .flat_map(|c| c.caret_stops.iter()) .filter(|s| s.affinity == SpikeCaretAffinity::Downstream) .map(|s| GraphemeEntry { source_offset: s.source_offset, device: to_device(rt, &s.position), }) .collect(); v.sort_by(|a, b| { a.device .x .partial_cmp(&b.device.x) .expect("device x is always finite") }); v } #[derive(Copy, Clone, PartialEq, Eq, Debug, Serialize, Deserialize)] pub enum ProbeKind { /// Generated from the midpoint of two device-x-adjacent `Downstream` /// stops (recipe §7: "for every caret stop, one probe at the midpoint of /// each adjacent grapheme" — see the module doc comment for why this is /// realized as one probe per adjacent *pair*, not two identical probes /// per interior grapheme). Interior, /// Before the first caret stop (recipe §7). BeforeFirst, /// After the last caret stop (recipe §7). AfterLast, } /// One committed probe: a device point, its expected hit-test answer, and /// which grapheme/interval it was generated from (traceability — task /// requirement). #[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct HitTestProbe { pub point: DevicePoint, pub expected_source_offset: u32, pub expected_affinity: SpikeCaretAffinity, /// Human-readable provenance: which fixture, which grapheme/interval, /// and which probe kind. Not machine-checked itself (the geometry and /// expected offset/affinity are); a report or a `FAIL` names the probe by /// this string rather than an opaque index. pub source_grapheme: String, pub kind: ProbeKind, } /// A probe the generator refused to emit because it could not clear the /// [`MIN_STOP_SEPARATION_DEVICE_PX`] floor — recipe §7: "do not shrink the /// margin — drop that probe and RECORD that it was dropped, with which /// fixture and why." #[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct DroppedProbe { pub fixture_id: String, pub point: DevicePoint, pub nearest_stop_distance_device_px: f64, pub reason: String, } #[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct FixtureProbeTable { pub fixture_id: String, pub probes: Vec, } /// `hittest_probes.json`'s root document. #[derive(Clone, PartialEq, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct HitTestProbeFile { pub contract: String, pub recipe: String, pub min_separation_device_px: f64, pub edge_margin_device_px: f64, pub fixtures: Vec, pub dropped: Vec, } /// Considers one candidate probe: accepts it into `probes` if it clears the /// separation floor against **every** caret stop in `all_stops` (both /// affinities), otherwise records it in `dropped` with the measured distance /// and never lowers the floor to make it fit (recipe §7, restated in this /// module's own doc comment). fn consider( fixture_id: &str, all_stops: &[DevicePoint], point: DevicePoint, expected_source_offset: u32, expected_affinity: SpikeCaretAffinity, kind: ProbeKind, label: String, probes: &mut Vec, dropped: &mut Vec, ) { let min_dist = all_stops .iter() .map(|s| distance(&point, s)) .fold(f64::INFINITY, f64::min); if min_dist < MIN_STOP_SEPARATION_DEVICE_PX { dropped.push(DroppedProbe { fixture_id: fixture_id.to_string(), point, nearest_stop_distance_device_px: min_dist, reason: format!( "{label}: nearest caret stop is only {min_dist:.4} device px away, below the \ {MIN_STOP_SEPARATION_DEVICE_PX} px floor recipe §7 requires — dropped rather \ than shrinking the margin" ), }); } else { probes.push(HitTestProbe { point, expected_source_offset, expected_affinity, source_grapheme: label, kind, }); } } /// Builds one fixture's probe table (recipe §7): one probe per pair of /// device-x-adjacent graphemes (their shared interior interval — see the /// module doc comment for why this, not two probes per stop, is the correct /// non-redundant realization of "midpoint of each adjacent grapheme"), plus /// one probe before the first stop and one after the last, each checked /// against the [`MIN_STOP_SEPARATION_DEVICE_PX`] floor and dropped (not /// shrunk) if it fails. pub fn build_probe_table( fixture_id: &str, rt: &SpikeResolvedText, ) -> (FixtureProbeTable, Vec) { let seq = downstream_sequence(rt); let all_stops = all_stop_device_points(rt); let mut probes = Vec::new(); let mut dropped = Vec::new(); for w in seq.windows(2) { let (a, b) = (&w[0], &w[1]); let mid = DevicePoint { x: (a.device.x + b.device.x) / 2.0, y: a.device.y, }; let label = format!( "{fixture_id}: interior, grapheme starting at byte {} (interval byte {}..byte {})", a.source_offset, a.source_offset, b.source_offset ); consider( fixture_id, &all_stops, mid, a.source_offset, SpikeCaretAffinity::Downstream, ProbeKind::Interior, label, &mut probes, &mut dropped, ); } if let Some(first) = seq.first() { let point = DevicePoint { x: first.device.x - EDGE_MARGIN_DEVICE_PX, y: first.device.y, }; let label = format!( "{fixture_id}: before the first caret stop (byte {})", first.source_offset ); consider( fixture_id, &all_stops, point, first.source_offset, SpikeCaretAffinity::Downstream, ProbeKind::BeforeFirst, label, &mut probes, &mut dropped, ); } if let Some(last) = seq.last() { let point = DevicePoint { x: last.device.x + EDGE_MARGIN_DEVICE_PX, y: last.device.y, }; let label = format!( "{fixture_id}: after the last caret stop (byte {})", last.source_offset ); consider( fixture_id, &all_stops, point, last.source_offset, SpikeCaretAffinity::Downstream, ProbeKind::AfterLast, label, &mut probes, &mut dropped, ); } ( FixtureProbeTable { fixture_id: fixture_id.to_string(), probes, }, dropped, ) } /// Builds every fixture's probe table from an already-loaded, already-valid /// [`FixtureFile`] (`crate::output::load_fixtures` validates before this ever /// sees it). pub fn build_all(fixtures: &FixtureFile) -> (Vec, Vec) { let mut tables = Vec::with_capacity(fixtures.fixtures.len()); let mut all_dropped = Vec::new(); for f in &fixtures.fixtures { let (table, mut dropped) = build_probe_table(&f.id, &f.resolved); tables.push(table); all_dropped.append(&mut dropped); } (tables, all_dropped) } pub fn build_hittest_probe_file(fixtures: &FixtureFile) -> HitTestProbeFile { let (tables, dropped) = build_all(fixtures); HitTestProbeFile { contract: "spec/CONTRACT_EDITOR_T4_SPIKE.md pins 8, 9, 10, 13, 14".to_string(), recipe: "spikes/editor-toolkit/ROUND2_TEXT_RECIPE.md §7".to_string(), min_separation_device_px: MIN_STOP_SEPARATION_DEVICE_PX, edge_margin_device_px: EDGE_MARGIN_DEVICE_PX, fixtures: tables, dropped, } } impl HitTestProbeFile { /// Checks the loaded file against literals restated here, then against a /// **fresh recomputation** from `fixtures` — the same two-tier discipline /// `output::FixtureFile::validate` uses (structural/literal checks, plus /// re-deriving the checkable facts rather than trusting the file's own /// other fields). /// /// The per-probe 4 px separation check runs first and independently /// (against `fixtures`'s own caret-stop positions, recomputed fresh, never /// against a self-declared distance field this file could carry /// un-audited) so a probe moved too close to a stop fails with a specific, /// on-topic message rather than the generic "recomputation disagrees" /// one below it. pub fn validate(&self, fixtures: &FixtureFile) -> Result<(), String> { if self.min_separation_device_px != MIN_STOP_SEPARATION_DEVICE_PX { return Err(format!( "min_separation_device_px is {}, recipe §7 fixes it at {MIN_STOP_SEPARATION_DEVICE_PX}", self.min_separation_device_px )); } if self.edge_margin_device_px != EDGE_MARGIN_DEVICE_PX { return Err(format!( "edge_margin_device_px is {}, this crate fixes it at {EDGE_MARGIN_DEVICE_PX}", self.edge_margin_device_px )); } if self.fixtures.len() != EXPECTED_FIXTURE_IDS.len() { return Err(format!( "{} fixture probe tables recorded, recipe §2 names {} fixtures", self.fixtures.len(), EXPECTED_FIXTURE_IDS.len() )); } for (i, expected_id) in EXPECTED_FIXTURE_IDS.iter().enumerate() { if self.fixtures[i].fixture_id != *expected_id { return Err(format!( "fixtures[{i}] id is {:?}, recipe §2 names {expected_id:?}", self.fixtures[i].fixture_id )); } if self.fixtures[i].probes.is_empty() { return Err(format!( "{expected_id}: no probes recorded — every fixture must have probes (recipe §7)" )); } } for ft in &self.fixtures { let resolved = &fixtures .fixtures .iter() .find(|f| f.id == ft.fixture_id) .ok_or_else(|| { format!( "{}: not present in the supplied fixtures file", ft.fixture_id ) })? .resolved; let stops = all_stop_device_points(resolved); for p in &ft.probes { let d = stops .iter() .map(|s| distance(&p.point, s)) .fold(f64::INFINITY, f64::min); if d < MIN_STOP_SEPARATION_DEVICE_PX { return Err(format!( "{}: probe {:?} (expects byte {} / {:?}) sits {d:.4} device px from its \ nearest caret stop, below the {MIN_STOP_SEPARATION_DEVICE_PX} px floor \ recipe §7 requires — a rounding tie could pass or fail it either way", ft.fixture_id, p.point, p.expected_source_offset, p.expected_affinity )); } } } let (expected_fixtures, expected_dropped) = build_all(fixtures); if self.fixtures != expected_fixtures { return Err( "hit-test probe table disagrees with a fresh recomputation from the supplied \ fixtures — the committed file has drifted from the data it was built from" .to_string(), ); } if self.dropped != expected_dropped { return Err(format!( "dropped-probe list disagrees with a fresh recomputation: {} recorded, {} expected", self.dropped.len(), expected_dropped.len() )); } Ok(()) } } /// Loads `hittest_probes.json` and validates it against `fixtures` in one /// call — the same "no public load-without-validating" discipline /// `output::load_fixtures` establishes, for the same reason. pub fn load_hittest_probes( path: &Path, fixtures: &FixtureFile, ) -> Result { let text = std::fs::read_to_string(path) .map_err(|e| format!("failed to read hit-test probes at {}: {e}", path.display()))?; let file: HitTestProbeFile = serde_json::from_str(&text) .map_err(|e| format!("failed to parse hit-test probes at {}: {e}", path.display()))?; file.validate(fixtures).map_err(|e| { format!( "hit-test probes at {} failed validation: {e}", path.display() ) })?; Ok(file) } #[cfg(test)] mod tests { use super::*; use crate::identity::{ SemVerRecord, SpikeShaperId, SpikeTextShapingIdentity, SpikeUnicodeComponent, }; use crate::output; use crate::types::{ SpikeBoundingBox, SpikeCaretStop, SpikeCluster, SpikeClusterMap, SpikeGlyphStyle, SpikeLanguageTag, SpikePositionedGlyph, SpikeProvenance, SpikeScriptTag, SpikeShapedSegment, SpikeStaffSpace, SpikeTextAlign, SpikeTextDirection, }; fn dummy_identity() -> SpikeTextShapingIdentity { SpikeTextShapingIdentity { faces: Vec::new(), shaper: SpikeShaperId("rustybuzz".to_string()), shaper_version: SemVerRecord { major: 0, minor: 20, patch: 1, }, features: Vec::new(), unicode_bidi: SpikeUnicodeComponent { impl_name: "unicode-bidi".to_string(), crate_version: "0.3.18".to_string(), unicode_version: Some("16.0.0".to_string()), }, unicode_segmentation: SpikeUnicodeComponent { impl_name: "unicode-segmentation".to_string(), crate_version: "1.13.3".to_string(), unicode_version: Some("17.0.0".to_string()), }, } } fn dummy_provenance() -> SpikeProvenance { SpikeProvenance { source: crate::types::SpikeTypedObjectId { discriminant: 0, canonical_bytes_hex: "00".repeat(18), }, synthesis: None, dependencies: Vec::new(), stable_id: 0, } } /// Three graphemes at staff-space x = 0.0, 1.0, 1.02 — the last gap is /// 0.02 staff space = 2 device px, so its interior midpoint sits 1 device /// px from each of its bounding stops, below the 4 px floor. Used to /// prove [`build_probe_table`] actually drops a too-close probe rather /// than silently shrinking the margin (task requirement). fn fixture_with_a_narrow_gap() -> SpikeResolvedText { let seg = SpikeShapedSegment { face: Some(0), glyphs: vec![ SpikePositionedGlyph { glyph_id: 1, offset: SpikePoint::new(0.0, 0.0), transform: None, }, SpikePositionedGlyph { glyph_id: 2, offset: SpikePoint::new(1.0, 0.0), transform: None, }, SpikePositionedGlyph { glyph_id: 3, offset: SpikePoint::new(1.02, 0.0), transform: None, }, ], source: 0..3, direction: SpikeTextDirection::Ltr, script: SpikeScriptTag("Latn".to_string()), language: SpikeLanguageTag(None), size: SpikeStaffSpace(1.28), }; let mk_cluster = |byte: u32, x: f64| SpikeCluster { source: byte..byte + 1, segment: 0, glyph_indices: vec![byte], resolved: true, grapheme_count: 1, caret_stops: vec![SpikeCaretStop { source_offset: byte, position: SpikePoint::new(x, 0.0), affinity: SpikeCaretAffinity::Downstream, }], }; SpikeResolvedText { provenance: dummy_provenance(), text: "abc".to_string(), shaping: dummy_identity(), segments: vec![seg], clusters: SpikeClusterMap { clusters: vec![mk_cluster(0, 0.0), mk_cluster(1, 1.0), mk_cluster(2, 1.02)], }, bounds: SpikeBoundingBox { left: 0.0, bottom: 0.0, right: 1.0, top: 1.0, }, reserved_box: SpikeBoundingBox { left: 0.0, bottom: 0.0, right: 1.0, top: 1.0, }, origin: SpikePoint::new(0.0, 0.0), align: SpikeTextAlign::Start, style: SpikeGlyphStyle { rgba: 0x0000_00ff }, layer: 0, } } #[test] fn a_probe_within_the_floor_is_dropped_and_recorded_not_shrunk() { let rt = fixture_with_a_narrow_gap(); let (table, dropped) = build_probe_table("T-NARROW", &rt); // Two interior intervals: byte0..byte1 (gap 100 device px, fine) and // byte1..byte2 (gap 2 device px, must be dropped). assert_eq!( dropped.len(), 1, "exactly the narrow byte1..byte2 interval must be dropped, got {dropped:?}" ); assert_eq!(dropped[0].fixture_id, "T-NARROW"); assert!( dropped[0].nearest_stop_distance_device_px < MIN_STOP_SEPARATION_DEVICE_PX, "recorded distance must actually be below the floor: {:?}", dropped[0] ); assert!( dropped[0].reason.contains("T-NARROW"), "{}", dropped[0].reason ); assert!( dropped[0].reason.contains("below the 4"), "{}", dropped[0].reason ); // The surviving probes must never include the dropped point's // interval — i.e. no probe expects byte 1 from an Interior kind at // this narrow gap. (byte 1 still legitimately appears from the wide // byte0..byte1 interval and is not itself excluded.) let narrow_interval_survived = table.probes.iter().any(|p| { p.kind == ProbeKind::Interior && p.expected_source_offset == 1 && p.source_grapheme.contains("byte 1..byte 2") }); assert!( !narrow_interval_survived, "the narrow interval's probe must not appear among the accepted probes: {:#?}", table.probes ); } #[test] fn a_wide_gap_produces_an_accepted_probe_at_its_midpoint() { let rt = fixture_with_a_narrow_gap(); let (table, _dropped) = build_probe_table("T-NARROW", &rt); let wide = table .probes .iter() .find(|p| p.kind == ProbeKind::Interior && p.expected_source_offset == 0) .expect("the wide byte0..byte1 interval must survive"); // origin (0,0) + relative (0.5, 0) staff -> device x = 50.0 assert!((wide.point.x - 50.0).abs() < 1e-9, "{:?}", wide.point); assert_eq!(wide.expected_affinity, SpikeCaretAffinity::Downstream); } fn committed_fixtures() -> Option { let p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures.json"); if !p.exists() { return None; } Some(output::load_fixtures(&p).expect("committed fixtures.json must load and validate")) } #[test] fn a_freshly_built_probe_file_validates() { let Some(fixtures) = committed_fixtures() else { eprintln!("NOT RUN: fixtures.json absent"); return; }; let file = build_hittest_probe_file(&fixtures); file.validate(&fixtures).unwrap(); } #[test] fn every_committed_fixture_has_probes_and_zero_are_dropped() { // Measured fact, restated as a check: every interior gap in the five // committed fixtures is at least 31.9 device px (see this crate's // generation output), comfortably clearing the 4 px floor, so a // freshly built table drops nothing. If this ever starts dropping // probes, that is itself a finding worth surfacing, not a silently // absorbed change. let Some(fixtures) = committed_fixtures() else { eprintln!("NOT RUN: fixtures.json absent"); return; }; let file = build_hittest_probe_file(&fixtures); for ft in &file.fixtures { assert!(!ft.probes.is_empty(), "{} has no probes", ft.fixture_id); } assert!( file.dropped.is_empty(), "expected zero drops on the committed fixture set, got {:#?}", file.dropped ); } /// Mutation-first (task requirement): move one probe to within 4 px of a /// stop and confirm `validate` rejects it with the specific /// separation-floor message, not the generic recomputation-drift one. #[test] fn validate_kills_a_probe_moved_within_the_floor() { let Some(fixtures) = committed_fixtures() else { eprintln!("NOT RUN: fixtures.json absent"); return; }; let mut file = build_hittest_probe_file(&fixtures); let fa = &mut file.fixtures[0]; assert_eq!(fa.fixture_id, "F-A"); // F-A byte0's own Downstream stop sits at device x = 160.0 (relative // x 0.0 + origin.x 1.599609375, times scale 100 = 159.9609375). // Move the first accepted probe to 2 device px away from it — // inside the 4 px floor. fa.probes[0].point.x = 159.9609375 + 2.0; let err = file.validate(&fixtures).unwrap_err(); assert!(err.contains("below the 4"), "{err}"); assert!(err.contains("device px"), "{err}"); } /// Mutation-first (task requirement): change one expected byte offset /// and confirm `validate` catches it via the recomputation-drift check. #[test] fn validate_kills_a_wrong_expected_offset() { let Some(fixtures) = committed_fixtures() else { eprintln!("NOT RUN: fixtures.json absent"); return; }; let mut file = build_hittest_probe_file(&fixtures); let fa = &mut file.fixtures[0]; assert_eq!(fa.fixture_id, "F-A"); fa.probes[0].expected_source_offset += 1; let err = file.validate(&fixtures).unwrap_err(); assert!( err.contains("disagrees with a fresh recomputation"), "{err}" ); } #[test] fn validate_kills_a_wrong_min_separation_literal() { let Some(fixtures) = committed_fixtures() else { eprintln!("NOT RUN: fixtures.json absent"); return; }; let mut file = build_hittest_probe_file(&fixtures); file.min_separation_device_px = 1.0; let err = file.validate(&fixtures).unwrap_err(); assert!(err.contains("min_separation_device_px"), "{err}"); } #[test] fn validate_kills_a_wrong_edge_margin_literal() { let Some(fixtures) = committed_fixtures() else { eprintln!("NOT RUN: fixtures.json absent"); return; }; let mut file = build_hittest_probe_file(&fixtures); file.edge_margin_device_px = 5.0; let err = file.validate(&fixtures).unwrap_err(); assert!(err.contains("edge_margin_device_px"), "{err}"); } #[test] fn validate_kills_a_missing_fixture_table() { let Some(fixtures) = committed_fixtures() else { eprintln!("NOT RUN: fixtures.json absent"); return; }; let mut file = build_hittest_probe_file(&fixtures); file.fixtures.pop(); let err = file.validate(&fixtures).unwrap_err(); assert!(err.contains("fixture probe tables"), "{err}"); } #[test] fn validate_kills_an_empty_probe_list() { let Some(fixtures) = committed_fixtures() else { eprintln!("NOT RUN: fixtures.json absent"); return; }; let mut file = build_hittest_probe_file(&fixtures); file.fixtures[0].probes.clear(); let err = file.validate(&fixtures).unwrap_err(); assert!(err.contains("no probes recorded"), "{err}"); } #[test] fn validate_kills_an_extra_spurious_probe() { let Some(fixtures) = committed_fixtures() else { eprintln!("NOT RUN: fixtures.json absent"); return; }; let mut file = build_hittest_probe_file(&fixtures); let extra = file.fixtures[0].probes[0].clone(); file.fixtures[0].probes.push(extra); let err = file.validate(&fixtures).unwrap_err(); assert!( err.contains("disagrees with a fresh recomputation"), "{err}" ); } #[test] fn json_round_trip_preserves_validity() { let Some(fixtures) = committed_fixtures() else { eprintln!("NOT RUN: fixtures.json absent"); return; }; let file = build_hittest_probe_file(&fixtures); let json = serde_json::to_string_pretty(&file).unwrap(); let reloaded: HitTestProbeFile = serde_json::from_str(&json).unwrap(); reloaded.validate(&fixtures).unwrap(); } #[test] fn an_unknown_field_is_refused() { let Some(fixtures) = committed_fixtures() else { eprintln!("NOT RUN: fixtures.json absent"); return; }; let file = build_hittest_probe_file(&fixtures); let mut v: serde_json::Value = serde_json::to_value(&file).unwrap(); v.as_object_mut() .unwrap() .insert("smuggled_field".into(), serde_json::json!(1)); let err = serde_json::from_value::(v) .unwrap_err() .to_string(); assert!(err.contains("smuggled_field"), "{err}"); } /// Every probe's expected affinity is `Downstream` (this module's own /// documented consequence of interval-floor semantics) — checked here so /// the claim in the module doc comment cannot silently stop being true. #[test] fn every_probe_expects_downstream_affinity() { let Some(fixtures) = committed_fixtures() else { eprintln!("NOT RUN: fixtures.json absent"); return; }; let file = build_hittest_probe_file(&fixtures); for ft in &file.fixtures { for p in &ft.probes { assert_eq!( p.expected_affinity, SpikeCaretAffinity::Downstream, "{}: probe {:?} expects a non-Downstream affinity", ft.fixture_id, p ); } } } }