epiphany/spikes/editor-toolkit/round2-textkit/src/hittest.rs

868 lines
34 KiB
Rust

//! 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<DevicePoint> {
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<GraphemeEntry> {
let mut v: Vec<GraphemeEntry> = 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<HitTestProbe>,
}
/// `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<FixtureProbeTable>,
pub dropped: Vec<DroppedProbe>,
}
/// 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<HitTestProbe>,
dropped: &mut Vec<DroppedProbe>,
) {
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<DroppedProbe>) {
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<FixtureProbeTable>, Vec<DroppedProbe>) {
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<HitTestProbeFile, String> {
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<FixtureFile> {
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::<HitTestProbeFile>(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
);
}
}
}
}