feat: add generated scene skill platform hardening

This commit is contained in:
木炎
2026-04-21 23:19:06 +08:00
parent 118fc77935
commit 956f0c2b68
439 changed files with 61974 additions and 3645 deletions

View File

@@ -56,7 +56,11 @@ fn write_config(
if let Some(direct_submit_skill) = direct_submit_skill {
payload["directSubmitSkill"] = json!(direct_submit_skill);
}
fs::write(&config_path, serde_json::to_string_pretty(&payload).unwrap()).unwrap();
fs::write(
&config_path,
serde_json::to_string_pretty(&payload).unwrap(),
)
.unwrap();
config_path
}
@@ -92,9 +96,9 @@ fn start_browser_ws_server() -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHa
Ok(message) => message,
Err(tungstenite::Error::ConnectionClosed)
| Err(tungstenite::Error::AlreadyClosed)
| Err(tungstenite::Error::Protocol(
ProtocolError::ResetWithoutClosingHandshake,
)) => break,
| Err(tungstenite::Error::Protocol(ProtocolError::ResetWithoutClosingHandshake)) => {
break
}
Err(err) => panic!("browser ws test server read failed: {err}"),
};
let payload = match message {
@@ -113,7 +117,9 @@ fn start_browser_ws_server() -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHa
continue;
}
let values = parsed.as_array().expect("browser action frame should be an array");
let values = parsed
.as_array()
.expect("browser action frame should be an array");
let request_url = values[0].as_str().expect("request_url should be a string");
let action = values[1].as_str().expect("action should be a string");
action_count += 1;
@@ -129,7 +135,9 @@ fn start_browser_ws_server() -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHa
let callback_frame = match action {
"sgHideBrowserCallAfterLoaded" => {
let target_url = values[2].as_str().expect("navigate target_url should be a string");
let target_url = values[2]
.as_str()
.expect("navigate target_url should be a string");
json!([
request_url,
"callBackJsToCpp",
@@ -139,7 +147,9 @@ fn start_browser_ws_server() -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHa
])
}
"sgBrowserExcuteJsCodeByArea" => {
let target_url = values[2].as_str().expect("script target_url should be a string");
let target_url = values[2]
.as_str()
.expect("script target_url should be a string");
let response_text = if action_count == 2 {
"知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度".to_string()
} else {
@@ -250,7 +260,10 @@ return {
root
}
fn write_direct_submit_config(workspace_root: &std::path::Path, skill_root: &std::path::Path) -> PathBuf {
fn write_direct_submit_config(
workspace_root: &std::path::Path,
skill_root: &std::path::Path,
) -> PathBuf {
let config_path = workspace_root.join("sgclaw_config.json");
fs::write(
&config_path,
@@ -266,10 +279,8 @@ fn write_direct_submit_config(workspace_root: &std::path::Path, skill_root: &std
}
fn direct_submit_runtime_context(skill_root: &std::path::Path) -> AgentRuntimeContext {
let workspace_root = std::env::temp_dir().join(format!(
"sgclaw-agent-runtime-workspace-{}",
Uuid::new_v4()
));
let workspace_root =
std::env::temp_dir().join(format!("sgclaw-agent-runtime-workspace-{}", Uuid::new_v4()));
fs::create_dir_all(&workspace_root).unwrap();
let config_path = write_direct_submit_config(&workspace_root, skill_root);
AgentRuntimeContext::new(Some(config_path), workspace_root)
@@ -295,6 +306,90 @@ fn submit_zhihu_hotlist_export_message() -> BrowserMessage {
}
}
fn build_manifest_scene_skill_root() -> PathBuf {
let root = std::env::temp_dir().join(format!(
"sgclaw-agent-runtime-scene-skill-root-{}",
Uuid::new_v4()
));
let skill_dir = root.join("manifest-scene-report");
let script_dir = skill_dir.join("scripts");
fs::create_dir_all(&script_dir).unwrap();
fs::write(
skill_dir.join("SKILL.toml"),
r#"
[skill]
name = "manifest-scene-report"
description = "Collect manifest scene report data."
version = "0.1.0"
[[tools]]
name = "collect_manifest_scene"
description = "Collect manifest scene report rows."
kind = "browser_script"
command = "scripts/collect_manifest_scene.js"
"#,
)
.unwrap();
fs::write(
skill_dir.join("scene.toml"),
r#"
[scene]
id = "manifest-scene-report"
skill = "manifest-scene-report"
tool = "collect_manifest_scene"
kind = "browser_script"
version = "0.1.0"
category = "report_collection"
[manifest]
schema_version = "1"
[bootstrap]
expected_domain = "manifest.example.test"
target_url = "https://manifest.example.test/report"
page_title_keywords = []
requires_target_page = true
[deterministic]
suffix = "。。。"
include_keywords = ["自定义场景报表"]
exclude_keywords = ["知乎"]
[[params]]
name = "period"
resolver = "literal_passthrough"
required = false
prompt_missing = "missing"
prompt_ambiguous = "ambiguous"
[params.resolver_config]
output_field = "period_value"
value = "2026-03"
[artifact]
type = "report-artifact"
success_status = ["ok", "partial", "empty"]
failure_status = ["blocked", "error"]
"#,
)
.unwrap();
fs::write(
script_dir.join("collect_manifest_scene.js"),
r#"
return {
type: "report-artifact",
report_name: "manifest-scene-report",
status: "ok",
columns: ["period_value"],
rows: [{ period_value: args.period_value }],
counts: { rows: 1 }
};
"#,
)
.unwrap();
root
}
fn direct_submit_mode_logs(sent: &[AgentMessage]) -> Vec<String> {
sent.iter()
.filter_map(|message| match message {
@@ -470,7 +565,10 @@ fn submit_task_uses_direct_skill_mode_without_llm_configuration() {
let sent = transport.sent_messages();
let completion = direct_submit_completion(&sent).expect("task completion");
assert!(completion.0, "expected direct submit task to succeed: {sent:?}");
assert!(
completion.0,
"expected direct submit task to succeed: {sent:?}"
);
assert!(
completion.1.contains("report artifact payload"),
"expected report artifact payload in summary: {}",
@@ -531,7 +629,9 @@ fn submit_task_rejects_invalid_direct_submit_skill_config_before_routing() {
if !success && summary.contains("skill.tool")
));
assert!(direct_submit_mode_logs(&sent).is_empty());
assert!(!sent.iter().any(|message| matches!(message, AgentMessage::Command { .. })));
assert!(!sent
.iter()
.any(|message| matches!(message, AgentMessage::Command { .. })));
}
#[test]
@@ -567,7 +667,10 @@ fn submit_task_treats_partial_report_artifact_as_success_with_warning_summary()
let sent = transport.sent_messages();
let completion = direct_submit_completion(&sent).expect("task completion");
assert!(completion.0, "expected partial artifact to succeed: {sent:?}");
assert!(
completion.0,
"expected partial artifact to succeed: {sent:?}"
);
assert!(completion.1.contains("fault-details-report"));
assert!(completion.1.contains("2026-03"));
assert!(completion.1.contains("status=partial"));
@@ -718,7 +821,10 @@ fn submit_task_routes_zhihu_hotlist_export_before_direct_submit() {
let mode_logs = direct_submit_mode_logs(&sent);
let completion = direct_submit_completion(&sent).expect("task completion");
assert_eq!(mode_logs, vec!["zeroclaw_process_message_primary".to_string()]);
assert_eq!(
mode_logs,
vec!["zeroclaw_process_message_primary".to_string()]
);
assert!(
!completion.0,
"expected zhihu export without page context to fail before browser actions: {sent:?}"
@@ -840,7 +946,10 @@ fn production_submit_task_with_ws_and_direct_submit_config_routes_zhihu_before_d
!mode_logs.iter().any(|mode| mode == "direct_skill_primary"),
"unexpected direct submit mode log for zhihu ws submit: {sent:?}"
);
assert!(completion.0, "expected zhihu ws submit to succeed: {sent:?}");
assert!(
completion.0,
"expected zhihu ws submit to succeed: {sent:?}"
);
assert!(
!completion
.1
@@ -851,6 +960,76 @@ fn production_submit_task_with_ws_and_direct_submit_config_routes_zhihu_before_d
std::env::remove_var("SGCLAW_DISABLE_POST_EXPORT_OPEN");
}
#[test]
fn submit_task_routes_configured_manifest_scene_before_llm_provider() {
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
std::env::remove_var("DEEPSEEK_API_KEY");
std::env::remove_var("DEEPSEEK_BASE_URL");
std::env::remove_var("DEEPSEEK_MODEL");
let skill_root = build_manifest_scene_skill_root();
let workspace_root = temp_workspace_root();
let config_path = write_config(
&workspace_root,
"deepseek-test-key",
"http://127.0.0.1:9",
"deepseek-chat",
Some(skill_root.to_str().unwrap()),
None,
None,
);
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
1,
serde_json::json!({
"text": {
"type": "report-artifact",
"report_name": "manifest-scene-report",
"status": "ok",
"columns": ["period_value"],
"rows": [{"period_value": "2026-03"}],
"counts": {"rows": 1}
}
}),
)]));
let browser_tool = BrowserPipeTool::new(
transport.clone(),
policy_for_domains(&["manifest.example.test"]),
vec![1, 2, 3, 4, 5, 6, 7, 8],
)
.with_response_timeout(Duration::from_secs(1));
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root);
handle_browser_message_with_context(
transport.as_ref(),
&browser_tool,
&runtime_context,
BrowserMessage::SubmitTask {
instruction: "请执行自定义场景报表。。。".to_string(),
conversation_id: String::new(),
messages: vec![],
page_url: String::new(),
page_title: String::new(),
},
)
.unwrap();
let sent = transport.sent_messages();
let completion = direct_submit_completion(&sent).expect("task completion");
assert!(completion.0, "expected manifest scene success: {sent:?}");
assert!(completion.1.contains("manifest-scene-report"));
assert!(completion.1.contains("detail_rows=1"));
assert!(sent.iter().any(|message| matches!(
message,
AgentMessage::LogEntry { level, message }
if level == "mode" && message == "direct_skill_primary"
)));
assert!(!sent.iter().any(|message| matches!(
message,
AgentMessage::LogEntry { level, message }
if level == "mode" && message == "compat_llm_primary"
)));
}
#[test]
fn production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstrap() {
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
@@ -939,7 +1118,9 @@ fn production_submit_task_routes_zhihu_through_ws_backend_without_helper_bootstr
)
}));
std::env::remove_var("SGCLAW_DISABLE_POST_EXPORT_OPEN");
assert!(!sent.iter().any(|message| matches!(message, AgentMessage::Command { .. })));
assert!(!sent
.iter()
.any(|message| matches!(message, AgentMessage::Command { .. })));
}
#[test]
@@ -952,8 +1133,12 @@ fn lifecycle_messages_emit_status_events_without_browser_commands() {
)
.with_response_timeout(Duration::from_secs(1));
sgclaw::agent::handle_browser_message(transport.as_ref(), &browser_tool, BrowserMessage::Connect)
.unwrap();
sgclaw::agent::handle_browser_message(
transport.as_ref(),
&browser_tool,
BrowserMessage::Connect,
)
.unwrap();
sgclaw::agent::handle_browser_message(transport.as_ref(), &browser_tool, BrowserMessage::Start)
.unwrap();
sgclaw::agent::handle_browser_message(transport.as_ref(), &browser_tool, BrowserMessage::Stop)

View File

@@ -0,0 +1,122 @@
use std::fs;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct BoundaryFamilyEntryDecision {
#[serde(rename = "decisionDate")]
decision_date: String,
scope: String,
#[serde(rename = "startingState")]
starting_state: StartingState,
#[serde(rename = "comparisonMatrix")]
comparison_matrix: Vec<ComparisonEntry>,
#[serde(rename = "selectedCandidate")]
selected_candidate: SelectedCandidate,
#[serde(rename = "holdReasons")]
hold_reasons: Vec<String>,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct StartingState {
#[serde(rename = "mainlineClosed")]
mainline_closed: Vec<String>,
#[serde(rename = "boundaryHeld")]
boundary_held: Vec<String>,
#[serde(rename = "deferredOutOfScope")]
deferred_out_of_scope: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct ComparisonEntry {
group: String,
#[serde(rename = "representativeScene")]
representative_scene: String,
#[serde(rename = "entryCondition")]
entry_condition: String,
#[serde(rename = "smallestNewCapability")]
smallest_new_capability: String,
#[serde(rename = "entryCost")]
entry_cost: String,
decision: String,
reason: String,
}
#[derive(Debug, Deserialize)]
struct SelectedCandidate {
group: String,
#[serde(rename = "representativeScene")]
representative_scene: String,
#[serde(rename = "nextDesign")]
next_design: String,
#[serde(rename = "nextPlan")]
next_plan: String,
}
#[test]
fn boundary_family_entry_roadmap_selects_one_next_candidate() {
let decision: BoundaryFamilyEntryDecision = serde_json::from_str(
&fs::read_to_string(
"tests/fixtures/generated_scene/boundary_family_entry_decision_2026-04-19.json",
)
.unwrap(),
)
.unwrap();
assert_eq!(decision.decision_date, "2026-04-19");
assert_eq!(decision.scope, "boundary-family-real-sample-entry-roadmap");
assert_eq!(
decision.starting_state.mainline_closed,
vec!["G1-E", "G2", "G3"]
);
assert_eq!(
decision.starting_state.boundary_held,
vec!["G6", "G7", "G8"]
);
assert_eq!(
decision.starting_state.deferred_out_of_scope,
vec!["G4", "G5"]
);
assert_eq!(decision.comparison_matrix.len(), 3);
assert_eq!(
decision
.comparison_matrix
.iter()
.filter(|entry| entry.decision == "selected")
.count(),
1
);
assert!(decision
.comparison_matrix
.iter()
.any(|entry| entry.group == "G7"
&& entry.decision == "selected"
&& entry.entry_cost == "medium"));
assert!(decision
.comparison_matrix
.iter()
.any(|entry| entry.group == "G6"
&& entry.decision == "hold"
&& entry.entry_cost == "high"));
assert!(decision
.comparison_matrix
.iter()
.any(|entry| entry.group == "G8"
&& entry.decision == "hold"
&& entry.entry_cost == "high"));
let selected = &decision.selected_candidate;
assert_eq!(selected.group, "G7");
assert_eq!(selected.representative_scene, "计量资产库存统计");
assert!(selected
.next_design
.ends_with("2026-04-19-g7-real-sample-entry-design.md"));
assert!(selected
.next_plan
.ends_with("2026-04-19-g7-real-sample-entry-plan.md"));
assert_eq!(decision.hold_reasons.len(), 2);
assert!(!decision.notes.is_empty());
}

View File

@@ -0,0 +1,113 @@
use std::fs;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct BoundaryRuntimePrerequisitesDecision {
#[serde(rename = "decisionDate")]
decision_date: String,
scope: String,
#[serde(rename = "startingState")]
starting_state: StartingState,
#[serde(rename = "comparisonMatrix")]
comparison_matrix: Vec<ComparisonEntry>,
#[serde(rename = "selectedDirection")]
selected_direction: SelectedDirection,
#[serde(rename = "holdReasons")]
hold_reasons: Vec<String>,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct StartingState {
#[serde(rename = "boundaryClosed")]
boundary_closed: Vec<String>,
#[serde(rename = "boundaryHeld")]
boundary_held: Vec<String>,
#[serde(rename = "mainlineClosed")]
mainline_closed: Vec<String>,
#[serde(rename = "deferredOutOfScope")]
deferred_out_of_scope: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct ComparisonEntry {
direction: String,
#[serde(rename = "blockedCapability")]
blocked_capability: String,
#[serde(rename = "isolationCost")]
isolation_cost: String,
decision: String,
reason: String,
}
#[derive(Debug, Deserialize)]
struct SelectedDirection {
direction: String,
#[serde(rename = "nextDesign")]
next_design: String,
#[serde(rename = "nextPlan")]
next_plan: String,
}
#[test]
fn boundary_runtime_prerequisites_roadmap_selects_one_direction() {
let decision: BoundaryRuntimePrerequisitesDecision = serde_json::from_str(
&fs::read_to_string(
"tests/fixtures/generated_scene/boundary_runtime_prerequisites_decision_2026-04-19.json",
)
.unwrap(),
)
.unwrap();
assert_eq!(decision.decision_date, "2026-04-19");
assert_eq!(decision.scope, "boundary-runtime-prerequisites-roadmap");
assert_eq!(decision.starting_state.boundary_closed, vec!["G7"]);
assert_eq!(decision.starting_state.boundary_held, vec!["G6", "G8"]);
assert_eq!(
decision.starting_state.mainline_closed,
vec!["G1-E", "G2", "G3"]
);
assert_eq!(
decision.starting_state.deferred_out_of_scope,
vec!["G4", "G5"]
);
assert_eq!(decision.comparison_matrix.len(), 2);
assert_eq!(
decision
.comparison_matrix
.iter()
.filter(|entry| entry.decision == "selected")
.count(),
1
);
assert!(decision
.comparison_matrix
.iter()
.any(|entry| entry.direction == "G6-host-bridge-prerequisites"
&& entry.decision == "selected"
&& entry.isolation_cost == "medium"));
assert!(decision
.comparison_matrix
.iter()
.any(|entry| entry.direction == "G8-local-doc-prerequisites"
&& entry.decision == "hold"
&& entry.isolation_cost == "high"));
assert_eq!(
decision.selected_direction.direction,
"G6-host-bridge-prerequisites"
);
assert!(decision
.selected_direction
.next_design
.ends_with("2026-04-19-g6-host-bridge-prerequisites-design.md"));
assert!(decision
.selected_direction
.next_plan
.ends_with("2026-04-19-g6-host-bridge-prerequisites-plan.md"));
assert_eq!(decision.hold_reasons.len(), 1);
assert!(!decision.notes.is_empty());
}

View File

@@ -147,5 +147,7 @@ fn bridge_backend_maps_bridge_failure_to_pipe_error() {
)
.unwrap_err();
assert!(matches!(error, PipeError::Protocol(message) if message == "bridge action failed: selector not found"));
assert!(
matches!(error, PipeError::Protocol(message) if message == "bridge action failed: selector not found")
);
}

View File

@@ -29,11 +29,7 @@ fn bridge_contract_represents_browser_action_requests_without_ws_business_frames
json!({ "url": "https://www.baidu.com" }),
"www.baidu.com",
),
BridgeBrowserActionRequest::new(
"click",
json!({ "selector": "#submit" }),
"www.zhihu.com",
),
BridgeBrowserActionRequest::new("click", json!({ "selector": "#submit" }), "www.zhihu.com"),
BridgeBrowserActionRequest::new(
"getText",
json!({ "selector": "#content" }),
@@ -76,5 +72,8 @@ fn bridge_contract_represents_browser_action_requests_without_ws_business_frames
assert!(object.contains_key("action"));
assert!(object.contains_key("params"));
assert!(object.contains_key("expected_domain"));
assert_eq!(first["expected_domain"], Value::String("www.baidu.com".to_string()));
assert_eq!(
first["expected_domain"],
Value::String("www.baidu.com".to_string())
);
}

View File

@@ -250,8 +250,8 @@ return {
command: "scripts/extract_hotlist.js".to_string(),
args,
};
let tool = BrowserScriptSkillTool::new("zhihu-hotlist", &skill_tool, &skill_dir, backend)
.unwrap();
let tool =
BrowserScriptSkillTool::new("zhihu-hotlist", &skill_tool, &skill_dir, backend).unwrap();
let result = tool
.execute(json!({
@@ -333,8 +333,8 @@ return {
command: script_name.to_string(),
args,
};
let tool = BrowserScriptSkillTool::new("zhihu-hotlist", &skill_tool, &skill_root, backend)
.unwrap();
let tool =
BrowserScriptSkillTool::new("zhihu-hotlist", &skill_tool, &skill_root, backend).unwrap();
let result = tool
.execute(json!({
@@ -458,7 +458,11 @@ async fn browser_script_helper_requires_expected_domain() {
let skill_dir = unique_temp_dir("sgclaw-browser-script-helper-missing-domain");
let scripts_dir = skill_dir.join("scripts");
fs::create_dir_all(&scripts_dir).unwrap();
fs::write(scripts_dir.join("collect_fault_details.js"), "return { ok: true };\n").unwrap();
fs::write(
scripts_dir.join("collect_fault_details.js"),
"return { ok: true };\n",
)
.unwrap();
let transport = Arc::new(MockTransport::new(vec![]));
let browser_tool = BrowserPipeTool::new(
@@ -568,7 +572,10 @@ return {
let backend = PipeBrowserBackend::from_inner(browser_tool);
let mut tool_args = HashMap::new();
tool_args.insert("period".to_string(), "YYYY-MM period to collect".to_string());
tool_args.insert(
"period".to_string(),
"YYYY-MM period to collect".to_string(),
);
let skill_tool = SkillTool {
name: "collect_fault_details".to_string(),
description: "Collect structured fault details".to_string(),
@@ -654,12 +661,9 @@ async fn execute_browser_script_tool_awaits_async_script() {
)
.unwrap();
let browser_tool = BrowserPipeTool::new(
transport.clone(),
policy_json,
vec![1, 2, 3, 4, 5, 6, 7, 8],
)
.with_response_timeout(Duration::from_secs(1));
let browser_tool =
BrowserPipeTool::new(transport.clone(), policy_json, vec![1, 2, 3, 4, 5, 6, 7, 8])
.with_response_timeout(Duration::from_secs(1));
let skill_tool = SkillTool {
name: "async_extract".to_string(),

View File

@@ -120,13 +120,9 @@ fn ws_backend_ignores_json_welcome_frame_before_zero_status() {
#[test]
fn ws_backend_fails_on_non_numeric_non_welcome_status_frame() {
let client = Arc::new(FakeWsClient::new(vec![Ok("not-a-status") ]));
let backend = WsBrowserBackend::new(
client,
test_policy(),
"https://www.baidu.com/current",
)
.with_response_timeout(Duration::from_secs(1));
let client = Arc::new(FakeWsClient::new(vec![Ok("not-a-status")]));
let backend = WsBrowserBackend::new(client, test_policy(), "https://www.baidu.com/current")
.with_response_timeout(Duration::from_secs(1));
let error = backend
.invoke(
@@ -139,7 +135,9 @@ fn ws_backend_fails_on_non_numeric_non_welcome_status_frame() {
)
.unwrap_err();
assert!(error.to_string().contains("invalid browser status frame: not-a-status"));
assert!(error
.to_string()
.contains("invalid browser status frame: not-a-status"));
}
#[test]
fn ws_backend_returns_success_for_zero_without_callback() {
@@ -179,12 +177,8 @@ fn ws_backend_returns_success_for_zero_without_callback() {
#[test]
fn ws_backend_fails_immediately_on_non_zero_return_code() {
let client = Arc::new(FakeWsClient::new(vec![Ok("7")]));
let backend = WsBrowserBackend::new(
client,
test_policy(),
"https://www.baidu.com/current",
)
.with_response_timeout(Duration::from_secs(1));
let backend = WsBrowserBackend::new(client, test_policy(), "https://www.baidu.com/current")
.with_response_timeout(Duration::from_secs(1));
let error = backend
.invoke(
@@ -197,7 +191,9 @@ fn ws_backend_fails_immediately_on_non_zero_return_code() {
)
.unwrap_err();
assert!(error.to_string().contains("browser returned non-zero status: 7"));
assert!(error
.to_string()
.contains("browser returned non-zero status: 7"));
}
#[test]
@@ -240,12 +236,8 @@ fn ws_backend_waits_for_callback_and_normalizes_result_payload() {
#[test]
fn ws_backend_times_out_while_waiting_for_callback_after_zero_status() {
let client = Arc::new(FakeWsClient::new(vec![Ok("0")]));
let backend = WsBrowserBackend::new(
client,
test_policy(),
"https://www.baidu.com/current",
)
.with_response_timeout(Duration::from_millis(1));
let backend = WsBrowserBackend::new(client, test_policy(), "https://www.baidu.com/current")
.with_response_timeout(Duration::from_millis(1));
let error = backend
.invoke(
@@ -337,12 +329,8 @@ fn ws_backend_reuses_last_navigated_url_for_followup_requests() {
#[test]
fn ws_backend_propagates_socket_drop_after_navigate_send() {
let client = Arc::new(FakeWsClient::new(vec![Err(PipeError::PipeClosed)]));
let backend = WsBrowserBackend::new(
client,
test_policy(),
"https://www.baidu.com/current",
)
.with_response_timeout(Duration::from_secs(1));
let backend = WsBrowserBackend::new(client, test_policy(), "https://www.baidu.com/current")
.with_response_timeout(Duration::from_secs(1));
let error = backend
.invoke(

View File

@@ -14,14 +14,26 @@ use ws_probe::{
#[derive(Clone)]
enum ServerStep {
ReceiveThenReply { expected: String, reply: String },
ReceiveThenReplyFrames { expected: String, replies: Vec<String> },
ReceiveThenStaySilent { expected: String },
ReceiveThenClose { expected: String },
ReceiveThenReply {
expected: String,
reply: String,
},
ReceiveThenReplyFrames {
expected: String,
replies: Vec<String>,
},
ReceiveThenStaySilent {
expected: String,
},
ReceiveThenClose {
expected: String,
},
CloseBeforeReceive,
}
fn spawn_fake_server(script: Vec<ServerStep>) -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHandle<()>) {
fn spawn_fake_server(
script: Vec<ServerStep>,
) -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let received = Arc::new(Mutex::new(Vec::new()));
@@ -157,10 +169,7 @@ fn parse_probe_args_accepts_ws_url_timeout_and_ordered_steps() {
#[test]
fn parse_probe_args_defaults_register_step_when_step_is_omitted() {
let args = vec![
"--ws-url".to_string(),
"ws://127.0.0.1:12345".to_string(),
];
let args = vec!["--ws-url".to_string(), "ws://127.0.0.1:12345".to_string()];
let parsed = parse_probe_args(&args).unwrap();
@@ -227,7 +236,10 @@ fn probe_records_welcome_then_silence_transcript() {
assert_eq!(
received.lock().unwrap().clone(),
steps.iter().map(|step| step.payload.clone()).collect::<Vec<_>>()
steps
.iter()
.map(|step| step.payload.clone())
.collect::<Vec<_>>()
);
assert_eq!(
results,
@@ -263,7 +275,8 @@ fn probe_runs_ordered_frame_script_and_records_per_step_results() {
},
ProbeStep {
label: "action".to_string(),
payload: r#"["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]"#.to_string(),
payload: r#"["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]"#
.to_string(),
expect_reply: true,
},
];
@@ -285,15 +298,27 @@ fn probe_runs_ordered_frame_script_and_records_per_step_results() {
assert_eq!(
received.lock().unwrap().clone(),
steps.iter().map(|step| step.payload.clone()).collect::<Vec<_>>()
steps
.iter()
.map(|step| step.payload.clone())
.collect::<Vec<_>>()
);
assert_eq!(results.len(), 3);
assert_eq!(results[0].label, "bootstrap-1");
assert_eq!(results[0].outcome, ProbeOutcome::Received(vec!["welcome".to_string()]));
assert_eq!(
results[0].outcome,
ProbeOutcome::Received(vec!["welcome".to_string()])
);
assert_eq!(results[1].label, "bootstrap-2");
assert_eq!(results[1].outcome, ProbeOutcome::Received(vec!["0".to_string()]));
assert_eq!(
results[1].outcome,
ProbeOutcome::Received(vec!["0".to_string()])
);
assert_eq!(results[2].label, "action");
assert_eq!(results[2].sent, r#"["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]"#);
assert_eq!(
results[2].sent,
r#"["about:blank","sgBrowerserOpenPage","https://www.zhihu.com/hot"]"#
);
assert_eq!(results[2].outcome, ProbeOutcome::TimedOut);
handle.join().unwrap();
@@ -313,7 +338,10 @@ fn probe_records_multiple_frames_for_one_step_within_timeout_window() {
let results = run_probe_script(&ws_url, Duration::from_millis(40), steps.clone()).unwrap();
assert_eq!(received.lock().unwrap().as_slice(), [steps[0].payload.as_str()]);
assert_eq!(
received.lock().unwrap().as_slice(),
[steps[0].payload.as_str()]
);
assert_eq!(
results,
vec![ProbeStepResult {
@@ -336,16 +364,18 @@ fn probe_records_steps_that_do_not_wait_for_reply_without_ambiguity() {
payload: r#"["about:blank","sgNoop"]"#.to_string(),
expect_reply: false,
}];
let (ws_url, received, handle) =
spawn_fake_server(vec![ServerStep::ReceiveThenStaySilent {
expected: steps[0].payload.clone(),
}]);
let (ws_url, received, handle) = spawn_fake_server(vec![ServerStep::ReceiveThenStaySilent {
expected: steps[0].payload.clone(),
}]);
let results = run_probe_script(&ws_url, Duration::from_millis(40), steps.clone()).unwrap();
handle.join().unwrap();
assert_eq!(received.lock().unwrap().as_slice(), [steps[0].payload.as_str()]);
assert_eq!(
received.lock().unwrap().as_slice(),
[steps[0].payload.as_str()]
);
assert_eq!(
results,
vec![ProbeStepResult {
@@ -380,7 +410,10 @@ fn probe_records_close_when_server_closes_before_next_send() {
let results = run_probe_script(&ws_url, Duration::from_millis(40), steps.clone()).unwrap();
assert_eq!(received.lock().unwrap().as_slice(), [steps[0].payload.as_str()]);
assert_eq!(
received.lock().unwrap().as_slice(),
[steps[0].payload.as_str()]
);
assert_eq!(
results,
vec![
@@ -413,7 +446,10 @@ fn probe_reports_socket_close_separately_from_timeout() {
let results = run_probe_script(&ws_url, Duration::from_millis(40), vec![step]).unwrap();
assert_eq!(received.lock().unwrap().as_slice(), [r#"["about:blank","sgOpenAgent"]"#]);
assert_eq!(
received.lock().unwrap().as_slice(),
[r#"["about:blank","sgOpenAgent"]"#]
);
assert_eq!(results.len(), 1);
assert_eq!(results[0].label, "close-case");
assert_eq!(results[0].outcome, ProbeOutcome::Closed);

View File

@@ -78,10 +78,9 @@ fn rejects_malformed_callback_frames_and_missing_request_ids() {
.unwrap_err();
assert!(malformed.to_string().contains("malformed callback payload"));
let wrong_function = decode_callback_frame(
r#"["https://www.zhihu.com/hot","sgBrowerserOpenPage","0"]"#,
)
.unwrap_err();
let wrong_function =
decode_callback_frame(r#"["https://www.zhihu.com/hot","sgBrowerserOpenPage","0"]"#)
.unwrap_err();
assert!(wrong_function
.to_string()
.contains("callback frame must target callBackJsToCpp"));
@@ -177,11 +176,15 @@ fn covers_supported_v1_action_mapping_and_rejects_unsupported_actions() {
];
for (action, params, request_id, browser_function, expects_callback) in cases {
let request = encode_v1_action(&action, &params, "https://www.zhihu.com/hot", request_id)
.unwrap();
let request =
encode_v1_action(&action, &params, "https://www.zhihu.com/hot", request_id).unwrap();
let payload: Value = serde_json::from_str(&request.payload).unwrap();
assert_eq!(payload[1], json!(browser_function), "action={action:?}");
assert_eq!(request.callback.is_some(), expects_callback, "action={action:?}");
assert_eq!(
request.callback.is_some(),
expects_callback,
"action={action:?}"
);
}
let unsupported = encode_v1_action(
@@ -191,5 +194,7 @@ fn covers_supported_v1_action_mapping_and_rejects_unsupported_actions() {
None,
)
.unwrap_err();
assert!(unsupported.to_string().contains("unsupported browser ws action"));
assert!(unsupported
.to_string()
.contains("unsupported browser ws action"));
}

View File

@@ -27,9 +27,7 @@ fn test_policy() -> MacPolicy {
.unwrap()
}
fn build_adapter(
messages: Vec<BrowserMessage>,
) -> (Arc<MockTransport>, ZeroClawBrowserTool) {
fn build_adapter(messages: Vec<BrowserMessage>) -> (Arc<MockTransport>, ZeroClawBrowserTool) {
let transport = Arc::new(MockTransport::new(messages));
let browser_tool = BrowserPipeTool::new(
transport.clone(),

View File

@@ -163,7 +163,10 @@ fn sgclaw_settings_default_to_compact_skills_and_browser_attached_profile() {
#[test]
fn sgclaw_settings_load_direct_submit_only_config_and_resolve_relative_skills_dir() {
let root = std::env::temp_dir().join(format!("sgclaw-direct-submit-only-config-{}", Uuid::new_v4()));
let root = std::env::temp_dir().join(format!(
"sgclaw-direct-submit-only-config-{}",
Uuid::new_v4()
));
fs::create_dir_all(&root).unwrap();
let config_path = root.join("sgclaw_config.json");

View File

@@ -47,7 +47,10 @@ async fn openxml_office_tool_renders_hotlist_xlsx_from_rows() {
let output_json: serde_json::Value = serde_json::from_str(&result.output).unwrap();
assert_eq!(output_json["row_count"], 2);
assert_eq!(output_json["renderer"], "openxml_office");
assert_eq!(output_json["output_path"], json!(output_path.to_str().unwrap()));
assert_eq!(
output_json["output_path"],
json!(output_path.to_str().unwrap())
);
let xml = read_sheet_xml(&output_path);
assert!(xml.contains("问题一"));

View File

@@ -8,14 +8,13 @@ use std::sync::{Arc, Mutex, OnceLock};
use std::thread;
use std::time::Duration;
use chrono::{Datelike, Local};
use common::MockTransport;
use serde_json::{json, Value};
use sgclaw::agent::{
handle_browser_message, handle_browser_message_with_context, AgentRuntimeContext,
};
use sgclaw::compat::workflow_executor::finalize_screen_export;
use sgclaw::compat::runtime::{execute_task, execute_task_with_sgclaw_settings, CompatTaskContext};
use sgclaw::compat::workflow_executor::finalize_screen_export;
use sgclaw::config::{DeepSeekSettings, SgClawSettings};
use sgclaw::pipe::{
Action, AgentMessage, BrowserMessage, BrowserPipeTool, ConversationMessage, Timing,
@@ -268,7 +267,9 @@ fn task_complete_summary(sent: &[AgentMessage]) -> String {
AgentMessage::TaskComplete { success, summary } if *success => Some(summary.clone()),
_ => None,
})
.unwrap_or_else(|| panic!("expected successful task completion, sent messages were: {sent:?}"))
.unwrap_or_else(|| {
panic!("expected successful task completion, sent messages were: {sent:?}")
})
}
fn extract_generated_artifact_path(summary: &str, extension: &str) -> PathBuf {
@@ -279,24 +280,6 @@ fn extract_generated_artifact_path(summary: &str, extension: &str) -> PathBuf {
.expect("expected artifact path in task summary")
}
fn expected_default_month() -> String {
let today = Local::now().date_naive();
let (year, month) = if today.month() == 1 {
(today.year() - 1, 12)
} else {
(today.year(), today.month() - 1)
};
format!("{year}-{month:02}")
}
fn expected_default_week_range() -> (String, String, String) {
let today = Local::now().date_naive();
let month_start = today.with_day(1).expect("current month should have day 1");
let start = month_start.format("%Y-%m-%d").to_string();
let end = today.format("%Y-%m-%d").to_string();
(format!("{start}{end}"), start, end)
}
#[test]
fn compat_runtime_uses_zeroclaw_provider_path_and_executes_browser_actions() {
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
@@ -2217,7 +2200,9 @@ fn handle_browser_message_chains_hotlist_skill_into_xlsx_export_and_auto_open()
let summary = task_complete_summary(&sent);
let generated = extract_generated_artifact_path(&summary, ".xlsx");
assert!(summary.contains("已导出知乎热榜 Excel") || summary.contains("已导出并打开知乎热榜 Excel"));
assert!(
summary.contains("已导出知乎热榜 Excel") || summary.contains("已导出并打开知乎热榜 Excel")
);
assert!(summary.contains(".xlsx"));
assert!(generated.exists());
assert!(sent.iter().any(|message| {
@@ -2333,7 +2318,10 @@ fn handle_browser_message_chains_hotlist_skill_into_screen_export_and_auto_open(
security,
..
} if action == &Action::Navigate
&& security.expected_domain == "__sgclaw_local_dashboard__" => Some((params, security)),
&& security.expected_domain == "__sgclaw_local_dashboard__" =>
{
Some((params, security))
}
_ => None,
})
.expect("dashboard route should emit local-dashboard navigate request");
@@ -2405,7 +2393,8 @@ fn handle_browser_message_chains_hotlist_skill_into_screen_export_and_auto_open(
}
#[test]
fn handle_browser_message_reports_dashboard_auto_open_protocol_error_when_presentation_url_is_missing() {
fn handle_browser_message_reports_dashboard_auto_open_protocol_error_when_presentation_url_is_missing(
) {
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
let transport = Arc::new(MockTransport::new(vec![]));
@@ -2437,7 +2426,8 @@ fn handle_browser_message_reports_dashboard_auto_open_protocol_error_when_presen
assert!(summary.contains("已生成知乎热榜大屏"));
assert!(summary.contains(output_path.to_string_lossy().as_ref()));
assert!(summary.contains("但浏览器自动打开失败screen_html_export did not return presentation.url"));
assert!(summary
.contains("但浏览器自动打开失败screen_html_export did not return presentation.url"));
let sent = transport.sent_messages();
assert!(!sent.iter().any(|message| {
@@ -2710,11 +2700,13 @@ fn ws_cleanup_no_longer_detects_fault_details_scene_route() {
#[test]
fn ws_cleanup_scene_keywords_do_not_trigger_primary_orchestration() {
assert!(!sgclaw::compat::orchestration::should_use_primary_orchestration(
"请处理95598抢修市指监测",
Some("https://95598.example.invalid/dispatch"),
Some("95598抢修市指监测"),
));
assert!(
!sgclaw::compat::orchestration::should_use_primary_orchestration(
"请处理95598抢修市指监测",
Some("https://95598.example.invalid/dispatch"),
Some("95598抢修市指监测"),
)
);
}
#[test]
@@ -3916,7 +3908,7 @@ fn handle_browser_message_executes_real_zhihu_write_skill_flow() {
}
fn staged_lineloss_skills_root() -> PathBuf {
PathBuf::from("D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills")
PathBuf::from("D:/data/ideaSpace/rust/sgClaw/claw-new/examples/generated_scene_platform/skills")
}
fn build_fault_details_direct_skill_root() -> PathBuf {
@@ -4033,9 +4025,10 @@ fn deterministic_lineloss_runtime_passes_canonical_args_to_browser_script_tool()
AgentMessage::TaskComplete { success, summary }
if *success
&& summary.contains("tq-lineloss-report")
&& summary.contains("国网兰州供电公司")
&& summary.contains("2026-03")
&& summary.contains("rows=1")
&& summary.contains("status=ok")
&& summary.contains("detail_rows=1")
&& summary.contains("summary_rows=0")
)
}));
assert!(sent.iter().any(|message| {
@@ -4064,7 +4057,6 @@ fn deterministic_lineloss_runtime_passes_canonical_args_to_browser_script_tool()
#[test]
fn deterministic_lineloss_runtime_defaults_missing_month_period_to_page_semantics() {
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
let expected_month = expected_default_month();
let workspace_root = temp_workspace_root();
let config_path = write_deepseek_config_with_skills_dir(
@@ -4076,35 +4068,7 @@ fn deterministic_lineloss_runtime_defaults_missing_month_period_to_page_semantic
);
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
1,
json!({
"text": {
"type": "report-artifact",
"report_name": "tq-lineloss-report",
"status": "ok",
"org": {
"label": "国网兰州供电公司",
"code": "62401"
},
"period": {
"mode": "month",
"mode_code": "1",
"value": expected_month,
"payload": {
"fdate": expected_default_month()
}
},
"rows": [
{ "ORG_NAME": "国网兰州供电公司", "LINE_LOSS_RATE": "3.21" }
],
"counts": {
"rows": 1
},
"reasons": []
}
}),
)]));
let transport = Arc::new(MockTransport::new(vec![]));
let browser_tool = BrowserPipeTool::new(
transport.clone(),
policy_for_domains(&["20.76.57.61"]),
@@ -4132,39 +4096,18 @@ fn deterministic_lineloss_runtime_defaults_missing_month_period_to_page_semantic
matches!(
message,
AgentMessage::TaskComplete { success, summary }
if *success
&& summary.contains("tq-lineloss-report")
&& summary.contains("国网兰州供电公司")
&& summary.contains(&expected_default_month())
&& summary.contains("rows=1")
)
}));
assert!(sent.iter().any(|message| {
matches!(
message,
AgentMessage::Command {
action,
params,
security,
..
}
if action == &Action::Eval
&& security.expected_domain == "20.76.57.61"
&& params["script"].as_str().is_some_and(|script|
script.contains("\"period_mode\":\"month\"")
&& script.contains("\"period_mode_code\":\"1\"")
&& script.contains(&format!("\"period_value\":\"{}\"", expected_default_month()))
&& script.contains("\"period_payload\":\"{")
&& script.contains(&format!("\\\"fdate\\\":\\\"{}\\\"", expected_default_month()))
)
if !*success
&& summary.contains("已命中台区线损报表技能但缺少统计周期请补充如“2026-03”或“2026年第12周”。")
)
}), "sent messages were: {sent:?}");
assert!(!sent
.iter()
.any(|message| matches!(message, AgentMessage::Command { .. })));
}
#[test]
fn deterministic_lineloss_runtime_defaults_missing_week_period_to_page_semantics() {
let _guard = env_lock().lock().unwrap_or_else(|err| err.into_inner());
let (expected_value, expected_start, expected_end) = expected_default_week_range();
let workspace_root = temp_workspace_root();
let config_path = write_deepseek_config_with_skills_dir(
@@ -4176,38 +4119,7 @@ fn deterministic_lineloss_runtime_defaults_missing_week_period_to_page_semantics
);
let runtime_context = AgentRuntimeContext::new(Some(config_path), workspace_root.clone());
let transport = Arc::new(MockTransport::new(vec![success_browser_response(
1,
json!({
"text": {
"type": "report-artifact",
"report_name": "tq-lineloss-report",
"status": "ok",
"org": {
"label": "国网兰州供电公司",
"code": "62401"
},
"period": {
"mode": "week",
"mode_code": "2",
"value": expected_value,
"payload": {
"tjzq": "week",
"level": "00",
"weekSfdate": expected_start,
"weekEfdate": expected_end
}
},
"rows": [
{ "ORG_NAME3": "国网兰州供电公司", "LINELOSS_RATE": "3.21" }
],
"counts": {
"rows": 1
},
"reasons": []
}
}),
)]));
let transport = Arc::new(MockTransport::new(vec![]));
let browser_tool = BrowserPipeTool::new(
transport.clone(),
policy_for_domains(&["20.76.57.61"]),
@@ -4235,34 +4147,13 @@ fn deterministic_lineloss_runtime_defaults_missing_week_period_to_page_semantics
matches!(
message,
AgentMessage::TaskComplete { success, summary }
if *success
&& summary.contains("tq-lineloss-report")
&& summary.contains("国网兰州供电公司")
&& summary.contains(&expected_value)
&& summary.contains("rows=1")
)
}));
assert!(sent.iter().any(|message| {
matches!(
message,
AgentMessage::Command {
action,
params,
security,
..
}
if action == &Action::Eval
&& security.expected_domain == "20.76.57.61"
&& params["script"].as_str().is_some_and(|script|
script.contains("\"period_mode\":\"week\"")
&& script.contains("\"period_mode_code\":\"2\"")
&& script.contains(&format!("\"period_value\":\"{}\"", expected_value))
&& script.contains("\"period_payload\":\"{")
&& script.contains(&format!("\\\"weekSfdate\\\":\\\"{}\\\"", expected_start))
&& script.contains(&format!("\\\"weekEfdate\\\":\\\"{}\\\"", expected_end))
)
if !*success
&& summary.contains("已命中台区线损报表技能但缺少统计周期请补充如“2026-03”或“2026年第12周”。")
)
}), "sent messages were: {sent:?}");
assert!(!sent
.iter()
.any(|message| matches!(message, AgentMessage::Command { .. })));
}
#[test]
@@ -4307,10 +4198,13 @@ fn deterministic_lineloss_missing_company_prompt_skips_browser_execution() {
matches!(
message,
AgentMessage::TaskComplete { success, summary }
if !*success && (summary.contains("缺少供电单位") || summary.contains("兰州公司"))
if !*success
&& summary.contains("已命中台区线损报表技能,但缺少供电单位。")
)
}));
assert!(!sent.iter().any(|message| matches!(message, AgentMessage::Command { .. })));
assert!(!sent
.iter()
.any(|message| matches!(message, AgentMessage::Command { .. })));
}
#[test]
@@ -4385,7 +4279,8 @@ fn deterministic_lineloss_runtime_maps_partial_artifact_to_success_summary() {
AgentMessage::TaskComplete { success, summary }
if *success
&& summary.contains("status=partial")
&& summary.contains("rows=1")
&& summary.contains("detail_rows=1")
&& summary.contains("summary_rows=0")
&& summary.contains("reasons=report_log_failed")
)
}));
@@ -4508,10 +4403,13 @@ fn deterministic_suffix_non_lineloss_request_does_not_fall_into_zhihu_logic() {
matches!(
message,
AgentMessage::TaskComplete { success, summary }
if !*success && summary.contains("台区线损")
if !*success
&& summary.contains("确定性提交当前只支持已注册的报表采集场景")
)
}));
assert!(!sent.iter().any(|message| matches!(message, AgentMessage::Command { .. })));
assert!(!sent
.iter()
.any(|message| matches!(message, AgentMessage::Command { .. })));
assert!(!sent.iter().any(|message| {
matches!(
message,

View File

@@ -1,126 +1,501 @@
mod common;
use std::path::PathBuf;
use std::fs;
use std::panic::AssertUnwindSafe;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use chrono::{Datelike, Local};
use zeroclaw::skills::load_skills_from_directory;
use sgclaw::compat::deterministic_submit::{
decide_deterministic_submit, DeterministicSubmitDecision,
decide_deterministic_submit, decide_deterministic_submit_with_skills_dir,
DeterministicSubmitDecision,
};
use sgclaw::compat::tq_lineloss::{
contracts::{PeriodMode, ResolvedOrg, ResolvedPeriod},
org_resolver::resolve_org,
period_resolver::resolve_period,
};
use sgclaw::runtime::is_zhihu_hotlist_task;
use uuid::Uuid;
fn temp_root(prefix: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("{prefix}-{}", Uuid::new_v4()));
fs::create_dir_all(&root).unwrap();
root
}
fn current_dir_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn with_temp_workspace<T>(prefix: &str, test: impl FnOnce(&Path) -> T) -> T {
let _guard = current_dir_lock().lock().unwrap();
let workspace_root = temp_root(prefix);
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(&workspace_root).unwrap();
let result = std::panic::catch_unwind(AssertUnwindSafe(|| test(&workspace_root)));
std::env::set_current_dir(original_dir).unwrap();
match result {
Ok(value) => value,
Err(payload) => std::panic::resume_unwind(payload),
}
}
fn default_skills_root(workspace_root: &Path) -> PathBuf {
workspace_root
.join(".sgclaw-zeroclaw-workspace")
.join("skills")
}
fn browser_script_skill_toml(skill_name: &str, tool_name: &str) -> String {
format!(
r#"[skill]
name = "{skill_name}"
description = "test skill"
version = "0.1.0"
[[tools]]
name = "{tool_name}"
description = "test tool"
kind = "browser_script"
command = "scripts/{tool_name}.js"
"#
)
}
fn toml_array(values: &[&str]) -> String {
if values.is_empty() {
return "[]".to_string();
}
let joined = values
.iter()
.map(|value| format!("\"{value}\""))
.collect::<Vec<_>>()
.join(", ");
format!("[{joined}]")
}
fn scene_toml(
scene_id: &str,
skill_name: &str,
tool_name: &str,
expected_domain: &str,
target_url: &str,
include_keywords: &[&str],
exclude_keywords: &[&str],
page_title_keywords: &[&str],
) -> String {
format!(
r#"[scene]
id = "{scene_id}"
skill = "{skill_name}"
tool = "{tool_name}"
kind = "browser_script"
version = "0.1.0"
category = "report_collection"
[manifest]
schema_version = "1"
[bootstrap]
expected_domain = "{expected_domain}"
target_url = "{target_url}"
page_title_keywords = {page_title_keywords}
requires_target_page = true
[deterministic]
suffix = "。。。"
include_keywords = {include_keywords}
exclude_keywords = {exclude_keywords}
[[params]]
name = "org"
resolver = "dictionary_entity"
required = true
prompt_missing = "已命中台区线损报表技能,但缺少供电单位。"
prompt_ambiguous = "已命中台区线损报表技能,但供电单位存在歧义,请补充更完整名称。"
[params.resolver_config]
dictionary_ref = "references/org-dictionary.json"
output_label_field = "org_label"
output_code_field = "org_code"
[[params]]
name = "period"
resolver = "month_week_period"
required = true
prompt_missing = "已命中台区线损报表技能,但缺少统计周期。"
prompt_ambiguous = "已命中台区线损报表技能,但统计周期存在歧义,请补充更明确表达。"
[artifact]
type = "report-artifact"
success_status = ["ok", "partial", "empty"]
failure_status = ["blocked", "error"]
"#,
include_keywords = toml_array(include_keywords),
exclude_keywords = toml_array(exclude_keywords),
page_title_keywords = toml_array(page_title_keywords),
)
}
fn org_dictionary_json() -> &'static str {
r#"[
{
"label": "国网兰州供电公司",
"code": "62401",
"aliases": ["国网兰州供电公司", "兰州供电公司", "兰州公司"]
},
{
"label": "城关供电分公司",
"code": "6240108",
"aliases": ["城关供电分公司", "城关分公司"]
},
{
"label": "国网天水供电公司",
"code": "62403",
"aliases": ["国网天水供电公司", "天水供电公司", "天水公司"]
}
]"#
}
fn write_scene_skill(
skills_root: &Path,
scene_id: &str,
skill_name: &str,
tool_name: &str,
expected_domain: &str,
target_url: &str,
include_keywords: &[&str],
exclude_keywords: &[&str],
page_title_keywords: &[&str],
) {
let skill_root = skills_root.join(skill_name);
fs::create_dir_all(skill_root.join("references")).unwrap();
fs::write(
skill_root.join("SKILL.toml"),
browser_script_skill_toml(skill_name, tool_name),
)
.unwrap();
fs::write(
skill_root.join("scene.toml"),
scene_toml(
scene_id,
skill_name,
tool_name,
expected_domain,
target_url,
include_keywords,
exclude_keywords,
page_title_keywords,
),
)
.unwrap();
fs::write(
skill_root.join("references").join("org-dictionary.json"),
org_dictionary_json(),
)
.unwrap();
}
fn assert_prompt_contains(decision: DeterministicSubmitDecision, needle: &str) {
match decision {
DeterministicSubmitDecision::Prompt { summary } => {
assert!(summary.contains(needle), "unexpected prompt: {summary}");
}
other => panic!("expected prompt containing {needle}, got {other:?}"),
}
}
#[test]
fn deterministic_submit_matches_final_bundle_lineloss_alias() {
let skills_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("dist")
.join("sgclaw_102_pseudoprod_validation_bundle_2026-04-20")
.join("skills");
let decision = decide_deterministic_submit_with_skills_dir(
"\u{53f0}\u{533a}\u{7ebf}\u{635f}\u{3002}\u{3002}\u{3002}",
None,
None,
&skills_root,
);
match decision {
DeterministicSubmitDecision::Prompt { summary } => {
assert!(
!summary.contains(
"\u{786e}\u{5b9a}\u{6027}\u{63d0}\u{4ea4}\u{5f53}\u{524d}\u{53ea}\u{652f}\u{6301}\u{5df2}\u{6ce8}\u{518c}"
),
"expected line-loss alias to reach registered scene resolver, got unsupported prompt: {summary}"
);
}
DeterministicSubmitDecision::Execute(plan) => {
assert_eq!(plan.tool_name, "sweep-030-scene.collect_sweep_030_scene");
}
DeterministicSubmitDecision::NotDeterministic => {
panic!("expected deterministic line-loss alias to be recognized")
}
}
}
#[test]
fn deterministic_submit_defaults_final_bundle_lineloss_month_to_page_semantics() {
let skills_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("dist")
.join("sgclaw_102_pseudoprod_validation_bundle_2026-04-20")
.join("skills");
let decision = decide_deterministic_submit_with_skills_dir(
"\u{5170}\u{5dde}\u{516c}\u{53f8} \u{7ebf}\u{635f}\u{5927}\u{6570}\u{636e} \u{6708}\u{7d2f}\u{8ba1}\u{7ebf}\u{635f}\u{7edf}\u{8ba1}\u{5206}\u{6790}\u{3002}\u{3002}\u{3002}",
None,
None,
&skills_root,
);
fn expected_default_month() -> String {
let today = Local::now().date_naive();
let (year, month) = if today.month() == 1 {
(today.year() - 1, 12)
} else {
(today.year(), today.month() - 1)
};
format!("{year}-{month:02}")
}
let expected_month = format!("{year}-{month:02}");
fn expected_default_week_range() -> (String, String, String) {
let today = Local::now().date_naive();
let month_start = today.with_day(1).expect("current month should have day 1");
let start = month_start.format("%Y-%m-%d").to_string();
let end = today.format("%Y-%m-%d").to_string();
(format!("{start}{end}"), start, end)
match decision {
DeterministicSubmitDecision::Execute(plan) => {
assert_eq!(plan.tool_name, "sweep-030-scene.collect_sweep_030_scene");
assert_eq!(plan.period_mode, "month");
assert_eq!(plan.period_mode_code, "1");
assert_eq!(plan.period_value, expected_month);
assert!(plan.period_payload.contains("fdate"));
}
other => panic!("expected execute plan with default month semantics, got {other:?}"),
}
}
#[test]
fn deterministic_submit_discovers_tq_lineloss_skill_contract() {
let skills_root = PathBuf::from("D:/data/ideaSpace/rust/sgClaw/claw/claw/skills/skill_staging/skills");
let skills = load_skills_from_directory(&skills_root, true);
let skill = skills
.iter()
.find(|skill| skill.name == "tq-lineloss-report")
.expect("tq-lineloss-report should be discoverable from staged skills root");
let tool = skill
.tools
.iter()
.find(|tool| tool.name == "collect_lineloss")
.expect("collect_lineloss tool should be discoverable");
assert_eq!(tool.kind, "browser_script");
assert_eq!(tool.command, "scripts/collect_lineloss.js");
let required_args = [
"expected_domain",
"org_label",
"org_code",
"period_mode",
"period_mode_code",
"period_value",
"period_payload",
];
for arg in required_args {
assert!(
tool.args.contains_key(arg),
"expected required arg {arg} in tq-lineloss-report.collect_lineloss"
fn deterministic_submit_uses_registry_backed_scene_plan() {
with_temp_workspace("sgclaw-deterministic-scene", |workspace_root| {
let skills_root = default_skills_root(workspace_root);
write_scene_skill(
&skills_root,
"tq-lineloss-report",
"tq-lineloss-report",
"collect_lineloss",
"20.76.57.61",
"http://20.76.57.61:18080/gsllys/tqLinelossStatis/tqQualifyRateMonitor",
&["线损", "统计分析"],
&["知乎"],
&["线损报表"],
);
}
assert_eq!(tool.args.len(), required_args.len());
let decision = decide_deterministic_submit(
"兰州公司 台区线损大数据 月累计线损率统计分析 2026-03。。。",
None,
None,
);
match decision {
DeterministicSubmitDecision::Execute(plan) => {
assert_eq!(plan.tool_name, "tq-lineloss-report.collect_lineloss");
assert_eq!(plan.expected_domain, "20.76.57.61");
assert_eq!(
plan.target_url,
"http://20.76.57.61:18080/gsllys/tqLinelossStatis/tqQualifyRateMonitor"
);
assert_eq!(plan.org_label, "国网兰州供电公司");
assert_eq!(plan.org_code, "62401");
assert_eq!(plan.period_mode, "month");
assert_eq!(plan.period_mode_code, "1");
assert_eq!(plan.period_value, "2026-03");
assert!(plan.period_payload.contains("fdate"));
}
other => panic!("expected execute plan, got {other:?}"),
}
});
}
#[test]
fn deterministic_submit_requires_exact_suffix() {
assert!(matches!(
decide_deterministic_submit("兰州公司 月累计 2026-03。。。", None, None),
DeterministicSubmitDecision::Execute(_)
));
with_temp_workspace("sgclaw-deterministic-suffix", |workspace_root| {
let skills_root = default_skills_root(workspace_root);
write_scene_skill(
&skills_root,
"tq-lineloss-report",
"tq-lineloss-report",
"collect_lineloss",
"20.76.57.61",
"http://20.76.57.61:18080/gsllys/tqLinelossStatis/tqQualifyRateMonitor",
&["线损", "统计分析"],
&["知乎"],
&["线损报表"],
);
assert!(matches!(
decide_deterministic_submit("兰州公司 月累计 2026-03", None, None),
DeterministicSubmitDecision::NotDeterministic
));
}
assert!(matches!(
decide_deterministic_submit(
"兰州公司 台区线损大数据 月累计线损率统计分析 2026-03。。。",
None,
None
),
DeterministicSubmitDecision::Execute(_)
));
#[test]
fn deterministic_submit_nonmatch_returns_supported_scene_message() {
let decision = decide_deterministic_submit("帮我打开百度。。。", None, None);
match decision {
DeterministicSubmitDecision::Prompt { summary } => {
assert!(summary.contains("台区线损") || summary.contains("支持场景"));
for instruction in [
"兰州公司 台区线损大数据 月累计线损率统计分析 2026-03",
"兰州公司 台区线损大数据 月累计线损率统计分析 2026-03...",
"兰州公司 台区线损大数据 月累计线损率统计分析 2026-03。。。。",
"兰州公司 台区线损大数据 月累计线损率统计分析 2026-03。。。 ",
] {
assert!(matches!(
decide_deterministic_submit(instruction, None, None),
DeterministicSubmitDecision::NotDeterministic
));
}
other => panic!("expected deterministic prompt for unsupported scene, got {other:?}"),
}
});
}
#[test]
fn deterministic_submit_rejects_page_context_mismatch() {
let decision = decide_deterministic_submit(
"兰州公司 月累计 2026-03。。。",
Some("https://www.zhihu.com/hot"),
Some("知乎热榜"),
);
fn deterministic_submit_fails_closed_on_scene_ambiguity() {
with_temp_workspace("sgclaw-deterministic-ambiguity", |workspace_root| {
let skills_root = default_skills_root(workspace_root);
write_scene_skill(
&skills_root,
"tq-lineloss-report",
"tq-lineloss-report",
"collect_lineloss",
"20.76.57.61",
"http://20.76.57.61:18080/gsllys/tqLinelossStatis/tqQualifyRateMonitor",
&["统计分析"],
&[],
&["线损报表"],
);
write_scene_skill(
&skills_root,
"other-report",
"other-report",
"collect_other",
"20.76.57.61",
"http://20.76.57.61:18080/other/report",
&["统计分析"],
&[],
&["其他报表"],
);
match decision {
DeterministicSubmitDecision::Prompt { summary } => {
assert!(summary.contains("台区线损") || summary.contains("页面") || summary.contains("不匹配"));
let decision = decide_deterministic_submit("兰州公司 统计分析。。。", None, None);
match decision {
DeterministicSubmitDecision::Prompt { summary } => {
assert!(
summary.contains("多个确定性场景"),
"unexpected prompt: {summary}"
);
assert!(
summary.contains("tq-lineloss-report"),
"unexpected prompt: {summary}"
);
assert!(
summary.contains("other-report"),
"unexpected prompt: {summary}"
);
}
other => panic!("expected ambiguity prompt, got {other:?}"),
}
other => panic!("expected deterministic mismatch prompt, got {other:?}"),
}
});
}
#[test]
fn zhihu_hotlist_request_without_suffix_keeps_existing_route() {
assert!(is_zhihu_hotlist_task(
"打开知乎热榜",
Some("https://www.zhihu.com/hot"),
Some("知乎热榜")
));
fn deterministic_submit_prompts_for_missing_period_instead_of_defaulting() {
with_temp_workspace("sgclaw-deterministic-period", |workspace_root| {
let skills_root = default_skills_root(workspace_root);
write_scene_skill(
&skills_root,
"tq-lineloss-report",
"tq-lineloss-report",
"collect_lineloss",
"20.76.57.61",
"http://20.76.57.61:18080/gsllys/tqLinelossStatis/tqQualifyRateMonitor",
&["线损", "统计分析"],
&["知乎"],
&["线损报表"],
);
assert_prompt_contains(
decide_deterministic_submit("兰州公司 月累计 统计分析。。。", None, None),
"缺少统计周期",
);
assert_prompt_contains(
decide_deterministic_submit("兰州公司 周累计 统计分析。。。", None, None),
"缺少统计周期",
);
});
}
#[test]
fn deterministic_submit_uses_page_context_to_break_ties_before_keyword_only_match() {
with_temp_workspace("sgclaw-deterministic-page-context", |workspace_root| {
let skills_root = default_skills_root(workspace_root);
write_scene_skill(
&skills_root,
"tq-lineloss-report",
"tq-lineloss-report",
"collect_lineloss",
"20.76.57.61",
"http://20.76.57.61:18080/gsllys/tqLinelossStatis/tqQualifyRateMonitor",
&["统计分析"],
&[],
&["线损报表"],
);
write_scene_skill(
&skills_root,
"fault-report",
"fault-report",
"collect_fault",
"20.76.57.61",
"http://20.76.57.61:18080/fault/report",
&["统计分析"],
&[],
&["95598工单"],
);
let decision = decide_deterministic_submit(
"兰州公司 月累计 统计分析 2026-03。。。",
Some("http://20.76.57.61:18080/#/lineloss"),
Some("台区线损报表"),
);
match decision {
DeterministicSubmitDecision::Execute(plan) => {
assert_eq!(plan.tool_name, "tq-lineloss-report.collect_lineloss");
assert_eq!(
plan.target_url,
"http://20.76.57.61:18080/gsllys/tqLinelossStatis/tqQualifyRateMonitor"
);
}
other => panic!("expected page context to select lineloss scene, got {other:?}"),
}
});
}
#[test]
fn deterministic_submit_unsupported_suffix_request_returns_supported_scene_message() {
with_temp_workspace("sgclaw-deterministic-unsupported", |workspace_root| {
let skills_root = default_skills_root(workspace_root);
write_scene_skill(
&skills_root,
"tq-lineloss-report",
"tq-lineloss-report",
"collect_lineloss",
"20.76.57.61",
"http://20.76.57.61:18080/gsllys/tqLinelossStatis/tqQualifyRateMonitor",
&["线损", "统计分析"],
&["知乎"],
&["线损报表"],
);
assert_prompt_contains(
decide_deterministic_submit("打开知乎热榜。。。", None, None),
"已注册的报表采集场景",
);
});
}
#[test]
fn zhihu_without_suffix_remains_not_deterministic() {
assert!(matches!(
decide_deterministic_submit(
"打开知乎热榜",
@@ -132,321 +507,51 @@ fn zhihu_hotlist_request_without_suffix_keeps_existing_route() {
}
#[test]
fn deterministic_submit_rejects_non_exact_suffix_variants() {
for instruction in [
"兰州公司 月累计 2026-03...",
"兰州公司 月累计 2026-03。。。。",
"兰州公司。。。月累计 2026-03",
"兰州公司 月累计 2026-03。。。 ",
] {
assert!(matches!(
decide_deterministic_submit(instruction, None, None),
DeterministicSubmitDecision::NotDeterministic
));
}
}
fn committed_lineloss_sample_package_drives_deterministic_submit() {
let _guard = current_dir_lock().lock().unwrap();
let skills_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("examples")
.join("generated_scene_platform")
.join("skills");
#[test]
fn lineloss_org_resolver_matches_city_alias() {
assert_eq!(
resolve_org("兰州公司").unwrap(),
ResolvedOrg {
label: "国网兰州供电公司".to_string(),
code: "62401".to_string(),
}
);
assert_eq!(
resolve_org("天水公司").unwrap(),
ResolvedOrg {
label: "国网天水供电公司".to_string(),
code: "62403".to_string(),
}
);
}
#[test]
fn lineloss_org_resolver_matches_county_alias() {
assert_eq!(
resolve_org("榆中县公司").unwrap(),
ResolvedOrg {
label: "国网榆中县供电公司".to_string(),
code: "6240121".to_string(),
}
);
assert_eq!(
resolve_org("城关供电分公司").unwrap(),
ResolvedOrg {
label: "城关供电分公司".to_string(),
code: "6240108".to_string(),
}
);
}
#[test]
fn lineloss_org_resolver_prompts_on_ambiguity() {
let summary = resolve_org("城关")
.expect_err("ambiguous alias should prompt instead of guessing");
assert!(summary.contains("供电单位存在歧义") || summary.contains("更完整名称"));
}
#[test]
fn deterministic_submit_lineloss_missing_company_prompts() {
let decision = decide_deterministic_submit("月累计 2026-03。。。", None, None);
match decision {
DeterministicSubmitDecision::Prompt { summary } => {
assert!(summary.contains("缺少供电单位") || summary.contains("兰州公司"));
}
other => panic!("expected missing-company prompt, got {other:?}"),
}
}
#[test]
fn lineloss_period_resolver_parses_month_text() {
assert_eq!(
resolve_period("月累计 2026-03").unwrap(),
ResolvedPeriod {
mode: PeriodMode::Month,
mode_code: "1".to_string(),
value: "2026-03".to_string(),
payload: serde_json::json!({
"fdate": "2026-03",
}),
}
);
assert_eq!(
resolve_period("月累计 2026年3月").unwrap(),
ResolvedPeriod {
mode: PeriodMode::Month,
mode_code: "1".to_string(),
value: "2026-03".to_string(),
payload: serde_json::json!({
"fdate": "2026-03",
}),
}
);
}
#[test]
fn lineloss_period_resolver_parses_week_text() {
let resolved = resolve_period("周累计 2026年第12周").unwrap();
assert_eq!(resolved.mode, PeriodMode::Week);
assert_eq!(resolved.mode_code, "2");
assert_eq!(resolved.value, "2026-W12");
assert_eq!(resolved.payload["tjzq"], "week");
assert_eq!(resolved.payload["level"], "00");
assert_eq!(resolved.payload["weekSfdate"], "2026-03-16");
assert_eq!(resolved.payload["weekEfdate"], "2026-03-22");
}
#[test]
fn lineloss_period_resolver_defaults_month_period_from_page_semantics() {
let expected_month = expected_default_month();
assert_eq!(
resolve_period("兰州公司 月累计").unwrap(),
ResolvedPeriod {
mode: PeriodMode::Month,
mode_code: "1".to_string(),
value: expected_month.clone(),
payload: serde_json::json!({
"fdate": expected_month,
}),
}
);
}
#[test]
fn lineloss_period_resolver_defaults_week_period_from_page_semantics() {
let (expected_value, expected_start, expected_end) = expected_default_week_range();
assert_eq!(
resolve_period("兰州公司 周累计").unwrap(),
ResolvedPeriod {
mode: PeriodMode::Week,
mode_code: "2".to_string(),
value: expected_value,
payload: serde_json::json!({
"tjzq": "week",
"level": "00",
"weekSfdate": expected_start,
"weekEfdate": expected_end,
}),
}
);
}
#[test]
fn lineloss_period_resolver_prompts_for_missing_year_on_week() {
let summary = resolve_period("周累计 第12周")
.expect_err("bare week should prompt for year instead of guessing");
assert!(summary.contains("年份") || summary.contains("第12周"));
}
#[test]
fn lineloss_period_resolver_rejects_contradictory_mode() {
let summary = resolve_period("月累计 周累计 2026-03")
.expect_err("contradictory month/week intent should not execute");
assert!(summary.contains("月/周") || summary.contains("冲突") || summary.contains("歧义"));
}
#[test]
fn lineloss_period_resolver_prompts_for_missing_mode() {
let summary = resolve_period("兰州公司 2026-03")
.expect_err("missing mode should prompt instead of guessing");
assert!(summary.contains("月/周类型") || summary.contains("月累计") || summary.contains("周累计"));
}
#[test]
fn lineloss_period_resolver_prompts_for_missing_period() {
let summary = resolve_period("兰州公司 月累计")
.expect_err("missing period should prompt instead of guessing");
assert!(summary.contains("周期") || summary.contains("时间") || summary.contains("2026-03"));
}
#[test]
fn deterministic_lineloss_execution_plan_contains_canonical_args() {
let decision = decide_deterministic_submit(
"兰州公司 月累计 2026-03。。。",
Some("http://20.76.57.61:8080/#/lineloss"),
Some("台区线损报表"),
let decision = decide_deterministic_submit_with_skills_dir(
"兰州公司 台区线损大数据 月累计线损率统计分析 2026-03。。。",
None,
None,
&skills_dir,
);
match decision {
DeterministicSubmitDecision::Execute(plan) => {
let debug = format!("{plan:?}");
assert!(debug.contains("国网兰州供电公司"), "missing canonical org label: {debug}");
assert!(debug.contains("62401"), "missing canonical org code: {debug}");
assert!(debug.contains("2026-03"), "missing canonical period value: {debug}");
assert!(debug.contains("month") || debug.contains("Month"), "missing canonical month mode: {debug}");
assert!(debug.contains("fdate"), "missing canonical month payload: {debug}");
}
other => panic!("expected deterministic execute plan, got {other:?}"),
}
}
#[test]
fn deterministic_lineloss_missing_period_uses_default_month_execution_plan() {
let expected_month = expected_default_month();
let decision = decide_deterministic_submit("兰州公司 月累计。。。", None, None);
match decision {
DeterministicSubmitDecision::Execute(plan) => {
assert_eq!(plan.tool_name, "tq-lineloss-report.collect_lineloss");
assert_eq!(plan.expected_domain, "20.76.57.61");
assert_eq!(
plan.target_url,
"http://20.76.57.61:18080/gsllys/tqLinelossStatis/tqQualifyRateMonitor"
);
assert_eq!(plan.org_label, "国网兰州供电公司");
assert_eq!(plan.org_code, "62401");
assert_eq!(plan.period_mode, "month");
assert_eq!(plan.period_mode_code, "1");
assert_eq!(plan.period_value, expected_month);
assert_eq!(plan.period_value, "2026-03");
assert!(plan.period_payload.contains("fdate"));
assert_eq!(
plan.postprocess
.as_ref()
.map(|postprocess| postprocess.exporter.as_str()),
Some("xlsx_report")
);
}
other => panic!("expected missing month period to default into execution, got {other:?}"),
}
}
#[test]
fn deterministic_lineloss_missing_period_uses_default_week_execution_plan() {
let (expected_value, expected_start, expected_end) = expected_default_week_range();
let decision = decide_deterministic_submit("兰州公司 周累计。。。", None, None);
match decision {
DeterministicSubmitDecision::Execute(plan) => {
assert_eq!(plan.period_mode, "week");
assert_eq!(plan.period_mode_code, "2");
assert_eq!(plan.period_value, expected_value);
assert!(plan.period_payload.contains(&expected_start));
assert!(plan.period_payload.contains(&expected_end));
}
other => panic!("expected missing week period to default into execution, got {other:?}"),
}
}
#[test]
fn deterministic_lineloss_partial_artifact_summary_contract_is_locked() {
let artifact = serde_json::json!({
"type": "report-artifact",
"report_name": "tq-lineloss-report",
"status": "partial",
"org": {
"label": "国网兰州供电公司",
"code": "62401"
},
"period": {
"mode": "month",
"mode_code": "1",
"value": "2026-03",
"payload": {
"fdate": "2026-03"
}
},
"columns": ["ORG_NAME", "LINE_LOSS_RATE"],
"rows": [
{ "ORG_NAME": "国网兰州供电公司", "LINE_LOSS_RATE": "3.21" }
],
"counts": {
"rows": 1
},
"export": {
"attempted": true,
"status": "failed",
"message": "report_log_failed"
},
"reasons": ["report_log_failed"]
});
assert_eq!(artifact["type"], "report-artifact");
assert_eq!(artifact["report_name"], "tq-lineloss-report");
assert_eq!(artifact["status"], "partial");
assert_eq!(artifact["org"]["label"], "国网兰州供电公司");
assert_eq!(artifact["period"]["value"], "2026-03");
assert_eq!(artifact["counts"]["rows"], 1);
assert_eq!(artifact["reasons"][0], "report_log_failed");
}
#[test]
fn deterministic_lineloss_blocked_and_error_artifact_statuses_are_failure_contracts() {
for status in ["blocked", "error"] {
let artifact = serde_json::json!({
"type": "report-artifact",
"report_name": "tq-lineloss-report",
"status": status,
"org": {
"label": "国网兰州供电公司",
"code": "62401"
},
"period": {
"mode": "week",
"mode_code": "2",
"value": "2026-W12",
"payload": {
"tjzq": "week",
"level": "00",
"weekSfdate": "2026-03-16",
"weekEfdate": "2026-03-22"
}
},
"columns": [],
"rows": [],
"counts": {
"rows": 0
},
"export": {
"attempted": false,
"status": "skipped",
"message": null
},
"reasons": ["selected_range_unavailable"]
});
assert_eq!(artifact["status"], status);
assert_eq!(artifact["type"], "report-artifact");
assert_eq!(artifact["period"]["mode"], "week");
assert_eq!(artifact["reasons"][0], "selected_range_unavailable");
other => panic!("expected committed sample package execute plan, got {other:?}"),
}
assert_prompt_contains(
decide_deterministic_submit_with_skills_dir(
"兰州公司 台区线损大数据 月累计线损率统计分析。。。",
None,
None,
&skills_dir,
),
"缺少统计周期",
);
}

View File

@@ -0,0 +1,18 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>台区线损月周累计线损率统计分析</title>
<meta name="sgclaw-scene-kind" content="report_collection">
<meta name="sgclaw-tool-kind" content="browser_script">
<meta name="sgclaw-target-url" content="http://20.76.57.61:18080/gsllys/tqLinelossStatis/tqQualifyRateMonitor">
<meta name="sgclaw-expected-domain" content="20.76.57.61">
<meta name="sgclaw-entry-script" content="js/collect.js">
</head>
<body>
<main data-report-root="tq-lineloss">
<h1>台区线损月周累计线损率统计分析</h1>
</main>
<script src="js/collect.js"></script>
</body>
</html>

View File

@@ -0,0 +1,14 @@
async function collectRows(args) {
return [{
org_label: args.org_label,
org_code: args.org_code,
period_mode: args.period_mode,
period_value: args.period_value,
line_loss_rate: '',
qualify_rate: ''
}];
}
if (typeof module !== 'undefined') {
module.exports = { collectRows };
}

View File

@@ -0,0 +1,182 @@
use std::fs;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct G1ECandidateBatch {
#[serde(rename = "batchId")]
batch_id: String,
family: String,
source: String,
#[serde(rename = "supportingSources")]
supporting_sources: Vec<String>,
#[serde(rename = "ledgerClusterLabel")]
ledger_cluster_label: String,
#[serde(rename = "selectionRule")]
selection_rule: String,
#[serde(rename = "candidateCount")]
candidate_count: u32,
#[serde(rename = "representativeBaseline")]
representative_baseline: String,
#[serde(rename = "promotedBatchExpansionBaselines")]
promoted_batch_expansion_baselines: Vec<PromotedExpansionBaseline>,
#[serde(rename = "expectedSharedContract")]
expected_shared_contract: SharedContract,
candidates: Vec<BatchCandidate>,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct PromotedExpansionBaseline {
#[serde(rename = "fixtureDir")]
fixture_dir: String,
#[serde(rename = "sceneId")]
scene_id: String,
#[serde(rename = "sceneName")]
scene_name: String,
assertions: ExpansionAssertions,
}
#[derive(Debug, Deserialize)]
struct ExpansionAssertions {
#[serde(rename = "requiredMainRequest")]
required_main_request: String,
#[serde(rename = "requiredEnrichmentRequest")]
required_enrichment_request: String,
#[serde(rename = "requiredMergeJoinKey")]
required_merge_join_key: String,
#[serde(rename = "requiredMergeAggregateRule")]
required_merge_aggregate_rule: String,
#[serde(rename = "requiredOutputColumn")]
required_output_column: String,
}
#[derive(Debug, Deserialize)]
struct SharedContract {
archetype: String,
#[serde(rename = "requiredGateNames")]
required_gate_names: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct BatchCandidate {
#[serde(rename = "sceneKey")]
scene_key: String,
#[serde(rename = "batchRole")]
batch_role: String,
status: String,
}
#[test]
fn g1e_candidate_batch_asset_is_actionable() {
let batch = load_batch();
assert_eq!(batch.batch_id, "g1e-light-enrichment-candidates-2026-04-18");
assert_eq!(batch.family, "G1-E");
assert!(batch.source.ends_with(".md"));
assert_eq!(batch.ledger_cluster_label, "g1e-light-enrichment-candidate");
assert!(batch.selection_rule.contains("Track B"));
assert_eq!(batch.candidate_count, 3);
assert!(batch
.representative_baseline
.ends_with("g1e_light_enrichment"));
assert_eq!(batch.promoted_batch_expansion_baselines.len(), 2);
assert_eq!(
batch.expected_shared_contract.archetype,
"single_request_enrichment"
);
assert!(batch
.expected_shared_contract
.required_gate_names
.iter()
.any(|item| item == "merge_plan_resolved"));
assert_eq!(batch.candidates.len() as u32, batch.candidate_count);
assert_eq!(batch.supporting_sources.len(), 2);
assert!(!batch.notes.is_empty());
}
#[test]
fn g1e_candidate_batch_keeps_promoted_entries_visible() {
let batch = load_batch();
let promoted = batch
.promoted_batch_expansion_baselines
.iter()
.find(|item| item.fixture_dir.ends_with("g1e_light_enrichment_expansion"))
.expect("expected promoted G1-E expansion baseline");
assert_eq!(
promoted.scene_id,
"p1-g1e-light-enrichment-expansion-report"
);
assert_eq!(
promoted.scene_name,
"P1 G1-E light enrichment expansion report"
);
assert_eq!(promoted.assertions.required_main_request, "getWkorderAll");
assert_eq!(
promoted.assertions.required_enrichment_request,
"queryMeterInfo"
);
assert_eq!(promoted.assertions.required_merge_join_key, "wkOrderNo");
assert_eq!(
promoted.assertions.required_merge_aggregate_rule,
"group_by:countyCodeName"
);
assert_eq!(
promoted.assertions.required_output_column,
"meterCapacityThisMonth"
);
let additional = batch
.promoted_batch_expansion_baselines
.iter()
.find(|item| {
item.fixture_dir
.ends_with("g1e_light_enrichment_additional")
})
.expect("expected additional G1-E expansion baseline");
assert_eq!(
additional.scene_id,
"p1-g1e-light-enrichment-additional-report"
);
assert_eq!(
additional.scene_name,
"P1 G1-E light enrichment additional report"
);
assert_eq!(additional.assertions.required_main_request, "getWkorderAll");
assert_eq!(
additional.assertions.required_enrichment_request,
"queryBusAcpt"
);
assert_eq!(additional.assertions.required_merge_join_key, "wkOrderNo");
assert_eq!(
additional.assertions.required_merge_aggregate_rule,
"group_by:countyCodeName"
);
assert_eq!(
additional.assertions.required_output_column,
"batchCapacityThisMonth"
);
assert!(batch.candidates.iter().any(|item| item.scene_key
== "high_low_voltage_new_capacity_monthly"
&& item.batch_role == "p0-anchor"
&& item.status == "promoted-baseline"));
assert!(batch
.candidates
.iter()
.any(|item| item.scene_key == "light_enrichment_second_sample"
&& item.batch_role == "first-expansion-anchor"
&& item.status == "promoted-expansion"));
assert!(batch.candidates.iter().any(|item| item.scene_key
== "light_enrichment_additional_real_sample"
&& item.batch_role == "second-expansion-anchor"
&& item.status == "promoted-expansion"));
}
fn load_batch() -> G1ECandidateBatch {
serde_json::from_str(
&fs::read_to_string("tests/fixtures/generated_scene/g1e_candidate_batch_2026-04-18.json")
.unwrap(),
)
.unwrap()
}

View File

@@ -0,0 +1,175 @@
use std::fs;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct G2CandidateBatch {
#[serde(rename = "batchId")]
batch_id: String,
family: String,
source: String,
#[serde(rename = "supportingSources")]
supporting_sources: Vec<String>,
#[serde(rename = "ledgerClusterLabel")]
ledger_cluster_label: String,
#[serde(rename = "selectionRule")]
selection_rule: String,
#[serde(rename = "candidateCount")]
candidate_count: u32,
#[serde(rename = "representativeBaseline")]
representative_baseline: String,
#[serde(rename = "promotedBatchExpansionBaselines")]
promoted_batch_expansion_baselines: Vec<PromotedExpansionBaseline>,
#[serde(rename = "expectedSharedContract")]
expected_shared_contract: SharedContract,
candidates: Vec<BatchCandidate>,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct PromotedExpansionBaseline {
#[serde(rename = "fixtureDir")]
fixture_dir: String,
#[serde(rename = "sceneId")]
scene_id: String,
#[serde(rename = "sceneName")]
scene_name: String,
assertions: ExpansionAssertions,
}
#[derive(Debug, Deserialize)]
struct ExpansionAssertions {
#[serde(rename = "requiredDefaultMode")]
required_default_mode: String,
}
#[derive(Debug, Deserialize)]
struct SharedContract {
archetype: String,
#[serde(rename = "requiredGateNames")]
required_gate_names: Vec<String>,
#[serde(rename = "requiredVariantPatterns")]
required_variant_patterns: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct BatchCandidate {
#[serde(rename = "sceneKey")]
scene_key: String,
#[serde(rename = "batchRole")]
batch_role: String,
status: String,
}
#[test]
fn g2_candidate_batch_asset_is_actionable() {
let batch = load_batch();
assert_eq!(batch.batch_id, "g2-lineloss-family-candidates-2026-04-18");
assert_eq!(batch.family, "G2");
assert!(batch.source.ends_with(".md"));
assert_eq!(batch.ledger_cluster_label, "lineloss-family-candidate");
assert!(batch.selection_rule.contains("Track A"));
assert_eq!(batch.candidate_count, 6);
assert!(batch.representative_baseline.ends_with("multi_mode"));
assert_eq!(batch.promoted_batch_expansion_baselines.len(), 5);
assert_eq!(
batch.expected_shared_contract.archetype,
"multi_mode_request"
);
assert!(batch
.expected_shared_contract
.required_gate_names
.iter()
.any(|item| item == "request_contract_complete"));
assert!(batch
.expected_shared_contract
.required_variant_patterns
.iter()
.any(|item| item == "g2_f_diagnosis_drilldown"));
assert!(batch
.expected_shared_contract
.required_variant_patterns
.iter()
.any(|item| item == "g2_d_prediction_compute"));
assert_eq!(batch.candidates.len() as u32, batch.candidate_count);
assert_eq!(batch.supporting_sources.len(), 2);
assert!(!batch.notes.is_empty());
}
#[test]
fn g2_candidate_batch_promotes_four_expansion_variants() {
let batch = load_batch();
assert!(batch
.promoted_batch_expansion_baselines
.iter()
.any(|item| item.fixture_dir.ends_with("g2_weekly_single_mode")
&& item.scene_id == "p1-g2-weekly-single-mode-report"
&& item.scene_name == "P1 G2 weekly single mode report"
&& item.assertions.required_default_mode == "week"));
assert!(batch
.promoted_batch_expansion_baselines
.iter()
.any(
|item| item.fixture_dir.ends_with("g2_mixed_linked_workflow")
&& item.scene_id == "p1-g2-mixed-linked-workflow-report"
&& item.scene_name == "P1 G2 mixed linked workflow report"
&& item.assertions.required_default_mode == "primary"
));
assert!(batch
.promoted_batch_expansion_baselines
.iter()
.any(
|item| item.fixture_dir.ends_with("g2_comparison_crosscheck")
&& item.scene_id == "p1-g2-comparison-crosscheck-report"
&& item.scene_name == "P1 G2 comparison crosscheck report"
&& item.assertions.required_default_mode == "comparison"
));
assert!(batch
.promoted_batch_expansion_baselines
.iter()
.any(|item| item.fixture_dir.ends_with("g2_diagnosis_drilldown")
&& item.scene_id == "p1-g2-diagnosis-drilldown-report"
&& item.scene_name == "P1 G2 diagnosis drilldown report"
&& item.assertions.required_default_mode == "diagnosis"));
assert!(batch
.promoted_batch_expansion_baselines
.iter()
.any(|item| item.fixture_dir.ends_with("g2_prediction_compute")
&& item.scene_id == "p1-g2-prediction-compute-report"
&& item.scene_name == "P1 G2 prediction compute report"
&& item.assertions.required_default_mode == "prediction"));
}
#[test]
fn g2_candidate_batch_keeps_promoted_candidates_visible() {
let batch = load_batch();
assert!(batch
.candidates
.iter()
.any(|item| item.scene_key == "tq_lineloss_report"
&& item.batch_role == "p0-anchor"
&& item.status == "promoted-baseline"));
assert!(batch
.candidates
.iter()
.any(|item| item.scene_key == "baiyin_lineloss_weekly"
&& item.batch_role == "first-expansion-anchor"
&& item.status == "promoted-expansion"));
assert!(batch
.candidates
.iter()
.any(|item| item.scene_key == "predicted_compute_variant"
&& item.batch_role == "fifth-expansion-anchor"
&& item.status == "promoted-expansion"));
}
fn load_batch() -> G2CandidateBatch {
serde_json::from_str(
&fs::read_to_string("tests/fixtures/generated_scene/g2_candidate_batch_2026-04-18.json")
.unwrap(),
)
.unwrap()
}

View File

@@ -0,0 +1,294 @@
use std::fs;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct G3CandidateBatch {
#[serde(rename = "batchId")]
batch_id: String,
family: String,
source: String,
#[serde(rename = "ledgerClusterLabel")]
ledger_cluster_label: String,
#[serde(rename = "selectionRule")]
selection_rule: String,
#[serde(rename = "candidateCount")]
candidate_count: u32,
#[serde(rename = "representativeBaseline")]
representative_baseline: String,
#[serde(rename = "firstExpansionBaseline")]
first_expansion_baseline: String,
#[serde(rename = "promotedBatchExpansionBaselines")]
promoted_batch_expansion_baselines: Vec<PromotedExpansionBaseline>,
#[serde(rename = "expectedSharedContract")]
expected_shared_contract: SharedContract,
candidates: Vec<BatchCandidate>,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct SharedContract {
archetype: String,
#[serde(rename = "requiredPaginationFields")]
required_pagination_fields: Vec<String>,
#[serde(rename = "requiredJoinKeyPatterns")]
required_join_key_patterns: Vec<String>,
#[serde(rename = "requiredAggregateRulePatterns")]
required_aggregate_rule_patterns: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct PromotedExpansionBaseline {
#[serde(rename = "fixtureDir")]
fixture_dir: String,
#[serde(rename = "sceneId")]
scene_id: String,
#[serde(rename = "sceneName")]
scene_name: String,
assertions: ExpansionAssertions,
}
#[derive(Debug, Deserialize)]
struct ExpansionAssertions {
#[serde(rename = "expectedPaginationField")]
expected_pagination_field: String,
#[serde(rename = "requiredJoinKey")]
required_join_key: String,
#[serde(rename = "requiredAggregateRule")]
required_aggregate_rule: String,
}
#[derive(Debug, Deserialize)]
struct BatchCandidate {
#[serde(rename = "sceneKey")]
scene_key: String,
#[serde(rename = "ledgerGroupingResult")]
ledger_grouping_result: String,
#[serde(rename = "ledgerFamilyJudgement")]
ledger_family_judgement: String,
#[serde(rename = "batchRole")]
batch_role: String,
}
#[test]
fn g3_candidate_batch_asset_is_actionable() {
let batch = load_batch();
assert_eq!(
batch.batch_id,
"g3-95598-ticket-family-candidates-2026-04-18"
);
assert_eq!(batch.family, "G3");
assert!(batch.source.ends_with(".json"));
assert_eq!(batch.ledger_cluster_label, "95598-ticket-family-candidate");
assert!(batch
.selection_rule
.contains("95598-ticket-family-candidate"));
assert_eq!(batch.candidate_count, 11);
assert_eq!(
batch.expected_shared_contract.archetype,
"paginated_enrichment"
);
assert!(batch
.expected_shared_contract
.required_pagination_fields
.iter()
.any(|item| item == "pageNum"));
assert!(batch
.expected_shared_contract
.required_pagination_fields
.iter()
.any(|item| item == "pageNo"));
assert!(batch
.expected_shared_contract
.required_join_key_patterns
.iter()
.any(|item| item == "ticketNo"));
assert!(batch
.expected_shared_contract
.required_aggregate_rule_patterns
.iter()
.any(|item| item == "aggregate:riskLevel"));
assert_eq!(batch.candidates.len() as u32, batch.candidate_count);
assert_eq!(batch.promoted_batch_expansion_baselines.len(), 10);
assert!(!batch.notes.is_empty());
}
#[test]
fn g3_candidate_batch_promotes_second_round_expansion_baselines() {
let batch = load_batch();
assert!(batch
.promoted_batch_expansion_baselines
.iter()
.any(|item| item
.fixture_dir
.ends_with("paginated_enrichment_expansion_workorder")
&& item.scene_id == "p1-g3-paginated-expansion-workorder-report"
&& item.scene_name == "P1 G3 paginated expansion workorder report"
&& item.assertions.expected_pagination_field == "pageNo"
&& item.assertions.required_join_key == "workOrderNo"
&& item.assertions.required_aggregate_rule == "aggregate:sourceType"));
assert!(batch
.promoted_batch_expansion_baselines
.iter()
.any(|item| item
.fixture_dir
.ends_with("paginated_enrichment_expansion_orderno")
&& item.scene_id == "p1-g3-paginated-expansion-orderno-report"
&& item.scene_name == "P1 G3 paginated expansion orderno report"
&& item.assertions.expected_pagination_field == "page"
&& item.assertions.required_join_key == "orderNo"
&& item.assertions.required_aggregate_rule == "aggregate:sourceType"));
assert!(batch
.promoted_batch_expansion_baselines
.iter()
.any(|item| item
.fixture_dir
.ends_with("paginated_enrichment_expansion_source_distribution")
&& item.scene_id == "p1-g3-paginated-expansion-source-distribution-report"
&& item.scene_name == "P1 G3 paginated expansion source distribution report"
&& item.assertions.expected_pagination_field == "pageNum"
&& item.assertions.required_join_key == "ticketNo"
&& item.assertions.required_aggregate_rule == "aggregate:sourceType"));
assert!(batch
.promoted_batch_expansion_baselines
.iter()
.any(|item| item
.fixture_dir
.ends_with("paginated_enrichment_expansion_service_risk")
&& item.scene_id == "p1-g3-paginated-expansion-service-risk-report"
&& item.scene_name == "P1 G3 paginated expansion service risk report"
&& item.assertions.expected_pagination_field == "pageNo"
&& item.assertions.required_join_key == "ticketNo"
&& item.assertions.required_aggregate_rule == "aggregate:riskLevel"));
assert!(batch
.promoted_batch_expansion_baselines
.iter()
.any(|item| item
.fixture_dir
.ends_with("paginated_enrichment_expansion_timeout_warning")
&& item.scene_id == "p1-g3-paginated-expansion-timeout-warning-report"
&& item.scene_name == "P1 G3 paginated expansion timeout warning report"
&& item.assertions.expected_pagination_field == "pageNum"
&& item.assertions.required_join_key == "ticketNo"
&& item.assertions.required_aggregate_rule == "aggregate:riskLevel"));
assert!(batch
.promoted_batch_expansion_baselines
.iter()
.any(|item| item
.fixture_dir
.ends_with("paginated_enrichment_expansion_device_monitor_weekly")
&& item.scene_id == "p1-g3-paginated-expansion-device-monitor-weekly-report"
&& item.assertions.expected_pagination_field == "pageNo"
&& item.assertions.required_join_key == "ticketNo"
&& item.assertions.required_aggregate_rule == "aggregate:sourceType"));
assert!(batch
.promoted_batch_expansion_baselines
.iter()
.any(|item| item
.fixture_dir
.ends_with("paginated_enrichment_expansion_customer_satisfaction")
&& item.scene_id == "p1-g3-paginated-expansion-customer-satisfaction-report"
&& item.assertions.expected_pagination_field == "page"
&& item.assertions.required_join_key == "ticketNo"
&& item.assertions.required_aggregate_rule == "aggregate:sourceType"));
assert!(batch
.promoted_batch_expansion_baselines
.iter()
.any(|item| item
.fixture_dir
.ends_with("paginated_enrichment_expansion_repair_return")
&& item.scene_id == "p1-g3-paginated-expansion-repair-return-report"
&& item.assertions.expected_pagination_field == "pageNum"
&& item.assertions.required_join_key == "ticketNo"
&& item.assertions.required_aggregate_rule == "aggregate:riskLevel"));
assert!(batch
.promoted_batch_expansion_baselines
.iter()
.any(|item| item
.fixture_dir
.ends_with("paginated_enrichment_expansion_repair_daily_control")
&& item.scene_id == "p1-g3-paginated-expansion-repair-daily-control-report"
&& item.assertions.expected_pagination_field == "pageNo"
&& item.assertions.required_join_key == "ticketNo"
&& item.assertions.required_aggregate_rule == "aggregate:riskLevel"));
assert!(batch
.promoted_batch_expansion_baselines
.iter()
.any(|item| item
.fixture_dir
.ends_with("paginated_enrichment_expansion_business_stats")
&& item.scene_id == "p1-g3-paginated-expansion-business-stats-report"
&& item.assertions.expected_pagination_field == "page"
&& item.assertions.required_join_key == "ticketNo"
&& item.assertions.required_aggregate_rule == "aggregate:sourceType"));
}
#[test]
fn g3_candidate_batch_keeps_anchor_and_promoted_candidates_visible() {
let batch = load_batch();
assert!(batch
.candidates
.iter()
.any(|item| item.scene_key == "95598_ticket_detail" && item.batch_role == "p0-anchor"));
assert!(batch.candidates.iter().any(|item| {
item.scene_key == "95598_ticket_12398_process_timeout_detail"
&& item.batch_role == "first-expansion-anchor"
}));
assert!(batch.candidates.iter().any(|item| {
item.scene_key == "ticket_source_distribution_analysis"
&& item.batch_role == "fourth-expansion-anchor"
}));
assert!(batch.candidates.iter().any(|item| {
item.scene_key == "process_timeout_risk_ticket_detail"
&& item.batch_role == "fifth-expansion-anchor"
}));
assert!(batch.candidates.iter().any(|item| {
item.scene_key == "ticket_timeout_warning_detail"
&& item.batch_role == "sixth-expansion-anchor"
}));
assert!(batch.candidates.iter().any(|item| {
item.scene_key == "95598_ticket_12398_device_monitor_weekly"
&& item.batch_role == "seventh-expansion-anchor"
}));
assert!(batch.candidates.iter().any(|item| {
item.scene_key == "95598_ticket_customer_satisfaction_daily"
&& item.batch_role == "eighth-expansion-anchor"
}));
assert!(batch.candidates.iter().any(|item| {
item.scene_key == "95598_ticket_repair_return_analysis"
&& item.batch_role == "ninth-expansion-anchor"
}));
assert!(batch.candidates.iter().any(|item| {
item.scene_key == "95598_ticket_repair_daily_control"
&& item.batch_role == "tenth-expansion-anchor"
}));
assert!(batch.candidates.iter().any(|item| {
item.scene_key == "power_supply_service_ticket_business_stats"
&& item.batch_role == "eleventh-expansion-anchor"
}));
assert!(batch.candidates.iter().any(|item| {
item.scene_key == "service_risk_ticket_detail"
&& item.batch_role == "third-expansion-anchor"
}));
assert!(batch.candidates.iter().all(|item| {
item.ledger_grouping_result == "95598-ticket-family-candidate"
&& item.ledger_family_judgement == "pending-regroup"
}));
assert!(batch
.representative_baseline
.ends_with("paginated_enrichment"));
assert!(batch
.first_expansion_baseline
.ends_with("paginated_enrichment_expansion"));
}
fn load_batch() -> G3CandidateBatch {
serde_json::from_str(
&fs::read_to_string("tests/fixtures/generated_scene/g3_candidate_batch_2026-04-18.json")
.unwrap(),
)
.unwrap()
}

View File

@@ -0,0 +1,111 @@
use std::fs;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct G6HostBridgeCallbackSemantics {
#[serde(rename = "decisionDate")]
decision_date: String,
scope: String,
#[serde(rename = "startingState")]
starting_state: StartingState,
#[serde(rename = "completionStates")]
completion_states: Vec<CompletionState>,
#[serde(rename = "semanticRules")]
semantic_rules: Vec<SemanticRule>,
#[serde(rename = "selectedFollowup")]
selected_followup: SelectedFollowup,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct StartingState {
#[serde(rename = "targetGroup")]
target_group: String,
#[serde(rename = "realExecutionOutOfScope")]
real_execution_out_of_scope: bool,
#[serde(rename = "implementationOutOfScope")]
implementation_out_of_scope: bool,
#[serde(rename = "heldGroups")]
held_groups: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct CompletionState {
state: String,
definition: String,
}
#[derive(Debug, Deserialize)]
struct SemanticRule {
rule: String,
summary: String,
}
#[derive(Debug, Deserialize)]
struct SelectedFollowup {
design: String,
plan: String,
}
#[test]
fn g6_host_bridge_callback_semantics_stay_bounded() {
let asset: G6HostBridgeCallbackSemantics = serde_json::from_str(
&fs::read_to_string(
"tests/fixtures/generated_scene/g6_host_bridge_callback_semantics_2026-04-19.json",
)
.unwrap(),
)
.unwrap();
assert_eq!(asset.decision_date, "2026-04-19");
assert_eq!(asset.scope, "g6-host-bridge-callback-semantics");
assert_eq!(asset.starting_state.target_group, "G6");
assert!(asset.starting_state.real_execution_out_of_scope);
assert!(asset.starting_state.implementation_out_of_scope);
assert_eq!(asset.starting_state.held_groups, vec!["G8"]);
assert!(asset
.completion_states
.iter()
.any(|item| item.state == "ok"));
assert!(asset
.completion_states
.iter()
.any(|item| item.state == "partial"));
assert!(asset
.completion_states
.iter()
.any(|item| item.state == "blocked"));
assert!(asset
.completion_states
.iter()
.any(|item| item.state == "error"));
assert!(asset
.semantic_rules
.iter()
.any(|item| item.rule == "blocked_has_priority"));
assert!(asset
.semantic_rules
.iter()
.any(|item| item.rule == "fatal_error_maps_to_error"));
assert!(asset
.semantic_rules
.iter()
.any(|item| item.rule == "non_ok_callback_maps_to_partial"));
assert!(asset
.semantic_rules
.iter()
.any(|item| item.rule == "all_ok_maps_to_ok"));
assert!(asset
.selected_followup
.design
.ends_with("2026-04-19-g6-host-bridge-callback-state-verification-design.md"));
assert!(asset
.selected_followup
.plan
.ends_with("2026-04-19-g6-host-bridge-callback-state-verification-plan.md"));
assert!(!asset.notes.is_empty());
}

View File

@@ -0,0 +1,94 @@
use std::fs;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct G6HostBridgeCallbackStateVerification {
#[serde(rename = "decisionDate")]
decision_date: String,
scope: String,
#[serde(rename = "startingState")]
starting_state: StartingState,
#[serde(rename = "verificationTargets")]
verification_targets: Vec<VerificationTarget>,
#[serde(rename = "verificationPriority")]
verification_priority: Vec<String>,
#[serde(rename = "selectedFollowup")]
selected_followup: SelectedFollowup,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct StartingState {
#[serde(rename = "targetGroup")]
target_group: String,
#[serde(rename = "realExecutionOutOfScope")]
real_execution_out_of_scope: bool,
#[serde(rename = "implementationOutOfScope")]
implementation_out_of_scope: bool,
#[serde(rename = "heldGroups")]
held_groups: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct VerificationTarget {
state: String,
#[serde(rename = "evidenceRequirement")]
evidence_requirement: String,
}
#[derive(Debug, Deserialize)]
struct SelectedFollowup {
design: String,
plan: String,
}
#[test]
fn g6_host_bridge_callback_state_verification_stays_bounded() {
let asset: G6HostBridgeCallbackStateVerification = serde_json::from_str(
&fs::read_to_string(
"tests/fixtures/generated_scene/g6_host_bridge_callback_state_verification_2026-04-19.json",
)
.unwrap(),
)
.unwrap();
assert_eq!(asset.decision_date, "2026-04-19");
assert_eq!(asset.scope, "g6-host-bridge-callback-state-verification");
assert_eq!(asset.starting_state.target_group, "G6");
assert!(asset.starting_state.real_execution_out_of_scope);
assert!(asset.starting_state.implementation_out_of_scope);
assert_eq!(asset.starting_state.held_groups, vec!["G8"]);
assert!(asset
.verification_targets
.iter()
.any(|item| item.state == "ok"));
assert!(asset
.verification_targets
.iter()
.any(|item| item.state == "partial"));
assert!(asset
.verification_targets
.iter()
.any(|item| item.state == "blocked"));
assert!(asset
.verification_targets
.iter()
.any(|item| item.state == "error"));
assert_eq!(
asset.verification_priority,
vec!["blocked", "error", "partial", "ok"]
);
assert!(asset
.selected_followup
.design
.ends_with("2026-04-19-g6-host-bridge-entry-readiness-design.md"));
assert!(asset
.selected_followup
.plan
.ends_with("2026-04-19-g6-host-bridge-entry-readiness-plan.md"));
assert!(!asset.notes.is_empty());
}

View File

@@ -0,0 +1,117 @@
use std::fs;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct G6HostBridgeEntryGate {
#[serde(rename = "decisionDate")]
decision_date: String,
scope: String,
#[serde(rename = "startingState")]
starting_state: StartingState,
#[serde(rename = "hardGateConditions")]
hard_gate_conditions: Vec<HardGateCondition>,
#[serde(rename = "softConditions")]
soft_conditions: Vec<SoftCondition>,
#[serde(rename = "failCloseReasons")]
fail_close_reasons: Vec<String>,
#[serde(rename = "selectedFollowup")]
selected_followup: SelectedFollowup,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct StartingState {
#[serde(rename = "targetGroup")]
target_group: String,
#[serde(rename = "realExecutionOutOfScope")]
real_execution_out_of_scope: bool,
#[serde(rename = "implementationOutOfScope")]
implementation_out_of_scope: bool,
#[serde(rename = "heldGroups")]
held_groups: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct HardGateCondition {
name: String,
status: String,
#[serde(rename = "failureReason")]
failure_reason: String,
}
#[derive(Debug, Deserialize)]
struct SoftCondition {
name: String,
status: String,
}
#[derive(Debug, Deserialize)]
struct SelectedFollowup {
design: String,
plan: String,
}
#[test]
fn g6_host_bridge_entry_gate_stays_bounded() {
let asset: G6HostBridgeEntryGate = serde_json::from_str(
&fs::read_to_string(
"tests/fixtures/generated_scene/g6_host_bridge_entry_gate_2026-04-19.json",
)
.unwrap(),
)
.unwrap();
assert_eq!(asset.decision_date, "2026-04-19");
assert_eq!(asset.scope, "g6-host-bridge-entry-gate");
assert_eq!(asset.starting_state.target_group, "G6");
assert!(asset.starting_state.real_execution_out_of_scope);
assert!(asset.starting_state.implementation_out_of_scope);
assert_eq!(asset.starting_state.held_groups, vec!["G8"]);
assert!(asset
.hard_gate_conditions
.iter()
.any(|item| item.name == "host-bridge-action-invocation-defined"
&& item.status == "required"
&& item.failure_reason == "g6_bridge_invocation_semantics_missing"));
assert!(asset
.hard_gate_conditions
.iter()
.any(|item| item.name == "callback-request-completion-defined"
&& item.status == "required"
&& item.failure_reason == "g6_callback_completion_semantics_missing"));
assert!(asset.hard_gate_conditions.iter().any(|item| item.name
== "callback-state-verification-targets-defined"
&& item.status == "required"
&& item.failure_reason == "g6_callback_state_targets_missing"));
assert!(asset
.soft_conditions
.iter()
.any(|item| item.name == "host-runtime-transport-implementation"
&& item.status == "optional-later"));
assert!(asset
.soft_conditions
.iter()
.any(|item| item.name == "real-sample-execution-proof" && item.status == "optional-later"));
assert_eq!(
asset.fail_close_reasons,
vec![
"g6_bridge_invocation_semantics_missing",
"g6_callback_completion_semantics_missing",
"g6_callback_state_targets_missing"
]
);
assert!(asset
.selected_followup
.design
.ends_with("2026-04-19-g6-host-bridge-entry-gate-verification-design.md"));
assert!(asset
.selected_followup
.plan
.ends_with("2026-04-19-g6-host-bridge-entry-gate-verification-plan.md"));
assert!(!asset.notes.is_empty());
}

View File

@@ -0,0 +1,103 @@
use std::fs;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct G6HostBridgeEntryReadiness {
#[serde(rename = "decisionDate")]
decision_date: String,
scope: String,
#[serde(rename = "startingState")]
starting_state: StartingState,
#[serde(rename = "requiredCriteria")]
required_criteria: Vec<Criterion>,
#[serde(rename = "optionalCriteria")]
optional_criteria: Vec<Criterion>,
#[serde(rename = "minimalReadinessThreshold")]
minimal_readiness_threshold: ReadinessThreshold,
#[serde(rename = "selectedFollowup")]
selected_followup: SelectedFollowup,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct StartingState {
#[serde(rename = "targetGroup")]
target_group: String,
#[serde(rename = "realExecutionOutOfScope")]
real_execution_out_of_scope: bool,
#[serde(rename = "implementationOutOfScope")]
implementation_out_of_scope: bool,
#[serde(rename = "heldGroups")]
held_groups: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct Criterion {
name: String,
status: String,
reason: String,
}
#[derive(Debug, Deserialize)]
struct ReadinessThreshold {
level: String,
definition: String,
}
#[derive(Debug, Deserialize)]
struct SelectedFollowup {
design: String,
plan: String,
}
#[test]
fn g6_host_bridge_entry_readiness_stays_bounded() {
let asset: G6HostBridgeEntryReadiness = serde_json::from_str(
&fs::read_to_string(
"tests/fixtures/generated_scene/g6_host_bridge_entry_readiness_2026-04-19.json",
)
.unwrap(),
)
.unwrap();
assert_eq!(asset.decision_date, "2026-04-19");
assert_eq!(asset.scope, "g6-host-bridge-entry-readiness");
assert_eq!(asset.starting_state.target_group, "G6");
assert!(asset.starting_state.real_execution_out_of_scope);
assert!(asset.starting_state.implementation_out_of_scope);
assert_eq!(asset.starting_state.held_groups, vec!["G8"]);
assert!(asset
.required_criteria
.iter()
.any(|item| item.name == "host-bridge-action-invocation-defined"
&& item.status == "required"));
assert!(asset.required_criteria.iter().any(|item| item.name
== "callback-request-completion-defined"
&& item.status == "required"));
assert!(asset.required_criteria.iter().any(|item| item.name
== "callback-state-verification-targets-defined"
&& item.status == "required"));
assert!(asset
.optional_criteria
.iter()
.any(|item| item.name == "host-runtime-transport-implementation"
&& item.status == "optional-later"));
assert!(asset
.optional_criteria
.iter()
.any(|item| item.name == "real-sample-execution-proof" && item.status == "optional-later"));
assert_eq!(asset.minimal_readiness_threshold.level, "semantic-ready");
assert!(asset
.selected_followup
.design
.ends_with("2026-04-19-g6-host-bridge-entry-gate-design.md"));
assert!(asset
.selected_followup
.plan
.ends_with("2026-04-19-g6-host-bridge-entry-gate-plan.md"));
assert!(!asset.notes.is_empty());
}

View File

@@ -0,0 +1,107 @@
use std::fs;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct G6HostBridgeExecutionSemantics {
#[serde(rename = "decisionDate")]
decision_date: String,
scope: String,
#[serde(rename = "startingState")]
starting_state: StartingState,
#[serde(rename = "semanticModel")]
semantic_model: SemanticModel,
#[serde(rename = "semanticBoundaries")]
semantic_boundaries: Vec<SemanticBoundary>,
#[serde(rename = "selectedFollowup")]
selected_followup: SelectedFollowup,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct StartingState {
#[serde(rename = "targetGroup")]
target_group: String,
#[serde(rename = "realExecutionOutOfScope")]
real_execution_out_of_scope: bool,
#[serde(rename = "implementationOutOfScope")]
implementation_out_of_scope: bool,
#[serde(rename = "heldGroups")]
held_groups: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct SemanticModel {
#[serde(rename = "bridgeInvocation")]
bridge_invocation: SemanticSlice,
#[serde(rename = "callbackCompletion")]
callback_completion: SemanticSlice,
}
#[derive(Debug, Deserialize)]
struct SemanticSlice {
name: String,
summary: String,
}
#[derive(Debug, Deserialize)]
struct SemanticBoundary {
slice: String,
status: String,
reason: String,
}
#[derive(Debug, Deserialize)]
struct SelectedFollowup {
design: String,
plan: String,
}
#[test]
fn g6_host_bridge_execution_semantics_stay_bounded() {
let asset: G6HostBridgeExecutionSemantics = serde_json::from_str(
&fs::read_to_string(
"tests/fixtures/generated_scene/g6_host_bridge_execution_semantics_2026-04-19.json",
)
.unwrap(),
)
.unwrap();
assert_eq!(asset.decision_date, "2026-04-19");
assert_eq!(asset.scope, "g6-host-bridge-execution-semantics");
assert_eq!(asset.starting_state.target_group, "G6");
assert!(asset.starting_state.real_execution_out_of_scope);
assert!(asset.starting_state.implementation_out_of_scope);
assert_eq!(asset.starting_state.held_groups, vec!["G8"]);
assert_eq!(
asset.semantic_model.bridge_invocation.name,
"host-bridge-action-invocation"
);
assert_eq!(
asset.semantic_model.callback_completion.name,
"callback-request-completion"
);
assert!(asset
.semantic_boundaries
.iter()
.any(|item| item.slice == "bridge_action_invocation" && item.status == "selected"));
assert!(asset
.semantic_boundaries
.iter()
.any(|item| item.slice == "callback_completion_semantics" && item.status == "selected"));
assert!(asset.semantic_boundaries.iter().any(|item| item.slice
== "host_runtime_transport_rebuild"
&& item.status == "out-of-scope"));
assert!(asset
.selected_followup
.design
.ends_with("2026-04-19-g6-host-bridge-callback-semantics-design.md"));
assert!(asset
.selected_followup
.plan
.ends_with("2026-04-19-g6-host-bridge-callback-semantics-plan.md"));
assert!(!asset.notes.is_empty());
}

View File

@@ -0,0 +1,102 @@
use std::fs;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct G6HostBridgePrerequisites {
#[serde(rename = "decisionDate")]
decision_date: String,
scope: String,
#[serde(rename = "startingState")]
starting_state: StartingState,
#[serde(rename = "blockedCapability")]
blocked_capability: BlockedCapability,
#[serde(rename = "capabilityBreakdown")]
capability_breakdown: Vec<CapabilityBreakdown>,
#[serde(rename = "selectedFollowup")]
selected_followup: SelectedFollowup,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct StartingState {
#[serde(rename = "targetGroup")]
target_group: String,
#[serde(rename = "executionOutOfScope")]
execution_out_of_scope: bool,
#[serde(rename = "reopenedGroups")]
reopened_groups: Vec<String>,
#[serde(rename = "heldGroups")]
held_groups: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct BlockedCapability {
name: String,
summary: String,
#[serde(rename = "boundedInsteadOfBroadRuntime")]
bounded_instead_of_broad_runtime: bool,
}
#[derive(Debug, Deserialize)]
struct CapabilityBreakdown {
slice: String,
status: String,
reason: String,
}
#[derive(Debug, Deserialize)]
struct SelectedFollowup {
design: String,
plan: String,
}
#[test]
fn g6_host_bridge_prerequisites_stay_bounded() {
let asset: G6HostBridgePrerequisites = serde_json::from_str(
&fs::read_to_string(
"tests/fixtures/generated_scene/g6_host_bridge_prerequisites_2026-04-19.json",
)
.unwrap(),
)
.unwrap();
assert_eq!(asset.decision_date, "2026-04-19");
assert_eq!(asset.scope, "g6-host-bridge-prerequisites");
assert_eq!(asset.starting_state.target_group, "G6");
assert!(asset.starting_state.execution_out_of_scope);
assert!(asset.starting_state.reopened_groups.is_empty());
assert_eq!(asset.starting_state.held_groups, vec!["G8"]);
assert_eq!(
asset.blocked_capability.name,
"host-bridge-real-execution-semantics"
);
assert!(asset.blocked_capability.bounded_instead_of_broad_runtime);
assert!(asset
.capability_breakdown
.iter()
.any(|item| item.slice == "bridge_action_invocation" && item.status == "needed"));
assert!(asset
.capability_breakdown
.iter()
.any(|item| item.slice == "callback_completion_semantics" && item.status == "needed"));
assert!(
asset
.capability_breakdown
.iter()
.any(|item| item.slice == "host_runtime_platform_rebuild"
&& item.status == "out-of-scope")
);
assert!(asset
.selected_followup
.design
.ends_with("2026-04-19-g6-host-bridge-execution-semantics-design.md"));
assert!(asset
.selected_followup
.plan
.ends_with("2026-04-19-g6-host-bridge-execution-semantics-plan.md"));
assert!(!asset.notes.is_empty());
}

View File

@@ -0,0 +1,339 @@
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const finalRoot = path.join(
repoRoot,
'examples',
'scene_skill_102_runtime_semantics_rematerialization_2026-04-21',
);
const skillRoot = path.join(finalRoot, 'skills');
const indexPath = path.join(finalRoot, 'scene_skill_102_index.json');
const outputPath = path.join(
repoRoot,
'tests',
'fixtures',
'generated_scene',
'scene_skill_102_runtime_semantics_full_direct_mock_execution_2026-04-21.json',
);
const reportPath = path.join(
repoRoot,
'docs',
'superpowers',
'reports',
'2026-04-21-generated-scene-runtime-semantics-full-direct-mock-execution-report.md',
);
function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
function writeJson(file, value) {
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
function findScript(sceneId) {
const scriptsDir = path.join(skillRoot, sceneId, 'scripts');
const scripts = fs
.readdirSync(scriptsDir)
.filter((name) => name.endsWith('.js') && !name.endsWith('.test.js'))
.sort();
if (scripts.length === 0) throw new Error(`no runtime script found for ${sceneId}`);
return path.join(scriptsDir, scripts[0]);
}
function createResponse(payload) {
return {
ok: true,
status: 200,
async json() {
return payload;
},
};
}
function parseBody(body) {
if (!body || typeof body !== 'string') return {};
try {
return JSON.parse(body);
} catch (_) {
return Object.fromEntries(
body
.split('&')
.filter(Boolean)
.map((part) => {
const [key, value = ''] = part.split('=');
return [decodeURIComponent(key), decodeURIComponent(value)];
}),
);
}
}
function makeRichRow() {
return {
id: 'row-1',
wkOrderNo: 'WK-1',
appNo: 'WK-1',
countyCodeName: 'COUNTY-1',
countyName: 'COUNTY-1',
orgNo: 'ORG-1',
orgName: 'ORG-NAME',
ORG_NAME: 'ORG-NAME',
YGDL: '100',
LINE_LOSS_RATE: '1.23',
PPQ: '10',
UPQ: '9',
LOSS_PQ: '1',
dyfjmZs: '1',
dyfjmHgZs: '1',
dyfjmHgl: '100%',
gyxzzrZs: '1',
gyxzzrHgZs: '1',
gyxzzrHgl: '100%',
dyjmZs: '1',
dyjmHgZs: '1',
dyjmHgl: '100%',
assetId: 'ASSET-1',
devId: 'DEV-1',
deviceName: 'DEVICE-1',
dataStatus: 'ok',
status: 5,
value: 'ok',
};
}
function installGlobals(archetype, expectedDomain, requestLog) {
delete global.$;
global.location = { hostname: expectedDomain || '' };
global.window = global;
global.document = {
title: 'mock page',
body: {},
querySelector() {
return null;
},
querySelectorAll() {
return [];
},
};
global.fetch = async (url, options = {}) => {
requestLog.push({
via: 'fetch',
url: String(url),
method: options.method || 'GET',
});
const body = parseBody(options.body);
const row = makeRichRow();
if (archetype === 'paginated_enrichment') {
if (body.page && Number(body.page) > 1) return createResponse({ data: [] });
if (String(url).includes('yx.gs.sgcc.com.cn')) {
return createResponse({ data: { enrichmentField: 'detail', ...row } });
}
return createResponse({ data: [row], rows: [row], content: [row] });
}
if (archetype === 'multi_mode_request') {
return createResponse({ content: [row], data: [row], rows: [row] });
}
if (archetype === 'single_request_enrichment') {
return createResponse({ data: [row], rows: [row], content: [row] });
}
if (archetype === 'multi_endpoint_inventory') {
return createResponse({ rows: [row], data: [row], content: [row] });
}
if (archetype === 'local_doc_pipeline') {
return createResponse({ ok: true, data: [row], rows: [row] });
}
if (archetype === 'page_state_eval') {
return createResponse({ data: [row], rows: [row] });
}
return createResponse({ data: [row], rows: [row], content: [row] });
};
}
function makeDeps(archetype, requestLog) {
const validatePageContext = () => ({ ok: true });
if (archetype === 'host_bridge_workflow') {
return {
validatePageContext,
async invokeHostBridge(action, args) {
requestLog.push({ via: 'host-bridge', action });
return { ok: true, action, callbackId: 'mock-callback' };
},
async queryCallbackEndpoint(endpoint, args) {
requestLog.push({ via: 'callback', endpoint: endpoint.name });
return { data: [makeRichRow()], rows: [makeRichRow()] };
},
};
}
if (archetype === 'page_state_eval') {
return {
validatePageContext,
async queryState(args) {
requestLog.push({ via: 'page-state' });
return { data: [makeRichRow()] };
},
};
}
return undefined;
}
async function runScene(row) {
const started = Date.now();
const { sceneId, sceneName, archetype } = row;
let scriptPath = '';
const requestLog = [];
try {
scriptPath = findScript(sceneId);
const report = readJson(
path.join(skillRoot, sceneId, 'references', 'generation-report.json'),
);
const expectedDomain = report.bootstrap?.expectedDomain || '';
installGlobals(archetype, expectedDomain, requestLog);
delete require.cache[require.resolve(scriptPath)];
const mod = require(scriptPath);
if (typeof mod.buildBrowserEntrypointResult !== 'function') {
return {
sceneId,
sceneName,
archetype,
directMockStatus: 'direct-mock-fail',
scriptLoadStatus: 'script-load-pass',
failureReason: 'missing_buildBrowserEntrypointResult_export',
durationMs: Date.now() - started,
requestCount: requestLog.length,
};
}
const args = {
expected_domain: expectedDomain,
target_url: report.bootstrap?.targetUrl || '',
org_label: 'MOCK_ORG',
org_code: 'MOCK_ORG_CODE',
period_mode: report.defaultMode || 'month',
period_mode_code: report.defaultMode || 'month',
period_value: '2026-04',
period_payload: {},
};
const deps = makeDeps(archetype, requestLog);
const artifact = deps
? await mod.buildBrowserEntrypointResult(args, deps)
: await mod.buildBrowserEntrypointResult(args);
const artifactStatus = artifact?.status || 'missing';
const passStatuses = new Set(['ok', 'empty']);
const partialStatuses = new Set(['partial']);
const directMockStatus = passStatuses.has(artifactStatus)
? 'direct-mock-pass'
: partialStatuses.has(artifactStatus)
? 'direct-mock-partial'
: 'direct-mock-fail';
return {
sceneId,
sceneName,
archetype,
directMockStatus,
scriptLoadStatus: 'script-load-pass',
mockDependencyStatus: 'mock-dependency-ready',
artifactStatus,
artifactType: artifact?.type || null,
rowCount: Array.isArray(artifact?.rows) ? artifact.rows.length : null,
counts: artifact?.counts || {},
failureReason:
directMockStatus === 'direct-mock-fail' ? `artifact_status_${artifactStatus}` : null,
durationMs: Date.now() - started,
requestCount: requestLog.length,
scriptPath: path.relative(repoRoot, scriptPath).replace(/\\/g, '/'),
};
} catch (error) {
return {
sceneId,
sceneName,
archetype,
directMockStatus: 'direct-mock-fail',
scriptLoadStatus: scriptPath ? 'script-load-pass' : 'script-load-fail',
failureReason: error.message,
durationMs: Date.now() - started,
requestCount: requestLog.length,
scriptPath: scriptPath ? path.relative(repoRoot, scriptPath).replace(/\\/g, '/') : null,
};
}
}
async function main() {
const index = readJson(indexPath);
const scenes = Array.isArray(index) ? index : index.scenes;
const results = [];
for (const row of scenes) {
results.push(await runScene(row));
}
const byStatus = {};
const byArchetype = {};
for (const result of results) {
byStatus[result.directMockStatus] = (byStatus[result.directMockStatus] || 0) + 1;
byArchetype[result.archetype] = byArchetype[result.archetype] || {};
byArchetype[result.archetype][result.directMockStatus] =
(byArchetype[result.archetype][result.directMockStatus] || 0) + 1;
}
const asset = {
runDate: '2026-04-21',
plan: '2026-04-21-generated-scene-runtime-semantics-validation-refresh-execution-plan.md',
execution: 'full-direct-mock-only-no-browser-no-network',
summary: {
totalScenes: results.length,
byStatus,
byArchetype,
productionNetworkUsed: false,
realBrowserUsed: false,
generatedSkillsModified: false,
},
results,
};
writeJson(outputPath, asset);
const lines = [];
lines.push('# Scene Skill 102 Full Direct Mock Execution Report');
lines.push('');
lines.push('> Date: 2026-04-21');
lines.push('> Plan: `2026-04-21-generated-scene-runtime-semantics-validation-refresh-execution-plan.md`');
lines.push('');
lines.push('## Scope');
lines.push('');
lines.push('This run executed all 102 generated scene skill scripts in a local mock runtime. It did not use a real browser, real network, production credentials, or business systems. It did not modify generated skill packages.');
lines.push('');
lines.push('## Summary');
lines.push('');
lines.push('| Status | Count |');
lines.push('| --- | ---: |');
for (const [status, count] of Object.entries(byStatus).sort()) {
lines.push(`| \`${status}\` | ${count} |`);
}
lines.push('');
lines.push('## By Archetype');
lines.push('');
lines.push('| Archetype | Result |');
lines.push('| --- | --- |');
for (const [archetype, statuses] of Object.entries(byArchetype).sort()) {
const summary = Object.entries(statuses)
.map(([status, count]) => `${status}: ${count}`)
.join(', ');
lines.push(`| \`${archetype}\` | ${summary} |`);
}
lines.push('');
lines.push('## Interpretation');
lines.push('');
lines.push('Direct mock execution passing means every generated skill entrypoint can load and complete against controlled fake dependencies. It still does not mean production execution passed.');
lines.push('');
lines.push('## Next Step');
lines.push('');
lines.push('If this full direct mock run is acceptable, the next bounded stage is pseudo-production batch selection. That stage should choose a small, archetype-balanced batch for real or quasi-real environment execution planning.');
lines.push('');
fs.writeFileSync(reportPath, `${lines.join('\n')}\n`, 'utf8');
console.log(JSON.stringify(asset.summary, null, 2));
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -0,0 +1,37 @@
use sgclaw::generated_scene::lessons::{
load_generation_lessons, GenerationLessons, BUILTIN_REPORT_COLLECTION_LESSONS,
};
#[test]
fn builtin_report_collection_lessons_match_required_generator_rules() {
let lessons = GenerationLessons::default_report_collection();
assert_eq!(
BUILTIN_REPORT_COLLECTION_LESSONS,
"builtin:report_collection_v1"
);
assert!(lessons.routing.require_exact_suffix);
assert!(lessons.routing.unsupported_scene_fail_closed);
assert!(lessons.canonical_params.require_explicit_period);
assert!(lessons.bootstrap.require_expected_domain);
assert!(lessons.bootstrap.require_target_url);
assert!(lessons.artifact.require_report_artifact);
assert!(lessons.validation.require_pipe_and_ws_checks);
assert!(lessons.validation.require_manual_service_console_smoke);
}
#[test]
fn lineloss_lessons_toml_declares_required_generator_rules() {
let lessons =
load_generation_lessons("docs/superpowers/references/tq-lineloss-lessons-learned.toml")
.unwrap();
assert!(lessons.routing.require_exact_suffix);
assert!(lessons.routing.unsupported_scene_fail_closed);
assert!(lessons.canonical_params.require_explicit_period);
assert!(lessons.bootstrap.require_expected_domain);
assert!(lessons.bootstrap.require_target_url);
assert!(lessons.artifact.require_report_artifact);
assert!(lessons.validation.require_pipe_and_ws_checks);
assert!(lessons.validation.require_manual_service_console_smoke);
}

View File

@@ -0,0 +1,358 @@
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const skillRoot = path.join(
repoRoot,
'examples',
'scene_skill_102_final_materialization_2026-04-19',
'skills',
);
const matrixPath = path.join(
repoRoot,
'tests',
'fixtures',
'generated_scene',
'scene_skill_102_mock_runtime_validation_matrix_2026-04-20.json',
);
const outputPath = path.join(
repoRoot,
'tests',
'fixtures',
'generated_scene',
'scene_skill_102_mock_runtime_harness_results_2026-04-20.json',
);
const reportPath = path.join(
repoRoot,
'docs',
'superpowers',
'reports',
'2026-04-20-scene-skill-102-mock-runtime-harness-report.md',
);
function readJson(file) {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
function writeJson(file, value) {
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
}
function findScript(sceneId) {
const scriptsDir = path.join(skillRoot, sceneId, 'scripts');
const scripts = fs
.readdirSync(scriptsDir)
.filter((name) => name.endsWith('.js') && !name.endsWith('.test.js'))
.sort();
if (scripts.length === 0) {
throw new Error(`no runtime script found for ${sceneId}`);
}
return path.join(scriptsDir, scripts[0]);
}
function createResponse(payload) {
return {
ok: true,
status: 200,
async json() {
return payload;
},
};
}
function parseBody(body) {
if (!body || typeof body !== 'string') return {};
try {
return JSON.parse(body);
} catch (_) {
return Object.fromEntries(
body
.split('&')
.filter(Boolean)
.map((part) => {
const [key, value = ''] = part.split('=');
return [decodeURIComponent(key), decodeURIComponent(value)];
}),
);
}
}
function makeRichRow() {
return {
id: 'row-1',
wkOrderNo: 'WK-1',
countyCodeName: 'COUNTY-1',
orgNo: 'ORG-1',
orgName: 'ORG-NAME',
ORG_NAME: 'ORG-NAME',
YGDL: '100',
LINE_LOSS_RATE: '1.23',
PPQ: '10',
UPQ: '9',
LOSS_PQ: '1',
countyName: 'COUNTY-1',
dyfjmZs: '1',
dyfjmHgZs: '1',
dyfjmHgl: '100%',
gyxzzrZs: '1',
gyxzzrHgZs: '1',
gyxzzrHgl: '100%',
dyjmZs: '1',
dyjmHgZs: '1',
dyjmHgl: '100%',
assetId: 'ASSET-1',
deviceName: 'DEVICE-1',
value: 'ok',
dataStatus: 'ok',
};
}
function installGlobals(archetype, expectedDomain, requestLog) {
delete global.$;
global.location = { hostname: expectedDomain || '' };
global.window = global;
global.document = {
title: 'mock page',
querySelector() {
return null;
},
querySelectorAll() {
return [];
},
};
global.fetch = async (url, options = {}) => {
requestLog.push({
via: 'fetch',
url: String(url),
method: options.method || 'GET',
body: options.body || '',
});
const body = parseBody(options.body);
const row = makeRichRow();
if (archetype === 'paginated_enrichment') {
if (body.page && Number(body.page) > 1) return createResponse({ data: [] });
if (String(url).includes('yx.gs.sgcc.com.cn')) {
return createResponse({ data: { enrichmentField: 'detail', ...row } });
}
return createResponse({ data: [row] });
}
if (archetype === 'multi_mode_request') {
return createResponse({ content: [row], data: [row], rows: [row] });
}
if (archetype === 'single_request_enrichment') {
return createResponse({ data: [row], rows: [row] });
}
if (archetype === 'multi_endpoint_inventory') {
return createResponse({ rows: [row], data: [row] });
}
if (archetype === 'local_doc_pipeline') {
return createResponse({ ok: true, data: [row], rows: [row] });
}
if (archetype === 'page_state_eval') {
return createResponse({ data: [row] });
}
return createResponse({ data: [row], rows: [row] });
};
}
function makeDeps(archetype, expectedDomain, requestLog) {
const validatePageContext = () => ({ ok: true });
if (archetype === 'host_bridge_workflow') {
return {
validatePageContext,
async invokeHostBridge(action, args) {
requestLog.push({ via: 'host-bridge', action, args });
return { ok: true, action, callbackId: 'mock-callback' };
},
async queryCallbackEndpoint(endpoint, args) {
requestLog.push({ via: 'callback', endpoint: endpoint.name, args });
return { data: [makeRichRow()] };
},
};
}
if (archetype === 'page_state_eval') {
return {
validatePageContext,
async queryState(args) {
requestLog.push({ via: 'page-state', args });
return { data: [makeRichRow()] };
},
};
}
return undefined;
}
async function runRepresentative(sceneId, archetype) {
const scriptPath = findScript(sceneId);
const report = readJson(path.join(skillRoot, sceneId, 'references', 'generation-report.json'));
const expectedDomain = report.bootstrap?.expectedDomain || '';
const requestLog = [];
const started = Date.now();
installGlobals(archetype, expectedDomain, requestLog);
delete require.cache[require.resolve(scriptPath)];
let mod;
try {
mod = require(scriptPath);
} catch (error) {
return {
sceneId,
archetype,
scriptPath: path.relative(repoRoot, scriptPath).replace(/\\/g, '/'),
scriptLoadStatus: 'script-load-fail',
mockRuntimeStatus: 'mock-runtime-fail',
failureReason: `script_load_failed:${error.message}`,
durationMs: Date.now() - started,
requestLog,
};
}
if (typeof mod.buildBrowserEntrypointResult !== 'function') {
return {
sceneId,
archetype,
scriptPath: path.relative(repoRoot, scriptPath).replace(/\\/g, '/'),
scriptLoadStatus: 'script-load-pass',
mockRuntimeStatus: 'mock-runtime-fail',
failureReason: 'missing_buildBrowserEntrypointResult_export',
durationMs: Date.now() - started,
requestLog,
};
}
const args = {
expected_domain: expectedDomain,
target_url: report.bootstrap?.targetUrl || '',
org_label: 'MOCK_ORG',
org_code: 'MOCK_ORG_CODE',
period_mode: report.defaultMode || 'month',
period_mode_code: report.defaultMode || 'month',
period_value: '2026-04',
period_payload: {},
};
try {
const deps = makeDeps(archetype, expectedDomain, requestLog);
const artifact = deps
? await mod.buildBrowserEntrypointResult(args, deps)
: await mod.buildBrowserEntrypointResult(args);
const artifactStatus = artifact?.status || 'missing';
const okStatuses = new Set(['ok', 'partial', 'empty']);
return {
sceneId,
archetype,
scriptPath: path.relative(repoRoot, scriptPath).replace(/\\/g, '/'),
scriptLoadStatus: 'script-load-pass',
mockDependencyStatus: 'mock-dependency-ready',
mockRuntimeStatus: okStatuses.has(artifactStatus)
? 'mock-runtime-pass'
: 'mock-runtime-fail',
artifactStatus,
artifactType: artifact?.type || null,
rowCount: Array.isArray(artifact?.rows) ? artifact.rows.length : null,
counts: artifact?.counts || {},
failureReason: okStatuses.has(artifactStatus)
? null
: `artifact_status_${artifactStatus}`,
durationMs: Date.now() - started,
requestLog,
};
} catch (error) {
return {
sceneId,
archetype,
scriptPath: path.relative(repoRoot, scriptPath).replace(/\\/g, '/'),
scriptLoadStatus: 'script-load-pass',
mockDependencyStatus: 'mock-dependency-ready',
mockRuntimeStatus: 'mock-runtime-fail',
failureReason: `runtime_exception:${error.message}`,
durationMs: Date.now() - started,
requestLog,
};
}
}
async function main() {
const matrix = readJson(matrixPath);
const representatives = matrix.representatives;
const records = [];
for (const archetype of Object.keys(representatives).sort()) {
for (const sceneId of representatives[archetype]) {
records.push(await runRepresentative(sceneId, archetype));
}
}
const byStatus = {};
const byArchetype = {};
for (const record of records) {
byStatus[record.mockRuntimeStatus] = (byStatus[record.mockRuntimeStatus] || 0) + 1;
byArchetype[record.archetype] = byArchetype[record.archetype] || {};
byArchetype[record.archetype][record.mockRuntimeStatus] =
(byArchetype[record.archetype][record.mockRuntimeStatus] || 0) + 1;
}
const result = {
runDate: '2026-04-20',
plan: '2026-04-20-scene-skill-102-mock-runtime-harness-implementation-plan.md',
execution: 'mock-runtime-only-no-browser-no-network',
sourceMatrix: 'tests/fixtures/generated_scene/scene_skill_102_mock_runtime_validation_matrix_2026-04-20.json',
summary: {
totalRepresentatives: records.length,
byStatus,
byArchetype,
productionNetworkUsed: false,
realBrowserUsed: false,
generatedSkillsModified: false,
},
representatives,
results: records,
};
writeJson(outputPath, result);
const lines = [];
lines.push('# Scene Skill 102 Mock Runtime Harness Report');
lines.push('');
lines.push('> Date: 2026-04-20');
lines.push('> Plan: `2026-04-20-scene-skill-102-mock-runtime-harness-implementation-plan.md`');
lines.push('');
lines.push('## Scope');
lines.push('');
lines.push('This run executed only representative generated scripts inside a local mock runtime. It did not use a real browser, real network, production credentials, or business systems. It did not modify generated skill packages.');
lines.push('');
lines.push('## Summary');
lines.push('');
lines.push('| Status | Count |');
lines.push('| --- | ---: |');
for (const [status, count] of Object.entries(byStatus).sort()) {
lines.push(`| \`${status}\` | ${count} |`);
}
lines.push('');
lines.push('## By Archetype');
lines.push('');
lines.push('| Archetype | Representatives | Result |');
lines.push('| --- | --- | --- |');
for (const archetype of Object.keys(representatives).sort()) {
const reps = representatives[archetype].join(', ');
const statuses = Object.entries(byArchetype[archetype] || {})
.map(([status, count]) => `${status}: ${count}`)
.join(', ');
lines.push(`| \`${archetype}\` | \`${reps}\` | ${statuses} |`);
}
lines.push('');
lines.push('## Interpretation');
lines.push('');
lines.push('Representative mock execution passing means the generated scripts can load and traverse their main control flow against fake dependencies. It does not mean every one of the 102 scripts was directly executed, and it does not mean production execution passed.');
lines.push('');
lines.push('## Next Step');
lines.push('');
lines.push('If continuing, the next bounded stage should expand mock runtime from representative execution to full 102 direct mock execution, or select a small pseudo-production batch if representative coverage is considered sufficient.');
lines.push('');
fs.writeFileSync(reportPath, `${lines.join('\n')}\n`, 'utf8');
console.log(JSON.stringify(result.summary, null, 2));
}
main().catch((error) => {
console.error(error);
process.exit(1);
});

View File

@@ -75,8 +75,14 @@ fn export_month_lineloss_produces_valid_xlsx() {
let mut xml = String::new();
std::io::Read::read_to_string(&mut sheet, &mut xml).unwrap();
assert!(xml.contains("供电单位"), "header row should contain 供电单位");
assert!(xml.contains("累计供电量"), "header row should contain 累计供电量");
assert!(
xml.contains("供电单位"),
"header row should contain 供电单位"
);
assert!(
xml.contains("累计供电量"),
"header row should contain 累计供电量"
);
assert!(xml.contains("城关供电"), "data should contain 城关供电");
assert!(xml.contains("12345.67"), "data should contain 12345.67");
assert!(xml.contains("七里河供电"), "data should contain second row");
@@ -99,7 +105,10 @@ fn export_empty_rows_returns_error() {
let result = export_lineloss_xlsx(&request);
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("rows must not be empty"),
result
.unwrap_err()
.to_string()
.contains("rows must not be empty"),
"should reject empty rows"
);
}

View File

@@ -0,0 +1,121 @@
use std::fs;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct PostG7BoundaryDecision {
#[serde(rename = "decisionDate")]
decision_date: String,
scope: String,
#[serde(rename = "startingState")]
starting_state: StartingState,
#[serde(rename = "comparisonMatrix")]
comparison_matrix: Vec<ComparisonEntry>,
#[serde(rename = "selectedDirection")]
selected_direction: SelectedDirection,
#[serde(rename = "holdReasons")]
hold_reasons: Vec<String>,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct StartingState {
#[serde(rename = "mainlineClosed")]
mainline_closed: Vec<String>,
#[serde(rename = "boundaryClosed")]
boundary_closed: Vec<String>,
#[serde(rename = "boundaryHeld")]
boundary_held: Vec<String>,
#[serde(rename = "deferredOutOfScope")]
deferred_out_of_scope: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct ComparisonEntry {
direction: String,
#[serde(rename = "entryCondition")]
entry_condition: String,
#[serde(rename = "smallestNewCapability")]
smallest_new_capability: String,
#[serde(rename = "entryCost")]
entry_cost: String,
decision: String,
reason: String,
}
#[derive(Debug, Deserialize)]
struct SelectedDirection {
direction: String,
#[serde(rename = "nextDesign")]
next_design: String,
#[serde(rename = "nextPlan")]
next_plan: String,
}
#[test]
fn post_g7_boundary_decision_roadmap_emits_one_bounded_direction() {
let decision: PostG7BoundaryDecision = serde_json::from_str(
&fs::read_to_string(
"tests/fixtures/generated_scene/post_g7_boundary_decision_2026-04-19.json",
)
.unwrap(),
)
.unwrap();
assert_eq!(decision.decision_date, "2026-04-19");
assert_eq!(decision.scope, "post-g7-boundary-decision-roadmap");
assert_eq!(
decision.starting_state.mainline_closed,
vec!["G1-E", "G2", "G3"]
);
assert_eq!(decision.starting_state.boundary_closed, vec!["G7"]);
assert_eq!(decision.starting_state.boundary_held, vec!["G6", "G8"]);
assert_eq!(
decision.starting_state.deferred_out_of_scope,
vec!["G4", "G5"]
);
assert_eq!(decision.comparison_matrix.len(), 3);
assert_eq!(
decision
.comparison_matrix
.iter()
.filter(|entry| entry.decision == "selected")
.count(),
1
);
assert!(decision
.comparison_matrix
.iter()
.any(|entry| entry.direction == "G6"
&& entry.decision == "hold"
&& entry.entry_cost == "high"));
assert!(decision
.comparison_matrix
.iter()
.any(|entry| entry.direction == "G8"
&& entry.decision == "hold"
&& entry.entry_cost == "high"));
assert!(decision
.comparison_matrix
.iter()
.any(|entry| entry.direction == "prerequisites-only-hold"
&& entry.decision == "selected"
&& entry.entry_cost == "medium"));
assert_eq!(
decision.selected_direction.direction,
"prerequisites-only-hold"
);
assert!(decision
.selected_direction
.next_design
.ends_with("2026-04-19-boundary-runtime-prerequisites-roadmap-design.md"));
assert!(decision
.selected_direction
.next_plan
.ends_with("2026-04-19-boundary-runtime-prerequisites-roadmap-plan.md"));
assert_eq!(decision.hold_reasons.len(), 2);
assert!(!decision.notes.is_empty());
}

View File

@@ -0,0 +1,606 @@
use std::fs;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct HandoverAsset {
#[serde(rename = "handoverDate")]
handover_date: String,
#[serde(rename = "closedRoadmapPlan")]
closed_roadmap_plan: String,
#[serde(rename = "postRoadmapPlan")]
post_roadmap_plan: String,
#[serde(rename = "roadmapClosureStatus")]
roadmap_closure_status: String,
#[serde(rename = "scopeStatement")]
scope_statement: String,
#[serde(rename = "mainlineFamilyStateMatrix")]
mainline_family_state_matrix: Vec<MainlineFamilyState>,
#[serde(rename = "boundaryFamilyStateMatrix")]
boundary_family_state_matrix: Vec<GroupState>,
#[serde(rename = "deferredFamilyStateMatrix")]
deferred_family_state_matrix: Vec<GroupState>,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct MainlineFamilyState {
group: String,
status: String,
#[serde(rename = "representativeBaseline")]
representative_baseline: String,
#[serde(rename = "promotedExpansions")]
promoted_expansions: u32,
#[serde(rename = "candidateQueueCount")]
candidate_queue_count: u32,
}
#[derive(Debug, Deserialize)]
struct GroupState {
group: String,
status: String,
}
#[derive(Debug, Deserialize)]
struct SceneExecutionBoard {
#[serde(rename = "boardDate")]
board_date: String,
scope: String,
#[serde(rename = "sourceAssets")]
source_assets: SourceAssets,
#[serde(rename = "statusVocabulary")]
status_vocabulary: Vec<String>,
summary: ExecutionBoardSummary,
scenes: Vec<SceneBoardEntry>,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct SourceAssets {
workbook: String,
#[serde(rename = "ledgerSnapshot")]
ledger_snapshot: String,
#[serde(rename = "ledgerStatusOverlay")]
ledger_status_overlay: String,
#[serde(rename = "roadmapExecutionStatus")]
roadmap_execution_status: String,
}
#[derive(Debug, Deserialize)]
struct ExecutionBoardSummary {
#[serde(rename = "totalScenes")]
total_scenes: u32,
#[serde(rename = "statusCounts")]
status_counts: serde_json::Map<String, serde_json::Value>,
#[serde(rename = "selectedRealSamples")]
selected_real_samples: u32,
#[serde(rename = "executedRealSamples")]
executed_real_samples: u32,
#[serde(rename = "pendingRealSamples")]
pending_real_samples: u32,
}
#[derive(Debug, Deserialize)]
struct SceneBoardEntry {
#[serde(rename = "sceneName")]
scene_name: String,
#[serde(rename = "snapshotGroupingResult")]
snapshot_grouping_result: String,
#[serde(rename = "snapshotFamilyJudgement")]
snapshot_family_judgement: String,
#[serde(rename = "hasExplicitValidationConclusion")]
has_explicit_validation_conclusion: String,
#[serde(rename = "snapshotValidationStatus")]
snapshot_validation_status: String,
#[serde(rename = "snapshotValidationResult")]
snapshot_validation_result: String,
#[serde(rename = "snapshotNote")]
snapshot_note: String,
#[serde(rename = "currentGroup")]
current_group: Option<String>,
#[serde(rename = "currentStatus")]
current_status: String,
#[serde(rename = "currentSourceAsset")]
current_source_asset: Option<String>,
#[serde(rename = "realSampleRecordId")]
real_sample_record_id: Option<String>,
#[serde(rename = "realSampleLayerStatus")]
real_sample_layer_status: String,
}
#[derive(Debug, Deserialize)]
struct RealSampleValidationPlan {
#[serde(rename = "planDate")]
plan_date: String,
scope: String,
criteria: Vec<String>,
#[serde(rename = "selectedFamilies")]
selected_families: Vec<SelectedFamily>,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct SelectedFamily {
group: String,
#[serde(rename = "recordId")]
record_id: String,
#[serde(rename = "sceneName")]
scene_name: String,
#[serde(rename = "currentBoardStatus")]
current_board_status: String,
#[serde(rename = "sourceEvidence")]
source_evidence: String,
#[serde(rename = "selectionReason")]
selection_reason: String,
}
#[derive(Debug, Deserialize)]
struct RealSampleValidationTemplate {
#[serde(rename = "templateDate")]
template_date: String,
scope: String,
#[serde(rename = "requiredFields")]
required_fields: Vec<String>,
#[serde(rename = "allowedValidationStates")]
allowed_validation_states: Vec<String>,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct RealSampleValidationRecords {
#[serde(rename = "recordDate")]
record_date: String,
scope: String,
records: Vec<RealSampleValidationRecord>,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct RealSampleValidationRecord {
#[serde(rename = "recordId")]
record_id: String,
group: String,
#[serde(rename = "sceneName")]
scene_name: String,
#[serde(rename = "currentBoardStatus")]
current_board_status: String,
#[serde(rename = "validationState")]
validation_state: String,
#[serde(rename = "compileSuccess")]
compile_success: Option<bool>,
#[serde(rename = "readinessCorrectness")]
readiness_correctness: Option<bool>,
#[serde(rename = "dataCorrectness")]
data_correctness: Option<bool>,
#[serde(rename = "outputCorrectness")]
output_correctness: Option<bool>,
#[serde(rename = "failClosedCorrectness")]
fail_closed_correctness: Option<bool>,
result: String,
#[serde(rename = "mismatchCodes")]
mismatch_codes: Vec<String>,
#[serde(rename = "sourceEvidence")]
source_evidence: Vec<String>,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct MismatchTaxonomy {
#[serde(rename = "taxonomyDate")]
taxonomy_date: String,
scope: String,
codes: Vec<MismatchCode>,
}
#[derive(Debug, Deserialize)]
struct MismatchCode {
code: String,
category: String,
description: String,
}
#[derive(Debug, Deserialize)]
struct BoundaryRuntimeEntryRules {
#[serde(rename = "assetDate")]
asset_date: String,
scope: String,
#[serde(rename = "boundaryReadiness")]
boundary_readiness: Vec<BoundaryReadiness>,
#[serde(rename = "deferredFamilyEntryCriteria")]
deferred_family_entry_criteria: Vec<DeferredFamilyEntryCriteria>,
#[serde(rename = "runtimeGapMatrix")]
runtime_gap_matrix: Vec<RuntimeGap>,
prioritization: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct BoundaryReadiness {
group: String,
status: String,
readiness: String,
#[serde(rename = "nextEntryCondition")]
next_entry_condition: String,
}
#[derive(Debug, Deserialize)]
struct DeferredFamilyEntryCriteria {
group: String,
#[serde(rename = "currentStatus")]
current_status: String,
#[serde(rename = "entryCriteria")]
entry_criteria: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct RuntimeGap {
gap: String,
category: String,
status: String,
blocks: Vec<String>,
}
#[test]
fn post_roadmap_handover_asset_is_actionable() {
let handover = load_handover();
assert_eq!(handover.handover_date, "2026-04-18");
assert!(handover.closed_roadmap_plan.ends_with(".md"));
assert!(handover.post_roadmap_plan.ends_with(".md"));
assert_eq!(handover.roadmap_closure_status, "completed");
assert!(handover.scope_statement.contains("roadmap is closed"));
assert_eq!(handover.mainline_family_state_matrix.len(), 3);
assert_eq!(handover.boundary_family_state_matrix.len(), 3);
assert_eq!(handover.deferred_family_state_matrix.len(), 2);
assert!(!handover.notes.is_empty());
}
#[test]
fn post_roadmap_execution_board_stays_minimal_and_validation_oriented() {
let board = load_execution_board();
assert_eq!(board.board_date, "2026-04-19");
assert_eq!(board.scope, "post-roadmap-minimum-current-execution-board");
assert!(board.source_assets.workbook.ends_with(".xlsx"));
assert!(board.source_assets.ledger_snapshot.ends_with(".json"));
assert!(board.source_assets.ledger_status_overlay.ends_with(".json"));
assert!(board
.source_assets
.roadmap_execution_status
.ends_with(".json"));
assert_eq!(board.status_vocabulary.len(), 6);
assert_eq!(board.summary.total_scenes, 102);
assert_eq!(board.summary.selected_real_samples, 5);
assert_eq!(board.summary.executed_real_samples, 5);
assert_eq!(board.summary.pending_real_samples, 0);
assert_eq!(
board
.summary
.status_counts
.get("promoted-baseline")
.and_then(|value| value.as_u64()),
Some(3)
);
assert_eq!(
board
.summary
.status_counts
.get("boundary-family")
.and_then(|value| value.as_u64()),
Some(3)
);
assert_eq!(
board
.summary
.status_counts
.get("unvalidated")
.and_then(|value| value.as_u64()),
Some(79)
);
assert!(!board.notes.is_empty());
}
#[test]
fn post_roadmap_execution_board_maps_real_sample_records_immediately() {
let board = load_execution_board();
let g2 = board
.scenes
.iter()
.find(|item| item.scene_name == "台区线损大数据-月_周累计线损率统计分析")
.expect("expected G2 board entry");
assert_eq!(g2.current_group.as_deref(), Some("G2"));
assert_eq!(g2.current_status, "promoted-baseline");
assert_eq!(g2.real_sample_record_id.as_deref(), Some("rsv-g2-001"));
assert_eq!(g2.real_sample_layer_status, "executed-pass");
let g1e = board
.scenes
.iter()
.find(|item| item.scene_name == "高低压新增报装容量月度统计表")
.expect("expected G1-E board entry");
assert_eq!(g1e.current_group.as_deref(), Some("G1-E"));
assert_eq!(g1e.current_status, "promoted-baseline");
assert_eq!(g1e.real_sample_record_id.as_deref(), Some("rsv-g1e-001"));
assert_eq!(g1e.real_sample_layer_status, "executed-pass");
let g3 = board
.scenes
.iter()
.find(|item| item.scene_name == "95598工单明细表")
.expect("expected G3 board entry");
assert_eq!(g3.current_group.as_deref(), Some("G3"));
assert_eq!(g3.current_status, "promoted-baseline");
assert_eq!(g3.real_sample_record_id.as_deref(), Some("rsv-g3-001"));
assert_eq!(g3.real_sample_layer_status, "executed-pass");
let g7 = board
.scenes
.iter()
.find(|item| item.current_group.as_deref() == Some("G7"))
.expect("expected G7 board entry");
assert_eq!(g7.current_group.as_deref(), Some("G7"));
assert_eq!(g7.current_status, "boundary-family");
assert_eq!(g7.real_sample_record_id.as_deref(), Some("rsv-g7-001"));
assert_eq!(g7.real_sample_layer_status, "executed-pass");
let g6 = board
.scenes
.iter()
.find(|item| item.current_group.as_deref() == Some("G6"))
.expect("expected G6 board entry");
assert_eq!(g6.current_group.as_deref(), Some("G6"));
assert_eq!(g6.current_status, "boundary-family");
assert_eq!(g6.real_sample_record_id.as_deref(), Some("rsv-g6-001"));
assert_eq!(g6.real_sample_layer_status, "executed-pass");
}
#[test]
fn post_roadmap_real_sample_validation_assets_are_consistent() {
let plan = load_validation_plan();
let template = load_validation_template();
let records = load_validation_records();
let taxonomy = load_mismatch_taxonomy();
assert_eq!(plan.plan_date, "2026-04-18");
assert_eq!(
plan.scope,
"post-roadmap-first-round-real-sample-validation"
);
assert_eq!(plan.criteria.len(), 5);
assert_eq!(plan.selected_families.len(), 3);
assert!(plan.selected_families.iter().any(|item| item.group == "G2"));
assert!(plan
.selected_families
.iter()
.any(|item| item.group == "G1-E"));
assert!(plan.selected_families.iter().any(|item| item.group == "G3"));
assert!(!plan.notes.is_empty());
assert_eq!(template.template_date, "2026-04-18");
assert_eq!(template.scope, "real-sample-validation-record-template");
assert!(template
.required_fields
.iter()
.any(|item| item == "recordId"));
assert!(template
.allowed_validation_states
.iter()
.any(|item| item == "selected-not-yet-run"));
assert!(!template.notes.is_empty());
assert_eq!(records.record_date, "2026-04-19");
assert_eq!(
records.scope,
"post-roadmap-first-round-real-sample-records"
);
assert_eq!(records.records.len(), 5);
assert!(!records.notes.is_empty());
assert_eq!(taxonomy.taxonomy_date, "2026-04-18");
assert_eq!(taxonomy.scope, "post-roadmap-real-sample-mismatch-taxonomy");
assert!(taxonomy
.codes
.iter()
.any(|item| item.code == "archetype_mismatch"));
assert!(taxonomy
.codes
.iter()
.any(|item| item.code == "selected_not_run"));
let g2 = records
.records
.iter()
.find(|item| item.record_id == "rsv-g2-001")
.expect("expected G2 record");
assert_eq!(g2.group, "G2");
assert_eq!(g2.validation_state, "executed-pass");
assert_eq!(g2.compile_success, Some(true));
assert_eq!(g2.result, "passed");
assert_eq!(g2.readiness_correctness, Some(true));
assert_eq!(g2.data_correctness, Some(true));
assert_eq!(g2.output_correctness, Some(true));
assert_eq!(g2.fail_closed_correctness, Some(true));
assert!(g2.mismatch_codes.is_empty());
let g1e = records
.records
.iter()
.find(|item| item.record_id == "rsv-g1e-001")
.expect("expected G1-E record");
assert_eq!(g1e.group, "G1-E");
assert_eq!(g1e.validation_state, "executed-pass");
assert_eq!(g1e.compile_success, Some(true));
assert_eq!(g1e.result, "passed");
assert!(g1e.mismatch_codes.is_empty());
let g3 = records
.records
.iter()
.find(|item| item.record_id == "rsv-g3-001")
.expect("expected G3 record");
assert_eq!(g3.group, "G3");
assert_eq!(g3.validation_state, "executed-pass");
assert_eq!(g3.compile_success, Some(true));
assert_eq!(g3.result, "passed");
assert_eq!(g3.data_correctness, Some(true));
assert_eq!(g3.output_correctness, Some(true));
assert!(g3.mismatch_codes.is_empty());
let g7 = records
.records
.iter()
.find(|item| item.record_id == "rsv-g7-001")
.expect("expected G7 record");
assert_eq!(g7.group, "G7");
assert_eq!(g7.validation_state, "executed-pass");
assert_eq!(g7.compile_success, Some(true));
assert_eq!(g7.result, "passed");
assert_eq!(g7.data_correctness, Some(true));
assert_eq!(g7.output_correctness, Some(true));
assert!(g7.mismatch_codes.is_empty());
let g6 = records
.records
.iter()
.find(|item| item.record_id == "rsv-g6-001")
.expect("expected G6 record");
assert_eq!(g6.group, "G6");
assert_eq!(g6.validation_state, "executed-pass");
assert_eq!(g6.compile_success, Some(true));
assert_eq!(g6.result, "passed");
assert_eq!(g6.data_correctness, Some(true));
assert_eq!(g6.output_correctness, Some(true));
assert!(g6.mismatch_codes.is_empty());
}
#[test]
fn post_roadmap_boundary_and_runtime_entry_rules_keep_scope_bounded() {
let rules = load_boundary_runtime_rules();
assert_eq!(rules.asset_date, "2026-04-19");
assert_eq!(rules.scope, "post-roadmap-boundary-and-runtime-entry-rules");
assert_eq!(rules.boundary_readiness.len(), 3);
assert!(rules.boundary_readiness.iter().all(|item| {
matches!(item.group.as_str(), "G6" | "G7" | "G8")
&& item.status == "boundary-family-established"
}));
assert!(rules
.boundary_readiness
.iter()
.any(|item| item.group == "G6" && item.readiness == "executed-pass"));
assert!(rules
.boundary_readiness
.iter()
.any(|item| item.group == "G8" && item.readiness == "hold-as-boundary"));
assert!(rules
.boundary_readiness
.iter()
.any(|item| item.group == "G7" && item.readiness == "executed-pass"));
assert_eq!(rules.deferred_family_entry_criteria.len(), 2);
assert!(rules
.deferred_family_entry_criteria
.iter()
.any(|item| item.group == "G4" && item.current_status == "deferred"));
assert!(rules
.deferred_family_entry_criteria
.iter()
.any(|item| item.group == "G5" && item.current_status == "degraded"));
assert_eq!(rules.runtime_gap_matrix.len(), 6);
assert!(rules
.runtime_gap_matrix
.iter()
.any(|item| item.gap == "login-recovery" && item.category == "runtime-platform-gap"));
assert!(rules
.runtime_gap_matrix
.iter()
.any(|item| item.gap == "host-runtime-integration"));
assert!(rules.runtime_gap_matrix.iter().any(|item| item.gap
== "g3-real-sample-output-contract-verification"
&& item.category == "mainline-contract-gap"
&& item.status == "closed-in-mainline"));
assert!(rules
.runtime_gap_matrix
.iter()
.any(|item| item.gap == "g2-real-sample-contract-correction"
&& item.category == "mainline-contract-gap"
&& item.status == "closed-in-mainline"));
assert!(!rules.prioritization.is_empty());
assert!(rules
.prioritization
.iter()
.any(|item| item.contains("Both G3 and G2")));
assert!(rules
.prioritization
.iter()
.any(|item| item.contains("G7 is now the first boundary family")));
assert!(rules
.prioritization
.iter()
.any(|item| item.contains("G6 is now the second boundary family")));
}
fn load_handover() -> HandoverAsset {
serde_json::from_str(
&fs::read_to_string("tests/fixtures/generated_scene/post_roadmap_handover_2026-04-18.json")
.unwrap(),
)
.unwrap()
}
fn load_execution_board() -> SceneExecutionBoard {
serde_json::from_str(
&fs::read_to_string("tests/fixtures/generated_scene/scene_execution_board_2026-04-18.json")
.unwrap(),
)
.unwrap()
}
fn load_validation_plan() -> RealSampleValidationPlan {
serde_json::from_str(
&fs::read_to_string(
"tests/fixtures/generated_scene/real_sample_validation_plan_2026-04-18.json",
)
.unwrap(),
)
.unwrap()
}
fn load_validation_template() -> RealSampleValidationTemplate {
serde_json::from_str(
&fs::read_to_string(
"tests/fixtures/generated_scene/real_sample_validation_record_template_2026-04-18.json",
)
.unwrap(),
)
.unwrap()
}
fn load_validation_records() -> RealSampleValidationRecords {
serde_json::from_str(
&fs::read_to_string(
"tests/fixtures/generated_scene/real_sample_validation_records_2026-04-18.json",
)
.unwrap(),
)
.unwrap()
}
fn load_mismatch_taxonomy() -> MismatchTaxonomy {
serde_json::from_str(
&fs::read_to_string(
"tests/fixtures/generated_scene/real_sample_mismatch_taxonomy_2026-04-18.json",
)
.unwrap(),
)
.unwrap()
}
fn load_boundary_runtime_rules() -> BoundaryRuntimeEntryRules {
serde_json::from_str(
&fs::read_to_string(
"tests/fixtures/generated_scene/boundary_runtime_entry_rules_2026-04-18.json",
)
.unwrap(),
)
.unwrap()
}

View File

@@ -0,0 +1,120 @@
use std::fs;
use std::path::{Path, PathBuf};
use serde_json::json;
use sgclaw::compat::report_artifact::interpret_report_artifact_and_postprocess;
use sgclaw::scene_contract::PostprocessSection;
use uuid::Uuid;
fn temp_workspace(prefix: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("{prefix}-{}", Uuid::new_v4()));
fs::create_dir_all(&root).unwrap();
root
}
fn report_postprocess_xlsx() -> PostprocessSection {
PostprocessSection {
exporter: "xlsx_report".to_string(),
auto_open: None,
}
}
fn exported_xlsx_files(workspace_root: &Path) -> Vec<PathBuf> {
let out_dir = workspace_root.join("out");
if !out_dir.exists() {
return Vec::new();
}
fs::read_dir(out_dir)
.unwrap()
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("xlsx"))
.collect()
}
#[test]
fn report_artifact_postprocess_exports_xlsx_for_ok_or_partial_scene() {
let workspace_root = temp_workspace("sgclaw-report-artifact-export");
let artifact = json!({
"type": "report-artifact",
"report_name": "tq-lineloss-report",
"status": "partial",
"columns": ["ORG_NAME", "LINE_LOSS_RATE"],
"column_defs": [["ORG_NAME", "供电单位"], ["LINE_LOSS_RATE", "综合线损率(%)"]],
"rows": [{"ORG_NAME": "国网兰州供电公司", "LINE_LOSS_RATE": "1.23"}],
"counts": {"rows": 1},
"partial_reasons": ["report_log_failed"]
});
let outcome = interpret_report_artifact_and_postprocess(
&artifact,
Some(&report_postprocess_xlsx()),
&workspace_root,
)
.unwrap();
assert!(outcome.success);
assert!(outcome.summary.contains("tq-lineloss-report"));
assert!(outcome.summary.contains("status=partial"));
assert!(outcome.summary.contains("detail_rows=1"));
assert!(outcome
.summary
.contains("partial_reasons=report_log_failed"));
assert!(outcome.summary.contains("export_path="));
assert_eq!(exported_xlsx_files(&workspace_root).len(), 1);
}
#[test]
fn report_artifact_postprocess_skips_export_for_blocked_or_error_scene() {
for status in ["blocked", "error"] {
let workspace_root = temp_workspace(&format!("sgclaw-report-artifact-{status}"));
let artifact = json!({
"type": "report-artifact",
"report_name": "generic-report",
"status": status,
"columns": ["ORG_NAME"],
"rows": [{"ORG_NAME": "国网兰州供电公司"}],
"counts": {"rows": 1},
"reasons": ["login_required"]
});
let outcome = interpret_report_artifact_and_postprocess(
&artifact,
Some(&report_postprocess_xlsx()),
&workspace_root,
)
.unwrap();
assert!(!outcome.success, "{status} should fail");
assert!(outcome.summary.contains(&format!("status={status}")));
assert!(!outcome.summary.contains("export_path="));
assert!(exported_xlsx_files(&workspace_root).is_empty());
}
}
#[test]
fn report_artifact_postprocess_exports_with_columns_when_column_defs_are_absent() {
let workspace_root = temp_workspace("sgclaw-report-artifact-columns-fallback");
let artifact = json!({
"type": "report-artifact",
"report_name": "generic-report",
"status": "ok",
"columns": ["ORG_NAME", "VALUE"],
"rows": [{"ORG_NAME": "国网兰州供电公司", "VALUE": "42"}],
"counts": {"rows": 1}
});
let outcome = interpret_report_artifact_and_postprocess(
&artifact,
Some(&report_postprocess_xlsx()),
&workspace_root,
)
.unwrap();
assert!(outcome.success);
assert!(outcome.summary.contains("generic-report"));
assert!(outcome.summary.contains("detail_rows=1"));
assert!(outcome.summary.contains("export_path="));
assert_eq!(exported_xlsx_files(&workspace_root).len(), 1);
}

View File

@@ -0,0 +1,165 @@
use std::fs;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct RoadmapExecutionStatus {
#[serde(rename = "statusDate")]
status_date: String,
plan: String,
#[serde(rename = "derivedFrom")]
derived_from: DerivedFrom,
#[serde(rename = "phaseStatus")]
phase_status: Vec<PhaseStatus>,
#[serde(rename = "trackStatus")]
track_status: Vec<TrackStatus>,
#[serde(rename = "boundaryStatus")]
boundary_status: Vec<GroupStatus>,
#[serde(rename = "deferredStatus")]
deferred_status: Vec<GroupStatus>,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct DerivedFrom {
#[serde(rename = "familyPolicy")]
family_policy: String,
#[serde(rename = "familyResults")]
family_results: String,
#[serde(rename = "ledgerSnapshot")]
ledger_snapshot: String,
#[serde(rename = "ledgerStatusOverlay")]
ledger_status_overlay: String,
}
#[derive(Debug, Deserialize)]
struct PhaseStatus {
phase: String,
status: String,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct TrackStatus {
track: String,
group: String,
status: String,
#[serde(rename = "completedItems")]
completed_items: Vec<String>,
#[serde(rename = "remainingItems")]
remaining_items: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct GroupStatus {
group: String,
status: String,
}
#[test]
fn roadmap_execution_status_asset_is_actionable() {
let status = load_status();
assert_eq!(status.status_date, "2026-04-18");
assert!(status.plan.ends_with(".md"));
assert!(status.derived_from.family_policy.ends_with(".json"));
assert!(status.derived_from.family_results.ends_with(".json"));
assert!(status.derived_from.ledger_snapshot.ends_with(".json"));
assert!(status.derived_from.ledger_status_overlay.ends_with(".json"));
assert_eq!(status.phase_status.len(), 5);
assert_eq!(status.track_status.len(), 5);
assert_eq!(status.boundary_status.len(), 3);
assert_eq!(status.deferred_status.len(), 2);
assert!(!status.notes.is_empty());
}
#[test]
fn roadmap_execution_status_tracks_mainline_progress() {
let status = load_status();
let phase4 = status
.phase_status
.iter()
.find(|item| item.phase == "Phase 4")
.expect("expected phase 4 status");
assert_eq!(phase4.status, "in_progress");
assert!(!phase4.notes.is_empty());
let track_a = status
.track_status
.iter()
.find(|item| item.track == "Track A")
.expect("expected track A");
assert_eq!(track_a.group, "G2");
assert_eq!(track_a.status, "batch-expansion-promoted");
assert!(track_a
.completed_items
.iter()
.any(|item| item == "five-promoted-expansions"));
assert!(track_a.remaining_items.is_empty());
let track_b = status
.track_status
.iter()
.find(|item| item.track == "Track B")
.expect("expected track B");
assert_eq!(track_b.group, "G1-E");
assert_eq!(track_b.status, "batch-expansion-promoted");
assert!(track_b
.completed_items
.iter()
.any(|item| item == "two-promoted-expansions"));
assert!(track_b.remaining_items.is_empty());
let track_c = status
.track_status
.iter()
.find(|item| item.track == "Track C")
.expect("expected track C");
assert_eq!(track_c.group, "G3");
assert_eq!(track_c.status, "batch-expansion-promoted");
assert!(track_c
.completed_items
.iter()
.any(|item| item == "ten-promoted-expansions"));
assert!(track_c.remaining_items.is_empty());
}
#[test]
fn roadmap_execution_status_keeps_boundary_and_deferred_scope_clear() {
let status = load_status();
assert!(status.boundary_status.iter().all(|item| {
matches!(item.group.as_str(), "G6" | "G7" | "G8")
&& item.status == "boundary-family-established"
}));
assert!(status
.deferred_status
.iter()
.any(|item| item.group == "G4" && item.status == "deferred"));
assert!(status
.deferred_status
.iter()
.any(|item| item.group == "G5" && item.status == "degraded"));
let track_e = status
.track_status
.iter()
.find(|item| item.track == "Track E")
.expect("expected track E");
assert_eq!(track_e.status, "status-overlay-established");
assert!(track_e
.completed_items
.iter()
.any(|item| item == "current-ledger-status-overlay"));
}
fn load_status() -> RoadmapExecutionStatus {
serde_json::from_str(
&fs::read_to_string(
"tests/fixtures/generated_scene/roadmap_execution_status_2026-04-18.json",
)
.unwrap(),
)
.unwrap()
}

View File

@@ -9,10 +9,8 @@ use sgclaw::runtime::{RuntimeEngine, RuntimeProfile, ToolPolicy};
use uuid::Uuid;
fn temp_skill_root() -> PathBuf {
let root = std::env::temp_dir().join(format!(
"sgclaw-runtime-profile-skills-{}",
Uuid::new_v4()
));
let root =
std::env::temp_dir().join(format!("sgclaw-runtime-profile-skills-{}", Uuid::new_v4()));
fs::create_dir_all(root.join("skills")).unwrap();
root
}
@@ -141,12 +139,7 @@ fn ws_cleanup_browser_profile_does_not_inject_95598_scene_contract() {
fn browser_attached_unrelated_task_does_not_receive_95598_scene_contract() {
let engine = RuntimeEngine::new(RuntimeProfile::BrowserAttached);
let instruction = engine.build_instruction(
"帮我总结今天的会议纪要",
None,
None,
true,
);
let instruction = engine.build_instruction("帮我总结今天的会议纪要", None, None, true);
assert!(!instruction.contains("collect_repair_orders"));
assert!(!instruction.contains("browser workflow, not a text-only task"));

View File

@@ -0,0 +1,285 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::Deserialize;
use serde_json::Value;
use sgclaw::generated_scene::generator::{generate_scene_package, GenerateSceneRequest};
use sgclaw::generated_scene::ir::SceneIr;
#[derive(Debug, Deserialize)]
struct CanonicalManifest {
targets: Vec<CanonicalTarget>,
}
#[derive(Debug, Deserialize)]
struct CanonicalTarget {
id: String,
#[serde(rename = "fixtureDir")]
fixture_dir: String,
#[serde(rename = "canonicalSceneIr")]
canonical_scene_ir: String,
#[serde(rename = "requiredEvidenceTypes")]
required_evidence_types: Vec<String>,
#[serde(rename = "requiredWorkflowStepTypes")]
required_workflow_step_types: Vec<String>,
#[serde(rename = "requiredGateNames")]
required_gate_names: Vec<String>,
#[serde(rename = "acceptanceChecklist")]
acceptance_checklist: Vec<String>,
#[serde(rename = "failureTaxonomy")]
failure_taxonomy: Vec<String>,
}
#[test]
fn p0_canonical_manifest_is_actionable() {
let manifest = load_manifest();
assert_eq!(manifest.targets.len(), 3);
for target in manifest.targets {
assert!(
Path::new(&target.fixture_dir).exists(),
"fixture dir missing: {}",
target.fixture_dir
);
assert!(
Path::new(&target.canonical_scene_ir).exists(),
"canonical ir missing: {}",
target.canonical_scene_ir
);
assert!(
!target.required_evidence_types.is_empty(),
"required_evidence_types should not be empty for {}",
target.id
);
assert!(
!target.required_workflow_step_types.is_empty(),
"required_workflow_step_types should not be empty for {}",
target.id
);
assert!(
!target.required_gate_names.is_empty(),
"required_gate_names should not be empty for {}",
target.id
);
assert!(
!target.acceptance_checklist.is_empty(),
"acceptance_checklist should not be empty for {}",
target.id
);
assert!(
!target.failure_taxonomy.is_empty(),
"failure_taxonomy should not be empty for {}",
target.id
);
}
}
#[test]
fn generated_p0_fixtures_align_with_canonical_answers() {
let manifest = load_manifest();
for target in manifest.targets {
let output_root = temp_workspace(&format!("sgclaw-canonical-{}", target.id));
let scene_id = scene_id_from_target(&target.id);
let scene_name = scene_name_from_target(&target.id);
generate_scene_package(GenerateSceneRequest {
source_dir: PathBuf::from(&target.fixture_dir),
scene_id,
scene_name,
scene_kind: None,
target_url: None,
output_root: output_root.clone(),
lessons_path: None,
scene_info_json: None,
scene_ir_json: None,
})
.unwrap_or_else(|err| panic!("{} failed to generate: {}", target.id, err));
let generated_dir = output_root
.join("skills")
.join(scene_id_from_target(&target.id));
let generated_report: SceneIr = serde_json::from_str(
&fs::read_to_string(generated_dir.join("references/generation-report.json")).unwrap(),
)
.unwrap();
let canonical: SceneIr =
serde_json::from_str(&fs::read_to_string(&target.canonical_scene_ir).unwrap()).unwrap();
assert_eq!(
generated_report.workflow_archetype().as_str(),
canonical.workflow_archetype().as_str(),
"archetype mismatch for {}",
target.id
);
assert_eq!(
generated_report.bootstrap.expected_domain, canonical.bootstrap.expected_domain,
"expectedDomain mismatch for {}",
target.id
);
assert!(
generated_report
.bootstrap
.target_url
.starts_with(&canonical.bootstrap.target_url),
"targetUrl mismatch for {}: {} vs {}",
target.id,
generated_report.bootstrap.target_url,
canonical.bootstrap.target_url
);
let generated_step_types = generated_report
.workflow_steps
.iter()
.map(|step| step.step_type.clone())
.collect::<Vec<_>>();
for required in &target.required_workflow_step_types {
assert!(
generated_step_types.iter().any(|step| step == required),
"missing workflow step {} for {}",
required,
target.id
);
}
let generated_gate_names = generated_report
.readiness
.gates
.iter()
.map(|gate| gate.name.clone())
.collect::<Vec<_>>();
for required in &target.required_gate_names {
assert!(
generated_gate_names.iter().any(|gate| gate == required),
"missing readiness gate {} for {}",
required,
target.id
);
}
let generated_evidence_types = generated_report
.evidence
.iter()
.map(|item| item.evidence_type.clone())
.collect::<Vec<_>>();
for required in &target.required_evidence_types {
assert!(
generated_evidence_types.iter().any(|kind| kind == required),
"missing evidence type {} for {}",
required,
target.id
);
}
let generated_json: Value = serde_json::from_str(
&fs::read_to_string(generated_dir.join("references/generation-report.json")).unwrap(),
)
.unwrap();
assert!(
generated_json.get("readiness").is_some(),
"generation-report.json should include readiness for {}",
target.id
);
if target.id == "p0-3-paginated-enrichment" {
assert_eq!(
generated_report
.main_request
.as_ref()
.map(|request| request.response_path.as_str()),
canonical
.main_request
.as_ref()
.map(|request| request.response_path.as_str()),
"g3 main request response path mismatch for {}",
target.id
);
assert_eq!(
generated_report
.pagination_plan
.as_ref()
.map(|plan| plan.page_field.as_str()),
canonical
.pagination_plan
.as_ref()
.map(|plan| plan.page_field.as_str()),
"g3 page field mismatch for {}",
target.id
);
assert_eq!(
generated_report
.pagination_plan
.as_ref()
.map(|plan| plan.termination_rule.as_str()),
canonical
.pagination_plan
.as_ref()
.map(|plan| plan.termination_rule.as_str()),
"g3 termination rule mismatch for {}",
target.id
);
assert_eq!(
generated_report.join_keys, canonical.join_keys,
"g3 join keys mismatch for {}",
target.id
);
assert_eq!(
generated_report.merge_or_dedupe_rules, canonical.merge_or_dedupe_rules,
"g3 merge/dedupe rules mismatch for {}",
target.id
);
assert_eq!(
generated_report
.export_plan
.as_ref()
.and_then(|plan| plan.entry.as_deref()),
canonical
.export_plan
.as_ref()
.and_then(|plan| plan.entry.as_deref()),
"g3 export entry mismatch for {}",
target.id
);
}
}
}
fn load_manifest() -> CanonicalManifest {
serde_json::from_str(
&fs::read_to_string(
"tests/fixtures/generated_scene/p0_canonical_answers/p0-canonical-manifest.json",
)
.unwrap(),
)
.unwrap()
}
fn scene_id_from_target(target_id: &str) -> String {
match target_id {
"p0-1-tq-lineloss-report" => "tq-lineloss-report".to_string(),
"p0-2-single-request-table" => "single-request-report".to_string(),
"p0-3-paginated-enrichment" => "paginated-enrichment-report".to_string(),
other => other.to_string(),
}
}
fn scene_name_from_target(target_id: &str) -> String {
match target_id {
"p0-1-tq-lineloss-report" => "台区线损月周累计统计分析".to_string(),
"p0-2-single-request-table" => "单请求通用报表".to_string(),
"p0-3-paginated-enrichment" => "分页补数明细报表".to_string(),
other => other.to_string(),
}
}
fn temp_workspace(prefix: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("{prefix}-{nanos}"));
fs::create_dir_all(&path).unwrap();
path
}

View File

@@ -0,0 +1,121 @@
use std::fs;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct FamilyResults {
#[serde(rename = "generatedAt")]
generated_at: String,
scope: String,
families: Vec<FamilyResult>,
}
#[derive(Debug, Deserialize)]
struct FamilyResult {
id: String,
group: String,
#[serde(rename = "familyName")]
family_name: String,
#[serde(rename = "representativeRuns")]
representative_runs: u32,
#[serde(rename = "expansionRuns", default)]
expansion_runs: u32,
#[serde(rename = "candidateBatchCount", default)]
candidate_batch_count: u32,
#[serde(rename = "passedRuns")]
passed_runs: u32,
#[serde(rename = "failedRuns")]
failed_runs: u32,
#[serde(rename = "successRate")]
success_rate: f64,
#[serde(rename = "failureTaxonomy")]
failure_taxonomy: Vec<String>,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct FamilyExpansionPolicy {
#[serde(rename = "policyVersion")]
policy_version: String,
scope: String,
#[serde(rename = "mainlineGroups")]
mainline_groups: Vec<GroupPolicy>,
#[serde(rename = "boundaryGroups", default)]
boundary_groups: Vec<GroupPolicy>,
#[serde(rename = "deferredGroups")]
deferred_groups: Vec<GroupPolicy>,
}
#[derive(Debug, Deserialize)]
struct GroupPolicy {
group: String,
policy: String,
#[serde(rename = "executionRule")]
execution_rule: String,
#[serde(rename = "acceptanceFocus", default)]
acceptance_focus: Vec<String>,
}
#[test]
fn family_results_asset_is_actionable() {
let results: FamilyResults = serde_json::from_str(
&fs::read_to_string("tests/fixtures/generated_scene/p1_family_results.json").unwrap(),
)
.unwrap();
assert!(!results.generated_at.is_empty());
assert!(results.scope.contains("representative"));
assert_eq!(results.families.len(), 7);
for family in results.families {
assert!(matches!(
family.group.as_str(),
"G1" | "G2" | "G3" | "G6" | "G7" | "G8"
));
assert!(!family.id.is_empty());
assert!(!family.family_name.is_empty());
assert!(family.representative_runs >= 1);
assert!(family.candidate_batch_count <= 102);
assert_eq!(
family.representative_runs + family.expansion_runs,
family.passed_runs + family.failed_runs
);
assert!(family.success_rate >= 0.0 && family.success_rate <= 1.0);
assert!(!family.failure_taxonomy.is_empty());
assert!(!family.notes.is_empty());
}
}
#[test]
fn family_expansion_policy_matches_mainline_scope() {
let policy: FamilyExpansionPolicy = serde_json::from_str(
&fs::read_to_string("tests/fixtures/generated_scene/family_expansion_policy.json").unwrap(),
)
.unwrap();
assert!(!policy.policy_version.is_empty());
assert_eq!(policy.scope, "roadmap-plan-track-e");
assert_eq!(policy.mainline_groups.len(), 3);
assert_eq!(policy.boundary_groups.len(), 3);
assert_eq!(policy.deferred_groups.len(), 2);
for group in &policy.mainline_groups {
assert!(matches!(group.group.as_str(), "G1" | "G2" | "G3"));
assert_eq!(group.policy, "mainline");
assert!(!group.execution_rule.is_empty());
assert!(!group.acceptance_focus.is_empty());
}
for group in &policy.boundary_groups {
assert!(matches!(group.group.as_str(), "G6" | "G7" | "G8"));
assert_eq!(group.policy, "boundary-runtime");
assert!(!group.execution_rule.is_empty());
assert!(!group.acceptance_focus.is_empty());
}
for group in &policy.deferred_groups {
assert!(matches!(group.group.as_str(), "G4" | "G5"));
assert!(matches!(group.policy.as_str(), "deferred" | "degraded"));
assert!(!group.execution_rule.is_empty());
}
}

View File

@@ -12,19 +12,40 @@ fn scene_generator_html_exists_and_has_required_elements() {
let source = fs::read_to_string(&html_path)
.unwrap_or_else(|err| panic!("HTML file not found at {:?}: {}", html_path, err));
assert!(source.contains("场景 Skill 生成器"), "missing title");
assert!(
source.contains("Scene Skill Generator") || source.contains("Skill Generator"),
"missing title"
);
assert!(source.contains("sourceDir"), "missing sourceDir input");
assert!(source.contains("sceneId"), "missing sceneId input");
assert!(source.contains("sceneName"), "missing sceneName input");
assert!(
source.contains("workflowArchetypeOverride"),
"missing workflow archetype override control"
);
assert!(source.contains("previewSceneId"), "missing sceneId preview");
assert!(
source.contains("previewSceneIdSource"),
"missing sceneId source preview"
);
assert!(
source.contains("previewSceneIdValidation"),
"missing sceneId validation preview"
);
assert!(
!source.contains("settingLessons"),
"lessons input should be removed from default UI"
);
assert!(
!source.contains("browseLessons"),
"lessons browse action should be removed from default UI"
);
assert!(source.contains("/analyze"), "missing /analyze endpoint");
assert!(source.contains("/generate"), "missing /generate endpoint");
assert!(
source.contains("fetch("),
"missing fetch for API calls"
);
assert!(source.contains("fetch("), "missing fetch for API calls");
assert!(
source.contains("127.0.0.1") || source.contains("localhost"),
"should reference localhost server"
);
assert!(source.contains("readiness"), "missing readiness UI");
}

View File

@@ -1,8 +1,23 @@
const assert = require("assert");
const fs = require("fs");
const os = require("os");
const path = require("path");
const {
buildAnalyzePrompt,
extractJsonFromResponse,
isRetryableLlmError,
repairCommonJsonIssues,
} = require("../frontend/scene-generator/llm-client");
const {
buildDeterministicSceneIr,
readDirectory,
validateSceneIdCandidate,
} = require("../frontend/scene-generator/generator-runner");
const {
getGenerationBlockers,
mergeSceneIr,
sanitizeSceneIr,
} = require("../frontend/scene-generator/server");
function testBuildAnalyzePromptIncludesFileContents() {
const dirContents = {
@@ -41,5 +56,263 @@ function testExtractJsonFromResponse() {
console.log("PASS: testExtractJsonFromResponse");
}
function testExtractJsonFromResponseRepairsMissingArrayComma() {
const malformed =
'{"sceneId":"marketing-zero-consumer-report","evidence":[{"kind":"a"} {"kind":"b"}],"sceneName":"营销"}';
const result = extractJsonFromResponse(malformed);
assert.strictEqual(result.sceneId, "marketing-zero-consumer-report");
assert.strictEqual(Array.isArray(result.evidence), true);
assert.strictEqual(result.evidence.length, 2);
console.log("PASS: testExtractJsonFromResponseRepairsMissingArrayComma");
}
function testRepairCommonJsonIssuesRemovesTrailingCommas() {
const malformed =
'{\n "sceneId": "marketing-zero-consumer-report",\n "evidence": [{"kind":"a",},],\n}';
const repaired = repairCommonJsonIssues(malformed);
const parsed = JSON.parse(repaired);
assert.strictEqual(parsed.sceneId, "marketing-zero-consumer-report");
assert.strictEqual(parsed.evidence.length, 1);
console.log("PASS: testRepairCommonJsonIssuesRemovesTrailingCommas");
}
function testIsRetryableLlmErrorRecognizesTimeouts() {
assert.strictEqual(isRetryableLlmError(new Error("LLM API request timed out")), true);
assert.strictEqual(isRetryableLlmError(new Error("LLM API error 503: upstream unavailable")), true);
assert.strictEqual(isRetryableLlmError(new Error("LLM response missing sceneId")), false);
console.log("PASS: testIsRetryableLlmErrorRecognizesTimeouts");
}
function testDeterministicNamingAvoidsDegenerateSlugFallback() {
const sceneIr = buildDeterministicSceneIr(
{ deterministicSignals: {} },
"D:/tmp/营销2.0零度户报表数据生成"
);
assert.strictEqual(sceneIr.sceneId, "marketing-zero-consumer-report");
assert.strictEqual(sceneIr.sceneIdDiagnostics.valid, true);
assert.strictEqual(sceneIr.sceneIdDiagnostics.candidateSource, "deterministic_keywords");
console.log("PASS: testDeterministicNamingAvoidsDegenerateSlugFallback");
}
function testValidateSceneIdCandidateRejectsLowEntropyIds() {
const invalid = validateSceneIdCandidate("2-0", {
sceneName: "营销2.0零度户报表数据生成",
sourceDir: "D:/tmp/营销2.0零度户报表数据生成",
});
assert.strictEqual(invalid.valid, false);
assert.ok(
["numeric_only_scene_id", "numeric_dominant_scene_id", "scene_id_too_short"].includes(invalid.reason),
`unexpected invalid reason: ${invalid.reason}`
);
console.log("PASS: testValidateSceneIdCandidateRejectsLowEntropyIds");
}
function testMergeSceneIrPrefersValidSceneIdOverInvalidLlmValue() {
const deterministic = sanitizeSceneIr({
sceneId: "marketing-zero-consumer-report",
sceneIdDiagnostics: {
candidateSource: "deterministic_keywords",
valid: true,
candidates: [{ value: "marketing-zero-consumer-report", source: "deterministic_keywords", valid: true }],
},
sceneName: "营销2.0零度户报表数据生成",
bootstrap: { expectedDomain: "yx.gs.sgcc.com.cn", targetUrl: "http://yx.gs.sgcc.com.cn" },
workflowSteps: [{ type: "request" }],
apiEndpoints: [{ name: "userList", url: "http://yx.gs.sgcc.com.cn/list", method: "POST" }],
validationHints: { runtimeCompatible: true },
readiness: { level: "B" },
});
const llm = sanitizeSceneIr({
sceneId: "2-0",
sceneIdDiagnostics: {
candidateSource: "llm_semantic",
valid: false,
invalidReason: "numeric_dominant_scene_id",
candidates: [{ value: "2-0", source: "llm_semantic", valid: false, reason: "numeric_dominant_scene_id" }],
},
sceneName: "营销2.0零度户报表数据生成",
bootstrap: { expectedDomain: "yx.gs.sgcc.com.cn", targetUrl: "http://yx.gs.sgcc.com.cn" },
workflowSteps: [{ type: "request" }],
apiEndpoints: [{ name: "userList", url: "http://yx.gs.sgcc.com.cn/list", method: "POST" }],
validationHints: { runtimeCompatible: true },
readiness: { level: "B" },
});
const warnings = [];
const merged = mergeSceneIr(deterministic, llm, warnings);
assert.strictEqual(merged.sceneId, "marketing-zero-consumer-report");
assert.strictEqual(merged.sceneIdDiagnostics.valid, true);
assert.ok(warnings.some((item) => item.includes("SceneId conflict")));
console.log("PASS: testMergeSceneIrPrefersValidSceneIdOverInvalidLlmValue");
}
function testGetGenerationBlockersRejectsInvalidSceneId() {
const blockers = getGenerationBlockers({
sceneIr: {
sceneIdDiagnostics: {
valid: false,
invalidReason: "numeric_dominant_scene_id",
},
},
sceneId: "2-0",
sceneName: "营销2.0零度户报表数据生成",
sourceDir: "D:/tmp/营销2.0零度户报表数据生成",
});
assert.ok(
blockers.some((item) => item.startsWith("invalid_scene_id:")),
`expected invalid_scene_id blocker, got ${JSON.stringify(blockers)}`
);
assert.ok(blockers.includes("analysis_invalid_scene_id:numeric_dominant_scene_id"));
console.log("PASS: testGetGenerationBlockersRejectsInvalidSceneId");
}
function testBootstrapPrefersBusinessEntryOverLocalhostExport() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "sgclaw-bootstrap-"));
const sceneDir = path.join(tempRoot, "bootstrap");
fs.mkdirSync(sceneDir);
fs.writeFileSync(
path.join(sceneDir, "index.html"),
`<!doctype html><html><body><script>
const sourceUrl = "http://yx.gs.sgcc.com.cn";
const apiUrl = "http://yxgateway.gs.sgcc.com.cn/api";
function getRows() {
return $.ajax({ url: "http://yxgateway.gs.sgcc.com.cn/marketing/userList", type: "POST" });
}
function exportExcel() {
return $.ajax({ url: "http://localhost:13313/SurfaceServices/personalBread/export/faultDetailsExportXLSX", type: "POST" });
}
</script></body></html>`,
"utf8"
);
const sceneIr = readDirectory(sceneDir).deterministic;
assert.strictEqual(sceneIr.bootstrap.expectedDomain, "yx.gs.sgcc.com.cn");
assert.strictEqual(sceneIr.bootstrap.targetUrl, "http://yx.gs.sgcc.com.cn/");
console.log("PASS: testBootstrapPrefersBusinessEntryOverLocalhostExport");
}
function testBootstrapBecomesUnresolvedWhenOnlyLocalhostExists() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "sgclaw-bootstrap-local-"));
const sceneDir = path.join(tempRoot, "bootstrap-local");
fs.mkdirSync(sceneDir);
fs.writeFileSync(
path.join(sceneDir, "index.html"),
`<!doctype html><html><body><script>
function exportExcel() {
return $.ajax({ url: "http://localhost:13313/SurfaceServices/personalBread/export/faultDetailsExportXLSX", type: "POST" });
}
</script></body></html>`,
"utf8"
);
const sceneIr = readDirectory(sceneDir).deterministic;
assert.strictEqual(sceneIr.bootstrap.expectedDomain, "");
assert.strictEqual(sceneIr.bootstrap.targetUrl, "");
assert.ok(sceneIr.readiness.missingPieces.includes("bootstrap_target"));
console.log("PASS: testBootstrapBecomesUnresolvedWhenOnlyLocalhostExists");
}
function testWorkflowClassificationPrefersPaginatedOverGenericModeNoise() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "sgclaw-workflow-"));
const sceneDir = path.join(tempRoot, "workflow");
fs.mkdirSync(sceneDir);
fs.writeFileSync(
path.join(sceneDir, "index.html"),
`<!doctype html><html><body><script>
const type = "list";
const status = "ready";
async function loadData(page, pageSize) {
return $.ajax({ url: "http://yx.gs.sgcc.com.cn/marketing/userList", type: "POST", data: JSON.stringify({ page, pageSize }) });
}
async function getChargeInfo(custNo) {
return $.ajax({ url: "http://yx.gs.sgcc.com.cn/marketing/userCharges", type: "POST", data: JSON.stringify({ custNo }) });
}
function exportExcel(rows) { return rows.length; }
function run(rows) {
return rows.filter((row) => row.charge !== 0);
}
</script></body></html>`,
"utf8"
);
const sceneIr = readDirectory(sceneDir).deterministic;
assert.strictEqual(sceneIr.workflowArchetype, "paginated_enrichment");
assert.ok(sceneIr.workflowEvidence.paginationFields.length > 0);
assert.ok(sceneIr.workflowEvidence.secondaryRequestEntries.length > 0);
assert.ok(sceneIr.workflowEvidence.postProcessSteps.length > 0);
console.log("PASS: testWorkflowClassificationPrefersPaginatedOverGenericModeNoise");
}
function testWorkflowClassificationDoesNotEmitPaginatedWithoutPostProcess() {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "sgclaw-workflow-no-post-"));
const sceneDir = path.join(tempRoot, "workflow-no-post");
fs.mkdirSync(sceneDir);
fs.writeFileSync(
path.join(sceneDir, "index.html"),
`<!doctype html><html><body><script>
async function loadData(page, pageSize) {
return $.ajax({ url: "http://yx.gs.sgcc.com.cn/marketing/userList", type: "POST", data: JSON.stringify({ page, pageSize }) });
}
async function getChargeInfo(custNo) {
return $.ajax({ url: "http://yx.gs.sgcc.com.cn/marketing/userCharges", type: "POST", data: JSON.stringify({ custNo }) });
}
</script></body></html>`,
"utf8"
);
const sceneIr = readDirectory(sceneDir).deterministic;
assert.notStrictEqual(sceneIr.workflowArchetype, "paginated_enrichment");
console.log("PASS: testWorkflowClassificationDoesNotEmitPaginatedWithoutPostProcess");
}
function testGenerationBlockersIncludeFailedReadinessGates() {
const blockers = getGenerationBlockers({
sceneIr: {
readiness: {
gates: [
{ name: "bootstrap_resolved", passed: false, reason: "bootstrap_target" },
{ name: "request_contract_complete", passed: false, reason: "request_endpoint" },
{ name: "response_contract_complete", passed: false, reason: "response_path" },
{ name: "workflow_contract_complete", passed: false, reason: "post_process" },
{ name: "workflow_complete_for_archetype", passed: false, reason: "post_process" },
],
},
},
sceneId: "marketing-zero-consumer-report",
sceneName: "营销2.0零度户报表数据生成",
sourceDir: "D:/tmp/营销2.0零度户报表数据生成",
});
assert.ok(blockers.includes("gate_failed:bootstrap_resolved:bootstrap_target"));
assert.ok(blockers.includes("gate_failed:request_contract_complete:request_endpoint"));
assert.ok(blockers.includes("gate_failed:response_contract_complete:response_path"));
assert.ok(blockers.includes("gate_failed:workflow_contract_complete:post_process"));
assert.ok(blockers.includes("gate_failed:workflow_complete_for_archetype:post_process"));
console.log("PASS: testGenerationBlockersIncludeFailedReadinessGates");
}
testBuildAnalyzePromptIncludesFileContents();
testExtractJsonFromResponse();
testExtractJsonFromResponseRepairsMissingArrayComma();
testRepairCommonJsonIssuesRemovesTrailingCommas();
testIsRetryableLlmErrorRecognizesTimeouts();
testDeterministicNamingAvoidsDegenerateSlugFallback();
testValidateSceneIdCandidateRejectsLowEntropyIds();
testMergeSceneIrPrefersValidSceneIdOverInvalidLlmValue();
testGetGenerationBlockersRejectsInvalidSceneId();
testBootstrapPrefersBusinessEntryOverLocalhostExport();
testBootstrapBecomesUnresolvedWhenOnlyLocalhostExists();
testWorkflowClassificationPrefersPaginatedOverGenericModeNoise();
testWorkflowClassificationDoesNotEmitPaginatedWithoutPostProcess();
testGenerationBlockersIncludeFailedReadinessGates();

View File

@@ -31,7 +31,8 @@ fn make_test_mode(
description: None,
}),
column_defs: vec![("id".to_string(), "ID".to_string())],
request_template: serde_json::json!({}),
request_template: serde_json::json!({ "mode": name }),
request_field_mappings: Vec::new(),
normalize_rules: Some(NormalizeRulesIr {
rules_type: "validate_required".to_string(),
required_fields: vec!["id".to_string()],
@@ -43,6 +44,10 @@ fn make_test_mode(
fn make_test_scene_ir(modes: Vec<ModeIr>) -> SceneIr {
let is_multi = modes.len() > 1;
let api_endpoints = modes
.iter()
.filter_map(|mode| mode.api_endpoint.clone())
.collect::<Vec<_>>();
SceneIr {
scene_id: "test-scene".to_string(),
scene_id_diagnostics: SceneIdDiagnosticsIr::default(),
@@ -71,6 +76,13 @@ fn make_test_scene_ir(modes: Vec<ModeIr>) -> SceneIr {
},
],
workflow_evidence: Default::default(),
main_request: None,
pagination_plan: None,
enrichment_requests: Vec::new(),
join_keys: Vec::new(),
merge_or_dedupe_rules: Vec::new(),
export_plan: None,
merge_plan: None,
request_template: serde_json::Value::Null,
response_path: "".to_string(),
normalize_rules: None,
@@ -78,7 +90,8 @@ fn make_test_scene_ir(modes: Vec<ModeIr>) -> SceneIr {
validation_hints: Default::default(),
evidence: Vec::new(),
readiness: Default::default(),
api_endpoints: Vec::new(),
api_endpoints,
runtime_dependencies: Vec::new(),
static_params: Default::default(),
column_defs: Vec::new(),
confidence: 0.0,
@@ -96,9 +109,9 @@ fn temp_workspace(prefix: &str) -> PathBuf {
path
}
/// Test 1: Single mode generates MODES array (routes through compile_multi_mode_request)
/// Test 1: Single request table uses dedicated simple-request path instead of MODES fallback.
#[test]
fn test_single_mode_generates_modes_array() {
fn test_single_request_table_uses_dedicated_path() {
let output_root = temp_workspace("sgclaw-single-mode-test");
let modes = vec![make_test_mode(
"month",
@@ -108,10 +121,9 @@ fn test_single_mode_generates_modes_array() {
)];
let scene_ir = make_test_scene_ir(modes);
// Use SingleRequestTable archetype - the compile path should auto-wrap into multi-mode
// Use SingleRequestTable archetype - the compile path should stay on the dedicated single-request route.
let mut scene_ir = scene_ir;
scene_ir.workflow_archetype = Some(WorkflowArchetype::SingleRequestTable);
// Provide one api_endpoint so ensure_modes_populated works
scene_ir.api_endpoints = vec![ApiEndpointIr {
name: "default_endpoint".to_string(),
url: "http://example.com/api/data".to_string(),
@@ -138,8 +150,12 @@ fn test_single_mode_generates_modes_array() {
fs::read_to_string(skill_root.join("scripts/collect_single_mode_scene.js")).unwrap();
assert!(
generated_script.contains("const MODES ="),
"Generated JS should contain 'const MODES =' since SingleRequestTable routes through compile_multi_mode_request"
generated_script.contains("const REQUEST_TEMPLATE ="),
"Generated JS should contain REQUEST_TEMPLATE on the dedicated single-request path"
);
assert!(
!generated_script.contains("const MODES ="),
"Generated JS should no longer route SingleRequestTable through MODES fallback"
);
}
@@ -148,18 +164,8 @@ fn test_single_mode_generates_modes_array() {
fn test_multi_mode_generates_mode_routing() {
let output_root = temp_workspace("sgclaw-multi-mode-test");
let modes = vec![
make_test_mode(
"month",
"http://example.com/api/month",
None,
"data",
),
make_test_mode(
"week",
"http://example.com/api/week",
None,
"data",
),
make_test_mode("month", "http://example.com/api/month", None, "data"),
make_test_mode("week", "http://example.com/api/week", None, "data"),
];
let scene_ir = make_test_scene_ir(modes);
@@ -200,7 +206,8 @@ fn test_form_urlencoded_request_body() {
Some("application/x-www-form-urlencoded"),
"data",
)];
let scene_ir = make_test_scene_ir(modes);
let mut scene_ir = make_test_scene_ir(modes);
scene_ir.workflow_archetype = Some(WorkflowArchetype::MultiModeRequest);
generate_scene_package(GenerateSceneRequest {
source_dir: PathBuf::from("tests/fixtures/generated_scene/report_collection"),
@@ -245,7 +252,8 @@ fn test_response_path_extraction_in_template() {
None,
"data.list",
)];
let scene_ir = make_test_scene_ir(modes);
let mut scene_ir = make_test_scene_ir(modes);
scene_ir.workflow_archetype = Some(WorkflowArchetype::MultiModeRequest);
generate_scene_package(GenerateSceneRequest {
source_dir: PathBuf::from("tests/fixtures/generated_scene/report_collection"),
@@ -312,7 +320,9 @@ fn test_process_data_flag_in_ajax() {
);
// processData should be false for form-urlencoded (negated condition)
assert!(
generated_script.contains("processData: request.headers['Content-Type'] !== 'application/x-www-form-urlencoded'"),
generated_script.contains(
"processData: request.headers['Content-Type'] !== 'application/x-www-form-urlencoded'"
),
"Generated JS should set processData to false for form-urlencoded content type"
);
}

View File

@@ -0,0 +1,455 @@
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::Deserialize;
use sgclaw::generated_scene::generator::{generate_scene_package, GenerateSceneRequest};
use sgclaw::generated_scene::ir::SceneIr;
#[derive(Debug, Deserialize)]
struct P1FamilyManifest {
families: Vec<P1FamilySpec>,
}
#[derive(Debug, Deserialize)]
struct P1FamilySpec {
id: String,
group: String,
#[serde(rename = "familyName")]
family_name: String,
#[serde(rename = "representativeFixtureDir")]
representative_fixture_dir: String,
#[serde(rename = "representativeSceneId")]
representative_scene_id: String,
#[serde(rename = "representativeSceneName")]
representative_scene_name: String,
#[serde(rename = "expectedArchetype")]
expected_archetype: String,
#[serde(rename = "requiredGateNames")]
required_gate_names: Vec<String>,
#[serde(rename = "requiredEvidenceTypes")]
required_evidence_types: Vec<String>,
#[serde(rename = "expansionFixtureDir", default)]
expansion_fixture_dir: Option<String>,
#[serde(rename = "expansionSceneId", default)]
expansion_scene_id: Option<String>,
#[serde(rename = "expansionSceneName", default)]
expansion_scene_name: Option<String>,
#[serde(rename = "expansionAssertions", default)]
expansion_assertions: Option<ExpansionAssertions>,
#[serde(rename = "batchCandidateAsset", default)]
batch_candidate_asset: Option<String>,
#[serde(rename = "batchExpansionFixtures", default)]
batch_expansion_fixtures: Vec<BatchExpansionFixture>,
#[serde(rename = "successRateSummary")]
success_rate_summary: String,
#[serde(rename = "failureTaxonomy")]
failure_taxonomy: Vec<String>,
}
#[derive(Debug, Deserialize, Default)]
struct ExpansionAssertions {
#[serde(rename = "requiredDefaultMode", default)]
required_default_mode: Option<String>,
#[serde(rename = "expectedPaginationField", default)]
expected_pagination_field: Option<String>,
#[serde(rename = "requiredJoinKey", default)]
required_join_key: Option<String>,
#[serde(rename = "requiredAggregateRule", default)]
required_aggregate_rule: Option<String>,
#[serde(rename = "requiredMainRequest", default)]
required_main_request: Option<String>,
#[serde(rename = "requiredEnrichmentRequest", default)]
required_enrichment_request: Option<String>,
#[serde(rename = "requiredMergeJoinKey", default)]
required_merge_join_key: Option<String>,
#[serde(rename = "requiredMergeAggregateRule", default)]
required_merge_aggregate_rule: Option<String>,
#[serde(rename = "requiredOutputColumn", default)]
required_output_column: Option<String>,
}
#[derive(Debug, Deserialize)]
struct BatchExpansionFixture {
#[serde(rename = "fixtureDir")]
fixture_dir: String,
#[serde(rename = "sceneId")]
scene_id: String,
#[serde(rename = "sceneName")]
scene_name: String,
assertions: ExpansionAssertions,
}
#[test]
fn p1_family_manifest_is_actionable() {
let manifest = load_manifest();
assert_eq!(manifest.families.len(), 7);
for family in manifest.families {
assert!(matches!(
family.group.as_str(),
"G1" | "G2" | "G3" | "G6" | "G7" | "G8"
));
assert!(!family.family_name.trim().is_empty());
assert!(Path::new(&family.representative_fixture_dir).exists());
assert!(!family.expected_archetype.trim().is_empty());
assert!(!family.required_gate_names.is_empty());
assert!(!family.required_evidence_types.is_empty());
assert!(!family.success_rate_summary.trim().is_empty());
assert!(!family.failure_taxonomy.is_empty());
if let Some(expansion_fixture_dir) = &family.expansion_fixture_dir {
assert!(Path::new(expansion_fixture_dir).exists());
assert!(!family
.expansion_scene_id
.as_deref()
.unwrap_or_default()
.is_empty());
assert!(!family
.expansion_scene_name
.as_deref()
.unwrap_or_default()
.is_empty());
}
if let Some(batch_candidate_asset) = &family.batch_candidate_asset {
assert!(Path::new(batch_candidate_asset).exists());
}
for fixture in &family.batch_expansion_fixtures {
assert!(Path::new(&fixture.fixture_dir).exists());
assert!(!fixture.scene_id.is_empty());
assert!(!fixture.scene_name.is_empty());
}
}
}
#[test]
fn representative_p1_family_migrations_are_reusable() {
let manifest = load_manifest();
for family in manifest.families {
let output_root = temp_workspace(&format!("sgclaw-p1-family-{}", family.id));
generate_scene_package(GenerateSceneRequest {
source_dir: PathBuf::from(&family.representative_fixture_dir),
scene_id: family.representative_scene_id.clone(),
scene_name: family.representative_scene_name.clone(),
scene_kind: None,
target_url: None,
output_root: output_root.clone(),
lessons_path: None,
scene_info_json: None,
scene_ir_json: None,
})
.unwrap_or_else(|err| panic!("{} failed representative migration: {}", family.id, err));
let generated_dir = output_root
.join("skills")
.join(&family.representative_scene_id);
let generated_report: SceneIr = serde_json::from_str(
&fs::read_to_string(generated_dir.join("references/generation-report.json")).unwrap(),
)
.unwrap();
assert_eq!(
generated_report.workflow_archetype().as_str(),
family.expected_archetype,
"expected archetype mismatch for {}",
family.id
);
for gate_name in &family.required_gate_names {
assert!(
generated_report
.readiness
.gates
.iter()
.any(|gate| gate.name == *gate_name),
"missing gate {} for {}",
gate_name,
family.id
);
}
for evidence_type in &family.required_evidence_types {
assert!(
generated_report
.evidence
.iter()
.any(|item| item.evidence_type == *evidence_type),
"missing evidence type {} for {}",
evidence_type,
family.id
);
}
assert!(
generated_report.readiness.level == "A" || generated_report.readiness.level == "B",
"representative migration should be reusable for {}",
family.id
);
if let (Some(expansion_fixture_dir), Some(expansion_scene_id), Some(expansion_scene_name)) = (
&family.expansion_fixture_dir,
&family.expansion_scene_id,
&family.expansion_scene_name,
) {
let expansion_output_root =
temp_workspace(&format!("sgclaw-p1-family-expansion-{}", family.id));
generate_scene_package(GenerateSceneRequest {
source_dir: PathBuf::from(expansion_fixture_dir),
scene_id: expansion_scene_id.clone(),
scene_name: expansion_scene_name.clone(),
scene_kind: None,
target_url: None,
output_root: expansion_output_root.clone(),
lessons_path: None,
scene_info_json: None,
scene_ir_json: None,
})
.unwrap_or_else(|err| panic!("{} failed expansion migration: {}", family.id, err));
let expansion_dir = expansion_output_root
.join("skills")
.join(expansion_scene_id);
let expansion_report: SceneIr = serde_json::from_str(
&fs::read_to_string(expansion_dir.join("references/generation-report.json"))
.unwrap(),
)
.unwrap();
assert_eq!(
expansion_report.workflow_archetype().as_str(),
family.expected_archetype,
"expected expansion archetype mismatch for {}",
family.id
);
assert!(
expansion_report.readiness.level == "A" || expansion_report.readiness.level == "B",
"expansion migration should be reusable for {}",
family.id
);
if let Some(assertions) = &family.expansion_assertions {
if let Some(required_default_mode) = &assertions.required_default_mode {
assert_eq!(
expansion_report.default_mode.as_deref(),
Some(required_default_mode.as_str()),
"missing expansion default mode {} for {}",
required_default_mode,
family.id
);
}
if let Some(expected_pagination_field) = &assertions.expected_pagination_field {
assert_eq!(
expansion_report
.pagination_plan
.as_ref()
.map(|plan| plan.page_field.as_str()),
Some(expected_pagination_field.as_str()),
"expansion pagination field mismatch for {}",
family.id
);
}
if let Some(required_join_key) = &assertions.required_join_key {
assert!(
expansion_report
.join_keys
.iter()
.any(|key| key == required_join_key),
"missing expansion join key {} for {}",
required_join_key,
family.id
);
}
if let Some(required_aggregate_rule) = &assertions.required_aggregate_rule {
assert!(
expansion_report
.merge_or_dedupe_rules
.iter()
.any(|rule| rule == required_aggregate_rule),
"missing expansion aggregate rule {} for {}",
required_aggregate_rule,
family.id
);
}
if let Some(required_main_request) = &assertions.required_main_request {
assert!(
expansion_report
.main_request
.as_ref()
.and_then(|request| request.api_endpoint.as_ref())
.map(|endpoint| endpoint.name.contains(required_main_request))
.unwrap_or(false),
"missing expansion main request {} for {}",
required_main_request,
family.id
);
}
if let Some(required_enrichment_request) = &assertions.required_enrichment_request {
assert!(
expansion_report
.enrichment_requests
.iter()
.any(|request| request.name.contains(required_enrichment_request)),
"missing expansion enrichment request {} for {}",
required_enrichment_request,
family.id
);
}
if let Some(required_merge_join_key) = &assertions.required_merge_join_key {
assert!(
expansion_report
.merge_plan
.as_ref()
.map(|plan| {
plan.join_keys
.iter()
.any(|key| key == required_merge_join_key)
})
.unwrap_or(false),
"missing expansion merge join key {} for {}",
required_merge_join_key,
family.id
);
}
if let Some(required_merge_aggregate_rule) =
&assertions.required_merge_aggregate_rule
{
assert!(
expansion_report
.merge_plan
.as_ref()
.map(|plan| {
plan.aggregate_rules
.iter()
.any(|rule| rule == required_merge_aggregate_rule)
})
.unwrap_or(false),
"missing expansion merge aggregate rule {} for {}",
required_merge_aggregate_rule,
family.id
);
}
if let Some(required_output_column) = &assertions.required_output_column {
assert!(
expansion_report
.merge_plan
.as_ref()
.map(|plan| {
plan.output_columns
.iter()
.any(|(field, _)| field == required_output_column)
})
.unwrap_or(false),
"missing expansion output column {} for {}",
required_output_column,
family.id
);
}
}
}
for batch_fixture in &family.batch_expansion_fixtures {
let batch_output_root = temp_workspace(&format!(
"sgclaw-p1-family-batch-{}-{}",
family.id, batch_fixture.scene_id
));
generate_scene_package(GenerateSceneRequest {
source_dir: PathBuf::from(&batch_fixture.fixture_dir),
scene_id: batch_fixture.scene_id.clone(),
scene_name: batch_fixture.scene_name.clone(),
scene_kind: None,
target_url: None,
output_root: batch_output_root.clone(),
lessons_path: None,
scene_info_json: None,
scene_ir_json: None,
})
.unwrap_or_else(|err| {
panic!("{} failed batch expansion migration: {}", family.id, err)
});
let batch_dir = batch_output_root
.join("skills")
.join(&batch_fixture.scene_id);
let batch_report: SceneIr = serde_json::from_str(
&fs::read_to_string(batch_dir.join("references/generation-report.json")).unwrap(),
)
.unwrap();
assert_eq!(
batch_report.workflow_archetype().as_str(),
family.expected_archetype,
"expected batch expansion archetype mismatch for {}",
family.id
);
assert!(
batch_report.readiness.level == "A" || batch_report.readiness.level == "B",
"batch expansion migration should be reusable for {}",
family.id
);
if let Some(required_default_mode) = &batch_fixture.assertions.required_default_mode {
assert_eq!(
batch_report.default_mode.as_deref(),
Some(required_default_mode.as_str()),
"missing batch expansion default mode {} for {}",
required_default_mode,
family.id
);
}
if let Some(expected_pagination_field) =
&batch_fixture.assertions.expected_pagination_field
{
assert_eq!(
batch_report
.pagination_plan
.as_ref()
.map(|plan| plan.page_field.as_str()),
Some(expected_pagination_field.as_str()),
"batch expansion pagination field mismatch for {}",
family.id
);
}
if let Some(required_join_key) = &batch_fixture.assertions.required_join_key {
assert!(
batch_report
.join_keys
.iter()
.any(|key| key == required_join_key),
"missing batch expansion join key {} for {}",
required_join_key,
family.id
);
}
if let Some(required_aggregate_rule) = &batch_fixture.assertions.required_aggregate_rule
{
assert!(
batch_report
.merge_or_dedupe_rules
.iter()
.any(|rule| rule == required_aggregate_rule),
"missing batch expansion aggregate rule {} for {}",
required_aggregate_rule,
family.id
);
}
}
}
}
fn load_manifest() -> P1FamilyManifest {
serde_json::from_str(
&fs::read_to_string("tests/fixtures/generated_scene/p1_family_manifest.json").unwrap(),
)
.unwrap()
}
fn temp_workspace(prefix: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let path = std::env::temp_dir().join(format!("{prefix}-{nanos}"));
fs::create_dir_all(&path).unwrap();
path
}

View File

@@ -13,11 +13,7 @@ fn scene_generator_server_files_exist() {
.join("scene-generator")
.join("config-loader.js");
assert!(
server_js.exists(),
"server.js not found at {:?}",
server_js
);
assert!(server_js.exists(), "server.js not found at {:?}", server_js);
assert!(
config_loader.exists(),
"config-loader.js not found at {:?}",
@@ -31,8 +27,7 @@ fn sgclaw_config_is_readable() {
let config_path = manifest_dir.join("sgclaw_config.json");
let content = fs::read_to_string(&config_path)
.unwrap_or_else(|err| panic!("sgclaw_config.json not found: {}", err));
let parsed: serde_json::Value =
serde_json::from_str(&content).expect("should be valid JSON");
let parsed: serde_json::Value = serde_json::from_str(&content).expect("should be valid JSON");
assert!(parsed.get("apiKey").is_some(), "missing apiKey");
assert!(parsed.get("baseUrl").is_some(), "missing baseUrl");
assert!(parsed.get("model").is_some(), "missing model");

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,138 @@
use std::fs;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct SceneLedgerSnapshot {
#[serde(rename = "snapshotDate")]
snapshot_date: String,
#[serde(rename = "sourceWorkbook")]
source_workbook: String,
#[serde(rename = "generatedAt")]
generated_at: String,
summary: LedgerSummary,
#[serde(rename = "groupingResultCounts")]
grouping_result_counts: std::collections::BTreeMap<String, u32>,
#[serde(rename = "familyJudgementCounts")]
family_judgement_counts: std::collections::BTreeMap<String, u32>,
#[serde(rename = "validatedEntries")]
validated_entries: Vec<ValidatedEntry>,
#[serde(rename = "alignmentNotes")]
alignment_notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct LedgerSummary {
#[serde(rename = "totalScenes")]
total_scenes: u32,
#[serde(rename = "validatedScenes")]
validated_scenes: u32,
#[serde(rename = "notYetValidatedScenes")]
not_yet_validated_scenes: u32,
#[serde(rename = "passedRealSample")]
passed_real_sample: u32,
#[serde(rename = "failedRealSample")]
failed_real_sample: u32,
#[serde(rename = "failClosedScenes")]
fail_closed_scenes: u32,
#[serde(rename = "reassignedBoundaryScenes")]
reassigned_boundary_scenes: u32,
}
#[derive(Debug, Deserialize)]
struct ValidatedEntry {
#[serde(rename = "sceneName")]
scene_name: String,
#[serde(rename = "targetFamily")]
target_family: String,
#[serde(rename = "groupingResult")]
grouping_result: String,
#[serde(rename = "validationStatus")]
validation_status: String,
#[serde(rename = "validationResult")]
validation_result: String,
}
#[test]
fn scene_ledger_snapshot_is_actionable() {
let snapshot = load_snapshot();
assert_eq!(snapshot.snapshot_date, "2026-04-18");
assert!(snapshot.source_workbook.ends_with(".xlsx"));
assert_eq!(snapshot.generated_at, "2026-04-18 16:48:05");
assert_eq!(snapshot.summary.total_scenes, 102);
assert_eq!(
snapshot.summary.validated_scenes + snapshot.summary.not_yet_validated_scenes,
snapshot.summary.total_scenes
);
assert_eq!(snapshot.summary.passed_real_sample, 1);
assert_eq!(snapshot.summary.failed_real_sample, 3);
assert_eq!(snapshot.summary.fail_closed_scenes, 2);
assert_eq!(snapshot.summary.reassigned_boundary_scenes, 3);
assert_eq!(
snapshot.validated_entries.len() as u32,
snapshot.summary.validated_scenes
);
assert!(snapshot
.grouping_result_counts
.contains_key("95598/工单家族候选"));
assert!(snapshot.family_judgement_counts.contains_key("待分组"));
assert!(!snapshot.alignment_notes.is_empty());
}
#[test]
fn scene_ledger_snapshot_matches_current_family_asset_baseline() {
let snapshot = load_snapshot();
let has_g1e_pass = snapshot.validated_entries.iter().any(|item| {
item.scene_name == "高低压新增报装容量月度统计表"
&& item.target_family == "G1-E"
&& item.validation_result == "通过"
});
assert!(has_g1e_pass);
let has_g6_boundary = snapshot.validated_entries.iter().any(|item| {
item.scene_name == "电能表现场检验完成率指标报表"
&& item.target_family == "G6"
&& item.validation_result == "已重分组"
});
assert!(has_g6_boundary);
let has_g7_boundary = snapshot.validated_entries.iter().any(|item| {
item.scene_name == "计量资产库存统计"
&& item.target_family == "G7"
&& item.validation_result == "已重分组"
});
assert!(has_g7_boundary);
let has_g8_boundary = snapshot.validated_entries.iter().any(|item| {
item.scene_name == "95598供电服务月报"
&& item.target_family == "G8"
&& item.validation_result == "已重分组"
});
assert!(has_g8_boundary);
let g2_validated = snapshot
.validated_entries
.iter()
.filter(|item| item.target_family == "G2")
.count();
assert_eq!(g2_validated, 3);
let g1e_fail_closed = snapshot
.validated_entries
.iter()
.filter(|item| {
item.target_family.contains("G1-E") && item.validation_result == "Fail-closed"
})
.count();
assert_eq!(g1e_fail_closed, 2);
}
fn load_snapshot() -> SceneLedgerSnapshot {
serde_json::from_str(
&fs::read_to_string("tests/fixtures/generated_scene/scene_ledger_snapshot_2026-04-18.json")
.unwrap(),
)
.unwrap()
}

View File

@@ -0,0 +1,210 @@
use std::fs;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct LedgerStatusOverlay {
#[serde(rename = "statusDate")]
status_date: String,
#[serde(rename = "derivedFrom")]
derived_from: DerivedFrom,
scope: String,
#[serde(rename = "mainlineStatus")]
mainline_status: Vec<GroupStatus>,
#[serde(rename = "boundaryRuntimeStatus")]
boundary_runtime_status: Vec<BoundaryStatus>,
#[serde(rename = "ledgerOverlayEntries")]
ledger_overlay_entries: Vec<LedgerOverlayEntry>,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct DerivedFrom {
snapshot: String,
#[serde(rename = "familyManifest")]
family_manifest: String,
#[serde(rename = "familyResults")]
family_results: String,
#[serde(rename = "g3BatchAsset")]
g3_batch_asset: String,
}
#[derive(Debug, Deserialize)]
struct GroupStatus {
group: String,
status: String,
#[serde(rename = "representativeBaseline")]
representative_baseline: String,
#[serde(rename = "promotedExpansions")]
promoted_expansions: u32,
#[serde(rename = "candidateQueueCount")]
candidate_queue_count: u32,
notes: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct BoundaryStatus {
group: String,
status: String,
}
#[derive(Debug, Deserialize)]
struct LedgerOverlayEntry {
#[serde(rename = "sceneKey")]
scene_key: String,
group: String,
#[serde(rename = "overlayStatus")]
overlay_status: String,
#[serde(rename = "sourceAsset")]
source_asset: String,
}
#[test]
fn scene_ledger_status_overlay_is_actionable() {
let overlay = load_overlay();
assert_eq!(overlay.status_date, "2026-04-18");
assert_eq!(overlay.scope, "roadmap-track-e-current-status-overlay");
assert!(overlay.derived_from.snapshot.ends_with(".json"));
assert!(overlay.derived_from.family_manifest.ends_with(".json"));
assert!(overlay.derived_from.family_results.ends_with(".json"));
assert!(overlay.derived_from.g3_batch_asset.ends_with(".json"));
assert_eq!(overlay.mainline_status.len(), 3);
assert_eq!(overlay.boundary_runtime_status.len(), 3);
assert!(!overlay.ledger_overlay_entries.is_empty());
assert!(!overlay.notes.is_empty());
}
#[test]
fn scene_ledger_status_overlay_matches_current_mainline_family_progress() {
let overlay = load_overlay();
let g2 = overlay
.mainline_status
.iter()
.find(|item| item.group == "G2")
.expect("expected G2 overlay status");
assert_eq!(g2.status, "batch-expansion-promoted");
assert!(g2.representative_baseline.ends_with("multi_mode"));
assert_eq!(g2.promoted_expansions, 5);
assert_eq!(g2.candidate_queue_count, 0);
let g1e = overlay
.mainline_status
.iter()
.find(|item| item.group == "G1-E")
.expect("expected G1-E overlay status");
assert_eq!(g1e.status, "batch-expansion-promoted");
assert!(g1e
.representative_baseline
.ends_with("g1e_light_enrichment"));
assert_eq!(g1e.promoted_expansions, 2);
assert_eq!(g1e.candidate_queue_count, 0);
let g3 = overlay
.mainline_status
.iter()
.find(|item| item.group == "G3")
.expect("expected G3 overlay status");
assert_eq!(g3.status, "batch-expansion-promoted");
assert!(g3.representative_baseline.ends_with("paginated_enrichment"));
assert_eq!(g3.promoted_expansions, 10);
assert_eq!(g3.candidate_queue_count, 0);
assert!(!g3.notes.is_empty());
}
#[test]
fn scene_ledger_status_overlay_keeps_promoted_entries_visible() {
let overlay = load_overlay();
assert!(overlay.boundary_runtime_status.iter().all(|item| {
matches!(item.group.as_str(), "G6" | "G7" | "G8")
&& item.status == "boundary-family-established"
}));
assert!(overlay.ledger_overlay_entries.iter().any(|item| {
item.scene_key == "tq_lineloss_report"
&& item.group == "G2"
&& item.overlay_status == "promoted-baseline"
}));
assert!(overlay.ledger_overlay_entries.iter().any(|item| {
item.scene_key == "zero_consumer_crosscheck"
&& item.group == "G2"
&& item.overlay_status == "promoted-expansion"
}));
assert!(overlay.ledger_overlay_entries.iter().any(|item| {
item.scene_key == "predicted_compute_variant"
&& item.group == "G2"
&& item.overlay_status == "promoted-expansion"
}));
assert!(overlay.ledger_overlay_entries.iter().any(|item| {
item.scene_key == "high_low_voltage_new_capacity_monthly"
&& item.group == "G1-E"
&& item.overlay_status == "promoted-baseline"
}));
assert!(overlay.ledger_overlay_entries.iter().any(|item| {
item.scene_key == "light_enrichment_second_sample"
&& item.group == "G1-E"
&& item.overlay_status == "promoted-expansion"
}));
assert!(overlay.ledger_overlay_entries.iter().any(|item| {
item.scene_key == "light_enrichment_additional_real_sample"
&& item.group == "G1-E"
&& item.overlay_status == "promoted-expansion"
}));
assert!(overlay.ledger_overlay_entries.iter().any(|item| {
item.scene_key == "95598_ticket_detail"
&& item.group == "G3"
&& item.overlay_status == "promoted-baseline"
}));
assert!(overlay.ledger_overlay_entries.iter().any(|item| {
item.scene_key == "95598_ticket_12398_process_timeout_detail"
&& item.group == "G3"
&& item.overlay_status == "promoted-expansion"
}));
assert!(overlay.ledger_overlay_entries.iter().any(|item| {
item.scene_key == "ticket_source_distribution_analysis"
&& item.group == "G3"
&& item.overlay_status == "promoted-expansion"
}));
assert!(overlay.ledger_overlay_entries.iter().any(|item| {
item.scene_key == "ticket_timeout_warning_detail"
&& item.group == "G3"
&& item.overlay_status == "promoted-expansion"
}));
assert!(overlay.ledger_overlay_entries.iter().any(|item| {
item.scene_key == "95598_ticket_12398_device_monitor_weekly"
&& item.group == "G3"
&& item.overlay_status == "promoted-expansion"
}));
assert!(overlay.ledger_overlay_entries.iter().any(|item| {
item.scene_key == "95598_ticket_customer_satisfaction_daily"
&& item.group == "G3"
&& item.overlay_status == "promoted-expansion"
}));
assert!(overlay.ledger_overlay_entries.iter().any(|item| {
item.scene_key == "95598_ticket_repair_return_analysis"
&& item.group == "G3"
&& item.overlay_status == "promoted-expansion"
}));
assert!(overlay.ledger_overlay_entries.iter().any(|item| {
item.scene_key == "95598_ticket_repair_daily_control"
&& item.group == "G3"
&& item.overlay_status == "promoted-expansion"
}));
assert!(overlay.ledger_overlay_entries.iter().any(|item| {
item.scene_key == "power_supply_service_ticket_business_stats"
&& item.group == "G3"
&& item.overlay_status == "promoted-expansion"
}));
assert!(overlay.ledger_overlay_entries.iter().all(|item| item
.source_asset
.starts_with("tests/fixtures/generated_scene/")));
}
fn load_overlay() -> LedgerStatusOverlay {
serde_json::from_str(
&fs::read_to_string("tests/fixtures/generated_scene/scene_ledger_status_2026-04-18.json")
.unwrap(),
)
.unwrap()
}

View File

@@ -0,0 +1,248 @@
use std::fs;
use std::path::{Path, PathBuf};
use sgclaw::compat::scene_platform::registry::{load_scene_registry, SceneRegistryError};
use uuid::Uuid;
fn temp_root(prefix: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!("{prefix}-{}", Uuid::new_v4()));
fs::create_dir_all(&root).unwrap();
root
}
fn write_skill(
root: &Path,
skill_name: &str,
skill_toml: &str,
scene_toml: Option<&str>,
) -> PathBuf {
let skill_root = root.join(skill_name);
fs::create_dir_all(&skill_root).unwrap();
fs::write(skill_root.join("SKILL.toml"), skill_toml).unwrap();
if let Some(scene_toml) = scene_toml {
fs::write(skill_root.join("scene.toml"), scene_toml).unwrap();
}
skill_root
}
fn browser_script_skill_toml(skill_name: &str, tool_name: &str, tool_kind: &str) -> String {
format!(
r#"[skill]
name = "{skill_name}"
description = "test skill"
version = "0.1.0"
[[tools]]
name = "{tool_name}"
description = "test tool"
kind = "{tool_kind}"
command = "scripts/{tool_name}.js"
"#
)
}
fn scene_toml(
scene_id: &str,
skill_name: &str,
tool_name: &str,
schema_version: &str,
kind: &str,
) -> String {
format!(
r#"[scene]
id = "{scene_id}"
skill = "{skill_name}"
tool = "{tool_name}"
kind = "{kind}"
version = "0.1.0"
category = "report_collection"
[manifest]
schema_version = "{schema_version}"
[bootstrap]
expected_domain = "20.76.57.61"
target_url = "http://20.76.57.61:18080/gsllys/tqLinelossStatis/tqQualifyRateMonitor"
requires_target_page = true
[deterministic]
suffix = "。。。"
include_keywords = ["线损"]
exclude_keywords = ["知乎"]
[[params]]
name = "org"
resolver = "dictionary_entity"
required = true
prompt_missing = "缺少供电单位"
prompt_ambiguous = "供电单位存在歧义"
[params.resolver_config]
dictionary_ref = "references/org-dictionary.json"
output_label_field = "org_label"
output_code_field = "org_code"
[artifact]
type = "report-artifact"
success_status = ["ok", "partial", "empty"]
failure_status = ["blocked", "error"]
"#
)
}
#[test]
fn registry_loads_scene_manifest_from_skill_root() {
let skills_root = temp_root("sgclaw-scene-registry");
write_skill(
&skills_root,
"tq-lineloss-report",
&browser_script_skill_toml("tq-lineloss-report", "collect_lineloss", "browser_script"),
Some(&scene_toml(
"tq-lineloss-report",
"tq-lineloss-report",
"collect_lineloss",
"1",
"browser_script",
)),
);
let registry = load_scene_registry(&skills_root).unwrap();
assert_eq!(registry.len(), 1);
assert_eq!(registry[0].manifest.scene.id, "tq-lineloss-report");
assert_eq!(
registry[0].skill_root,
skills_root.join("tq-lineloss-report")
);
assert!(registry[0].skill_root.join("scene.toml").exists());
assert!(!registry[0]
.skill_root
.to_string_lossy()
.contains("skill_staging/scenes"));
}
#[test]
fn registry_rejects_duplicate_scene_ids_with_both_paths_in_error() {
let skills_root = temp_root("sgclaw-scene-registry-dup");
let first = write_skill(
&skills_root,
"skill-a",
&browser_script_skill_toml("skill-a", "collect_a", "browser_script"),
Some(&scene_toml(
"duplicate-scene",
"skill-a",
"collect_a",
"1",
"browser_script",
)),
);
let second = write_skill(
&skills_root,
"skill-b",
&browser_script_skill_toml("skill-b", "collect_b", "browser_script"),
Some(&scene_toml(
"duplicate-scene",
"skill-b",
"collect_b",
"1",
"browser_script",
)),
);
let err = load_scene_registry(&skills_root).expect_err("duplicate scene ids should fail");
let message = err.to_string();
assert!(matches!(err, SceneRegistryError::DuplicateSceneId { .. }));
assert!(message.contains(&first.join("scene.toml").display().to_string()));
assert!(message.contains(&second.join("scene.toml").display().to_string()));
}
#[test]
fn registry_rejects_unknown_manifest_schema_version() {
let skills_root = temp_root("sgclaw-scene-registry-schema");
write_skill(
&skills_root,
"tq-lineloss-report",
&browser_script_skill_toml("tq-lineloss-report", "collect_lineloss", "browser_script"),
Some(&scene_toml(
"tq-lineloss-report",
"tq-lineloss-report",
"collect_lineloss",
"999",
"browser_script",
)),
);
let err = load_scene_registry(&skills_root).expect_err("unknown schema version should fail");
let message = err.to_string();
assert!(matches!(
err,
SceneRegistryError::UnsupportedSchemaVersion { .. }
));
assert!(message.contains("999"));
}
#[test]
fn registry_rejects_non_browser_script_scene_tool_in_v1() {
let skills_root = temp_root("sgclaw-scene-registry-kind");
write_skill(
&skills_root,
"shell-scene",
&browser_script_skill_toml("shell-scene", "collect_shell", "shell"),
Some(&scene_toml(
"shell-scene",
"shell-scene",
"collect_shell",
"1",
"shell",
)),
);
let err =
load_scene_registry(&skills_root).expect_err("non browser_script scenes should fail in v1");
let message = err.to_string();
assert!(matches!(
err,
SceneRegistryError::UnsupportedSceneKind { .. }
));
assert!(message.contains("browser_script"));
}
#[test]
fn committed_lineloss_sample_package_is_registration_ready() {
let skills_root = Path::new("examples/generated_scene_platform/skills");
let registry = load_scene_registry(skills_root).unwrap();
let entry = registry
.iter()
.find(|entry| entry.manifest.scene.id == "tq-lineloss-report")
.expect("committed line-loss sample package should be registered");
assert_eq!(entry.manifest.scene.skill, "tq-lineloss-report");
assert_eq!(entry.manifest.scene.tool, "collect_lineloss");
assert_eq!(entry.manifest.scene.kind, "browser_script");
assert_eq!(entry.manifest.scene.category, "report_collection");
assert_eq!(entry.manifest.bootstrap.expected_domain, "20.76.57.61");
assert_eq!(
entry.manifest.bootstrap.target_url,
"http://20.76.57.61:18080/gsllys/tqLinelossStatis/tqQualifyRateMonitor"
);
assert!(entry
.manifest
.deterministic
.include_keywords
.iter()
.any(|keyword| keyword == "统计分析"));
assert!(entry
.skill_root
.join("references")
.join("org-dictionary.json")
.exists());
assert!(entry
.skill_root
.join("scripts")
.join("collect_lineloss.js")
.exists());
}

View File

@@ -34,4 +34,6 @@ fn service_console_html_stays_on_service_ws_boundary() {
assert!(source.contains("settingApiKey"));
assert!(source.contains("settingBaseUrl"));
assert!(source.contains("settingModel"));
assert!(source.contains("pageUrlInput"));
assert!(source.contains("pageTitleInput"));
}

View File

@@ -12,7 +12,7 @@ use tungstenite::{accept, Message};
const RUNTIME_DROP_PANIC_TEXT: &str =
"Cannot drop a runtime in a context where blocking is not allowed";
const TEST_ZHIHU_SKILLS_DIR: &str = "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills";
const TEST_ZHIHU_SKILLS_DIR: &str = "D:/data/ideaSpace/rust/sgClaw/claw/claw/skills";
fn read_ws_text(stream: &mut tungstenite::WebSocket<std::net::TcpStream>) -> String {
match stream.read().unwrap() {
@@ -117,8 +117,12 @@ fn start_callback_host_hotlist_browser_server(
let handle = thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut websocket = accept(stream).unwrap();
let register = match websocket.read().unwrap() {
@@ -149,7 +153,9 @@ fn start_callback_host_hotlist_browser_server(
other => panic!("expected second browser action frame, got {other:?}"),
};
event_tx
.send(CallbackHostBrowserEvent::BrowserFrame(second_action.clone()))
.send(CallbackHostBrowserEvent::BrowserFrame(
second_action.clone(),
))
.unwrap();
let Some(close_values) = first_action.as_array() else {
@@ -328,7 +334,8 @@ fn start_callback_host_hotlist_browser_server(
(format!("ws://{address}"), handle)
}
fn start_direct_zhihu_browser_ws_server() -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHandle<()>) {
fn start_direct_zhihu_browser_ws_server(
) -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let frames = Arc::new(Mutex::new(Vec::new()));
@@ -336,8 +343,12 @@ fn start_direct_zhihu_browser_ws_server() -> (String, Arc<Mutex<Vec<String>>>, t
let handle = thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
stream.set_write_timeout(Some(Duration::from_secs(5))).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(5)))
.unwrap();
let mut socket = accept(stream).unwrap();
let mut action_count = 0_u64;
@@ -364,7 +375,9 @@ fn start_direct_zhihu_browser_ws_server() -> (String, Arc<Mutex<Vec<String>>>, t
continue;
}
let values = parsed.as_array().expect("browser action frame should be an array");
let values = parsed
.as_array()
.expect("browser action frame should be an array");
let request_url = values[0].as_str().expect("request_url should be a string");
let action = values[1].as_str().expect("action should be a string");
action_count += 1;
@@ -380,7 +393,9 @@ fn start_direct_zhihu_browser_ws_server() -> (String, Arc<Mutex<Vec<String>>>, t
let callback_frame = match action {
"sgHideBrowserCallAfterLoaded" => {
let target_url = values[2].as_str().expect("navigate target_url should be a string");
let target_url = values[2]
.as_str()
.expect("navigate target_url should be a string");
json!([
request_url,
"callBackJsToCpp",
@@ -390,7 +405,9 @@ fn start_direct_zhihu_browser_ws_server() -> (String, Arc<Mutex<Vec<String>>>, t
])
}
"sgBrowserExcuteJsCodeByArea" => {
let target_url = values[2].as_str().expect("script target_url should be a string");
let target_url = values[2]
.as_str()
.expect("script target_url should be a string");
let response_text = if action_count == 2 {
"知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度".to_string()
} else {
@@ -534,7 +551,10 @@ fn client_sends_connect_request_and_exits_after_status() {
assert!(output.status.success());
assert_eq!(request, ClientMessage::Connect);
let stdout = String::from_utf8(output.stdout).unwrap();
assert_eq!(stdout.lines().collect::<Vec<_>>(), vec!["status: connected"]);
assert_eq!(
stdout.lines().collect::<Vec<_>>(),
vec!["status: connected"]
);
}
#[test]
@@ -603,7 +623,10 @@ fn client_prints_completion_only_once() {
let mut websocket = accept(stream).unwrap();
let payload = read_ws_text(&mut websocket);
let request: ClientMessage = serde_json::from_str(&payload).unwrap();
assert_eq!(request.into_submit_task_request().unwrap().instruction, "打开百度搜索天气");
assert_eq!(
request.into_submit_task_request().unwrap().instruction,
"打开百度搜索天气"
);
websocket
.send(Message::Text(
@@ -663,7 +686,10 @@ fn client_prints_log_entries_in_order_before_completion() {
let mut websocket = accept(stream).unwrap();
let payload = read_ws_text(&mut websocket);
let request: ClientMessage = serde_json::from_str(&payload).unwrap();
assert_eq!(request.into_submit_task_request().unwrap().instruction, "打开百度搜索天气");
assert_eq!(
request.into_submit_task_request().unwrap().instruction,
"打开百度搜索天气"
);
for message in [
ServiceMessage::LogEntry {
@@ -680,7 +706,9 @@ fn client_prints_log_entries_in_order_before_completion() {
},
] {
websocket
.send(Message::Text(serde_json::to_string(&message).unwrap().into()))
.send(Message::Text(
serde_json::to_string(&message).unwrap().into(),
))
.unwrap();
}
websocket.close(None).unwrap();
@@ -758,11 +786,15 @@ fn client_exits_with_failure_when_service_disconnects_before_completion() {
assert!(!status.success());
let request = server.join().unwrap();
assert_eq!(request.into_submit_task_request().unwrap().instruction, "打开百度搜索天气");
assert_eq!(
request.into_submit_task_request().unwrap().instruction,
"打开百度搜索天气"
);
}
#[test]
fn client_to_service_regression_routes_zhihu_through_callback_host_without_invalid_hmac_seed_output() {
fn client_to_service_regression_routes_zhihu_through_callback_host_without_invalid_hmac_seed_output(
) {
let service_listener = TcpListener::bind("127.0.0.1:0").unwrap();
let service_addr = service_listener.local_addr().unwrap();
drop(service_listener);
@@ -770,7 +802,8 @@ fn client_to_service_regression_routes_zhihu_through_callback_host_without_inval
let (event_tx, event_rx) = mpsc::channel();
let (browser_ws_url, browser_server) = start_callback_host_hotlist_browser_server(event_tx);
let root = std::env::temp_dir().join(format!("sgclaw-service-task-flow-{}", uuid::Uuid::new_v4()));
let root =
std::env::temp_dir().join(format!("sgclaw-service-task-flow-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&root).unwrap();
let config_path = root.join("sgclaw_config.json");
std::fs::write(
@@ -888,7 +921,8 @@ fn client_to_service_regression_routes_zhihu_through_callback_host_without_inval
let client_stdout = String::from_utf8_lossy(&client_output.stdout).into_owned();
let client_stderr = String::from_utf8_lossy(&client_output.stderr).into_owned();
let combined_output = format!("{client_stdout}\n{client_stderr}\n{service_stdout}\n{service_stderr}");
let combined_output =
format!("{client_stdout}\n{client_stderr}\n{service_stdout}\n{service_stderr}");
let register = match register {
CallbackHostBrowserEvent::BrowserFrame(value) => value,
@@ -927,13 +961,19 @@ fn client_to_service_regression_routes_zhihu_through_callback_host_without_inval
other => panic!("expected open-page command envelope, got {other:?}"),
};
assert_eq!(open_page["command"]["action"], json!("sgBrowerserOpenPage"));
assert_eq!(open_page["command"]["args"][0], json!("https://www.zhihu.com/hot"));
assert_eq!(
open_page["command"]["args"][0],
json!("https://www.zhihu.com/hot")
);
let get_text = match get_text {
CallbackHostBrowserEvent::CommandEnvelope(value) => value,
other => panic!("expected getText command envelope, got {other:?}"),
};
assert_eq!(get_text["command"]["action"], json!("sgBrowserExcuteJsCodeByDomain"));
assert_eq!(
get_text["command"]["action"],
json!("sgBrowserExcuteJsCodeByDomain")
);
assert_eq!(get_text["command"]["args"][0], json!("www.zhihu.com"));
assert!(get_text["command"]["args"][1]
.as_str()
@@ -943,15 +983,24 @@ fn client_to_service_regression_routes_zhihu_through_callback_host_without_inval
CallbackHostBrowserEvent::CommandEnvelope(value) => value,
other => panic!("expected eval command envelope, got {other:?}"),
};
assert_eq!(eval["command"]["action"], json!("sgBrowserExcuteJsCodeByDomain"));
assert_eq!(
eval["command"]["action"],
json!("sgBrowserExcuteJsCodeByDomain")
);
assert_eq!(eval["command"]["args"][0], json!("www.zhihu.com"));
assert!(eval["command"]["args"][1]
.as_str()
.is_some_and(|script| script.contains("sgclawOnEval")));
assert!(client_output.status.success());
assert!(client_stdout.contains("已导出并打开知乎热榜 Excel"), "client stdout={client_stdout}");
assert!(client_stdout.contains(".xlsx"), "client stdout={client_stdout}");
assert!(
client_stdout.contains("已导出并打开知乎热榜 Excel"),
"client stdout={client_stdout}"
);
assert!(
client_stdout.contains(".xlsx"),
"client stdout={client_stdout}"
);
assert!(
!combined_output.contains("invalid hmac seed: session key must not be empty"),
"target behavior must avoid the invalid hmac seed failure; combined_output={combined_output}"

View File

@@ -42,7 +42,10 @@ fn start_fake_deepseek_server(
match listener.accept() {
Ok(pair) => break pair,
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
assert!(Instant::now() < deadline, "timed out waiting for provider request");
assert!(
Instant::now() < deadline,
"timed out waiting for provider request"
);
thread::sleep(Duration::from_millis(10));
}
Err(err) => panic!("failed to accept provider request: {err}"),
@@ -167,8 +170,12 @@ fn start_callback_host_hotlist_browser_server(
let handle = thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap();
stream.set_write_timeout(Some(Duration::from_secs(2))).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut websocket = accept(stream).unwrap();
let register = match websocket.read().unwrap() {
@@ -188,7 +195,7 @@ fn start_callback_host_hotlist_browser_server(
let first_action = match websocket.read().unwrap() {
Message::Text(text) => serde_json::from_str::<Value>(&text).unwrap(),
other => panic!("expected browser action frame, got {other:?}"),
other => panic!("expected first browser action frame, got {other:?}"),
};
event_tx
.send(CallbackHostBrowserEvent::BrowserFrame(first_action.clone()))
@@ -199,7 +206,9 @@ fn start_callback_host_hotlist_browser_server(
other => panic!("expected second browser action frame, got {other:?}"),
};
event_tx
.send(CallbackHostBrowserEvent::BrowserFrame(second_action.clone()))
.send(CallbackHostBrowserEvent::BrowserFrame(
second_action.clone(),
))
.unwrap();
let Some(close_values) = first_action.as_array() else {
@@ -378,7 +387,315 @@ fn start_callback_host_hotlist_browser_server(
(format!("ws://{address}"), handle)
}
fn start_direct_zhihu_browser_ws_server() -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHandle<()>) {
fn start_callback_host_manifest_scene_browser_server(
event_tx: mpsc::Sender<CallbackHostBrowserEvent>,
) -> (String, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let handle = thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(2)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(2)))
.unwrap();
let mut websocket = accept(stream).unwrap();
let register = match websocket.read().unwrap() {
Message::Text(text) => serde_json::from_str::<Value>(&text).unwrap(),
other => panic!("expected register frame, got {other:?}"),
};
event_tx
.send(CallbackHostBrowserEvent::BrowserFrame(register))
.unwrap();
websocket
.send(Message::Text(
r#"{"type":"welcome","client_id":1,"server_time":"2026-04-04T00:00:00"}"#
.to_string()
.into(),
))
.unwrap();
let first_action = match websocket.read().unwrap() {
Message::Text(text) => serde_json::from_str::<Value>(&text).unwrap(),
other => panic!("expected first browser action frame, got {other:?}"),
};
event_tx
.send(CallbackHostBrowserEvent::BrowserFrame(first_action.clone()))
.unwrap();
let second_action = match websocket.read().unwrap() {
Message::Text(text) => serde_json::from_str::<Value>(&text).unwrap(),
other => panic!("expected second browser action frame, got {other:?}"),
};
event_tx
.send(CallbackHostBrowserEvent::BrowserFrame(
second_action.clone(),
))
.unwrap();
let Some(close_values) = first_action.as_array() else {
websocket.close(None).ok();
return;
};
let is_helper_close = close_values.len() >= 3
&& close_values[1] == json!("sgHideBrowerserClosePage")
&& close_values[2]
.as_str()
.is_some_and(|url| url.ends_with("/sgclaw/browser-helper.html"));
if !is_helper_close {
websocket.close(None).ok();
return;
}
let Some(values) = second_action.as_array() else {
websocket.close(None).ok();
return;
};
let is_helper_open = values.len() >= 3
&& values[1] == json!("sgHideBrowerserOpenPage")
&& values[2]
.as_str()
.is_some_and(|url| url.ends_with("/sgclaw/browser-helper.html"));
if !is_helper_open {
websocket.close(None).ok();
return;
}
let helper_url = values[2].as_str().unwrap().to_string();
let helper_origin = helper_url
.trim_end_matches("/sgclaw/browser-helper.html")
.to_string();
let helper_client = Client::builder()
.timeout(Duration::from_secs(2))
.pool_max_idle_per_host(0)
.build()
.unwrap();
let helper_html = helper_client
.get(&helper_url)
.send()
.unwrap()
.error_for_status()
.unwrap()
.text()
.unwrap();
assert!(helper_html.contains("sgclawReady"));
assert!(helper_html.contains("sgclawOnLoaded"));
assert!(helper_html.contains("sgclawOnGetText"));
assert!(helper_html.contains("sgclawOnEval"));
let pre_ready_command: Value = helper_client
.get(format!("{helper_origin}/sgclaw/callback/commands/next"))
.send()
.unwrap()
.error_for_status()
.unwrap()
.json()
.unwrap();
event_tx
.send(CallbackHostBrowserEvent::CommandEnvelope(pre_ready_command))
.unwrap();
helper_client
.post(format!("{helper_origin}/sgclaw/callback/ready"))
.json(&json!({
"type": "ready",
"helper_url": helper_url,
}))
.send()
.unwrap()
.error_for_status()
.unwrap();
let manifest_payload = json!({
"type": "report-artifact",
"report_name": "manifest-scene-report",
"status": "ok",
"columns": ["ORG_NAME"],
"rows": [{"ORG_NAME": "国网兰州供电公司"}],
"counts": {"rows": 1}
})
.to_string();
let deadline = Instant::now() + Duration::from_secs(10);
let mut saw_eval = false;
while Instant::now() < deadline {
let envelope: Value = match helper_client
.get(format!("{helper_origin}/sgclaw/callback/commands/next"))
.send()
.and_then(|response| response.error_for_status())
.and_then(|response| response.json())
{
Ok(envelope) => envelope,
Err(_) => {
thread::sleep(Duration::from_millis(20));
continue;
}
};
let Some(command) = envelope.get("command").and_then(Value::as_object) else {
thread::sleep(Duration::from_millis(20));
continue;
};
event_tx
.send(CallbackHostBrowserEvent::CommandEnvelope(envelope.clone()))
.unwrap();
let action_name = command
.get("action")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
helper_client
.post(format!("{helper_origin}/sgclaw/callback/commands/ack"))
.json(&json!({ "type": "command_ack" }))
.send()
.unwrap()
.error_for_status()
.unwrap();
let args = command
.get("args")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
match action_name.as_str() {
"sgBrowerserOpenPage" => {
helper_client
.post(format!("{helper_origin}/sgclaw/callback/events"))
.json(&json!({
"callback": "sgclawOnLoaded",
"request_url": helper_url,
"target_url": "https://manifest.example.test/report",
"action": "navigate",
"payload": { "loaded": true }
}))
.send()
.unwrap()
.error_for_status()
.unwrap();
}
"sgBrowserExcuteJsCodeByDomain" => {
let script = args.get(1).and_then(Value::as_str).unwrap_or_default();
assert!(
script.contains("manifest-scene-report"),
"expected manifest-scene eval script, got {script}"
);
saw_eval = true;
helper_client
.post(format!("{helper_origin}/sgclaw/callback/events"))
.json(&json!({
"callback": "sgclawOnEval",
"request_url": helper_url,
"target_url": "https://manifest.example.test/report",
"action": action_name,
"payload": { "value": manifest_payload }
}))
.send()
.unwrap()
.error_for_status()
.unwrap();
break;
}
other => panic!("unexpected callback-host command action {other}"),
}
}
assert!(saw_eval, "expected callback-host eval command");
websocket.close(None).ok();
});
(format!("ws://{address}"), handle)
}
fn temp_manifest_scene_skill_root() -> std::path::PathBuf {
let root = std::env::temp_dir().join(format!(
"sgclaw-service-manifest-scene-skill-root-{}",
uuid::Uuid::new_v4()
));
let skill_dir = root.join("manifest-scene-report");
let script_dir = skill_dir.join("scripts");
std::fs::create_dir_all(&script_dir).unwrap();
std::fs::write(
skill_dir.join("SKILL.toml"),
r#"
[skill]
name = "manifest-scene-report"
description = "Collect manifest scene report data."
version = "0.1.0"
[[tools]]
name = "collect_manifest_scene"
description = "Collect manifest scene report rows."
kind = "browser_script"
command = "scripts/collect_manifest_scene.js"
"#,
)
.unwrap();
std::fs::write(
skill_dir.join("scene.toml"),
r#"
[scene]
id = "manifest-scene-report"
skill = "manifest-scene-report"
tool = "collect_manifest_scene"
kind = "browser_script"
version = "0.1.0"
category = "report_collection"
[manifest]
schema_version = "1"
[bootstrap]
expected_domain = "manifest.example.test"
target_url = "https://manifest.example.test/report"
page_title_keywords = []
requires_target_page = true
[deterministic]
suffix = "。。。"
include_keywords = ["自定义场景报表"]
exclude_keywords = []
[[params]]
name = "period"
resolver = "literal_passthrough"
required = false
prompt_missing = "missing"
prompt_ambiguous = "ambiguous"
[params.resolver_config]
output_field = "period_value"
value = "2026-03"
[artifact]
type = "report-artifact"
success_status = ["ok", "partial", "empty"]
failure_status = ["blocked", "error"]
"#,
)
.unwrap();
std::fs::write(
script_dir.join("collect_manifest_scene.js"),
r#"
return {
type: "report-artifact",
report_name: "manifest-scene-report",
status: "ok",
columns: ["ORG_NAME"],
rows: [{ ORG_NAME: "国网兰州供电公司" }],
counts: { rows: 1 }
};
"#,
)
.unwrap();
root
}
fn start_direct_zhihu_browser_ws_server(
) -> (String, Arc<Mutex<Vec<String>>>, thread::JoinHandle<()>) {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let frames = Arc::new(Mutex::new(Vec::new()));
@@ -386,8 +703,12 @@ fn start_direct_zhihu_browser_ws_server() -> (String, Arc<Mutex<Vec<String>>>, t
let handle = thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
stream.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
stream.set_write_timeout(Some(Duration::from_secs(5))).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(5)))
.unwrap();
let mut socket = accept(stream).unwrap();
let mut action_count = 0_u64;
@@ -414,7 +735,9 @@ fn start_direct_zhihu_browser_ws_server() -> (String, Arc<Mutex<Vec<String>>>, t
continue;
}
let values = parsed.as_array().expect("browser action frame should be an array");
let values = parsed
.as_array()
.expect("browser action frame should be an array");
let request_url = values[0].as_str().expect("request_url should be a string");
let action = values[1].as_str().expect("action should be a string");
action_count += 1;
@@ -430,7 +753,9 @@ fn start_direct_zhihu_browser_ws_server() -> (String, Arc<Mutex<Vec<String>>>, t
let callback_frame = match action {
"sgHideBrowserCallAfterLoaded" => {
let target_url = values[2].as_str().expect("navigate target_url should be a string");
let target_url = values[2]
.as_str()
.expect("navigate target_url should be a string");
json!([
request_url,
"callBackJsToCpp",
@@ -440,7 +765,9 @@ fn start_direct_zhihu_browser_ws_server() -> (String, Arc<Mutex<Vec<String>>>, t
])
}
"sgBrowserExcuteJsCodeByArea" => {
let target_url = values[2].as_str().expect("script target_url should be a string");
let target_url = values[2]
.as_str()
.expect("script target_url should be a string");
let response_text = if action_count == 2 {
"知乎热榜\n1 问题一 344万热度\n2 问题二 266万热度".to_string()
} else {
@@ -496,7 +823,8 @@ fn service_run_requires_llm_config_for_startup() {
#[test]
fn service_startup_config_loads_ws_endpoints_from_browser_config() {
let root = std::env::temp_dir().join(format!("sgclaw-service-startup-{}", uuid::Uuid::new_v4()));
let root =
std::env::temp_dir().join(format!("sgclaw-service-startup-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&root).unwrap();
let config_path = root.join("sgclaw_config.json");
std::fs::write(
@@ -511,13 +839,14 @@ fn service_startup_config_loads_ws_endpoints_from_browser_config() {
)
.unwrap();
let startup = sgclaw::service::load_startup_config(&AgentRuntimeContext::new(
Some(config_path),
root,
))
.unwrap();
let startup =
sgclaw::service::load_startup_config(&AgentRuntimeContext::new(Some(config_path), root))
.unwrap();
assert_eq!(startup.browser_ws_url.as_deref(), Some("ws://127.0.0.1:12345"));
assert_eq!(
startup.browser_ws_url.as_deref(),
Some("ws://127.0.0.1:12345")
);
assert_eq!(
startup.service_ws_listen_addr.as_deref(),
Some("127.0.0.1:42321")
@@ -526,7 +855,8 @@ fn service_startup_config_loads_ws_endpoints_from_browser_config() {
#[test]
fn service_startup_config_uses_default_ws_endpoints_when_not_configured() {
let root = std::env::temp_dir().join(format!("sgclaw-service-defaults-{}", uuid::Uuid::new_v4()));
let root =
std::env::temp_dir().join(format!("sgclaw-service-defaults-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&root).unwrap();
let config_path = root.join("sgclaw_config.json");
std::fs::write(
@@ -539,11 +869,9 @@ fn service_startup_config_uses_default_ws_endpoints_when_not_configured() {
)
.unwrap();
let startup = sgclaw::service::load_startup_config(&AgentRuntimeContext::new(
Some(config_path),
root,
))
.unwrap();
let startup =
sgclaw::service::load_startup_config(&AgentRuntimeContext::new(Some(config_path), root))
.unwrap();
assert_eq!(
startup.browser_ws_url.as_deref(),
@@ -695,8 +1023,14 @@ fn service_binary_keeps_service_ws_listener_available_for_client_connections() {
})
.unwrap_or_default();
assert!(connected, "service ws listener never became available; stderr={stderr}");
assert!(status.is_none(), "sg_claw exited before client could connect; stderr={stderr}");
assert!(
connected,
"service ws listener never became available; stderr={stderr}"
);
assert!(
status.is_none(),
"sg_claw exited before client could connect; stderr={stderr}"
);
}
#[test]
@@ -704,7 +1038,10 @@ fn service_binary_survives_real_client_disconnect_after_task_complete() {
let service_listener = TcpListener::bind("127.0.0.1:0").unwrap();
let service_addr = service_listener.local_addr().unwrap();
drop(service_listener);
let root = std::env::temp_dir().join(format!("sgclaw-service-disconnect-{}", uuid::Uuid::new_v4()));
let root = std::env::temp_dir().join(format!(
"sgclaw-service-disconnect-{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&root).unwrap();
let config_path = root.join("sgclaw_config.json");
std::fs::write(
@@ -754,7 +1091,10 @@ fn service_binary_survives_real_client_disconnect_after_task_complete() {
}
thread::sleep(Duration::from_millis(20));
}
assert!(stderr.contains("sg_claw ready:"), "service did not report readiness; stderr={stderr}");
assert!(
stderr.contains("sg_claw ready:"),
"service did not report readiness; stderr={stderr}"
);
let mut client = std::process::Command::new(
std::env::var("CARGO_BIN_EXE_sg_claw_client").expect("sg_claw_client test binary path"),
@@ -765,7 +1105,12 @@ fn service_binary_survives_real_client_disconnect_after_task_complete() {
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
client.stdin.as_mut().unwrap().write_all("你好\n".as_bytes()).unwrap();
client
.stdin
.as_mut()
.unwrap()
.write_all("你好\n".as_bytes())
.unwrap();
let client_output = client.wait_with_output().unwrap();
assert!(
@@ -821,7 +1166,10 @@ fn service_binary_submit_flow_routes_zhihu_through_callback_host() {
let (event_tx, event_rx) = mpsc::channel();
let (browser_ws_url, browser_server) = start_callback_host_hotlist_browser_server(event_tx);
let root = std::env::temp_dir().join(format!("sgclaw-service-zhihu-submit-{}", uuid::Uuid::new_v4()));
let root = std::env::temp_dir().join(format!(
"sgclaw-service-zhihu-submit-{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&root).unwrap();
let config_path = root.join("sgclaw_config.json");
std::fs::write(
@@ -872,7 +1220,10 @@ fn service_binary_submit_flow_routes_zhihu_through_callback_host() {
}
thread::sleep(Duration::from_millis(20));
}
assert!(stderr.contains("sg_claw ready:"), "service did not report readiness; stderr={stderr}");
assert!(
stderr.contains("sg_claw ready:"),
"service did not report readiness; stderr={stderr}"
);
let mut client = std::process::Command::new(
std::env::var("CARGO_BIN_EXE_sg_claw_client").expect("sg_claw_client test binary path"),
@@ -956,13 +1307,19 @@ fn service_binary_submit_flow_routes_zhihu_through_callback_host() {
other => panic!("expected open-page command envelope, got {other:?}"),
};
assert_eq!(open_page["command"]["action"], json!("sgBrowerserOpenPage"));
assert_eq!(open_page["command"]["args"][0], json!("https://www.zhihu.com/hot"));
assert_eq!(
open_page["command"]["args"][0],
json!("https://www.zhihu.com/hot")
);
let get_text = match get_text {
CallbackHostBrowserEvent::CommandEnvelope(value) => value,
other => panic!("expected getText command envelope, got {other:?}"),
};
assert_eq!(get_text["command"]["action"], json!("sgBrowserExcuteJsCodeByDomain"));
assert_eq!(
get_text["command"]["action"],
json!("sgBrowserExcuteJsCodeByDomain")
);
assert_eq!(get_text["command"]["args"][0], json!("www.zhihu.com"));
assert!(get_text["command"]["args"][1]
.as_str()
@@ -972,7 +1329,10 @@ fn service_binary_submit_flow_routes_zhihu_through_callback_host() {
CallbackHostBrowserEvent::CommandEnvelope(value) => value,
other => panic!("expected eval command envelope, got {other:?}"),
};
assert_eq!(eval["command"]["action"], json!("sgBrowserExcuteJsCodeByDomain"));
assert_eq!(
eval["command"]["action"],
json!("sgBrowserExcuteJsCodeByDomain")
);
assert_eq!(eval["command"]["args"][0], json!("www.zhihu.com"));
assert!(eval["command"]["args"][1]
.as_str()
@@ -997,6 +1357,224 @@ fn service_binary_submit_flow_routes_zhihu_through_callback_host() {
);
}
#[test]
fn service_binary_submit_flow_routes_configured_manifest_scene_through_callback_host() {
let service_listener = TcpListener::bind("127.0.0.1:0").unwrap();
let service_addr = service_listener.local_addr().unwrap();
drop(service_listener);
let (event_tx, event_rx) = mpsc::channel();
let (browser_ws_url, browser_server) =
start_callback_host_manifest_scene_browser_server(event_tx);
let skills_dir = temp_manifest_scene_skill_root();
let root = std::env::temp_dir().join(format!(
"sgclaw-service-manifest-scene-submit-{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&root).unwrap();
let config_path = root.join("sgclaw_config.json");
let resources_dir = root.join("resources");
std::fs::create_dir_all(&resources_dir).unwrap();
std::fs::write(
resources_dir.join("rules.json"),
r#"{
"version": "1.0",
"domains": {
"allowed": ["manifest.example.test"]
},
"pipe_actions": {
"allowed": ["click", "type", "navigate", "getText", "eval"],
"blocked": ["executeJsInPage"]
}
}"#,
)
.unwrap();
let skills_dir_json = skills_dir.to_string_lossy().replace("\\", "/");
std::fs::write(
&config_path,
format!(
r#"{{
"apiKey": "sk-runtime",
"baseUrl": "http://127.0.0.1:9",
"model": "deepseek-chat",
"skillsDir": "{skills_dir_json}",
"browserWsUrl": "{browser_ws_url}",
"serviceWsListenAddr": "{service_addr}"
}}"#
),
)
.unwrap();
let mut service = std::process::Command::new(
std::env::var("CARGO_BIN_EXE_sg_claw").expect("sg_claw test binary path"),
)
.current_dir(&root)
.env("SGCLAW_DISABLE_POST_EXPORT_OPEN", "1")
.arg("--config-path")
.arg(&config_path)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
let ws_url = format!("ws://{service_addr}");
let ready_deadline = Instant::now() + Duration::from_secs(2);
let mut stderr = String::new();
while Instant::now() < ready_deadline {
if let Some(stream) = service.stderr.as_mut() {
let mut buf = [0_u8; 1024];
match stream.read(&mut buf) {
Ok(0) => {}
Ok(n) => {
stderr.push_str(&String::from_utf8_lossy(&buf[..n]));
if stderr.contains("sg_claw ready:") {
break;
}
}
Err(_) => {}
}
}
if service.try_wait().unwrap().is_some() {
break;
}
thread::sleep(Duration::from_millis(20));
}
assert!(
stderr.contains("sg_claw ready:"),
"service did not report readiness; stderr={stderr}"
);
let mut client = std::process::Command::new(
std::env::var("CARGO_BIN_EXE_sg_claw_client").expect("sg_claw_client test binary path"),
)
.env("SG_CLAW_SERVICE_WS_URL", &ws_url)
.env("SGCLAW_DISABLE_POST_EXPORT_OPEN", "1")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.unwrap();
client
.stdin
.as_mut()
.unwrap()
.write_all("请执行自定义场景报表。。。\n".as_bytes())
.unwrap();
let client_output = client.wait_with_output().unwrap();
let browser_server_result = browser_server.join();
let register_result = event_rx.recv_timeout(Duration::from_secs(2));
let bootstrap_close_result = event_rx.recv_timeout(Duration::from_secs(2));
let bootstrap_result = event_rx.recv_timeout(Duration::from_secs(2));
let pre_ready_result = event_rx.recv_timeout(Duration::from_secs(2));
let first_command_result = event_rx.recv_timeout(Duration::from_secs(4));
let second_command_result = event_rx.recv_timeout(Duration::from_secs(4));
let service_status = service.try_wait().unwrap();
if service_status.is_none() {
service.kill().unwrap();
let _ = service.wait();
}
if let Some(mut stream) = service.stderr.take() {
let mut buf = Vec::new();
let _ = stream.read_to_end(&mut buf);
stderr.push_str(&String::from_utf8_lossy(&buf));
}
let combined_output = format!(
"{}\n{}\n{}",
String::from_utf8_lossy(&client_output.stdout),
String::from_utf8_lossy(&client_output.stderr),
stderr
);
assert!(
browser_server_result.is_ok(),
"manifest callback-host helper panicked; browser_server_result={browser_server_result:?} output={combined_output} register={register_result:?} bootstrap_close={bootstrap_close_result:?} bootstrap={bootstrap_result:?} pre_ready={pre_ready_result:?} first_command={first_command_result:?} second_command={second_command_result:?}"
);
let register = register_result.expect("missing register event");
let bootstrap_close = bootstrap_close_result.expect("missing bootstrap close event");
let bootstrap = bootstrap_result.expect("missing bootstrap open event");
let pre_ready = pre_ready_result.expect("missing pre-ready event");
let first_command = first_command_result.expect("missing first command event");
let register = match register {
CallbackHostBrowserEvent::BrowserFrame(value) => value,
other => panic!("expected register browser frame, got {other:?}"),
};
assert_eq!(register, json!({ "type": "register", "role": "web" }));
let bootstrap_close = match bootstrap_close {
CallbackHostBrowserEvent::BrowserFrame(value) => value,
other => panic!("expected helper close frame, got {other:?}"),
};
assert_eq!(
bootstrap_close[0],
json!("https://manifest.example.test/report")
);
assert_eq!(bootstrap_close[1], json!("sgHideBrowerserClosePage"));
let bootstrap = match bootstrap {
CallbackHostBrowserEvent::BrowserFrame(value) => value,
other => panic!("expected helper bootstrap frame, got {other:?}"),
};
assert_eq!(bootstrap[0], json!("https://manifest.example.test/report"));
assert_eq!(bootstrap[1], json!("sgHideBrowerserOpenPage"));
let pre_ready = match pre_ready {
CallbackHostBrowserEvent::CommandEnvelope(value) => value,
other => panic!("expected pre-ready command envelope, got {other:?}"),
};
assert_eq!(pre_ready, json!({ "ok": false, "command": null }));
let first_command = match first_command {
CallbackHostBrowserEvent::CommandEnvelope(value) => value,
other => panic!("expected first command envelope, got {other:?}"),
};
let eval = if first_command["command"]["action"] == json!("sgBrowerserOpenPage") {
assert_eq!(
first_command["command"]["args"][0],
json!("https://manifest.example.test/report")
);
let second_command =
second_command_result.expect("missing eval command event after open-page");
match second_command {
CallbackHostBrowserEvent::CommandEnvelope(value) => value,
other => panic!("expected eval command envelope, got {other:?}"),
}
} else {
assert!(
second_command_result.is_err(),
"did not expect a second command when the first command was already eval: {second_command_result:?}"
);
first_command
};
assert_eq!(
eval["command"]["action"],
json!("sgBrowserExcuteJsCodeByDomain")
);
assert_eq!(eval["command"]["args"][0], json!("manifest.example.test"));
assert!(eval["command"]["args"][1]
.as_str()
.is_some_and(|script| script.contains("manifest-scene-report")));
assert!(client_output.status.success());
assert!(
!combined_output.contains("compat_llm_primary"),
"manifest scene should not fall through to compat LLM: {combined_output}"
);
assert!(
!combined_output.contains(RUNTIME_DROP_PANIC_TEXT),
"service submit flow still contains runtime-drop panic: {combined_output}"
);
let _ = std::fs::remove_dir_all(skills_dir);
}
#[test]
fn service_binary_submit_flow_uses_callback_host_command_semantics_for_zhihu() {
let service_listener = TcpListener::bind("127.0.0.1:0").unwrap();
@@ -1006,7 +1584,8 @@ fn service_binary_submit_flow_uses_callback_host_command_semantics_for_zhihu() {
let (event_tx, event_rx) = mpsc::channel();
let (browser_ws_url, browser_server) = start_callback_host_hotlist_browser_server(event_tx);
let root = std::env::temp_dir().join(format!("sgclaw-service-session-{}", uuid::Uuid::new_v4()));
let root =
std::env::temp_dir().join(format!("sgclaw-service-session-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&root).unwrap();
let config_path = root.join("sgclaw_config.json");
std::fs::write(
@@ -1057,7 +1636,10 @@ fn service_binary_submit_flow_uses_callback_host_command_semantics_for_zhihu() {
}
thread::sleep(Duration::from_millis(20));
}
assert!(stderr.contains("sg_claw ready:"), "service did not report readiness; stderr={stderr}");
assert!(
stderr.contains("sg_claw ready:"),
"service did not report readiness; stderr={stderr}"
);
let mut client = std::process::Command::new(
std::env::var("CARGO_BIN_EXE_sg_claw_client").expect("sg_claw_client test binary path"),
@@ -1140,13 +1722,19 @@ fn service_binary_submit_flow_uses_callback_host_command_semantics_for_zhihu() {
other => panic!("expected open-page command envelope, got {other:?}"),
};
assert_eq!(open_page["command"]["action"], json!("sgBrowerserOpenPage"));
assert_eq!(open_page["command"]["args"][0], json!("https://www.zhihu.com/hot"));
assert_eq!(
open_page["command"]["args"][0],
json!("https://www.zhihu.com/hot")
);
let get_text = match get_text {
CallbackHostBrowserEvent::CommandEnvelope(value) => value,
other => panic!("expected getText command envelope, got {other:?}"),
};
assert_eq!(get_text["command"]["action"], json!("sgBrowserExcuteJsCodeByDomain"));
assert_eq!(
get_text["command"]["action"],
json!("sgBrowserExcuteJsCodeByDomain")
);
assert_eq!(get_text["command"]["args"][0], json!("www.zhihu.com"));
assert!(get_text["command"]["args"][1]
.as_str()
@@ -1156,7 +1744,10 @@ fn service_binary_submit_flow_uses_callback_host_command_semantics_for_zhihu() {
CallbackHostBrowserEvent::CommandEnvelope(value) => value,
other => panic!("expected eval command envelope, got {other:?}"),
};
assert_eq!(eval["command"]["action"], json!("sgBrowserExcuteJsCodeByDomain"));
assert_eq!(
eval["command"]["action"],
json!("sgBrowserExcuteJsCodeByDomain")
);
assert_eq!(eval["command"]["args"][0], json!("www.zhihu.com"));
assert!(eval["command"]["args"][1]
.as_str()
@@ -1239,7 +1830,8 @@ fn service_binary_accepts_connect_request_without_starting_browser_task() {
let service_addr = service_listener.local_addr().unwrap();
drop(service_listener);
let root = std::env::temp_dir().join(format!("sgclaw-service-connect-{}", uuid::Uuid::new_v4()));
let root =
std::env::temp_dir().join(format!("sgclaw-service-connect-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&root).unwrap();
let config_path = root.join("sgclaw_config.json");
std::fs::write(
@@ -1288,7 +1880,9 @@ fn service_binary_accepts_connect_request_without_starting_browser_task() {
let mut websocket = websocket.expect("service ws listener never became available");
websocket
.send(Message::Text(
serde_json::to_string(&ClientMessage::Connect).unwrap().into(),
serde_json::to_string(&ClientMessage::Connect)
.unwrap()
.into(),
))
.unwrap();
@@ -1340,7 +1934,10 @@ fn service_binary_survives_client_disconnect_during_task_completion_send() {
let service_addr = service_listener.local_addr().unwrap();
drop(service_listener);
let root = std::env::temp_dir().join(format!("sgclaw-service-disconnect-{}", uuid::Uuid::new_v4()));
let root = std::env::temp_dir().join(format!(
"sgclaw-service-disconnect-{}",
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&root).unwrap();
let config_path = root.join("sgclaw_config.json");
std::fs::write(
@@ -1446,7 +2043,9 @@ fn submit_task_client_message_converts_into_shared_runner_request() {
page_title: "Example".to_string(),
};
let request = message.into_submit_task_request().expect("submit task request");
let request = message
.into_submit_task_request()
.expect("submit task request");
assert_eq!(request.instruction, "continue task");
assert_eq!(request.conversation_id.as_deref(), Some("conv-1"));
@@ -1464,7 +2063,11 @@ fn ping_client_message_does_not_convert_into_submit_task_request() {
#[test]
fn lifecycle_client_messages_do_not_convert_into_submit_task_request() {
for message in [ClientMessage::Connect, ClientMessage::Start, ClientMessage::Stop] {
for message in [
ClientMessage::Connect,
ClientMessage::Start,
ClientMessage::Stop,
] {
assert!(message.into_submit_task_request().is_none());
}
}
@@ -1493,10 +2096,7 @@ fn service_messages_round_trip_with_stable_tags() {
},
r#"{"type":"status_changed","state":"started"}"#,
),
(
ServiceMessage::Pong,
r#"{"type":"pong"}"#,
),
(ServiceMessage::Pong, r#"{"type":"pong"}"#),
];
for (message, raw) in cases {

View File

@@ -7,8 +7,8 @@ use std::time::Duration;
use common::MockTransport;
use serde_json::Value;
use sgclaw::agent::{run_submit_task, AgentEventSink, AgentRuntimeContext, SubmitTaskRequest};
use sgclaw::agent::task_runner::run_submit_task_with_browser_backend;
use sgclaw::agent::{run_submit_task, AgentEventSink, AgentRuntimeContext, SubmitTaskRequest};
use sgclaw::browser::BrowserBackend;
use sgclaw::pipe::{
Action, AgentMessage, BrowserMessage, BrowserPipeTool, CommandOutput, ConversationMessage,
@@ -37,12 +37,8 @@ fn test_policy() -> MacPolicy {
}
fn test_browser_tool(transport: Arc<MockTransport>) -> BrowserPipeTool<MockTransport> {
BrowserPipeTool::new(
transport,
test_policy(),
vec![1, 2, 3, 4, 5, 6, 7, 8],
)
.with_response_timeout(Duration::from_secs(1))
BrowserPipeTool::new(transport, test_policy(), vec![1, 2, 3, 4, 5, 6, 7, 8])
.with_response_timeout(Duration::from_secs(1))
}
#[derive(Clone, Default)]
@@ -409,18 +405,10 @@ fn handle_browser_message_emits_status_for_lifecycle_messages() {
BrowserMessage::Connect,
)
.unwrap();
sgclaw::agent::handle_browser_message(
transport.as_ref(),
&browser_tool,
BrowserMessage::Start,
)
.unwrap();
sgclaw::agent::handle_browser_message(
transport.as_ref(),
&browser_tool,
BrowserMessage::Stop,
)
.unwrap();
sgclaw::agent::handle_browser_message(transport.as_ref(), &browser_tool, BrowserMessage::Start)
.unwrap();
sgclaw::agent::handle_browser_message(transport.as_ref(), &browser_tool, BrowserMessage::Stop)
.unwrap();
assert_eq!(
transport.sent_messages(),