From 9fe376a3bbd4ea030207688c9204aa0e2a7ce98f Mon Sep 17 00:00:00 2001 From: BruceChen Date: Mon, 13 Apr 2026 16:24:23 +0000 Subject: [PATCH] Add pathing contract loader validation scaffold --- .../Contracts/PathingContractStore.cs | 144 ++++++++++++- .../Contracts/PathingPlannerContract.cs | 9 +- .../Contracts/PathingTimingBudget.cs | 11 +- .../Execution/PathPlanningContractTests.cs | 199 +++++++++++++++++- .../Pathing/pathing-planner-contracts.json | 43 ++-- .../Pathing/pathing-timing-budgets.json | 22 +- 6 files changed, 376 insertions(+), 52 deletions(-) diff --git a/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingContractStore.cs b/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingContractStore.cs index 9e83cab5..6a49d2c4 100644 --- a/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingContractStore.cs +++ b/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingContractStore.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Serialization; +using MinecraftClient.Pathing.Core; namespace MinecraftClient.Tests.Pathing.Execution.Contracts; @@ -21,21 +22,36 @@ public sealed class PathingContractStore string rootPath = FindRepositoryRoot(); string pathingDir = Path.Combine(rootPath, "MinecraftClient.Tests", "TestData", "Pathing"); + string plannerJson = File.ReadAllText(Path.Combine(pathingDir, "pathing-planner-contracts.json")); + string timingJson = File.ReadAllText(Path.Combine(pathingDir, "pathing-timing-budgets.json")); + + return LoadFromJson(plannerJson, timingJson); + } + + public static PathingContractStore LoadFromJson(string plannerJson, string timingJson) + { + ArgumentException.ThrowIfNullOrWhiteSpace(plannerJson); + ArgumentException.ThrowIfNullOrWhiteSpace(timingJson); + var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; options.Converters.Add(new JsonStringEnumConverter()); - string plannerJson = File.ReadAllText(Path.Combine(pathingDir, "pathing-planner-contracts.json")); - string timingJson = File.ReadAllText(Path.Combine(pathingDir, "pathing-timing-budgets.json")); - - Dictionary plannerContracts = JsonSerializer.Deserialize>(plannerJson, options) + PathingPlannerContract[] plannerContracts = JsonSerializer.Deserialize(plannerJson, options) ?? throw new InvalidOperationException("Failed to deserialize planner contracts."); - Dictionary timingBudgets = JsonSerializer.Deserialize>(timingJson, options) + PathingTimingBudget[] timingBudgets = JsonSerializer.Deserialize(timingJson, options) ?? throw new InvalidOperationException("Failed to deserialize timing budgets."); - return new PathingContractStore(plannerContracts, timingBudgets); + Dictionary plannerByScenario = BuildPlannerDictionary(plannerContracts); + Dictionary timingByScenario = BuildTimingDictionary(timingBudgets); + ValidateScenarioSetConsistency(plannerByScenario, timingByScenario); + ValidateScenarioSegmentAlignment(plannerByScenario, timingByScenario); + + return new PathingContractStore( + plannerByScenario, + timingByScenario); } public PathingPlannerContract GetPlanner(string id) @@ -68,4 +84,120 @@ public sealed class PathingContractStore throw new DirectoryNotFoundException("Unable to locate repository root from current test execution directory."); } + + private static Dictionary BuildPlannerDictionary(IEnumerable contracts) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (PathingPlannerContract contract in contracts) + { + PathingPlannerContract normalized = ValidateAndNormalizePlanner(contract); + if (!result.TryAdd(normalized.ScenarioId, normalized)) + throw new InvalidDataException($"Duplicate planner contract scenario id '{normalized.ScenarioId}'."); + } + + return result; + } + + private static Dictionary BuildTimingDictionary(IEnumerable budgets) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (PathingTimingBudget budget in budgets) + { + PathingTimingBudget normalized = ValidateAndNormalizeTiming(budget); + if (!result.TryAdd(normalized.ScenarioId, normalized)) + throw new InvalidDataException($"Duplicate timing budget scenario id '{normalized.ScenarioId}'."); + } + + return result; + } + + private static PathingPlannerContract ValidateAndNormalizePlanner(PathingPlannerContract contract) + { + if (string.IsNullOrWhiteSpace(contract.ScenarioId)) + throw new InvalidDataException("Planner contract has blank scenario id."); + + if (contract.Segments is null || contract.Segments.Count == 0) + throw new InvalidDataException($"Planner contract '{contract.ScenarioId}' must contain at least one segment."); + + var normalizedSegments = new List(contract.Segments.Count); + for (int i = 0; i < contract.Segments.Count; i++) + { + PathingPlannerSegmentContract segment = contract.Segments[i]; + normalizedSegments.Add(segment); + } + + return contract with { Segments = normalizedSegments.AsReadOnly() }; + } + + private static PathingTimingBudget ValidateAndNormalizeTiming(PathingTimingBudget budget) + { + if (string.IsNullOrWhiteSpace(budget.ScenarioId)) + throw new InvalidDataException("Timing budget has blank scenario id."); + + if (budget.Segments is null || budget.Segments.Count == 0) + throw new InvalidDataException($"Timing budget '{budget.ScenarioId}' must contain at least one segment."); + + if (budget.ExpectedTotalTicks < 0 || budget.MaxTotalTicks < 0) + throw new InvalidDataException($"Timing budget '{budget.ScenarioId}' total ticks must be nonnegative."); + + if (budget.ExpectedTotalTicks > budget.MaxTotalTicks) + throw new InvalidDataException($"Timing budget '{budget.ScenarioId}' has ExpectedTotalTicks greater than MaxTotalTicks."); + + var normalizedSegments = new List(budget.Segments.Count); + for (int i = 0; i < budget.Segments.Count; i++) + { + PathingSegmentTimingBudget segment = budget.Segments[i]; + if (segment.ExpectedTicks < 0 || segment.MaxTicks < 0) + throw new InvalidDataException($"Timing budget '{budget.ScenarioId}' segment {i} has negative tick values."); + + if (segment.ExpectedTicks > segment.MaxTicks) + throw new InvalidDataException($"Timing budget '{budget.ScenarioId}' segment {i} has ExpectedTicks greater than MaxTicks."); + + normalizedSegments.Add(segment); + } + + return budget with { Segments = normalizedSegments.AsReadOnly() }; + } + + private static void ValidateScenarioSetConsistency( + IReadOnlyDictionary plannersByScenario, + IReadOnlyDictionary timingsByScenario) + { + string[] plannerOnly = plannersByScenario.Keys.Except(timingsByScenario.Keys, StringComparer.Ordinal).OrderBy(id => id, StringComparer.Ordinal).ToArray(); + string[] timingOnly = timingsByScenario.Keys.Except(plannersByScenario.Keys, StringComparer.Ordinal).OrderBy(id => id, StringComparer.Ordinal).ToArray(); + + if (plannerOnly.Length == 0 && timingOnly.Length == 0) + return; + + string plannerOnlyList = plannerOnly.Length == 0 ? "" : string.Join(", ", plannerOnly); + string timingOnlyList = timingOnly.Length == 0 ? "" : string.Join(", ", timingOnly); + throw new InvalidDataException( + $"Planner/timing scenario set mismatch. Missing timing entries for: {plannerOnlyList}. Missing planner entries for: {timingOnlyList}."); + } + + private static void ValidateScenarioSegmentAlignment( + IReadOnlyDictionary plannersByScenario, + IReadOnlyDictionary timingsByScenario) + { + foreach ((string scenarioId, PathingPlannerContract planner) in plannersByScenario) + { + PathingTimingBudget timing = timingsByScenario[scenarioId]; + if (planner.Segments.Count != timing.Segments.Count) + { + throw new InvalidDataException( + $"Scenario '{scenarioId}' segment count mismatch. Planner has {planner.Segments.Count} segments, timing has {timing.Segments.Count}."); + } + + for (int i = 0; i < planner.Segments.Count; i++) + { + MoveType plannerMove = planner.Segments[i].MoveType; + MoveType timingMove = timing.Segments[i].MoveType; + if (plannerMove != timingMove) + { + throw new InvalidDataException( + $"Scenario '{scenarioId}' segment {i} move mismatch. Planner has {plannerMove}, timing has {timingMove}."); + } + } + } + } } diff --git a/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingPlannerContract.cs b/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingPlannerContract.cs index c6770813..7e004da7 100644 --- a/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingPlannerContract.cs +++ b/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingPlannerContract.cs @@ -5,10 +5,11 @@ namespace MinecraftClient.Tests.Pathing.Execution.Contracts; public readonly record struct PathingBlock(int X, int Y, int Z); public sealed record PathingPlannerSegmentContract( - MoveType Move, - PathingBlock From, - PathingBlock To); + MoveType MoveType, + PathingBlock StartBlock, + PathingBlock EndBlock); public sealed record PathingPlannerContract( + string ScenarioId, PathStatus ExpectedStatus, - PathingPlannerSegmentContract[] Segments); + IReadOnlyList Segments); diff --git a/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingTimingBudget.cs b/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingTimingBudget.cs index 68c1273b..b7126274 100644 --- a/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingTimingBudget.cs +++ b/MinecraftClient.Tests/Pathing/Execution/Contracts/PathingTimingBudget.cs @@ -3,9 +3,12 @@ using MinecraftClient.Pathing.Core; namespace MinecraftClient.Tests.Pathing.Execution.Contracts; public sealed record PathingSegmentTimingBudget( - MoveType Move, - int BudgetMs); + MoveType MoveType, + int ExpectedTicks, + int MaxTicks); public sealed record PathingTimingBudget( - int TotalBudgetMs, - PathingSegmentTimingBudget[] Segments); + string ScenarioId, + int ExpectedTotalTicks, + int MaxTotalTicks, + IReadOnlyList Segments); diff --git a/MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs b/MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs index 97e5bcf3..d8ae6e19 100644 --- a/MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs +++ b/MinecraftClient.Tests/Pathing/Execution/PathPlanningContractTests.cs @@ -13,17 +13,202 @@ public sealed class PathPlanningContractTests PathingPlannerContract contract = store.GetPlanner("manager-accepted-ascend-chain"); + Assert.Equal("manager-accepted-ascend-chain", contract.ScenarioId); Assert.Equal(PathStatus.Success, contract.ExpectedStatus); - Assert.Equal(6, contract.Segments.Length); + Assert.Equal(6, contract.Segments.Count); PathingPlannerSegmentContract firstSegment = contract.Segments[0]; - Assert.Equal(MoveType.Diagonal, firstSegment.Move); - Assert.Equal(new PathingBlock(171, 80, 160), firstSegment.From); - Assert.Equal(new PathingBlock(172, 80, 161), firstSegment.To); + Assert.Equal(MoveType.Diagonal, firstSegment.MoveType); + Assert.Equal(new PathingBlock(171, 80, 160), firstSegment.StartBlock); + Assert.Equal(new PathingBlock(172, 80, 161), firstSegment.EndBlock); PathingPlannerSegmentContract lastSegment = contract.Segments[5]; - Assert.Equal(MoveType.Ascend, lastSegment.Move); - Assert.Equal(new PathingBlock(176, 82, 162), lastSegment.From); - Assert.Equal(new PathingBlock(177, 83, 162), lastSegment.To); + Assert.Equal(MoveType.Ascend, lastSegment.MoveType); + Assert.Equal(new PathingBlock(176, 82, 162), lastSegment.StartBlock); + Assert.Equal(new PathingBlock(177, 83, 162), lastSegment.EndBlock); + } + + [Fact] + public void Get_ManagerAcceptedAscendChain_LoadsTimingScaffoldShape() + { + var store = PathingContractStore.LoadFromRepositoryRoot(); + + PathingTimingBudget timing = store.GetTiming("manager-accepted-ascend-chain"); + + Assert.Equal("manager-accepted-ascend-chain", timing.ScenarioId); + Assert.Equal(6, timing.Segments.Count); + Assert.True(timing.ExpectedTotalTicks <= timing.MaxTotalTicks); + + PathingSegmentTimingBudget firstSegment = timing.Segments[0]; + Assert.Equal(MoveType.Diagonal, firstSegment.MoveType); + Assert.True(firstSegment.ExpectedTicks <= firstSegment.MaxTicks); + + foreach (PathingSegmentTimingBudget segment in timing.Segments) + { + Assert.True(segment.ExpectedTicks <= segment.MaxTicks); + } + } + + [Fact] + public void LoadFromJson_RejectsTimingSegment_WhenExpectedExceedsMax() + { + const string plannerJson = """ +[ + { + "scenarioId": "manager-accepted-ascend-chain", + "expectedStatus": "Success", + "segments": [ + { + "moveType": "Diagonal", + "startBlock": { "x": 171, "y": 80, "z": 160 }, + "endBlock": { "x": 172, "y": 80, "z": 161 } + } + ] + } +] +"""; + const string timingJson = """ +[ + { + "scenarioId": "manager-accepted-ascend-chain", + "expectedTotalTicks": 1, + "maxTotalTicks": 1, + "segments": [ + { "moveType": "Diagonal", "expectedTicks": 2, "maxTicks": 1 } + ] + } +] +"""; + + InvalidDataException error = Assert.Throws( + () => PathingContractStore.LoadFromJson(plannerJson, timingJson)); + Assert.Contains("manager-accepted-ascend-chain", error.Message); + } + + [Fact] + public void LoadFromJson_Rejects_WhenPlannerAndTimingScenarioSetsMismatch() + { + const string plannerJson = """ +[ + { + "scenarioId": "planner-only", + "expectedStatus": "Success", + "segments": [ + { + "moveType": "Traverse", + "startBlock": { "x": 0, "y": 80, "z": 0 }, + "endBlock": { "x": 1, "y": 80, "z": 0 } + } + ] + } +] +"""; + const string timingJson = """ +[ + { + "scenarioId": "timing-only", + "expectedTotalTicks": 1, + "maxTotalTicks": 1, + "segments": [ + { "moveType": "Traverse", "expectedTicks": 1, "maxTicks": 1 } + ] + } +] +"""; + + InvalidDataException error = Assert.Throws( + () => PathingContractStore.LoadFromJson(plannerJson, timingJson)); + Assert.Contains("planner-only", error.Message); + Assert.Contains("timing-only", error.Message); + } + + [Fact] + public void LoadFromJson_RejectsDuplicatePlannerScenarioId() + { + const string plannerJson = """ +[ + { + "scenarioId": "dup", + "expectedStatus": "Success", + "segments": [ + { + "moveType": "Traverse", + "startBlock": { "x": 0, "y": 80, "z": 0 }, + "endBlock": { "x": 1, "y": 80, "z": 0 } + } + ] + }, + { + "scenarioId": "dup", + "expectedStatus": "Success", + "segments": [ + { + "moveType": "Traverse", + "startBlock": { "x": 1, "y": 80, "z": 0 }, + "endBlock": { "x": 2, "y": 80, "z": 0 } + } + ] + } +] +"""; + const string timingJson = """ +[ + { + "scenarioId": "dup", + "expectedTotalTicks": 1, + "maxTotalTicks": 1, + "segments": [ + { "moveType": "Traverse", "expectedTicks": 1, "maxTicks": 1 } + ] + } +] +"""; + + InvalidDataException error = Assert.Throws( + () => PathingContractStore.LoadFromJson(plannerJson, timingJson)); + Assert.Contains("Duplicate planner contract scenario id", error.Message); + } + + [Fact] + public void LoadFromJson_Rejects_WhenPlannerAndTimingMoveSequenceDiffer() + { + const string plannerJson = """ +[ + { + "scenarioId": "sequence-mismatch", + "expectedStatus": "Success", + "segments": [ + { + "moveType": "Traverse", + "startBlock": { "x": 0, "y": 80, "z": 0 }, + "endBlock": { "x": 1, "y": 80, "z": 0 } + }, + { + "moveType": "Ascend", + "startBlock": { "x": 1, "y": 80, "z": 0 }, + "endBlock": { "x": 2, "y": 81, "z": 0 } + } + ] + } +] +"""; + const string timingJson = """ +[ + { + "scenarioId": "sequence-mismatch", + "expectedTotalTicks": 2, + "maxTotalTicks": 2, + "segments": [ + { "moveType": "Traverse", "expectedTicks": 1, "maxTicks": 1 }, + { "moveType": "Diagonal", "expectedTicks": 1, "maxTicks": 1 } + ] + } +] +"""; + + InvalidDataException error = Assert.Throws( + () => PathingContractStore.LoadFromJson(plannerJson, timingJson)); + Assert.Contains("sequence-mismatch", error.Message); + Assert.Contains("segment 1", error.Message); } } diff --git a/MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json b/MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json index 1bb20015..baaf9d41 100644 --- a/MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json +++ b/MinecraftClient.Tests/TestData/Pathing/pathing-planner-contracts.json @@ -1,37 +1,38 @@ -{ - "manager-accepted-ascend-chain": { +[ + { + "scenarioId": "manager-accepted-ascend-chain", "expectedStatus": "Success", "segments": [ { - "move": "Diagonal", - "from": { "x": 171, "y": 80, "z": 160 }, - "to": { "x": 172, "y": 80, "z": 161 } + "moveType": "Diagonal", + "startBlock": { "x": 171, "y": 80, "z": 160 }, + "endBlock": { "x": 172, "y": 80, "z": 161 } }, { - "move": "Diagonal", - "from": { "x": 172, "y": 80, "z": 161 }, - "to": { "x": 173, "y": 80, "z": 162 } + "moveType": "Diagonal", + "startBlock": { "x": 172, "y": 80, "z": 161 }, + "endBlock": { "x": 173, "y": 80, "z": 162 } }, { - "move": "Traverse", - "from": { "x": 173, "y": 80, "z": 162 }, - "to": { "x": 174, "y": 80, "z": 162 } + "moveType": "Traverse", + "startBlock": { "x": 173, "y": 80, "z": 162 }, + "endBlock": { "x": 174, "y": 80, "z": 162 } }, { - "move": "Ascend", - "from": { "x": 174, "y": 80, "z": 162 }, - "to": { "x": 175, "y": 81, "z": 162 } + "moveType": "Ascend", + "startBlock": { "x": 174, "y": 80, "z": 162 }, + "endBlock": { "x": 175, "y": 81, "z": 162 } }, { - "move": "Ascend", - "from": { "x": 175, "y": 81, "z": 162 }, - "to": { "x": 176, "y": 82, "z": 162 } + "moveType": "Ascend", + "startBlock": { "x": 175, "y": 81, "z": 162 }, + "endBlock": { "x": 176, "y": 82, "z": 162 } }, { - "move": "Ascend", - "from": { "x": 176, "y": 82, "z": 162 }, - "to": { "x": 177, "y": 83, "z": 162 } + "moveType": "Ascend", + "startBlock": { "x": 176, "y": 82, "z": 162 }, + "endBlock": { "x": 177, "y": 83, "z": 162 } } ] } -} +] diff --git a/MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json b/MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json index 156d40db..2d935b5a 100644 --- a/MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json +++ b/MinecraftClient.Tests/TestData/Pathing/pathing-timing-budgets.json @@ -1,13 +1,15 @@ -{ - "manager-accepted-ascend-chain": { - "totalBudgetMs": 0, +[ + { + "scenarioId": "manager-accepted-ascend-chain", + "expectedTotalTicks": 0, + "maxTotalTicks": 0, "segments": [ - { "move": "Diagonal", "budgetMs": 0 }, - { "move": "Diagonal", "budgetMs": 0 }, - { "move": "Traverse", "budgetMs": 0 }, - { "move": "Ascend", "budgetMs": 0 }, - { "move": "Ascend", "budgetMs": 0 }, - { "move": "Ascend", "budgetMs": 0 } + { "moveType": "Diagonal", "expectedTicks": 0, "maxTicks": 0 }, + { "moveType": "Diagonal", "expectedTicks": 0, "maxTicks": 0 }, + { "moveType": "Traverse", "expectedTicks": 0, "maxTicks": 0 }, + { "moveType": "Ascend", "expectedTicks": 0, "maxTicks": 0 }, + { "moveType": "Ascend", "expectedTicks": 0, "maxTicks": 0 }, + { "moveType": "Ascend", "expectedTicks": 0, "maxTicks": 0 } ] } -} +]