feat: add generated scene skill platform hardening
This commit is contained in:
@@ -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)
|
||||
|
||||
122
tests/boundary_family_entry_roadmap_test.rs
Normal file
122
tests/boundary_family_entry_roadmap_test.rs
Normal 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());
|
||||
}
|
||||
113
tests/boundary_runtime_prerequisites_roadmap_test.rs
Normal file
113
tests/boundary_runtime_prerequisites_roadmap_test.rs
Normal 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());
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, ¶ms, "https://www.zhihu.com/hot", request_id)
|
||||
.unwrap();
|
||||
let request =
|
||||
encode_v1_action(&action, ¶ms, "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"));
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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("问题一"));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
"缺少统计周期",
|
||||
);
|
||||
}
|
||||
|
||||
18
tests/fixtures/scene_source/tq_lineloss/index.html
vendored
Normal file
18
tests/fixtures/scene_source/tq_lineloss/index.html
vendored
Normal 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>
|
||||
14
tests/fixtures/scene_source/tq_lineloss/js/collect.js
vendored
Normal file
14
tests/fixtures/scene_source/tq_lineloss/js/collect.js
vendored
Normal 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 };
|
||||
}
|
||||
182
tests/g1e_candidate_batch_test.rs
Normal file
182
tests/g1e_candidate_batch_test.rs
Normal 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()
|
||||
}
|
||||
175
tests/g2_candidate_batch_test.rs
Normal file
175
tests/g2_candidate_batch_test.rs
Normal 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()
|
||||
}
|
||||
294
tests/g3_candidate_batch_test.rs
Normal file
294
tests/g3_candidate_batch_test.rs
Normal 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()
|
||||
}
|
||||
111
tests/g6_host_bridge_callback_semantics_test.rs
Normal file
111
tests/g6_host_bridge_callback_semantics_test.rs
Normal 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());
|
||||
}
|
||||
94
tests/g6_host_bridge_callback_state_verification_test.rs
Normal file
94
tests/g6_host_bridge_callback_state_verification_test.rs
Normal 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());
|
||||
}
|
||||
117
tests/g6_host_bridge_entry_gate_test.rs
Normal file
117
tests/g6_host_bridge_entry_gate_test.rs
Normal 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());
|
||||
}
|
||||
103
tests/g6_host_bridge_entry_readiness_test.rs
Normal file
103
tests/g6_host_bridge_entry_readiness_test.rs
Normal 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());
|
||||
}
|
||||
107
tests/g6_host_bridge_execution_semantics_test.rs
Normal file
107
tests/g6_host_bridge_execution_semantics_test.rs
Normal 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());
|
||||
}
|
||||
102
tests/g6_host_bridge_prerequisites_test.rs
Normal file
102
tests/g6_host_bridge_prerequisites_test.rs
Normal 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());
|
||||
}
|
||||
339
tests/generated_scene_full_direct_mock_runner.js
Normal file
339
tests/generated_scene_full_direct_mock_runner.js
Normal 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);
|
||||
});
|
||||
37
tests/generated_scene_lessons_test.rs
Normal file
37
tests/generated_scene_lessons_test.rs
Normal 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);
|
||||
}
|
||||
358
tests/generated_scene_mock_runtime_harness_runner.js
Normal file
358
tests/generated_scene_mock_runtime_harness_runner.js
Normal 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);
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
121
tests/post_g7_boundary_decision_roadmap_test.rs
Normal file
121
tests/post_g7_boundary_decision_roadmap_test.rs
Normal 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());
|
||||
}
|
||||
606
tests/post_roadmap_execution_assets_test.rs
Normal file
606
tests/post_roadmap_execution_assets_test.rs
Normal 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()
|
||||
}
|
||||
120
tests/report_artifact_postprocess_test.rs
Normal file
120
tests/report_artifact_postprocess_test.rs
Normal 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);
|
||||
}
|
||||
165
tests/roadmap_execution_status_test.rs
Normal file
165
tests/roadmap_execution_status_test.rs
Normal 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()
|
||||
}
|
||||
@@ -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"));
|
||||
|
||||
285
tests/scene_generator_canonical_test.rs
Normal file
285
tests/scene_generator_canonical_test.rs
Normal 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
|
||||
}
|
||||
121
tests/scene_generator_family_policy_test.rs
Normal file
121
tests/scene_generator_family_policy_test.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
455
tests/scene_generator_p1_family_test.rs
Normal file
455
tests/scene_generator_p1_family_test.rs
Normal 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
|
||||
}
|
||||
@@ -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
138
tests/scene_ledger_snapshot_test.rs
Normal file
138
tests/scene_ledger_snapshot_test.rs
Normal 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()
|
||||
}
|
||||
210
tests/scene_ledger_status_test.rs
Normal file
210
tests/scene_ledger_status_test.rs
Normal 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()
|
||||
}
|
||||
248
tests/scene_registry_test.rs
Normal file
248
tests/scene_registry_test.rs
Normal 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());
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user