tests/Architecture/LayerBoundaryTests.cs

Unit tests enforcing architecture and layering rules for the Hexagon.V2 codebase. It scans source files and runtime/config files to check forbidden API usage, sandbox boundaries, Sync attribute usage, networking/prediction defaults, lifecycle ordering, and snapshot contract surface.

File AccessReflection
#nullable enable

using System.IO;
using System.Reflection;
using System.Text.RegularExpressions;
using Hexagon.V2.Networking;

namespace Hexagon.V2.Tests.Architecture;

[TestClass]
public sealed class LayerBoundaryTests
{
	private static readonly Regex BlockComments = new(@"/\*.*?\*/", RegexOptions.Singleline | RegexOptions.Compiled);
	private static readonly Regex LineComments = new(@"//.*?$", RegexOptions.Multiline | RegexOptions.Compiled);
	private static readonly Regex SyncAttribute = new(@"\[\s*Sync(?<body>[^\]]*)\]", RegexOptions.Compiled);
	private static readonly Regex AuthorityApi = new(
		@"\bRpc\.|\[\s*Rpc\b|\[\s*Sync\b|\bSyncFlags\.|\bFileSystem\.Data\b",
		RegexOptions.Compiled);

	public TestContext TestContext { get; set; } = null!;

	[TestMethod]
	public void DomainAndApplicationAreSandboxIndependent()
	{
		var violations = ProductFiles("Domain", "Application")
			.Select(file => (File: file, Source: SourceWithoutComments(file)))
			.Where(value => ContainsAny(value.Source, "using Sandbox", "Sandbox.") ||
				AuthorityApi.IsMatch(value.Source))
			.Select(value => Relative(value.File))
			.ToArray();

		Assert.IsEmpty(violations,
			$"Domain/Application must remain Sandbox-independent: {string.Join(", ", violations)}");
	}

	[TestMethod]
	public void SandboxAndAuthorityApisAreRestrictedToRuntimeAndInfrastructure()
	{
		var violations = ProductFiles()
			.Select(file => (File: file, Source: SourceWithoutComments(file)))
			.Where(value => ContainsAny(value.Source, "using Sandbox", "Sandbox.") ||
				AuthorityApi.IsMatch(value.Source))
			.Where(value => !IsAllowedSandboxLayer(value.File))
			.Select(value => Relative(value.File))
			.ToArray();

		Assert.IsEmpty(violations,
			$"Sandbox/authority APIs are restricted to Runtime and Infrastructure: {string.Join(", ", violations)}");
	}

	[TestMethod]
	public void EveryV2SyncAttributeIsExplicitlyHostAuthored()
	{
		var violations = new List<string>();
		foreach (var file in ProductFiles())
		{
			var source = SourceWithoutComments(file);
			foreach (Match match in SyncAttribute.Matches(source))
			{
				if (!match.Groups["body"].Value.Contains("SyncFlags.FromHost", StringComparison.Ordinal))
					violations.Add($"{Relative(file)}: {match.Value}");
			}
		}

		Assert.IsEmpty(violations,
			$"Every v2 synchronized field must be host-authored: {string.Join(" | ", violations)}");
	}

	[TestMethod]
	public void ReplicatedPlayerBodyIsPresentationOnlyAndNeverWritesTheClientStore()
	{
		var playerBody = Path.Combine(V2Root(), "Runtime", "HexPlayerBody.cs");
		var source = SourceWithoutComments(playerBody);
		var forbidden = new[] { "HexClientStore", "ClientStore", "ApplyState(", "ReplacePlayer(" };
		var violations = forbidden.Where(value => source.Contains(value, StringComparison.Ordinal)).ToArray();

		Assert.IsEmpty(violations,
			$"Replicated player state must remain presentation-only: {string.Join(", ", violations)}");
	}

	[TestMethod]
	public void AuthoritativeBodyLifecycleUsesAnIndependentDormantUnownedNetworkRoot()
	{
		var playerBody = Path.Combine( V2Root(), "Runtime", "HexPlayerBody.cs" );
		var source = SourceWithoutComments( playerBody );

		StringAssert.Contains( source, "new GameObject( false, \"Hexagon Authoritative Body\" )" );
		Assert.IsFalse( source.Contains( "new GameObject( GameObject, false", StringComparison.Ordinal ) );
		StringAssert.Contains( source, "Owner = null!" );
		StringAssert.Contains( source, "StartEnabled = false" );
		StringAssert.Contains( source, "OwnerTransfer = OwnerTransfer.Fixed" );
		StringAssert.Contains( source, "OrphanedMode = NetworkOrphaned.Destroy" );
		StringAssert.Contains( source, "controller.UseInputControls = false" );
		StringAssert.Contains( source, "Components.Get<PlayerController>( FindMode.EverythingInSelf )" );
		StringAssert.Contains( source, "NetworkMode = NetworkMode.Never" );
		StringAssert.Contains( source, "if ( _predictedBody is not null ) DestroyPredictedBody()" );
		StringAssert.Contains( source, "AuthoritativeBodyReplacement.RequireActivated" );
		StringAssert.Contains( source, "_candidate.Network.Refresh()" );
	}

	[TestMethod]
	public void IdentityShellDestructionCleansIndependentAuthoritativeBodies()
	{
		var playerBody = Path.Combine( V2Root(), "Runtime", "HexPlayerBody.cs" );
		var source = SourceWithoutComments( playerBody );
		var onDestroy = source.IndexOf( "protected override void OnDestroy()", StringComparison.Ordinal );
		var preparedCleanup = source.IndexOf( "_preparedBody?.Dispose()", onDestroy, StringComparison.Ordinal );
		var activeCleanup = source.IndexOf(
			"DestroyAuthoritativeBody( publishRemoval: false )", preparedCleanup, StringComparison.Ordinal );

		Assert.IsGreaterThanOrEqualTo( 0, onDestroy );
		Assert.IsGreaterThan( onDestroy, preparedCleanup );
		Assert.IsGreaterThan( preparedCleanup, activeCleanup );
		StringAssert.Contains( source, "var publishRemoval = !Game.IsClosing" );
		StringAssert.Contains( source, "if ( publishRemoval )" );
		StringAssert.Contains( source, "try { GameObject.Network.Refresh(); }" );
	}

	[TestMethod]
	public void ClientInputCannotBecomeSpatialAuthority()
	{
		var playerBody = SourceWithoutComments( Path.Combine( V2Root(), "Runtime", "HexPlayerBody.cs" ) );
		StringAssert.Contains( playerBody, "Rpc.Host( NetFlags.OwnerOnly | NetFlags.UnreliableNoDelay )" );
		StringAssert.Contains( playerBody, "Rpc.Caller.Id != HostConnection.Id" );
		StringAssert.Contains( playerBody, "_inputAdmission.TryBeginAttempt" );
		StringAssert.Contains( playerBody, "_inputAdmission.TryAcceptCharged" );
		StringAssert.Contains( playerBody, "HostInputAuthenticator" );
		StringAssert.Contains( playerBody, "HasProcessedInput" );
		StringAssert.Contains( playerBody, "ProcessedMovementState" );
		StringAssert.Contains( playerBody, "PredictionReconciliation.Calculate" );
		StringAssert.Contains( playerBody, "AuthoritativeBody" );
		Assert.IsFalse( playerBody.Contains( "ProcessedInputSequence == 0", StringComparison.Ordinal ),
			"Sequence zero is valid after wrap and cannot be used as the unprocessed-input sentinel." );
		Assert.IsFalse( playerBody.Contains( "PlayableBody", StringComparison.Ordinal ) );

		var runtime = SourceWithoutComments( Path.Combine( V2Root(), "Runtime", "HexagonRuntimeSystem.cs" ) );
		StringAssert.Contains( runtime, "connection.CanSpawnObjects = false" );
		StringAssert.Contains( runtime, "connection.CanRefreshObjects = false" );
		StringAssert.Contains( runtime, "connection.CanDestroyObjects = false" );
		StringAssert.Contains( runtime, "PlayerInputSessionAuthentication.IsAuthorized" );
	}

	[TestMethod]
	public void ProjectNetworkingAndPredictionCollisionDefaultsAreFailClosed()
	{
		var networking = File.ReadAllText( Path.Combine( ProductRoot(), "ProjectSettings", "Networking.config" ) );
		foreach ( var permission in new[]
		{
			"\"ClientsCanSpawnObjects\": false",
			"\"ClientsCanRefreshObjects\": false",
			"\"ClientsCanDestroyObjects\": false",
			// Host migration would hand authority to a machine with no host application,
			// no domain services, and no persistence lease; both flags must stay closed.
			"\"DestroyLobbyWhenHostLeaves\": true",
			"\"AutoSwitchToBestHost\": false"
		} ) StringAssert.Contains( networking, permission );
		StringAssert.Contains( networking, "\"UpdateRate\": 30" );

		var runtime = SourceWithoutComments( Path.Combine( V2Root(), "Runtime", "HexagonRuntimeSystem.cs" ) );
		StringAssert.Contains( runtime, "void Component.INetworkListener.OnBecameHost( Connection previousHost )" );
		StringAssert.Contains( runtime, "HEXAGON_HOST_MIGRATION_REFUSED" );
		StringAssert.Contains( runtime, "Networking.Disconnect()" );
		var collision = File.ReadAllText( Path.Combine( ProductRoot(), "ProjectSettings", "Collision.config" ) );
		StringAssert.Contains( collision, "\"b\": \"prediction\"" );
		StringAssert.Contains( collision, "\"r\": \"Ignore\"" );
	}

	[TestMethod]
	public void HostServicePublicationAndRpcShutdownAreFailClosed()
	{
		var runtime = SourceWithoutComments( Path.Combine( V2Root(), "Runtime", "HexagonRuntimeSystem.cs" ) );
		StringAssert.Contains( runtime, "OperationResult<HexHostServicesComponent> CreateHostServices()" );
		StringAssert.Contains( runtime, "HostServicePublication.RequirePublished" );
		StringAssert.Contains( runtime, "servicesObject.NetworkSpawn" );
		StringAssert.Contains( runtime, "if ( servicesObject.IsValid() ) servicesObject.Destroy()" );
		StringAssert.Contains( runtime, "if ( hostServices.Failed )" );

		var shutdown = runtime.IndexOf( "private async Task<OperationResult> ShutdownHostAsync()", StringComparison.Ordinal );
		var commandDrain = runtime.IndexOf( "_hostOperations.DrainAsync()", shutdown, StringComparison.Ordinal );
		var pairedDisconnectLoop = runtime.IndexOf( "foreach ( var disconnect in disconnects )", shutdown, StringComparison.Ordinal );
		var sessionDisconnect = runtime.IndexOf( "disconnect.Session.Disconnect()", pairedDisconnectLoop, StringComparison.Ordinal );
		var disconnect = runtime.IndexOf( "application.Disconnected", shutdown, StringComparison.Ordinal );
		var applicationDrain = runtime.IndexOf( "application.DisposeAsync", shutdown, StringComparison.Ordinal );
		var persistenceDrain = runtime.IndexOf( "persistence.ShutdownAsync", shutdown, StringComparison.Ordinal );
		var persistenceDispose = runtime.IndexOf( "persistence.DisposeAsync", shutdown, StringComparison.Ordinal );
		var quiescedEvidence = runtime.IndexOf( "application.CompleteQuiescedShutdown", shutdown, StringComparison.Ordinal );
		Assert.IsGreaterThanOrEqualTo( 0, shutdown );
		Assert.IsLessThan( disconnect, sessionDisconnect,
			"Each client session must be revoked immediately before its application disconnect callback." );
		Assert.IsLessThan( commandDrain, disconnect, "Disconnect callbacks must revoke sessions before RPC dispatch drains." );
		Assert.IsLessThan( applicationDrain, commandDrain, "RPC dispatch must finish before application disposal." );
		Assert.IsLessThan( persistenceDrain, applicationDrain, "Application disposal must finish before persistence shutdown." );
		Assert.IsLessThan( persistenceDispose, persistenceDrain, "Persistence must stop before it is disposed." );
		Assert.IsLessThan( quiescedEvidence, persistenceDispose, "Quiesced evidence must follow persistence disposal." );

		var services = SourceWithoutComments( Path.Combine( V2Root(), "Runtime", "HexHostServicesComponent.cs" ) );
		StringAssert.Contains( services, "runtime.TryStartHostOperation" );
		Assert.IsFalse( services.Contains( "_ = DispatchAsync", StringComparison.Ordinal ) );
	}

	[TestMethod]
	public void ClientStateSyncShellAppliesOnlyAfterScopeCaptureAndEncodeAborts()
	{
		var services = SourceWithoutComments( Path.Combine( V2Root(), "Runtime", "HexHostServicesComponent.cs" ) );
		var send = services.IndexOf( "public void SendClientState(", StringComparison.Ordinal );
		Assert.IsGreaterThanOrEqualTo( 0, send );
		var scopeCapture = services.IndexOf( "runtime.CaptureClientScope( recipient )", send, StringComparison.Ordinal );
		var encodeAbort = services.IndexOf( "LogSnapshotWireFailure( \"ENCODE\", \"client-state\"", send, StringComparison.Ordinal );
		var applyShell = services.IndexOf( "player.HostApplyPublicSnapshot( publicSnapshot )", send, StringComparison.Ordinal );
		var wireSend = services.IndexOf( "ReceiveClientState( scope.Value, encoded.Value )", send, StringComparison.Ordinal );
		Assert.IsGreaterThanOrEqualTo( 0, applyShell );
		Assert.IsGreaterThanOrEqualTo( 0, wireSend );
		Assert.IsLessThan( applyShell, scopeCapture,
			"The scope-capture abort must run before the replicated [Sync] shell is mutated." );
		Assert.IsLessThan( applyShell, encodeAbort,
			"The wire-encode abort must run before the replicated [Sync] shell is mutated." );
		Assert.IsLessThan( wireSend, applyShell,
			"The [Sync] shell mutation must sit immediately before the send, after every abort exit." );
	}

	[TestMethod]
	public void ClientLayerDoesNotReferenceServerAggregatesOrPersistence()
	{
		var forbidden = new[]
		{
			"Hexagon.V2.Persistence",
			"CharacterRecord",
			"InventoryRecord",
			"ItemRecord",
			"WorldItemRecord",
			"TypedPayload",
			"DocumentSnapshot",
			"IPersistenceProvider"
		};
		var violations = ProductFiles("Client")
			.Select(file => (File: file, Source: SourceWithoutComments(file)))
			.Where(value => ContainsAny(value.Source, forbidden))
			.Select(value => Relative(value.File))
			.ToArray();

		Assert.IsEmpty(violations,
			$"Client may depend only on snapshots, commands, IDs, and kernel results: {string.Join(", ", violations)}");
	}

	[TestMethod]
	public void SnapshotContractsExposeNoServerOnlyNamesOrTypes()
	{
		var forbiddenPropertyNames = new HashSet<string>(StringComparer.Ordinal)
		{
			"SteamId",
			"AccountId",
			"IsDirty",
			"SchemaState",
			"BanExpiresAt",
			"Traits",
			"RevisionToken"
		};
		var forbiddenTypeNames = new HashSet<string>(StringComparer.Ordinal)
		{
			"Hexagon.V2.Domain.CharacterRecord",
			"Hexagon.V2.Domain.InventoryRecord",
			"Hexagon.V2.Domain.ItemRecord",
			"Hexagon.V2.Domain.WorldItemRecord",
			"Hexagon.V2.Domain.TypedPayload"
		};
		var snapshotTypes = typeof(PlayerPublicSnapshot).Assembly.GetTypes()
			.Where(type => type.Namespace == "Hexagon.V2.Networking" &&
				(type.Name.EndsWith("Snapshot", StringComparison.Ordinal) || type == typeof(SnapshotValue)))
			.ToArray();
		var violations = new List<string>();
		foreach (var type in snapshotTypes)
		{
			foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public))
			{
				if (forbiddenPropertyNames.Contains(property.Name))
					violations.Add($"{type.Name}.{property.Name}");
				if (ContainsForbiddenType(property.PropertyType, forbiddenTypeNames))
					violations.Add($"{type.Name}.{property.Name}: {property.PropertyType.FullName}");
			}
		}

		Assert.IsNotEmpty(snapshotTypes);
		Assert.IsEmpty(violations,
			$"Snapshots expose server-only members: {string.Join(", ", violations)}");
	}

	[TestMethod]
	public void LegacyNamespacesAreReportedWithoutGatingV2()
	{
		var codeRoot = Path.Combine(ProductRoot(), "Code");
		var legacyFiles = Directory.EnumerateFiles(codeRoot, "*.cs", SearchOption.AllDirectories)
			.Where(file => !file.StartsWith(Path.Combine(codeRoot, "V2") + Path.DirectorySeparatorChar,
				StringComparison.OrdinalIgnoreCase))
			.Where(file => Regex.IsMatch(SourceWithoutComments(file), @"\bnamespace\s+Hexagon(?:\.|;)",
				RegexOptions.CultureInvariant))
			.Select(file => Path.GetRelativePath(ProductRoot(), file))
			.OrderBy(file => file, StringComparer.Ordinal)
			.ToArray();

		TestContext.WriteLine($"Legacy Hexagon namespace files (non-gating): {legacyFiles.Length}");
		foreach (var file in legacyFiles.Take(25))
			TestContext.WriteLine(file);
	}

	[TestMethod]
	public void HostConstructionOccursOnlyAfterConfigurationAndRecoveredDomainValidation()
	{
		var runtime = SourceWithoutComments( Path.Combine( V2Root(), "Runtime", "HexagonRuntimeSystem.cs" ) );
		var configuration = runtime.IndexOf( "configuration.InitializeAsync", StringComparison.Ordinal );
		var validation = runtime.IndexOf( "new DomainInvariantValidator", StringComparison.Ordinal );
		var hostConstruction = runtime.IndexOf( "descriptor.CreateHostApplication", StringComparison.Ordinal );

		Assert.IsGreaterThanOrEqualTo( 0, configuration );
		Assert.IsLessThan( configuration, validation );
		Assert.IsLessThan( hostConstruction, configuration );
	}

	private static bool ContainsForbiddenType(Type type, IReadOnlySet<string> forbidden)
	{
		if (type.FullName is not null && forbidden.Contains(type.FullName))
			return true;
		if (type.IsArray)
			return ContainsForbiddenType(type.GetElementType()!, forbidden);
		return type.IsGenericType && type.GetGenericArguments().Any(argument => ContainsForbiddenType(argument, forbidden));
	}

	private static bool ContainsAny(string source, params string[] values) =>
		values.Any(value => source.Contains(value, StringComparison.Ordinal));

	private static bool IsAllowedSandboxLayer(string file)
	{
		var relative = Path.GetRelativePath(V2Root(), file);
		var separator = relative.IndexOfAny(new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar });
		var layer = separator < 0 ? relative : relative[..separator];
		return layer is "Runtime" or "Infrastructure";
	}

	private static IEnumerable<string> ProductFiles(params string[] layers)
	{
		var roots = layers.Length == 0
			? Directory.EnumerateDirectories(V2Root())
			: layers.Select(layer => Path.Combine(V2Root(), layer));
		return roots.Where(Directory.Exists)
			.SelectMany(root => Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories));
	}

	private static string SourceWithoutComments(string file)
	{
		var source = File.ReadAllText(file);
		return LineComments.Replace(BlockComments.Replace(source, string.Empty), string.Empty);
	}

	private static string Relative(string file) => Path.GetRelativePath(ProductRoot(), file);
	private static string V2Root() => Path.Combine(ProductRoot(), "Code", "V2");

	private static string ProductRoot()
	{
		var current = new DirectoryInfo(AppContext.BaseDirectory);
		while (current is not null)
		{
			if (Directory.Exists(Path.Combine(current.FullName, "Code", "V2")))
				return current.FullName;
			current = current.Parent;
		}

		throw new DirectoryNotFoundException("Could not locate the Hexagon product root from the test output directory.");
	}
}