tests/Client/HexClientStoreTests.cs

Unit tests for HexClientStore behavior. Exercises session lifecycle, state application, chat merging, roster/schema immutability, snapshot validation and subscriber error handling.

Reflection
#nullable enable

using System;
using System.Collections.Generic;
using Hexagon.V2.Client;
using Hexagon.V2.Domain;
using Hexagon.V2.Networking;

namespace Hexagon.V2.Tests.Client;

[TestClass]
public sealed class HexClientStoreTests
{
	[TestMethod]
	public void ThrowingSubscriberDoesNotSkipLaterSubscribersOrEscapeThePublish()
	{
		var store = new HexClientStore();
		var diagnostics = new List<Exception>();
		store.SubscriberFailureDiagnostic = diagnostics.Add;
		var received = new List<ClientStoreChange>();
		store.Changed += _ => throw new InvalidOperationException( "first subscriber threw" );
		store.Changed += received.Add;

		store.PrepareSession( ClientSessionNonce.New() );

		Assert.HasCount( 1, received, "The second subscriber must still observe the change." );
		Assert.AreEqual( ClientStoreChangeKind.SessionStarted, received[0].Kind );
		Assert.AreEqual( 1, store.SubscriberFailureCount );
		Assert.HasCount( 1, diagnostics );
		Assert.IsInstanceOfType<InvalidOperationException>( diagnostics[0] );
	}

	[TestMethod]
	public void CompleteStatePublicationAtomicallyTransitionsAndUnloadsCharacter()
	{
		var store = new HexClientStore();
		var changes = new List<ClientStoreChange>();
		store.Changed += changes.Add;
		var connection = ConnectionEpoch.New();
		var characterId = CharacterId.New();
		var characterList = CharacterList( characterId );
		var chat = Chat( new ChatDeliveryEpoch( connection, 1 ) );

		var scope = Establish( store, connection );
		store.ReplaceCharacterList( scope, characterList );
		Assert.IsTrue( store.ApplyState( scope, State( connection, 1, 1, characterId, new[] { Inventory() }, Action() ) ) );
		store.ReplaceChat( scope, chat );

		Assert.AreEqual( ClientLifecycleState.CharacterActive, store.Lifecycle );
		Assert.IsNotNull( store.PublicPlayer );
		Assert.IsNotNull( store.PrivatePlayer );
		Assert.HasCount( 1, store.Inventories );
		Assert.IsNotNull( store.ActiveAction );
		Assert.AreSame( chat, store.Chat );

		Assert.IsTrue( store.ApplyState( scope, State( connection, 2, 2, null ) ) );

		Assert.AreEqual( ClientLifecycleState.Connected, store.Lifecycle );
		Assert.IsNotNull( store.PublicPlayer );
		Assert.IsNull( store.PublicPlayer.CharacterId );
		Assert.IsNull( store.PrivatePlayer );
		Assert.IsEmpty( store.Inventories );
		Assert.IsNull( store.ActiveAction );
		Assert.AreSame( characterList, store.CharacterList );
		Assert.IsNull( store.Chat );
		Assert.AreEqual( ClientStoreChangeKind.StateApplied, changes[^1].Kind );

		store.ClearSession();
		Assert.AreEqual( ClientLifecycleState.Disconnected, store.Lifecycle );
		Assert.IsNull( store.State );
		Assert.IsNull( store.CharacterList );
		Assert.IsNull( store.Chat );
	}

	[TestMethod]
	public void CharacterSwitchReplacesEveryCharacterScopedViewInOnePublication()
	{
		var store = new HexClientStore();
		var connection = ConnectionEpoch.New();
		var scope = Establish( store, connection );
		var first = CharacterId.New();
		var second = CharacterId.New();
		store.ApplyState( scope, State( connection, 1, 1, first, new[] { Inventory( "First" ) }, Action() ) );
		store.ReplaceChat( scope, Chat( new ChatDeliveryEpoch( connection, 1 ) ) );

		var secondInventory = Inventory( "Second" );
		Assert.IsTrue( store.ApplyState( scope, State( connection, 2, 2, second, new[] { secondInventory } ) ) );

		Assert.AreEqual( second, store.PublicPlayer!.CharacterId );
		Assert.AreEqual( second, store.PrivatePlayer!.CharacterId );
		Assert.HasCount( 1, store.Inventories );
		Assert.AreEqual( "Second", store.Inventories[0].Title );
		Assert.IsNull( store.ActiveAction );
		Assert.IsNull( store.Chat, "Character-epoch changes must clear receiver-scoped chat history." );
	}

	[TestMethod]
	public void LateRevisionWrongConnectionAndUnversionedCharacterSwitchAreRejected()
	{
		var store = new HexClientStore();
		var firstConnection = ConnectionEpoch.New();
		var scope = Establish( store, firstConnection );
		var character = CharacterId.New();
		var accepted = State( firstConnection, 4, 10, character, new[] { Inventory( "Accepted" ) } );
		Assert.IsTrue( store.ApplyState( scope, accepted ) );

		Assert.IsFalse( store.ApplyState( scope, State( firstConnection, 4, 9, character ) ) );
		Assert.IsFalse( store.ApplyState( scope, State( ConnectionEpoch.New(), 5, 11, CharacterId.New() ) ) );
		Assert.IsFalse( store.ApplyState( scope, State( firstConnection, 4, 11, CharacterId.New() ) ) );

		Assert.AreSame( accepted, store.State );
		Assert.AreEqual( "Accepted", store.Inventories[0].Title );
	}

	[TestMethod]
	public void SnapshotRejectsMismatchedPrivateCharacterAndDuplicateInventories()
	{
		var connection = ConnectionEpoch.New();
		var character = CharacterId.New();
		Assert.ThrowsExactly<ArgumentException>( () => new ClientStateSnapshot(
			new ClientStateEpoch( connection, 1, 1 ),
			Public( character ),
			Private( CharacterId.New() ),
			PlayerRosterSnapshot.Empty,
			null,
			Array.Empty<InventorySnapshot>(),
			null ) );

		var inventory = Inventory();
		Assert.ThrowsExactly<ArgumentException>( () => new ClientStateSnapshot(
			new ClientStateEpoch( connection, 1, 1 ),
			Public( character ),
			Private( character ),
			PlayerRosterSnapshot.Empty,
			null,
			new[] { inventory, Inventory( "Duplicate", inventory.InventoryId ) },
			null ) );
	}

	[TestMethod]
	public void RosterAndSchemaViewsAreImmutableAtomicAndProtectedByTheSameEpoch()
	{
		var connection = ConnectionEpoch.New();
		var character = CharacterId.New();
		var rosterFields = new Dictionary<string, SnapshotValue>( StringComparer.Ordinal )
		{
			["name"] = SnapshotValue.String( "Alyx" )
		};
		var rosterRows = new List<PlayerRosterRowSnapshot>
		{
			new( ConnectionId.New(), character, rosterFields )
		};
		var roster = new PlayerRosterSnapshot( 4, rosterRows );
		var panelFields = new Dictionary<string, SnapshotValue>( StringComparer.Ordinal )
		{
			["title"] = SnapshotValue.String( "Permit Vendor" )
		};
		var row = new Dictionary<string, SnapshotValue>( StringComparer.Ordinal )
		{
			["stock"] = SnapshotValue.Integer( 3 )
		};
		var schemaView = new SchemaViewSnapshot( "vendor", 8, panelFields, new[] { row } );
		var accepted = State(
			connection, 1, 1, character,
			roster: roster,
			schemaViews: new[] { schemaView } );

		rosterFields["name"] = SnapshotValue.String( "Mutated" );
		rosterRows.Clear();
		panelFields["title"] = SnapshotValue.String( "Mutated" );
		row["stock"] = SnapshotValue.Integer( 0 );
		var store = new HexClientStore();
		var scope = Establish( store, connection );
		Assert.IsTrue( store.ApplyState( scope, accepted ) );

		Assert.AreEqual( "Alyx", store.Roster!.Rows[0].Fields["name"].StringValue );
		Assert.AreEqual( "Permit Vendor", store.SchemaViews!["vendor"].Fields["title"].StringValue );
		Assert.AreEqual( 3L, store.SchemaViews["vendor"].Rows[0]["stock"].IntegerValue );

		var lateRoster = new PlayerRosterSnapshot( 5 );
		Assert.IsFalse( store.ApplyState( scope, State(
			connection, 1, 1, character,
			roster: lateRoster,
			schemaViews: Array.Empty<SchemaViewSnapshot>() ) ) );
		Assert.AreSame( roster, store.Roster );
		Assert.IsTrue( store.SchemaViews.ContainsKey( "vendor" ) );
	}

	[TestMethod]
	public void AtomicStateRejectsDuplicatePanelIds()
	{
		var character = CharacterId.New();
		var view = new SchemaViewSnapshot( "civic", 1 );
		Assert.ThrowsExactly<ArgumentException>( () => State(
			ConnectionEpoch.New(), 1, 1, character,
			roster: PlayerRosterSnapshot.Empty,
			schemaViews: new[] { view, new SchemaViewSnapshot( "civic", 2 ) } ) );
	}

	[TestMethod]
	public void BeginSessionIsInstanceScopedAndIndependentOfListenHostState()
	{
		var hostProcessStore = new HexClientStore();
		var clientScopeStore = new HexClientStore();
		var hostCharacter = CharacterId.New();
		var hostConnection = ConnectionEpoch.New();
		var hostScope = Establish( hostProcessStore, hostConnection );
		hostProcessStore.ApplyState( hostScope, State(
			hostConnection, 1, 1, hostCharacter, new[] { Inventory( "Host inventory" ) } ) );

		var clientNonce = ClientSessionNonce.New();
		clientScopeStore.PrepareSession( clientNonce );

		Assert.AreEqual( ClientLifecycleState.CharacterActive, hostProcessStore.Lifecycle );
		Assert.HasCount( 1, hostProcessStore.Inventories );
		Assert.AreEqual( ClientLifecycleState.Disconnected, clientScopeStore.Lifecycle );
		Assert.IsNull( clientScopeStore.State );
		Assert.AreEqual( 1L, clientScopeStore.Version );
		Assert.IsEmpty( typeof(HexClientStore).GetFields(
			System.Reflection.BindingFlags.Static |
			System.Reflection.BindingFlags.Public |
			System.Reflection.BindingFlags.NonPublic) );
	}

	[TestMethod]
	public void OutOfOrderUniqueChatDeliveriesMergeWithoutLoweringRevisionOrDuplicatingMessages()
	{
		var store = new HexClientStore();
		var connection = ConnectionEpoch.New();
		var scope = Establish( store, connection );
		var epoch = new ChatDeliveryEpoch( connection, 1 );
		Assert.IsTrue( store.ApplyState( scope, State( connection, 1, 1, CharacterId.New() ) ) );
		var first = Message( "first", DateTimeOffset.UnixEpoch );
		var second = Message( "second", DateTimeOffset.UnixEpoch );
		var third = Message( "third", DateTimeOffset.UnixEpoch );
		var version = store.Version;

		store.ReplaceChat( scope, new ChatSnapshot( epoch, 4, new[] { first } ) );
		store.ReplaceChat( scope, new ChatSnapshot( epoch, 6, new[] { third } ) );
		store.ReplaceChat( scope, new ChatSnapshot( epoch, 5, new[] { second } ) );
		store.ReplaceChat( scope, new ChatSnapshot( epoch, 3, new[] { first } ) );

		Assert.AreEqual( 6L, store.Chat!.Revision );
		CollectionAssert.AreEqual( new[] { "first", "second", "third" },
			store.Chat.Messages.Select( message => message.Text ).ToArray() );
		Assert.AreEqual( version + 3, store.Version,
			"A delayed unique delivery must append; a delayed duplicate must not publish." );
	}

	[TestMethod]
	public void SameRevisionChunksMergeAndRetentionKeepsNewestBoundedHistory()
	{
		var store = new HexClientStore();
		var connection = ConnectionEpoch.New();
		var scope = Establish( store, connection );
		var epoch = new ChatDeliveryEpoch( connection, 1 );
		Assert.IsTrue( store.ApplyState( scope, State( connection, 1, 1, CharacterId.New() ) ) );
		var messages = Enumerable.Range( 0, HexClientStore.MaximumRetainedChatMessages + 25 )
			.Select( index => Message( index.ToString(), DateTimeOffset.UnixEpoch.AddSeconds( index ) ) )
			.ToArray();

		store.ReplaceChat( scope, new ChatSnapshot( epoch, 10, messages.Take( 150 ) ) );
		store.ReplaceChat( scope, new ChatSnapshot( epoch, 10, messages.Skip( 150 ) ) );

		Assert.AreEqual( 10L, store.Chat!.Revision );
		Assert.HasCount( HexClientStore.MaximumRetainedChatMessages, store.Chat.Messages );
		Assert.AreEqual( "25", store.Chat.Messages[0].Text );
		Assert.AreEqual( "224", store.Chat.Messages[^1].Text );
	}

	[TestMethod]
	public void ReceiverStoresRemainIsolatedWhenHostPublishesDifferentRecipientSnapshots()
	{
		var firstReceiver = new HexClientStore();
		var secondReceiver = new HexClientStore();
		var firstConnection = ConnectionEpoch.New();
		var secondConnection = ConnectionEpoch.New();
		var firstScope = Establish( firstReceiver, firstConnection );
		var secondScope = Establish( secondReceiver, secondConnection );
		var firstEpoch = new ChatDeliveryEpoch( firstConnection, 1 );
		var secondEpoch = new ChatDeliveryEpoch( secondConnection, 1 );
		Assert.IsTrue( firstReceiver.ApplyState( firstScope, State( firstConnection, 1, 1, CharacterId.New() ) ) );
		Assert.IsTrue( secondReceiver.ApplyState( secondScope, State( secondConnection, 1, 1, CharacterId.New() ) ) );
		firstReceiver.ReplaceChat( firstScope, new ChatSnapshot( firstEpoch, 1, new[] { Message( "first-only", DateTimeOffset.UnixEpoch ) } ) );
		secondReceiver.ReplaceChat( secondScope, new ChatSnapshot( secondEpoch, 1, new[] { Message( "second-only", DateTimeOffset.UnixEpoch ) } ) );
		firstReceiver.ReplaceChat( firstScope, new ChatSnapshot( firstEpoch, 2, new[] { Message( "first-next", DateTimeOffset.UnixEpoch.AddSeconds( 1 ) ) } ) );

		CollectionAssert.AreEqual( new[] { "first-only", "first-next" },
			firstReceiver.Chat!.Messages.Select( message => message.Text ).ToArray() );
		CollectionAssert.AreEqual( new[] { "second-only" },
			secondReceiver.Chat!.Messages.Select( message => message.Text ).ToArray() );
	}

	[TestMethod]
	public void PriorCharacterAndConnectionEpochDeliveriesCannotEnterTheCurrentView()
	{
		var store = new HexClientStore();
		var connection = ConnectionEpoch.New();
		var scope = Establish( store, connection );
		var firstCharacter = CharacterId.New();
		var secondCharacter = CharacterId.New();
		Assert.IsTrue( store.ApplyState( scope, State( connection, 1, 1, firstCharacter ) ) );
		store.ReplaceChat( scope, new ChatSnapshot(
			new ChatDeliveryEpoch( connection, 1 ), 8,
			new[] { Message( "first-character", DateTimeOffset.UnixEpoch ) } ) );
		Assert.IsTrue( store.ApplyState( scope, State( connection, 2, 2, secondCharacter ) ) );
		var version = store.Version;

		store.ReplaceChat( scope, new ChatSnapshot(
			new ChatDeliveryEpoch( connection, 1 ), 100,
			new[] { Message( "delayed-character", DateTimeOffset.UnixEpoch ) } ) );
		store.ReplaceChat( scope, new ChatSnapshot(
			new ChatDeliveryEpoch( ConnectionEpoch.New(), 2 ), 101,
			new[] { Message( "delayed-connection", DateTimeOffset.UnixEpoch ) } ) );

		Assert.IsNull( store.Chat );
		Assert.AreEqual( version, store.Version );

		var current = new ChatSnapshot(
			new ChatDeliveryEpoch( connection, 2 ), 1,
			new[] { Message( "current", DateTimeOffset.UnixEpoch ) } );
		store.ReplaceChat( scope, current );
		Assert.AreSame( current, store.Chat );
		store.ClearSession();
		var clearedVersion = store.Version;
		store.ReplaceChat( scope, current );
		Assert.IsNull( store.Chat );
		Assert.AreEqual( clearedVersion, store.Version );
	}

	[TestMethod]
	public void ClearAndReconnectRejectEveryPacketFromThePriorNonceAndHello()
	{
		var store = new HexClientStore();
		var oldConnection = ConnectionEpoch.New();
		var oldScope = Establish( store, oldConnection );
		Assert.IsTrue( store.ApplyState( oldScope, State( oldConnection, 1, 1, CharacterId.New() ) ) );
		store.ClearSession();

		var newNonce = ClientSessionNonce.New();
		var newScope = new ClientSessionScope( newNonce, ConnectionEpoch.New() );
		store.PrepareSession( newNonce );
		Assert.IsFalse( store.AcceptHello( new ClientSessionHello( oldScope ) ) );
		Assert.IsTrue( store.AcceptHello( new ClientSessionHello( newScope ) ) );
		Assert.IsFalse( store.Accepts( oldScope ), "Delayed command results are gated by the same exact scope." );

		Assert.IsFalse( store.ApplyState( oldScope, State( oldConnection, 2, 2, CharacterId.New() ) ) );
		Assert.IsFalse( store.ReplaceCharacterList( oldScope, new CharacterListSnapshot( 99, Array.Empty<CharacterSummarySnapshot>() ) ) );
		Assert.IsFalse( store.ReplaceChat( oldScope, new ChatSnapshot(
			new ChatDeliveryEpoch( oldConnection, 1 ), 99, Array.Empty<ChatMessageSnapshot>() ) ) );
		Assert.IsNull( store.State );
		Assert.IsNull( store.CharacterList );
		Assert.IsNull( store.Chat );
	}

	[TestMethod]
	public void ExactDuplicateHelloIsAnIdempotentNoOpButOtherScopesAreRejected()
	{
		var store = new HexClientStore();
		var scope = Establish( store, ConnectionEpoch.New() );
		var version = store.Version;

		Assert.IsTrue( store.AcceptHello( new ClientSessionHello( scope ) ) );
		Assert.AreEqual( version, store.Version );
		Assert.AreEqual( scope, store.Scope );

		Assert.IsFalse( store.AcceptHello( new ClientSessionHello(
			new ClientSessionScope( scope.Nonce, ConnectionEpoch.New() ) ) ) );
		Assert.IsFalse( store.AcceptHello( new ClientSessionHello(
			new ClientSessionScope( ClientSessionNonce.New(), scope.Connection ) ) ) );
		Assert.AreEqual( version + 2, store.Version );
		Assert.AreEqual( scope, store.Scope );
	}

	[TestMethod]
	public void SessionRevocationIsExactScopedAndClearsAllPublishedState()
	{
		var store = new HexClientStore();
		var connection = ConnectionEpoch.New();
		var scope = Establish( store, connection );
		var character = CharacterId.New();
		Assert.IsTrue( store.ApplyState( scope, State( connection, 1, 1, character ) ) );
		Assert.IsTrue( store.ReplaceCharacterList(
			scope,
			new CharacterListSnapshot( 1, Array.Empty<CharacterSummarySnapshot>() ) ) );

		var stale = new ClientSessionScope( ClientSessionNonce.New(), scope.Connection );
		Assert.IsFalse( store.RevokeSession( stale ) );
		Assert.AreEqual( scope, store.Scope );
		Assert.IsNotNull( store.State );

		Assert.IsTrue( store.RevokeSession( scope ) );
		Assert.AreEqual( ClientLifecycleState.Disconnected, store.Lifecycle );
		Assert.IsNull( store.Scope );
		Assert.IsNull( store.State );
		Assert.IsNull( store.CharacterList );
		Assert.IsNull( store.Chat );
		Assert.IsFalse( store.RevokeSession( scope ) );
	}

	private static ClientSessionScope Establish( HexClientStore store, ConnectionEpoch connection )
	{
		var nonce = ClientSessionNonce.New();
		var scope = new ClientSessionScope( nonce, connection );
		store.PrepareSession( nonce );
		Assert.IsTrue( store.AcceptHello( new ClientSessionHello( scope ) ) );
		return scope;
	}

	private static ClientStateSnapshot State(
		ConnectionEpoch connection,
		long characterEpoch,
		long revision,
		CharacterId? characterId,
		IEnumerable<InventorySnapshot>? inventories = null,
		ActionProgressSnapshot? action = null,
		PlayerRosterSnapshot? roster = null,
		IEnumerable<SchemaViewSnapshot>? schemaViews = null ) => new(
		new ClientStateEpoch( connection, characterEpoch, revision ),
		Public( characterId ),
		characterId is null ? null : Private( characterId.Value ),
		roster ?? PlayerRosterSnapshot.Empty,
		schemaViews,
		inventories,
		action );

	private static PlayerPublicSnapshot Public( CharacterId? characterId ) => new(
		ConnectionId.New(),
		7656119,
		"Player",
		characterId,
		characterId is null ? string.Empty : "Alyx Vance",
		characterId is null ? string.Empty : "Description",
		characterId is null ? null : new DefinitionId( "citizen_model" ),
		characterId is null ? null : new FactionId( "citizen" ),
		null,
		false,
		false );

	[TestMethod]
	public void OwnerFacingCharacterNameDoesNotBecomeTheDefaultReplicatedLabel()
	{
		var snapshot = Public( CharacterId.New() );

		Assert.AreEqual( "Alyx Vance", snapshot.CharacterName );
		Assert.AreEqual( "Unknown citizen", snapshot.ReplicatedCharacterName );
	}

	private static PlayerPrivateSnapshot Private( CharacterId characterId ) => new(
		characterId,
		100,
		InventoryId.New(),
		new Dictionary<string, SnapshotValue>( StringComparer.Ordinal )
		{
			["cid"] = SnapshotValue.String( "12345" )
		} );

	private static CharacterListSnapshot CharacterList( CharacterId characterId ) => new(
		1,
		new[]
		{
			new CharacterSummarySnapshot(
				characterId,
				0,
				"Alyx Vance",
				"Description",
				new DefinitionId( "citizen_model" ),
				new FactionId( "citizen" ),
				null,
				DateTimeOffset.UnixEpoch,
				false )
		} );

	private static InventorySnapshot Inventory( string title = "Inventory", InventoryId? id = null ) => new(
		id ?? InventoryId.New(),
		1,
		InventoryViewKind.Main,
		title,
		4,
		4,
		Array.Empty<InventoryItemSnapshot>() );

	private static ChatSnapshot Chat( ChatDeliveryEpoch epoch ) => new(
		epoch,
		1,
		new[]
		{
			new ChatMessageSnapshot(
				Guid.NewGuid(), "ic", CharacterId.New(), "Speaker", "Hello",
				DateTimeOffset.UnixEpoch )
		} );

	private static ChatMessageSnapshot Message( string text, DateTimeOffset sentAt ) => new(
		Guid.NewGuid(), "ic", CharacterId.New(), "Speaker", text, sentAt );

	private static ActionProgressSnapshot Action() => new(
		Guid.NewGuid(),
		new ActionId( "use" ),
		"Using",
		DateTimeOffset.UnixEpoch,
		TimeSpan.FromSeconds( 3 ),
		true );
}