e42: enhanced (formatted) process programs S7F23-F26

E42 was an explicit out-of-scope item in the prior COMPLIANCE.md.
This commit closes it.

Wire messages added via the catalog:
  S7F23  Formatted PP Send       (H↔E, W=1)
  S7F24  Formatted PP Ack        (ProcessProgramAck)
  S7F25  Formatted PP Request    (PPID, W=1)
  S7F26  Formatted PP Data       (E→H, no reply)

Body shape: <L,4 PPID MDLN SOFTREV <L,n <L,2 CCODE <L,m <L,2
PNAME PVAL>>>>>.  PVAL is declared ITEM so any SECS-II Item type
round-trips — proven by a test that mixes ASCII, BOOLEAN, U4, F8,
Binary, and nested List values in one step.

RecipeStore extension:
  add_formatted(ppid, FormattedRecipe{mdln, softrev, steps})
  get_formatted(ppid) -> optional<FormattedRecipe>
  has_formatted(ppid) -> bool

Formatted + opaque views live alongside each other: a PPID can carry
both, size() counts unique PPIDs.  remove() kills both views.

Six new tests cover wire round-trip per function, every
ProcessProgramAck code, ITEM passthrough, and the store's dual-view
semantics.

COMPLIANCE.md updated: E30 §6.17 row mentions S7F23-F26, S5 message
table grows two rows, §8 "out of scope" entry for E42 removed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-09 11:58:03 +02:00
parent d4d1a411d7
commit 78fb0c3826
5 changed files with 317 additions and 8 deletions
+167
View File
@@ -0,0 +1,167 @@
// E42 Enhanced (formatted) Process Programs — S7F23/F24/F25/F26.
//
// Round-trips the wire shape through the generated builders/parsers,
// confirms RecipeStore tracks formatted PPs independently from
// opaque ones, and confirms the value parameter can carry any
// SECS-II Item type via the ITEM passthrough.
#include <doctest/doctest.h>
#include <string>
#include <vector>
#include "secsgem/gem/messages.hpp"
#include "secsgem/gem/store/recipes.hpp"
#include "secsgem/secs2/item.hpp"
using namespace secsgem;
using namespace secsgem::gem;
namespace s2 = secsgem::secs2;
namespace {
FormattedStep heat_step() {
FormattedStep s;
s.ccode = "HEAT";
s.params.push_back({"target_c", s2::Item::f4(350.5f)});
s.params.push_back({"ramp_rate", s2::Item::f4(2.5f)});
return s;
}
FormattedStep gas_flow_step() {
FormattedStep s;
s.ccode = "GAS_FLOW";
s.params.push_back({"chamber", s2::Item::ascii("A")});
s.params.push_back({"sccm", s2::Item::u4(uint32_t{500})});
return s;
}
} // namespace
TEST_CASE("S7F23 formatted PP send: round-trip preserves ppid/mdln/softrev/steps") {
FormattedProcessProgram fpp;
fpp.ppid = "RCP-FMT-1";
fpp.mdln = "ACME-3000";
fpp.softrev = "1.4.2";
fpp.steps = {heat_step(), gas_flow_step()};
auto msg = s7f23_formatted_pp_send(fpp);
CHECK(msg.stream == 7);
CHECK(msg.function == 23);
CHECK(msg.reply_expected);
auto parsed = parse_s7f23(msg);
REQUIRE(parsed.has_value());
CHECK(parsed->ppid == "RCP-FMT-1");
CHECK(parsed->mdln == "ACME-3000");
CHECK(parsed->softrev == "1.4.2");
REQUIRE(parsed->steps.size() == 2);
CHECK(parsed->steps[0].ccode == "HEAT");
REQUIRE(parsed->steps[0].params.size() == 2);
CHECK(parsed->steps[0].params[0].name == "target_c");
CHECK(parsed->steps[0].params[0].value.format() == s2::Format::F4);
CHECK(parsed->steps[1].ccode == "GAS_FLOW");
CHECK(parsed->steps[1].params[1].name == "sccm");
CHECK(parsed->steps[1].params[1].value.format() == s2::Format::U4);
}
TEST_CASE("S7F24 ack: round-trip every ProcessProgramAck code") {
for (auto ack : {ProcessProgramAck::Accept,
ProcessProgramAck::PermissionNotGranted,
ProcessProgramAck::PpidNotFound,
ProcessProgramAck::ModeUnsupported}) {
auto msg = s7f24_formatted_pp_ack(ack);
CHECK(msg.stream == 7);
CHECK(msg.function == 24);
REQUIRE(msg.body.has_value());
CHECK(msg.body->as_bytes()[0] == static_cast<uint8_t>(ack));
}
}
TEST_CASE("S7F25 formatted PP request: round-trip PPID") {
auto msg = s7f25_formatted_pp_request("RCP-FMT-1");
CHECK(msg.stream == 7);
CHECK(msg.function == 25);
CHECK(msg.reply_expected);
auto parsed = parse_s7f25(msg);
REQUIRE(parsed.has_value());
CHECK(*parsed == "RCP-FMT-1");
}
TEST_CASE("S7F26 formatted PP data: round-trip same shape as S7F23") {
FormattedProcessProgram fpp;
fpp.ppid = "RCP-FMT-2";
fpp.mdln = "ACME-3000";
fpp.softrev = "1.4.2";
fpp.steps = {heat_step()};
auto msg = s7f26_formatted_pp_data(fpp);
CHECK(msg.stream == 7);
CHECK(msg.function == 26);
CHECK_FALSE(msg.reply_expected);
auto parsed = parse_s7f26(msg);
REQUIRE(parsed.has_value());
CHECK(parsed->ppid == "RCP-FMT-2");
REQUIRE(parsed->steps.size() == 1);
CHECK(parsed->steps[0].ccode == "HEAT");
}
TEST_CASE("RecipeStore: formatted recipes live alongside opaque text recipes") {
RecipeStore r;
r.add("RCP-OPAQUE", "STEP HEAT 350C\nEND");
r.add_formatted("RCP-FMT", {"ACME-3000", "1.4.2",
{{"HEAT", {{"target_c", s2::Item::f4(350.0f)}}}}});
CHECK(r.size() == 2);
CHECK(r.get("RCP-OPAQUE").has_value());
CHECK_FALSE(r.get("RCP-FMT").has_value()); // not registered as opaque
CHECK(r.has_formatted("RCP-FMT"));
CHECK_FALSE(r.has_formatted("RCP-OPAQUE"));
auto fmt = r.get_formatted("RCP-FMT");
REQUIRE(fmt.has_value());
CHECK(fmt->mdln == "ACME-3000");
REQUIRE(fmt->steps.size() == 1);
CHECK(fmt->steps[0].ccode == "HEAT");
// PPID collision: same PPID can have both forms (different views).
r.add("RCP-FMT", "OPAQUE_DUMP_OF_RCP_FMT");
CHECK(r.size() == 2);
CHECK(r.get("RCP-FMT").value() == "OPAQUE_DUMP_OF_RCP_FMT");
CHECK(r.has_formatted("RCP-FMT"));
// Remove kills both views in one call.
CHECK(r.remove("RCP-FMT") == ProcessProgramAck::Accept);
CHECK_FALSE(r.get("RCP-FMT").has_value());
CHECK_FALSE(r.has_formatted("RCP-FMT"));
}
TEST_CASE("S7F23: ITEM passthrough carries any SECS-II type") {
// The `value` parameter is declared `ITEM` in the schema — confirms
// each major Item kind survives the round-trip.
FormattedProcessProgram fpp;
fpp.ppid = "RCP-MIXED";
fpp.mdln = "M";
fpp.softrev = "1";
FormattedStep step;
step.ccode = "MIXED";
step.params = {
{"text", s2::Item::ascii("HELLO")},
{"flag", s2::Item::boolean(true)},
{"counter", s2::Item::u4(uint32_t{42})},
{"value", s2::Item::f8(3.14159)},
{"binary", s2::Item::binary({0x01, 0xFF})},
{"nested", s2::Item::list({s2::Item::ascii("a"),
s2::Item::u2(uint16_t{7})})},
};
fpp.steps = {step};
auto msg = s7f23_formatted_pp_send(fpp);
auto parsed = parse_s7f23(msg);
REQUIRE(parsed.has_value());
REQUIRE(parsed->steps.size() == 1);
REQUIRE(parsed->steps[0].params.size() == 6);
CHECK(parsed->steps[0].params[0].value.as_ascii() == "HELLO");
CHECK(parsed->steps[0].params[2].value.format() == s2::Format::U4);
CHECK(parsed->steps[0].params[3].value.format() == s2::Format::F8);
CHECK(parsed->steps[0].params[5].value.is_list());
}