From 1e2ed70ba1e07be51e08dc0b2eee9207627946d9 Mon Sep 17 00:00:00 2001 From: Coasterpete <83011124+Coasterpete@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:46:30 -0400 Subject: [PATCH 1/2] Add TrackLayoutPackageV2 import mapper --- .../V2/TrackLayoutPackageV2Json.cs | 72 ++++ .../V2/TrackLayoutPackageV2Mapper.cs | 262 ++++++++++++ .../V2/TrackLayoutPackageV2Validator.cs | 4 +- .../IO/TrackLayoutPackageV2MapperTests.cs | 393 ++++++++++++++++++ .../IO/TrackLayoutPackageV2ValidatorTests.cs | 2 + 5 files changed, 732 insertions(+), 1 deletion(-) create mode 100644 Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Mapper.cs create mode 100644 Quantum.Tests/IO/TrackLayoutPackageV2MapperTests.cs diff --git a/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Json.cs b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Json.cs index 8be6d99..d420284 100644 --- a/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Json.cs +++ b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Json.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Globalization; using System.Text.Json; using System.Text.Json.Serialization; @@ -48,6 +50,76 @@ public static TrackLayoutPackageV2Dto Deserialize(string json) return dto; } + public static TrackLayoutPackageV2ImportResult Import(string json) + { + if (json == null) + { + throw new ArgumentNullException(nameof(json)); + } + + try + { + return TrackLayoutPackageV2Mapper.Import(Deserialize(json)); + } + catch (JsonException ex) + { + return TrackLayoutPackageV2ImportResult.Failure( + new[] + { + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.MalformedJson, + "json", + CreateMalformedJsonMessage(ex)) + }); + } + } + + private static string CreateMalformedJsonMessage(JsonException exception) + { + JsonException detail = exception.InnerException as JsonException ?? exception; + string message = exception.Message; + if (!ReferenceEquals(detail, exception)) + { + message += " JSON parser detail: " + detail.Message; + } + + string context = CreateJsonExceptionContext(detail); + if (context.Length != 0) + { + message += " " + context; + } + + return message; + } + + private static string CreateJsonExceptionContext(JsonException exception) + { + var parts = new List(); + if (!string.IsNullOrEmpty(exception.Path)) + { + parts.Add("path '" + exception.Path + "'"); + } + + if (exception.LineNumber.HasValue) + { + parts.Add("line " + exception.LineNumber.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (exception.BytePositionInLine.HasValue) + { + parts.Add( + "byte position " + + exception.BytePositionInLine.Value.ToString(CultureInfo.InvariantCulture)); + } + + if (parts.Count == 0) + { + return string.Empty; + } + + return "Context: " + string.Join(", ", parts) + "."; + } + private static JsonSerializerOptions CreateOptions(bool indented) { return new JsonSerializerOptions diff --git a/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Mapper.cs b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Mapper.cs new file mode 100644 index 0000000..33df0d3 --- /dev/null +++ b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Mapper.cs @@ -0,0 +1,262 @@ +using System; +using System.Collections.Generic; +using Quantum.Math; +using Quantum.Track; +using Quantum.Track.Authoring; + +namespace Quantum.IO.TrackLayout.V2 +{ + public sealed class TrackLayoutPackageV2ImportResult + { + public TrackLayoutPackageV2ImportResult( + bool success, + TrackAuthoringDefinition? definition, + HeartlineOffset? heartlineOffset, + IReadOnlyList diagnostics) + { + Success = success; + Definition = definition; + HeartlineOffset = heartlineOffset; + Diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); + } + + public bool Success { get; } + + public TrackAuthoringDefinition? Definition { get; } + + public HeartlineOffset? HeartlineOffset { get; } + + public IReadOnlyList Diagnostics { get; } + + internal static TrackLayoutPackageV2ImportResult Failure( + IReadOnlyList diagnostics) + { + return new TrackLayoutPackageV2ImportResult(false, null, null, diagnostics); + } + } + + /// + /// Maps TrackLayoutPackageV2 DTOs into backend authoring definitions. + /// + public static class TrackLayoutPackageV2Mapper + { + public static TrackLayoutPackageV2ImportResult Import(TrackLayoutPackageV2Dto dto) + { + if (dto == null) + { + throw new ArgumentNullException(nameof(dto)); + } + + IReadOnlyList diagnostics = + TrackLayoutPackageV2Validator.Validate(dto); + if (diagnostics.Count != 0) + { + return TrackLayoutPackageV2ImportResult.Failure(diagnostics); + } + + try + { + var sections = new List(dto.Sections.Length); + for (int i = 0; i < dto.Sections.Length; i++) + { + sections.Add(MapSection(dto.Sections[i])); + } + + TrackStartPose startPose = MapStartPose(dto.StartPose); + TrackBankingDefinition? banking = MapBanking(dto.Banking); + TrackAuthoringDefinition definition = banking == null + ? new TrackAuthoringDefinition(sections, startPose) + : new TrackAuthoringDefinition(sections, startPose, banking); + HeartlineOffset? heartlineOffset = MapHeartline(dto.Heartline); + + return new TrackLayoutPackageV2ImportResult( + true, + definition, + heartlineOffset, + Array.Empty()); + } + catch (Exception ex) when ( + ex is ArgumentException || + ex is ArgumentOutOfRangeException || + ex is InvalidOperationException || + ex is NotSupportedException) + { + return TrackLayoutPackageV2ImportResult.Failure( + new[] + { + new TrackLayoutPackageV2ValidationDiagnostic( + TrackLayoutPackageV2ValidationCode.MappingFailed, + "dto", + "Validated TrackLayoutPackageV2 DTO could not be mapped; this likely indicates " + + "validator/mapper parity drift. Mapper detail: " + ex.Message) + }); + } + } + + private static GeometricSectionDefinition MapSection(TrackLayoutSectionV2Dto section) + { + switch (section.Kind) + { + case TrackLayoutPackageV2Vocabulary.StraightSectionKind: + return new StraightSectionDefinition( + section.Id, + section.Length, + section.RollRadians); + + case TrackLayoutPackageV2Vocabulary.ConstantCurvatureSectionKind: + return new ConstantCurvatureSectionDefinition( + section.Id, + section.Length, + section.Radius!.Value, + section.RollRadians); + + case TrackLayoutPackageV2Vocabulary.CurvatureTransitionSectionKind: + return new CurvatureTransitionSectionDefinition( + section.Id, + section.Length, + section.StartCurvature!.Value, + section.EndCurvature!.Value, + MapCurvatureInterpolation(section.InterpolationMode), + section.RollRadians); + + case TrackLayoutPackageV2Vocabulary.SpatialSectionKind: + return new SpatialSectionDefinition( + section.Id, + section.Length, + MapVectors(section.ControlPoints!), + section.Degree!.Value, + section.Weights!, + section.RollRadians); + + default: + throw new NotSupportedException( + "Unsupported TrackLayoutPackageV2 section kind '" + section.Kind + "'."); + } + } + + private static TrackStartPose MapStartPose(TrackStartPoseV2Dto startPose) + { + return new TrackStartPose( + MapVector(startPose.Position), + MapVector(startPose.Tangent), + MapVector(startPose.Normal), + MapVector(startPose.Binormal)); + } + + private static TrackBankingDefinition? MapBanking(TrackBankingV2Dto? banking) + { + if (banking == null) + { + return null; + } + + var keys = new BankingProfileKey[banking.Keys.Length]; + for (int i = 0; i < banking.Keys.Length; i++) + { + TrackBankingKeyV2Dto key = banking.Keys[i]; + keys[i] = new BankingProfileKey( + key.Distance, + key.RollRadians, + MapBankingInterpolation(key.InterpolationToNext)); + } + + return new TrackBankingDefinition(keys); + } + + private static HeartlineOffset? MapHeartline(TrackHeartlineV2Dto? heartline) + { + if (heartline == null) + { + return null; + } + + if (!string.Equals( + heartline.Kind, + TrackLayoutPackageV2Vocabulary.HeartlineKindConstantOffset, + StringComparison.Ordinal) || + !string.Equals( + heartline.DistanceDomain, + TrackLayoutPackageV2Vocabulary.HeartlineDistanceDomainCenterlineStation, + StringComparison.Ordinal) || + !string.Equals( + heartline.AxisSource, + TrackLayoutPackageV2Vocabulary.HeartlineAxisSourceSampledFrame, + StringComparison.Ordinal)) + { + throw new NotSupportedException( + "Only constantOffset heartline offsets over centerlineStation using sampledFrame axes are supported."); + } + + return new HeartlineOffset(heartline.NormalOffset, heartline.LateralOffset); + } + + private static CurvatureTransitionInterpolationMode MapCurvatureInterpolation( + string? interpolationMode) + { + switch (interpolationMode) + { + case TrackLayoutPackageV2Vocabulary.CurvatureInterpolationLinear: + return CurvatureTransitionInterpolationMode.Linear; + + default: + throw new ArgumentOutOfRangeException( + nameof(interpolationMode), + interpolationMode, + "Unsupported curvature transition interpolation mode."); + } + } + + private static BankingProfileInterpolationMode MapBankingInterpolation( + string interpolationToNext) + { + switch (interpolationToNext) + { + case TrackLayoutPackageV2Vocabulary.BankingInterpolationConstant: + return BankingProfileInterpolationMode.Constant; + + case TrackLayoutPackageV2Vocabulary.BankingInterpolationLinear: + return BankingProfileInterpolationMode.Linear; + + case TrackLayoutPackageV2Vocabulary.BankingInterpolationSmoothStep: + return BankingProfileInterpolationMode.SmoothStep; + + case TrackLayoutPackageV2Vocabulary.BankingInterpolationQuadratic: + return BankingProfileInterpolationMode.Quadratic; + + case TrackLayoutPackageV2Vocabulary.BankingInterpolationCubic: + return BankingProfileInterpolationMode.Cubic; + + case TrackLayoutPackageV2Vocabulary.BankingInterpolationQuartic: + return BankingProfileInterpolationMode.Quartic; + + case TrackLayoutPackageV2Vocabulary.BankingInterpolationQuintic: + return BankingProfileInterpolationMode.Quintic; + + case TrackLayoutPackageV2Vocabulary.BankingInterpolationSinusoidal: + return BankingProfileInterpolationMode.Sinusoidal; + + default: + throw new ArgumentOutOfRangeException( + nameof(interpolationToNext), + interpolationToNext, + "Unsupported banking interpolation mode."); + } + } + + private static Vector3d[] MapVectors(TrackLayoutVector3dV2Dto[] vectors) + { + var result = new Vector3d[vectors.Length]; + for (int i = 0; i < vectors.Length; i++) + { + result[i] = MapVector(vectors[i]); + } + + return result; + } + + private static Vector3d MapVector(TrackLayoutVector3dV2Dto vector) + { + return new Vector3d(vector.X, vector.Y, vector.Z); + } + } +} diff --git a/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Validator.cs b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Validator.cs index 706cd47..1e0742a 100644 --- a/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Validator.cs +++ b/Quantum.IO/TrackLayout/V2/TrackLayoutPackageV2Validator.cs @@ -33,7 +33,9 @@ public enum TrackLayoutPackageV2ValidationCode InvalidBankingDomain = 24, InvalidHeartlineKind = 25, InvalidHeartlineDistanceDomain = 26, - InvalidHeartlineAxisSource = 27 + InvalidHeartlineAxisSource = 27, + MalformedJson = 28, + MappingFailed = 29 } public sealed class TrackLayoutPackageV2ValidationDiagnostic diff --git a/Quantum.Tests/IO/TrackLayoutPackageV2MapperTests.cs b/Quantum.Tests/IO/TrackLayoutPackageV2MapperTests.cs new file mode 100644 index 0000000..24d1199 --- /dev/null +++ b/Quantum.Tests/IO/TrackLayoutPackageV2MapperTests.cs @@ -0,0 +1,393 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Quantum.IO.TrackLayout.V2; +using Quantum.Math; +using Quantum.Track; +using Quantum.Track.Authoring; + +namespace Quantum.Tests; + +public sealed class TrackLayoutPackageV2MapperTests +{ + private const double Tolerance = 1e-6; + + [Fact] + public void JsonImport_MinimalValidV2Package_MapsCompilesAndEvaluates() + { + string json = TrackLayoutPackageV2Json.Serialize(CreateMinimalDto()); + + TrackLayoutPackageV2ImportResult result = TrackLayoutPackageV2Json.Import(json); + + Assert.True(result.Success); + Assert.Empty(result.Diagnostics); + Assert.NotNull(result.Definition); + Assert.False(result.HeartlineOffset.HasValue); + TrackAuthoringCompilation compilation = TrackAuthoringDocumentBuilder.Compile( + result.Definition!); + var evaluator = new TrackEvaluator(compilation.Document); + TrackFrame frame = evaluator.EvaluateFrameAtDistance(6.0); + + Assert.Equal(12.0, compilation.TotalLength); + Assert.NotNull(compilation.Runtime); + AssertVectorNear(new Vector3d(6.0, 0.0, 0.0), frame.Position); + AssertVectorNear(Vector3d.UnitX, frame.Tangent); + } + + [Fact] + public void Import_RepresentativeSections_MapsSupportedAuthoringSectionKinds() + { + TrackLayoutPackageV2Dto dto = CreateRepresentativeDto(); + dto.Banking = null; + + TrackLayoutPackageV2ImportResult result = TrackLayoutPackageV2Mapper.Import(dto); + + Assert.True(result.Success); + Assert.NotNull(result.Definition); + IReadOnlyList sections = result.Definition!.Sections; + Assert.Equal(4, sections.Count); + + StraightSectionDefinition straight = Assert.IsType(sections[0]); + Assert.Equal("entry", straight.Id); + Assert.Equal(10.0, straight.Length); + Assert.Equal(0.1, straight.RollRadians); + + ConstantCurvatureSectionDefinition arc = + Assert.IsType(sections[1]); + Assert.Equal("turn", arc.Id); + Assert.Equal(-30.0, arc.Radius); + + CurvatureTransitionSectionDefinition transition = + Assert.IsType(sections[2]); + Assert.Equal("transition", transition.Id); + Assert.Equal(0.02, transition.StartCurvature); + Assert.Equal(-0.01, transition.EndCurvature); + Assert.Equal(CurvatureTransitionInterpolationMode.Linear, transition.InterpolationMode); + + SpatialSectionDefinition spatial = Assert.IsType(sections[3]); + Assert.Equal("spatial", spatial.Id); + Assert.Equal(3, spatial.Degree); + Assert.Equal(4, spatial.ControlPoints.Count); + AssertVectorNear(new Vector3d(6.0, 0.0, 0.0), spatial.ControlPoints[3]); + Assert.Equal(new[] { 1.0, 1.0, 1.0, 1.0 }, spatial.Weights.ToArray()); + + TrackAuthoringCompilation compilation = TrackAuthoringDocumentBuilder.Compile( + result.Definition); + Assert.Equal(36.0, compilation.TotalLength); + Assert.NotNull(compilation.Runtime); + } + + [Fact] + public void Import_BankingAndHeartline_MapsToBackendOptInRuntimeContracts() + { + TrackLayoutPackageV2Dto dto = CreateBankedHeartlineDto(); + + TrackLayoutPackageV2ImportResult result = TrackLayoutPackageV2Mapper.Import(dto); + + Assert.True(result.Success); + Assert.NotNull(result.Definition); + Assert.NotNull(result.Definition!.Banking); + Assert.True(result.HeartlineOffset.HasValue); + + Assert.Equal(2, result.Definition.Banking!.Keys.Count); + Assert.Equal(BankingProfileInterpolationMode.Constant, result.Definition.Banking.Keys[0].InterpolationToNext); + HeartlineOffset heartlineOffset = result.HeartlineOffset!.Value; + AssertDoubleNear(1.5, heartlineOffset.NormalOffsetMeters); + AssertDoubleNear(-0.25, heartlineOffset.LateralOffsetMeters); + + TrackAuthoringCompilation compilation = TrackAuthoringDocumentBuilder.Compile( + result.Definition); + var evaluator = new TrackEvaluator(compilation.Document); + HeartlineFrame heartlineFrame = Assert.Single(HeartlineSampler.SampleAtDistances( + evaluator, + compilation.BankingProfile, + heartlineOffset, + new[] { 5.0 })); + + AssertVectorNear(new Vector3d(5.0, 0.25, 1.5), heartlineFrame.Position); + } + + [Fact] + public void Import_InvalidV2Package_ReturnsValidationDiagnosticsWithoutDefinition() + { + TrackLayoutPackageV2Dto dto = CreateMinimalDto(); + dto.Sections = Array.Empty(); + + TrackLayoutPackageV2ImportResult result = TrackLayoutPackageV2Mapper.Import(dto); + + Assert.False(result.Success); + Assert.Null(result.Definition); + Assert.False(result.HeartlineOffset.HasValue); + AssertDiagnostic( + result.Diagnostics, + TrackLayoutPackageV2ValidationCode.EmptySections, + "sections"); + } + + [Fact] + public void Import_DuplicateUnknownAndInvalidSections_AreRejectedByValidation() + { + TrackLayoutPackageV2Dto duplicate = CreateMinimalDto(); + duplicate.Sections = new[] + { + CreateStraightSection("entry", 2.0), + CreateStraightSection("entry", 3.0) + }; + + TrackLayoutPackageV2Dto unknown = CreateMinimalDto(); + unknown.Sections = new[] + { + new TrackLayoutSectionV2Dto + { + Kind = "legacy", + Id = "legacy", + Length = 1.0, + RollRadians = 0.0 + } + }; + + TrackLayoutPackageV2Dto invalid = CreateMinimalDto(); + invalid.Sections = new[] + { + CreateStraightSection("invalid-length", 0.0) + }; + + TrackLayoutPackageV2ImportResult duplicateResult = TrackLayoutPackageV2Mapper.Import(duplicate); + TrackLayoutPackageV2ImportResult unknownResult = TrackLayoutPackageV2Mapper.Import(unknown); + TrackLayoutPackageV2ImportResult invalidResult = TrackLayoutPackageV2Mapper.Import(invalid); + + AssertRejectedByValidation( + duplicateResult, + TrackLayoutPackageV2ValidationCode.DuplicateSectionId, + "sections[1].id"); + AssertRejectedByValidation( + unknownResult, + TrackLayoutPackageV2ValidationCode.UnknownSectionKind, + "sections[0].kind"); + AssertRejectedByValidation( + invalidResult, + TrackLayoutPackageV2ValidationCode.NonPositiveLength, + "sections[0].length"); + } + + [Fact] + public void JsonImport_MalformedJson_ReturnsDiagnosticWithoutDefinition() + { + TrackLayoutPackageV2ImportResult result = TrackLayoutPackageV2Json.Import("{"); + + Assert.False(result.Success); + Assert.Null(result.Definition); + Assert.False(result.HeartlineOffset.HasValue); + TrackLayoutPackageV2ValidationDiagnostic diagnostic = AssertDiagnostic( + result.Diagnostics, + TrackLayoutPackageV2ValidationCode.MalformedJson, + "json"); + Assert.Contains( + "Failed to deserialize TrackLayoutPackageV2Dto", + diagnostic.Message, + StringComparison.Ordinal); + } + + private static TrackLayoutPackageV2Dto CreateMinimalDto() + { + return new TrackLayoutPackageV2Dto + { + Metadata = new TrackLayoutMetadataV2Dto + { + Units = "meters", + SourceName = "Minimal V2 layout", + LayoutId = null + }, + Sections = new[] + { + CreateStraightSection("entry", 12.0) + }, + Banking = null, + Heartline = null + }; + } + + private static TrackLayoutPackageV2Dto CreateRepresentativeDto() + { + return new TrackLayoutPackageV2Dto + { + Metadata = new TrackLayoutMetadataV2Dto + { + Units = "meters", + SourceName = "representative", + LayoutId = "layout.m148.import-representative" + }, + StartPose = new TrackStartPoseV2Dto + { + Position = new TrackLayoutVector3dV2Dto { X = 1.0, Y = 2.0, Z = 3.0 }, + Tangent = new TrackLayoutVector3dV2Dto { X = 0.0, Y = 1.0, Z = 0.0 }, + Normal = new TrackLayoutVector3dV2Dto { X = 0.0, Y = 0.0, Z = 1.0 }, + Binormal = new TrackLayoutVector3dV2Dto { X = 1.0, Y = 0.0, Z = 0.0 } + }, + Sections = new[] + { + new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.StraightSectionKind, + Id = "entry", + Length = 10.0, + RollRadians = 0.1 + }, + new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.ConstantCurvatureSectionKind, + Id = "turn", + Length = 12.0, + RollRadians = -0.2, + Radius = -30.0 + }, + new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.CurvatureTransitionSectionKind, + Id = "transition", + Length = 8.0, + RollRadians = 0.05, + StartCurvature = 0.02, + EndCurvature = -0.01, + InterpolationMode = TrackLayoutPackageV2Vocabulary.CurvatureInterpolationLinear + }, + new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.SpatialSectionKind, + Id = "spatial", + Length = 6.0, + RollRadians = 0.25, + Degree = 3, + ControlPoints = new[] + { + new TrackLayoutVector3dV2Dto { X = 0.0, Y = 0.0, Z = 0.0 }, + new TrackLayoutVector3dV2Dto { X = 2.0, Y = 0.0, Z = 0.0 }, + new TrackLayoutVector3dV2Dto { X = 4.0, Y = 0.0, Z = 0.0 }, + new TrackLayoutVector3dV2Dto { X = 6.0, Y = 0.0, Z = 0.0 } + }, + Weights = new[] { 1.0, 1.0, 1.0, 1.0 } + } + }, + Banking = new TrackBankingV2Dto + { + Keys = new[] + { + new TrackBankingKeyV2Dto + { + Distance = 0.0, + RollRadians = 0.0, + InterpolationToNext = TrackLayoutPackageV2Vocabulary.BankingInterpolationLinear + }, + new TrackBankingKeyV2Dto + { + Distance = 10.0, + RollRadians = 0.2, + InterpolationToNext = TrackLayoutPackageV2Vocabulary.BankingInterpolationSmoothStep + }, + new TrackBankingKeyV2Dto + { + Distance = 22.0, + RollRadians = -0.35, + InterpolationToNext = TrackLayoutPackageV2Vocabulary.BankingInterpolationConstant + }, + new TrackBankingKeyV2Dto + { + Distance = 36.0, + RollRadians = 0.1, + InterpolationToNext = TrackLayoutPackageV2Vocabulary.BankingInterpolationConstant + } + } + }, + Heartline = null + }; + } + + private static TrackLayoutPackageV2Dto CreateBankedHeartlineDto() + { + return new TrackLayoutPackageV2Dto + { + Metadata = new TrackLayoutMetadataV2Dto + { + Units = "meters", + SourceName = "Banked heartline V2 import", + LayoutId = "layout.m148.banked-heartline" + }, + Sections = new[] + { + CreateStraightSection("entry", 10.0) + }, + Banking = new TrackBankingV2Dto + { + Keys = new[] + { + new TrackBankingKeyV2Dto + { + Distance = 0.0, + RollRadians = System.Math.PI * 0.5, + InterpolationToNext = TrackLayoutPackageV2Vocabulary.BankingInterpolationConstant + }, + new TrackBankingKeyV2Dto + { + Distance = 10.0, + RollRadians = System.Math.PI * 0.5, + InterpolationToNext = TrackLayoutPackageV2Vocabulary.BankingInterpolationConstant + } + } + }, + Heartline = new TrackHeartlineV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.HeartlineKindConstantOffset, + DistanceDomain = TrackLayoutPackageV2Vocabulary.HeartlineDistanceDomainCenterlineStation, + AxisSource = TrackLayoutPackageV2Vocabulary.HeartlineAxisSourceSampledFrame, + NormalOffset = 1.5, + LateralOffset = -0.25 + } + }; + } + + private static TrackLayoutSectionV2Dto CreateStraightSection(string id, double length) + { + return new TrackLayoutSectionV2Dto + { + Kind = TrackLayoutPackageV2Vocabulary.StraightSectionKind, + Id = id, + Length = length, + RollRadians = 0.0 + }; + } + + private static void AssertRejectedByValidation( + TrackLayoutPackageV2ImportResult result, + TrackLayoutPackageV2ValidationCode code, + string path) + { + Assert.False(result.Success); + Assert.Null(result.Definition); + Assert.False(result.HeartlineOffset.HasValue); + AssertDiagnostic(result.Diagnostics, code, path); + } + + private static TrackLayoutPackageV2ValidationDiagnostic AssertDiagnostic( + IReadOnlyList diagnostics, + TrackLayoutPackageV2ValidationCode code, + string path) + { + TrackLayoutPackageV2ValidationDiagnostic? diagnostic = diagnostics.FirstOrDefault( + d => d.Code == code && d.Path == path); + + Assert.NotNull(diagnostic); + return diagnostic!; + } + + private static void AssertVectorNear(Vector3d expected, Vector3d actual) + { + AssertDoubleNear(expected.X, actual.X); + AssertDoubleNear(expected.Y, actual.Y); + AssertDoubleNear(expected.Z, actual.Z); + } + + private static void AssertDoubleNear(double expected, double actual) + { + Assert.InRange(System.Math.Abs(expected - actual), 0.0, Tolerance); + } +} diff --git a/Quantum.Tests/IO/TrackLayoutPackageV2ValidatorTests.cs b/Quantum.Tests/IO/TrackLayoutPackageV2ValidatorTests.cs index d5bd45c..44e73ed 100644 --- a/Quantum.Tests/IO/TrackLayoutPackageV2ValidatorTests.cs +++ b/Quantum.Tests/IO/TrackLayoutPackageV2ValidatorTests.cs @@ -46,6 +46,8 @@ public void ValidationCodeValues_AreStable() Assert.Equal(25, (int)TrackLayoutPackageV2ValidationCode.InvalidHeartlineKind); Assert.Equal(26, (int)TrackLayoutPackageV2ValidationCode.InvalidHeartlineDistanceDomain); Assert.Equal(27, (int)TrackLayoutPackageV2ValidationCode.InvalidHeartlineAxisSource); + Assert.Equal(28, (int)TrackLayoutPackageV2ValidationCode.MalformedJson); + Assert.Equal(29, (int)TrackLayoutPackageV2ValidationCode.MappingFailed); } [Fact] From 462b4f9e7c72bc33082c4e470ed8b91d94a2d6c4 Mon Sep 17 00:00:00 2001 From: Coasterpete <83011124+Coasterpete@users.noreply.github.com> Date: Fri, 19 Jun 2026 19:51:59 -0400 Subject: [PATCH 2/2] Stabilize BankingProfile browser artifact paths --- Quantum.Debug/BankingProfileBrowserCommand.cs | 9 +++++++-- Quantum.Tests/Debug/BankingProfileBrowserCommandTests.cs | 7 ++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Quantum.Debug/BankingProfileBrowserCommand.cs b/Quantum.Debug/BankingProfileBrowserCommand.cs index 3bf176f..9d5d61c 100644 --- a/Quantum.Debug/BankingProfileBrowserCommand.cs +++ b/Quantum.Debug/BankingProfileBrowserCommand.cs @@ -101,10 +101,15 @@ private static string BuildHtml( string outputHtmlPath, JsonElement artifactRoot) { + string? outputDirectory = Path.GetDirectoryName(outputHtmlPath); + string payloadPathBase = string.IsNullOrEmpty(outputDirectory) + ? Environment.CurrentDirectory + : outputDirectory; + var payload = new BankingProfileBrowserPayload { - SourcePath = ToDisplayPath(Path.GetRelativePath(Environment.CurrentDirectory, diagnosticsJsonPath)), - OutputPath = ToDisplayPath(Path.GetRelativePath(Environment.CurrentDirectory, outputHtmlPath)), + SourcePath = ToDisplayPath(Path.GetRelativePath(payloadPathBase, diagnosticsJsonPath)), + OutputPath = ToDisplayPath(Path.GetRelativePath(payloadPathBase, outputHtmlPath)), Artifact = artifactRoot }; diff --git a/Quantum.Tests/Debug/BankingProfileBrowserCommandTests.cs b/Quantum.Tests/Debug/BankingProfileBrowserCommandTests.cs index 0551f77..742d5f8 100644 --- a/Quantum.Tests/Debug/BankingProfileBrowserCommandTests.cs +++ b/Quantum.Tests/Debug/BankingProfileBrowserCommandTests.cs @@ -115,7 +115,12 @@ public void Run_ValidDiagnosticsJson_EmbedsParseableDiagnosticsJsonPayload() Assert.Equal("Linear", samples[1].GetProperty("interpolationMode").GetString()); Assert.Equal("Constant", samples[3].GetProperty("interpolationMode").GetString()); Assert.True(samples[1].TryGetProperty("approximateRollSlopeRadPerMeter", out _)); - Assert.Contains("banking-profile-diagnostics.sample.json", root.GetProperty("sourcePath").GetString()); + Assert.Equal( + "banking-profile-diagnostics.sample.json", + root.GetProperty("sourcePath").GetString()); + Assert.Equal( + "banking-profile.browser.html", + root.GetProperty("outputPath").GetString()); } finally {