Files
lmxopcua/tests/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasCapabilityMatrixTests.cs
Joseph Doherty a6be2f77b5 FOCAS version-matrix stabilization (PR 1 of #220 split) — ship the cheap half of the hardware-free stability gap ahead of the Tier-C out-of-process split. Without any CNC or simulator on the bench, the highest-leverage move is to catch operator config errors at init time instead of at steady-state per-read. Adds FocasCncSeries enum (Unknown/16i/0i-D/0i-F family/30i family/PowerMotion-i) + FocasCapabilityMatrix static class that encodes the per-series documented ranges for macro variables (cnc_rdmacro/wrmacro), parameters (cnc_rdparam/wrparam), and PMC letters + byte ceilings (pmc_rdpmcrng/wrpmcrng) straight from the Fanuc FOCAS Developer Kit. FocasDeviceOptions gains a Series knob (defaults Unknown = permissive so pre-matrix configs don't break on upgrade). FocasDriver.InitializeAsync now calls FocasAddress.TryParse on every tag + runs FocasCapabilityMatrix.Validate against the owning device's declared series, throwing InvalidOperationException with a reason string that names both the series and the documented limit ("Parameter #30000 is outside the documented range [0, 29999] for Thirty_i") so an operator can tell whether the mismatch is in the config or in their declared CNC model. Unknown series skips validation entirely. Ships 46 new theory cases in FocasCapabilityMatrixTests.cs — covering every boundary in the matrix (widen 16i->0i-F: macro ceiling 999->9999, param 9999->14999; widen 0i-F->30i: PMC letters +K+T; PMC-number 16i=999/0i-D=1999/0i-F=9999/30i=59999), permissive Unknown-series behavior, rejection-message content, and case-insensitive PMC-letter matching. Widening a range without updating docs/v2/focas-version-matrix.md fails a test because every InlineData cites the row it reflects. Full FOCAS test suite stays at 165/165 passing (119 existing + 46 new). Also authors docs/v2/focas-version-matrix.md as the authoritative range reference with per-function citations, CNC-series era context, error-surface shape, and the link back to the matrix code; docs/v2/implementation/focas-isolation-plan.md as the multi-PR plan for #220 Tier-C isolation (Shared contracts -> Host skeleton -> move Fwlib32 calls -> Supervisor+respawn -> MMF+ops glue, 2200-3200 LOC across 5 PRs mirroring the Galaxy Tier-C topology); and promotes docs/drivers/FOCAS-Test-Fixture.md from "version-matrix coverage = no" to explicit coverage via the new test file + cross-links to the matrix and isolation-plan docs. Leaves task #220 open since isolation itself (the expensive half) is still ahead.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 13:44:37 -04:00

157 lines
6.8 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// Version-matrix coverage for <see cref="FocasCapabilityMatrix"/>. Encodes the
/// documented Fanuc FOCAS Developer Kit support boundaries per CNC series so a
/// config-time change that widens or narrows a range without updating
/// <c>docs/v2/focas-version-matrix.md</c> fails a test. Every assertion cites the
/// specific matrix row it reflects.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasCapabilityMatrixTests
{
// ---- Macro ranges ----
[Theory]
[InlineData(FocasCncSeries.Sixteen_i, 999, true)]
[InlineData(FocasCncSeries.Sixteen_i, 1000, false)] // above legacy ceiling
[InlineData(FocasCncSeries.Zero_i_D, 999, true)]
[InlineData(FocasCncSeries.Zero_i_D, 9999, false)] // 0i-D is still legacy-ceiling
[InlineData(FocasCncSeries.Zero_i_F, 9999, true)] // widened on 0i-F
[InlineData(FocasCncSeries.Zero_i_F, 10000, false)]
[InlineData(FocasCncSeries.Thirty_i, 99999, true)] // highest-end
[InlineData(FocasCncSeries.Thirty_i, 100000, false)]
[InlineData(FocasCncSeries.PowerMotion_i, 999, true)]
[InlineData(FocasCncSeries.PowerMotion_i, 1000, false)] // atypical coverage
public void Macro_range_matches_series(FocasCncSeries series, int number, bool accepted)
{
var address = new FocasAddress(FocasAreaKind.Macro, null, number, null);
var result = FocasCapabilityMatrix.Validate(series, address);
(result is null).ShouldBe(accepted,
$"Macro #{number} on {series}: expected {(accepted ? "accept" : "reject")}, got {(result ?? "accept")}");
}
// ---- Parameter ranges ----
[Theory]
[InlineData(FocasCncSeries.Sixteen_i, 9999, true)]
[InlineData(FocasCncSeries.Sixteen_i, 10000, false)] // 16i capped at 9999
[InlineData(FocasCncSeries.Zero_i_F, 14999, true)]
[InlineData(FocasCncSeries.Zero_i_F, 15000, false)]
[InlineData(FocasCncSeries.Thirty_i, 29999, true)]
[InlineData(FocasCncSeries.Thirty_i, 30000, false)]
public void Parameter_range_matches_series(FocasCncSeries series, int number, bool accepted)
{
var address = new FocasAddress(FocasAreaKind.Parameter, null, number, null);
var result = FocasCapabilityMatrix.Validate(series, address);
(result is null).ShouldBe(accepted);
}
// ---- PMC letters ----
[Theory]
[InlineData(FocasCncSeries.Sixteen_i, "X", true)]
[InlineData(FocasCncSeries.Sixteen_i, "Y", true)]
[InlineData(FocasCncSeries.Sixteen_i, "R", true)]
[InlineData(FocasCncSeries.Sixteen_i, "F", false)] // 16i has no F/G signal groups
[InlineData(FocasCncSeries.Sixteen_i, "G", false)]
[InlineData(FocasCncSeries.Sixteen_i, "K", false)]
[InlineData(FocasCncSeries.Zero_i_D, "E", true)] // widened since 0i-D
[InlineData(FocasCncSeries.Zero_i_D, "F", false)] // still no F on 0i-D
[InlineData(FocasCncSeries.Zero_i_F, "F", true)] // F/G added on 0i-F
[InlineData(FocasCncSeries.Zero_i_F, "K", false)] // K/T still 30i-only
[InlineData(FocasCncSeries.Thirty_i, "K", true)]
[InlineData(FocasCncSeries.Thirty_i, "T", true)]
[InlineData(FocasCncSeries.Thirty_i, "Q", false)] // unsupported even on 30i
public void Pmc_letter_matches_series(FocasCncSeries series, string letter, bool accepted)
{
var address = new FocasAddress(FocasAreaKind.Pmc, letter, 0, null);
var result = FocasCapabilityMatrix.Validate(series, address);
(result is null).ShouldBe(accepted,
$"PMC letter '{letter}' on {series}: expected {(accepted ? "accept" : "reject")}, got {(result ?? "accept")}");
}
// ---- PMC number ceiling ----
[Theory]
[InlineData(FocasCncSeries.Sixteen_i, "R", 999, true)]
[InlineData(FocasCncSeries.Sixteen_i, "R", 1000, false)]
[InlineData(FocasCncSeries.Zero_i_D, "R", 1999, true)]
[InlineData(FocasCncSeries.Zero_i_D, "R", 2000, false)]
[InlineData(FocasCncSeries.Zero_i_F, "R", 9999, true)]
[InlineData(FocasCncSeries.Zero_i_F, "R", 10000, false)]
[InlineData(FocasCncSeries.Thirty_i, "R", 59999, true)]
[InlineData(FocasCncSeries.Thirty_i, "R", 60000, false)]
public void Pmc_number_ceiling_matches_series(FocasCncSeries series, string letter, int number, bool accepted)
{
var address = new FocasAddress(FocasAreaKind.Pmc, letter, number, null);
var result = FocasCapabilityMatrix.Validate(series, address);
(result is null).ShouldBe(accepted);
}
// ---- Unknown series is permissive ----
[Theory]
[InlineData("Z", 999_999)] // absurd PMC address
[InlineData("Q", 0)] // non-existent letter
public void Unknown_series_accepts_any_PMC(string letter, int number)
{
var address = new FocasAddress(FocasAreaKind.Pmc, letter, number, null);
FocasCapabilityMatrix.Validate(FocasCncSeries.Unknown, address).ShouldBeNull();
}
[Fact]
public void Unknown_series_accepts_any_macro_number()
{
var address = new FocasAddress(FocasAreaKind.Macro, null, 999_999, null);
FocasCapabilityMatrix.Validate(FocasCncSeries.Unknown, address).ShouldBeNull();
}
[Fact]
public void Unknown_series_accepts_any_parameter_number()
{
var address = new FocasAddress(FocasAreaKind.Parameter, null, 999_999, null);
FocasCapabilityMatrix.Validate(FocasCncSeries.Unknown, address).ShouldBeNull();
}
// ---- Reason messages include enough context to diagnose ----
[Fact]
public void Rejection_message_names_series_and_limit()
{
var address = new FocasAddress(FocasAreaKind.Macro, null, 100_000, null);
var reason = FocasCapabilityMatrix.Validate(FocasCncSeries.Zero_i_F, address);
reason.ShouldNotBeNull();
reason.ShouldContain("100000");
reason.ShouldContain("Zero_i_F");
reason.ShouldContain("9999");
}
[Fact]
public void Pmc_rejection_lists_accepted_letters()
{
var address = new FocasAddress(FocasAreaKind.Pmc, "Q", 0, null);
var reason = FocasCapabilityMatrix.Validate(FocasCncSeries.Thirty_i, address);
reason.ShouldNotBeNull();
reason.ShouldContain("'Q'");
reason.ShouldContain("X"); // some accepted letter should appear
reason.ShouldContain("Y");
}
// ---- PMC address letter is case-insensitive ----
[Theory]
[InlineData("x")]
[InlineData("X")]
[InlineData("f")]
public void Pmc_letter_match_is_case_insensitive_on_30i(string letter)
{
var address = new FocasAddress(FocasAreaKind.Pmc, letter, 0, null);
FocasCapabilityMatrix.Validate(FocasCncSeries.Thirty_i, address).ShouldBeNull();
}
}