feat: service console auto-connect, settings panel, and batch of enhancements
- Auto-connect WebSocket on page load in service console - Settings modal for editing sgclaw_config.json (API key, base URL, model, skills dir, etc.) - UpdateConfig/ConfigUpdated protocol messages for remote config save - save_to_path() for SgClawSettings serialization - ConfigUpdated handler in sg_claw_client binary - Protocol serialization tests for new message types - HTML test assertions for auto-connect and settings UI - Additional pending changes: deterministic submit, org units, lineloss xlsx export, browser script tool, and docs 🤖 Generated with [Qoder][https://qoder.com]
This commit is contained in:
105
tests/lineloss_xlsx_export_test.rs
Normal file
105
tests/lineloss_xlsx_export_test.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::json;
|
||||
use sgclaw::compat::lineloss_xlsx_export::{export_lineloss_xlsx, LinelossExportRequest};
|
||||
|
||||
fn temp_output_path(name: &str) -> PathBuf {
|
||||
let dir = std::env::temp_dir().join("sgclaw-test-xlsx");
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir.join(name)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_month_lineloss_produces_valid_xlsx() {
|
||||
let output_path = temp_output_path("month-test.xlsx");
|
||||
if output_path.exists() {
|
||||
fs::remove_file(&output_path).unwrap();
|
||||
}
|
||||
|
||||
let request = LinelossExportRequest {
|
||||
sheet_name: "国网兰州供电公司月度线损分析报表(2026-03)".to_string(),
|
||||
column_defs: vec![
|
||||
("ORG_NAME".to_string(), "供电单位".to_string()),
|
||||
("YGDL".to_string(), "累计供电量".to_string()),
|
||||
("YYDL".to_string(), "累计售电量".to_string()),
|
||||
("YXSL".to_string(), "线损完成率(%)".to_string()),
|
||||
("RAT_SCOPE".to_string(), "线损率累计目标值".to_string()),
|
||||
("BLANK3".to_string(), "目标完成率".to_string()),
|
||||
("BLANK2".to_string(), "排行".to_string()),
|
||||
],
|
||||
rows: vec![
|
||||
serde_json::from_value(json!({
|
||||
"ORG_NAME": "城关供电",
|
||||
"YGDL": "12345.67",
|
||||
"YYDL": "11234.56",
|
||||
"YXSL": "9.00",
|
||||
"RAT_SCOPE": "9.50",
|
||||
"BLANK3": "94.74",
|
||||
"BLANK2": "1"
|
||||
}))
|
||||
.unwrap(),
|
||||
serde_json::from_value(json!({
|
||||
"ORG_NAME": "七里河供电",
|
||||
"YGDL": "9876.54",
|
||||
"YYDL": "8765.43",
|
||||
"YXSL": "11.24",
|
||||
"RAT_SCOPE": "10.00",
|
||||
"BLANK3": "112.40",
|
||||
"BLANK2": "2"
|
||||
}))
|
||||
.unwrap(),
|
||||
],
|
||||
output_path: output_path.clone(),
|
||||
};
|
||||
|
||||
let result_path = export_lineloss_xlsx(&request).unwrap();
|
||||
assert_eq!(result_path, output_path);
|
||||
assert!(output_path.exists());
|
||||
|
||||
// Verify it's a valid ZIP (xlsx is a zip archive)
|
||||
let file = fs::File::open(&output_path).unwrap();
|
||||
let mut archive = zip::ZipArchive::new(file).unwrap();
|
||||
|
||||
// Must contain the standard OpenXML entries
|
||||
let entry_names: Vec<String> = (0..archive.len())
|
||||
.map(|i| archive.by_index(i).unwrap().name().to_string())
|
||||
.collect();
|
||||
|
||||
assert!(entry_names.contains(&"[Content_Types].xml".to_string()));
|
||||
assert!(entry_names.contains(&"xl/worksheets/sheet1.xml".to_string()));
|
||||
assert!(entry_names.contains(&"xl/workbook.xml".to_string()));
|
||||
|
||||
// Read sheet1.xml and verify it contains our data
|
||||
let mut sheet = archive.by_name("xl/worksheets/sheet1.xml").unwrap();
|
||||
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("城关供电"), "data should contain 城关供电");
|
||||
assert!(xml.contains("12345.67"), "data should contain 12345.67");
|
||||
assert!(xml.contains("七里河供电"), "data should contain second row");
|
||||
|
||||
// Cleanup
|
||||
fs::remove_file(&output_path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_empty_rows_returns_error() {
|
||||
let output_path = temp_output_path("empty-test.xlsx");
|
||||
|
||||
let request = LinelossExportRequest {
|
||||
sheet_name: "test".to_string(),
|
||||
column_defs: vec![("A".to_string(), "ColA".to_string())],
|
||||
rows: vec![],
|
||||
output_path: output_path.clone(),
|
||||
};
|
||||
|
||||
let result = export_lineloss_xlsx(&request);
|
||||
assert!(result.is_err());
|
||||
assert!(
|
||||
result.unwrap_err().to_string().contains("rows must not be empty"),
|
||||
"should reject empty rows"
|
||||
);
|
||||
}
|
||||
@@ -24,4 +24,14 @@ fn service_console_html_stays_on_service_ws_boundary() {
|
||||
assert!(!source.contains("/sgclaw/callback/commands/next"));
|
||||
assert!(!source.contains("/sgclaw/callback/commands/ack"));
|
||||
assert!(!source.contains("ws://127.0.0.1:12345"));
|
||||
|
||||
// Auto-connect and settings enhancement assertions
|
||||
assert!(source.contains("DOMContentLoaded"));
|
||||
assert!(source.contains("settingsBtn"));
|
||||
assert!(source.contains("settingsModal"));
|
||||
assert!(source.contains("update_config"));
|
||||
assert!(source.contains("config_updated"));
|
||||
assert!(source.contains("settingApiKey"));
|
||||
assert!(source.contains("settingBaseUrl"));
|
||||
assert!(source.contains("settingModel"));
|
||||
}
|
||||
|
||||
72
tests/service_protocol_update_config_test.rs
Normal file
72
tests/service_protocol_update_config_test.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use sgclaw::service::{ClientMessage, ConfigUpdatePayload, ServiceMessage};
|
||||
|
||||
#[test]
|
||||
fn update_config_serializes_correctly() {
|
||||
let config = ConfigUpdatePayload {
|
||||
api_key: Some("test-key".to_string()),
|
||||
base_url: Some("https://api.example.com".to_string()),
|
||||
model: Some("test-model".to_string()),
|
||||
skills_dir: Some("/path/to/skills".to_string()),
|
||||
direct_submit_skill: Some("my-skill.my-tool".to_string()),
|
||||
runtime_profile: Some("browser-attached".to_string()),
|
||||
browser_backend: Some("super-rpa".to_string()),
|
||||
};
|
||||
|
||||
let msg = ClientMessage::UpdateConfig { config };
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
|
||||
assert!(json.contains("\"type\":\"update_config\""));
|
||||
assert!(json.contains("\"apiKey\":\"test-key\""));
|
||||
assert!(json.contains("\"baseUrl\":\"https://api.example.com\""));
|
||||
assert!(json.contains("\"model\":\"test-model\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_config_deserializes_correctly() {
|
||||
let json = r#"{
|
||||
"type": "update_config",
|
||||
"config": {
|
||||
"apiKey": "key123",
|
||||
"baseUrl": "https://api.test.com",
|
||||
"model": "gpt-4"
|
||||
}
|
||||
}"#;
|
||||
|
||||
let msg: ClientMessage = serde_json::from_str(json).unwrap();
|
||||
match msg {
|
||||
ClientMessage::UpdateConfig { config } => {
|
||||
assert_eq!(config.api_key, Some("key123".to_string()));
|
||||
assert_eq!(config.base_url, Some("https://api.test.com".to_string()));
|
||||
assert_eq!(config.model, Some("gpt-4".to_string()));
|
||||
assert!(config.skills_dir.is_none());
|
||||
}
|
||||
_ => panic!("expected UpdateConfig variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_updated_serializes_correctly() {
|
||||
let msg = ServiceMessage::ConfigUpdated {
|
||||
success: true,
|
||||
message: "配置已保存".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&msg).unwrap();
|
||||
|
||||
assert!(json.contains("\"type\":\"config_updated\""));
|
||||
assert!(json.contains("\"success\":true"));
|
||||
assert!(json.contains("配置已保存"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_updated_deserializes_correctly() {
|
||||
let json = r#"{"type":"config_updated","success":false,"message":"保存失败"}"#;
|
||||
let msg: ServiceMessage = serde_json::from_str(json).unwrap();
|
||||
|
||||
match msg {
|
||||
ServiceMessage::ConfigUpdated { success, message } => {
|
||||
assert!(!success);
|
||||
assert_eq!(message, "保存失败");
|
||||
}
|
||||
_ => panic!("expected ConfigUpdated variant"),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user