NEO-29: resolve follow-up review regressions

Fix the hotbar client test transport body construction so the suite parses and runs again, and normalize AAA marker ordering in the flagged C# tests to restore readability consistency with the test template.
pull/54/head
VinPropane 2026-04-25 23:26:25 -04:00
parent 71af7a5942
commit 9ac4f27ea2
7 changed files with 155 additions and 108 deletions

View File

@ -52,13 +52,11 @@ class HoldFirstThenAutoTransport:
) -> Error: ) -> Error:
request_count += 1 request_count += 1
last_body = request_data last_body = request_data
var body_bytes: PackedByteArray = ( var body_json := (
'{"schemaVersion":1,"updated":true,"loadout":' '{"schemaVersion":1,"updated":true,"loadout":'
+ ( + '{"schemaVersion":1,"playerId":"dev-local-1","slotCount":8,"slots":[]}}'
'{"schemaVersion":1,"playerId":"dev-local-1","slotCount":8,"slots":[]}}'
. to_utf8_buffer()
)
) )
var body_bytes: PackedByteArray = body_json.to_utf8_buffer()
if _first: if _first:
_first = false _first = false
call_deferred("_emit", body_bytes) call_deferred("_emit", body_bytes)

View File

@ -3,6 +3,7 @@
- **Date:** 2026-04-25 - **Date:** 2026-04-25
- **Scope:** Branch `NEO-29-hotbarloadout-v1-contract-baseline-persistence-path` (`origin/main...HEAD`) - **Scope:** Branch `NEO-29-hotbarloadout-v1-contract-baseline-persistence-path` (`origin/main...HEAD`)
- **Base:** `origin/main` - **Base:** `origin/main`
- **Follow-up:** Re-review after `NEO-29: apply code review follow-ups` and subsequent test refactor commits (`d25d7d0..HEAD`).
## Verdict ## Verdict
@ -10,19 +11,19 @@ Approve with nits.
## Summary ## Summary
This branch delivers the planned `HotbarLoadout` v1 vertical slice end-to-end: server `GET`/`POST` APIs with stable deny reason codes, persistence selection (Postgres when configured, in-memory fallback otherwise), client hydration wiring, and targeted tests/manual QA artifacts. The API behavior and persistence wiring align with the NEO-29 plan decisions (per-player scope, fixed eight slots, server-owned validation). Contract safety is good for a prototype slice: deny responses preserve authoritative loadout snapshots, and request validation covers out-of-bounds, unknown abilities, and duplicate slots. Overall runtime risk is low; most residual risk is documentation-state drift rather than code behavior. The earlier documentation-alignment and lower-bound slot test suggestions were addressed correctly. E1.M4 status tracking now matches implementation state, the `slotIndex < 0` deny-path test was added, and the client GDScript parse regression in `hotbar_loadout_client_test.gd` is now fixed. Refactored server AAA tests run cleanly in the targeted suites, and the hotbar client test suite now executes successfully. Remaining risk is low and limited to test-run warning noise from the headless Godot runner rather than functional test failures.
## Documentation checked ## Documentation checked
- `docs/plans/NEO-29-implementation-plan.md`**matches** (scope, API shape, persistence policy, deny reasons, tests/docs artifacts all present). - `docs/plans/NEO-29-implementation-plan.md`**matches** (scope, API shape, persistence policy, deny reasons, tests/docs artifacts all present).
- `docs/plans/E1M4-prototype-backlog.md`**matches** (E1M4-01 slice framing and NEO-29 implementation note are consistent). - `docs/plans/E1M4-prototype-backlog.md`**matches** (E1M4-01 slice framing and NEO-29 implementation note are consistent).
- `docs/decomposition/modules/E1_M4_AbilityInputScaffold.md`**partially matches** (implementation snapshot updated, but summary `Status` still `Planned` despite landed implementation). - `docs/decomposition/modules/E1_M4_AbilityInputScaffold.md`**matches** (summary status and implementation snapshot now align with landed NEO-29 work).
- `docs/decomposition/modules/module_dependency_register.md`**partially matches** (E1.M4 row remains `Planned`; this story appears to satisfy “work started,” which should move to `In Progress` per alignment policy). - `docs/decomposition/modules/module_dependency_register.md`**matches** (E1.M4 row and note now show `In Progress`).
- `docs/decomposition/modules/documentation_and_implementation_alignment.md`**conflicts** with current status labels (rule says move module status from `Planned` to `In Progress` when implementation stories merge). - `docs/decomposition/modules/documentation_and_implementation_alignment.md`**matches** (tracking table now includes E1.M4 implementation snapshot/pointers).
- `docs/decomposition/modules/contracts.md`**matches** for prototype scope (JSON/HTTP spike is acceptable for early slice; versioned envelope and stable reason codes are documented). - `docs/decomposition/modules/contracts.md`**matches** for prototype scope (JSON/HTTP spike is acceptable for early slice; versioned envelope and stable reason codes are documented).
- `docs/decomposition/modules/client_server_authority.md`**matches** (server remains authority for persistent gameplay-relevant loadout state; client acts as view/sync). - `docs/decomposition/modules/client_server_authority.md`**matches** (server remains authority for persistent gameplay-relevant loadout state; client acts as view/sync).
Register/tracking follow-up needed after merge: update E1.M4 status in `module_dependency_register.md`, `E1_M4_AbilityInputScaffold.md` summary table, and the implementation tracking table entry expectations. Register/tracking follow-up from the first pass is now closed.
## Blocking issues ## Blocking issues
@ -31,6 +32,7 @@ None.
## Suggestions ## Suggestions
1. ~~Update decomposition status tracking for E1.M4 from `Planned` to `In Progress` in the register/module summary (and corresponding tracking inventory) so docs comply with `documentation_and_implementation_alignment.md` and do not under-report shipped progress.~~ Done. Updated `docs/decomposition/modules/module_dependency_register.md` (row + note), `docs/decomposition/modules/E1_M4_AbilityInputScaffold.md` (summary status), and `docs/decomposition/modules/documentation_and_implementation_alignment.md` (new E1.M4 tracking row). 1. ~~Update decomposition status tracking for E1.M4 from `Planned` to `In Progress` in the register/module summary (and corresponding tracking inventory) so docs comply with `documentation_and_implementation_alignment.md` and do not under-report shipped progress.~~ Done. Updated `docs/decomposition/modules/module_dependency_register.md` (row + note), `docs/decomposition/modules/E1_M4_AbilityInputScaffold.md` (summary status), and `docs/decomposition/modules/documentation_and_implementation_alignment.md` (new E1.M4 tracking row).
2. ~~Several C# test files now place AAA markers inconsistently (e.g., `// Act` above `// Arrange` and inline comment placement on brace lines). This does not break runtime behavior, but it undermines the readability goal of the AAA refactor and conflicts with the documented template/order in `server/NeonSprawl.Server.Tests/TestTemplates/AAA_TEST_METHOD_TEMPLATE.cs.txt`.~~ Done. Normalized affected suites so AAA markers are consistently ordered and placed on their own lines in `server/NeonSprawl.Server.Tests/Game/Interaction/HorizontalReachTests.cs`, `server/NeonSprawl.Server.Tests/Game/Interaction/InteractionApiTests.cs`, `server/NeonSprawl.Server.Tests/Game/PositionState/MoveCommandValidationTests.cs`, `server/NeonSprawl.Server.Tests/Game/Targeting/PlayerTargetStateReaderTests.cs`, and `server/NeonSprawl.Server.Tests/Game/Targeting/TargetingApiTests.cs`.
## Nits ## Nits
@ -42,3 +44,9 @@ None.
- `dotnet test NeonSprawl.sln --filter "FullyQualifiedName~HotbarLoadoutPersistenceIntegrationTests"` - `dotnet test NeonSprawl.sln --filter "FullyQualifiedName~HotbarLoadoutPersistenceIntegrationTests"`
- `godot --headless --path client -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://test/hotbar_loadout_client_test.gd` - `godot --headless --path client -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://test/hotbar_loadout_client_test.gd`
- Manual checklist: `docs/manual-qa/NEO-29.md` - Manual checklist: `docs/manual-qa/NEO-29.md`
- Follow-up rerun (re-review):
- `dotnet test NeonSprawl.sln --filter "FullyQualifiedName~HotbarLoadoutApiTests|FullyQualifiedName~HotbarLoadoutPersistenceIntegrationTests|FullyQualifiedName~TargetingApiTests|FullyQualifiedName~InteractionApiTests|FullyQualifiedName~MoveCommandApiTests|FullyQualifiedName~MoveCommandValidationTests|FullyQualifiedName~MoveStreamApiTests|FullyQualifiedName~PlayerTargetStateReaderTests|FullyQualifiedName~HorizontalReachTests|FullyQualifiedName~InteractablesWorldApiTests"` ✅ (71 passed)
- `godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://test/hotbar_loadout_client_test.gd` ❌ (parse/type error in `client/test/hotbar_loadout_client_test.gd`)
- Final rerun (latest working tree):
- `dotnet test NeonSprawl.sln --filter "FullyQualifiedName~HotbarLoadoutApiTests|FullyQualifiedName~HotbarLoadoutPersistenceIntegrationTests|FullyQualifiedName~TargetingApiTests|FullyQualifiedName~InteractionApiTests|FullyQualifiedName~MoveCommandApiTests|FullyQualifiedName~MoveCommandValidationTests|FullyQualifiedName~MoveStreamApiTests|FullyQualifiedName~PlayerTargetStateReaderTests|FullyQualifiedName~HorizontalReachTests|FullyQualifiedName~InteractablesWorldApiTests"` ✅ (71 passed)
- `godot --headless --path . -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://test/hotbar_loadout_client_test.gd` ✅ (6 passed; runner still prints non-fatal warning/error noise at shutdown)

View File

@ -8,52 +8,66 @@ public class HorizontalReachTests
[Fact] [Fact]
public void HorizontalDistance_IgnoresY() public void HorizontalDistance_IgnoresY()
{ {
// Y is not a parameter; identical X/Z → zero horizontal separation regardless of implied height difference.
// Act
// Arrange // Arrange
// Y is not a parameter; identical X/Z -> zero horizontal separation regardless of implied height difference.
// Act
var d = HorizontalReach.HorizontalDistance(1, 2, 1, 2); var d = HorizontalReach.HorizontalDistance(1, 2, 1, 2);
// Assert // Assert
Assert.Equal(0, d); Assert.Equal(0, d);
} }
[Fact] [Fact]
public void IsWithinHorizontalRadius_AtExactBoundary_ReturnsTrue() public void IsWithinHorizontalRadius_AtExactBoundary_ReturnsTrue()
{ // Assert {
// Act
// Arrange // Arrange
Assert.True(HorizontalReach.IsWithinHorizontalRadius(0, 0, 3, 0, 3)); // Act
var isInRange = HorizontalReach.IsWithinHorizontalRadius(0, 0, 3, 0, 3);
// Assert
Assert.True(isInRange);
} }
[Fact] [Fact]
public void IsWithinHorizontalRadius_JustInside_ReturnsTrue() public void IsWithinHorizontalRadius_JustInside_ReturnsTrue()
{ // Act {
// Arrange // Arrange
const double radius = 3.0; const double radius = 3.0;
const double eps = 1e-10; const double eps = 1e-10;
// Act
var isInRange = HorizontalReach.IsWithinHorizontalRadius(0, 0, radius - eps, 0, radius);
// Assert // Assert
Assert.True(HorizontalReach.IsWithinHorizontalRadius(0, 0, radius - eps, 0, radius)); Assert.True(isInRange);
} }
[Fact] [Fact]
public void IsWithinHorizontalRadius_JustOutside_ReturnsFalse() public void IsWithinHorizontalRadius_JustOutside_ReturnsFalse()
{ // Act {
// Arrange // Arrange
const double radius = 3.0; const double radius = 3.0;
const double eps = 1e-7; const double eps = 1e-7;
// Act
var isInRange = HorizontalReach.IsWithinHorizontalRadius(0, 0, radius + eps, 0, radius);
// Assert // Assert
Assert.False(HorizontalReach.IsWithinHorizontalRadius(0, 0, radius + eps, 0, radius)); Assert.False(isInRange);
} }
[Fact] [Fact]
public void IsWithinHorizontalRadius_NegativeRadius_ThrowsArgumentOutOfRange() public void IsWithinHorizontalRadius_NegativeRadius_ThrowsArgumentOutOfRange()
{ // Assert {
// Act
// Arrange // Arrange
Action act = () => HorizontalReach.IsWithinHorizontalRadius(0, 0, 0, 0, -1.0);
Assert.Throws<ArgumentOutOfRangeException>(() => // Act
HorizontalReach.IsWithinHorizontalRadius(0, 0, 0, 0, -1.0)); var ex = Assert.Throws<ArgumentOutOfRangeException>(act);
// Assert
Assert.NotNull(ex);
} }
} }

View File

@ -12,9 +12,8 @@ public class InteractionApiTests
{ {
[Fact] [Fact]
public async Task PostInteract_ShouldAllow_WhenExactlyAtHorizontalRadius() public async Task PostInteract_ShouldAllow_WhenExactlyAtHorizontalRadius()
{ // Act {
// Arrange // Arrange
await using var factory = new InMemoryWebApplicationFactory(); await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient(); var client = factory.CreateClient();
var move = new MoveCommandRequest var move = new MoveCommandRequest
@ -22,8 +21,9 @@ public class InteractionApiTests
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
Target = new PositionVector { X = 3, Y = 0, Z = 0 }, Target = new PositionVector { X = 3, Y = 0, Z = 0 },
}; };
// Assert
Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync("/game/players/dev-local-1/move", move)).StatusCode); // Act
var moveResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/move", move);
var response = await client.PostAsJsonAsync( var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/interact", "/game/players/dev-local-1/interact",
@ -32,9 +32,11 @@ public class InteractionApiTests
SchemaVersion = InteractionRequest.CurrentSchemaVersion, SchemaVersion = InteractionRequest.CurrentSchemaVersion,
InteractableId = PrototypeInteractableRegistry.PrototypeTerminalId, InteractableId = PrototypeInteractableRegistry.PrototypeTerminalId,
}); });
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<InteractionResponse>(); var body = await response.Content.ReadFromJsonAsync<InteractionResponse>();
// Assert
Assert.Equal(HttpStatusCode.OK, moveResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body); Assert.NotNull(body);
Assert.True(body!.Allowed); Assert.True(body!.Allowed);
} }
@ -121,9 +123,8 @@ public class InteractionApiTests
[Fact] [Fact]
public async Task PostInteract_ShouldBeCaseInsensitive_ForInteractableId() public async Task PostInteract_ShouldBeCaseInsensitive_ForInteractableId()
{ // Act {
// Arrange // Arrange
await using var factory = new InMemoryWebApplicationFactory(); await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient(); var client = factory.CreateClient();
var move = new MoveCommandRequest var move = new MoveCommandRequest
@ -131,8 +132,9 @@ public class InteractionApiTests
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
Target = new PositionVector { X = 0, Y = 0, Z = 0 }, Target = new PositionVector { X = 0, Y = 0, Z = 0 },
}; };
// Assert
Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync("/game/players/dev-local-1/move", move)).StatusCode); // Act
var moveResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/move", move);
var req = new InteractionRequest var req = new InteractionRequest
{ {
@ -141,9 +143,11 @@ public class InteractionApiTests
}; };
var response = await client.PostAsJsonAsync("/game/players/dev-local-1/interact", req); var response = await client.PostAsJsonAsync("/game/players/dev-local-1/interact", req);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<InteractionResponse>(); var body = await response.Content.ReadFromJsonAsync<InteractionResponse>();
// Assert
Assert.Equal(HttpStatusCode.OK, moveResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body); Assert.NotNull(body);
Assert.True(body!.Allowed); Assert.True(body!.Allowed);
} }
@ -263,9 +267,8 @@ public class InteractionApiTests
[Fact] [Fact]
public async Task PostInteract_ShouldAllowResourceNode_WhenPlayerInRangeOfResourceAnchor() public async Task PostInteract_ShouldAllowResourceNode_WhenPlayerInRangeOfResourceAnchor()
{ // Act {
// Arrange // Arrange
await using var factory = new InMemoryWebApplicationFactory(); await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient(); var client = factory.CreateClient();
var move = new MoveCommandRequest var move = new MoveCommandRequest
@ -273,8 +276,9 @@ public class InteractionApiTests
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
Target = new PositionVector { X = 12, Y = 0.9, Z = -6 }, Target = new PositionVector { X = 12, Y = 0.9, Z = -6 },
}; };
// Assert
Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync("/game/players/dev-local-1/move", move)).StatusCode); // Act
var moveResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/move", move);
var response = await client.PostAsJsonAsync( var response = await client.PostAsJsonAsync(
"/game/players/dev-local-1/interact", "/game/players/dev-local-1/interact",
@ -283,9 +287,11 @@ public class InteractionApiTests
SchemaVersion = InteractionRequest.CurrentSchemaVersion, SchemaVersion = InteractionRequest.CurrentSchemaVersion,
InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, InteractableId = PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId,
}); });
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<InteractionResponse>(); var body = await response.Content.ReadFromJsonAsync<InteractionResponse>();
// Assert
Assert.Equal(HttpStatusCode.OK, moveResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.NotNull(body); Assert.NotNull(body);
Assert.True(body!.Allowed); Assert.True(body!.Allowed);
Assert.Equal(PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, body.InteractableId); Assert.Equal(PrototypeInteractableRegistry.PrototypeResourceNodeAlphaId, body.InteractableId);

View File

@ -23,106 +23,124 @@ public class MoveCommandValidationTests
[Fact] [Fact]
public void TryValidate_ShouldAllow_WhenHorizontalExactlyAtMax() public void TryValidate_ShouldAllow_WhenHorizontalExactlyAtMax()
{ // Act {
// Arrange // Arrange
var from = new PositionSnapshot(0, 0, 0, 0); var from = new PositionSnapshot(0, 0, 0, 0);
var to = new PositionVector { X = 5, Y = 0, Z = 0 }; var to = new PositionVector { X = 5, Y = 0, Z = 0 };
// Act
var ok = MoveCommandValidation.TryValidate(from, to, Rules(5, 10), out var code);
// Assert // Assert
Assert.True(MoveCommandValidation.TryValidate(from, to, Rules(5, 10), out var code)); Assert.True(ok);
Assert.Equal("", code); Assert.Equal("", code);
} }
[Fact] [Fact]
public void TryValidate_ShouldRejectHorizontal_WhenJustOutsideMax() public void TryValidate_ShouldRejectHorizontal_WhenJustOutsideMax()
{ // Act {
// Arrange // Arrange
var from = new PositionSnapshot(0, 0, 0, 0); var from = new PositionSnapshot(0, 0, 0, 0);
var to = new PositionVector { X = 5 + 1e-6, Y = 0, Z = 0 }; var to = new PositionVector { X = 5 + 1e-6, Y = 0, Z = 0 };
// Act
var ok = MoveCommandValidation.TryValidate(from, to, Rules(5, 10), out var code);
// Assert // Assert
Assert.False(MoveCommandValidation.TryValidate(from, to, Rules(5, 10), out var code)); Assert.False(ok);
Assert.Equal(MoveCommandReasonCodes.HorizontalStepExceeded, code); Assert.Equal(MoveCommandReasonCodes.HorizontalStepExceeded, code);
} }
[Fact] [Fact]
public void TryValidate_ShouldAllow_WhenVerticalExactlyAtMax() public void TryValidate_ShouldAllow_WhenVerticalExactlyAtMax()
{ // Act {
// Arrange // Arrange
var from = new PositionSnapshot(0, 1, 0, 0); var from = new PositionSnapshot(0, 1, 0, 0);
var to = new PositionVector { X = 0, Y = 2.5, Z = 0 }; var to = new PositionVector { X = 0, Y = 2.5, Z = 0 };
// Act
var ok = MoveCommandValidation.TryValidate(from, to, Rules(10, 1.5), out _);
// Assert // Assert
Assert.True(MoveCommandValidation.TryValidate(from, to, Rules(10, 1.5), out _)); Assert.True(ok);
} }
[Fact] [Fact]
public void TryValidate_ShouldRejectVertical_WhenJustOutsideMax() public void TryValidate_ShouldRejectVertical_WhenJustOutsideMax()
{ // Act {
// Arrange // Arrange
var from = new PositionSnapshot(0, 1, 0, 0); var from = new PositionSnapshot(0, 1, 0, 0);
var to = new PositionVector { X = 0, Y = 2.5 + 1e-9, Z = 0 }; var to = new PositionVector { X = 0, Y = 2.5 + 1e-9, Z = 0 };
// Act
var ok = MoveCommandValidation.TryValidate(from, to, Rules(10, 1.5), out var code);
// Assert // Assert
Assert.False(MoveCommandValidation.TryValidate(from, to, Rules(10, 1.5), out var code)); Assert.False(ok);
Assert.Equal(MoveCommandReasonCodes.VerticalStepExceeded, code); Assert.Equal(MoveCommandReasonCodes.VerticalStepExceeded, code);
} }
[Fact] [Fact]
public void TryValidate_ShouldPreferHorizontalReason_WhenBothWouldFail() public void TryValidate_ShouldPreferHorizontalReason_WhenBothWouldFail()
{ // Act {
// Arrange // Arrange
var from = new PositionSnapshot(0, 0, 0, 0); var from = new PositionSnapshot(0, 0, 0, 0);
var to = new PositionVector { X = 100, Y = 100, Z = 0 }; var to = new PositionVector { X = 100, Y = 100, Z = 0 };
// Act
var ok = MoveCommandValidation.TryValidate(from, to, Rules(1, 1), out var code);
// Assert // Assert
Assert.False(MoveCommandValidation.TryValidate(from, to, Rules(1, 1), out var code)); Assert.False(ok);
Assert.Equal(MoveCommandReasonCodes.HorizontalStepExceeded, code); Assert.Equal(MoveCommandReasonCodes.HorizontalStepExceeded, code);
} }
[Fact] [Fact]
public void TryValidate_ShouldAllow_WhenHorizontalDisabled() public void TryValidate_ShouldAllow_WhenHorizontalDisabled()
{ // Act {
// Arrange // Arrange
var from = new PositionSnapshot(0, 0, 0, 0); var from = new PositionSnapshot(0, 0, 0, 0);
var to = new PositionVector { X = 1000, Y = 0, Z = 1000 }; var to = new PositionVector { X = 1000, Y = 0, Z = 1000 };
// Act
var ok = MoveCommandValidation.TryValidate(from, to, Rules(1, 10, horizontalEnabled: false), out var code);
// Assert // Assert
Assert.True(MoveCommandValidation.TryValidate(from, to, Rules(1, 10, horizontalEnabled: false), out var code)); Assert.True(ok);
Assert.Equal("", code); Assert.Equal("", code);
} }
[Fact] [Fact]
public void TryValidate_ShouldIgnoreBounds_WhenDistrictDisabled() public void TryValidate_ShouldIgnoreBounds_WhenDistrictDisabled()
{ // Act {
// Arrange // Arrange
var from = new PositionSnapshot(0, 0, 0, 0); var from = new PositionSnapshot(0, 0, 0, 0);
var to = new PositionVector { X = -50, Y = 0, Z = 0 }; var to = new PositionVector { X = -50, Y = 0, Z = 0 };
// Act
var ok = MoveCommandValidation.TryValidate(from, to, Rules(100, 100, bounds: false), out _);
// Assert // Assert
Assert.True(MoveCommandValidation.TryValidate(from, to, Rules(100, 100, bounds: false), out _)); Assert.True(ok);
} }
[Fact] [Fact]
public void TryValidate_ShouldRejectOutOfBounds_WhenDistrictEnabled() public void TryValidate_ShouldRejectOutOfBounds_WhenDistrictEnabled()
{ // Act {
// Arrange // Arrange
var from = new PositionSnapshot(5, 5, 5, 0); var from = new PositionSnapshot(5, 5, 5, 0);
var to = new PositionVector { X = 5, Y = 5, Z = 11 }; var to = new PositionVector { X = 5, Y = 5, Z = 11 };
// Act
var ok = MoveCommandValidation.TryValidate(from, to, Rules(20, 20, bounds: true), out var code);
// Assert // Assert
Assert.False(MoveCommandValidation.TryValidate(from, to, Rules(20, 20, bounds: true), out var code)); Assert.False(ok);
Assert.Equal(MoveCommandReasonCodes.OutOfBounds, code); Assert.Equal(MoveCommandReasonCodes.OutOfBounds, code);
} }
[Fact] [Fact]
public void TryValidate_ShouldAllowOnBoundsSurface_WhenDistrictEnabled() public void TryValidate_ShouldAllowOnBoundsSurface_WhenDistrictEnabled()
{ // Act {
// Arrange // Arrange
var from = new PositionSnapshot(5, 5, 5, 0); var from = new PositionSnapshot(5, 5, 5, 0);
var to = new PositionVector { X = 10, Y = 10, Z = 10 }; var to = new PositionVector { X = 10, Y = 10, Z = 10 };
// Act
var ok = MoveCommandValidation.TryValidate(from, to, Rules(20, 20, bounds: true), out _);
// Assert // Assert
Assert.True(MoveCommandValidation.TryValidate(from, to, Rules(20, 20, bounds: true), out _)); Assert.True(ok);
} }
} }

View File

@ -8,64 +8,65 @@ public class PlayerTargetStateReaderTests
{ {
[Fact] [Fact]
public void ComputeValidity_ShouldReturnInvalidTarget_WhenLockIdNotInRegistry() public void ComputeValidity_ShouldReturnInvalidTarget_WhenLockIdNotInRegistry()
{ // Act {
// Arrange // Arrange
var snap = new PositionSnapshot(0, 0, 0, 0); var snap = new PositionSnapshot(0, 0, 0, 0);
// Act
var validity = PlayerTargetStateReader.ComputeValidity("not_in_registry", in snap);
// Assert // Assert
Assert.Equal(TargetValidity.InvalidTarget, PlayerTargetStateReader.ComputeValidity("not_in_registry", in snap)); Assert.Equal(TargetValidity.InvalidTarget, validity);
} }
[Fact] [Fact]
public void ComputeValidity_ShouldReturnNone_WhenLockNull() public void ComputeValidity_ShouldReturnNone_WhenLockNull()
{ // Act {
// Arrange // Arrange
var snap = new PositionSnapshot(0, 0, 0, 0); var snap = new PositionSnapshot(0, 0, 0, 0);
// Act
var validity = PlayerTargetStateReader.ComputeValidity(null, in snap);
// Assert // Assert
Assert.Equal(TargetValidity.None, PlayerTargetStateReader.ComputeValidity(null, in snap)); Assert.Equal(TargetValidity.None, validity);
} }
[Fact] [Fact]
public void ComputeValidity_AtExactLockRadius_ReturnsOk() public void ComputeValidity_AtExactLockRadius_ReturnsOk()
{ {
// Alpha anchor XZ (-3, -3), lockRadius 6 → horizontal distance 6 from (3, -3) is on boundary (inclusive).
// Act
// Arrange // Arrange
// Alpha anchor XZ (-3, -3), lockRadius 6 -> horizontal distance 6 from (3, -3) is on boundary (inclusive).
var snap = new PositionSnapshot(3, 0, -3, 0); var snap = new PositionSnapshot(3, 0, -3, 0);
// Act
var validity = PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap);
// Assert // Assert
Assert.Equal( Assert.Equal(TargetValidity.Ok, validity);
TargetValidity.Ok,
PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap));
} }
[Fact] [Fact]
public void ComputeValidity_JustOutsideLockRadius_ReturnsOutOfRange() public void ComputeValidity_JustOutsideLockRadius_ReturnsOutOfRange()
{ // Act {
// Arrange // Arrange
const double eps = 1e-6; const double eps = 1e-6;
var snap = new PositionSnapshot(3 + eps, 0, -3, 0); var snap = new PositionSnapshot(3 + eps, 0, -3, 0);
// Act
var validity = PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap);
// Assert // Assert
Assert.Equal( Assert.Equal(TargetValidity.OutOfRange, validity);
TargetValidity.OutOfRange,
PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap));
} }
[Fact] [Fact]
public void ComputeValidity_AtOrigin_ShouldReturnOk_ForBothPrototypeTargets() public void ComputeValidity_AtOrigin_ShouldReturnOk_ForBothPrototypeTargets()
{ {
// Arrange
// NEO-24 post-merge: alpha (-3,-3) r=6 and beta (3,3) r=6 overlap at origin so Tab can // NEO-24 post-merge: alpha (-3,-3) r=6 and beta (3,3) r=6 overlap at origin so Tab can
// flip without locomotion. Origin is ~4.24 m from each anchor (< 6 m). // flip without locomotion. Origin is ~4.24 m from each anchor (< 6 m).
// Act
// Arrange
var snap = new PositionSnapshot(0, 0, 0, 0); var snap = new PositionSnapshot(0, 0, 0, 0);
// Act
var alphaValidity = PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap);
var betaValidity = PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetBetaId, in snap);
// Assert // Assert
Assert.Equal( Assert.Equal(TargetValidity.Ok, alphaValidity);
TargetValidity.Ok, Assert.Equal(TargetValidity.Ok, betaValidity);
PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetAlphaId, in snap));
Assert.Equal(
TargetValidity.Ok,
PlayerTargetStateReader.ComputeValidity(PrototypeTargetRegistry.PrototypeTargetBetaId, in snap));
} }
} }

View File

@ -199,22 +199,20 @@ public class TargetingApiTests
[Fact] [Fact]
public async Task PostSelect_ShouldClearLock_WhenTargetIdOmitted() public async Task PostSelect_ShouldClearLock_WhenTargetIdOmitted()
{ // Act {
// Arrange // Arrange
await using var factory = new InMemoryWebApplicationFactory(); await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient(); var client = factory.CreateClient();
// Assert
Assert.Equal( // Act
HttpStatusCode.OK, var setResponse = await client.PostAsJsonAsync(
(await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select", "/game/players/dev-local-1/target/select",
new TargetSelectRequest new TargetSelectRequest
{ {
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
})).StatusCode); });
var clear = await client.PostAsync( var clear = await client.PostAsync(
"/game/players/dev-local-1/target/select", "/game/players/dev-local-1/target/select",
@ -222,9 +220,11 @@ public class TargetingApiTests
$"{{\"schemaVersion\":{TargetSelectRequest.CurrentSchemaVersion}}}", $"{{\"schemaVersion\":{TargetSelectRequest.CurrentSchemaVersion}}}",
Encoding.UTF8, Encoding.UTF8,
"application/json")); "application/json"));
Assert.Equal(HttpStatusCode.OK, clear.StatusCode);
var body = await clear.Content.ReadFromJsonAsync<TargetSelectResponse>(); var body = await clear.Content.ReadFromJsonAsync<TargetSelectResponse>();
// Assert
Assert.Equal(HttpStatusCode.OK, setResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, clear.StatusCode);
Assert.NotNull(body); Assert.NotNull(body);
Assert.True(body!.SelectionApplied); Assert.True(body!.SelectionApplied);
Assert.Null(body.TargetState.LockedTargetId); Assert.Null(body.TargetState.LockedTargetId);
@ -233,33 +233,35 @@ public class TargetingApiTests
[Fact] [Fact]
public async Task GetTarget_ShouldShowOutOfRange_AfterMoveWithoutSelect() public async Task GetTarget_ShouldShowOutOfRange_AfterMoveWithoutSelect()
{ // Act {
// Arrange // Arrange
await using var factory = new InMemoryWebApplicationFactory(); await using var factory = new InMemoryWebApplicationFactory();
var client = factory.CreateClient(); var client = factory.CreateClient();
// Assert
Assert.Equal( // Act
HttpStatusCode.OK, var selectResponse = await client.PostAsJsonAsync(
(await client.PostAsJsonAsync(
"/game/players/dev-local-1/target/select", "/game/players/dev-local-1/target/select",
new TargetSelectRequest new TargetSelectRequest
{ {
SchemaVersion = TargetSelectRequest.CurrentSchemaVersion, SchemaVersion = TargetSelectRequest.CurrentSchemaVersion,
TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId, TargetId = PrototypeTargetRegistry.PrototypeTargetAlphaId,
})).StatusCode); });
var move = new MoveCommandRequest var move = new MoveCommandRequest
{ {
SchemaVersion = MoveCommandRequest.CurrentSchemaVersion, SchemaVersion = MoveCommandRequest.CurrentSchemaVersion,
Target = new PositionVector { X = 50, Y = 0, Z = 50 }, Target = new PositionVector { X = 50, Y = 0, Z = 50 },
}; };
Assert.Equal(HttpStatusCode.OK, (await client.PostAsJsonAsync("/game/players/dev-local-1/move", move)).StatusCode); var moveResponse = await client.PostAsJsonAsync("/game/players/dev-local-1/move", move);
var get = await client.GetAsync("/game/players/dev-local-1/target"); var get = await client.GetAsync("/game/players/dev-local-1/target");
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
var body = await get.Content.ReadFromJsonAsync<PlayerTargetStateResponse>(); var body = await get.Content.ReadFromJsonAsync<PlayerTargetStateResponse>();
// Assert
Assert.Equal(HttpStatusCode.OK, selectResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, moveResponse.StatusCode);
Assert.Equal(HttpStatusCode.OK, get.StatusCode);
Assert.NotNull(body); Assert.NotNull(body);
Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body!.LockedTargetId); Assert.Equal(PrototypeTargetRegistry.PrototypeTargetAlphaId, body!.LockedTargetId);
Assert.Equal(TargetValidity.OutOfRange, body.Validity); Assert.Equal(TargetValidity.OutOfRange, body.Validity);