Frontend menu + settings + saves foundation

Netcode frontend pattern: UITK main menu / pause / settings (MenuUi + controllers), on-demand world lifecycle (WorldLauncher/SessionRunner), GameBootstrap menu branch; Graphics/Audio settings (SettingsService/GameVolume); single-slot save foundation (SaveData/SaveService, born-correct load at director spawn, autosave on Siege->Calm + quit); RuntimePanelSettings + theme; BuildTool menu; 10 EditMode tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 15:05:36 -07:00
parent f3f65bccbf
commit f31ffe910b
56 changed files with 1744 additions and 8 deletions
@@ -0,0 +1,113 @@
using NUnit.Framework;
using ProjectM.Client;
using UnityEngine;
namespace ProjectM.Tests
{
/// <summary>
/// Pure-logic tests for the client settings model (no ECS world): JSON round-trip via JsonUtility,
/// defensive clamping of hand-edited/corrupt values, and the GameVolume bus statics. Engine APIs
/// (Screen/QualitySettings) resolve in EditMode, so Defaults()/Clamped() are exercisable here.
/// </summary>
public class GameSettingsTests
{
static GameSettings Sample() => new GameSettings
{
Version = GameSettings.CurrentVersion,
ResWidth = 2560,
ResHeight = 1440,
RefreshHz = 144,
FullScreenMode = (int)FullScreenMode.ExclusiveFullScreen,
QualityLevel = 0,
VSync = 2,
TargetFps = 120,
Master = 0.8f,
Music = 0.5f,
Sfx = 0.3f,
};
[Test]
public void Json_RoundTrip_PreservesAllFields()
{
var src = Sample();
var json = JsonUtility.ToJson(src);
var dst = JsonUtility.FromJson<GameSettings>(json);
Assert.AreEqual(src.Version, dst.Version);
Assert.AreEqual(src.ResWidth, dst.ResWidth);
Assert.AreEqual(src.ResHeight, dst.ResHeight);
Assert.AreEqual(src.RefreshHz, dst.RefreshHz);
Assert.AreEqual(src.FullScreenMode, dst.FullScreenMode);
Assert.AreEqual(src.QualityLevel, dst.QualityLevel);
Assert.AreEqual(src.VSync, dst.VSync);
Assert.AreEqual(src.TargetFps, dst.TargetFps);
Assert.AreEqual(src.Master, dst.Master, 1e-5f);
Assert.AreEqual(src.Music, dst.Music, 1e-5f);
Assert.AreEqual(src.Sfx, dst.Sfx, 1e-5f);
}
[Test]
public void Clamped_BoundsOutOfRangeValues()
{
var bad = new GameSettings
{
ResWidth = 99999, ResHeight = -5, RefreshHz = -10,
FullScreenMode = 99, QualityLevel = 9999, VSync = 7,
TargetFps = 5, Master = 2f, Music = -1f, Sfx = 3f,
};
var c = bad.Clamped();
Assert.LessOrEqual(c.ResWidth, 7680);
Assert.GreaterOrEqual(c.ResHeight, 480);
Assert.GreaterOrEqual(c.RefreshHz, 0);
Assert.That(c.FullScreenMode, Is.InRange(0, 3));
Assert.That(c.VSync, Is.InRange(0, 2));
Assert.AreEqual(1f, c.Master, 1e-5f); // 2f -> 1
Assert.AreEqual(0f, c.Music, 1e-5f); // -1 -> 0
Assert.AreEqual(1f, c.Sfx, 1e-5f); // 3f -> 1
Assert.That(c.QualityLevel, Is.GreaterThanOrEqualTo(0)); // clamped to a valid level index
}
[Test]
public void Clamped_NonPositiveTargetFps_BecomesUncapped()
{
Assert.AreEqual(-1, new GameSettings { TargetFps = 0 }.Clamped().TargetFps);
Assert.AreEqual(-1, new GameSettings { TargetFps = -50 }.Clamped().TargetFps);
Assert.AreEqual(120, new GameSettings { TargetFps = 120, Master = 1f }.Clamped().TargetFps);
}
[Test]
public void Defaults_AreValidAndFullVolume()
{
var d = GameSettings.Defaults();
Assert.AreEqual(GameSettings.CurrentVersion, d.Version);
Assert.Greater(d.ResWidth, 0);
Assert.Greater(d.ResHeight, 0);
Assert.AreEqual(1f, d.Master, 1e-5f);
Assert.AreEqual(1f, d.Music, 1e-5f);
Assert.AreEqual(1f, d.Sfx, 1e-5f);
// Defaults survive a clamp unchanged (they are in-range by construction).
var c = d.Clamped();
Assert.AreEqual(d.Master, c.Master, 1e-5f);
Assert.AreEqual(d.VSync, c.VSync);
}
[Test]
public void GameVolume_BusesAreSettable_DefaultFull()
{
float prevMusic = GameVolume.Music, prevSfx = GameVolume.Sfx;
try
{
GameVolume.Music = 0.25f;
GameVolume.Sfx = 0.6f;
Assert.AreEqual(0.25f, GameVolume.Music, 1e-5f);
Assert.AreEqual(0.6f, GameVolume.Sfx, 1e-5f);
}
finally
{
GameVolume.Music = prevMusic;
GameVolume.Sfx = prevSfx;
}
}
}
}