using NUnit.Framework; using ProjectM.Client; using ProjectM.Simulation; namespace ProjectM.Tests { /// /// Pure-logic coverage for the first-run onboarding step machine () — the /// testable core of the client-only OnboardingSystem. No World/ECS needed: each case builds a /// and asserts the deterministic advance rule, the mask helpers, /// the scheme-aware prompts, and the pointer kinds. /// public class OnboardingStepTests { static OnboardingStepMath.Snapshot Empty() => new OnboardingStepMath.Snapshot(); // ---- mask helpers (resume point + dormant detection) ---- [Test] public void FirstIncomplete_EmptyMask_IsWelcome() => Assert.AreEqual(OnboardingStepMath.Welcome, OnboardingStepMath.FirstIncomplete(0)); [Test] public void FirstIncomplete_SkipsCompletedPrefix() { int mask = (1 << OnboardingStepMath.Welcome) | (1 << OnboardingStepMath.Move) | (1 << OnboardingStepMath.Build); Assert.AreEqual(OnboardingStepMath.Fabricator, OnboardingStepMath.FirstIncomplete(mask)); } [Test] public void AllComplete_TrueForFullMaskAndMigrationSentinel() { Assert.IsFalse(OnboardingStepMath.AllComplete(0)); int full = (1 << OnboardingStepMath.StepCount) - 1; Assert.IsTrue(OnboardingStepMath.AllComplete(full)); Assert.IsTrue(OnboardingStepMath.AllComplete(int.MaxValue)); // the v1->v2 migration sentinel reads as done } // ---- per-step completion rules ---- [Test] public void Welcome_AdvancesOnTimer() { var s = Empty(); s.StepElapsed = OnboardingStepMath.WelcomeSeconds - 0.1f; Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Welcome, s)); s.StepElapsed = OnboardingStepMath.WelcomeSeconds; Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Welcome, s)); } [Test] public void Move_AdvancesAfterThreshold() { var s = Empty(); s.MoveDistance = OnboardingStepMath.MoveThreshold - 0.1f; Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Move, s)); s.MoveDistance = OnboardingStepMath.MoveThreshold; Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Move, s)); } [Test] public void ReadyUp_AdvancesOnLocalReadyOrLaunch() { var s = Empty(); s.Lifecycle = RunLifecycle.Staging; Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, s)); var ready = Empty(); ready.LocalReady = true; Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, ready)); var launched = Empty(); launched.Lifecycle = RunLifecycle.Launching; Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.ReadyUp, launched)); } [Test] public void Build_AbsoluteTurretCount_AutoSuppressesAtBuiltBase() { var s = Empty(); s.TurretCount = 0; Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Build, s)); s.TurretCount = 1; // a join-client landing at an already-built base satisfies it on entry Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Build, s)); } [Test] public void Fabricator_SoftBeat_AdvancesOnBuildOrTimeout() { var none = Empty(); Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, none)); var built = Empty(); built.FabricatorCount = 1; Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, built)); var timedOut = Empty(); timedOut.StepElapsed = OnboardingStepMath.FabricatorSoftSeconds; Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Fabricator, timedOut)); } [Test] public void Rooms_AdvancesInRoomAfterMinimumDwell() { var s = Empty(); Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, s)); var onExpTooSoon = Empty(); onExpTooSoon.OnExpedition = true; onExpTooSoon.StepElapsed = OnboardingStepMath.RoomsSeconds - 0.1f; Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, onExpTooSoon)); // D2: don't advance the instant we teleport in var dwelled = Empty(); dwelled.OnExpedition = true; dwelled.StepElapsed = OnboardingStepMath.RoomsSeconds; Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, dwelled)); // the mine prompt + node pointer showed IN the room var elapsedAtBase = Empty(); elapsedAtBase.StepElapsed = OnboardingStepMath.RoomsSeconds + 5f; Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Rooms, elapsedAtBase)); // elapsed alone (still at base) must NOT satisfy } [Test] public void Boon_AdvancesOnClearedObjectiveOrRewardLifecycle() { var s = Empty(); s.ObjectiveState = ExpeditionObjectiveState.Active; Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, s)); s.ObjectiveState = ExpeditionObjectiveState.Cleared; Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, s)); var reward = Empty(); reward.Lifecycle = RunLifecycle.RoomReward; Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, reward)); var route = Empty(); route.Lifecycle = RunLifecycle.RouteSelect; Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Boon, route)); } [Test] public void Return_AdvancesOnComingHomeAfterExpeditionOrTimeout() { var onExp = Empty(); onExp.WasOnExpedition = true; onExp.OnExpedition = true; Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, onExp)); // still out on expedition var neverLeftBase = Empty(); neverLeftBase.OnExpedition = false; // at base but never observed on expedition this step Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, neverLeftBase)); // D3: a start-at-base Return must NOT instantly satisfy var cameHome = Empty(); cameHome.WasOnExpedition = true; cameHome.OnExpedition = false; Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, cameHome)); // was on expedition, now home var timedOut = Empty(); timedOut.StepElapsed = OnboardingStepMath.ReturnMaxSeconds; Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Return, timedOut)); // D3 soft backstop (never a 1-tick edge) } [Test] public void Defend_WaitsForSiegeEndButTimesOutWithoutOne() { var mid = Empty(); mid.SawSiege = true; mid.Phase = CyclePhase.Siege; Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, mid)); var survived = Empty(); survived.SawSiege = true; survived.Phase = CyclePhase.Calm; Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, survived)); var noSiege = Empty(); noSiege.StepElapsed = OnboardingStepMath.DefendNoSiegeSeconds; Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Defend, noSiege)); } [Test] public void Done_LingersThenCompletes() { var s = Empty(); s.StepElapsed = OnboardingStepMath.DoneSeconds - 0.1f; Assert.IsFalse(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Done, s)); s.StepElapsed = OnboardingStepMath.DoneSeconds; Assert.IsTrue(OnboardingStepMath.IsSatisfied(OnboardingStepMath.Done, s)); } // ---- prompts (scheme-aware, never empty) ---- [Test] public void Prompts_NonEmptyForEveryStep() { for (byte i = 0; i < OnboardingStepMath.StepCount; i++) { Assert.IsNotEmpty(OnboardingStepMath.Prompt(i, false), "kbm step " + i); Assert.IsNotEmpty(OnboardingStepMath.Prompt(i, true), "pad step " + i); } } [Test] public void Prompts_AreSchemeAware() { StringAssert.Contains("WASD", OnboardingStepMath.Prompt(OnboardingStepMath.Move, false)); StringAssert.Contains("Tab", OnboardingStepMath.Prompt(OnboardingStepMath.Build, false)); StringAssert.Contains("Y", OnboardingStepMath.Prompt(OnboardingStepMath.Build, true)); } // ---- pointer kinds ---- [Test] public void PointerKinds_OnlyTheRoomsStepPoints() { Assert.AreEqual(OnboardingStepMath.PointerOreNode, OnboardingStepMath.PointerKind(OnboardingStepMath.Rooms)); Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.ReadyUp)); Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Return)); Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Move)); Assert.AreEqual(OnboardingStepMath.PointerNone, OnboardingStepMath.PointerKind(OnboardingStepMath.Defend)); } } /// /// Public-surface coverage of the onboarding settings fields + their interaction with the dormant check /// (the v1->v2 migration itself runs through the private SettingsService.Migrate at load — its EFFECT is /// pinned here via the all-done sentinel + Defaults/Clamped, and end-to-end in the Play smoke). /// public class OnboardingSettingsTests { [Test] public void Defaults_TutorialOn_MaskEmpty() { var d = GameSettings.Defaults(); Assert.AreEqual(1, d.TutorialHints); Assert.AreEqual(0, d.OnboardingMask); Assert.AreEqual(GameSettings.CurrentVersion, d.Version); } [Test] public void Clamped_NormalizesHints_PreservesMask() { var s = GameSettings.Defaults(); s.TutorialHints = 5; // out of the 0/1 range s.OnboardingMask = 0x55; // an arbitrary bitmask must survive untouched var c = s.Clamped(); Assert.AreEqual(1, c.TutorialHints); Assert.AreEqual(0x55, c.OnboardingMask); } [Test] public void Defaults_ForceEachLaunch_Off() => Assert.AreEqual(0, GameSettings.Defaults().ForceOnboardingEachLaunch); [Test] public void Clamped_NormalizesForceEachLaunchToBool() { var s = GameSettings.Defaults(); s.ForceOnboardingEachLaunch = 7; // any non-zero collapses to the 0/1 dev flag Assert.AreEqual(1, s.Clamped().ForceOnboardingEachLaunch); s.ForceOnboardingEachLaunch = 0; Assert.AreEqual(0, s.Clamped().ForceOnboardingEachLaunch); } } }