namespace LivingVillage.Desktop.Tests open Microsoft.VisualStudio.TestTools.UnitTesting open LivingVillage.Desktop /// P46 menu/splash environment FX: /// * per-tile water ripple whose phase reuses VillageArt.waterFrameTickAt; /// * lantern breathing glow alpha; /// * two-layer parallax cloud shadow above the ridge (pure horizontal drift); /// * a deterministic shot table + layout invariants (nothing moved, nothing clipped). /// Every function is frame-driven, so re-rendering the same frame is byte-identical. [] type P46MenuAmbienceTests () = let twoPiOverThree = 2.0f * float32 System.Math.PI / 3.0f let assertInt (expected: int) (actual: int) (message: string) = Assert.IsTrue((expected = actual), sprintf "%s: expected %d actual %d" message expected actual) let assertClose (expected: float32) (actual: float32) (message: string) = Assert.IsTrue((abs (expected - actual) < 1e-5f), sprintf "%s: expected %f actual %f" message expected actual) [] member _.WaterPhaseReusesWaterFrameTickAtOnTheCycleBoundary () = // At every 32-tick boundary the fractional part is zero, so the phase must equal // the waterFrameTickAt index scaled to 120-degree steps: that is the reuse contract. for frame in [ 0L; 32L; 64L; 31L * 32L ] do for column in [ 0; 1; 7; 39 ] do for rowIndex in [ 0; 1 ] do let expected = float32 (VillageArt.waterFrameTickAt frame column rowIndex) * twoPiOverThree assertClose expected (MenuAmbience.waterPhase frame column rowIndex) (sprintf "frame %d col %d row %d must reuse waterFrameTickAt" frame column rowIndex) [] member _.AllEffectsAreDeterministicForTheSameFrame () = for frame in [ 0L; 1L; 16L; 33L; 173L; 4097L ] do for column in [ 0; 5; 39 ] do for rowIndex in [ 0; 1 ] do assertClose (MenuAmbience.waterColumnBrightness frame column rowIndex) (MenuAmbience.waterColumnBrightness frame column rowIndex) "water brightness must be deterministic" assertInt (MenuAmbience.waterColumnLift frame column rowIndex) (MenuAmbience.waterColumnLift frame column rowIndex) "water lift must be deterministic" for index in [ 0; 1; 3 ] do assertInt (MenuAmbience.lanternBreathAlpha frame index) (MenuAmbience.lanternBreathAlpha frame index) "breath must be deterministic" for layer in [ 0; 1 ] do assertInt (MenuAmbience.cloudOffsetPx layer 140L 1280) (MenuAmbience.cloudOffsetPx layer 140L 1280) "cloud offset must be deterministic" assertInt (List.length (MenuAmbience.cloudPuffs layer 140L 1280)) (List.length (MenuAmbience.cloudPuffs layer 140L 1280)) "cloud puffs must be deterministic" [] member _.WaterRippleVariesAcrossColumnsAndFrames () = Assert.IsTrue(MenuAmbience.waterRippleAmplitude >= 0.25f, "ripple amplitude must stay >= 0.25") // Adjacent tile columns sit 120 degrees apart, so they differ visibly. let adjacent = [ for column in 0 .. 38 -> abs (MenuAmbience.waterColumnBrightness 150L column 0 - MenuAmbience.waterColumnBrightness 150L (column + 1) 0) ] Assert.IsTrue(List.max adjacent >= 0.2f, sprintf "adjacent column step %.3f must be >= 0.20" (List.max adjacent)) // Across one row the column brightness has a real spread. let columns = [ for column in 0 .. 39 -> float (MenuAmbience.waterColumnBrightness 150L column 0) ] let mean = List.average columns let std = sqrt (List.average [ for v in columns -> (v - mean) ** 2.0 ]) Assert.IsTrue(std >= 0.15, sprintf "across-column std %.3f must be >= 0.15" std) // And the same column moves as the frame advances within a 32-tick window. let overFrames = [ for frame in 0L .. 31L -> MenuAmbience.waterColumnBrightness frame 5 0 ] Assert.IsTrue(overFrames |> List.distinct |> List.length > 3, "ripple must move with the frame") [] member _.LanternBreathIsBoundedAndSwingsTwoPhases () = let samples = [ for frame in 0L .. 260L do for index in 0 .. 5 -> MenuAmbience.lanternBreathAlpha frame index ] Assert.IsTrue(samples |> List.forall (fun a -> a >= 40 && a <= 96), "breath alpha must stay in [40, 96]") Assert.IsTrue(samples |> List.distinct |> List.length > 1, "breath must vary") Assert.AreNotEqual(MenuAmbience.lanternBreathAlpha 50L 0, MenuAmbience.lanternBreathAlpha 50L 1) // The two evidence phases must actually be a peak and a trough (readable luma delta). let peak = MenuAmbience.lanternBreathAlpha (P46ShotScript.menuPeakFrame + 1L) 0 let trough = MenuAmbience.lanternBreathAlpha (P46ShotScript.menuTroughFrame + 1L) 0 Assert.IsTrue(peak - trough >= 50, sprintf "breath phases %d vs %d must differ by >= 50" peak trough) [] member _.CloudParallaxIsBoundedAndLayered () = let width = 1280 let span = width + 200 assertInt 2 MenuAmbience.cloudLayerCount "two parallax layers" for layer in 0 .. MenuAmbience.cloudLayerCount - 1 do for frame in [ 0L; 40L; 100L; 140L; 10000L ] do let offset = MenuAmbience.cloudOffsetPx layer frame width Assert.IsTrue(offset >= 0 && offset < span, sprintf "layer %d offset %d out of [0,%d)" layer offset span) // Every cloud body sits entirely above the ridge line: no ground/roof overlap. for (_, cy, _, ry) in MenuAmbience.cloudPuffs layer frame width do Assert.IsTrue(cy - ry >= 0 && cy + ry < 44, sprintf "layer %d cloud y %d..%d must stay above ridge 44" layer (cy - ry) (cy + ry)) // The inner layer drifts slower than the outer one (parallax), inside one span. assertInt (2 * MenuAmbience.cloudOffsetPx 0 100L width) (MenuAmbience.cloudOffsetPx 1 100L width) "parallax 2x" // Drift moves with the frame. assertInt 20 (MenuAmbience.cloudOffsetPx 0 140L width - MenuAmbience.cloudOffsetPx 0 100L width) "cloud drift px" [] member _.P46ShotTableIsOrderedAndNamesAreDistinct () = Assert.IsTrue(P46ShotScript.splashRippleFrame < P46ShotScript.cloudEarlyFrame) Assert.IsTrue(P46ShotScript.cloudEarlyFrame < P46ShotScript.cloudLateFrame) Assert.IsTrue(P46ShotScript.menuPeakFrame < P46ShotScript.menuTroughFrame) Assert.IsTrue(LaunchScreen.revealProgress P46ShotScript.splashRippleFrame < 1.0f, "the ripple frame must still show the water band") Assert.IsTrue(P46ShotScript.cloudLateFrame <= LaunchScreen.durationFrames, "cloud frames must fall inside the splash") Assert.IsTrue(P46ShotScript.menuPeakFrame >= 120L && P46ShotScript.menuTroughFrame >= 120L, "menu lantern phases must wait for the entrance to settle") let names = [ P46ShotScript.splashRippleName P46ShotScript.cloudEarlyName P46ShotScript.cloudLateName P46ShotScript.menuPeakName P46ShotScript.menuTroughName ] assertInt names.Length (names |> List.distinct |> List.length) "shot names distinct" [] member _.LayoutKeyFieldsAreUnchanged () = // Pins the splash/menu layout so an environment-FX change can never silently move it. let layout = TitleScreen.layout 1280 720 assertInt 44 layout.RidgeY "RidgeY" assertInt 76 layout.EaveY "EaveY" Assert.AreEqual([ 624; 656 ], layout.WaterRows) assertInt 512 layout.TitleX "TitleX" assertInt 120 layout.TitleY "TitleY" assertInt 4 layout.TitleScale "TitleScale" assertInt 410 layout.PanelX "PanelX" assertInt 296 layout.PanelY "PanelY" assertInt 460 layout.PanelWidth "PanelWidth" assertInt 320 layout.PanelHeight "PanelHeight" assertInt 450 layout.MenuX "MenuX" assertInt 434 layout.CursorX "CursorX" assertInt 466 layout.TextX "TextX" assertInt 240 layout.ControlsPanelY "ControlsPanelY" assertInt 566 layout.ControlsActionX "ControlsActionX" [] member _.LayoutStaysInsideTheViewportAcrossSizes () = for (width, height) in [ 320, 240; 640, 480; 800, 600; 1280, 720; 1920, 1080 ] do let layout = TitleScreen.layout width height let titleWidth = 4 * (4 * 16) Assert.IsTrue(layout.TitleX >= 0 && layout.TitleX + titleWidth <= width, sprintf "%dx%d title out of bounds" width height) Assert.IsTrue(layout.PanelX >= 0 && layout.PanelY >= 0 && layout.PanelX + layout.PanelWidth <= width && layout.PanelY + layout.PanelHeight <= height, sprintf "%dx%d panel out of bounds" width height) for rowIndex in 0 .. 4 do let cursor, label = TitleScreen.selectionRects layout rowIndex Assert.IsTrue(TitleScreen.rectsDisjoint cursor label, sprintf "%dx%d row %d cursor/label overlap" width height rowIndex) Assert.IsTrue(label.X >= 0 && label.X + label.Width <= layout.PanelX + layout.PanelWidth, sprintf "%dx%d row %d label out of panel" width height rowIndex) for rowIndex in 0 .. 7 do let key, action = TitleScreen.controlRowRects layout rowIndex Assert.IsTrue(TitleScreen.rectsDisjoint key action, sprintf "%dx%d control row %d overlap" width height rowIndex) if height >= 600 then Assert.IsTrue(key.Y + key.Height <= height && action.Y + action.Height <= height, sprintf "%dx%d control row %d below viewport" width height rowIndex) for row in layout.WaterRows do Assert.IsTrue(row >= 0 && row + 32 <= height, sprintf "%dx%d water row %d out of bounds" width height row)