test: lock short route planner and timing contracts

This commit is contained in:
BruceChen 2026-04-13 16:52:34 +00:00
parent b9bff02107
commit ba1dc32ccb
9 changed files with 339 additions and 4 deletions

View file

@ -116,8 +116,11 @@ public sealed class PathingContractStore
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.");
if (contract.Segments is null)
throw new InvalidDataException($"Planner contract '{contract.ScenarioId}' has null segments.");
if (contract.ExpectedStatus != PathStatus.Failed && contract.Segments.Count == 0)
throw new InvalidDataException($"Planner contract '{contract.ScenarioId}' must contain at least one segment unless the expected status is Failed.");
var normalizedSegments = new List<PathingPlannerSegmentContract>(contract.Segments.Count);
for (int i = 0; i < contract.Segments.Count; i++)
@ -134,8 +137,8 @@ public sealed class PathingContractStore
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.Segments is null)
throw new InvalidDataException($"Timing budget '{budget.ScenarioId}' has null segments.");
if (budget.ExpectedTotalTicks < 0 || budget.MaxTotalTicks < 0)
throw new InvalidDataException($"Timing budget '{budget.ScenarioId}' total ticks must be nonnegative.");
@ -143,6 +146,9 @@ public sealed class PathingContractStore
if (budget.ExpectedTotalTicks > budget.MaxTotalTicks)
throw new InvalidDataException($"Timing budget '{budget.ScenarioId}' has ExpectedTotalTicks greater than MaxTotalTicks.");
if (budget.Segments.Count == 0 && (budget.ExpectedTotalTicks != 0 || budget.MaxTotalTicks != 0))
throw new InvalidDataException($"Timing budget '{budget.ScenarioId}' must use zero totals when it has no segments.");
var normalizedSegments = new List<PathingSegmentTimingBudget>(budget.Segments.Count);
for (int i = 0; i < budget.Segments.Count; i++)
{

View file

@ -1,4 +1,5 @@
using MinecraftClient.Pathing.Core;
using MinecraftClient.Pathing.Execution;
using MinecraftClient.Tests.Pathing.Execution.Contracts;
using Xunit;
@ -211,4 +212,17 @@ public sealed class PathPlanningContractTests
Assert.Contains("sequence-mismatch", error.Message);
Assert.Contains("segment 1", error.Message);
}
[Theory]
[InlineData("same-move-ascend-staircase")]
[InlineData("same-move-descend-staircase")]
[InlineData("rejected-3x1-invalid-goal")]
public void Scenario_PlannerMatchesContract(string scenarioId)
{
PathingExecutionScenario scenario = PathingExecutionScenarioCatalog.Get(scenarioId);
PathResult planResult = PathingScenarioRunner.PlanOnly(scenario);
PathingPlannerContract contract = PathingContractStore.LoadFromRepositoryRoot().GetPlanner(scenarioId);
PathingContractAssert.PlannerMatches(contract, PathSegmentBuilder.FromPath(planResult.Path), planResult);
}
}

View file

@ -1,3 +1,4 @@
using MinecraftClient.Tests.Pathing.Execution.Contracts;
using Xunit;
namespace MinecraftClient.Tests.Pathing.Execution;
@ -16,4 +17,16 @@ public sealed class PathTimingContractTests
Assert.Equal(6, result.SegmentRuns.Count);
Assert.All(result.SegmentRuns, run => Assert.True(run.ElapsedTicks > 0));
}
[Theory]
[InlineData("same-move-ascend-staircase")]
[InlineData("same-move-descend-staircase")]
public void Scenario_ExecutionStaysWithinTimingBudget(string scenarioId)
{
PathingExecutionScenario scenario = PathingExecutionScenarioCatalog.Get(scenarioId);
PathingTimingBudget budget = PathingContractStore.LoadFromRepositoryRoot().GetTiming(scenarioId);
PathingScenarioResult result = PathingScenarioRunner.RunAccepted(scenario);
PathingContractAssert.TimingMatches(budget, result);
}
}

View file

@ -0,0 +1,26 @@
using MinecraftClient.Pathing.Core;
using Xunit;
using Xunit.Abstractions;
namespace MinecraftClient.Tests.Pathing.Execution;
public sealed class PathingContractBootstrapTests
{
private readonly ITestOutputHelper _output;
public PathingContractBootstrapTests(ITestOutputHelper output) => _output = output;
[Theory]
[InlineData("same-move-ascend-staircase")]
[InlineData("same-move-descend-staircase")]
[InlineData("rejected-3x1-invalid-goal")]
public void PrintShortRouteContractFragments(string scenarioId)
{
PathingExecutionScenario scenario = PathingExecutionScenarioCatalog.Get(scenarioId);
PathResult planResult = PathingScenarioRunner.PlanOnly(scenario);
_output.WriteLine(PathingContractBootstrapWriter.WritePlannerFragment(scenarioId, planResult));
if (planResult.Status == PathStatus.Success)
_output.WriteLine(PathingContractBootstrapWriter.WriteTimingFragment(scenarioId, PathingScenarioRunner.RunAccepted(scenario)));
}
}

View file

@ -16,6 +16,33 @@ internal static class PathingExecutionScenarioCatalog
StartYaw = 315f,
MaxExecutionTicks = 420
},
"same-move-ascend-staircase" => new PathingExecutionScenario
{
Id = scenarioId,
BuildWorld = BuildSameMoveAscendStaircase,
Start = new Location(340.5, 80, 340.5),
Goal = new GoalBlock(345, 85, 340),
StartYaw = 270f,
MaxExecutionTicks = 420
},
"same-move-descend-staircase" => new PathingExecutionScenario
{
Id = scenarioId,
BuildWorld = BuildSameMoveDescendStaircase,
Start = new Location(362.5, 85, 360.5),
Goal = new GoalBlock(367, 80, 360),
StartYaw = 270f,
MaxExecutionTicks = 420
},
"rejected-3x1-invalid-goal" => new PathingExecutionScenario
{
Id = scenarioId,
BuildWorld = BuildRejectedThreeByOneInvalidGoal,
Start = new Location(141.5, 80, 138.5),
Goal = new GoalBlock(144, 81, 138),
StartYaw = 270f,
MaxExecutionTicks = 80
},
_ => throw new ArgumentOutOfRangeException(nameof(scenarioId), scenarioId, null)
};
@ -28,4 +55,37 @@ internal static class PathingExecutionScenarioCatalog
FlatWorldTestBuilder.SetSolid(world, 177, 82, 162);
return world;
}
private static World BuildSameMoveAscendStaircase()
{
World world = FlatWorldTestBuilder.CreateStoneFloor(min: 338, max: 347);
FlatWorldTestBuilder.ClearBox(world, 340, 80, 338, 347, 86, 342);
FlatWorldTestBuilder.FillSolid(world, 341, 80, 339, 341, 80, 341);
FlatWorldTestBuilder.FillSolid(world, 342, 81, 339, 342, 81, 341);
FlatWorldTestBuilder.FillSolid(world, 343, 82, 339, 343, 82, 341);
FlatWorldTestBuilder.FillSolid(world, 344, 83, 339, 344, 83, 341);
FlatWorldTestBuilder.FillSolid(world, 345, 84, 339, 345, 84, 341);
return world;
}
private static World BuildSameMoveDescendStaircase()
{
World world = FlatWorldTestBuilder.CreateStoneFloor(min: 358, max: 369);
FlatWorldTestBuilder.ClearBox(world, 360, 79, 358, 369, 85, 362);
FlatWorldTestBuilder.FillSolid(world, 362, 84, 359, 362, 84, 361);
FlatWorldTestBuilder.FillSolid(world, 363, 83, 359, 363, 83, 361);
FlatWorldTestBuilder.FillSolid(world, 364, 82, 359, 364, 82, 361);
FlatWorldTestBuilder.FillSolid(world, 365, 81, 359, 365, 81, 361);
FlatWorldTestBuilder.FillSolid(world, 366, 80, 359, 366, 80, 361);
FlatWorldTestBuilder.FillSolid(world, 367, 79, 359, 367, 79, 361);
return world;
}
private static World BuildRejectedThreeByOneInvalidGoal()
{
World world = FlatWorldTestBuilder.CreateStoneFloor(min: 135, max: 148);
FlatWorldTestBuilder.ClearBox(world, 140, 80, 135, 148, 85, 140);
FlatWorldTestBuilder.SetSolid(world, 143, 80, 138);
return world;
}
}

View file

@ -0,0 +1,69 @@
using System.Text;
using MinecraftClient.Mapping;
using MinecraftClient.Pathing.Core;
using MinecraftClient.Pathing.Execution;
using MinecraftClient.Tests.Pathing.Execution.Contracts;
using Xunit;
using Xunit.Sdk;
namespace MinecraftClient.Tests.Pathing.Execution;
internal static class PathingContractAssert
{
internal static void PlannerMatches(PathingPlannerContract contract, IReadOnlyList<PathSegment> segments, PathResult result)
{
if (result.Status != contract.ExpectedStatus)
throw new XunitException($"planner status mismatch: expected {contract.ExpectedStatus}, got {result.Status}");
if (segments.Count != contract.Segments.Count)
throw new XunitException($"segment count mismatch: expected {contract.Segments.Count}, got {segments.Count}");
for (int i = 0; i < segments.Count; i++)
{
PathSegment actual = segments[i];
PathingPlannerSegmentContract expected = contract.Segments[i];
Assert.Equal(expected.MoveType, actual.MoveType);
Assert.Equal(expected.StartBlock, ToBlock(actual.Start));
Assert.Equal(expected.EndBlock, ToBlock(actual.End));
}
}
internal static void TimingMatches(PathingTimingBudget budget, PathingScenarioResult result)
{
if (!result.Completed)
throw new XunitException("navigation did not complete");
if (result.ReplanCount != 0)
throw new XunitException($"expected 0 replans, saw {result.ReplanCount}\n{Format(result, budget)}");
if (result.TotalTicks > budget.MaxTotalTicks)
throw new XunitException($"route exceeded budget: actual={result.TotalTicks} max={budget.MaxTotalTicks}\n{Format(result, budget)}");
if (result.SegmentRuns.Count != budget.Segments.Count)
throw new XunitException($"segment timing count mismatch: actual={result.SegmentRuns.Count} expected={budget.Segments.Count}");
for (int i = 0; i < budget.Segments.Count; i++)
{
if (result.SegmentRuns[i].ElapsedTicks > budget.Segments[i].MaxTicks)
throw new XunitException($"segment {i} exceeded budget\n{Format(result, budget)}");
}
}
private static string Format(PathingScenarioResult result, PathingTimingBudget budget)
{
var sb = new StringBuilder();
sb.AppendLine($"route actual={result.TotalTicks} expected={budget.ExpectedTotalTicks} max={budget.MaxTotalTicks}");
for (int i = 0; i < result.SegmentRuns.Count; i++)
{
PathSegmentRun actual = result.SegmentRuns[i];
PathingSegmentTimingBudget expected = budget.Segments[i];
sb.AppendLine($"seg[{i}] move={actual.MoveType} actual={actual.ElapsedTicks} expected={expected.ExpectedTicks} max={expected.MaxTicks}");
}
return sb.ToString();
}
private static PathingBlock ToBlock(Location location) =>
new((int)Math.Floor(location.X), (int)Math.Floor(location.Y), (int)Math.Floor(location.Z));
}

View file

@ -0,0 +1,62 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using MinecraftClient.Pathing.Core;
using MinecraftClient.Pathing.Execution;
namespace MinecraftClient.Tests.Pathing.Execution;
internal static class PathingContractBootstrapWriter
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true
};
static PathingContractBootstrapWriter()
{
JsonOptions.Converters.Add(new JsonStringEnumConverter());
}
internal static string WritePlannerFragment(string scenarioId, PathResult planResult)
{
IReadOnlyList<PathSegment> segments = PathSegmentBuilder.FromPath(planResult.Path);
return JsonSerializer.Serialize(new
{
scenarioId,
expectedStatus = planResult.Status,
segments = segments.Select(segment => new
{
moveType = segment.MoveType,
startBlock = ToBlockObject(segment.Start),
endBlock = ToBlockObject(segment.End)
})
}, JsonOptions);
}
internal static string WriteTimingFragment(string scenarioId, PathingScenarioResult result)
{
return JsonSerializer.Serialize(new
{
scenarioId,
expectedTotalTicks = result.TotalTicks,
maxTotalTicks = SeedMaxTicks(result.TotalTicks),
segments = result.SegmentRuns.Select(run => new
{
moveType = run.MoveType,
expectedTicks = run.ElapsedTicks,
maxTicks = SeedMaxTicks(run.ElapsedTicks)
})
}, JsonOptions);
}
private static object ToBlockObject(MinecraftClient.Mapping.Location location) => new
{
x = (int)Math.Floor(location.X),
y = (int)Math.Floor(location.Y),
z = (int)Math.Floor(location.Z)
};
private static int SeedMaxTicks(int expectedTicks) =>
expectedTicks + Math.Max(2, (int)Math.Ceiling(expectedTicks * 0.20));
}

View file

@ -34,5 +34,62 @@
"endBlock": { "x": 177, "y": 83, "z": 162 }
}
]
},
{
"scenarioId": "same-move-ascend-staircase",
"expectedStatus": "Success",
"segments": [
{
"moveType": "Ascend",
"startBlock": { "x": 340, "y": 80, "z": 340 },
"endBlock": { "x": 341, "y": 81, "z": 340 }
},
{
"moveType": "Ascend",
"startBlock": { "x": 341, "y": 81, "z": 340 },
"endBlock": { "x": 342, "y": 82, "z": 340 }
},
{
"moveType": "Ascend",
"startBlock": { "x": 342, "y": 82, "z": 340 },
"endBlock": { "x": 343, "y": 83, "z": 340 }
},
{
"moveType": "Ascend",
"startBlock": { "x": 343, "y": 83, "z": 340 },
"endBlock": { "x": 344, "y": 84, "z": 340 }
},
{
"moveType": "Ascend",
"startBlock": { "x": 344, "y": 84, "z": 340 },
"endBlock": { "x": 345, "y": 85, "z": 340 }
}
]
},
{
"scenarioId": "same-move-descend-staircase",
"expectedStatus": "Success",
"segments": [
{
"moveType": "Descend",
"startBlock": { "x": 362, "y": 85, "z": 360 },
"endBlock": { "x": 364, "y": 83, "z": 360 }
},
{
"moveType": "Descend",
"startBlock": { "x": 364, "y": 83, "z": 360 },
"endBlock": { "x": 366, "y": 81, "z": 360 }
},
{
"moveType": "Descend",
"startBlock": { "x": 366, "y": 81, "z": 360 },
"endBlock": { "x": 367, "y": 80, "z": 360 }
}
]
},
{
"scenarioId": "rejected-3x1-invalid-goal",
"expectedStatus": "Failed",
"segments": []
}
]

View file

@ -11,5 +11,33 @@
{ "moveType": "Ascend", "expectedTicks": 0, "maxTicks": 0 },
{ "moveType": "Ascend", "expectedTicks": 0, "maxTicks": 0 }
]
},
{
"scenarioId": "same-move-ascend-staircase",
"expectedTotalTicks": 56,
"maxTotalTicks": 68,
"segments": [
{ "moveType": "Ascend", "expectedTicks": 11, "maxTicks": 14 },
{ "moveType": "Ascend", "expectedTicks": 11, "maxTicks": 14 },
{ "moveType": "Ascend", "expectedTicks": 11, "maxTicks": 14 },
{ "moveType": "Ascend", "expectedTicks": 11, "maxTicks": 14 },
{ "moveType": "Ascend", "expectedTicks": 12, "maxTicks": 15 }
]
},
{
"scenarioId": "same-move-descend-staircase",
"expectedTotalTicks": 61,
"maxTotalTicks": 74,
"segments": [
{ "moveType": "Descend", "expectedTicks": 24, "maxTicks": 29 },
{ "moveType": "Descend", "expectedTicks": 25, "maxTicks": 30 },
{ "moveType": "Descend", "expectedTicks": 12, "maxTicks": 15 }
]
},
{
"scenarioId": "rejected-3x1-invalid-goal",
"expectedTotalTicks": 0,
"maxTotalTicks": 0,
"segments": []
}
]