Aetheria/tests/Aetheria.Shared.Tests/BaseStateTests.cs
Meta Vens 27598a464a BaseState: an includeChildren walk keeps its state in locals
AddTransition's graph walk kept its visited, leaf and ignore sets in static
lists, so two Minions built at once on different threads (zones are built in
parallel) walked and cleared each other's state. Soul hit it as "collection
was modified" during parallel test runs. The sets are now the call's own.

BaseStateTests starts sixteen walks together behind a barrier, fifty rounds,
over prebuilt 200-state chains; with the statics restored every run failed
(several hundred of 800 walks wrong or throwing); with locals, clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:12:41 +02:00

72 lines
2.9 KiB
C#

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using Xunit;
// A transition added with includeChildren reaches every state its source can reach. Zones, and so their Minions'
// state graphs, are built on more than one thread at once, so each walk must see only its own graph.
public sealed class BaseStateTests
{
private const int Depth = 200;
// A chain root -> s1 -> ... -> sN, and the exit a transition from root will lead to.
private static (BaseState root, List<BaseState> chain, BaseState exit) Chain()
{
var root = new BaseState(null);
var chain = new List<BaseState>();
var previous = root;
for (var i = 0; i < Depth; i++)
{
var state = new BaseState(null);
previous.AddTransition(state, () => false);
chain.Add(state);
previous = state;
}
return (root, chain, new BaseState(null));
}
// Every state on the chain gains exactly one transition to the exit, and the exit gains none.
private static bool ExitReachesTheWholeChain((BaseState root, List<BaseState> chain, BaseState exit) graph) =>
graph.chain.Prepend(graph.root).All(state => state.Transitions.Count(t => t.TargetState == graph.exit) == 1)
&& graph.exit.Transitions.Count == 0;
private static void AddExit((BaseState root, List<BaseState> chain, BaseState exit) graph) =>
graph.root.AddTransition(graph.exit, () => false, null, true, graph.root);
[Fact]
public void AnIncludeChildrenTransitionReachesTheWholeGraph()
{
var graph = Chain();
AddExit(graph);
Assert.True(ExitReachesTheWholeChain(graph));
}
// Graphs built beforehand, then their walks started together on parallel threads, round after round.
[Fact]
public void GraphWalksOnParallelThreadsSeeOnlyTheirOwnGraph()
{
const int rounds = 50, width = 16;
var wrong = 0;
var threw = 0;
for (var round = 0; round < rounds; round++)
{
var graphs = Enumerable.Range(0, width).Select(_ => Chain()).ToArray();
using var start = new Barrier(width);
var threads = graphs.Select(graph => new Thread(() =>
{
start.SignalAndWait();
try { AddExit(graph); }
catch (Exception) { Interlocked.Increment(ref threw); }
})).ToArray();
foreach (var thread in threads) thread.Start();
foreach (var thread in threads) thread.Join();
wrong += graphs.Count(graph => !ExitReachesTheWholeChain(graph));
}
Assert.True(wrong == 0 && threw == 0, $"of {rounds * width} parallel walks, {wrong} left the wrong graph and {threw} threw");
}
}