fix(daemon)+test: accurate duplicate-ARRIVED message; broaden E90/E157 + names coverage

The duplicate-ARRIVED fix from the previous commit returned INVALID_OBJECT
with the message "no substrate 'X'" — a lie, since the substrate exists.
Rewrite ReportSubstrate so ARRIVED has its own ack mapping: a duplicate is
CANNOT_DO_NOW with "substrate 'X' already exists" (a state conflict, not a
missing object), and we never silently re-create over live FSM state.

Coverage gaps closed:
- C++: ARRIVED records carrier_id/slot (now asserted); module NOT_EXECUTING
  reset transition; duplicate-ARRIVED expects CANNOT_DO_NOW.
- Interop: @eq.command now drives the real host S2F41 path (was @eq.on, so
  the headline decorator had zero wire coverage); @eq.command NameError on
  unknown name; eq.names var/alarm + dir() + typo-suggestion; replaced the
  two `check(..., True)` tautologies with full E90 journey + AT_DESTINATION
  and real error paths (ghost wafer raises, illegal module jump raises).

All 8 daemon test cases (248 assertions) and 24 pyclient interop checks pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 22:01:20 +02:00
parent d22bbc4ab2
commit 2218b854ce
3 changed files with 110 additions and 28 deletions
+51 -17
View File
@@ -8,8 +8,11 @@ host actually receives on the wire:
eq.set / eq["..."] -> S1F3-visible values, GetVariables round-trip
eq.fire -> host receives S6F11 with the configured report
eq.alarm / eq.clear -> host receives S5F1 set/clear
@eq.on + eq.listen -> host's S2F41 gets HCACK=4, handler runs,
completion event reaches the host
@eq.command + eq.listen -> host's S2F41 gets HCACK=4, the name-bound
handler runs, completion event reaches the host
eq.names.* -> Describe-backed, typo-safe name lookup
eq.report_substrate -> E90 wafer journey (+ unknown-wafer error path)
eq.report_module -> E157 module walk/reset (+ illegal-jump error path)
eq.control_state / request_control_state / eq.health
Exits 0 on success.
@@ -50,10 +53,12 @@ def run(grpc_addr: str, hsms_host: str, hsms_port: int) -> int:
started = threading.Event()
@eq.on("START")
def _start(cmd): # noqa: ANN001
# @eq.command binds by function name, validated against the live equipment
# (Describe) at decoration time — this is the path the host's S2F41 drives.
@eq.command
def START(cmd): # noqa: ANN001
started.set()
eq.fire("ProcessStarted") # the host's real completion signal
eq.fire(eq.names.event.ProcessStarted) # typo-safe completion signal
eq.listen(background=True)
@@ -140,31 +145,60 @@ def run(grpc_addr: str, hsms_host: str, hsms_port: int) -> int:
body = host.settings.streams_functions.decode(rsp).get()
check("host S2F41 -> HCACK=4",
isinstance(body, dict) and int(body.get("HCACK") or -1) == 4)
check("@eq.on('START') handler ran", started.wait(timeout=10))
check("@eq.command('START') handler ran", started.wait(timeout=10))
check("handler's eq.fire reached the host (completion signal)",
ceid300.wait(timeout=10))
# ---- Describe-backed names + @eq.command binding ----
# ---- Describe-backed names (every category, dir(), typo hint) ----
check("eq.names.event has ProcessStarted",
"ProcessStarted" in eq.names.event)
check("eq.names.command has START", "START" in eq.names.command)
check("eq.names.var has ChamberPressure",
"ChamberPressure" in eq.names.var)
check("eq.names.alarm has chiller_temp_high",
"chiller_temp_high" in eq.names.alarm)
check("dir(eq.names.event) lists names for autocomplete",
"ProcessStarted" in dir(eq.names.event))
try:
_ = eq.names.event.NoSuchEvent
check("typo on eq.names raises", False)
except AttributeError:
check("typo on eq.names raises", True)
eq.fire(eq.names.event.ProcessStarted)
check("eq.fire(eq.names.event.*) accepted", True)
_ = eq.names.event.ProcessStated # one-letter typo
check("typo on eq.names raises with suggestion", False)
except AttributeError as e:
check("typo on eq.names raises with suggestion",
"ProcessStarted" in str(e)) # close-match hint present
# ---- E90/E157 material tracking ----
eq.report_substrate("WFR-PY-1", "ARRIVED")
# ---- @eq.command rejects an unknown command name at decoration time ----
try:
@eq.command
def NOT_A_REAL_COMMAND(cmd): # noqa: ANN001
pass
check("@eq.command on unknown name raises NameError", False)
except NameError:
check("@eq.command on unknown name raises NameError", True)
# ---- E90 substrate tracking: full journey, then the error path ----
eq.report_substrate("WFR-PY-1", "ARRIVED", carrier_id="FOUP-PY", slot=4)
eq.report_substrate("WFR-PY-1", "AT_WORK")
eq.report_substrate("WFR-PY-1", "PROCESSING")
eq.report_substrate("WFR-PY-1", "PROCESSED")
check("report_substrate journey accepted", True)
eq.report_substrate("WFR-PY-1", "AT_DESTINATION")
check("report_substrate full journey accepted (no raise)", True)
try:
eq.report_substrate("WFR-GHOST", "AT_WORK") # never ARRIVED
check("report_substrate on unknown wafer raises", False)
except SecsGemError as e:
check("report_substrate on unknown wafer raises", "WFR-GHOST" in str(e))
# ---- E157 module tracking: walk and reset ----
eq.report_module("MOD-PY-1", "GENERAL_EXECUTING")
eq.report_module("MOD-PY-1", "STEP_EXECUTING")
check("report_module accepted", True)
eq.report_module("MOD-PY-1", "STEP_COMPLETED")
eq.report_module("MOD-PY-1", "NOT_EXECUTING")
check("report_module walk + reset accepted (no raise)", True)
try:
eq.report_module("MOD-PY-2", "STEP_EXECUTING") # illegal from idle
check("report_module illegal jump raises", False)
except SecsGemError:
check("report_module illegal jump raises", True)
# ---- operator offline via the client ----
eq.request_control_state("HOST_OFFLINE")