diff --git a/.github/workflows/build-test-corvax.yml b/.github/workflows/build-test-corvax.yml new file mode 100644 index 0000000000..2ee762512d --- /dev/null +++ b/.github/workflows/build-test-corvax.yml @@ -0,0 +1,79 @@ +name: Build & Test Corvax + +on: + push: + branches: [ master, staging, stable ] + merge_group: + pull_request: + types: [ opened, reopened, synchronize, ready_for_review ] + branches: [ master, staging, stable ] + paths: + - 'Corvax/**' + - 'Content.Client/**' + - 'Content.Server/**' + - 'Content.Shared/**' + - 'Content.IntegrationTests/**' + - 'Content.Tests/**' + - 'Resources/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name != 'merge_group' }} + +jobs: + build: + name: Build & Test Corvax + if: github.actor != 'IanComradeBot' && github.event.pull_request.draft == false + runs-on: ubuntu-latest + timeout-minutes: 50 + + steps: + - name: Checkout Master + uses: actions/checkout@v7 + + - name: Setup Submodule + run: | + git submodule update --init --recursive + + - name: Pull engine updates + uses: space-wizards/submodule-dependency@v0.1.5 + + - name: Update Engine Submodules + run: | + cd RobustToolbox/ + git submodule update --init --recursive + + - name: Setup .NET Core + uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + + - name: Install dependencies + run: dotnet restore + + - name: Build Project + run: dotnet build --configuration DebugOpt --no-restore /m + + - name: Run Content.Corvax.Tests + shell: pwsh + run: | + $env:DOTNET_gcServer=1 + dotnet test --no-build --configuration DebugOpt Corvax/Content.Corvax.Tests/Content.Corvax.Tests.csproj -- NUnit.ConsoleOut=0 NUnit.MapWarningTo=Failed NUnit.TestOutputXml="logs" NUnit.WorkDirectory="$(pwd)/test_results" + + - name: Archive NUnit3 test results. + if: always() + uses: actions/upload-artifact@v4 + with: + name: nunit3-corvax-results + path: test_results/* + retention-days: 7 + compression-level: 9 + + ci-success: + name: Build & Test Corvax + needs: + - build + runs-on: ubuntu-latest + steps: + - name: CI succeeded + run: exit 0 diff --git a/Content.IntegrationTests/Tests/PostMapInitTest.cs b/Content.IntegrationTests/Tests/PostMapInitTest.cs index dc47ef3796..d5d7b8d9d4 100644 --- a/Content.IntegrationTests/Tests/PostMapInitTest.cs +++ b/Content.IntegrationTests/Tests/PostMapInitTest.cs @@ -29,8 +29,37 @@ using Robust.Shared.Prototypes; using Robust.Shared.Utility; namespace Content.IntegrationTests.Tests { + // Corvax-Tests-start [TestFixture] - public sealed class PostMapInitTest : GameTest + public sealed class PostMapInitTest : PostMapInitTestBase + { + // ReSharper disable UseCollectionExpression + private const string ExcludeCorvax = "/Maps/Corvax"; + + [Test, TestCaseSource(nameof(GridsSource), new object[] { false, new string[] { ExcludeCorvax } })] + [EnsureCVar(Side.Server, typeof(CCVars), nameof(CCVars.GridFill), false)] + public async Task GridsLoadableTest(string mapFile) => await RunGridsLoadableTest(mapFile); + + [Test, TestCaseSource(nameof(ShuttleMapFilesSource), new object[] { false, new string[] { ExcludeCorvax } })] + [EnsureCVar(Side.Server, typeof(CCVars), nameof(CCVars.GridFill), false)] + public async Task ShuttlesLoadableTest(ResPath path) => await RunShuttlesLoadableTest(path); + + [Test, TestCaseSource(nameof(AllMapFilesSource), new object[] { false, new string[] { ExcludeCorvax } })] + public async Task NoSavedPostMapInitTest(ResPath map) => await RunNoSavedPostMapInitTest(map); + + [Test, TestCaseSource(nameof(GameMapsSource))] + [EnsureCVar(Side.Server, typeof(CCVars), nameof(CCVars.GridFill), false)] + public async Task GameMapsLoadableTest(string mapProto) + => await RunGameMapsLoadableTest(mapProto, customOnly: false, new string[] { ExcludeCorvax }); + + [Test, TestCaseSource(nameof(AllMapFilesSource), new object[] { false, new string[] { ExcludeCorvax } })] + [EnsureCVar(Side.Server, typeof(CCVars), nameof(CCVars.GridFill), false)] + public async Task NonGameMapsLoadableTest(ResPath mapPath) => await RunNonGameMapsLoadableTest(mapPath); + // ReSharper restore UseCollectionExpression + } + // Corvax-Tests-end + + public abstract class PostMapInitTestBase : GameTest // Corvax-Tests-Edit { public override PoolSettings PoolSettings => new PoolSettings() { @@ -41,6 +70,63 @@ namespace Content.IntegrationTests.Tests private const bool SkipTestMaps = true; private const string TestMapsPath = "/Maps/Test/"; + // Corvax-Tests-start + private static bool MatchesAnyPrefix(string path, string[] prefixes) + { + foreach (var prefix in prefixes) + { + if (path.StartsWith(prefix, StringComparison.Ordinal)) + return true; + } + + return false; + } + + private static bool MatchesAnyPrefix(ResPath path, string[] prefixes) + => MatchesAnyPrefix(path.ToString(), prefixes); + + public static IEnumerable GridsSource(bool customOnly, string[] prefixes) + { + foreach (var map in Grids) + { + if (MatchesAnyPrefix(map, prefixes) != customOnly) + continue; + + yield return new TestCaseData(map).SetName(map); + } + } + + public static IEnumerable AllMapFilesSource(bool customOnly, string[] prefixes) + { + foreach (var path in AllMapFiles) + { + if (MatchesAnyPrefix(path, prefixes) != customOnly) + continue; + + yield return new TestCaseData(path).SetName(path.ToString()); + } + } + + public static IEnumerable ShuttleMapFilesSource(bool customOnly, string[] prefixes) + { + foreach (var path in ShuttleMapFiles) + { + if (MatchesAnyPrefix(path, prefixes) != customOnly) + continue; + + yield return new TestCaseData(path).SetName(path.ToString()); + } + } + + public static IEnumerable GameMapsSource() + { + foreach (var id in GameMaps) + { + yield return new TestCaseData(id).SetName(id); + } + } + // Corvax-Tests-end + private static readonly string[] NoSpawnMaps = { "CentComm", @@ -50,7 +136,7 @@ namespace Content.IntegrationTests.Tests private static readonly string[] Grids = { "/Maps/centcomm.yml", - "/Maps/Corvax/corvax_centcomm.yml", // Corvax edit + "/Maps/Corvax/corvax_centcomm.yml", // Corvax-Tests AdminTestArenaSystem.ArenaMapPath }; @@ -83,7 +169,7 @@ namespace Content.IntegrationTests.Tests { "/Maps/centcomm.yml", "/Maps/Shuttles/AdminSpawn/**", // admin gaming - "/Maps/Corvax/corvax_centcomm.yml" // Corvax edit + "/Maps/Corvax/corvax_centcomm.yml" // Corvax-Tests }; /// @@ -95,16 +181,19 @@ namespace Content.IntegrationTests.Tests private static readonly string[] GameMaps = GameDataScrounger.PrototypesOfKind().Where(x => x != PoolManager.TestMap).ToArray(); private static readonly ResPath[] AllMapFiles = GameDataScrounger.FilesInDirectoryInVfs("/Maps", "*.yml"); - private static readonly ResPath[] ShuttleMapFiles = GameDataScrounger.FilesInDirectoryInVfs("/Maps/Shuttles", "*.yml"); + // Corvax-Tests-start + private static readonly ResPath[] ShuttleMapFiles = + GameDataScrounger.FilesInDirectoryInVfs("/Maps/Shuttles", "*.yml") + .Concat(GameDataScrounger.FilesInDirectoryInVfs("/Maps/Corvax/Shuttles", "*.yml")) + .ToArray(); + // Corvax-Tests-end private static readonly ProtoId DoNotMapCategory = "DoNotMap"; /// /// Asserts that specific files have been saved as grids and not maps. /// - [Test, TestCaseSource(nameof(Grids))] - [EnsureCVar(Side.Server, typeof(CCVars), nameof(CCVars.GridFill), false)] - public async Task GridsLoadableTest(string mapFile) + protected async Task RunGridsLoadableTest(string mapFile) // Corvax-Tests-Edit { var pair = Pair; var server = pair.Server; @@ -134,10 +223,7 @@ namespace Content.IntegrationTests.Tests /// /// Asserts that shuttles are loadable and have been saved as grids and not maps. /// - [Test] - [TestCaseSource(nameof(ShuttleMapFiles))] - [EnsureCVar(Side.Server, typeof(CCVars), nameof(CCVars.GridFill), false)] - public async Task ShuttlesLoadableTest(ResPath path) + protected async Task RunShuttlesLoadableTest(ResPath path) // Corvax-Tests-Edit { var pair = Pair; var server = pair.Server; @@ -167,9 +253,7 @@ namespace Content.IntegrationTests.Tests }); } - [Test] - [TestCaseSource(nameof(AllMapFiles))] - public async Task NoSavedPostMapInitTest(ResPath map) + protected async Task RunNoSavedPostMapInitTest(ResPath map) // Corvax-Tests-Edit { var pair = Pair; var server = pair.Server; @@ -204,7 +288,7 @@ namespace Content.IntegrationTests.Tests // TODO MAP TESTS // Move this to some separate test? - //CheckDoNotMap(map, root, protoManager); // Corvax-Changes + //CheckDoNotMap(map, root, protoManager); // Corvax-Tests if (version >= 7) { @@ -233,7 +317,7 @@ namespace Content.IntegrationTests.Tests await server.WaitPost(() => server.EntMan.Spawn(null, new MapCoordinates(0, 0, id))); // First check that a pre-init version passes - var path = new ResPath($"{nameof(NoSavedPostMapInitTest)}.yml"); + var path = new ResPath($"{nameof(RunNoSavedPostMapInitTest)}.yml"); // Corvax-Tests-Edit Assert.That(loader.TrySaveMap(id, path)); Assert.That(IsPreInit(path, loader, deps, ev.RenamedPrototypes, ev.DeletedPrototypes)); @@ -324,9 +408,7 @@ namespace Content.IntegrationTests.Tests return true; } - [Test, TestCaseSource(nameof(GameMaps))] - [EnsureCVar(Side.Server, typeof(CCVars), nameof(CCVars.GridFill), false)] - public async Task GameMapsLoadableTest(string mapProto) + protected async Task RunGameMapsLoadableTest(string mapProto, bool customOnly, string[] prefixes) // Corvax-Tests-Edit { var pair = Pair; var server = pair.Server; @@ -339,13 +421,19 @@ namespace Content.IntegrationTests.Tests var shuttleSystem = entManager.EntitySysManager.GetEntitySystem(); var cfg = server.ResolveDependency(); + // Corvax-Tests-start + var gameMapProto = protoManager.Index(mapProto); + if (MatchesAnyPrefix(gameMapProto.MapPath, prefixes) != customOnly) + return; + // Corvax-Tests-end + await server.WaitPost(() => { MapId mapId; try { var opts = DeserializationOptions.Default with { InitializeMaps = true }; - ticker.LoadGameMap(protoManager.Index(mapProto), out mapId, opts); + ticker.LoadGameMap(gameMapProto, out mapId, opts); // Corvax-Tests-Edit } catch (Exception ex) { @@ -464,10 +552,7 @@ namespace Content.IntegrationTests.Tests return resultCount; } - [Test] - [TestCaseSource(nameof(AllMapFiles))] - [EnsureCVar(Side.Server, typeof(CCVars), nameof(CCVars.GridFill), false)] - public async Task NonGameMapsLoadableTest(ResPath mapPath) + protected async Task RunNonGameMapsLoadableTest(ResPath mapPath) // Corvax-Tests-Edit { var pair = Pair; var server = pair.Server; diff --git a/Content.Server/Corvax/TTS/TTSSystem.Sanitize.cs b/Content.Server/Corvax/TTS/TTSSystem.Sanitize.cs index 7da91d2641..8b9602e7eb 100644 --- a/Content.Server/Corvax/TTS/TTSSystem.Sanitize.cs +++ b/Content.Server/Corvax/TTS/TTSSystem.Sanitize.cs @@ -270,8 +270,12 @@ public static class NumberConverter private static void AppendWithSpace(StringBuilder stringBuilder, string str) { + if (string.IsNullOrEmpty(str)) + return; + if (stringBuilder.Length > 0) stringBuilder.Append(" "); + stringBuilder.Append(str); } diff --git a/Corvax/Content.Corvax.Tests/AssemblyInfo.cs b/Corvax/Content.Corvax.Tests/AssemblyInfo.cs new file mode 100644 index 0000000000..fdd365b7e2 --- /dev/null +++ b/Corvax/Content.Corvax.Tests/AssemblyInfo.cs @@ -0,0 +1 @@ +[assembly: Parallelizable(ParallelScope.Fixtures)] diff --git a/Corvax/Content.Corvax.Tests/Content.Corvax.Tests.csproj b/Corvax/Content.Corvax.Tests/Content.Corvax.Tests.csproj new file mode 100644 index 0000000000..f7fd80cab1 --- /dev/null +++ b/Corvax/Content.Corvax.Tests/Content.Corvax.Tests.csproj @@ -0,0 +1,34 @@ + + + ..\..\bin\Content.Corvax.Tests\ + false + disable + false + Content.Corvax.Tests + Content.Corvax.Tests + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Corvax/Content.Corvax.Tests/GlobalUsings.cs b/Corvax/Content.Corvax.Tests/GlobalUsings.cs new file mode 100644 index 0000000000..eba65606e4 --- /dev/null +++ b/Corvax/Content.Corvax.Tests/GlobalUsings.cs @@ -0,0 +1,6 @@ +// Global usings for Content.Corvax.Tests + +global using NUnit.Framework; +global using System; +global using System.Threading.Tasks; +global using Content.IntegrationTests; diff --git a/Corvax/Content.Corvax.Tests/PoolManagerTestEventHandler.cs b/Corvax/Content.Corvax.Tests/PoolManagerTestEventHandler.cs new file mode 100644 index 0000000000..6ad3eaa316 --- /dev/null +++ b/Corvax/Content.Corvax.Tests/PoolManagerTestEventHandler.cs @@ -0,0 +1,31 @@ +namespace Content.Corvax.Tests; + +[SetUpFixture] +public sealed class PoolManagerTestEventHandler +{ + private static TimeSpan MaximumTotalTestingTimeLimit => TimeSpan.FromMinutes(30); + private static TimeSpan HardStopTimeLimit => MaximumTotalTestingTimeLimit.Add(TimeSpan.FromMinutes(1)); + + [OneTimeSetUp] + public void Setup() + { + PoolManager.Startup(); + _ = Task.Delay(MaximumTotalTestingTimeLimit).ContinueWith(_ => + { + TestContext.Error.WriteLine($"\n\n{nameof(PoolManagerTestEventHandler)}: ERROR: Tests are taking too long. Shutting down.\n\n"); + PoolManager.Shutdown(); + }); + + _ = Task.Delay(HardStopTimeLimit).ContinueWith(_ => + { + var deathReport = PoolManager.DeathReport(); + Environment.FailFast($"Tests took way too long;\n Death Report:\n{deathReport}"); + }); + } + + [OneTimeTearDown] + public void TearDown() + { + PoolManager.Shutdown(); + } +} diff --git a/Corvax/Content.Corvax.Tests/Syndicate/DocumentPrinterTest.cs b/Corvax/Content.Corvax.Tests/Syndicate/DocumentPrinterTest.cs new file mode 100644 index 0000000000..026efc7cb8 --- /dev/null +++ b/Corvax/Content.Corvax.Tests/Syndicate/DocumentPrinterTest.cs @@ -0,0 +1,130 @@ +using Content.Server.Corvax.Documents; +using Content.Shared.Access.Components; +using Content.IntegrationTests.Fixtures; +using Content.Shared.Corvax.Documents; +using Robust.Shared.GameObjects; +using Robust.Shared.Localization; +using Robust.Shared.Map; + +namespace Content.Corvax.Tests.Syndicate; + +[TestFixture] +public sealed class DocumentPrinterTest : GameTest +{ + [Test] + public async Task ReplacesAllPlaceholders() + { + var pair = Pair; + var server = pair.Server; + + var docSystem = server.System(); + var loc = server.ResolveDependency(); + + // Build content using the actual localized variable tokens, so the test + // doesn't hardcode the "$date" / "$station" / etc. symbol format. + var dateVar = loc.GetString("doc-var-date"); + var stationVar = loc.GetString("doc-var-station"); + var nameVar = loc.GetString("doc-var-name"); + var jobVar = loc.GetString("doc-var-job"); + + var content = $"Date: {dateVar}, Station: {stationVar}, Name: {nameVar}, Job: {jobVar}"; + + await server.WaitPost(() => + { + var formatted = docSystem.FormatString(content, "MyStation"); + + Assert.Multiple(() => + { + Assert.That(formatted, Does.Not.Contain(dateVar), "Date placeholder was not replaced."); + Assert.That(formatted, Does.Not.Contain(stationVar), "Station placeholder was not replaced."); + Assert.That(formatted, Does.Not.Contain(nameVar), "Name placeholder was not replaced."); + Assert.That(formatted, Does.Not.Contain(jobVar), "Job placeholder was not replaced."); + + Assert.That(formatted, Does.Contain("MyStation"), "Station name was not inserted."); + Assert.That(formatted, Does.Contain(loc.GetString("doc-text-printer-default-name")), + "Default name was not inserted when no ID card is provided."); + Assert.That(formatted, Does.Contain(loc.GetString("doc-text-printer-default-job")), + "Default job was not inserted when no ID card is provided."); + }); + }); + } + + [Test] + public async Task UsesIdCardWhenProvided() + { + var pair = Pair; + var server = pair.Server; + + var docSystem = server.System(); + var entManager = server.ResolveDependency(); + var loc = server.ResolveDependency(); + + var nameVar = loc.GetString("doc-var-name"); + var jobVar = loc.GetString("doc-var-job"); + var content = $"Name: {nameVar}, Job: {jobVar}"; + + await server.WaitPost(() => + { + var idCardEntity = entManager.SpawnEntity(null, MapCoordinates.Nullspace); + var idCard = entManager.EnsureComponent(idCardEntity); + idCard.FullName = "John Doe"; + idCard.LocalizedJobTitle = "Engineer"; + + var formatted = docSystem.FormatString(content, "MyStation", idCard); + + Assert.Multiple(() => + { + Assert.That(formatted, Does.Not.Contain(nameVar), "Name placeholder was not replaced with ID card name."); + Assert.That(formatted, Does.Not.Contain(jobVar), "Job placeholder was not replaced with ID card job."); + Assert.That(formatted, Does.Contain("John Doe"), "ID card full name was not inserted."); + Assert.That(formatted, Does.Contain("Engineer"), "ID card job title was not inserted."); + }); + + entManager.DeleteEntity(idCardEntity); + }); + } + + [Test] + public async Task UsesDefaultStationWhenNull() + { + var pair = Pair; + var server = pair.Server; + + var docSystem = server.System(); + var loc = server.ResolveDependency(); + + var stationVar = loc.GetString("doc-var-station"); + var content = $"Station: {stationVar}"; + + await server.WaitPost(() => + { + var formatted = docSystem.FormatString(content, null); + + Assert.Multiple(() => + { + Assert.That(formatted, Does.Not.Contain(stationVar), "Station placeholder was not replaced when station is null."); + Assert.That(formatted, Does.Contain(loc.GetString("doc-text-printer-default-station")), + "Default station name was not used when station is null."); + }); + }); + } + + [Test] + public async Task HasDefaultSlotName() + { + var pair = Pair; + var server = pair.Server; + + var entManager = server.ResolveDependency(); + + await server.WaitPost(() => + { + var ent = entManager.SpawnEntity(null, MapCoordinates.Nullspace); + var comp = entManager.EnsureComponent(ent); + + Assert.That(comp.SlotName, Is.EqualTo("id"), "DocumentPrinterComponent.SlotName should default to \"id\"."); + + entManager.DeleteEntity(ent); + }); + } +} diff --git a/Corvax/Content.Corvax.Tests/Syndicate/GhostGoLobbyTest.cs b/Corvax/Content.Corvax.Tests/Syndicate/GhostGoLobbyTest.cs new file mode 100644 index 0000000000..ed392924c8 --- /dev/null +++ b/Corvax/Content.Corvax.Tests/Syndicate/GhostGoLobbyTest.cs @@ -0,0 +1,201 @@ +using Content.IntegrationTests.Fixtures; +using Content.Server.Corvax.Ghost; +using Content.Shared.GameTicking; +using Content.Shared.Humanoid; +using Content.Shared.Preferences; +using Robust.Shared.GameObjects; + +namespace Content.Corvax.Tests.Syndicate; + +[TestFixture] +public sealed class GhostGoLobbyTest : GameTest +{ + private const string TestName = "Oleg"; + + [Test] + public async Task MarkCharacterTracksUsedCharacter() + { + var server = Pair.Server; + var system = server.System(); + var profile = MakeProfile(TestName); + + await server.WaitPost(() => + { + Assert.That(system.IsCharacterUsed(profile), Is.False, "Character must not be tracked before marking."); + + system.MarkCharacterUsed(profile); + + Assert.That(system.IsCharacterUsed(profile), Is.True, "Character must be tracked after marking."); + }); + } + + [Test] + public async Task UntrackedCharacterIsNotReportedAsUsed() + { + var server = Pair.Server; + var system = server.System(); + + await server.WaitPost(() => + { + var profile = MakeProfile(TestName); + + Assert.That(system.IsCharacterUsed(profile), Is.False, "Untracked character must not be reported as used."); + }); + } + + [Test] + public async Task ProfilesWithSameValuesHashEqually() + { + var server = Pair.Server; + var system = server.System(); + + await server.WaitPost(() => + { + var a = MakeProfile(TestName); + var b = MakeProfile(TestName); + + system.MarkCharacterUsed(a); + + Assert.That(system.IsCharacterUsed(b), Is.True, "Two profiles with identical Name/Sex/Age/Species must hash equally."); + }); + } + + [TestCase(TestName, "Ne" + TestName, TestName = "DifferentNameDoesNotMatch")] + [TestCase(TestName, TestName, TestName = "SameNameMatches")] + public async Task CharacterMatchingByName(string usedName, string checkName) + { + var server = Pair.Server; + var system = server.System(); + + await server.WaitPost(() => + { + system.MarkCharacterUsed(MakeProfile(usedName)); + + var expected = usedName == checkName; + Assert.That(system.IsCharacterUsed(MakeProfile(checkName)), Is.EqualTo(expected), + expected + ? $"Expected '{checkName}' to match the previously used '{usedName}'." + : $"Expected '{checkName}' NOT to match the previously used '{usedName}'."); + }); + } + + [Test] + public async Task DifferentAgeDoesNotMatch() + { + var server = Pair.Server; + var system = server.System(); + + await server.WaitPost(() => + { + var used = MakeProfile(TestName, age: 25); + var other = MakeProfile(TestName, age: 40); + + system.MarkCharacterUsed(used); + + Assert.That(system.IsCharacterUsed(other), Is.False, "Profiles with different Age must hash differently."); + }); + } + + [Test] + public async Task DifferentSexDoesNotMatch() + { + var server = Pair.Server; + var system = server.System(); + + await server.WaitPost(() => + { + var used = MakeProfile(TestName, sex: Sex.Female); + var other = MakeProfile(TestName, sex: Sex.Male); + + system.MarkCharacterUsed(used); + + Assert.That(system.IsCharacterUsed(other), Is.False, "Profiles with different Sex must hash differently."); + }); + } + + [Test] + public async Task DifferentSpeciesDoesNotMatch() + { + var server = Pair.Server; + var system = server.System(); + + await server.WaitPost(() => + { + var used = MakeProfile(TestName, species: "Human"); + var other = MakeProfile(TestName, species: "Reptilian"); + + system.MarkCharacterUsed(used); + + Assert.That(system.IsCharacterUsed(other), Is.False, "Profiles with different Species must hash differently."); + }); + } + + [Test] + public async Task PreRoundLobbyClearsUsedCharacters() + { + var server = Pair.Server; + var system = server.System(); + var profile = MakeProfile(TestName); + + await server.WaitPost(() => + { + system.MarkCharacterUsed(profile); + Assert.That(system.IsCharacterUsed(profile), Is.True, "Setup sanity: character must be marked before clearing."); + }); + + await server.WaitPost(() => + { + // Simulate a transition into the pre-round lobby. + var ev = new GameRunLevelChangedEvent(GameRunLevel.InRound, GameRunLevel.PreRoundLobby); + server.EntMan.EventBus.RaiseEvent(EventSource.Local, ev); + }); + + await server.WaitPost(() => + { + Assert.That(system.IsCharacterUsed(profile), Is.False, "Used characters must be cleared when entering the pre-round lobby."); + }); + } + + [Test] + public async Task OtherRunLevelsDoNotClearUsedCharacters() + { + var server = Pair.Server; + var system = server.System(); + var profile = MakeProfile(TestName); + + await server.WaitPost(() => + { + system.MarkCharacterUsed(profile); + }); + + await server.WaitPost(() => + { + // Transition into PostRound — must NOT clear the tracked set. + var ev = new GameRunLevelChangedEvent(GameRunLevel.InRound, GameRunLevel.PostRound); + server.EntMan.EventBus.RaiseEvent(EventSource.Local, ev); + }); + + await server.WaitPost(() => + { + Assert.That(system.IsCharacterUsed(profile), Is.True, "Used characters must only be cleared on PreRoundLobby, not other run levels."); + }); + } + + /// + /// Builds a minimal profile with a fixed Name/Sex/Age/Species combo. + /// Sex and Age are variadic so each test can tweak a single field + /// while keeping the rest stable. + /// + private static HumanoidCharacterProfile MakeProfile( + string name, + Sex sex = Sex.Male, + int age = 25, + string species = "Human") + { + return new HumanoidCharacterProfile() + .WithName(name) + .WithSex(sex) + .WithAge(age) + .WithSpecies(species); + } +} diff --git a/Corvax/Content.Corvax.Tests/Syndicate/Guidebook/GuideGeneratorTest.cs b/Corvax/Content.Corvax.Tests/Syndicate/Guidebook/GuideGeneratorTest.cs new file mode 100644 index 0000000000..f7786a1909 --- /dev/null +++ b/Corvax/Content.Corvax.Tests/Syndicate/Guidebook/GuideGeneratorTest.cs @@ -0,0 +1,90 @@ +using System.IO; +using System.Text; +using System.Text.Json; +using Content.IntegrationTests.Fixtures; +using Content.Server.Corvax.GuideGenerator; + +namespace Content.Corvax.Tests.Syndicate.Guidebook; + +[TestFixture] +public sealed class GuideGeneratorTest : GameTest +{ + private static string RunGenerator(Action publish) + { + using var ms = new MemoryStream(); + publish(ms); + return Encoding.UTF8.GetString(ms.ToArray()); + } + + [Test] + public async Task ComponentListJsonIsValid() + { + var server = Pair.Server; + + await server.WaitPost(() => + { + var text = RunGenerator(ComponentListGenerator.PublishJson); + + Assert.That(text, Is.Not.Empty); + + using var doc = JsonDocument.Parse(text); + Assert.That(doc.RootElement.ValueKind, Is.EqualTo(JsonValueKind.Object)); + + foreach (var prop in doc.RootElement.EnumerateObject()) + { + Assert.That(prop.Value.ValueKind, Is.EqualTo(JsonValueKind.Array), + $"Component '{prop.Name}' must map to an array of entity ids."); + Assert.That(prop.Value.GetArrayLength(), Is.GreaterThan(0), + $"Component '{prop.Name}' must be attached to at least one entity."); + } + }); + } + + [Test] + public async Task PrototypeListJsonIsValid() + { + var server = Pair.Server; + + await server.WaitPost(() => + { + var text = RunGenerator(PrototypeListGenerator.PublishJson); + + Assert.That(text, Is.Not.Empty); + + using var doc = JsonDocument.Parse(text); + Assert.That(doc.RootElement.ValueKind, Is.EqualTo(JsonValueKind.Object)); + + foreach (var prop in doc.RootElement.EnumerateObject()) + { + Assert.That(prop.Value.ValueKind, Is.EqualTo(JsonValueKind.Array), + $"Prototype kind '{prop.Name}' must map to an array of ids."); + Assert.That(prop.Value.GetArrayLength(), Is.GreaterThan(0), + $"Prototype kind '{prop.Name}' must have at least one prototype."); + } + }); + } + + [Test] + public async Task TagJsonIsValid() + { + var server = Pair.Server; + + await server.WaitPost(() => + { + var text = RunGenerator(TagJsonGenerator.PublishJson); + + Assert.That(text, Is.Not.Empty); + + using var doc = JsonDocument.Parse(text); + Assert.That(doc.RootElement.ValueKind, Is.EqualTo(JsonValueKind.Object)); + + foreach (var prop in doc.RootElement.EnumerateObject()) + { + Assert.That(prop.Value.ValueKind, Is.EqualTo(JsonValueKind.Array), + $"Tag '{prop.Name}' must map to an array of entity ids."); + Assert.That(prop.Value.GetArrayLength(), Is.GreaterThan(0), + $"Tag '{prop.Name}' must be attached to at least one entity."); + } + }); + } +} diff --git a/Corvax/Content.Corvax.Tests/Syndicate/Guidebook/GuideGeneratorUnitTest.cs b/Corvax/Content.Corvax.Tests/Syndicate/Guidebook/GuideGeneratorUnitTest.cs new file mode 100644 index 0000000000..af5fc534a8 --- /dev/null +++ b/Corvax/Content.Corvax.Tests/Syndicate/Guidebook/GuideGeneratorUnitTest.cs @@ -0,0 +1,111 @@ +using System.Collections.Generic; +using Content.Server.Corvax.GuideGenerator; +using Robust.Shared.Serialization.Markdown.Mapping; +using Robust.Shared.Serialization.Markdown.Sequence; +using Robust.Shared.Serialization.Markdown.Value; + +namespace Content.Corvax.Tests.Syndicate.Guidebook; + +[TestFixture] +public sealed class TextToolsTest +{ + [TestCase("test", "Test")] + [TestCase("a", "A")] + [TestCase("hello world", "Hello world")] + public void CapitalizeString(string input, string expected) + { + Assert.That(TextTools.CapitalizeString(input), Is.EqualTo(expected)); + } + + [TestCase("Test", "test")] + [TestCase("A", "a")] + [TestCase("HELLO WORLD", "hELLO WORLD")] + public void DecapitalizeString(string input, string expected) + { + Assert.That(TextTools.DecapitalizeString(input), Is.EqualTo(expected)); + } + + [TestCase(" test ", "test")] + [TestCase(",test,", "test")] + [TestCase(" ,; test ;, ", "test")] + public void NormalizeSuffixToken(string input, string expected) + { + Assert.That(TextTools.NormalizeSuffixToken(input), Is.EqualTo(expected)); + } + + [Test] + public void GetEditorSuffixFiltersIgnoredTokens() + { + var ignored = new HashSet(System.StringComparer.OrdinalIgnoreCase) { "do not map" }; + var result = TextTools.GetEditorSuffix("test, do not map", ignored, TextTools.NormalizeSuffixToken); + Assert.That(result, Is.EqualTo("test")); + } + + [Test] + public void GetEditorSuffixLowercasesResult() + { + var result = TextTools.GetEditorSuffix("Test, OTHER", new HashSet(), TextTools.NormalizeSuffixToken); + Assert.That(result, Is.EqualTo("test, other")); + } +} + +[TestFixture] +public sealed class FieldEntryTest +{ + [Test] + public void ConvertValueInt() + { + Assert.That(FieldEntry.ConvertNode(new ValueDataNode("42")), Is.EqualTo(42)); + } + + [Test] + public void ConvertValueDouble() + { + Assert.That(FieldEntry.ConvertNode(new ValueDataNode("3.14")), Is.EqualTo(3.14)); + } + + [Test] + public void ConvertMappingProducesDictionary() + { + var node = new MappingDataNode + { + { "a", new ValueDataNode("1") }, + { "b", new ValueDataNode("two") }, + }; + + var result = FieldEntry.ConvertNode(node) as Dictionary; + + Assert.That(result, Is.Not.Null); + Assert.That(result!["a"], Is.EqualTo(1)); + Assert.That(result["b"], Is.EqualTo("two")); + } + + [Test] + public void ConvertSequenceProducesList() + { + var node = new SequenceDataNode("a", "b", "c"); + var result = FieldEntry.ConvertNode(node) as List; + + Assert.That(result, Is.Not.Null); + Assert.That(result!.Count, Is.EqualTo(3)); + Assert.That(result[0], Is.EqualTo("a")); + Assert.That(result[2], Is.EqualTo("c")); + } + + [Test] + public void DeduplicateAgainstDefaultWrapsInDefaultAndIdKeys() + { + var defaults = new Dictionary { ["a"] = 1, ["b"] = 2 }; + var values = new Dictionary + { + ["ent1"] = new Dictionary { ["a"] = 1 }, + }; + + var result = FieldEntry.DeduplicateAgainstDefault(defaults, values); + + Assert.That(result, Does.ContainKey(FieldEntry.DefaultField)); + Assert.That(result[FieldEntry.DefaultField], Is.EqualTo(defaults)); + Assert.That(result, Does.ContainKey(FieldEntry.PrototypeId)); + Assert.That(result[FieldEntry.PrototypeId], Is.EqualTo(values)); + } +} diff --git a/Corvax/Content.Corvax.Tests/Syndicate/MobIpcTest.cs b/Corvax/Content.Corvax.Tests/Syndicate/MobIpcTest.cs new file mode 100644 index 0000000000..44e87b2aac --- /dev/null +++ b/Corvax/Content.Corvax.Tests/Syndicate/MobIpcTest.cs @@ -0,0 +1,113 @@ +using Content.IntegrationTests.Fixtures; +using Content.Shared.Corvax.Ipc; +using Content.Shared.Damage.Systems; +using Content.Shared.Emp; +using Content.Shared.Mobs; +using Content.Shared.Mobs.Components; +using Content.Shared.Sound.Components; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; + +namespace Content.Corvax.Tests.Syndicate; + +[TestFixture] +public sealed class MobIpcTest : GameTest +{ + [Test] + public async Task EmpPulseDamagesIpc() + { + var pair = Pair; + var server = pair.Server; + + var entManager = server.ResolveDependency(); + var mapSystem = server.System(); + var damageableSystem = server.System(); + + await server.WaitPost(() => + { + mapSystem.CreateMap(out var mapId); + var coords = new MapCoordinates(0, 0, mapId); + + var uid = entManager.SpawnEntity("MobIpc", coords); + entManager.EnsureComponent(uid); + + var before = damageableSystem.GetTotalDamage(uid); + + var ev = new EmpPulseEvent(); + server.EntMan.EventBus.RaiseLocalEvent(uid, ref ev); + + var after = damageableSystem.GetTotalDamage(uid); + + Assert.Multiple(() => + { + Assert.That(ev.Affected, Is.True, "IPC must be marked as affected by an EMP pulse."); + Assert.That(after, Is.GreaterThan(before), "IPC must take Shock damage from an EMP pulse."); + }); + + entManager.DeleteEntity(uid); + mapSystem.DeleteMap(mapId); + }); + } + + [Test] + public async Task MobStateCriticalAddsWarningSound() + { + var pair = Pair; + var server = pair.Server; + + var entManager = server.ResolveDependency(); + var mapSystem = server.System(); + + await server.WaitPost(() => + { + mapSystem.CreateMap(out var mapId); + var coords = new MapCoordinates(0, 0, mapId); + + var uid = entManager.SpawnEntity("MobIpc", coords); + entManager.EnsureComponent(uid); + var mobState = entManager.EnsureComponent(uid); + + Assert.That(entManager.HasComponent(uid), Is.False, + "Setup sanity: no warning sound before entering Critical."); + + var ev = new MobStateChangedEvent(uid, mobState, MobState.Alive, MobState.Critical); + server.EntMan.EventBus.RaiseLocalEvent(uid, ev); + + Assert.That(entManager.HasComponent(uid), Is.True, + "IPC must emit a warning sound when entering Critical state."); + + entManager.DeleteEntity(uid); + mapSystem.DeleteMap(mapId); + }); + } + + [Test] + public async Task MobStateAliveRemovesWarningSound() + { + var pair = Pair; + var server = pair.Server; + + var entManager = server.ResolveDependency(); + var mapSystem = server.System(); + + await server.WaitPost(() => + { + mapSystem.CreateMap(out var mapId); + var coords = new MapCoordinates(0, 0, mapId); + + var uid = entManager.SpawnEntity("MobIpc", coords); + entManager.EnsureComponent(uid); + var mobState = entManager.EnsureComponent(uid); + entManager.EnsureComponent(uid); + + var ev = new MobStateChangedEvent(uid, mobState, MobState.Critical, MobState.Alive); + server.EntMan.EventBus.RaiseLocalEvent(uid, ev); + + Assert.That(entManager.HasComponent(uid), Is.False, + "IPC must stop the warning sound when leaving Critical state."); + + entManager.DeleteEntity(uid); + mapSystem.DeleteMap(mapId); + }); + } +} diff --git a/Corvax/Content.Corvax.Tests/Syndicate/PostMapInitCorvaxTest.cs b/Corvax/Content.Corvax.Tests/Syndicate/PostMapInitCorvaxTest.cs new file mode 100644 index 0000000000..399b61c8a1 --- /dev/null +++ b/Corvax/Content.Corvax.Tests/Syndicate/PostMapInitCorvaxTest.cs @@ -0,0 +1,51 @@ +// ReSharper disable UseCollectionExpression +using Content.IntegrationTests.Fixtures.Attributes; +using Content.IntegrationTests.Tests; +using Content.Shared.CCVar; +using Robust.Shared.Utility; + +namespace Content.Corvax.Tests.Syndicate; + +/// +/// Runs PostMapInit tests only for maps from /Maps/Corvax/**. +/// +[TestFixture] +[NonParallelizable] +public sealed class PostMapInitCorvaxTest : PostMapInitTestBase +{ + private const string IncludedCorvax = "/Maps/Corvax"; + + [Test, TestCaseSource(nameof(GridsSource), new object[] { true, new[] { IncludedCorvax } })] + [EnsureCVar(Side.Server, typeof(CCVars), nameof(CCVars.GridFill), false)] + public async Task GridsLoadableTest(string mapFile) + { + await RunGridsLoadableTest(mapFile); + } + + [Test, TestCaseSource(nameof(ShuttleMapFilesSource), new object[] { true, new[] { IncludedCorvax } })] + [EnsureCVar(Side.Server, typeof(CCVars), nameof(CCVars.GridFill), false)] + public async Task ShuttlesLoadableTest(ResPath path) + { + await RunShuttlesLoadableTest(path); + } + + [Test, TestCaseSource(nameof(AllMapFilesSource), new object[] { true, new[] { IncludedCorvax } })] + public async Task NoSavedPostMapInitTest(ResPath map) + { + await RunNoSavedPostMapInitTest(map); + } + + [Test, TestCaseSource(nameof(GameMapsSource))] + [EnsureCVar(Side.Server, typeof(CCVars), nameof(CCVars.GridFill), false)] + public async Task GameMapsLoadableTest(string mapProto) + { + await RunGameMapsLoadableTest(mapProto, customOnly: true, new[] { IncludedCorvax }); + } + + [Test, TestCaseSource(nameof(AllMapFilesSource), new object[] { true, new[] { IncludedCorvax } })] + [EnsureCVar(Side.Server, typeof(CCVars), nameof(CCVars.GridFill), false)] + public async Task NonGameMapsLoadableTest(ResPath mapPath) + { + await RunNonGameMapsLoadableTest(mapPath); + } +} diff --git a/Corvax/Content.Corvax.Tests/Syndicate/SecApartmentDataTest.cs b/Corvax/Content.Corvax.Tests/Syndicate/SecApartmentDataTest.cs new file mode 100644 index 0000000000..72d1aa1460 --- /dev/null +++ b/Corvax/Content.Corvax.Tests/Syndicate/SecApartmentDataTest.cs @@ -0,0 +1,196 @@ +using System.Linq; +using Content.IntegrationTests.Fixtures; +using Content.Server.Corvax.SecApartment; +using Content.Shared.SecApartment; +using Content.Shared.StatusIcon; +using Robust.Shared.GameObjects; +using Robust.Shared.Prototypes; + +namespace Content.Corvax.Tests.Syndicate; + +[TestFixture] +public sealed class SecApartmentDataTest +{ + [Test] + public void SquadDefaultsAreCorrect() + { + var squad = new Squad("squad_1234", "Alpha Team"); + + Assert.Multiple(() => + { + Assert.That(squad.SquadId, Is.EqualTo("squad_1234")); + Assert.That(squad.Name, Is.EqualTo("Alpha Team")); + Assert.That(squad.Description, Is.Empty, "Description must start empty."); + Assert.That(squad.Members, Is.Empty, "Members must start empty."); + Assert.That(squad.Status, Is.EqualTo(SquadStatus.Active), "New squad must be Active."); + Assert.That(squad.IconId, Is.EqualTo(SquadIconNum.Alpha), "New squad must use Alpha icon."); + }); + } + + [Test] + public void CrewMemberInfoPreservesFields() + { + var owner = new NetEntity(42); + var info = new CrewMemberInfo("id1", owner, "John", "Engineer", "JobIconEngineer", null); + + Assert.Multiple(() => + { + Assert.That(info.MemberId, Is.EqualTo("id1")); + Assert.That(info.OwnerUid, Is.EqualTo(owner)); + Assert.That(info.Name, Is.EqualTo("John")); + Assert.That(info.JobTitle, Is.EqualTo("Engineer")); + Assert.That(info.JobIcon, Is.EqualTo("JobIconEngineer")); + Assert.That(info.SensorStatus, Is.Null); + }); + } + + [Test] + public void TimerEntryPreservesFields() + { + var uid = new NetEntity(7); + var entry = new TimerEntry(uid, "Bomb", TimeSpan.FromMinutes(2), TimeSpan.FromMinutes(5)); + + Assert.Multiple(() => + { + Assert.That(entry.TimerUid, Is.EqualTo(uid)); + Assert.That(entry.Label, Is.EqualTo("Bomb")); + Assert.That(entry.RemainingTime, Is.EqualTo(TimeSpan.FromMinutes(2))); + Assert.That(entry.TotalTime, Is.EqualTo(TimeSpan.FromMinutes(5))); + Assert.That(entry.FinishedAt, Is.Null); + }); + } + + [Test] + public void StationDataStartsEmpty() + { + var data = new StationData(); + + Assert.Multiple(() => + { + Assert.That(data.Squads, Is.Empty); + Assert.That(data.TrackedTimers, Is.Empty); + }); + } + + [Test] + public void SquadMemberComponentDefaultsToAlphaIcon() + { + var comp = new SquadMemberComponent(); + Assert.That(comp.StatusIcon.Id, Is.EqualTo("SecuritySquadIconAlpha")); + } + + [Test] + public void SecApartmentComponentStationIsNullByDefault() + { + var comp = new SecApartmentComponent(); + Assert.That(comp.Station, Is.Null); + } + + [Test] + public void SquadIconNumHasTwentyFourEntries() + { + var values = Enum.GetValues(); + Assert.That(values.Length, Is.EqualTo(24), + "SquadIconNum must contain exactly 24 entries (Alpha..Omega)."); + } + + [Test] + public void SquadStatusHasActiveAndOnBreak() + { + var values = Enum.GetValues().Cast().ToArray(); + + Assert.Multiple(() => + { + Assert.That(values, Does.Contain(SquadStatus.Active)); + Assert.That(values, Does.Contain(SquadStatus.OnBreak)); + }); + } +} + +[TestFixture] +public sealed class SecApartmentPrototypeTest : GameTest +{ + /// + /// Every icon that SecApartmentSystem.GetIconPrototypeId can return + /// must exist as a . If someone renames + /// a squad icon prototype without updating the switch, this test catches it. + /// + [Test] + public async Task AllSquadIconPrototypesExist() + { + var server = Pair.Server; + var protoManager = server.ResolveDependency(); + + var names = new[] + { + "SecuritySquadIconAlpha", + "SecuritySquadIconBeta", + "SecuritySquadIconGamma", + "SecuritySquadIconDelta", + "SecuritySquadIconEpsilon", + "SecuritySquadIconZeta", + "SecuritySquadIconHeta", + "SecuritySquadIconTheta", + "SecuritySquadIconIota", + "SecuritySquadIconKappa", + "SecuritySquadIconLambda", + "SecuritySquadIconMu", + "SecuritySquadIconNu", + "SecuritySquadIconXi", + "SecuritySquadIconOmicron", + "SecuritySquadIconPi", + "SecuritySquadIconRo", + "SecuritySquadIconSigma", + "SecuritySquadIconTau", + "SecuritySquadIconUpsilon", + "SecuritySquadIconFi", + "SecuritySquadIconHi", + "SecuritySquadIconPsi", + "SecuritySquadIconOmega", + }; + + await server.WaitPost(() => + { + Assert.Multiple(() => + { + foreach (var name in names) + { + Assert.That(protoManager.HasIndex(name), Is.True, + $"Squad icon prototype '{name}' is referenced by GetIconPrototypeId but not registered."); + } + }); + }); + } + + [Test] + public async Task SecuritySquadIconCountMatchesEnum() + { + var server = Pair.Server; + var protoManager = server.ResolveDependency(); + + await server.WaitPost(() => + { + var squadIcons = protoManager.EnumeratePrototypes() + .Where(p => p.ID.StartsWith("SecuritySquadIcon")) + .ToList(); + + var enumCount = Enum.GetValues().Length; + + Assert.That(squadIcons.Count, Is.EqualTo(enumCount), + $"Expected {enumCount} SecuritySquadIcon* prototypes, found {squadIcons.Count}. " + + "If you added a new SquadIconNum entry, add the matching prototype."); + }); + } + + [Test] + public async Task SecApartmentSystemIsRegistered() + { + var server = Pair.Server; + + await server.WaitPost(() => + { + var sys = server.System(); + Assert.That(sys, Is.Not.Null, "SecApartmentSystem must be registered on the server."); + }); + } +} diff --git a/Corvax/Content.Corvax.Tests/Syndicate/StationGoalTest.cs b/Corvax/Content.Corvax.Tests/Syndicate/StationGoalTest.cs new file mode 100644 index 0000000000..8c148a40a9 --- /dev/null +++ b/Corvax/Content.Corvax.Tests/Syndicate/StationGoalTest.cs @@ -0,0 +1,114 @@ +using System.Collections.Generic; +using System.Linq; +using Content.IntegrationTests.Fixtures; +using Content.Server.Corvax.StationGoal; +using Content.Shared.MassMedia.Components; +using Content.Shared.Station.Components; +using Robust.Shared.GameObjects; +using Robust.Shared.Prototypes; + +namespace Content.Corvax.Tests.Syndicate; + +[TestFixture] +public sealed class StationGoalTest : GameTest +{ + public override PoolSettings PoolSettings => new() + { + Dirty = true, + DummyTicker = false, + Connected = true, + Map = PoolManager.TestStation, + }; + + [Test] + public async Task ValidateGoals() + { + var pair = Pair; + var server = pair.Server; + + var protoManager = server.ResolveDependency(); + + await server.WaitPost(() => + { + var goals = protoManager.EnumeratePrototypes().ToList(); + Assert.That(goals, Is.Not.Empty, "There must be at least one Station Goal."); + + var seen = new HashSet(); + foreach (var goal in goals) + { + Assert.That(goal.Text, Is.Not.Null.And.Not.Empty, $"Station Goal {goal.ID} has no text."); + Assert.That(seen.Add(goal.ID), Is.True, $"Duplicate Station Goal ID: {goal.ID}"); + } + }); + } + + /// + /// Sends a station goal to a fax on the pre-loaded station and verifies the result. + /// If is set, also checks whether a news article is published. + /// + [TestCase(false, TestName = "SendsGoalToFaxWithoutNews")] + [TestCase(true, TestName = "SendsGoalToFaxWithNews")] + public async Task SendsGoalToFax(bool expectNews) + { + var pair = Pair; + var server = pair.Server; + + var entManager = server.ResolveDependency(); + var goalSystem = server.System(); + var protoManager = server.ResolveDependency(); + + EntityUid station = default; + int articlesBefore = 0; + + await server.WaitPost(() => + { + var stationQuery = entManager.EntityQueryEnumerator(); + Assert.That(stationQuery.MoveNext(out var stationUid, out _), "Pool did not create a station from the map."); + station = stationUid; + + if (expectNews) + { + // Ensure the component exists so news can be published. + var stationNews = entManager.EnsureComponent(station); + articlesBefore = stationNews.Articles.Count; + } + else + { + // Remove the component (news writer may have created it at map init) + // so that the publish step is silently skipped. + entManager.RemoveComponent(station); + articlesBefore = 0; + } + }); + + var goalId = protoManager.EnumeratePrototypes().First().ID; + + await server.WaitPost(() => + { + var result = goalSystem.SendStationGoal(station, goalId); + Assert.That(result, Is.True, $"SendStationGoal returned false for {goalId} (fax did not accept?)"); + }); + + server.RunTicks(2); + + await server.WaitPost(() => + { + var hasNews = entManager.TryGetComponent(station, out var stationNews); + + if (expectNews) + { + Assert.That(hasNews, Is.True, "StationNewsComponent is missing after sending the station goal."); + Assert.That(stationNews!.Articles.Count, Is.EqualTo(articlesBefore + 1), + "Expected exactly one new news article after sending the station goal."); + + var article = stationNews.Articles[^1]; + Assert.That(article.Title, Is.Not.Null.And.Not.Empty, "News article has an empty title."); + Assert.That(article.Content, Is.Not.Null.And.Not.Empty, "News article has empty content."); + } + else + { + Assert.That(hasNews, Is.False, "StationNewsComponent should not have been created when news publishing is disabled."); + } + }); + } +} diff --git a/Corvax/Content.Corvax.Tests/Syndicate/TTS/ClientTtsTests.cs b/Corvax/Content.Corvax.Tests/Syndicate/TTS/ClientTtsTests.cs new file mode 100644 index 0000000000..d525922944 --- /dev/null +++ b/Corvax/Content.Corvax.Tests/Syndicate/TTS/ClientTtsTests.cs @@ -0,0 +1,169 @@ +using System.Collections.Generic; +using System.Linq; +using Content.IntegrationTests.Fixtures; +using Content.Shared.Corvax.TTS; +using Robust.Shared.GameObjects; + +using ClientTTS = Content.Client.Corvax.TTS.TTSSystem; + +namespace Content.Corvax.Tests.Syndicate.TTS; + +internal static class ClientTtsTestHelpers +{ + public static Dictionary> GetQueues(ClientTTS tts) + => (Dictionary>)TtsReflection.ClientQueuesField.GetValue(tts)!; + + public static void EnableWithFullVolume(ClientTTS tts) + { + TtsReflection.ClearClientState(tts); + TtsReflection.ClientEnabledField.SetValue(tts, true); + TtsReflection.ClientRadioVolumeField.SetValue(tts, 1.2f); + TtsReflection.ClientVolumeField.SetValue(tts, 1.2f); + } +} + +[TestFixture] +public sealed class ClientTtsGatingTest : GameTest +{ + [Test] + public async Task DisabledSkipsEvent() + { + var client = Pair.Client; + var tts = client.System(); + + await client.WaitPost(() => + { + TtsReflection.ClearClientState(tts); + TtsReflection.ClientEnabledField.SetValue(tts, false); + + var source = new NetEntity(1); + client.EntMan.EventBus.RaiseEvent(EventSource.Local, + new PlayTTSEvent(new byte[] { 1 }, source)); + + Assert.That(ClientTtsTestHelpers.GetQueues(tts), Is.Empty, + "Disabled TTS must not enqueue any playback."); + }); + } + + [Test] + public async Task RadioZeroVolumeSkipsEvent() + { + var client = Pair.Client; + var tts = client.System(); + + await client.WaitPost(() => + { + TtsReflection.ClearClientState(tts); + TtsReflection.ClientEnabledField.SetValue(tts, true); + TtsReflection.ClientRadioVolumeField.SetValue(tts, 0f); + + var source = new NetEntity(1); + client.EntMan.EventBus.RaiseEvent(EventSource.Local, + new PlayTTSEvent(new byte[] { 1 }, source, isRadio: true)); + + Assert.That(ClientTtsTestHelpers.GetQueues(tts), Is.Empty, + "Radio TTS with zero volume must not enqueue."); + }); + } + + [Test] + public async Task ZeroVolumeSkipsEvent() + { + var client = Pair.Client; + var tts = client.System(); + + await client.WaitPost(() => + { + TtsReflection.ClearClientState(tts); + TtsReflection.ClientEnabledField.SetValue(tts, true); + TtsReflection.ClientVolumeField.SetValue(tts, 0f); + + var source = new NetEntity(1); + client.EntMan.EventBus.RaiseEvent(EventSource.Local, + new PlayTTSEvent(new byte[] { 1 }, source)); + + Assert.That(ClientTtsTestHelpers.GetQueues(tts), Is.Empty, + "TTS with zero volume must not enqueue."); + }); + } +} + +[TestFixture] +public sealed class ClientTtsQueueTest : GameTest +{ + [Test] + public async Task QueueOverflowDropsOldest() + { + var client = Pair.Client; + var tts = client.System(); + + await client.WaitPost(() => + { + ClientTtsTestHelpers.EnableWithFullVolume(tts); + + var source = new NetEntity(1); + + // Force the entity into the "playing" state so incoming events enqueue + // instead of trying to play immediately. + var playing = (HashSet)TtsReflection.ClientPlayingField.GetValue(tts)!; + playing.Add(source); + + // Enqueue 7 events. The system keeps the queue at 6 max, + // dropping the oldest when a new event arrives. + for (var i = 0; i < 7; i++) + { + var payload = new byte[] { (byte)(i + 1) }; + client.EntMan.EventBus.RaiseEvent(EventSource.Local, + new PlayTTSEvent(payload, source)); + } + + var queues = ClientTtsTestHelpers.GetQueues(tts); + Assert.That(queues.ContainsKey(source), Is.True, "Queue for source should exist."); + + var queue = queues[source]; + Assert.That(queue.Count, Is.EqualTo(6), "Queue must cap at 6 entries."); + + // The first event's payload (1) must have been dropped. + Assert.That(queue.All(e => e.Data[0] != 1), Is.True, + "Oldest event should have been dropped on overflow."); + }); + } + + [Test] + public async Task GlobalEventBypassesQueue() + { + var client = Pair.Client; + var tts = client.System(); + + await client.WaitPost(() => + { + ClientTtsTestHelpers.EnableWithFullVolume(tts); + + // SourceUid == null → routed to PlayTTSInternal directly, bypassing the queue. + // PlayTTSInternal will try to load the payload as audio; the fake byte is not + // valid audio and may throw on some backends. The routing decision — "not queued" — + // is what this test verifies, so audio errors are captured and reported, not + // allowed to mask the actual assertion. + Exception? playbackError = null; + try + { + client.EntMan.EventBus.RaiseEvent(EventSource.Local, + new PlayTTSEvent(new byte[] { 1 })); + } + catch (Exception ex) + { + playbackError = ex; + } + + // Primary assertion: nothing was queued for any source. + Assert.That(ClientTtsTestHelpers.GetQueues(tts), Is.Empty, + "Global (null source) must not be queued."); + + if (playbackError is not null) + { + // Not a failure — just useful diagnostics when audio plumbing changes. + TestContext.WriteLine($"Note: PlayTTSInternal threw {playbackError.GetType().Name} " + "(expected for a fake audio payload)."); + } + }); + } +} diff --git a/Corvax/Content.Corvax.Tests/Syndicate/TTS/ServerTtsPipelineTests.cs b/Corvax/Content.Corvax.Tests/Syndicate/TTS/ServerTtsPipelineTests.cs new file mode 100644 index 0000000000..5060a68bbf --- /dev/null +++ b/Corvax/Content.Corvax.Tests/Syndicate/TTS/ServerTtsPipelineTests.cs @@ -0,0 +1,182 @@ +using Content.IntegrationTests.Fixtures; +using Content.Shared.Chat; +using Content.Shared.Corvax.TTS; +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Prototypes; + +using ServerTTS = Content.Server.Corvax.TTS.TTSSystem; +using ServerTTSManager = Content.Server.Corvax.TTS.TTSManager; + +namespace Content.Corvax.Tests.Syndicate.TTS; + +[TestFixture] +public sealed class TtsSanitizeTest : GameTest +{ + [Test] + public async Task ReplacesAbbreviations() + { + var server = Pair.Server; + var tts = server.System(); + + await server.WaitPost(() => + { + Assert.That(TtsReflection.CallSanitize(tts, "нт"), Is.EqualTo("Эн Тэ")); + Assert.That(TtsReflection.CallSanitize(tts, "гсб"), Is.EqualTo("Гэ Эс Бэ")); + Assert.That(TtsReflection.CallSanitize(tts, "id"), Is.EqualTo("Ай Ди")); + }); + } + + [Test] + public async Task StripsInvalidCharacters() + { + var server = Pair.Server; + var tts = server.System(); + + await server.WaitPost(() => + { + var result = TtsReflection.CallSanitize(tts, "Hello * World #"); + Assert.That(result, Does.Not.Contain("*")); + Assert.That(result, Does.Not.Contain("#")); + }); + } + + [Test] + public async Task EmptyInputReturnsEmpty() + { + var server = Pair.Server; + var tts = server.System(); + + await server.WaitPost(() => + { + Assert.That(TtsReflection.CallSanitize(tts, ""), Is.Empty); + Assert.That(TtsReflection.CallSanitize(tts, " "), Is.Empty); + }); + } +} + +[TestFixture] +public sealed class TtsSsmlTest : GameTest +{ + [Test] + public async Task WrapsInSpeakTags() + { + var server = Pair.Server; + var tts = server.System(); + + await server.WaitPost(() => + { + // Traits value here is irrelevant for the wrapping check; use 0. + var ssml = TtsReflection.CallToSsml(tts, "Hello world", 0); + Assert.That(ssml, Does.StartWith("")); + Assert.That(ssml, Does.EndWith("")); + }); + } + + [Test] + public async Task EscapesXml() + { + var server = Pair.Server; + var tts = server.System(); + + await server.WaitPost(() => + { + var ssml = TtsReflection.CallToSsml(tts, " & \"q\"", 0); + Assert.That(ssml, Does.Contain("<test>")); + Assert.That(ssml, Does.Contain("&")); + Assert.That(ssml, Does.Contain(""")); + }); + } +} + +[TestFixture] +public sealed class TtsEntitySpokeTest : GameTest +{ + private static EntityUid SpawnSpeaker( + IEntityManager entMan, SharedMapSystem mapSystem, MapId mapId, string? voiceId) + { + var mob = entMan.SpawnEntity("MobHuman", new MapCoordinates(0, 0, mapId)); + var tts = entMan.EnsureComponent(mob); + tts.VoicePrototypeId = voiceId; + return mob; + } + + [Test] + public async Task RunsWithCachedAudio() + { + var server = Pair.Server; + var entMan = server.ResolveDependency(); + var mapSystem = server.System(); + var tts = server.System(); + var manager = server.ResolveDependency(); + var protoManager = server.ResolveDependency(); + + await server.WaitPost(() => + { + mapSystem.CreateMap(out var mapId); + + var voiceProto = protoManager.Index("Taskmaster"); + var mob = SpawnSpeaker(entMan, mapSystem, mapId, "Taskmaster"); + + // Pre-compute the exact SSML GenerateTTS will produce and seed the cache. + TtsReflection.ClearServerCache(manager); + var ssml = TtsReflection.BuildSsmlFor(tts, "test", isWhisper: false); + TtsReflection.PrepopulateServerCache( + manager, voiceProto.Speaker, ssml, new byte[] { 1, 2, 3 }); + + // Sanity: fail here, not on a silent network call if the SSML pipeline drifts. + Assert.That(TtsReflection.HasServerCacheEntry(manager, voiceProto.Speaker, ssml), + Is.True, "Sanity: cache must contain the seeded SSML entry."); + + // Raise EntitySpokeEvent on the mob; the system must hit the cache. + var ev = new EntitySpokeEvent(mob, "test", "test", null, null); + server.EntMan.EventBus.RaiseLocalEvent(mob, ev); + + entMan.DeleteEntity(mob); + mapSystem.DeleteMap(mapId); + }); + } + + [Test] + public async Task IgnoresVeryLongMessage() + { + var server = Pair.Server; + var entMan = server.ResolveDependency(); + var mapSystem = server.System(); + + await server.WaitPost(() => + { + mapSystem.CreateMap(out var mapId); + var mob = SpawnSpeaker(entMan, mapSystem, mapId, "Taskmaster"); + + // 201 chars — over MaxMessageChars (200). The handler must return early + // and never touch TTSManager (so no HTTP even without cache). + var longMsg = new string('a', 201); + var ev = new EntitySpokeEvent(mob, longMsg, longMsg, null, null); + server.EntMan.EventBus.RaiseLocalEvent(mob, ev); + + entMan.DeleteEntity(mob); + mapSystem.DeleteMap(mapId); + }); + } + + [Test] + public async Task IgnoresEntityWithoutVoice() + { + var server = Pair.Server; + var entMan = server.ResolveDependency(); + var mapSystem = server.System(); + + await server.WaitPost(() => + { + mapSystem.CreateMap(out var mapId); + var mob = SpawnSpeaker(entMan, mapSystem, mapId, voiceId: null); + + var ev = new EntitySpokeEvent(mob, "test", "test", null, null); + server.EntMan.EventBus.RaiseLocalEvent(mob, ev); + + entMan.DeleteEntity(mob); + mapSystem.DeleteMap(mapId); + }); + } +} diff --git a/Corvax/Content.Corvax.Tests/Syndicate/TTS/TtsPrototypeTests.cs b/Corvax/Content.Corvax.Tests/Syndicate/TTS/TtsPrototypeTests.cs new file mode 100644 index 0000000000..75b0931737 --- /dev/null +++ b/Corvax/Content.Corvax.Tests/Syndicate/TTS/TtsPrototypeTests.cs @@ -0,0 +1,58 @@ +using System.Collections.Generic; +using System.Linq; +using Content.IntegrationTests.Fixtures; +using Content.Shared.Corvax.TTS; +using Robust.Shared.Prototypes; + +namespace Content.Corvax.Tests.Syndicate.TTS; + +[TestFixture] +public sealed class TtsVoicePrototypeTest : GameTest +{ + [Test] + public async Task PrototypesAreValid() + { + var server = Pair.Server; + var protoManager = server.ResolveDependency(); + + await server.WaitPost(() => + { + var voices = protoManager.EnumeratePrototypes().ToList(); + Assert.That(voices, Is.Not.Empty, "There must be at least one TTS voice."); + + var seen = new HashSet(); + foreach (var voice in voices) + { + Assert.That(voice.ID, Is.Not.Null.And.Not.Empty); + Assert.That(seen.Add(voice.ID), Is.True, $"Duplicate voice ID: {voice.ID}"); + Assert.That(voice.Speaker, Is.Not.Null.And.Not.Empty, $"Voice {voice.ID} has empty Speaker."); + } + }); + } + + [Test] + public async Task DefaultVoiceExists() + { + var server = Pair.Server; + var protoManager = server.ResolveDependency(); + + await server.WaitPost(() => + { + Assert.That(protoManager.HasIndex("Taskmaster"), Is.True, + "Default voice 'Taskmaster' must exist."); + }); + } + + [Test] + public async Task AnnouncementSpeakerExists() + { + var server = Pair.Server; + var protoManager = server.ResolveDependency(); + + await server.WaitPost(() => + { + Assert.That(protoManager.HasIndex("Glados"), Is.True, + "Announcement speaker 'Glados' must exist."); + }); + } +} diff --git a/Corvax/Content.Corvax.Tests/Syndicate/TTS/TtsReflection.cs b/Corvax/Content.Corvax.Tests/Syndicate/TTS/TtsReflection.cs new file mode 100644 index 0000000000..93f04fdadd --- /dev/null +++ b/Corvax/Content.Corvax.Tests/Syndicate/TTS/TtsReflection.cs @@ -0,0 +1,178 @@ +using System.Collections.Generic; +using System.Reflection; +using System.Text; +using Content.Shared.Corvax.TTS; +using Robust.Shared.GameObjects; + +using ClientTTS = Content.Client.Corvax.TTS.TTSSystem; +using ServerTTS = Content.Server.Corvax.TTS.TTSSystem; +using ServerTTSManager = Content.Server.Corvax.TTS.TTSManager; + +namespace Content.Corvax.Tests.Syndicate.TTS; + +/// +/// Reflection bridge into the TTS implementation. +/// All lookups are validated on first touch and throw a descriptive +/// when the production surface +/// changes — otherwise a silent NullReferenceException inside a +/// single test would hide the actual contract break. +/// +internal static class TtsReflection +{ + private static FieldInfo RequireField(Type type, string name) + { + var field = type.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic); + if (field is null) + { + throw new InvalidOperationException( + $"TTS reflection contract broken: {type.FullName}.{name} field not found. " + + "Update TtsReflection to match the current implementation."); + } + + return field; + } + + private static MethodInfo RequireMethod(Type type, string name) + { + var method = type.GetMethod(name, BindingFlags.Instance | BindingFlags.NonPublic); + if (method is null) + throw new InvalidOperationException( + $"TTS reflection contract broken: {type.FullName}.{name}() not found. " + + "Update TtsReflection to match the current implementation."); + return method; + } + + private static Type RequireNestedType(Type type, string name) + { + var nested = type.GetNestedType(name, BindingFlags.NonPublic); + if (nested is null) + throw new InvalidOperationException( + $"TTS reflection contract broken: nested type {type.FullName}+{name} not found. " + + "Update TtsReflection to match the current implementation."); + return nested; + } + + private static readonly FieldInfo ServerCacheField = + RequireField(typeof(ServerTTSManager), "_cache"); + + private static readonly FieldInfo ServerCacheKeysField = + RequireField(typeof(ServerTTSManager), "_cacheKeysSeq"); + + private static readonly MethodInfo ServerSanitizeMethod = + RequireMethod(typeof(ServerTTS), "Sanitize"); + + private static readonly MethodInfo ServerToSsmlMethod = + RequireMethod(typeof(ServerTTS), "ToSsmlText"); + + private static readonly Type ServerSoundTraitsType = + RequireNestedType(typeof(ServerTTS), "SoundTraits"); + + public static readonly FieldInfo ClientPlayingField = + RequireField(typeof(ClientTTS), "_playingEntities"); + + public static readonly FieldInfo ClientQueuesField = + RequireField(typeof(ClientTTS), "_entityQueues"); + + public static readonly FieldInfo ClientEnabledField = + RequireField(typeof(ClientTTS), "_ttsEnabled"); + + public static readonly FieldInfo ClientRadioVolumeField = + RequireField(typeof(ClientTTS), "_radioVolume"); + + public static readonly FieldInfo ClientVolumeField = + RequireField(typeof(ClientTTS), "_volume"); + + private static int TraitValue(string name) + { + if (!Enum.IsDefined(ServerSoundTraitsType, name)) + { + throw new InvalidOperationException( + $"TTS reflection contract broken: SoundTraits.{name} is not defined. " + + "Update TtsReflection to match the current enum."); + } + + return Convert.ToInt32(Enum.Parse(ServerSoundTraitsType, name)); + } + + private static readonly int NormalTraits = + TraitValue("RateFast") | TraitValue("PitchMedium"); + + private static readonly int WhisperTraits = + TraitValue("RateSlow") | TraitValue("PitchVerylow") | TraitValue("VolumeXSoft"); + + public static string CallSanitize(ServerTTS system, string text) + => (string)ServerSanitizeMethod.Invoke(system, new object[] { text })!; + + public static string CallToSsml(ServerTTS system, string sanitized, int traitsValue) + { + var traits = Enum.ToObject(ServerSoundTraitsType, traitsValue); + return (string)ServerToSsmlMethod.Invoke(system, new[] { sanitized, traits })!; + } + + /// + /// Mirrors the SSML pipeline inside ServerTTS.GenerateTTS: + /// sanitize → append trailing period if the text ends in a letter → wrap via ToSsmlText. + /// The period step is duplicated because it lives inside GenerateTTS itself, + /// which would otherwise trigger the HTTP-backed . + /// If the production pipeline changes, the cache key will not match and + /// RunsWithCachedAudio will fall through to the network — a real failure, + /// not a silent pass. + /// + public static string BuildSsmlFor(ServerTTS system, string text, bool isWhisper) + { + var sanitized = CallSanitize(system, text); + if (string.IsNullOrEmpty(sanitized)) + return string.Empty; + + if (char.IsLetter(sanitized[^1])) + sanitized += "."; + + var traits = isWhisper ? WhisperTraits : NormalTraits; + return CallToSsml(system, sanitized, traits); + } + + public static void ClearServerCache(ServerTTSManager manager) + { + var cache = (Dictionary)ServerCacheField.GetValue(manager)!; + var keys = (List)ServerCacheKeysField.GetValue(manager)!; + cache.Clear(); + keys.Clear(); + } + + public static void PrepopulateServerCache( + ServerTTSManager manager, string speaker, string ssml, byte[] data) + { + var cache = (Dictionary)ServerCacheField.GetValue(manager)!; + var keys = (List)ServerCacheKeysField.GetValue(manager)!; + + var key = BuildCacheKey(speaker, ssml); + cache[key] = data; + keys.Add(key); + } + + /// + /// Sanity helper: verify that a seeded cache entry is actually present, + /// so RunsWithCachedAudio fails at the setup step rather than + /// silently making a real network call. + /// + public static bool HasServerCacheEntry(ServerTTSManager manager, string speaker, string ssml) + { + var cache = (Dictionary)ServerCacheField.GetValue(manager)!; + return cache.ContainsKey(BuildCacheKey(speaker, ssml)); + } + + private static string BuildCacheKey(string speaker, string ssml) + { + var rawKey = $"{speaker}/{ssml.ToLowerInvariant()}"; + return rawKey.Length <= 32 + ? rawKey + : Convert.ToHexString( + System.Security.Cryptography.MD5.HashData(Encoding.UTF8.GetBytes(rawKey))); + } + + public static void ClearClientState(ClientTTS tts) + { + ((Dictionary>)ClientQueuesField.GetValue(tts)!).Clear(); + ((HashSet)ClientPlayingField.GetValue(tts)!).Clear(); + } +} diff --git a/Corvax/Content.Corvax.Tests/Syndicate/TTS/TtsUnitTests.cs b/Corvax/Content.Corvax.Tests/Syndicate/TTS/TtsUnitTests.cs new file mode 100644 index 0000000000..e7efb36a76 --- /dev/null +++ b/Corvax/Content.Corvax.Tests/Syndicate/TTS/TtsUnitTests.cs @@ -0,0 +1,166 @@ +using System.Collections.Generic; +using Content.Server.Corvax.TTS; +using Content.Shared.Corvax.TTS; +using Robust.Shared.GameObjects; + +namespace Content.Corvax.Tests.Syndicate.TTS; + +[TestFixture] +public sealed class TtsComponentTest +{ + [Test] + public void DefaultVoiceIsTaskmaster() + { + var comp = new TTSComponent(); + Assert.That(comp.VoicePrototypeId, Is.Not.Null); + Assert.That(comp.VoicePrototypeId!.Value.Id, Is.EqualTo("Taskmaster")); + } + + [Test] + public void VoiceCanBeNulled() + { + var comp = new TTSComponent { VoicePrototypeId = null }; + Assert.That(comp.VoicePrototypeId, Is.Null); + } +} + +[TestFixture] +public sealed class TtsEventTest +{ + [Test] + public void PlayEventStoresAllParameters() + { + var data = new byte[] { 1, 2, 3 }; + var source = new NetEntity(42); + var ev = new PlayTTSEvent(data, source, isWhisper: true, isRadio: true); + + Assert.Multiple(() => + { + Assert.That(ev.Data, Is.SameAs(data)); + Assert.That(ev.SourceUid, Is.EqualTo(source)); + Assert.That(ev.IsWhisper, Is.True); + Assert.That(ev.IsRadio, Is.True); + }); + } + + [Test] + public void PlayEventDefaultsAreFalse() + { + var ev = new PlayTTSEvent(Array.Empty()); + + Assert.Multiple(() => + { + Assert.That(ev.SourceUid, Is.Null); + Assert.That(ev.IsWhisper, Is.False); + Assert.That(ev.IsRadio, Is.False); + }); + } + + [Test] + public void RequestPreviewEventStoresVoiceId() + { + var ev = new RequestPreviewTTSEvent("TestVoice"); + Assert.That(ev.VoiceId, Is.EqualTo("TestVoice")); + } + + [Test] + public void TransformSpeakerEventStoresVoiceId() + { + var sender = new EntityUid(1); + var ev = new TransformSpeakerVoiceEvent(sender, "TestVoice"); + + Assert.Multiple(() => + { + Assert.That(ev.Sender, Is.EqualTo(sender)); + Assert.That(ev.VoiceId, Is.EqualTo("TestVoice")); + }); + } +} + +[TestFixture] +public sealed class RadioChannelFlagTest +{ + private static readonly RadioChannelFlag[] AllChannels = + { + RadioChannelFlag.Common, RadioChannelFlag.Command, RadioChannelFlag.Engineering, + RadioChannelFlag.Medical, RadioChannelFlag.Science, RadioChannelFlag.Security, + RadioChannelFlag.Service, RadioChannelFlag.Supply, RadioChannelFlag.Legal, + RadioChannelFlag.Syndicate, RadioChannelFlag.Binary, RadioChannelFlag.Handheld, + RadioChannelFlag.Freelance, RadioChannelFlag.CentCom, RadioChannelFlag.Xenoborg, + RadioChannelFlag.Mothership, + }; + + [Test] + public void EachChannelIsDistinctBit() + { + var seen = new HashSet(); + foreach (var flag in AllChannels) + { + var value = (int)flag; + Assert.That(value, Is.GreaterThan(0)); + Assert.That(value & (value - 1), Is.EqualTo(0), $"{flag} must be a single bit."); + Assert.That(seen.Add(value), Is.True, $"Bit collision at {flag}."); + } + } + + [Test] + public void AllExceptCommonExcludesCommon() + { + Assert.That(RadioChannelFlag.AllExceptCommon.HasFlag(RadioChannelFlag.Common), Is.False); + } + + [Test] + public void AllIncludesEveryChannel() + { + foreach (var flag in AllChannels) + { + Assert.That(RadioChannelFlag.All.HasFlag(flag), Is.True, $"All must include {flag}."); + } + } +} + +[TestFixture] +public sealed class TtsVoiceEffectPresetTest +{ + [Test] + public void EnumValuesAreStable() + { + // These values are networked; changing them breaks clients. + Assert.That((int)TTSVoiceEffectPreset.None, Is.EqualTo(0)); + Assert.That((int)TTSVoiceEffectPreset.Room, Is.EqualTo(1)); + Assert.That((int)TTSVoiceEffectPreset.Hall, Is.EqualTo(2)); + Assert.That((int)TTSVoiceEffectPreset.Void, Is.EqualTo(3)); + Assert.That((int)TTSVoiceEffectPreset.Airlock, Is.EqualTo(4)); + Assert.That((int)TTSVoiceEffectPreset.Warm, Is.EqualTo(5)); + Assert.That((int)TTSVoiceEffectPreset.Subspace, Is.EqualTo(6)); + } +} + +[TestFixture] +public sealed class NumberConverterTest +{ + [TestCase(0L, "ноль")] + [TestCase(1L, "один")] + [TestCase(10L, "десять")] + [TestCase(21L, "двадцать один")] + [TestCase(1000L, "одна тысяча")] + [TestCase(2000L, "две тысячи")] + [TestCase(5000L, "пять тысяч")] + [TestCase(1_000_000L, "один миллион")] + public void ConvertsNumber(long input, string expected) + { + Assert.That(NumberConverter.NumberToText(input), Is.EqualTo(expected)); + } + + [Test] + public void NegativePrefixedWithMinus() + { + Assert.That(NumberConverter.NumberToText(-5), Does.StartWith("минус")); + } + + [Test] + public void OutOfRangeReturnsEmpty() + { + Assert.That(NumberConverter.NumberToText(1_000_000_000_000_000), Is.Empty); + } +} diff --git a/SpaceStation14.slnx b/SpaceStation14.slnx index d54083df8e..4aa08dc0b0 100644 --- a/SpaceStation14.slnx +++ b/SpaceStation14.slnx @@ -13,7 +13,7 @@ - + @@ -26,6 +26,10 @@ + + + + @@ -181,4 +185,4 @@ - \ No newline at end of file +