feat(scripts): read-only, allow-listed Files global for site scripts; Delmia test receiver parses recipe files
Script file access (design docs/plans/2026-09-28-script-file-access-design.md): Files.ReadTextAsync / ReadLinesAsync / ExistsAsync on site ScriptGlobals (+ Instance.Files), backed by the site-singleton ScriptFileReader. The one host-mediated exception to the System.IO ban - scripts still never name a System.IO type. OFF by default: ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots (empty = every call throws Disabled) + MaxFileBytes (1 MiB, validated 1..64 MiB, roots must be absolute). Guard: fully qualified, no device paths / ADS, canonical path strictly inside a root (directory-boundary match), no reparse point on the file or any directory below the root, size cap before and during the read. Errors are one Commons type, ScriptFileAccessException(+ScriptFileAccessFailure); Commons.Types.Scripts joins the default script imports on every surface so it binds by short name. Mirrored on ScriptCompileSurface (CompileFiles, parity-pinned) and the Test Run sandbox (binds, refuses Disabled). Inbound (central) scripts do not get it. Rig: docker/recipes (sample RecipeValueName,RecipeValue files incl. an invalid one) bind-mounted read-only at /app/recipes on both site-a nodes with AllowedRoots [/app/recipes]. The TestMesMachine SimDelmiaRecipe simulator now reads + parses the downloaded file, converts each value to its recipe tag's type and loads it all-or-nothing (14 new TestDelmiaReceiver attributes); verify-mes-test-machine.sh group A covers accept / re-download / invalid lines / NotFound / OutsideAllowedRoots, VERIFY_GROUPS selects groups (rig: AD).
This commit is contained in:
@@ -238,6 +238,7 @@ services:
|
||||
- "9023:8083" # gRPC streaming
|
||||
volumes:
|
||||
- ./site-a-node-a/appsettings.Site.json:/app/appsettings.Site.json:ro
|
||||
- ./recipes:/app/recipes:ro # script file access (Files global) — sample Delmia recipes
|
||||
- site-a-node-a-data:/app/data
|
||||
# Same named volume, mounted a second time OUTSIDE the content root (/app).
|
||||
# Secrets 0.6.2 refuses a store path under the content root; this mount is
|
||||
@@ -274,6 +275,7 @@ services:
|
||||
- "9024:8083" # gRPC streaming
|
||||
volumes:
|
||||
- ./site-a-node-b/appsettings.Site.json:/app/appsettings.Site.json:ro
|
||||
- ./recipes:/app/recipes:ro # script file access (Files global) — sample Delmia recipes
|
||||
- site-a-node-b-data:/app/data
|
||||
# Same named volume, mounted a second time OUTSIDE the content root (/app).
|
||||
# Secrets 0.6.2 refuses a store path under the content root; this mount is
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Delmia recipe download - sample for the SBTEST test machines (docker/seed-mes-test-machine.sh)
|
||||
# One value per line: RecipeValueName,RecipeValue
|
||||
# The value may be an int, a double (invariant culture, '.' decimal), a bool (true/false) or text.
|
||||
# Blank lines and lines starting with '#' are ignored. Everything after the FIRST comma is the value.
|
||||
RecipeName,Aluminide Coat R100
|
||||
RecipeRevision,7
|
||||
CoatingType,Aluminide
|
||||
ReactorTempSP,1050.5
|
||||
PotTempSP,980
|
||||
ArgonFlowSP,40.25
|
||||
HydrogenFlowSP,25
|
||||
ChlorineFlowSP,12.5
|
||||
VacuumSP,0.5
|
||||
TargetRuntime,480
|
||||
PurgeEnabled,true
|
||||
OperatorNote,Load parts cold, verify fixture ID
|
||||
@@ -0,0 +1,13 @@
|
||||
# Second valid recipe - different values/types so a re-download visibly changes every tag
|
||||
RecipeName,Chromide Coat R200
|
||||
RecipeRevision,12
|
||||
CoatingType,Chromide
|
||||
ReactorTempSP,1082
|
||||
PotTempSP,1001
|
||||
ArgonFlowSP,38
|
||||
HydrogenFlowSP,27
|
||||
ChlorineFlowSP,11.75
|
||||
VacuumSP,0.45
|
||||
TargetRuntime,540
|
||||
PurgeEnabled,false
|
||||
OperatorNote,"Quoted text, commas kept"
|
||||
@@ -0,0 +1,6 @@
|
||||
# Deliberately INVALID - the receiver must reject it and load nothing:
|
||||
# line 4: no comma; line 5: unknown RecipeValueName; line 6: PotTempSP must be an int
|
||||
RecipeName,Broken Recipe
|
||||
ReactorTempSP 1050
|
||||
MysterySetting,42
|
||||
PotTempSP,980.5
|
||||
@@ -24,11 +24,17 @@ set -euo pipefail
|
||||
# handshakes are ported to per-attribute SetAsync + WaitAsync.
|
||||
# * Galaxy simulators SimLeft/RightMoveIn, SimLeft/RightMoveOut, SimDelmiaRecipe (ValueChange on the
|
||||
# trigger flags) — they play the machine side and complete each handshake.
|
||||
# SimDelmiaRecipe READS + PARSES the downloaded recipe file via the Files global
|
||||
# and loads it into the TestDelmiaReceiver recipe tags (see docker/recipes/).
|
||||
# - Instances SBTEST01 and SBTEST02 (SBTEST02 overrides SensorFault to MES-reportable), deployed.
|
||||
# - Inbound methods SimpleAlarmStatusRequest (updated in place) and AlarmStatusRequest (legacy
|
||||
# /mes/simplealarmstatus + /mes/alarmstatus shapes). Both resolve the machine through BTDB.dbo.Machine,
|
||||
# so they need the "BTDB" database connection — present on vd03, absent on the docker rig.
|
||||
#
|
||||
# Recipe files: the site needs ScadaBridge:SiteRuntime:ScriptFileAccess:AllowedRoots covering the folder the
|
||||
# DownloadPath points into (rig: docker/recipes bind-mounted at /app/recipes on site-a; vd03:
|
||||
# E:\ApiInstall\ScadaBridge\recipes). Without it every download is rejected "recipe file not readable (Disabled)".
|
||||
#
|
||||
# The MES methods resolve SAPID -> BTDB.dbo.Machine.Code, so each instance needs a BTDB row
|
||||
# (Code = instance name). On vd03 those were inserted 2026-09-28: SBTEST01 = SAPID 990001 (MachineID 203),
|
||||
# SBTEST02 = SAPID 990002 (MachineID 204), LastUpdateBy 'scadabridge-test-seed'.
|
||||
@@ -128,7 +134,7 @@ done
|
||||
ensure_attr "$RX_ID" MoveInJobSequenceNumber --data-type String --description "MoveIn payload"
|
||||
ensure_attr "$RX_ID" ScanStateCmd --data-type Boolean --value false --description "Unused by the simulator (prod parity)"
|
||||
|
||||
echo "2/7 Template TestDelmiaReceiver (10 static attributes)..."
|
||||
echo "2/7 Template TestDelmiaReceiver (10 handshake + 14 recipe static attributes)..."
|
||||
DR_ID=$(ensure_template TestDelmiaReceiver "In-memory stand-in for the Galaxy DelmiaReceiver object (prod attribute names, static)")
|
||||
ensure_attr "$DR_ID" ReadyFlag --data-type Boolean --value true --description "Precondition; true = receiver accepts downloads"
|
||||
ensure_attr "$DR_ID" RecipeDownloadFlag --data-type Boolean --value false --description "Trigger: set true after the payload is written"
|
||||
@@ -137,6 +143,22 @@ ensure_attr "$DR_ID" RecipeProcessResult --data-type Boolean --value false --des
|
||||
for s in DownloadPath WorkOrderNumber PartNumber JobStepNumber Username RecipeProcessResultText; do
|
||||
ensure_attr "$DR_ID" "$s" --data-type String --description "Recipe-download payload / outcome text"
|
||||
done
|
||||
# Recipe tags the simulator loads from the downloaded file (the Galaxy object's recipe schema stand-in).
|
||||
# The type here is what each file value is converted to — keep it in step with RECIPE_SCHEMA in SimDelmiaRecipe.
|
||||
ensure_attr "$DR_ID" RecipeName --data-type String --description "Recipe tag (loaded from the recipe file)"
|
||||
ensure_attr "$DR_ID" RecipeRevision --data-type Int32 --value 0 --description "Recipe tag (loaded from the recipe file)"
|
||||
ensure_attr "$DR_ID" CoatingType --data-type String --description "Recipe tag (loaded from the recipe file)"
|
||||
ensure_attr "$DR_ID" ReactorTempSP --data-type Double --value 0 --description "Recipe tag (loaded from the recipe file)"
|
||||
ensure_attr "$DR_ID" PotTempSP --data-type Int32 --value 0 --description "Recipe tag (loaded from the recipe file)"
|
||||
ensure_attr "$DR_ID" ArgonFlowSP --data-type Double --value 0 --description "Recipe tag (loaded from the recipe file)"
|
||||
ensure_attr "$DR_ID" HydrogenFlowSP --data-type Int32 --value 0 --description "Recipe tag (loaded from the recipe file)"
|
||||
ensure_attr "$DR_ID" ChlorineFlowSP --data-type Double --value 0 --description "Recipe tag (loaded from the recipe file)"
|
||||
ensure_attr "$DR_ID" VacuumSP --data-type Double --value 0 --description "Recipe tag (loaded from the recipe file)"
|
||||
ensure_attr "$DR_ID" TargetRuntime --data-type Int32 --value 0 --description "Recipe tag (loaded from the recipe file)"
|
||||
ensure_attr "$DR_ID" PurgeEnabled --data-type Boolean --value false --description "Recipe tag (loaded from the recipe file)"
|
||||
ensure_attr "$DR_ID" OperatorNote --data-type String --description "Recipe tag (loaded from the recipe file)"
|
||||
ensure_attr "$DR_ID" RecipeValueCount --data-type Int32 --value 0 --description "How many values the last accepted download loaded"
|
||||
ensure_attr "$DR_ID" RecipeLoadedFile --data-type String --description "Path of the last accepted recipe file"
|
||||
|
||||
echo "3/7 Template TestMesMachine (standalone base) — compositions, knobs, alarm tags, alarms..."
|
||||
M_ID=$(ensure_template TestMesMachine "In-memory MES/Delmia/alarm-API test machine: Left/Right MES receivers + Delmia receiver, simulated Galaxy side (docker/seed-mes-test-machine.sh)")
|
||||
@@ -379,19 +401,69 @@ return null;
|
||||
CS
|
||||
}
|
||||
read -r -d '' SIM_RECIPE_CODE <<'CS' || true
|
||||
// Plays the Galaxy DelmiaReceiver: accepts unless SimRecipeReject is set, DownloadPath is blank or ends in
|
||||
// ".bad", or the work order is blank. SimNoResponse = never answer.
|
||||
// Plays the Galaxy DelmiaReceiver. On the rising edge of RecipeDownloadFlag it READS the downloaded recipe
|
||||
// file through the Files global (script file access, design 2026-09-28 — only beneath the site's configured
|
||||
// ScriptFileAccess:AllowedRoots), parses "RecipeValueName,RecipeValue" lines, converts each value to its
|
||||
// recipe tag's type and loads them. All-or-nothing: any bad line rejects the download and loads nothing.
|
||||
// Blank lines and '#' comments are ignored; everything after the FIRST comma is the value; a value wrapped
|
||||
// in double quotes is unwrapped. SimRecipeReject / SimNoResponse knobs still apply.
|
||||
System.Func<object?,bool> IsTrue = v => { var s=v?.ToString(); return (v is bool b && b)||s=="1"||string.Equals(s,"true",System.StringComparison.OrdinalIgnoreCase); };
|
||||
var recv = Children["DelmiaReceiver"];
|
||||
if (!IsTrue(await recv.Attributes.GetAsync("RecipeDownloadFlag"))) return null;
|
||||
if (IsTrue(await Attributes.GetAsync("SimNoResponse"))) return null;
|
||||
var path = (await recv.Attributes.GetAsync("DownloadPath"))?.ToString() ?? "";
|
||||
var path = (await recv.Attributes.GetAsync("DownloadPath"))?.ToString()?.Trim() ?? "";
|
||||
var wo = (await recv.Attributes.GetAsync("WorkOrderNumber"))?.ToString() ?? "";
|
||||
var forced = IsTrue(await Attributes.GetAsync("SimRecipeReject"));
|
||||
var ok = !forced && path.Length > 0 && wo.Length > 0 && !path.EndsWith(".bad", System.StringComparison.OrdinalIgnoreCase);
|
||||
// RECIPE_SCHEMA — recipe tag name -> target type (keep in step with the TestDelmiaReceiver attributes).
|
||||
var schema = new System.Collections.Generic.Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) {
|
||||
["RecipeName"] = "String", ["RecipeRevision"] = "Int32", ["CoatingType"] = "String",
|
||||
["ReactorTempSP"] = "Double", ["PotTempSP"] = "Int32", ["ArgonFlowSP"] = "Double",
|
||||
["HydrogenFlowSP"] = "Int32", ["ChlorineFlowSP"] = "Double", ["VacuumSP"] = "Double",
|
||||
["TargetRuntime"] = "Int32", ["PurgeEnabled"] = "Boolean", ["OperatorNote"] = "String" };
|
||||
var inv = System.Globalization.CultureInfo.InvariantCulture;
|
||||
var errors = new System.Collections.Generic.List<string>();
|
||||
var values = new System.Collections.Generic.List<(string Name, object Value)>();
|
||||
if (IsTrue(await Attributes.GetAsync("SimRecipeReject"))) errors.Add("simulated reject (SimRecipeReject)");
|
||||
else if (wo.Length == 0) errors.Add("missing work order number");
|
||||
else if (path.Length == 0) errors.Add("missing download path");
|
||||
else {
|
||||
IReadOnlyList<string>? lines = null;
|
||||
try { lines = await Files.ReadLinesAsync(path); }
|
||||
catch (ScriptFileAccessException ex) { errors.Add($"recipe file not readable ({ex.Failure}): {ex.Message}"); }
|
||||
for (var i = 0; lines != null && i < lines.Count; i++) {
|
||||
var line = lines[i].Trim();
|
||||
if (line.Length == 0 || line.StartsWith("#")) continue;
|
||||
var comma = line.IndexOf(',');
|
||||
if (comma <= 0) { errors.Add($"line {i + 1}: expected RecipeValueName,RecipeValue"); continue; }
|
||||
var name = line.Substring(0, comma).Trim();
|
||||
var raw = line.Substring(comma + 1).Trim();
|
||||
if (raw.Length >= 2 && raw[0] == '"' && raw[raw.Length - 1] == '"') raw = raw.Substring(1, raw.Length - 2);
|
||||
var key = schema.Keys.FirstOrDefault(k => string.Equals(k, name, StringComparison.OrdinalIgnoreCase));
|
||||
if (key == null) { errors.Add($"line {i + 1}: unknown recipe value '{name}'"); continue; }
|
||||
if (values.Any(v => v.Name == key)) { errors.Add($"line {i + 1}: '{key}' appears more than once"); continue; }
|
||||
object? typed = null;
|
||||
switch (schema[key]) {
|
||||
case "Int32": if (int.TryParse(raw, System.Globalization.NumberStyles.Integer, inv, out var n)) typed = n; break;
|
||||
case "Double": if (double.TryParse(raw, System.Globalization.NumberStyles.Float, inv, out var d)) typed = d; break;
|
||||
case "Boolean": if (bool.TryParse(raw, out var bv)) typed = bv; break;
|
||||
default: typed = raw; break;
|
||||
}
|
||||
if (typed == null) { errors.Add($"line {i + 1}: {key} expects {schema[key]}, got '{raw}'"); continue; }
|
||||
values.Add((key, typed));
|
||||
}
|
||||
if (lines != null && errors.Count == 0 && values.Count == 0) errors.Add("recipe file contains no values");
|
||||
}
|
||||
var ok = errors.Count == 0;
|
||||
if (ok) {
|
||||
foreach (var (name, value) in values) await recv.Attributes.SetAsync(name, value);
|
||||
await recv.Attributes.SetAsync("RecipeValueCount", values.Count);
|
||||
await recv.Attributes.SetAsync("RecipeLoadedFile", path);
|
||||
}
|
||||
var file = path.Substring(path.LastIndexOfAny(new[] { '\\', '/' }) + 1);
|
||||
var text = ok ? $"Loaded {values.Count} recipe values from {file} for {wo}"
|
||||
: "Recipe rejected: " + string.Join("; ", errors.Take(5)) + (errors.Count > 5 ? $" (+{errors.Count - 5} more)" : "");
|
||||
if (!ok) Log.Warn(text);
|
||||
await recv.Attributes.SetAsync("RecipeProcessResult", ok);
|
||||
await recv.Attributes.SetAsync("RecipeProcessResultText", ok ? "Simulated download accepted for " + wo
|
||||
: forced ? "Simulated reject (SimRecipeReject)" : "Simulated reject: bad path or missing work order");
|
||||
await recv.Attributes.SetAsync("RecipeProcessResultText", text);
|
||||
await recv.Attributes.SetAsync("RecipeDownloadFlag", false);
|
||||
await recv.Attributes.SetAsync("RecipeProcessedFlag", true);
|
||||
return null;
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
"GrpcPort": 8083,
|
||||
"MetricsPort": 8084
|
||||
},
|
||||
// Script file access (design 2026-09-28): the Files global reads only beneath these
|
||||
// roots. ./recipes is bind-mounted read-only at /app/recipes on site-a (both nodes, so
|
||||
// a failover keeps the same view); site-b/site-c stay unconfigured = Files disabled.
|
||||
"SiteRuntime": {
|
||||
"ScriptFileAccess": {
|
||||
"AllowedRoots": [ "/app/recipes" ]
|
||||
}
|
||||
},
|
||||
"Cluster": {
|
||||
"SeedNodes": [
|
||||
"akka.tcp://scadabridge@scadabridge-site-a-a:8082",
|
||||
|
||||
@@ -9,6 +9,14 @@
|
||||
"GrpcPort": 8083,
|
||||
"MetricsPort": 8084
|
||||
},
|
||||
// Script file access (design 2026-09-28): the Files global reads only beneath these
|
||||
// roots. ./recipes is bind-mounted read-only at /app/recipes on site-a (both nodes, so
|
||||
// a failover keeps the same view); site-b/site-c stay unconfigured = Files disabled.
|
||||
"SiteRuntime": {
|
||||
"ScriptFileAccess": {
|
||||
"AllowedRoots": [ "/app/recipes" ]
|
||||
}
|
||||
},
|
||||
"Cluster": {
|
||||
"SeedNodes": [
|
||||
"akka.tcp://scadabridge@scadabridge-site-a-b:8082",
|
||||
|
||||
@@ -7,7 +7,13 @@ set -euo pipefail
|
||||
# SBTEST01 / SBTEST02 test machines, and trips/clears the computed alarms by writing their static source
|
||||
# attributes. Needs the BTDB rows SBTEST01=990001 / SBTEST02=990002 (vd03 only).
|
||||
#
|
||||
# SCADABRIDGE_URL=http://wonder-app-vd03.zmr.zimmer.com:8085 bash docker/verify-mes-test-machine.sh
|
||||
# SCADABRIDGE_URL=http://wonder-app-vd03.zmr.zimmer.com:8085 \
|
||||
# RECIPE_ROOT='E:\ApiInstall\ScadaBridge\recipes' RECIPE_OUTSIDE='E:\ApiInstall\ScadaBridge\app\appsettings.Site.json' \
|
||||
# bash docker/verify-mes-test-machine.sh
|
||||
#
|
||||
# Group A reads the docker/recipes/ sample files through the Files global: RECIPE_ROOT is where they live
|
||||
# ON THE SITE NODE (default /app/recipes, the rig's site-a mount) and must be inside the site's
|
||||
# ScriptFileAccess:AllowedRoots; RECIPE_OUTSIDE is an existing file OUTSIDE the roots (proves the allow-list).
|
||||
#
|
||||
# Leaves both machines as it found them: alarm sources reset to their defaults, no batch moved in.
|
||||
|
||||
@@ -16,6 +22,8 @@ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
export SB_CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge"
|
||||
export SB_URL="${SCADABRIDGE_URL:-http://localhost:9000}"
|
||||
export SB_USER="${SCADABRIDGE_USERNAME:-multi-role}" SB_PASS="${SCADABRIDGE_PASSWORD:-password}"
|
||||
export VERIFY_GROUPS="${VERIFY_GROUPS:-ABCD}" # the rig has no BTDB: VERIFY_GROUPS=AD there
|
||||
export RECIPE_ROOT="${RECIPE_ROOT:-/app/recipes}" RECIPE_OUTSIDE="${RECIPE_OUTSIDE:-/app/appsettings.Site.json}"
|
||||
|
||||
python3 - <<'PY'
|
||||
import json, os, subprocess, sys
|
||||
@@ -57,68 +65,107 @@ def movein(sap, wo=WO): return call("MesMoveIn", {"SAPID": sap, "ContainerNumb
|
||||
def moveout(sap, wo=WO): return call("MesMoveOut", {"SAPID": sap, "ContainerNumber": "CN-1", "NumberWorkOrders": len(wo), "WorkOrders": wo, "OperatorName": "sb-test"})
|
||||
def names(r): return sorted(a["Name"] for a in r.get("Alarms") or [])
|
||||
|
||||
print("A. DelmiaRecipeDownload (routes by MachineCode)")
|
||||
r = call("DelmiaRecipeDownload", {"MachineCode": "SBTEST01", "DownloadPath": r"\\share\recipes\r1.xml", "WorkOrderNumber": "WO-1001", "PartNumber": "PN-2001", "JobStepNumber": "20", "Username": "sb-test"})
|
||||
check("A1 accepted", r.get("Result") is True and "accepted" in r.get("ResultText", ""), r)
|
||||
r = call("DelmiaRecipeDownload", {"MachineCode": "SBTEST01", "DownloadPath": r"\\share\recipes\r1.bad", "WorkOrderNumber": "WO-1001"})
|
||||
check("A2 .bad path rejected", r.get("Result") is False and "reject" in r.get("ResultText", "").lower(), r)
|
||||
def group_A():
|
||||
print("A. DelmiaRecipeDownload -> recipe file read + parsed + loaded (Files global)")
|
||||
ROOT, OUTSIDE = os.environ["RECIPE_ROOT"], os.environ["RECIPE_OUTSIDE"]
|
||||
SEP = "\\" if "\\" in ROOT else "/"
|
||||
def recipe(name): return ROOT.rstrip("/\\") + SEP + name
|
||||
def download(path, wo="WO-1001"):
|
||||
return call("DelmiaRecipeDownload", {"MachineCode": "SBTEST01", "DownloadPath": path, "WorkOrderNumber": wo,
|
||||
"PartNumber": "PN-2001", "JobStepNumber": "20", "Username": "sb-test"})
|
||||
def recv_tags():
|
||||
snap = cli("debug", "snapshot", "--id", str(instances["SBTEST01"]))
|
||||
return {a["attributeName"].split(".", 1)[1]: a["value"] for a in snap["attributeValues"]
|
||||
if a["attributeName"].startswith("DelmiaReceiver.")}
|
||||
|
||||
print("B. MesMoveIn / MesMoveOut (SAPID -> BTDB -> SBTEST01)")
|
||||
r = movein("990001_A")
|
||||
batch = r.get("BatchID", 0)
|
||||
check("B1 MoveIn left succeeds with a batch id", r.get("WasSuccessful") is True and batch >= 5000, r)
|
||||
r = moveout("990001_A")
|
||||
check("B2 MoveOut left returns the same batch", r.get("WasSuccessful") is True and r.get("BatchID") == batch, r)
|
||||
check("B3 MoveOut Run returns 16 cycle rows", len(r.get("Data") or []) == 16 and r["Data"][0].get("ValueName") == "TargetRuntime", r.get("Data"))
|
||||
r = moveout("990001_A")
|
||||
check("B4 second MoveOut rejected (nothing moved in)", r.get("WasSuccessful") is False and "No batch" in r.get("ErrorText", ""), r)
|
||||
r = movein("990001_A", [{"WorkOrderNumber": "WO-FAIL-1", "PartNumber": "PN-2001"}])
|
||||
check("B5 FAIL work order rejected", r.get("WasSuccessful") is False and "FAIL" in r.get("ErrorText", ""), r)
|
||||
r = movein("990001")
|
||||
check("B6 missing side suffix", r.get("WasSuccessful") is False and "suffix" in r.get("ErrorText", ""), r)
|
||||
r = movein("990001_B_LT"); lt = r.get("BatchID", 0)
|
||||
check("B7 leak-test MoveIn right", r.get("WasSuccessful") is True and lt > 0, r)
|
||||
r = moveout("990001_B_LT")
|
||||
check("B8 leak-test MoveOut returns 6 LT rows", r.get("WasSuccessful") is True and len(r.get("Data") or []) == 6 and r["Data"][0]["ValueName"] == "LTType", r)
|
||||
r = movein("999999_A")
|
||||
check("B9 unknown SAPID", r.get("WasSuccessful") is False and "No machine found" in r.get("ErrorText", ""), r)
|
||||
r = download(recipe("SBTEST-R100.txt"))
|
||||
check("A1 R100 accepted, 12 values", r.get("Result") is True and r.get("ResultText") == "Loaded 12 recipe values from SBTEST-R100.txt for WO-1001", r)
|
||||
t = recv_tags()
|
||||
check("A2 R100 values loaded with their types",
|
||||
t.get("RecipeName") == "Aluminide Coat R100" and t.get("RecipeRevision") == "7" and t.get("ReactorTempSP") == "1050.5"
|
||||
and t.get("PotTempSP") == "980" and t.get("PurgeEnabled", "").lower() == "true" and t.get("RecipeValueCount") == "12"
|
||||
and t.get("OperatorNote") == "Load parts cold, verify fixture ID", t)
|
||||
r = download(recipe("SBTEST-R200.txt"))
|
||||
t = recv_tags()
|
||||
check("A3 R200 re-download replaces every value (quoted text unwrapped)", r.get("Result") is True
|
||||
and t.get("CoatingType") == "Chromide" and t.get("ChlorineFlowSP") == "11.75" and t.get("PurgeEnabled", "").lower() == "false"
|
||||
and t.get("OperatorNote") == "Quoted text, commas kept", (r, t))
|
||||
r = download(recipe("SBTEST-R900-invalid.txt"))
|
||||
txt = r.get("ResultText", "")
|
||||
check("A4 invalid recipe rejected with line numbers", r.get("Result") is False and "line 4" in txt and "line 5: unknown recipe value 'MysterySetting'" in txt
|
||||
and "line 6: PotTempSP expects Int32" in txt, r)
|
||||
check("A5 rejected download loaded nothing (still R200)", recv_tags().get("RecipeName") == "Chromide Coat R200")
|
||||
r = download(recipe("SBTEST-NOPE.txt"))
|
||||
check("A6 missing file -> NotFound", r.get("Result") is False and "(NotFound)" in r.get("ResultText", ""), r)
|
||||
r = download(OUTSIDE)
|
||||
check("A7 file outside AllowedRoots -> refused", r.get("Result") is False and "(OutsideAllowedRoots)" in r.get("ResultText", ""), r)
|
||||
r = download(recipe("SBTEST-R100.txt"), wo="")
|
||||
check("A8 blank work order rejected", r.get("Result") is False and "work order" in r.get("ResultText", ""), r)
|
||||
|
||||
print("C. Alarm APIs")
|
||||
r = call("SimpleAlarmStatusRequest", {"SAPID": "990001"})
|
||||
check("C1 no alarms active -> success, empty", r.get("WasSuccessful") is True and names(r) == [], r)
|
||||
for inst in ("SBTEST01", "SBTEST02"):
|
||||
write(inst, "VacuumPressure", 12); write(inst, "DoorOpen", "true"); write(inst, "SensorStatus", -1)
|
||||
r = call("SimpleAlarmStatusRequest", {"SAPID": "990001"})
|
||||
check("C2 SBTEST01 simple = flagged only (HighVacuumFault)", names(r) == ["HighVacuumFault"], names(r))
|
||||
a = (r.get("Alarms") or [{}])[0]
|
||||
check("C3 row shape", a.get("HierarchicalName") == "SBTEST01.HighVacuumFault" and a.get("IsFlaggedForMES") is True
|
||||
and a.get("Severity") == 800 and a.get("StatusCode") == "Triggered.Acked" and a.get("TriggeredDT"), a)
|
||||
r = call("SimpleAlarmStatusRequest", {"SAPID": "990002"})
|
||||
check("C4 SBTEST02 simple includes the overridden SensorFault", names(r) == ["HighVacuumFault", "SensorFault"], names(r))
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST01"}})
|
||||
check("C5 full, no filter = all 3 active", names(r) == ["DoorOpenAlarm", "HighVacuumFault", "SensorFault"], names(r))
|
||||
flags = {x["Name"]: x["IsFlaggedForMES"] for x in r.get("Alarms") or []}
|
||||
check("C6 IsFlaggedForMES per alarm", flags == {"DoorOpenAlarm": False, "HighVacuumFault": True, "SensorFault": False}, flags)
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"SAPID": "990001_A"}, "AlarmFilter": {"MinSeverity": 500}})
|
||||
check("C7 SAPID w/ suffix + MinSeverity 500", names(r) == ["HighVacuumFault", "SensorFault"], names(r))
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"ZTag": "SBTEST01"}, "AlarmFilter": {"NameFilter": "door"}})
|
||||
check("C8 ZTag + NameFilter", names(r) == ["DoorOpenAlarm"], names(r))
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"MachineID": 204}, "AlarmFilter": {"FlaggedOnly": True, "MaxSeverity": 750}})
|
||||
check("C9 MachineID + FlaggedOnly + MaxSeverity", names(r) == ["SensorFault"], names(r))
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST01"}, "AlarmFilter": {"IncludeAcked": False}})
|
||||
check("C10 IncludeAcked=false drops computed (always-acked) alarms", r.get("WasSuccessful") is True and names(r) == [], names(r))
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"SAPID": "123"}})
|
||||
check("C11 unknown SAPID error text", r.get("WasSuccessful") is False and "Failed to find machine with SAPID '123'" in (r.get("ErrorText") or ""), r)
|
||||
r = call("AlarmStatusRequest", {})
|
||||
check("C12 empty filter error", r.get("WasSuccessful") is False and "machine filter" in (r.get("ErrorText") or ""), r)
|
||||
r = call("SimpleAlarmStatusRequest", {"SAPID": "103559"})
|
||||
check("C13 machine without AlarmStatus script -> not supported", r.get("WasSuccessful") is False and "not supported" in (r.get("ErrorText") or ""), r)
|
||||
def group_B():
|
||||
print("B. MesMoveIn / MesMoveOut (SAPID -> BTDB -> SBTEST01)")
|
||||
r = movein("990001_A")
|
||||
batch = r.get("BatchID", 0)
|
||||
check("B1 MoveIn left succeeds with a batch id", r.get("WasSuccessful") is True and batch >= 5000, r)
|
||||
r = moveout("990001_A")
|
||||
check("B2 MoveOut left returns the same batch", r.get("WasSuccessful") is True and r.get("BatchID") == batch, r)
|
||||
check("B3 MoveOut Run returns 16 cycle rows", len(r.get("Data") or []) == 16 and r["Data"][0].get("ValueName") == "TargetRuntime", r.get("Data"))
|
||||
r = moveout("990001_A")
|
||||
check("B4 second MoveOut rejected (nothing moved in)", r.get("WasSuccessful") is False and "No batch" in r.get("ErrorText", ""), r)
|
||||
r = movein("990001_A", [{"WorkOrderNumber": "WO-FAIL-1", "PartNumber": "PN-2001"}])
|
||||
check("B5 FAIL work order rejected", r.get("WasSuccessful") is False and "FAIL" in r.get("ErrorText", ""), r)
|
||||
r = movein("990001")
|
||||
check("B6 missing side suffix", r.get("WasSuccessful") is False and "suffix" in r.get("ErrorText", ""), r)
|
||||
r = movein("990001_B_LT"); lt = r.get("BatchID", 0)
|
||||
check("B7 leak-test MoveIn right", r.get("WasSuccessful") is True and lt > 0, r)
|
||||
r = moveout("990001_B_LT")
|
||||
check("B8 leak-test MoveOut returns 6 LT rows", r.get("WasSuccessful") is True and len(r.get("Data") or []) == 6 and r["Data"][0]["ValueName"] == "LTType", r)
|
||||
r = movein("999999_A")
|
||||
check("B9 unknown SAPID", r.get("WasSuccessful") is False and "No machine found" in r.get("ErrorText", ""), r)
|
||||
|
||||
print("D. Reset alarm sources")
|
||||
for inst in ("SBTEST01", "SBTEST02"):
|
||||
write(inst, "VacuumPressure", 0.5); write(inst, "DoorOpen", "false"); write(inst, "SensorStatus", 0)
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST02"}})
|
||||
check("D1 cleared", r.get("WasSuccessful") is True and names(r) == [], names(r))
|
||||
def group_C():
|
||||
print("C. Alarm APIs")
|
||||
r = call("SimpleAlarmStatusRequest", {"SAPID": "990001"})
|
||||
check("C1 no alarms active -> success, empty", r.get("WasSuccessful") is True and names(r) == [], r)
|
||||
for inst in ("SBTEST01", "SBTEST02"):
|
||||
write(inst, "VacuumPressure", 12); write(inst, "DoorOpen", "true"); write(inst, "SensorStatus", -1)
|
||||
r = call("SimpleAlarmStatusRequest", {"SAPID": "990001"})
|
||||
check("C2 SBTEST01 simple = flagged only (HighVacuumFault)", names(r) == ["HighVacuumFault"], names(r))
|
||||
a = (r.get("Alarms") or [{}])[0]
|
||||
check("C3 row shape", a.get("HierarchicalName") == "SBTEST01.HighVacuumFault" and a.get("IsFlaggedForMES") is True
|
||||
and a.get("Severity") == 800 and a.get("StatusCode") == "Triggered.Acked" and a.get("TriggeredDT"), a)
|
||||
r = call("SimpleAlarmStatusRequest", {"SAPID": "990002"})
|
||||
check("C4 SBTEST02 simple includes the overridden SensorFault", names(r) == ["HighVacuumFault", "SensorFault"], names(r))
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST01"}})
|
||||
check("C5 full, no filter = all 3 active", names(r) == ["DoorOpenAlarm", "HighVacuumFault", "SensorFault"], names(r))
|
||||
flags = {x["Name"]: x["IsFlaggedForMES"] for x in r.get("Alarms") or []}
|
||||
check("C6 IsFlaggedForMES per alarm", flags == {"DoorOpenAlarm": False, "HighVacuumFault": True, "SensorFault": False}, flags)
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"SAPID": "990001_A"}, "AlarmFilter": {"MinSeverity": 500}})
|
||||
check("C7 SAPID w/ suffix + MinSeverity 500", names(r) == ["HighVacuumFault", "SensorFault"], names(r))
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"ZTag": "SBTEST01"}, "AlarmFilter": {"NameFilter": "door"}})
|
||||
check("C8 ZTag + NameFilter", names(r) == ["DoorOpenAlarm"], names(r))
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"MachineID": 204}, "AlarmFilter": {"FlaggedOnly": True, "MaxSeverity": 750}})
|
||||
check("C9 MachineID + FlaggedOnly + MaxSeverity", names(r) == ["SensorFault"], names(r))
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST01"}, "AlarmFilter": {"IncludeAcked": False}})
|
||||
check("C10 IncludeAcked=false drops computed (always-acked) alarms", r.get("WasSuccessful") is True and names(r) == [], names(r))
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"SAPID": "123"}})
|
||||
check("C11 unknown SAPID error text", r.get("WasSuccessful") is False and "Failed to find machine with SAPID '123'" in (r.get("ErrorText") or ""), r)
|
||||
r = call("AlarmStatusRequest", {})
|
||||
check("C12 empty filter error", r.get("WasSuccessful") is False and "machine filter" in (r.get("ErrorText") or ""), r)
|
||||
r = call("SimpleAlarmStatusRequest", {"SAPID": "103559"})
|
||||
check("C13 machine without AlarmStatus script -> not supported", r.get("WasSuccessful") is False and "not supported" in (r.get("ErrorText") or ""), r)
|
||||
|
||||
def group_D():
|
||||
print("D. Reset alarm sources")
|
||||
for inst in ("SBTEST01", "SBTEST02"):
|
||||
write(inst, "VacuumPressure", 0.5); write(inst, "DoorOpen", "false"); write(inst, "SensorStatus", 0)
|
||||
r = call("AlarmStatusRequest", {"MachineFilter": {"Code": "SBTEST02"}})
|
||||
check("D1 cleared", r.get("WasSuccessful") is True and names(r) == [], names(r))
|
||||
|
||||
GROUPS = os.environ["VERIFY_GROUPS"].upper()
|
||||
for g in "ABCD":
|
||||
if g in GROUPS:
|
||||
globals()["group_" + g]()
|
||||
|
||||
print(f"\n{'PASS' if failed == 0 else 'FAIL'} {passed}/{passed + failed}")
|
||||
sys.exit(1 if failed else 0)
|
||||
|
||||
Reference in New Issue
Block a user