test(simulation): Phase-4 hardening — deferred #953/#963 review gaps (T-964)
The verified-still-open coverage list: per-type attractor reachability fixtures (LakeShore via enclosed depression, PassEntrance via crafted saddle, PlainCenter via flat terrain, RiverCrossing via confluence) plus thin_by_spacing behavior (collision, strict-< boundary, equirectangular column wrap); heightmap 8-bit decode, sea_level passthrough, downsample identity and zero-target early-return; drainage area_pct bit-for-bit determinism plus the isolated-basin-fallback divergence comment (Tyre N1, citing the pre-#953 behavior it deliberately departs from); the layer1 mountain-branch pairing test (investigated first — the cascade test supplies a mountain pool but only ever asserted river counts, a genuine gap); an importer idempotency test covering atlas_city_names AND atlas_feature_names plus the Sol exemption, wired into make test-tooling; and the oasis_water dilation radius scaled by GRID_W/512 (Tyre N2, hash-stable). One stale item dropped per the refinement trim (test_sim_determinism wiring — already done). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -900,4 +900,238 @@ mod tests {
|
||||
"RiverMouth attractors must survive the cap when mouths exist"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Per-type attractor reachability (T-964): crafted heightmaps that
|
||||
// guarantee at least one attractor of the named type, isolating each
|
||||
// extraction branch instead of relying on the slope/sine fixtures above
|
||||
// (which reliably exercise RiverMouth/CoastalAccess/ValleyFloor, but never
|
||||
// guarantee LakeShore/PassEntrance/PlainCenter/RiverCrossing).
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn lake_shore_reachable_via_enclosed_depression() {
|
||||
// Two separate below-sea-level components: a wide strip along the
|
||||
// west edge (the largest — becomes ocean) and a small isolated pit
|
||||
// elsewhere (smaller — becomes an enclosed lake, D-209/compute_lake_mask).
|
||||
// Land cells 8-adjacent to the pit must classify LakeShore.
|
||||
let (w, h) = (32usize, 16usize);
|
||||
let mut data = vec![0.6f32; w * h];
|
||||
for r in 0..h {
|
||||
for c in 0..4 {
|
||||
data[r * w + c] = 0.1; // wide ocean strip
|
||||
}
|
||||
}
|
||||
for r in 6..8 {
|
||||
for c in 16..18 {
|
||||
data[r * w + c] = 0.1; // small isolated pit, far from the ocean
|
||||
}
|
||||
}
|
||||
let heightmap = hm(data, w as u32, h as u32, 0.3);
|
||||
let dr = drainage::analyze(&heightmap.data, w as u32, h as u32, 0.3);
|
||||
let ta = TerrainAnalysis::analyze(&heightmap, &dr);
|
||||
assert!(
|
||||
ta.lake_mask.iter().any(|&x| x),
|
||||
"fixture sanity: the isolated pit must register as a lake, not ocean"
|
||||
);
|
||||
let a = extract_attractors(&heightmap, &dr, &ta);
|
||||
assert!(
|
||||
a.iter()
|
||||
.any(|x| x.attractor_type == AttractorType::LakeShore),
|
||||
"land adjacent to an enclosed lake must classify LakeShore"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pass_entrance_reachable_via_morphological_saddle() {
|
||||
// Classic saddle: the 8-ring around the center alternates high/low
|
||||
// going clockwise (N,NE,E,SE,S,SW,W,NW), giving 8 sign transitions
|
||||
// (is_saddle requires >= 4). Whole grid is high-elevation land so the
|
||||
// saddle's elev_pct clears the >= 0.5 PassEntrance gate.
|
||||
let (w, h) = (32usize, 16usize);
|
||||
let (cr, cc) = (h / 2, w / 2);
|
||||
let mut data = vec![0.7f32; w * h];
|
||||
data[cr * w + cc] = 0.75; // the saddle point itself
|
||||
const RING: [(i32, i32); 8] = [
|
||||
(-1, 0),
|
||||
(-1, 1),
|
||||
(0, 1),
|
||||
(1, 1),
|
||||
(1, 0),
|
||||
(1, -1),
|
||||
(0, -1),
|
||||
(-1, -1),
|
||||
];
|
||||
let ring_vals = [0.95, 0.55, 0.95, 0.55, 0.95, 0.55, 0.95, 0.55];
|
||||
for (k, &(dr_off, dc_off)) in RING.iter().enumerate() {
|
||||
let rr = (cr as i32 + dr_off) as usize;
|
||||
let cc_ = (cc as i32 + dc_off) as usize;
|
||||
data[rr * w + cc_] = ring_vals[k];
|
||||
}
|
||||
let heightmap = hm(data, w as u32, h as u32, 0.0);
|
||||
let dr = drainage::analyze(&heightmap.data, w as u32, h as u32, 0.0);
|
||||
let ta = TerrainAnalysis::analyze(&heightmap, &dr);
|
||||
assert!(
|
||||
ta.elev_pct[cr * w + cc] >= 0.5,
|
||||
"fixture sanity: saddle point must clear the PassEntrance elev_pct gate"
|
||||
);
|
||||
let a = extract_attractors(&heightmap, &dr, &ta);
|
||||
assert!(
|
||||
a.iter()
|
||||
.any(|x| x.attractor_type == AttractorType::PassEntrance),
|
||||
"a genuine morphological saddle at high elevation must classify PassEntrance"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_center_reachable_via_flat_uniform_terrain() {
|
||||
// A uniformly flat, non-ocean grid: slope_deg is 0 everywhere (well
|
||||
// under the < 2.0 PlainCenter gate), so at least one cell survives
|
||||
// thin_by_spacing as PlainCenter even where ValleyFloor also
|
||||
// competes for the uniform elev_pct=0.5 rank tie.
|
||||
let (w, h) = (32usize, 16usize);
|
||||
let data = vec![0.9f32; w * h];
|
||||
let heightmap = hm(data, w as u32, h as u32, 0.0);
|
||||
let dr = drainage::analyze(&heightmap.data, w as u32, h as u32, 0.0);
|
||||
let ta = TerrainAnalysis::analyze(&heightmap, &dr);
|
||||
let a = extract_attractors(&heightmap, &dr, &ta);
|
||||
assert!(
|
||||
a.iter()
|
||||
.any(|x| x.attractor_type == AttractorType::PlainCenter),
|
||||
"flat, non-ocean terrain must produce at least one PlainCenter attractor"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn river_crossing_reachable_via_confluence() {
|
||||
// Two V-shaped tributary valleys (west + east branches) converge into
|
||||
// a single trunk valley at (confluence_row, confluence_col) — the
|
||||
// trunk cell has 2+ river-cell inflows, so `drainage::analyze` must
|
||||
// report it as a confluence (drainage.rs's own D8 confluence rule),
|
||||
// and extract_attractors must tag it RiverCrossing.
|
||||
let (w, h) = (64usize, 64usize);
|
||||
let confluence_col = (w / 2) as f32;
|
||||
let confluence_row = (h / 2) as f32;
|
||||
let data: Vec<f32> = (0..(w * h))
|
||||
.map(|i| {
|
||||
let r = (i / w) as f32;
|
||||
let c = (i % w) as f32;
|
||||
if r <= confluence_row {
|
||||
// Upstream: two separate branches either side of the
|
||||
// confluence column, each sloping down toward it.
|
||||
let branch_center = if c < confluence_col {
|
||||
confluence_col * 0.5
|
||||
} else {
|
||||
confluence_col * 1.5
|
||||
};
|
||||
let lateral = (c - branch_center).abs() / w as f32;
|
||||
let downstream = (confluence_row - r) / h as f32;
|
||||
(0.3 + lateral * 1.5 - downstream * 0.4).clamp(0.0, 1.0)
|
||||
} else {
|
||||
// Downstream: single widening trunk valley.
|
||||
let lateral = (c - confluence_col).abs() / w as f32;
|
||||
let downstream = (r - confluence_row) / h as f32;
|
||||
(0.3 + lateral * 1.5 - downstream * 0.6).clamp(0.0, 1.0)
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let heightmap = hm(data, w as u32, h as u32, 0.0);
|
||||
let dr = drainage::analyze(&heightmap.data, w as u32, h as u32, 0.0);
|
||||
assert!(
|
||||
!dr.river_network.confluences.is_empty(),
|
||||
"fixture sanity: the converging-tributary fixture must produce a confluence"
|
||||
);
|
||||
let ta = TerrainAnalysis::analyze(&heightmap, &dr);
|
||||
let a = extract_attractors(&heightmap, &dr, &ta);
|
||||
assert!(
|
||||
a.iter()
|
||||
.any(|x| x.attractor_type == AttractorType::RiverCrossing),
|
||||
"a genuine D8 confluence must classify RiverCrossing"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// thin_by_spacing behavior (T-964): spacing collisions + equirectangular
|
||||
// column wrap.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn thin_by_spacing_drops_close_candidates_keeps_strongest() {
|
||||
// Three candidates within MIN_SPACING (12) of each other: only the
|
||||
// strongest should survive; a fourth, far-away candidate is
|
||||
// independent and must survive alongside it.
|
||||
let claimed = vec![false; 64 * 64];
|
||||
let cands = vec![
|
||||
(10usize, 10usize, 0.5f32),
|
||||
(10usize, 15usize, 0.9f32), // strongest, within spacing of the other two
|
||||
(15usize, 10usize, 0.3f32),
|
||||
(50usize, 50usize, 0.4f32), // far away — independent, must survive
|
||||
];
|
||||
let kept = thin_by_spacing(cands, &claimed, 64);
|
||||
assert_eq!(
|
||||
kept.len(),
|
||||
2,
|
||||
"expected exactly 2 survivors (the strongest of the clustered trio + the \
|
||||
far-away independent point), got {kept:?}"
|
||||
);
|
||||
assert!(
|
||||
kept.contains(&(10, 15, 0.9)),
|
||||
"the strongest candidate in the cluster must survive: {kept:?}"
|
||||
);
|
||||
assert!(
|
||||
kept.contains(&(50, 50, 0.4)),
|
||||
"the far-away independent candidate must survive: {kept:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thin_by_spacing_respects_exact_spacing_boundary() {
|
||||
// Chebyshev distance exactly MIN_SPACING (12) apart must NOT collide
|
||||
// (the check is `dr.max(dc) < MIN_SPACING`, a strict less-than) — both
|
||||
// survive. One cell short of that (11) must collide — only the
|
||||
// stronger survives.
|
||||
let claimed = vec![false; 64 * 64];
|
||||
|
||||
let at_boundary = vec![(0usize, 0usize, 0.5f32), (12usize, 0usize, 0.5f32)];
|
||||
let kept_boundary = thin_by_spacing(at_boundary, &claimed, 64);
|
||||
assert_eq!(
|
||||
kept_boundary.len(),
|
||||
2,
|
||||
"cells exactly MIN_SPACING apart must both survive (strict <): {kept_boundary:?}"
|
||||
);
|
||||
|
||||
let inside_spacing = vec![(0usize, 0usize, 0.5f32), (11usize, 0usize, 0.9f32)];
|
||||
let kept_inside = thin_by_spacing(inside_spacing, &claimed, 64);
|
||||
assert_eq!(
|
||||
kept_inside.len(),
|
||||
1,
|
||||
"cells 1 short of MIN_SPACING must collide, keeping only the stronger: \
|
||||
{kept_inside:?}"
|
||||
);
|
||||
assert_eq!(kept_inside[0], (11, 0, 0.9));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thin_by_spacing_column_wrap_does_not_collide_across_the_seam() {
|
||||
// thin_by_spacing itself is a pure Chebyshev-distance thinner over
|
||||
// (row, col) pairs — it has NO knowledge of the equirectangular
|
||||
// column wrap (unlike NB8-based neighbor walks elsewhere in this
|
||||
// file, which wrap explicitly via `wrap_col`). Two candidates at
|
||||
// opposite ends of a wide grid (col 0 and col w-1) are geographically
|
||||
// adjacent on the globe but numerically far apart in (row, col)
|
||||
// space, so thin_by_spacing must NOT treat them as colliding — both
|
||||
// survive. This pins the current (non-wrap-aware) behavior so a
|
||||
// future change to make thinning wrap-aware is a deliberate,
|
||||
// visible decision, not a silent behavior drift.
|
||||
let w = 64usize;
|
||||
let claimed = vec![false; w * 64];
|
||||
let cands = vec![(5usize, 0usize, 0.5f32), (5usize, w - 1, 0.6f32)];
|
||||
let kept = thin_by_spacing(cands, &claimed, w);
|
||||
assert_eq!(
|
||||
kept.len(),
|
||||
2,
|
||||
"column-wrap-adjacent candidates are numerically far apart in (row, col) \
|
||||
space — thin_by_spacing must not collide them: {kept:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user