use pointlock_ir::{FlowIR, StepIR};
const EXAMPLE_FLOW: &str = r#"
{
"irVersion": 1,
"flowId": "wifi_toggle",
"irHash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"provider": { "name": "devicerail", "version": "0.4.2" },
"requiredFeatures": [
"device.semanticActions.v1",
"observation.uiSnapshot.v1",
"verdict.record.v1"
],
"lockfileDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"params": [
{ "name": "ssid", "schema": { "type": "string", "minLength": 1 }, "required": true },
{ "name": "username", "schema": { "type": "string" }, "required": false, "default": "qa-bot" }
],
"outputs": [
{ "name": "wifi_verdict",
"schema": { "enum": ["pass", "fail", "unknown"] },
"from": { "ref": "steps.wifi_on_visible.verdict" } }
],
"verdictPolicy": "strict",
"body": [
{
"kind": "action",
"stepId": "open_wifi_settings",
"effectHash": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
"judgeHash": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
"checkpoint": true,
"verb": "tap",
"effect": "mutating",
"idempotent": true,
"timeoutMs": 15000,
"preflight": [
{ "assertId": "settings_root_visible",
"predicate": { "type": "elementState",
"selector": { "identifier": "settings_root" }, "state": "visible" },
"verifyVia": ["uiTree"],
"onMissingInput": "unknown" }
],
"retry": {
"maxAttempts": 2,
"backoffMs": { "initial": 500, "factor": 2, "max": 4000 },
"retryOn": ["action_failed_retryable", "target_stale"]
},
"binding": {
"attempts": [
{ "channel": "uiTree",
"actionName": "tapElement",
"args": { "target": { "lit": { "kind": "selector",
"selector": { "identifier": "wifi_row" } } } },
"requiresFeature": "device.semanticActions.v1",
"acceptExecutionModes": ["nativeSemantic", "webSemantic"],
"protection": "standard" },
{ "channel": "coordinate",
"actionName": "tap",
"args": { "x": { "lit": 512 }, "y": { "lit": 384 } },
"acceptExecutionModes": ["nativeSemantic", "webSemantic", "coordinateFallback"],
"protection": "standard" }
]
},
"assertions": [
{ "assertId": "wifi_screen_open",
"predicate": { "type": "elementState",
"selector": { "identifier": "wifi_toggle" }, "state": "visible" },
"verifyVia": ["uiTree", "vision"],
"visionPrompt": "Wi-Fi 设置页已打开,Wi-Fi 开关控件在屏幕上可见",
"onMissingInput": "unknown" }
]
},
{
"kind": "action",
"stepId": "set_ssid:field",
"effectHash": "sha256:3333333333333333333333333333333333333333333333333333333333333333",
"judgeHash": "sha256:4444444444444444444444444444444444444444444444444444444444444444",
"checkpoint": false,
"verb": "set_value",
"effect": "mutating",
"idempotent": true,
"binding": {
"attempts": [
{ "channel": "uiTree",
"actionName": "setElementValue",
"args": {
"target": { "lit": { "kind": "selector",
"selector": { "identifier": "ssid_field" } } },
"value": { "ref": "params.ssid" }
},
"requiresFeature": "device.semanticActions.v1",
"acceptExecutionModes": ["nativeSemantic", "webSemantic"],
"protection": "standard" }
]
},
"assertions": []
},
{
"kind": "assert",
"stepId": "wifi_on_visible",
"effectHash": "sha256:5555555555555555555555555555555555555555555555555555555555555555",
"judgeHash": "sha256:6666666666666666666666666666666666666666666666666666666666666666",
"checkpoint": true,
"observe": "fresh",
"assertions": [
{ "assertId": "toggle_enabled",
"predicate": { "type": "elementState",
"selector": { "identifier": "wifi_toggle" }, "state": "enabled" },
"verifyVia": ["uiTree", "vision"],
"visionPrompt": "Wi-Fi 开关处于打开(enabled)状态",
"onMissingInput": "unknown" },
{ "assertId": "ssid_listed",
"predicate": { "type": "elementText",
"selector": { "identifier": "current_network_label" },
"match": { "value": "Pointlock-Lab", "mode": "contains", "caseSensitive": false } },
"verifyVia": ["uiTree"],
"onMissingInput": "unknown" }
]
},
{
"kind": "call",
"stepId": "ensure_session",
"effectHash": "sha256:7777777777777777777777777777777777777777777777777777777777777777",
"judgeHash": "sha256:8888888888888888888888888888888888888888888888888888888888888888",
"checkpoint": true,
"flowRef": { "flowId": "ensure_logged_in",
"irHash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" },
"inputs": { "username": { "ref": "params.username" } }
},
{
"kind": "if",
"stepId": "wait_if_passed",
"effectHash": "sha256:9999999999999999999999999999999999999999999999999999999999999999",
"judgeHash": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
"checkpoint": true,
"cond": { "fn": "eq",
"args": [ { "ref": "steps.wifi_on_visible.verdict" }, { "lit": "pass" } ] },
"then": [
{
"kind": "action",
"stepId": "wait_connected",
"effectHash": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd",
"judgeHash": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee",
"checkpoint": true,
"verb": "wait_for",
"effect": "readonly",
"idempotent": true,
"timeoutMs": 30000,
"binding": {
"attempts": [
{ "channel": "uiTree",
"actionName": "waitForElement",
"args": { "selector": { "lit": { "identifier": "connected_banner" } },
"condition": { "lit": "visible" } },
"requiresFeature": "device.semanticActions.v1",
"acceptExecutionModes": ["nativeSemantic", "webSemantic"],
"protection": "standard" }
]
},
"outputs": { "matched": { "ref": "steps.wait_connected.output.matched" } },
"outputSchema": { "type": "object",
"properties": { "matched": { "type": "boolean" } },
"required": ["matched"] },
"assertions": [
{ "assertId": "banner_matched",
"predicate": { "type": "expr",
"expr": { "fn": "eq",
"args": [ { "ref": "steps.wait_connected.output.matched" },
{ "lit": true } ] } },
"verifyVia": [],
"onMissingInput": "unknown" }
]
}
]
},
{
"kind": "human",
"stepId": "confirm_wifi",
"effectHash": "sha256:abababababababababababababababababababababababababababababababab",
"judgeHash": "sha256:cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd",
"checkpoint": true,
"mode": "confirm",
"prompt": "确认设备已连接到目标 Wi-Fi 网络",
"presents": [ { "ref": "steps.wifi_on_visible.verdict" }, { "ref": "params.ssid" } ],
"decisions": ["confirmed", "rejected"],
"timeoutMs": 600000,
"onTimeout": "unknown"
},
{
"kind": "let",
"stepId": "label",
"effectHash": "sha256:efefefefefefefefefefefefefefefefefefefefefefefefefefefefefefefef",
"judgeHash": "sha256:1212121212121212121212121212121212121212121212121212121212121212",
"checkpoint": true,
"bindings": {
"report_label": { "fn": "concat",
"args": [ { "lit": "wifi:" }, { "ref": "params.ssid" } ] }
}
}
],
"handlers": [
{ "hook": "onUnknown",
"action": {
"kind": "escalate",
"human": {
"kind": "human",
"stepId": "flow:onUnknown:escalate",
"effectHash": "sha256:3434343434343434343434343434343434343434343434343434343434343434",
"judgeHash": "sha256:5656565656565656565656565656565656565656565656565656565656565656",
"checkpoint": true,
"mode": "judge",
"prompt": "机器无法判定本步结果,请依据证据人工裁决",
"presents": [],
"decisions": ["pass", "fail", "unknown"],
"timeoutMs": 3600000,
"onTimeout": "unknown"
}
},
"maxTriggers": 1 }
],
"sourceMap": [
{ "irPath": "/body/0",
"file": "flows/wifi_toggle.flow.yaml",
"span": { "startLine": 33, "startCol": 3, "endLine": 52, "endCol": 62 } },
{ "irPath": "/body/1",
"file": "flows/wifi_toggle.flow.yaml",
"span": { "startLine": 18, "startCol": 7, "endLine": 20, "endCol": 24 },
"origin": [
{ "macro": "fill_field",
"file": "flows/wifi_toggle.flow.yaml",
"span": { "startLine": 54, "startCol": 3, "endLine": 57, "endCol": 32 } }
] }
],
"subflows": {
"ensure_logged_in": {
"flowId": "ensure_logged_in",
"irHash": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
}
}
}
"#;
#[test]
fn example_flow_round_trips() {
let flow: FlowIR = serde_json::from_str(EXAMPLE_FLOW).expect("02 §13.2 deserializes");
assert_eq!(flow.flow_id.as_str(), "wifi_toggle");
assert_eq!(flow.body.len(), 7);
assert_eq!(flow.body[0].kind(), "action");
assert_eq!(flow.body[1].step_id().as_str(), "set_ssid:field");
assert!(flow.body[1].step_id().is_synthesized());
assert_eq!(flow.body[2].kind(), "assert");
assert_eq!(flow.body[4].kind(), "if");
let StepIR::If(if_step) = &flow.body[4] else {
panic!("body[4] should be an if step");
};
assert_eq!(if_step.then.len(), 1);
assert!(if_step.r#else.is_none());
assert_eq!(flow.handlers.as_ref().map(Vec::len), Some(1));
assert_eq!(flow.required_features.len(), 3);
let reserialized = serde_json::to_value(&flow).expect("reserializes");
let original: serde_json::Value = serde_json::from_str(EXAMPLE_FLOW).unwrap();
assert_eq!(reserialized, original);
}
#[test]
fn rejects_out_of_grammar_values() {
let mut doc: serde_json::Value = serde_json::from_str(EXAMPLE_FLOW).unwrap();
doc["irVersion"] = serde_json::json!(2);
assert!(serde_json::from_value::<FlowIR>(doc).is_err());
let mut doc: serde_json::Value = serde_json::from_str(EXAMPLE_FLOW).unwrap();
doc["outputs"][0]["from"] = serde_json::json!({ "ref": "secrets.token" });
assert!(serde_json::from_value::<FlowIR>(doc).is_err());
let mut doc: serde_json::Value = serde_json::from_str(EXAMPLE_FLOW).unwrap();
doc["irHash"] = serde_json::json!("sha256:zz");
assert!(serde_json::from_value::<FlowIR>(doc).is_err());
let mut doc: serde_json::Value = serde_json::from_str(EXAMPLE_FLOW).unwrap();
doc["body"][0]["binding"]["attempts"][0]["protection"] = serde_json::json!("protected");
assert!(serde_json::from_value::<FlowIR>(doc).is_err());
let mut doc: serde_json::Value = serde_json::from_str(EXAMPLE_FLOW).unwrap();
doc["body"][0]["binding"]["attempts"][0]["channel"] = serde_json::json!("vision");
assert!(serde_json::from_value::<FlowIR>(doc).is_err());
let mut doc: serde_json::Value = serde_json::from_str(EXAMPLE_FLOW).unwrap();
doc["body"][0]["assertions"][0]["verifyVia"][0] = serde_json::json!("coordinate");
assert!(serde_json::from_value::<FlowIR>(doc).is_err());
let mut doc: serde_json::Value = serde_json::from_str(EXAMPLE_FLOW).unwrap();
doc["body"][0]["assertions"][0]["onMissingInput"] = serde_json::json!("fail");
assert!(serde_json::from_value::<FlowIR>(doc).is_err());
}