Skip to content

Commit b380e73

Browse files
MatuxGGclaude
andcommitted
feat: add unit tests for pure logic
Introduces a GLMod.Tests xUnit project covering the parts of the codebase that don't touch IL2CPP/Among Us types: - Entities: GLGame, GLPlayer, GLPosition (constructors, mutations, turn-aware position routing, GetId error cases) - Enums: SabotageType / GameMapType extensions - ServiceManager: enable/disable/exists state transitions - GameConstants: action-prefix invariants and support-id alphabet 58 tests, all passing. While writing the alphabet test, surfaced a discrepancy between CLAUDE.md §6 (claimed "no 0, no O") and the actual SUPPORT_ID_CHARS (only '0' is excluded); fixed the spec to match. Documents the test scope and CI status in CLAUDE.md §11. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 78c736d commit b380e73

7 files changed

Lines changed: 539 additions & 2 deletions

File tree

CLAUDE.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ Reactor, Coms, Lights, O2
228228
| Constant | Value |
229229
|-------------------------------|----------------------------------------------|
230230
| `API_ENDPOINT` | `https://goodloss.fr/api` |
231-
| `SUPPORT_ID_CHARS` | `A-Za-z1-9` (no `0`, no `O`) |
231+
| `SUPPORT_ID_CHARS` | `A-Za-z1-9` (no `0`) |
232232
| `SUPPORT_ID_LENGTH` | `10` |
233233
| `RPC_SYNC_TIMEOUT` | `5.0f` seconds |
234234
| `BACKGROUND_POLLING_INTERVAL` | `0.5f` seconds |
@@ -327,13 +327,19 @@ Base: `https://goodloss.fr/api`
327327
- **Recommended IDE**: Visual Studio 2022.
328328
- **SDK**: .NET 6.0.
329329
- `dotnet build --configuration Debug` at the repo root.
330+
- `dotnet test GLMod.Tests/GLMod.Tests.csproj --configuration Debug` to run the unit-test suite.
330331
- An `AfterTargets="Build"` step copies `GLMod.dll` to `Among Us/BepInEx/plugins/` (Debug only) — see [GLMod/GLMod.csproj](GLMod/GLMod.csproj).
331332

332333
### 11.2 CI
333334
- **GitHub Actions**: [.github/workflows/main.yml](.github/workflows/main.yml).
334335
- Trigger: push & PR on `main`.
335336
- Single job: `dotnet restore` + `dotnet build --configuration Debug`.
336-
- No automated tests at this point.
337+
- Unit tests (xUnit, in [GLMod.Tests/](GLMod.Tests/)) are runnable locally; the CI workflow does not invoke `dotnet test` yet.
338+
339+
### 11.4 Testing
340+
- Test framework: xUnit ([GLMod.Tests/GLMod.Tests.csproj](GLMod.Tests/GLMod.Tests.csproj)).
341+
- Scope: pure logic — entities (`GLGame`, `GLPlayer`, `GLPosition`), enums (`SabotageType`, `GameMapType`), `ServiceManager`, and `GameConstants` invariants. Anything that touches IL2CPP / Among Us types stays untestable until further decoupling.
342+
- Add new tests for any new pure-logic surface. Refactor toward injectable abstractions before adding tests that would otherwise require IL2CPP mocking.
337343

338344
### 11.3 Release
339345
- Build driven by [build.cake](build.cake) (Cake).
@@ -358,6 +364,12 @@ GLMod/ # Repo root
358364
│ └── dev.MD # Integration guide for third-party modders.
359365
├── Among Us/ # (gitignored) Local install used for debugging.
360366
│ └── BepInEx/ # Core + interop DLLs (referenced by csproj).
367+
├── GLMod.Tests/ # xUnit test project for pure-logic types.
368+
│ ├── GLMod.Tests.csproj
369+
│ ├── EntityTests.cs
370+
│ ├── EnumTests.cs
371+
│ ├── GameConstantsTests.cs
372+
│ └── ServiceManagerTests.cs
361373
└── GLMod/ # C# project.
362374
├── GLMod.csproj # Project definition, mod version, targeted Among Us version.
363375
├── GLMod.cs # Plugin entry point + static facade.

GLMod.Tests/EntityTests.cs

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
using System.Collections.Generic;
2+
using GLMod.GLEntities;
3+
using Xunit;
4+
5+
namespace GLMod.Tests
6+
{
7+
public class GLPositionTests
8+
{
9+
[Fact]
10+
public void Constructor_AssignsAllFields()
11+
{
12+
var pos = new GLPosition(1.5f, -2.25f, "1234567890", "5");
13+
14+
Assert.Equal(1.5f, pos.x);
15+
Assert.Equal(-2.25f, pos.y);
16+
Assert.Equal("1234567890", pos.triggerTime);
17+
Assert.Equal("5", pos.turn);
18+
}
19+
}
20+
21+
public class GLPlayerTests
22+
{
23+
[Fact]
24+
public void DefaultConstructor_InitializesCountersToZero()
25+
{
26+
var player = new GLPlayer();
27+
28+
Assert.Equal("0", player.tasks);
29+
Assert.Equal("0", player.tasksDead);
30+
Assert.Equal("0", player.tasksMax);
31+
Assert.Equal("0", player.win);
32+
Assert.Empty(player.positions);
33+
}
34+
35+
[Fact]
36+
public void AddTasks_IncrementsTaskCounter()
37+
{
38+
var player = new GLPlayer();
39+
40+
player.AddTasks();
41+
player.AddTasks();
42+
player.AddTasks();
43+
44+
Assert.Equal("3", player.tasks);
45+
}
46+
47+
[Fact]
48+
public void AddTasksDead_IncrementsDeadTaskCounter()
49+
{
50+
var player = new GLPlayer();
51+
52+
player.AddTasksDead();
53+
player.AddTasksDead();
54+
55+
Assert.Equal("2", player.tasksDead);
56+
}
57+
58+
[Fact]
59+
public void SetTasksMax_StoresValueAsString()
60+
{
61+
var player = new GLPlayer();
62+
63+
player.SetTasksMax(42);
64+
65+
Assert.Equal("42", player.tasksMax);
66+
}
67+
68+
[Fact]
69+
public void SetWin_MarksPlayerAsWinner()
70+
{
71+
var player = new GLPlayer();
72+
73+
player.SetWin();
74+
75+
Assert.Equal("1", player.win);
76+
}
77+
78+
[Fact]
79+
public void AddPosition_RoundsCoordinatesAndStoresTurn()
80+
{
81+
var player = new GLPlayer();
82+
83+
player.AddPosition(1.234567f, 2.985654f, "ts", "7");
84+
85+
Assert.Single(player.positions);
86+
Assert.Equal(1.23f, player.positions[0].x);
87+
Assert.Equal(2.99f, player.positions[0].y);
88+
Assert.Equal("ts", player.positions[0].triggerTime);
89+
Assert.Equal("7", player.positions[0].turn);
90+
}
91+
92+
[Fact]
93+
public void SetColor_StoresColor()
94+
{
95+
var player = new GLPlayer();
96+
97+
player.SetColor("Red");
98+
99+
Assert.Equal("Red", player.color);
100+
}
101+
}
102+
103+
public class GLGameTests
104+
{
105+
private static GLGame NewGame(bool ranked = false)
106+
=> new GLGame("ABCDEF", "Polus", ranked, "Vanilla");
107+
108+
[Fact]
109+
public void Constructor_RankedFalse_StoresZero()
110+
{
111+
var game = NewGame(ranked: false);
112+
113+
Assert.Equal("0", game.ranked);
114+
Assert.Equal("ABCDEF", game.code);
115+
Assert.Equal("Polus", game.map);
116+
Assert.Equal("Vanilla", game.modName);
117+
Assert.Equal("1", game.turns);
118+
Assert.Equal("", game.winner);
119+
Assert.Empty(game.players);
120+
Assert.Empty(game.actions);
121+
}
122+
123+
[Fact]
124+
public void Constructor_RankedTrue_StoresOne()
125+
{
126+
var game = NewGame(ranked: true);
127+
Assert.Equal("1", game.ranked);
128+
}
129+
130+
[Fact]
131+
public void SetRanked_FlipsValue()
132+
{
133+
var game = NewGame(ranked: false);
134+
135+
game.SetRanked(true);
136+
Assert.Equal("1", game.ranked);
137+
138+
game.SetRanked(false);
139+
Assert.Equal("0", game.ranked);
140+
}
141+
142+
[Fact]
143+
public void SetIdAndGetId_RoundTrip()
144+
{
145+
var game = NewGame();
146+
147+
game.SetId(42);
148+
149+
Assert.Equal("42", game.id);
150+
Assert.Equal(42, game.GetId());
151+
}
152+
153+
[Fact]
154+
public void GetId_WhenIdIsNotInteger_ThrowsInvalidOperation()
155+
{
156+
var game = NewGame();
157+
game.id = "not-an-int";
158+
159+
var ex = Assert.Throws<System.InvalidOperationException>(() => game.GetId());
160+
Assert.Contains("not-an-int", ex.Message);
161+
}
162+
163+
[Fact]
164+
public void GetId_WhenIdIsNull_ThrowsInvalidOperation()
165+
{
166+
var game = NewGame();
167+
168+
var ex = Assert.Throws<System.InvalidOperationException>(() => game.GetId());
169+
Assert.Contains("<null>", ex.Message);
170+
}
171+
172+
[Fact]
173+
public void AddPlayer_AppendsPlayerWithDefaults()
174+
{
175+
var game = NewGame();
176+
177+
game.AddPlayer("login1", "Alice", "Sheriff", "Crewmate", "Red");
178+
179+
Assert.Single(game.players);
180+
var p = game.players[0];
181+
Assert.Equal("login1", p.login);
182+
Assert.Equal("Alice", p.playerName);
183+
Assert.Equal("Sheriff", p.role);
184+
Assert.Equal("Crewmate", p.team);
185+
Assert.Equal("Red", p.color);
186+
Assert.Equal("0", p.win);
187+
}
188+
189+
[Fact]
190+
public void SetWinner_MarksPlayersOnWinningTeam()
191+
{
192+
var game = NewGame();
193+
game.AddPlayer(null, "Alice", "Crewmate", "Crewmate", "Red");
194+
game.AddPlayer(null, "Bob", "Impostor", "Impostor", "Blue");
195+
game.AddPlayer(null, "Carol", "Engineer", "Crewmate", "Green");
196+
197+
game.SetWinner("Crewmate");
198+
199+
Assert.Equal("Crewmate", game.winner);
200+
Assert.Equal("1", game.players[0].win);
201+
Assert.Equal("0", game.players[1].win);
202+
Assert.Equal("1", game.players[2].win);
203+
}
204+
205+
[Fact]
206+
public void SetWinners_AcceptsMultipleTeams()
207+
{
208+
var game = NewGame();
209+
game.AddPlayer(null, "Alice", "Crewmate", "Crewmate", "Red");
210+
game.AddPlayer(null, "Bob", "Lover", "Love", "Blue");
211+
game.AddPlayer(null, "Carol", "Impostor", "Impostor", "Green");
212+
213+
game.SetWinners(new List<string> { "Crewmate", "Love" });
214+
215+
Assert.Equal("Crewmate", game.winner);
216+
Assert.Equal("1", game.players[0].win);
217+
Assert.Equal("1", game.players[1].win);
218+
Assert.Equal("0", game.players[2].win);
219+
}
220+
221+
[Fact]
222+
public void AddTurn_BelowThousand_Shifts()
223+
{
224+
var game = NewGame();
225+
Assert.Equal("1", game.turns);
226+
227+
game.AddTurn();
228+
229+
// 1 + 1000 = 1001 (meeting turn marker)
230+
Assert.Equal("1001", game.turns);
231+
}
232+
233+
[Fact]
234+
public void AddTurn_AtMeetingMarker_RollsBackToSequence()
235+
{
236+
var game = NewGame();
237+
game.turns = "1001";
238+
239+
game.AddTurn();
240+
241+
// 1001 - 999 = 2
242+
Assert.Equal("2", game.turns);
243+
}
244+
245+
[Fact]
246+
public void AddAction_AppendsActionWithCurrentTurn()
247+
{
248+
var game = NewGame();
249+
250+
game.AddAction("Alice", "Bob", "killed");
251+
252+
Assert.Single(game.actions);
253+
Assert.Equal("1", game.actions[0].turn);
254+
Assert.Equal("Alice", game.actions[0].source);
255+
Assert.Equal("Bob", game.actions[0].target);
256+
Assert.Equal("killed", game.actions[0].action);
257+
Assert.False(string.IsNullOrEmpty(game.actions[0].triggerTimeMs));
258+
}
259+
260+
[Fact]
261+
public void AddPosition_RoutesToCorrectPlayer_AndCarriesTurn()
262+
{
263+
var game = NewGame();
264+
game.AddPlayer(null, "Alice", "Sheriff", "Crewmate", "Red");
265+
game.turns = "3";
266+
267+
game.AddPosition("Alice", 5f, 6f, "ts");
268+
269+
Assert.Single(game.players[0].positions);
270+
Assert.Equal("3", game.players[0].positions[0].turn);
271+
Assert.Equal("ts", game.players[0].positions[0].triggerTime);
272+
}
273+
}
274+
}

GLMod.Tests/EnumTests.cs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
using GLMod.Enums;
2+
using Xunit;
3+
4+
namespace GLMod.Tests
5+
{
6+
public class SabotageTypeTests
7+
{
8+
[Theory]
9+
[InlineData(SabotageType.Reactor, "Reactor")]
10+
[InlineData(SabotageType.Coms, "Coms")]
11+
[InlineData(SabotageType.Lights, "Lights")]
12+
[InlineData(SabotageType.O2, "O2")]
13+
public void ToActionString_ReturnsEnumName(SabotageType value, string expected)
14+
{
15+
Assert.Equal(expected, value.ToActionString());
16+
}
17+
18+
[Theory]
19+
[InlineData("Reactor", SabotageType.Reactor)]
20+
[InlineData("Coms", SabotageType.Coms)]
21+
[InlineData("Lights", SabotageType.Lights)]
22+
[InlineData("O2", SabotageType.O2)]
23+
public void TryParse_KnownValues_ReturnsTrue(string input, SabotageType expected)
24+
{
25+
bool ok = SabotageTypeExtensions.TryParse(input, out var result);
26+
27+
Assert.True(ok);
28+
Assert.Equal(expected, result);
29+
}
30+
31+
[Theory]
32+
[InlineData("")]
33+
[InlineData(null)]
34+
[InlineData("Unknown")]
35+
[InlineData("reactor")]
36+
public void TryParse_UnknownValue_ReturnsFalse(string input)
37+
{
38+
bool ok = SabotageTypeExtensions.TryParse(input, out _);
39+
Assert.False(ok);
40+
}
41+
}
42+
43+
public class GameMapTypeTests
44+
{
45+
[Theory]
46+
[InlineData((byte)0, GameMapType.TheSkeld)]
47+
[InlineData((byte)1, GameMapType.MiraHQ)]
48+
[InlineData((byte)2, GameMapType.Polus)]
49+
[InlineData((byte)4, GameMapType.Airship)]
50+
[InlineData((byte)5, GameMapType.TheFungle)]
51+
public void FromMapId_KnownIds_MapToType(byte id, GameMapType expected)
52+
{
53+
Assert.Equal(expected, GameMapTypeExtensions.FromMapId(id));
54+
}
55+
56+
[Theory]
57+
[InlineData((byte)3)]
58+
[InlineData((byte)42)]
59+
[InlineData((byte)255)]
60+
public void FromMapId_UnknownId_ReturnsUnknown(byte id)
61+
{
62+
Assert.Equal(GameMapType.Unknown, GameMapTypeExtensions.FromMapId(id));
63+
}
64+
65+
[Theory]
66+
[InlineData(GameMapType.TheSkeld, "The Skeld")]
67+
[InlineData(GameMapType.MiraHQ, "MiraHQ")]
68+
[InlineData(GameMapType.Polus, "Polus")]
69+
[InlineData(GameMapType.Airship, "Airship")]
70+
[InlineData(GameMapType.TheFungle, "The Fungle")]
71+
[InlineData(GameMapType.Unknown, "Unknown")]
72+
public void ToDisplayName_AllKnownTypes(GameMapType type, string expected)
73+
{
74+
Assert.Equal(expected, type.ToDisplayName());
75+
}
76+
}
77+
}

0 commit comments

Comments
 (0)