using AcDream.Core.World; using Xunit; namespace AcDream.Core.Tests.World; public sealed class TeleportAnimSequencerTests { // --- Task 1.1: type presence --- [Fact] public void Enums_HaveExpectedValues() { Assert.Equal(0, (int)TeleportAnimState.Off); Assert.Equal(1, (int)TeleportAnimState.WorldFadeOut); Assert.Equal(2, (int)TeleportAnimState.TunnelFadeIn); Assert.Equal(3, (int)TeleportAnimState.Tunnel); Assert.Equal(4, (int)TeleportAnimState.TunnelContinue); Assert.Equal(5, (int)TeleportAnimState.TunnelFadeOut); Assert.Equal(6, (int)TeleportAnimState.WorldFadeIn); _ = TeleportEntryKind.Portal; _ = TeleportEntryKind.Login; _ = TeleportEntryKind.Death; _ = TeleportEntryKind.Logout; _ = TeleportAnimEvent.PlayEnterSound; _ = TeleportAnimEvent.PlayExitSound; _ = TeleportAnimEvent.Place; _ = TeleportAnimEvent.FireLoginComplete; _ = TeleportAnimEvent.EnterTunnel; } [Fact] public void Snapshot_DefaultsAreOff_ClearAlpha() { var snap = new TeleportAnimSnapshot( TeleportAnimState.Off, FadeAlpha: 0f, ShowTunnel: false, ShowPleaseWait: false); Assert.Equal(TeleportAnimState.Off, snap.State); Assert.Equal(0f, snap.FadeAlpha); Assert.False(snap.ShowTunnel); Assert.False(snap.ShowPleaseWait); } // --- Task 1.2: Begin(), IsActive, initial state --- [Theory] [InlineData(TeleportEntryKind.Portal, TeleportAnimState.Tunnel)] [InlineData(TeleportEntryKind.Login, TeleportAnimState.Tunnel)] [InlineData(TeleportEntryKind.Death, TeleportAnimState.Tunnel)] [InlineData(TeleportEntryKind.Logout, TeleportAnimState.WorldFadeOut)] public void Begin_SetsCorrectStartState(TeleportEntryKind kind, TeleportAnimState expectedStart) { var seq = new TeleportAnimSequencer(); Assert.False(seq.IsActive); seq.Begin(kind); Assert.True(seq.IsActive); Assert.Equal(expectedStart, seq.State); } [Fact] public void Begin_EmitsPlayEnterSoundOnFirstTick() { // PlayEnterSound is edge-triggered on the first Tick after Begin. var seq = new TeleportAnimSequencer(); seq.Begin(TeleportEntryKind.Portal); var (_, events) = seq.Tick(dt: 0f, worldReady: false); Assert.Contains(TeleportAnimEvent.PlayEnterSound, events); } // --- Task 1.3: State transitions — timing-correct Tick() --- // Helper: drive the sequencer forward by total elapsed time using small fixed steps. private static (TeleportAnimSnapshot snap, List allEvents) DriveSeconds(TeleportAnimSequencer seq, float seconds, bool worldReady, float step = 0.016f) { var allEvts = new List(); float remaining = seconds; TeleportAnimSnapshot last = default; while (remaining > 0f) { float dt = Math.Min(step, remaining); var (snap, evts) = seq.Tick(dt, worldReady); last = snap; allEvts.AddRange(evts); remaining -= dt; } return (last, allEvts); } // --- Logout path: WorldFadeOut -> TunnelFadeIn -> Tunnel --- [Fact] public void Logout_AfterFadeTime_TransitionsToTunnelFadeIn() { var seq = new TeleportAnimSequencer(); seq.Begin(TeleportEntryKind.Logout); // Consume the enter-sound tick at dt=0 seq.Tick(0f, worldReady: false); // Drive just past FadeTime (1.0s) DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.02f, worldReady: false); Assert.Equal(TeleportAnimState.TunnelFadeIn, seq.State); } [Fact] public void Logout_After2xFadeTime_TransitionsToTunnel() { var seq = new TeleportAnimSequencer(); seq.Begin(TeleportEntryKind.Logout); seq.Tick(0f, worldReady: false); // consume enter-sound DriveSeconds(seq, 2f * TeleportAnimSequencer.FadeTime + 0.02f, worldReady: false); Assert.Equal(TeleportAnimState.Tunnel, seq.State); } // --- Portal/Login/Death path: enters at Tunnel --- [Fact] public void Portal_StartsInTunnel_HoldsWhileNotReady() { var seq = new TeleportAnimSequencer(); seq.Begin(TeleportEntryKind.Portal); seq.Tick(0f, worldReady: false); // consume enter-sound // Drive 10s — should not advance past Tunnel while !worldReady DriveSeconds(seq, 10f, worldReady: false); Assert.Equal(TeleportAnimState.Tunnel, seq.State); } [Fact] public void Portal_WhenWorldReady_TransitionsToTunnelContinue_EmitsEnterTunnelAndPlace() { var seq = new TeleportAnimSequencer(); seq.Begin(TeleportEntryKind.Portal); seq.Tick(0f, worldReady: false); // consume enter-sound // The very first tick with worldReady=true should advance Tunnel→TunnelContinue and emit Place. var (_, evts) = seq.Tick(0.016f, worldReady: true); Assert.Equal(TeleportAnimState.TunnelContinue, seq.State); Assert.Contains(TeleportAnimEvent.Place, evts); } // --- TunnelContinue: MIN_CONTINUE hold then TunnelFadeOut --- [Fact] public void TunnelContinue_DoesNotAdvance_BeforeMinContinue() { var seq = new TeleportAnimSequencer(); seq.Begin(TeleportEntryKind.Portal); seq.Tick(0f, worldReady: false); // Enter TunnelContinue seq.Tick(0.016f, worldReady: true); Assert.Equal(TeleportAnimState.TunnelContinue, seq.State); // Drive MinContinue - ε — should still be TunnelContinue DriveSeconds(seq, TeleportAnimSequencer.MinContinue - 0.1f, worldReady: true); Assert.Equal(TeleportAnimState.TunnelContinue, seq.State); } [Fact] public void TunnelContinue_AdvancesToTunnelFadeOut_AfterMinContinue() { var seq = new TeleportAnimSequencer(); seq.Begin(TeleportEntryKind.Portal); seq.Tick(0f, worldReady: false); seq.Tick(0.016f, worldReady: true); // -> TunnelContinue DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true); Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State); } // --- MAX_CONTINUE forces progress even when !worldReady --- [Fact] public void TunnelContinue_ForcesAdvance_AtMaxContinue_EvenIfNotReady() { // Simulate: world never becomes fully ready but MAX_CONTINUE forces fade-out // This path exercises the safety-net from spec §3.4. var seq = new TeleportAnimSequencer(); seq.Begin(TeleportEntryKind.Portal); seq.Tick(0f, worldReady: false); // Manually push the state to TunnelContinue by passing worldReady=true for one tick, // then simulate worldReady toggling back to false. seq.Tick(0.016f, worldReady: true); // -> TunnelContinue // Drive MaxContinue + ε with worldReady=false (simulating "world never loaded") DriveSeconds(seq, TeleportAnimSequencer.MaxContinue + 0.05f, worldReady: false); Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State); } // --- TunnelFadeOut -> WorldFadeIn: PlayExitSound edge event --- [Fact] public void TunnelFadeOut_EmitsPlayExitSound_OnTransitionToWorldFadeIn() { var seq = new TeleportAnimSequencer(); seq.Begin(TeleportEntryKind.Portal); seq.Tick(0f, worldReady: false); seq.Tick(0.016f, worldReady: true); // -> TunnelContinue // Drive through MinContinue -> TunnelFadeOut DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true); Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State); // Drive through FadeTime -> WorldFadeIn; PlayExitSound should fire on that edge var (_, evts) = DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true); Assert.Equal(TeleportAnimState.WorldFadeIn, seq.State); Assert.Contains(TeleportAnimEvent.PlayExitSound, evts); } // --- WorldFadeIn -> Off: FireLoginComplete edge event --- [Fact] public void WorldFadeIn_EmitsFireLoginComplete_OnTransitionToOff() { var seq = new TeleportAnimSequencer(); seq.Begin(TeleportEntryKind.Portal); seq.Tick(0f, worldReady: false); seq.Tick(0.016f, worldReady: true); // -> TunnelContinue DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true); DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true); // -> WorldFadeIn var (_, evts) = DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true); Assert.Equal(TeleportAnimState.Off, seq.State); Assert.False(seq.IsActive); Assert.Contains(TeleportAnimEvent.FireLoginComplete, evts); } }