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:
@@ -263,6 +263,9 @@ test-tooling:
|
||||
@mkdir -p .cache
|
||||
@python3 tooling/economy-db/test_traits.py 2> .cache/test-tooling-traits.log || \
|
||||
{ echo " FAIL: traits validation units — log follows:"; cat .cache/test-tooling-traits.log; exit 1; }
|
||||
@echo " [test-tooling] atlas_city_names/atlas_feature_names idempotency (T-964)..."
|
||||
@python3 tooling/economy-db/test_atlas_idempotency.py 2> .cache/test-tooling-atlas-idempotency.log || \
|
||||
{ echo " FAIL: atlas name-pool idempotency — log follows:"; cat .cache/test-tooling-atlas-idempotency.log; exit 1; }
|
||||
@echo " [test-tooling] import_economics --dry-run (committed DB)..."
|
||||
@rc=0; python3 tooling/economy-db/import_economics.py --dry-run \
|
||||
> .cache/test-tooling-dryrun.log 2>&1 || rc=$$?; \
|
||||
|
||||
@@ -651,7 +651,28 @@ fn merge_small_basins(
|
||||
best = rep;
|
||||
}
|
||||
}
|
||||
// No neighbor (isolated basin) → merge into the next smallest active.
|
||||
// No adjacent neighbor (a fully isolated basin, e.g. a land patch
|
||||
// wholly surrounded by ocean with no shared D8 edge to any other
|
||||
// active basin) → merge into whatever OTHER basin is still active
|
||||
// (BTreeSet iteration order = ascending id, so this picks the lowest
|
||||
// active id that isn't `smallest` itself).
|
||||
//
|
||||
// **Intentional divergence from the pre-#953 behavior (Tyre N1,
|
||||
// T-964):** the old O(merges × n) implementation (`find_largest_neighbor`,
|
||||
// see git history at 7cf761434) used `nbr_id.unwrap_or(0)` — an
|
||||
// isolated basin with no neighbor merged unconditionally into literal
|
||||
// basin id 0, regardless of whether basin 0 was itself active, nearby,
|
||||
// or already merged away. That was never a deliberate design choice,
|
||||
// just the fallback the size-map lookup happened to default to.
|
||||
// Falling back to basin 0 unconditionally can violate the size-adjacency
|
||||
// determinism contract this function documents above (smallest-by-
|
||||
// `(size, id)`, largest-neighbor-by-`(size, lowest id)`): it merges an
|
||||
// isolated basin into an arbitrary fixed id rather than into the
|
||||
// basin-graph's own remaining active set. This rewrite instead merges
|
||||
// into the lowest still-active id (excluding `smallest`) — never
|
||||
// literal basin 0 unless 0 happens to be that id — keeping the merge
|
||||
// target inside the same active/adjacency bookkeeping this function
|
||||
// already maintains, rather than reaching for an unrelated constant.
|
||||
let merge_into = if best >= 0 {
|
||||
best
|
||||
} else {
|
||||
@@ -951,6 +972,37 @@ mod tests {
|
||||
assert!(rf.max_accumulation >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basin_area_pct_is_bit_for_bit_deterministic() {
|
||||
// `determinism()` above only checks basin COUNT matches across runs;
|
||||
// this pins the actual `area_pct` f32 VALUES bit-for-bit (D-010) —
|
||||
// `area_pct = cells.len() as f32 / n as f32` is a pure integer-ratio
|
||||
// computation, so repeated runs on identical input must produce the
|
||||
// exact same bit pattern per basin, not merely "close enough".
|
||||
let elev = slope_grid(128, 64);
|
||||
let r1 = analyze(&elev, 128, 64, 0.3);
|
||||
let r2 = analyze(&elev, 128, 64, 0.3);
|
||||
assert_eq!(
|
||||
r1.drainage_basins.len(),
|
||||
r2.drainage_basins.len(),
|
||||
"fixture sanity: basin count itself must match before comparing area_pct"
|
||||
);
|
||||
for (b1, b2) in r1.drainage_basins.iter().zip(r2.drainage_basins.iter()) {
|
||||
assert_eq!(
|
||||
b1.basin_id, b2.basin_id,
|
||||
"basin ids must be assigned in the same order across runs"
|
||||
);
|
||||
assert_eq!(
|
||||
b1.area_pct.to_bits(),
|
||||
b2.area_pct.to_bits(),
|
||||
"basin {} area_pct must be bit-for-bit identical across runs: {} vs {}",
|
||||
b1.basin_id,
|
||||
b1.area_pct,
|
||||
b2.area_pct
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolated_basins_no_panic() {
|
||||
// Two land patches split by an ocean band (rows 3-4 below sea level):
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +247,89 @@ mod tests {
|
||||
assert!(matches!(err, HeightmapLoadError::UnsupportedFormat { .. }));
|
||||
}
|
||||
|
||||
/// Encode an 8-bit grayscale PNG (row-major u8 elevation) to bytes.
|
||||
fn encode_gray8(w: u32, h: u32, vals: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
{
|
||||
let mut enc = png::Encoder::new(&mut out, w, h);
|
||||
enc.set_color(png::ColorType::Grayscale);
|
||||
enc.set_depth(png::BitDepth::Eight);
|
||||
let mut writer = enc.write_header().unwrap();
|
||||
writer.write_image_data(vals).unwrap();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_8bit_grayscale_as_coarse_viewable_test_format() {
|
||||
// 8-bit grayscale is accepted per the module doc — "coarse —
|
||||
// viewable/test only" — normalized the same way as 16-bit (byte /
|
||||
// 255.0), distinct from the 16-bit path's `/ 65535.0` divisor.
|
||||
let (w, h) = (4u32, 1u32);
|
||||
let vals: Vec<u8> = vec![0, 85, 170, 255]; // 0, 1/3, 2/3, 1.0
|
||||
let png_bytes = encode_gray8(w, h, &vals);
|
||||
let hm = load_heightmap_reader(png_bytes.as_slice(), "T", 0.3).unwrap();
|
||||
assert_eq!((hm.width, hm.height), (w, h));
|
||||
assert_eq!(hm.data.len(), 4);
|
||||
assert!((hm.data[0] - 0.0).abs() < 1e-6);
|
||||
assert!((hm.data[1] - 1.0 / 3.0).abs() < 1e-3);
|
||||
assert!((hm.data[2] - 2.0 / 3.0).abs() < 1e-3);
|
||||
assert!((hm.data[3] - 1.0).abs() < 1e-6);
|
||||
assert!(hm.is_land(0, 3)); // 1.0 > sea 0.3
|
||||
assert!(!hm.is_land(0, 0)); // 0.0 < sea 0.3
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sea_level_passes_through_default_when_no_text_chunk() {
|
||||
// No `sea_level` tEXt chunk written — the loader must fall back to
|
||||
// exactly the caller-supplied `default_sea_level`, unmodified
|
||||
// (the mirror case of `reads_sea_level_from_text_chunk`, which
|
||||
// covers the chunk-present override path).
|
||||
let png_bytes = encode_gray16(2, 1, &[0, 65535]);
|
||||
let hm = load_heightmap_reader(png_bytes.as_slice(), "T", 0.42).unwrap();
|
||||
assert!(
|
||||
(hm.sea_level - 0.42).abs() < 1e-6,
|
||||
"sea_level must pass through the supplied default unmodified, got {}",
|
||||
hm.sea_level
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downsample_to_larger_or_equal_target_returns_identity_clone() {
|
||||
// `target_w >= self.width && target_h >= self.height` short-circuits
|
||||
// to a clone (no upsampling) — exercise both the exact-equal case and
|
||||
// the strictly-larger case.
|
||||
let vals: Vec<u16> = vec![0, 21845, 43690, 65535]; // 0, 1/3, 2/3, 1.0
|
||||
let hm = load_heightmap_reader(encode_gray16(2, 2, &vals).as_slice(), "T", 0.3).unwrap();
|
||||
|
||||
let same = hm.downsample(2, 2);
|
||||
assert_eq!((same.width, same.height), (2, 2));
|
||||
assert_eq!(same.data, hm.data, "equal-size downsample must be an identity clone");
|
||||
|
||||
let larger = hm.downsample(8, 8);
|
||||
assert_eq!(
|
||||
(larger.width, larger.height),
|
||||
(2, 2),
|
||||
"downsample must early-return the original dims (no upsampling) when the \
|
||||
target is larger than the source"
|
||||
);
|
||||
assert_eq!(larger.data, hm.data, "upsample request must be an identity clone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downsample_zero_target_returns_identity_clone() {
|
||||
// `target_w == 0 || target_h == 0` is the other early-return branch —
|
||||
// a degenerate target dimension must not panic or divide by zero.
|
||||
let vals: Vec<u16> = vec![0, 65535, 32768, 16384];
|
||||
let hm = load_heightmap_reader(encode_gray16(2, 2, &vals).as_slice(), "T", 0.3).unwrap();
|
||||
let zero_w = hm.downsample(0, 4);
|
||||
assert_eq!((zero_w.width, zero_w.height), (2, 2));
|
||||
assert_eq!(zero_w.data, hm.data);
|
||||
let zero_h = hm.downsample(4, 0);
|
||||
assert_eq!((zero_h.width, zero_h.height), (2, 2));
|
||||
assert_eq!(zero_h.data, hm.data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_sea_level_from_text_chunk() {
|
||||
// The bake writes sea_level as a tEXt chunk; the loader must prefer it
|
||||
|
||||
@@ -461,6 +461,87 @@ mod tests {
|
||||
assert!(rivers.len() <= names.len());
|
||||
}
|
||||
|
||||
/// T-964 layer1.rs mountain-branch coverage: `attach_feature_names`'s
|
||||
/// mountain half (pairing `Alpine` sub-biome attractors with
|
||||
/// `mountain_names`, per its own doc) was only exercised indirectly by
|
||||
/// `cascade::tests::topography_layer_attaches_feature_names_when_pools_supplied`,
|
||||
/// which asserts exclusively on RIVER assignment counts — the mountain
|
||||
/// pool there has a single entry and its actual pairing is never checked.
|
||||
/// This test constructs a heightmap guaranteed to produce a real `Alpine`
|
||||
/// attractor (`subbiome::classify_variant`'s `elev_pct > 0.80` gate) and
|
||||
/// asserts the mountain half of `attach_feature_names` actually pairs it.
|
||||
#[test]
|
||||
fn attach_feature_names_pairs_alpine_attractor_with_mountain_pool() {
|
||||
// A morphological saddle (PassEntrance, features.rs's own is_saddle
|
||||
// ring test) pushed to a high absolute elevation, so the saddle point
|
||||
// itself clears BOTH the PassEntrance elev_pct>=0.5 gate AND the
|
||||
// Alpine sub-biome elev_pct>0.80 gate (subbiome::classify_variant).
|
||||
// A PassEntrance candidate's strength (`1 - elev_pct`) is ranked
|
||||
// independent of the ValleyFloor/PlainCenter habitability contest
|
||||
// (features::habitability penalizes extreme elevation) — the saddle
|
||||
// is guaranteed a dedicated, single-cell candidate, unlike a flat
|
||||
// high plateau (whose interior loses the thin_by_spacing contest to
|
||||
// nearby low-elevation flat cells competing on habitability, since a
|
||||
// plateau spanning a single MIN_SPACING bucket produces exactly one
|
||||
// surviving candidate per bucket — verified empirically before
|
||||
// settling on this saddle-based fixture instead).
|
||||
let (w, h) = (32u32, 16u32);
|
||||
let (cr, cc) = (h as usize / 2, w as usize / 2);
|
||||
let mut data = vec![0.85f32; (w * h) as usize];
|
||||
data[cr * w as usize + cc] = 0.9; // the saddle point
|
||||
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.99, 0.75, 0.99, 0.75, 0.99, 0.75, 0.99, 0.75];
|
||||
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 as usize + cc_] = ring_vals[k];
|
||||
}
|
||||
let heightmap = BodyHeightmap {
|
||||
body_id: "AlpineBody".into(),
|
||||
width: w,
|
||||
height: h,
|
||||
data,
|
||||
sea_level: 0.0,
|
||||
};
|
||||
let (o, _ta) = run_layer1(&heightmap);
|
||||
assert!(
|
||||
o.attractors
|
||||
.iter()
|
||||
.any(|a| a.sub_biome == crate::simulation::generator::SubBiomeVariant::Alpine),
|
||||
"fixture sanity: the high-elevation saddle must produce at least one \
|
||||
Alpine attractor — got {:?}",
|
||||
o.attractors
|
||||
.iter()
|
||||
.map(|a| (a.position, a.attractor_type, a.sub_biome))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
let river_names: Vec<String> = Vec::new();
|
||||
let mountain_names = vec!["Wiesenbach".to_string(), "Kaltgrat".to_string()];
|
||||
let (rivers, mountains) = attach_feature_names(&o, &river_names, &mountain_names);
|
||||
assert!(rivers.is_empty(), "no river-name pool was supplied");
|
||||
assert!(
|
||||
!mountains.is_empty(),
|
||||
"an Alpine attractor with a non-empty mountain-name pool must yield at \
|
||||
least one mountain assignment"
|
||||
);
|
||||
assert!(
|
||||
mountains
|
||||
.iter()
|
||||
.all(|(_, name)| mountain_names.contains(name)),
|
||||
"assigned mountain names must come from the supplied pool: {mountains:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// T-1184 — hydrology productionization
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Idempotency tests for economy_import.atlas's names-only pool populators
|
||||
(T-964 — Phase-4 test hardening).
|
||||
|
||||
`populate_atlas_city_names` and `populate_atlas_feature_names` both clear
|
||||
their target table before reinserting (see each function's own docstring:
|
||||
"there is no UNIQUE(body_id, name[, feature_type]) — without the clear a
|
||||
re-run would accumulate duplicates"). This test proves that contract holds in
|
||||
practice: two back-to-back runs against the same DB must yield an IDENTICAL
|
||||
row count, not a doubled one, and Sol ('GJ 0') must remain permanently
|
||||
exempt (D-223) on both runs.
|
||||
|
||||
Runs against a scratch COPY of the committed `server/data/systems.db` (real
|
||||
schema + real `bodies`/FK data — not a hand-rolled in-memory schema, which
|
||||
would drift from the real FK web this populator depends on). Wiki content
|
||||
(`wiki/star-systems/*/bodies/*/markers.json`) is read directly from the repo
|
||||
— read-only input, safe to reuse as-is; only the DB connection is scratch.
|
||||
Deliberately calls the two populator functions directly rather than the full
|
||||
`import_economics.py` CLI: the CLI's first step shells out to the Rust
|
||||
`generate_brands` binary and overwrites `generated_brands.toml` on disk
|
||||
(`brands.regenerate_brands`), a real side effect on shared repo content that
|
||||
has nothing to do with atlas name-pool idempotency and would make this test
|
||||
depend on a Rust build.
|
||||
|
||||
Stdlib only (unittest) — run directly or via `make test-tooling`:
|
||||
python3 tooling/economy-db/test_atlas_idempotency.py
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
from economy_import import atlas # noqa: E402
|
||||
from economy_import.paths import DB_PATH # noqa: E402
|
||||
|
||||
|
||||
class AtlasNamePoolIdempotencyTests(unittest.TestCase):
|
||||
"""Two runs on a scratch DB copy must yield stable, non-doubled counts."""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not DB_PATH.exists():
|
||||
raise unittest.SkipTest(f"committed systems.db not found at {DB_PATH}")
|
||||
|
||||
def setUp(self):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
scratch_path = Path(self._tmp.name) / "systems_scratch.db"
|
||||
shutil.copyfile(DB_PATH, scratch_path)
|
||||
self.conn = sqlite3.connect(str(scratch_path))
|
||||
self.conn.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
def tearDown(self):
|
||||
self.conn.close()
|
||||
self._tmp.cleanup()
|
||||
|
||||
def test_atlas_city_names_count_stable_across_two_runs(self):
|
||||
first = atlas.populate_atlas_city_names(self.conn, dry_run=False)
|
||||
first_count = self.conn.execute(
|
||||
"SELECT COUNT(*) FROM atlas_city_names"
|
||||
).fetchone()[0]
|
||||
self.assertEqual(
|
||||
first, first_count, "populator return value must match the rows it wrote"
|
||||
)
|
||||
|
||||
second = atlas.populate_atlas_city_names(self.conn, dry_run=False)
|
||||
second_count = self.conn.execute(
|
||||
"SELECT COUNT(*) FROM atlas_city_names"
|
||||
).fetchone()[0]
|
||||
|
||||
self.assertEqual(
|
||||
first_count,
|
||||
second_count,
|
||||
"a second run must yield an IDENTICAL atlas_city_names count, not "
|
||||
"a doubled one — the clear-before-reinsert contract must hold",
|
||||
)
|
||||
self.assertEqual(
|
||||
first, second, "the populator's own return value must also be stable"
|
||||
)
|
||||
self.assertGreater(
|
||||
first_count, 0, "fixture sanity: the committed wiki content must yield rows"
|
||||
)
|
||||
|
||||
def test_atlas_feature_names_count_stable_across_two_runs(self):
|
||||
# T-1169: atlas_feature_names landed alongside atlas_city_names but
|
||||
# is a separate table/populator — must be checked independently, not
|
||||
# assumed to share the city-name populator's idempotency by proximity.
|
||||
first = atlas.populate_atlas_feature_names(self.conn, dry_run=False)
|
||||
first_count = self.conn.execute(
|
||||
"SELECT COUNT(*) FROM atlas_feature_names"
|
||||
).fetchone()[0]
|
||||
self.assertEqual(first, first_count)
|
||||
|
||||
second = atlas.populate_atlas_feature_names(self.conn, dry_run=False)
|
||||
second_count = self.conn.execute(
|
||||
"SELECT COUNT(*) FROM atlas_feature_names"
|
||||
).fetchone()[0]
|
||||
|
||||
self.assertEqual(
|
||||
first_count,
|
||||
second_count,
|
||||
"a second run must yield an IDENTICAL atlas_feature_names count, not "
|
||||
"a doubled one — the clear-before-reinsert contract must hold",
|
||||
)
|
||||
self.assertEqual(first, second)
|
||||
self.assertGreater(
|
||||
first_count, 0, "fixture sanity: the committed wiki content must yield rows"
|
||||
)
|
||||
|
||||
def test_sol_system_gj0_gets_zero_rows_in_both_pools_across_two_runs(self):
|
||||
# D-223 permanent exemption: Sol ('GJ 0') keeps authored,
|
||||
# geometry-bearing markers.json via sol_import.py — never the names
|
||||
# pool. Must hold on the FIRST run (not just "never accumulates") and
|
||||
# remain zero on the second.
|
||||
for run in (1, 2):
|
||||
atlas.populate_atlas_city_names(self.conn, dry_run=False)
|
||||
atlas.populate_atlas_feature_names(self.conn, dry_run=False)
|
||||
|
||||
sol_cities = self.conn.execute(
|
||||
"""SELECT COUNT(*) FROM atlas_city_names
|
||||
WHERE body_id IN (SELECT body_id FROM bodies WHERE system_id = 'GJ 0')"""
|
||||
).fetchone()[0]
|
||||
sol_features = self.conn.execute(
|
||||
"""SELECT COUNT(*) FROM atlas_feature_names
|
||||
WHERE body_id IN (SELECT body_id FROM bodies WHERE system_id = 'GJ 0')"""
|
||||
).fetchone()[0]
|
||||
|
||||
self.assertEqual(
|
||||
sol_cities, 0, f"run {run}: Sol (GJ 0) must have zero atlas_city_names rows"
|
||||
)
|
||||
self.assertEqual(
|
||||
sol_features,
|
||||
0,
|
||||
f"run {run}: Sol (GJ 0) must have zero atlas_feature_names rows",
|
||||
)
|
||||
|
||||
# Fixture sanity: GJ 0 must actually have bodies in this DB, or the
|
||||
# zero-rows assertions above would be vacuously true.
|
||||
gj0_body_count = self.conn.execute(
|
||||
"SELECT COUNT(*) FROM bodies WHERE system_id = 'GJ 0'"
|
||||
).fetchone()[0]
|
||||
self.assertGreater(
|
||||
gj0_body_count,
|
||||
0,
|
||||
"fixture sanity: GJ 0 (Sol) must have bodies in the committed DB, or "
|
||||
"the exemption checks above are vacuous",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -784,8 +784,21 @@ def compute_biome(body_def, elevation, sea_level, surface_water,
|
||||
oasis_water = (biome == 2) & land # scattered lake cells on land
|
||||
if oasis_water.any():
|
||||
from scipy.ndimage import binary_dilation
|
||||
ring1 = binary_dilation(oasis_water, iterations=2) & ~oasis_water & land
|
||||
ring2 = binary_dilation(oasis_water, iterations=4) & ~oasis_water & ~ring1 & land
|
||||
# Pixel-unit iteration counts scale by GRID_W/512 (module
|
||||
# convention, see the GRID_W comment above) so the vegetation
|
||||
# ring covers the same physical distance at any resolution — a
|
||||
# fixed pixel count would shrink the ring (relative to the
|
||||
# planet's real size) as GRID_W grows past the 512 baseline.
|
||||
scale = GRID_W / 512.0
|
||||
ring1_iters = max(1, round(2 * scale))
|
||||
ring2_iters = max(1, round(4 * scale))
|
||||
ring1 = binary_dilation(oasis_water, iterations=ring1_iters) & ~oasis_water & land
|
||||
ring2 = (
|
||||
binary_dilation(oasis_water, iterations=ring2_iters)
|
||||
& ~oasis_water
|
||||
& ~ring1
|
||||
& land
|
||||
)
|
||||
# Inner ring: lush vegetation (coast/lowland green)
|
||||
biome[ring1] = 4 # lowland
|
||||
# Outer ring: transitional (savanna/shrub)
|
||||
|
||||
Reference in New Issue
Block a user