Skip to content

Commit c81d283

Browse files
committed
Add test harnesses for the network jobs
RemoteTech.Tests runs the Burst/NativeArray math headlessly under xunit by backing native arrays with Marshal.AllocHGlobal (NativeArena), covering the range/geometry math. RemoteTech.InGameTests is a KSP.Testing plugin with an in-game runner UI that exercises the jobs against real Unity Collections (adjacency, edges, Dijkstra, mark/cone geometry, hashing).
1 parent 2c83574 commit c81d283

15 files changed

Lines changed: 1632 additions & 0 deletions
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
using System;
2+
using System.Collections;
3+
using System.Collections.Generic;
4+
5+
namespace RemoteTech.InGameTests;
6+
7+
/// <summary>
8+
/// MSTest-compatible assertion shim that throws on failure. KSP's test
9+
/// framework (<see cref="KSP.Testing.TestManager"/>) treats a thrown exception
10+
/// as a failed test. Adapted from the sibling BackgroundResourceProcessing
11+
/// test harness.
12+
/// </summary>
13+
public static class Assert
14+
{
15+
public static void AreEqual<T>(T expected, T actual)
16+
{
17+
if (!EqualityComparer<T>.Default.Equals(expected, actual))
18+
throw new Exception($"Assert.AreEqual failed. Expected:<{expected}>. Actual:<{actual}>.");
19+
}
20+
21+
public static void AreEqual<T>(T expected, T actual, string message)
22+
{
23+
if (!EqualityComparer<T>.Default.Equals(expected, actual))
24+
throw new Exception($"Assert.AreEqual failed. Expected:<{expected}>. Actual:<{actual}>. {message}");
25+
}
26+
27+
public static void AreEqual(double expected, double actual, double delta)
28+
{
29+
if (double.IsNaN(expected) || double.IsNaN(actual) || Math.Abs(expected - actual) > delta)
30+
throw new Exception($"Assert.AreEqual failed. Expected:<{expected}>. Actual:<{actual}>. Delta:<{delta}>.");
31+
}
32+
33+
public static void AreEqual(double expected, double actual, double delta, string message)
34+
{
35+
if (double.IsNaN(expected) || double.IsNaN(actual) || Math.Abs(expected - actual) > delta)
36+
throw new Exception($"Assert.AreEqual failed. Expected:<{expected}>. Actual:<{actual}>. Delta:<{delta}>. {message}");
37+
}
38+
39+
public static void AreNotEqual<T>(T expected, T actual)
40+
{
41+
if (EqualityComparer<T>.Default.Equals(expected, actual))
42+
throw new Exception($"Assert.AreNotEqual failed. Expected any value except:<{expected}>.");
43+
}
44+
45+
public static void IsTrue(bool condition)
46+
{
47+
if (!condition) throw new Exception("Assert.IsTrue failed.");
48+
}
49+
50+
public static void IsTrue(bool condition, string message)
51+
{
52+
if (!condition) throw new Exception($"Assert.IsTrue failed. {message}");
53+
}
54+
55+
public static void IsFalse(bool condition)
56+
{
57+
if (condition) throw new Exception("Assert.IsFalse failed.");
58+
}
59+
60+
public static void IsFalse(bool condition, string message)
61+
{
62+
if (condition) throw new Exception($"Assert.IsFalse failed. {message}");
63+
}
64+
65+
public static T ThrowsException<T>(Action action)
66+
where T : Exception
67+
{
68+
try
69+
{
70+
action();
71+
}
72+
catch (T ex)
73+
{
74+
return ex;
75+
}
76+
catch (Exception ex)
77+
{
78+
throw new Exception($"Assert.ThrowsException failed. Expected:<{typeof(T).Name}>. Actual:<{ex.GetType().Name}>.");
79+
}
80+
throw new Exception($"Assert.ThrowsException failed. Expected:<{typeof(T).Name}>. No exception was thrown.");
81+
}
82+
83+
public static void Fail(string message)
84+
{
85+
throw new Exception($"Assert.Fail. {message}");
86+
}
87+
88+
public static void IsNull(object value)
89+
{
90+
if (value != null) throw new Exception($"Assert.IsNull failed. Actual:<{value}>.");
91+
}
92+
93+
public static void IsNotNull(object value)
94+
{
95+
if (value == null) throw new Exception("Assert.IsNotNull failed.");
96+
}
97+
98+
public static void IsNotNull(object value, string message)
99+
{
100+
if (value == null) throw new Exception($"Assert.IsNotNull failed. {message}");
101+
}
102+
103+
public static void AreSame(object expected, object actual)
104+
{
105+
if (!ReferenceEquals(expected, actual))
106+
throw new Exception($"Assert.AreSame failed. Expected:<{expected}>. Actual:<{actual}>.");
107+
}
108+
}
109+
110+
public static class CollectionAssert
111+
{
112+
public static void AreEqual(ICollection expected, ICollection actual)
113+
{
114+
if (expected == null && actual == null) return;
115+
if (expected == null || actual == null)
116+
throw new Exception("CollectionAssert.AreEqual failed. One collection is null.");
117+
if (expected.Count != actual.Count)
118+
throw new Exception($"CollectionAssert.AreEqual failed. Expected count:<{expected.Count}>. Actual count:<{actual.Count}>.");
119+
120+
var e = expected.GetEnumerator();
121+
var a = actual.GetEnumerator();
122+
int index = 0;
123+
while (e.MoveNext() && a.MoveNext())
124+
{
125+
if (!Equals(e.Current, a.Current))
126+
throw new Exception($"CollectionAssert.AreEqual failed at index {index}. Expected:<{e.Current}>. Actual:<{a.Current}>.");
127+
index++;
128+
}
129+
}
130+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using KSP.Testing;
4+
using RemoteTech.Collections;
5+
6+
namespace RemoteTech.InGameTests.Collections;
7+
8+
public class ArrayMapTests : RTTestBase
9+
{
10+
[TestInfo("ArrayMapTests_ListAsSpan_ReturnsOccupiedElements_NotUnusedCapacity")]
11+
public void ListAsSpan_ReturnsOccupiedElements_NotUnusedCapacity()
12+
{
13+
var list = new List<int>(16) { 1, 2, 3 };
14+
var span = list.AsSpan();
15+
16+
Assert.AreEqual(3, span.Length);
17+
Assert.AreEqual(1, span[0]);
18+
Assert.AreEqual(2, span[1]);
19+
Assert.AreEqual(3, span[2]);
20+
}
21+
22+
[TestInfo("ArrayMapTests_Values_AfterRemove_ContainsOnlyLiveEntries")]
23+
public void Values_AfterRemove_ContainsOnlyLiveEntries()
24+
{
25+
var map = new ArrayMap<Guid, string>();
26+
var a = Guid.NewGuid();
27+
var b = Guid.NewGuid();
28+
var c = Guid.NewGuid();
29+
30+
map[a] = "a";
31+
map[b] = "b";
32+
map[c] = "c";
33+
map.Remove(b);
34+
35+
var values = map.Values;
36+
Assert.AreEqual(2, values.Length);
37+
foreach (var value in values)
38+
Assert.IsNotNull(value);
39+
}
40+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
using System;
2+
using KSP.Testing;
3+
using RemoteTech.Collections;
4+
using Unity.Collections;
5+
using Unity.Collections.LowLevel.Unsafe;
6+
using UnityEngine;
7+
8+
namespace RemoteTech.InGameTests.Collections;
9+
10+
public unsafe class SpookyHashTests : RTTestBase
11+
{
12+
[TestInfo("SpookyHashTests_MatchesUnity_ForInt")]
13+
public void MatchesUnity_ForInt()
14+
{
15+
foreach (int value in new[] { 0, 1, -1, 12345, int.MinValue, int.MaxValue })
16+
{
17+
int v = value;
18+
Hash128 expected = default;
19+
HashUtilities.ComputeHash128(ref v, ref expected);
20+
21+
Hash128 actual = default;
22+
SpookyHash.Hash128(&v, sizeof(int), ref actual);
23+
24+
Assert.AreEqual(expected, actual, $"int {value}");
25+
}
26+
}
27+
28+
[TestInfo("SpookyHashTests_MatchesUnity_ForGuidArrays")]
29+
public void MatchesUnity_ForGuidArrays()
30+
{
31+
foreach (int count in new[] { 1, 2, 3, 4, 5, 8, 13 })
32+
{
33+
var arena = new NativeArray<Guid>(count, Allocator.Temp);
34+
for (int i = 0; i < count; i++)
35+
arena[i] = Guid.NewGuid();
36+
37+
int size = count * UnsafeUtility.SizeOf<Guid>();
38+
39+
Hash128 expected = default;
40+
HashUnsafeUtilities.ComputeHash128(NativeArrayUnsafeUtility.GetUnsafePtr(arena), (ulong)size, &expected);
41+
42+
Hash128 actual = default;
43+
SpookyHash.Hash128(NativeArrayUnsafeUtility.GetUnsafePtr(arena), size, ref actual);
44+
45+
Assert.AreEqual(expected, actual, $"{count} guids");
46+
}
47+
}
48+
49+
[TestInfo("SpookyHashTests_MatchesUnity_ForLongBuffer")]
50+
public void MatchesUnity_ForLongBuffer()
51+
{
52+
// Exercises the >=192-byte block (Mix/End) path, not just Short.
53+
//
54+
// We'd normally use a length of 191, but unity's implementation has a bug there that
55+
// makes it incorrect so we use 188.
56+
foreach (int length in new[] { 188, 192, 193, 300, 500 })
57+
{
58+
var buf = new NativeArray<byte>(length, Allocator.Temp);
59+
var rng = new System.Random(length);
60+
for (int i = 0; i < length; i++)
61+
buf[i] = (byte)rng.Next(256);
62+
63+
Hash128 expected = default;
64+
HashUnsafeUtilities.ComputeHash128(NativeArrayUnsafeUtility.GetUnsafePtr(buf), (ulong)length, &expected);
65+
66+
Hash128 actual = default;
67+
SpookyHash.Hash128(NativeArrayUnsafeUtility.GetUnsafePtr(buf), length, ref actual);
68+
69+
Assert.AreEqual(expected, actual, $"length {length}");
70+
}
71+
}
72+
73+
[TestInfo("SpookyHashTests_MatchesUnity_ForChainedSeed")]
74+
public void MatchesUnity_ForChainedSeed()
75+
{
76+
// Mirrors NetworkUpdate.HashPath: hash a small int, then
77+
// feed a guid array using the running hash as the seed for the next call.
78+
int state = 2;
79+
var guids = new NativeArray<Guid>(3, Allocator.Temp);
80+
for (int i = 0; i < guids.Length; i++)
81+
guids[i] = Guid.NewGuid();
82+
int size = guids.Length * UnsafeUtility.SizeOf<Guid>();
83+
84+
Hash128 expected = default;
85+
HashUtilities.ComputeHash128(ref state, ref expected);
86+
HashUnsafeUtilities.ComputeHash128(NativeArrayUnsafeUtility.GetUnsafePtr(guids), (ulong)size, &expected);
87+
88+
Hash128 actual = default;
89+
SpookyHash.Hash128(&state, sizeof(int), ref actual);
90+
SpookyHash.Hash128(NativeArrayUnsafeUtility.GetUnsafePtr(guids), size, ref actual);
91+
92+
Assert.AreEqual(expected, actual);
93+
}
94+
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
using System.Collections.Generic;
2+
using KSP.Testing;
3+
using RemoteTech.Collections;
4+
using RemoteTech.Network;
5+
using RemoteTech.SimpleTypes;
6+
using Unity.Collections;
7+
8+
namespace RemoteTech.InGameTests.Network;
9+
10+
public class BuildAdjacencyJobTests : RTTestBase
11+
{
12+
private static NetworkEdge Edge(int a, int b, double dist, bool valid = true)
13+
=> new NetworkEdge { aIdx = a, bIdx = b, distance = dist, linkType = valid ? LinkType.Omni : LinkType.None };
14+
15+
private static JobNode Node(NodeFlags flags) => new JobNode { flags = flags };
16+
17+
private sealed class Csr
18+
{
19+
public NativeList<IntRange> Ranges;
20+
public NativeList<int> Adjacency;
21+
public NativeList<double> Distances;
22+
23+
public List<(int target, double cost)> Row(int node)
24+
{
25+
var list = new List<(int, double)>();
26+
foreach (int e in Ranges[node])
27+
list.Add((Adjacency[e], Distances[e]));
28+
return list;
29+
}
30+
}
31+
32+
private static Csr Build(JobNode[] nodes, NetworkEdge[] edges, bool relay = false)
33+
{
34+
var nodeArr = new NativeArray<JobNode>(nodes, Allocator.Temp);
35+
var edgeArr = new NativeArray<NetworkEdge>(edges, Allocator.Temp);
36+
37+
var ranges = new NativeList<IntRange>(nodes.Length, Allocator.Temp);
38+
ranges.ResizeUninitialized(nodes.Length);
39+
var adjacency = new NativeList<int>(0, Allocator.Temp);
40+
var distances = new NativeList<double>(0, Allocator.Temp);
41+
42+
var config = new JobConfig { signalRelayEnabled = relay };
43+
NetworkUpdate.ComputeAdjacencyLists(in config, nodeArr, edgeArr, ranges, adjacency, distances);
44+
45+
return new Csr { Ranges = ranges, Adjacency = adjacency, Distances = distances };
46+
}
47+
48+
[TestInfo("BuildAdjacencyJobTests_UndirectedEdge_AppearsOnBothRows")]
49+
public void UndirectedEdge_AppearsOnBothRows()
50+
{
51+
var powered = NodeFlags.Powered | NodeFlags.CanRelay;
52+
var csr = Build(
53+
new[] { Node(powered), Node(powered), Node(powered) },
54+
new[] { Edge(0, 1, 10.0), Edge(1, 2, 20.0) });
55+
56+
CollectionAssert.AreEqual(new[] { (1, 10.0) }, csr.Row(0));
57+
CollectionAssert.AreEqual(new[] { (0, 10.0), (2, 20.0) }, csr.Row(1));
58+
CollectionAssert.AreEqual(new[] { (1, 20.0) }, csr.Row(2));
59+
int total = csr.Ranges[0].Length + csr.Ranges[1].Length + csr.Ranges[2].Length;
60+
Assert.AreEqual(4, total); // two undirected edges -> four directed entries
61+
}
62+
63+
[TestInfo("BuildAdjacencyJobTests_InvalidEdges_AreSkipped")]
64+
public void InvalidEdges_AreSkipped()
65+
{
66+
var powered = NodeFlags.Powered | NodeFlags.CanRelay;
67+
var csr = Build(
68+
new[] { Node(powered), Node(powered) },
69+
new[] { Edge(0, 1, 10.0, valid: false) });
70+
71+
Assert.AreEqual(0, csr.Row(0).Count);
72+
Assert.AreEqual(0, csr.Row(1).Count);
73+
}
74+
75+
[TestInfo("BuildAdjacencyJobTests_UnpoweredEndpoint_MakesEdgeNonTraversable")]
76+
public void UnpoweredEndpoint_MakesEdgeNonTraversable()
77+
{
78+
var csr = Build(
79+
new[] { Node(NodeFlags.Powered), Node(NodeFlags.None) },
80+
new[] { Edge(0, 1, 10.0) });
81+
82+
Assert.AreEqual(0, csr.Row(0).Count);
83+
Assert.AreEqual(0, csr.Row(1).Count);
84+
}
85+
86+
[TestInfo("BuildAdjacencyJobTests_RelayEnabled_RequiresBothEndpointsRelayCapable")]
87+
public void RelayEnabled_RequiresBothEndpointsRelayCapable()
88+
{
89+
// Node 1 is powered but not relay-capable.
90+
var nodes = new[]
91+
{
92+
Node(NodeFlags.Powered | NodeFlags.CanRelay),
93+
Node(NodeFlags.Powered),
94+
};
95+
var edges = new[] { Edge(0, 1, 10.0) };
96+
97+
var off = Build(nodes, edges, relay: false);
98+
Assert.AreEqual(1, off.Row(0).Count);
99+
100+
var on = Build(nodes, edges, relay: true);
101+
Assert.AreEqual(0, on.Row(0).Count);
102+
Assert.AreEqual(0, on.Row(1).Count);
103+
}
104+
105+
[TestInfo("BuildAdjacencyJobTests_VesselConnected_FlagsOnlyNonEmptyRanges")]
106+
public void VesselConnected_FlagsOnlyNonEmptyRanges()
107+
{
108+
var ranges = new NativeArray<IntRange>(new[] { new IntRange(0, 1), new IntRange(1, 0), new IntRange(1, 2) }, Allocator.Temp);
109+
var connected = new NativeBitArray(3, Allocator.Temp);
110+
111+
NetworkUpdate.ComputeVesselConnected(ranges, connected);
112+
113+
Assert.IsTrue(connected.IsSet(0));
114+
Assert.IsFalse(connected.IsSet(1));
115+
Assert.IsTrue(connected.IsSet(2));
116+
}
117+
}

0 commit comments

Comments
 (0)