Files
FlyShotHost/tests/Flyshot.Core.Tests/DomainModelTests.cs
yunxiao.zhu f7e2bb0e7b feat(*): 添加触发样本偏移与实发轨迹分析导出
* 为 RobotConfig 增加 trigger_sample_index_offset_cycles 配置
  * 让 DO 事件携带示教点关节角并按最接近 sample 绑定触发
  * 调整运行时 IO 地址位掩码映射并补充 ShotEvents 导出
  * 新增 2026042802-1 抓包分析脚本、数据产物与结论文档
  * 补齐配置兼容、规划绑定和运行时触发相关测试
2026-05-09 11:12:31 +08:00

262 lines
9.4 KiB
C#

using System.Text.Json;
using Flyshot.Core.Domain;
using Flyshot.Core.Planning.Sampling;
namespace Flyshot.Core.Tests;
/// <summary>
/// Verifies the Task 2 domain contracts before planning and runtime code depend on them.
/// </summary>
public sealed class DomainModelTests
{
/// <summary>
/// Ensures robot profiles keep a stable copy of joint limits and reject invalid dimensions.
/// </summary>
[Fact]
public void RobotProfile_CopiesJointLimits_AndRejectsMismatchedDof()
{
var jointLimits = new[]
{
new JointLimit("J1", 7.85, 32.72, 272.7),
new JointLimit("J2", 6.63, 27.63, 230.28)
};
var profile = new RobotProfile(
name: "LR_Mate_200iD_7L",
modelPath: "Models/LR_Mate_200iD_7L.robot",
degreesOfFreedom: 2,
jointLimits: jointLimits,
jointCouplings: Array.Empty<JointCoupling>(),
servoPeriod: TimeSpan.FromMilliseconds(8),
triggerPeriod: TimeSpan.FromMilliseconds(8));
Assert.Equal(2, profile.DegreesOfFreedom);
Assert.NotSame(jointLimits, profile.JointLimits);
// The planner must not accept a profile whose DOF and limits disagree.
Assert.Throws<ArgumentException>(() => new RobotProfile(
name: "InvalidRobot",
modelPath: "Models/Invalid.robot",
degreesOfFreedom: 3,
jointLimits: jointLimits,
jointCouplings: Array.Empty<JointCoupling>(),
servoPeriod: TimeSpan.FromMilliseconds(8),
triggerPeriod: TimeSpan.FromMilliseconds(8)));
}
/// <summary>
/// Ensures uploaded flyshot programs keep their shot metadata aligned with teach waypoints.
/// </summary>
[Fact]
public void FlyshotProgram_RejectsMisalignedShotMetadata()
{
var waypoints = new[]
{
new JointWaypoint(new[] { 0.0, 1.0 }),
new JointWaypoint(new[] { 2.0, 3.0 })
};
var validProgram = new FlyshotProgram(
name: "EOL10_EAU_0",
waypoints: waypoints,
shotFlags: new[] { true, false },
offsetValues: new[] { 0, 1 },
addressGroups: new[]
{
new IoAddressGroup(new[] { 100 }),
new IoAddressGroup(Array.Empty<int>())
});
Assert.Equal(1, validProgram.ShotWaypointCount);
// The gateway cannot recover from count mismatches after this point, so fail fast here.
Assert.Throws<ArgumentException>(() => new FlyshotProgram(
name: "BrokenProgram",
waypoints: waypoints,
shotFlags: new[] { true },
offsetValues: new[] { 0, 1 },
addressGroups: new[]
{
new IoAddressGroup(new[] { 100 }),
new IoAddressGroup(Array.Empty<int>())
}));
}
/// <summary>
/// Ensures execution requests start from predictable defaults for compatibility paths.
/// </summary>
[Fact]
public void TrajectoryRequest_UsesExpectedDefaults()
{
var request = new TrajectoryRequest(
robot: CreateRobotProfile(),
program: CreateProgram(),
method: PlanningMethod.Icsp);
Assert.False(request.MoveToStart);
Assert.False(request.SaveTrajectoryArtifacts);
Assert.False(request.UseCache);
Assert.Equal(PlanningMethod.Icsp, request.Method);
}
/// <summary>
/// Ensures runtime snapshots expose safe empty collections before the controller connects.
/// </summary>
[Fact]
public void ControllerStateSnapshot_InitializesEmptyRuntimeCollections()
{
var snapshot = new ControllerStateSnapshot(
capturedAt: DateTimeOffset.Parse("2026-04-23T10:00:00+08:00"),
connectionState: "Disconnected",
isEnabled: false,
isInMotion: false,
speedRatio: 100.0);
Assert.Empty(snapshot.JointPositions);
Assert.Empty(snapshot.CartesianPose);
Assert.Empty(snapshot.ActiveAlarms);
Assert.Empty(snapshot.StateTailWords);
}
/// <summary>
/// 验证控制器快照会保留 TCP 10010 尾部状态字作为诊断字段。
/// </summary>
[Fact]
public void ControllerStateSnapshot_CopiesStateTailWordsForDiagnostics()
{
var snapshot = new ControllerStateSnapshot(
capturedAt: DateTimeOffset.Parse("2026-04-23T10:00:00+08:00"),
connectionState: "Connected",
isEnabled: true,
isInMotion: false,
speedRatio: 1.0,
stateTailWords: [2u, 0u, 0u, 1u]);
var json = JsonSerializer.Serialize(snapshot);
Assert.Equal([2u, 0u, 0u, 1u], snapshot.StateTailWords);
Assert.Contains("\"stateTailWords\":[2,0,0,1]", json);
}
/// <summary>
/// Ensures JSON payloads keep stable enum and property names for downstream SDKs.
/// </summary>
[Fact]
public void DomainObjects_SerializeStableContract()
{
var result = new TrajectoryResult(
programName: "EOL10_EAU_0",
method: PlanningMethod.SelfAdaptIcsp,
isValid: true,
duration: TimeSpan.FromSeconds(1.25),
shotEvents: new[]
{
new ShotEvent(
waypointIndex: 0,
triggerTime: 0.5,
sampleIndex: 62,
sampleTime: 0.496,
addressGroup: new IoAddressGroup(new[] { 100 }))
},
triggerTimeline: new[]
{
new TrajectoryDoEvent(
waypointIndex: 0,
triggerTime: 0.5,
offsetCycles: 0,
holdCycles: 1,
addressGroup: new IoAddressGroup(new[] { 100 }),
referenceJointsDegrees: new[] { 12.5, -3.0 })
},
artifacts: new[]
{
new TrajectoryArtifact(
kind: TrajectoryArtifactKind.JointDenseTrajectory,
logicalName: "JointDetialTraj.txt",
relativePath: "artifacts/JointDetialTraj.txt")
},
failureReason: null,
usedCache: true,
originalWaypointCount: 4,
plannedWaypointCount: 5,
triggerSampleIndexOffsetCycles: 7);
var json = JsonSerializer.Serialize(result);
Assert.Contains("\"programName\":\"EOL10_EAU_0\"", json);
Assert.Contains("\"method\":\"SelfAdaptIcsp\"", json);
Assert.Contains("\"kind\":\"JointDenseTrajectory\"", json);
Assert.Contains("\"usedCache\":true", json);
Assert.Contains("\"triggerSampleIndexOffsetCycles\":7", json);
Assert.Contains("\"referenceJointsDegrees\":[12.5,-3]", json);
}
/// <summary>
/// 验证触发绑定允许在最佳 sample 基础上继续向后偏移固定命令周期。
/// </summary>
[Fact]
public void TriggerSampleBinder_Bind_AppliesConfiguredSampleIndexOffset()
{
var trigger = new TrajectoryDoEvent(
waypointIndex: 1,
triggerTime: 0.008,
offsetCycles: 0,
holdCycles: 2,
addressGroup: new IoAddressGroup([2, 4]),
referenceJointsDegrees: [10.0, 20.0]);
var samples = new[]
{
new J519SendSample(sampleIndex: 0, sendTime: 0.0, trajectoryTime: 0.0, speedRatio: 1.0, jointsDegrees: [0.0, 0.0]),
new J519SendSample(sampleIndex: 1, sendTime: 0.008, trajectoryTime: 0.008, speedRatio: 1.0, jointsDegrees: [10.0, 20.0]),
new J519SendSample(sampleIndex: 2, sendTime: 0.016, trajectoryTime: 0.016, speedRatio: 1.0, jointsDegrees: [11.0, 21.0]),
new J519SendSample(sampleIndex: 3, sendTime: 0.024, trajectoryTime: 0.024, speedRatio: 1.0, jointsDegrees: [12.0, 22.0])
};
var binding = Assert.Single(Flyshot.Core.Planning.Sampling.TriggerSampleBinder.Bind([trigger], samples, sampleIndexOffsetCycles: 2));
Assert.True(binding.FoundInWindow);
Assert.Equal(3, binding.SampleIndex);
Assert.Equal(0.024, binding.Sample.SendTime, precision: 6);
}
/// <summary>
/// Creates a representative robot profile for request-level domain tests.
/// </summary>
private static RobotProfile CreateRobotProfile()
{
return new RobotProfile(
name: "LR_Mate_200iD_7L",
modelPath: "Models/LR_Mate_200iD_7L.robot",
degreesOfFreedom: 2,
jointLimits: new[]
{
new JointLimit("J1", 7.85, 32.72, 272.7),
new JointLimit("J2", 6.63, 27.63, 230.28)
},
jointCouplings: Array.Empty<JointCoupling>(),
servoPeriod: TimeSpan.FromMilliseconds(8),
triggerPeriod: TimeSpan.FromMilliseconds(8));
}
/// <summary>
/// Creates a representative uploaded program for request-level domain tests.
/// </summary>
private static FlyshotProgram CreateProgram()
{
return new FlyshotProgram(
name: "EOL10_EAU_0",
waypoints: new[]
{
new JointWaypoint(new[] { 0.0, 1.0 }),
new JointWaypoint(new[] { 2.0, 3.0 })
},
shotFlags: new[] { true, false },
offsetValues: new[] { 0, 1 },
addressGroups: new[]
{
new IoAddressGroup(new[] { 100 }),
new IoAddressGroup(Array.Empty<int>())
});
}
}