/ GUNRPG.Tests / OperatorModeTests.cs
OperatorModeTests.cs
  1  using GUNRPG.Application.Operators;
  2  using GUNRPG.Core.Operators;
  3  using GUNRPG.Infrastructure.Persistence;
  4  using LiteDB;
  5  using Xunit;
  6  
  7  namespace GUNRPG.Tests;
  8  
  9  /// <summary>
 10  /// Tests for Operator Mode enforcement and Infil/Exfil workflow.
 11  /// Validates the boundary between Base mode (operator management) and Infil mode (combat).
 12  /// </summary>
 13  public class OperatorModeTests : IDisposable
 14  {
 15      private readonly LiteDatabase _database;
 16      private readonly IOperatorEventStore _eventStore;
 17      private readonly OperatorExfilService _service;
 18  
 19      public OperatorModeTests()
 20      {
 21          var mapper = new BsonMapper();
 22          mapper.Entity<OperatorEventDocument>().Id(x => x.Id);
 23          _database = new LiteDatabase(":memory:", mapper);
 24          _eventStore = new LiteDbOperatorEventStore(_database);
 25          _service = new OperatorExfilService(_eventStore);
 26      }
 27  
 28      public void Dispose()
 29      {
 30          _database?.Dispose();
 31      }
 32  
 33      [Fact]
 34      public async Task NewOperator_StartsInBaseMode()
 35      {
 36          // Arrange
 37          var createResult = await _service.CreateOperatorAsync("TestOp");
 38          var operatorId = createResult.Value!;
 39  
 40          // Act
 41          var loadResult = await _service.LoadOperatorAsync(operatorId);
 42  
 43          // Assert
 44          Assert.True(loadResult.IsSuccess);
 45          var aggregate = loadResult.Value!;
 46          Assert.Equal(OperatorMode.Base, aggregate.CurrentMode);
 47          Assert.Null(aggregate.InfilStartTime);
 48          Assert.Null(aggregate.ActiveCombatSessionId);
 49      }
 50  
 51      [Fact]
 52      public async Task StartInfil_TransitionsToInfilMode()
 53      {
 54          // Arrange
 55          var createResult = await _service.CreateOperatorAsync("TestOp");
 56          var operatorId = createResult.Value!;
 57  
 58          // Act
 59          var infilResult = await _service.StartInfilAsync(operatorId);
 60  
 61          // Assert
 62          Assert.True(infilResult.IsSuccess);
 63          var infilSessionId = infilResult.Value;
 64          Assert.NotEqual(Guid.Empty, infilSessionId);
 65  
 66          var loadResult = await _service.LoadOperatorAsync(operatorId);
 67          var aggregate = loadResult.Value!;
 68          Assert.Equal(OperatorMode.Infil, aggregate.CurrentMode);
 69          Assert.NotNull(aggregate.InfilStartTime);
 70          Assert.Equal(infilSessionId, aggregate.InfilSessionId); // InfilSessionId is set, not ActiveCombatSessionId
 71          Assert.Null(aggregate.ActiveCombatSessionId); // No active combat session until player engages
 72      }
 73  
 74      [Fact]
 75      public async Task StartInfil_WhenAlreadyInInfil_Fails()
 76      {
 77          // Arrange
 78          var createResult = await _service.CreateOperatorAsync("TestOp");
 79          var operatorId = createResult.Value!;
 80          await _service.StartInfilAsync(operatorId);
 81  
 82          // Act
 83          var secondInfilResult = await _service.StartInfilAsync(operatorId);
 84  
 85          // Assert
 86          Assert.False(secondInfilResult.IsSuccess);
 87          Assert.Contains("already in Infil mode", secondInfilResult.ErrorMessage);
 88      }
 89  
 90      [Fact]
 91      public async Task ChangeLoadout_InBaseMode_Succeeds()
 92      {
 93          // Arrange
 94          var createResult = await _service.CreateOperatorAsync("TestOp");
 95          var operatorId = createResult.Value!;
 96  
 97          // Act
 98          var result = await _service.ChangeLoadoutAsync(operatorId, "SOKOL 545");
 99  
100          // Assert
101          Assert.True(result.IsSuccess);
102  
103          var loadResult = await _service.LoadOperatorAsync(operatorId);
104          Assert.Equal("SOKOL 545", loadResult.Value!.EquippedWeaponName);
105      }
106  
107      [Fact]
108      public async Task ChangeLoadout_InInfilMode_Fails()
109      {
110          // Arrange
111          var createResult = await _service.CreateOperatorAsync("TestOp");
112          var operatorId = createResult.Value!;
113          await _service.StartInfilAsync(operatorId);
114  
115          // Act
116          var result = await _service.ChangeLoadoutAsync(operatorId, "SOKOL 545");
117  
118          // Assert
119          Assert.False(result.IsSuccess);
120          Assert.Contains("loadout is locked", result.ErrorMessage);
121      }
122  
123      [Fact]
124      public async Task TreatWounds_InBaseMode_Succeeds()
125      {
126          // Arrange
127          var createResult = await _service.CreateOperatorAsync("TestOp");
128          var operatorId = createResult.Value!;
129  
130          // Act
131          var result = await _service.TreatWoundsAsync(operatorId, 10f);
132  
133          // Assert
134          Assert.True(result.IsSuccess);
135      }
136  
137      [Fact]
138      public async Task TreatWounds_InInfilMode_Fails()
139      {
140          // Arrange
141          var createResult = await _service.CreateOperatorAsync("TestOp");
142          var operatorId = createResult.Value!;
143          await _service.StartInfilAsync(operatorId);
144  
145          // Act
146          var result = await _service.TreatWoundsAsync(operatorId, 10f);
147  
148          // Assert
149          Assert.False(result.IsSuccess);
150          Assert.Contains("Cannot treat wounds while in Infil mode", result.ErrorMessage);
151      }
152  
153      [Fact]
154      public async Task UnlockPerk_InBaseMode_Succeeds()
155      {
156          // Arrange
157          var createResult = await _service.CreateOperatorAsync("TestOp");
158          var operatorId = createResult.Value!;
159  
160          // Act
161          var result = await _service.UnlockPerkAsync(operatorId, "TestPerk");
162  
163          // Assert
164          Assert.True(result.IsSuccess);
165      }
166  
167      [Fact]
168      public async Task UnlockPerk_InInfilMode_Fails()
169      {
170          // Arrange
171          var createResult = await _service.CreateOperatorAsync("TestOp");
172          var operatorId = createResult.Value!;
173          await _service.StartInfilAsync(operatorId);
174  
175          // Act
176          var result = await _service.UnlockPerkAsync(operatorId, "TestPerk");
177  
178          // Assert
179          Assert.False(result.IsSuccess);
180          Assert.Contains("Cannot unlock perk while in Infil mode", result.ErrorMessage);
181      }
182  
183      [Fact]
184      public async Task ApplyXp_InBaseMode_Succeeds()
185      {
186          // Arrange
187          var createResult = await _service.CreateOperatorAsync("TestOp");
188          var operatorId = createResult.Value!;
189  
190          // Act
191          var result = await _service.ApplyXpAsync(operatorId, 100, "Test");
192  
193          // Assert
194          Assert.True(result.IsSuccess);
195      }
196  
197      [Fact]
198      public async Task ApplyXp_InInfilMode_Fails()
199      {
200          // Arrange
201          var createResult = await _service.CreateOperatorAsync("TestOp");
202          var operatorId = createResult.Value!;
203          await _service.StartInfilAsync(operatorId);
204  
205          // Act
206          var result = await _service.ApplyXpAsync(operatorId, 100, "Test");
207  
208          // Assert
209          Assert.False(result.IsSuccess);
210          Assert.Contains("Cannot apply XP while in Infil mode", result.ErrorMessage);
211      }
212  
213      [Fact]
214      public async Task FailInfil_ResetsStreakAndReturnsToBase()
215      {
216          // Arrange
217          var createResult = await _service.CreateOperatorAsync("TestOp");
218          var operatorId = createResult.Value!;
219  
220          // Start infil first
221          await _service.StartInfilAsync(operatorId);
222  
223          // Act
224          var failResult = await _service.FailInfilAsync(operatorId, "Timeout");
225  
226          // Assert
227          Assert.True(failResult.IsSuccess);
228  
229          var loadResult = await _service.LoadOperatorAsync(operatorId);
230          var aggregate = loadResult.Value!;
231          Assert.Equal(OperatorMode.Base, aggregate.CurrentMode);
232          Assert.Equal(0, aggregate.ExfilStreak);
233          Assert.Null(aggregate.InfilStartTime);
234          Assert.Null(aggregate.ActiveCombatSessionId);
235      }
236  
237      [Fact]
238      public async Task FailInfil_WhenInBaseMode_Fails()
239      {
240          // Arrange
241          var createResult = await _service.CreateOperatorAsync("TestOp");
242          var operatorId = createResult.Value!;
243  
244          // Act
245          var result = await _service.FailInfilAsync(operatorId, "Timeout");
246  
247          // Assert
248          Assert.False(result.IsSuccess);
249          Assert.Contains("not in Infil mode", result.ErrorMessage);
250      }
251  
252      [Fact]
253      public async Task StartInfil_LocksLoadout()
254      {
255          // Arrange
256          var createResult = await _service.CreateOperatorAsync("TestOp");
257          var operatorId = createResult.Value!;
258          await _service.ChangeLoadoutAsync(operatorId, "SOKOL 545");
259  
260          // Act
261          await _service.StartInfilAsync(operatorId);
262  
263          // Assert
264          var loadResult = await _service.LoadOperatorAsync(operatorId);
265          var aggregate = loadResult.Value!;
266          Assert.Equal("SOKOL 545", aggregate.LockedLoadout);
267      }
268  
269      [Fact]
270      public async Task InfilSuccess_PreservesLoadout()
271      {
272          // Arrange
273          var createResult = await _service.CreateOperatorAsync("TestOp");
274          var operatorId = createResult.Value!;
275          await _service.ChangeLoadoutAsync(operatorId, "SOKOL 545");
276          var infilResult = await _service.StartInfilAsync(operatorId);
277          Assert.True(infilResult.IsSuccess);
278  
279          // Act - Complete exfil which should preserve loadout
280          var exfilResult = await _service.CompleteExfilAsync(operatorId);
281  
282          // Assert
283          Assert.True(exfilResult.IsSuccess);
284          
285          // After CompleteExfilAsync, operator is still in Infil mode (legacy behavior)
286          // ProcessCombatOutcomeAsync handles the full workflow including mode transition
287          var loadResult = await _service.LoadOperatorAsync(operatorId);
288          var aggregate = loadResult.Value!;
289          Assert.Equal("SOKOL 545", aggregate.EquippedWeaponName); // Loadout preserved
290          Assert.Equal(OperatorMode.Infil, aggregate.CurrentMode); // Still in Infil (not ended yet)
291          Assert.Equal(0, aggregate.ExfilStreak); // CompleteExfilAsync does not increment streak (only infil completion does)
292      }
293  
294      [Fact]
295      public async Task InfilFailure_ClearsLoadout()
296      {
297          // Arrange
298          var createResult = await _service.CreateOperatorAsync("TestOp");
299          var operatorId = createResult.Value!;
300          await _service.ChangeLoadoutAsync(operatorId, "SOKOL 545");
301          await _service.StartInfilAsync(operatorId);
302  
303          // Act
304          await _service.FailInfilAsync(operatorId, "Timeout");
305  
306          // Assert
307          var loadResult = await _service.LoadOperatorAsync(operatorId);
308          var aggregate = loadResult.Value!;
309          Assert.Equal(string.Empty, aggregate.EquippedWeaponName); // Loadout cleared on failure
310          Assert.Equal(OperatorMode.Base, aggregate.CurrentMode);
311      }
312  
313      [Fact]
314      public async Task ProcessCombatOutcome_WhenNotInInfil_Fails()
315      {
316          // Arrange
317          var mapper = new BsonMapper();
318          mapper.Entity<OperatorEventDocument>().Id(x => x.Id);
319          using var db = new LiteDatabase(":memory:", mapper);
320          var eventStore = new LiteDbOperatorEventStore(db);
321          var service = new OperatorExfilService(eventStore);
322          
323          var createResult = await service.CreateOperatorAsync("TestOp");
324          var operatorId = createResult.Value!;
325  
326          var outcome = new GUNRPG.Application.Combat.CombatOutcome(
327              Guid.NewGuid(),
328              operatorId,
329              operatorDied: false,
330              xpGained: 100,
331              gearLost: Array.Empty<GUNRPG.Core.Equipment.GearId>(),
332              isVictory: true);
333  
334          // Act
335          var result = await service.ProcessCombatOutcomeAsync(outcome, playerConfirmed: true);
336  
337          // Assert
338          Assert.False(result.IsSuccess);
339          Assert.Contains("not in Infil mode", result.ErrorMessage);
340      }
341  
342      [Fact]
343      public async Task ProcessCombatOutcome_Victory_StaysInInfilMode()
344      {
345          // Arrange
346          var mapper = new BsonMapper();
347          mapper.Entity<OperatorEventDocument>().Id(x => x.Id);
348          using var db = new LiteDatabase(":memory:", mapper);
349          var eventStore = new LiteDbOperatorEventStore(db);
350          var service = new OperatorExfilService(eventStore);
351          
352          var createResult = await service.CreateOperatorAsync("TestOp");
353          var operatorId = createResult.Value!;
354          var infilResult = await service.StartInfilAsync(operatorId);
355          var sessionId = infilResult.Value; // Use the actual session ID
356  
357          var outcome = new GUNRPG.Application.Combat.CombatOutcome(
358              sessionId, // Use the session ID returned by StartInfilAsync
359              operatorId,
360              operatorDied: false,
361              xpGained: 100,
362              gearLost: Array.Empty<GUNRPG.Core.Equipment.GearId>(),
363              isVictory: true);
364  
365          // Act
366          var result = await service.ProcessCombatOutcomeAsync(outcome, playerConfirmed: true);
367  
368          // Assert
369          Assert.True(result.IsSuccess);
370          
371          var loadResult = await service.LoadOperatorAsync(operatorId);
372          var aggregate = loadResult.Value!;
373          Assert.Equal(OperatorMode.Infil, aggregate.CurrentMode); // Should stay in Infil mode
374          Assert.Equal(0, aggregate.ExfilStreak); // Streak NOT incremented by combat victory (only on infil completion)
375          Assert.Equal(100, aggregate.TotalXp);
376          Assert.Null(aggregate.ActiveCombatSessionId); // Should clear ActiveSessionId after victory to prevent auto-resume
377      }
378  
379      [Fact]
380      public async Task ProcessCombatOutcome_Death_ReturnsToBaseModeAndResetsStreak()
381      {
382          // Arrange
383          var mapper = new BsonMapper();
384          mapper.Entity<OperatorEventDocument>().Id(x => x.Id);
385          using var db = new LiteDatabase(":memory:", mapper);
386          var eventStore = new LiteDbOperatorEventStore(db);
387          var service = new OperatorExfilService(eventStore);
388          
389          var createResult = await service.CreateOperatorAsync("TestOp");
390          var operatorId = createResult.Value!;
391          var infilResult = await service.StartInfilAsync(operatorId);
392          var sessionId = infilResult.Value; // Use the actual session ID
393  
394          var outcome = new GUNRPG.Application.Combat.CombatOutcome(
395              sessionId, // Use the session ID returned by StartInfilAsync
396              operatorId,
397              operatorDied: true,
398              xpGained: 0,
399              gearLost: Array.Empty<GUNRPG.Core.Equipment.GearId>(),
400              isVictory: false);
401  
402          // Act
403          var result = await service.ProcessCombatOutcomeAsync(outcome, playerConfirmed: true);
404  
405          // Assert
406          Assert.True(result.IsSuccess);
407          
408          var loadResult = await service.LoadOperatorAsync(operatorId);
409          var aggregate = loadResult.Value!;
410          Assert.Equal(OperatorMode.Base, aggregate.CurrentMode);
411          Assert.Equal(0, aggregate.ExfilStreak);
412          Assert.False(aggregate.IsDead); // Operator respawns after death
413          Assert.Equal(aggregate.MaxHealth, aggregate.CurrentHealth); // Health restored after respawn
414      }
415  
416      [Fact]
417      public async Task IsInfilTimedOut_AfterThirtyMinutes_ReturnsTrue()
418      {
419          // Arrange
420          var mapper = new BsonMapper();
421          mapper.Entity<OperatorEventDocument>().Id(x => x.Id);
422          using var db = new LiteDatabase(":memory:", mapper);
423          var eventStore = new LiteDbOperatorEventStore(db);
424          var service = new OperatorExfilService(eventStore);
425          
426          var createResult = await service.CreateOperatorAsync("TestOp");
427          var operatorId = createResult.Value!;
428          
429          // Load to get the correct previous hash
430          var loadResult = await service.LoadOperatorAsync(operatorId);
431          var aggregate = loadResult.Value!;
432          var previousHash = aggregate.GetLastEventHash();
433          
434          // Start infil with a timestamp 31 minutes ago
435          var oldTimestamp = DateTimeOffset.UtcNow.AddMinutes(-31);
436          var infilEvent = new InfilStartedEvent(
437              operatorId,
438              1,
439              Guid.NewGuid(),
440              "TestLoadout",
441              oldTimestamp,
442              previousHash);
443          await eventStore.AppendEventAsync(infilEvent);
444  
445          // Act
446          var result = await service.IsInfilTimedOutAsync(operatorId);
447  
448          // Assert
449          Assert.True(result.IsSuccess);
450          Assert.True(result.Value);
451      }
452  
453      [Fact]
454      public async Task IsInfilTimedOut_BeforeThirtyMinutes_ReturnsFalse()
455      {
456          // Arrange  
457          var createResult = await _service.CreateOperatorAsync("TestOp");
458          var operatorId = createResult.Value!;
459          await _service.StartInfilAsync(operatorId);
460  
461          // Act
462          var result = await _service.IsInfilTimedOutAsync(operatorId);
463  
464          // Assert
465          Assert.True(result.IsSuccess);
466          Assert.False(result.Value);
467      }
468  
469      [Fact]
470      public async Task ProcessCombatOutcome_NonVictorySurvival_ReturnsToBaseMode()
471      {
472          // Arrange
473          var mapper = new BsonMapper();
474          mapper.Entity<OperatorEventDocument>().Id(x => x.Id);
475          using var db = new LiteDatabase(":memory:", mapper);
476          var eventStore = new LiteDbOperatorEventStore(db);
477          var service = new OperatorExfilService(eventStore);
478          
479          var createResult = await service.CreateOperatorAsync("TestOp");
480          var operatorId = createResult.Value!;
481          var infilResult = await service.StartInfilAsync(operatorId);
482          var sessionId = infilResult.Value;
483  
484          // Operator survived but did not win
485          var outcome = new GUNRPG.Application.Combat.CombatOutcome(
486              sessionId,
487              operatorId,
488              operatorDied: false,
489              xpGained: 0,
490              gearLost: Array.Empty<GUNRPG.Core.Equipment.GearId>(),
491              isVictory: false);
492  
493          // Act
494          var result = await service.ProcessCombatOutcomeAsync(outcome, playerConfirmed: true);
495  
496          // Assert
497          Assert.True(result.IsSuccess);
498          
499          var loadResult = await service.LoadOperatorAsync(operatorId);
500          var aggregate = loadResult.Value!;
501          Assert.Equal(OperatorMode.Base, aggregate.CurrentMode);
502          Assert.Equal(0, aggregate.ExfilStreak);
503          Assert.Null(aggregate.ActiveCombatSessionId);
504      }
505  }