addcorvaxtests

This commit is contained in:
Zekins3366 2026-10-04 18:35:12 +03:00
parent 03e4fc958c
commit 2dcd55d9b8
21 changed files with 2029 additions and 26 deletions

79
.github/workflows/build-test-corvax.yml vendored Normal file
View file

@ -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

View file

@ -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<TestCaseData> 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<TestCaseData> 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<TestCaseData> 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<TestCaseData> 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
};
/// <summary>
@ -95,16 +181,19 @@ namespace Content.IntegrationTests.Tests
private static readonly string[] GameMaps = GameDataScrounger.PrototypesOfKind<GameMapPrototype>().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<EntityCategoryPrototype> DoNotMapCategory = "DoNotMap";
/// <summary>
/// Asserts that specific files have been saved as grids and not maps.
/// </summary>
[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
/// <summary>
/// Asserts that shuttles are loadable and have been saved as grids and not maps.
/// </summary>
[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<ShuttleSystem>();
var cfg = server.ResolveDependency<IConfigurationManager>();
// Corvax-Tests-start
var gameMapProto = protoManager.Index<GameMapPrototype>(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<GameMapPrototype>(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;

View file

@ -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);
}

View file

@ -0,0 +1 @@
[assembly: Parallelizable(ParallelScope.Fixtures)]

View file

@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputPath>..\..\bin\Content.Corvax.Tests\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<Nullable>disable</Nullable>
<IsPackable>false</IsPackable>
<AssemblyName>Content.Corvax.Tests</AssemblyName>
<RootNamespace>Content.Corvax.Tests</RootNamespace>
</PropertyGroup>
<Import Project="..\..\MSBuild\Content.props" />
<ItemGroup>
<PackageReference Include="NUnit" />
<PackageReference Include="NUnit3TestAdapter" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Content.Client\Content.Client.csproj" />
<ProjectReference Include="..\..\Content.Server\Content.Server.csproj" />
<ProjectReference Include="..\..\Content.Shared\Content.Shared.csproj" />
<ProjectReference Include="..\..\Content.Tests\Content.Tests.csproj" />
<ProjectReference Include="..\..\Content.IntegrationTests\Content.IntegrationTests.csproj" />
</ItemGroup>
<Import Project="..\..\RobustToolbox\MSBuild\Robust.Properties.targets" />
<Import Project="..\..\RobustToolbox\MSBuild\Robust.CompNetworkGenerator.targets" />
<Import Project="..\..\RobustToolbox\Imports\Client.props" />
<Import Project="..\..\RobustToolbox\Imports\Server.props" />
<Import Project="..\..\RobustToolbox\Imports\Shared.props" />
<Import Project="..\..\RobustToolbox\Imports\Testing.props" />
</Project>

View file

@ -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;

View file

@ -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();
}
}

View file

@ -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<DocumentPrinterSystem>();
var loc = server.ResolveDependency<ILocalizationManager>();
// 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<DocumentPrinterSystem>();
var entManager = server.ResolveDependency<IEntityManager>();
var loc = server.ResolveDependency<ILocalizationManager>();
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<IdCardComponent>(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<DocumentPrinterSystem>();
var loc = server.ResolveDependency<ILocalizationManager>();
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<IEntityManager>();
await server.WaitPost(() =>
{
var ent = entManager.SpawnEntity(null, MapCoordinates.Nullspace);
var comp = entManager.EnsureComponent<DocumentPrinterComponent>(ent);
Assert.That(comp.SlotName, Is.EqualTo("id"), "DocumentPrinterComponent.SlotName should default to \"id\".");
entManager.DeleteEntity(ent);
});
}
}

View file

@ -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<GhostGoLobbySystem>();
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<GhostGoLobbySystem>();
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<GhostGoLobbySystem>();
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<GhostGoLobbySystem>();
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<GhostGoLobbySystem>();
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<GhostGoLobbySystem>();
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<GhostGoLobbySystem>();
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<GhostGoLobbySystem>();
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<GhostGoLobbySystem>();
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.");
});
}
/// <summary>
/// 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.
/// </summary>
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);
}
}

View file

@ -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<Stream> 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.");
}
});
}
}

View file

@ -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<string>(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<string>(), 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<string, object?>;
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<object?>;
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<string, object?> { ["a"] = 1, ["b"] = 2 };
var values = new Dictionary<string, object?>
{
["ent1"] = new Dictionary<string, object?> { ["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));
}
}

View file

@ -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<IEntityManager>();
var mapSystem = server.System<SharedMapSystem>();
var damageableSystem = server.System<DamageableSystem>();
await server.WaitPost(() =>
{
mapSystem.CreateMap(out var mapId);
var coords = new MapCoordinates(0, 0, mapId);
var uid = entManager.SpawnEntity("MobIpc", coords);
entManager.EnsureComponent<IpcComponent>(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<IEntityManager>();
var mapSystem = server.System<SharedMapSystem>();
await server.WaitPost(() =>
{
mapSystem.CreateMap(out var mapId);
var coords = new MapCoordinates(0, 0, mapId);
var uid = entManager.SpawnEntity("MobIpc", coords);
entManager.EnsureComponent<IpcComponent>(uid);
var mobState = entManager.EnsureComponent<MobStateComponent>(uid);
Assert.That(entManager.HasComponent<SpamEmitSoundComponent>(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<SpamEmitSoundComponent>(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<IEntityManager>();
var mapSystem = server.System<SharedMapSystem>();
await server.WaitPost(() =>
{
mapSystem.CreateMap(out var mapId);
var coords = new MapCoordinates(0, 0, mapId);
var uid = entManager.SpawnEntity("MobIpc", coords);
entManager.EnsureComponent<IpcComponent>(uid);
var mobState = entManager.EnsureComponent<MobStateComponent>(uid);
entManager.EnsureComponent<SpamEmitSoundComponent>(uid);
var ev = new MobStateChangedEvent(uid, mobState, MobState.Critical, MobState.Alive);
server.EntMan.EventBus.RaiseLocalEvent(uid, ev);
Assert.That(entManager.HasComponent<SpamEmitSoundComponent>(uid), Is.False,
"IPC must stop the warning sound when leaving Critical state.");
entManager.DeleteEntity(uid);
mapSystem.DeleteMap(mapId);
});
}
}

View file

@ -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;
/// <summary>
/// Runs PostMapInit tests only for maps from /Maps/Corvax/**.
/// </summary>
[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);
}
}

View file

@ -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<SquadIconNum>();
Assert.That(values.Length, Is.EqualTo(24),
"SquadIconNum must contain exactly 24 entries (Alpha..Omega).");
}
[Test]
public void SquadStatusHasActiveAndOnBreak()
{
var values = Enum.GetValues<SquadStatus>().Cast<SquadStatus>().ToArray();
Assert.Multiple(() =>
{
Assert.That(values, Does.Contain(SquadStatus.Active));
Assert.That(values, Does.Contain(SquadStatus.OnBreak));
});
}
}
[TestFixture]
public sealed class SecApartmentPrototypeTest : GameTest
{
/// <summary>
/// Every icon that <c>SecApartmentSystem.GetIconPrototypeId</c> can return
/// must exist as a <see cref="StatusIconPrototype"/>. If someone renames
/// a squad icon prototype without updating the switch, this test catches it.
/// </summary>
[Test]
public async Task AllSquadIconPrototypesExist()
{
var server = Pair.Server;
var protoManager = server.ResolveDependency<IPrototypeManager>();
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<StatusIconPrototype>(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<IPrototypeManager>();
await server.WaitPost(() =>
{
var squadIcons = protoManager.EnumeratePrototypes<StatusIconPrototype>()
.Where(p => p.ID.StartsWith("SecuritySquadIcon"))
.ToList();
var enumCount = Enum.GetValues<SquadIconNum>().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<Content.Server.Corvax.SecApartment.SecApartmentSystem>();
Assert.That(sys, Is.Not.Null, "SecApartmentSystem must be registered on the server.");
});
}
}

View file

@ -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<IPrototypeManager>();
await server.WaitPost(() =>
{
var goals = protoManager.EnumeratePrototypes<StationGoalPrototype>().ToList();
Assert.That(goals, Is.Not.Empty, "There must be at least one Station Goal.");
var seen = new HashSet<string>();
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}");
}
});
}
/// <summary>
/// Sends a station goal to a fax on the pre-loaded station and verifies the result.
/// If <paramref name="expectNews"/> is set, also checks whether a news article is published.
/// </summary>
[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<IEntityManager>();
var goalSystem = server.System<StationGoalPaperSystem>();
var protoManager = server.ResolveDependency<IPrototypeManager>();
EntityUid station = default;
int articlesBefore = 0;
await server.WaitPost(() =>
{
var stationQuery = entManager.EntityQueryEnumerator<StationDataComponent>();
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<StationNewsComponent>(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<StationNewsComponent>(station);
articlesBefore = 0;
}
});
var goalId = protoManager.EnumeratePrototypes<StationGoalPrototype>().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<StationNewsComponent>(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.");
}
});
}
}

View file

@ -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<NetEntity, Queue<PlayTTSEvent>> GetQueues(ClientTTS tts)
=> (Dictionary<NetEntity, Queue<PlayTTSEvent>>)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<ClientTTS>();
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<ClientTTS>();
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<ClientTTS>();
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<ClientTTS>();
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<NetEntity>)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<ClientTTS>();
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).");
}
});
}
}

View file

@ -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<ServerTTS>();
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<ServerTTS>();
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<ServerTTS>();
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<ServerTTS>();
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("<speak>"));
Assert.That(ssml, Does.EndWith("</speak>"));
});
}
[Test]
public async Task EscapesXml()
{
var server = Pair.Server;
var tts = server.System<ServerTTS>();
await server.WaitPost(() =>
{
var ssml = TtsReflection.CallToSsml(tts, "<test> & \"q\"", 0);
Assert.That(ssml, Does.Contain("&lt;test&gt;"));
Assert.That(ssml, Does.Contain("&amp;"));
Assert.That(ssml, Does.Contain("&quot;"));
});
}
}
[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<TTSComponent>(mob);
tts.VoicePrototypeId = voiceId;
return mob;
}
[Test]
public async Task RunsWithCachedAudio()
{
var server = Pair.Server;
var entMan = server.ResolveDependency<IEntityManager>();
var mapSystem = server.System<SharedMapSystem>();
var tts = server.System<ServerTTS>();
var manager = server.ResolveDependency<ServerTTSManager>();
var protoManager = server.ResolveDependency<IPrototypeManager>();
await server.WaitPost(() =>
{
mapSystem.CreateMap(out var mapId);
var voiceProto = protoManager.Index<TTSVoicePrototype>("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<IEntityManager>();
var mapSystem = server.System<SharedMapSystem>();
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<IEntityManager>();
var mapSystem = server.System<SharedMapSystem>();
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);
});
}
}

View file

@ -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<IPrototypeManager>();
await server.WaitPost(() =>
{
var voices = protoManager.EnumeratePrototypes<TTSVoicePrototype>().ToList();
Assert.That(voices, Is.Not.Empty, "There must be at least one TTS voice.");
var seen = new HashSet<string>();
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<IPrototypeManager>();
await server.WaitPost(() =>
{
Assert.That(protoManager.HasIndex<TTSVoicePrototype>("Taskmaster"), Is.True,
"Default voice 'Taskmaster' must exist.");
});
}
[Test]
public async Task AnnouncementSpeakerExists()
{
var server = Pair.Server;
var protoManager = server.ResolveDependency<IPrototypeManager>();
await server.WaitPost(() =>
{
Assert.That(protoManager.HasIndex<TTSVoicePrototype>("Glados"), Is.True,
"Announcement speaker 'Glados' must exist.");
});
}
}

View file

@ -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;
/// <summary>
/// Reflection bridge into the TTS implementation.
/// All lookups are validated on first touch and throw a descriptive
/// <see cref="InvalidOperationException"/> when the production surface
/// changes — otherwise a silent <c>NullReferenceException</c> inside a
/// single test would hide the actual contract break.
/// </summary>
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 })!;
}
/// <summary>
/// Mirrors the SSML pipeline inside <c>ServerTTS.GenerateTTS</c>:
/// sanitize → append trailing period if the text ends in a letter → wrap via <c>ToSsmlText</c>.
/// The period step is duplicated because it lives inside <c>GenerateTTS</c> itself,
/// which would otherwise trigger the HTTP-backed <see cref="ServerTTSManager"/>.
/// If the production pipeline changes, the cache key will not match and
/// <c>RunsWithCachedAudio</c> will fall through to the network — a real failure,
/// not a silent pass.
/// </summary>
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<string, byte[]>)ServerCacheField.GetValue(manager)!;
var keys = (List<string>)ServerCacheKeysField.GetValue(manager)!;
cache.Clear();
keys.Clear();
}
public static void PrepopulateServerCache(
ServerTTSManager manager, string speaker, string ssml, byte[] data)
{
var cache = (Dictionary<string, byte[]>)ServerCacheField.GetValue(manager)!;
var keys = (List<string>)ServerCacheKeysField.GetValue(manager)!;
var key = BuildCacheKey(speaker, ssml);
cache[key] = data;
keys.Add(key);
}
/// <summary>
/// Sanity helper: verify that a seeded cache entry is actually present,
/// so <c>RunsWithCachedAudio</c> fails at the setup step rather than
/// silently making a real network call.
/// </summary>
public static bool HasServerCacheEntry(ServerTTSManager manager, string speaker, string ssml)
{
var cache = (Dictionary<string, byte[]>)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<NetEntity, Queue<PlayTTSEvent>>)ClientQueuesField.GetValue(tts)!).Clear();
((HashSet<NetEntity>)ClientPlayingField.GetValue(tts)!).Clear();
}
}

View file

@ -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<byte>());
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<int>();
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);
}
}

View file

@ -13,7 +13,7 @@
<Folder Name="/Build/">
<File Path="Tools/generate_hashes.ps1" />
</Folder>
<Folder Name="/Corvax Interfaces/">
<Folder Name="/Corvax/">
<Project Path="Corvax/Content.Corvax.Interfaces.Client/Content.Corvax.Interfaces.Client.csproj">
<BuildType Solution="DebugOpt|*" Project="Debug" />
<BuildType Solution="Tools|*" Project="Debug" />
@ -26,6 +26,10 @@
<BuildType Solution="DebugOpt|*" Project="Debug" />
<BuildType Solution="Tools|*" Project="Debug" />
</Project>
<Project Path="Corvax/Content.Corvax.Tests/Content.Corvax.Tests.csproj">
<BuildType Solution="DebugOpt|*" Project="Debug" />
<BuildType Solution="Tools|*" Project="Debug" />
</Project>
</Folder>
<Folder Name="/MSBuild/">
<File Path="MSBuild/Content.props" />
@ -181,4 +185,4 @@
<Project Path="Content.Tools/Content.Tools.csproj" />
<Project Path="Content.YAMLLinter/Content.YAMLLinter.csproj" />
<Project Path="Pow3r/Pow3r.csproj" />
</Solution>
</Solution>