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:
2026-07-25 18:58:53 +02:00
co-authored by Claude Fable 5
parent e0d120e555
commit f81622bbf0
7 changed files with 626 additions and 3 deletions
+3
View File
@@ -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=$$?; \
+53 -1
View File
@@ -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):
+234
View File
@@ -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:?}"
);
}
}
+83
View File
@@ -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
+81
View File
@@ -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)
+15 -2
View File
@@ -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)