## Tests for ScatterField — the client-side deterministic scatter service. ## ## What is worth pinning here is the CONTRACT, not the numbers: the service ## exists so paint looks the same when the player comes back, and so that two ## consumers scattering over the same coordinates do not draw on top of each ## other. Both of those are properties, and both fail silently if they break — ## a correlated domain reads as "one slightly odd texture", not as a bug. ## ## Deliberately NOT pinned: the specific values. Bit-identity with the server's ## Rust noise is explicitly not a requirement (Jeroen, 2026-08-07 — "a seed is a ## seed and the functional intended outcome is repetition here"), and freezing ## the outputs here would turn any future improvement to the mixer into a ## test-breaking change for no gain. class_name TestScatterField extends GdUnitTestSuite const ScatterField := preload("res://scripts/scatter_field.gd") # ============================================================================= # The core contract: same question, same answer # ============================================================================= func test_same_position_repeats_forever() -> void: var salt: int = ScatterField.domain(&"test/repeat") var first: float = ScatterField.value(salt, 12, 34) for _i in range(50): assert_float(ScatterField.value(salt, 12, 34)).override_failure_message( "a position must answer identically every time — this is the whole service" ).is_equal(first) func test_neighbouring_positions_decorrelate() -> void: # Consumers walk coordinates in order, so adjacent inputs producing adjacent # outputs would render as a gradient or a moire rather than as scatter. var salt: int = ScatterField.domain(&"test/decorrelate") var a: float = ScatterField.value(salt, 100, 100) var b: float = ScatterField.value(salt, 101, 100) var c: float = ScatterField.value(salt, 100, 101) assert_float(absf(a - b)).is_greater(0.001) assert_float(absf(a - c)).is_greater(0.001) func test_values_stay_in_unit_range() -> void: var salt: int = ScatterField.domain(&"test/range") for i in range(500): var v: float = ScatterField.value(salt, i, i * 7) assert_float(v).is_between(0.0, 1.0) func test_negative_coordinates_are_supported() -> void: # World coordinates go negative — a consumer painting west or south of the # origin must not crash or fold onto its mirror position. var salt: int = ScatterField.domain(&"test/negative") var neg: float = ScatterField.value(salt, -500, -900) assert_float(neg).is_between(0.0, 1.0) assert_float(neg).override_failure_message( "(-x, -y) must not collide with (x, y) — that would mirror all paint about the origin" ).is_not_equal(ScatterField.value(salt, 500, 900)) # ============================================================================= # Domain separation — why graffiti and cracks do not land together # ============================================================================= func test_domains_are_uncorrelated_at_the_same_position() -> void: var graffiti: int = ScatterField.domain(&"world/graffiti") var cracks: int = ScatterField.domain(&"world/cracks") var agree: int = 0 for i in range(200): if is_equal_approx(ScatterField.value(graffiti, i, 0), ScatterField.value(cracks, i, 0)): agree += 1 assert_int(agree).override_failure_message( "two domains agreeing at the same coordinates means every consumer marks the " + "same spots — the artefacts would stack and read as one" ).is_less(3) func test_domain_resolution_is_stable_and_cached() -> void: assert_int(ScatterField.domain(&"test/stable")).is_equal( ScatterField.domain(&"test/stable") ) assert_int(ScatterField.domain(&"test/a")).is_not_equal(ScatterField.domain(&"test/b")) # ============================================================================= # The derived helpers # ============================================================================= func test_chance_honours_its_probability() -> void: var salt: int = ScatterField.domain(&"test/chance") var hits: int = 0 var n: int = 4000 for i in range(n): if ScatterField.chance(salt, i % 64, i / 64, 0.25): hits += 1 var rate: float = float(hits) / float(n) assert_float(rate).override_failure_message( "p=0.25 produced %.3f — a biased field makes every density constant a lie" % rate ).is_between(0.21, 0.29) func test_chance_extremes_are_absolute() -> void: var salt: int = ScatterField.domain(&"test/extremes") for i in range(200): assert_bool(ScatterField.chance(salt, i, 0, 0.0)).is_false() assert_bool(ScatterField.chance(salt, i, 0, 1.0)).is_true() func test_pick_covers_its_whole_range() -> void: # A variant chooser that never returns some of its options silently reduces # the asset set — the sprite exists, it just never appears. var salt: int = ScatterField.domain(&"test/pick") var seen: Dictionary = {} for i in range(600): seen[ScatterField.pick(salt, i, 0, 4)] = true assert_int(seen.size()).is_equal(4) for i in range(50): assert_int(ScatterField.pick(salt, i, 0, 4)).is_between(0, 3) func test_pick_degenerate_counts_are_safe() -> void: var salt: int = ScatterField.domain(&"test/pick_degenerate") assert_int(ScatterField.pick(salt, 5, 5, 1)).is_equal(0) assert_int(ScatterField.pick(salt, 5, 5, 0)).is_equal(0) func test_jitter_is_centred_and_bounded() -> void: var salt: int = ScatterField.domain(&"test/jitter") var sum := Vector2.ZERO var n: int = 2000 for i in range(n): var j: Vector2 = ScatterField.jitter(salt, i % 50, i / 50) assert_float(j.x).is_between(-0.5, 0.5) assert_float(j.y).is_between(-0.5, 0.5) sum += j # Both axes must be independent streams; a shared one puts every mark on the # diagonal, which reads as a hatch rather than as scatter. assert_float(absf(sum.x / float(n))).is_less(0.05) assert_float(absf(sum.y / float(n))).is_less(0.05) # ============================================================================= # The time axis — animation and layering # ============================================================================= func test_time_step_changes_the_field() -> void: var salt: int = ScatterField.domain(&"sky/cloud_cover") var t0: float = ScatterField.value(salt, 7, 7, 0) var t1: float = ScatterField.value(salt, 7, 7, 1) assert_float(absf(t0 - t1)).override_failure_message( "advancing t must move the field, or cloud cover cannot animate" ).is_greater(0.001) # ...and each step is itself stable, so an animation loops rather than boils. assert_float(ScatterField.value(salt, 7, 7, 1)).is_equal(t1) # ============================================================================= # smooth() — the continuous field # ============================================================================= func test_smooth_is_continuous_between_lattice_points() -> void: # The point of smooth() over value(): neighbouring samples must be CLOSE, or # cloud cover is speckle instead of cloud. var salt: int = ScatterField.domain(&"test/smooth") var prev: float = ScatterField.smooth(salt, 0.0, 0.0, 64.0) for i in range(1, 200): var v: float = ScatterField.smooth(salt, float(i), 0.0, 64.0) assert_float(absf(v - prev)).override_failure_message( "smooth() jumped %.3f in one world unit at wavelength 64 — not a field" % absf(v - prev) ).is_less(0.2) prev = v func test_smooth_stays_in_unit_range_and_repeats() -> void: var salt: int = ScatterField.domain(&"test/smooth_range") for i in range(300): var v: float = ScatterField.smooth(salt, float(i) * 3.7, float(i) * 1.3, 32.0) assert_float(v).is_between(0.0, 1.0) assert_float(ScatterField.smooth(salt, 12.5, 8.25, 32.0)).is_equal( ScatterField.smooth(salt, 12.5, 8.25, 32.0) ) func test_smooth_degenerate_wavelength_does_not_divide_by_zero() -> void: var salt: int = ScatterField.domain(&"test/smooth_zero") assert_float(ScatterField.smooth(salt, 4.0, 4.0, 0.0)).is_between(0.0, 1.0) assert_float(ScatterField.smooth(salt, 4.0, 4.0, -8.0)).is_between(0.0, 1.0)