diff --git a/src/generated_scene/analyzer.rs b/src/generated_scene/analyzer.rs new file mode 100644 index 0000000..3c5dc14 --- /dev/null +++ b/src/generated_scene/analyzer.rs @@ -0,0 +1,228 @@ +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SceneKind { + ReportCollection, + Monitoring, +} + +impl SceneKind { + pub fn from_str(s: &str) -> Option { + match s { + "report_collection" => Some(Self::ReportCollection), + "monitoring" => Some(Self::Monitoring), + _ => None, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::ReportCollection => "report_collection", + Self::Monitoring => "monitoring", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ToolKind { + BrowserScript, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BootstrapAnalysis { + pub target_url: Option, + pub expected_domain: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SceneSourceAnalysis { + pub scene_kind: SceneKind, + pub tool_kind: ToolKind, + pub bootstrap: BootstrapAnalysis, + pub collection_entry_script: Option, + pub source_dir: PathBuf, +} + +#[derive(Debug)] +pub struct AnalyzeSceneError { + message: String, +} + +impl AnalyzeSceneError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for AnalyzeSceneError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for AnalyzeSceneError {} + +/// Analyze scene source with an optional scene kind hint. +/// +/// The hint parameter takes priority over meta tags. +/// If neither hint nor meta tag is present, defaults to ReportCollection. +pub fn analyze_scene_source_with_hint( + source_dir: &Path, + scene_kind_hint: Option, +) -> Result { + let index_path = source_dir.join("index.html"); + let html = fs::read_to_string(&index_path).map_err(|err| { + AnalyzeSceneError::new(format!( + "failed to read scene source {}: {err}", + index_path.display() + )) + })?; + + // Determine scene kind: hint > meta > default + let scene_kind = if let Some(hint) = scene_kind_hint { + hint + } else { + let meta_kind = meta_content(&html, "sgclaw-scene-kind"); + meta_kind + .as_deref() + .and_then(SceneKind::from_str) + .unwrap_or(SceneKind::ReportCollection) + }; + + // Tool kind is currently only browser_script + let tool_kind = meta_content(&html, "sgclaw-tool-kind"); + if let Some(ref tk) = tool_kind { + if tk != "browser_script" { + return Err(AnalyzeSceneError::new(format!( + "unsupported tool kind: {}", + tk + ))); + } + } + // Default tool kind to BrowserScript + + let target_url = meta_content(&html, "sgclaw-target-url"); + let expected_domain = meta_content(&html, "sgclaw-expected-domain"); + let entry_script = meta_content(&html, "sgclaw-entry-script"); + + // Validate required fields based on scene kind + match scene_kind { + SceneKind::ReportCollection => { + // ReportCollection requires target_url, expected_domain, and entry_script + if target_url.as_deref().unwrap_or_default().trim().is_empty() + || expected_domain + .as_deref() + .unwrap_or_default() + .trim() + .is_empty() + || entry_script + .as_deref() + .unwrap_or_default() + .trim() + .is_empty() + { + return Err(AnalyzeSceneError::new( + "report_collection scene requires target_url, expected_domain, and entry_script", + )); + } + } + SceneKind::Monitoring => { + // Monitoring type has optional fields - no validation needed + } + } + + Ok(SceneSourceAnalysis { + scene_kind, + tool_kind: ToolKind::BrowserScript, + bootstrap: BootstrapAnalysis { + target_url, + expected_domain, + }, + collection_entry_script: entry_script, + source_dir: source_dir.to_path_buf(), + }) +} + +/// Analyze scene source (compatibility wrapper). +/// +/// Requires meta tags to be present. For new code, use `analyze_scene_source_with_hint`. +pub fn analyze_scene_source(source_dir: &Path) -> Result { + let index_path = source_dir.join("index.html"); + let html = fs::read_to_string(&index_path).map_err(|err| { + AnalyzeSceneError::new(format!( + "failed to read scene source {}: {err}", + index_path.display() + )) + })?; + + let scene_kind = meta_content(&html, "sgclaw-scene-kind"); + let tool_kind = meta_content(&html, "sgclaw-tool-kind"); + if scene_kind.as_deref() != Some("report_collection") + || tool_kind.as_deref() != Some("browser_script") + { + return Err(AnalyzeSceneError::new( + "generated scene v1 supports report/collection browser_script only", + )); + } + + let target_url = meta_content(&html, "sgclaw-target-url"); + let expected_domain = meta_content(&html, "sgclaw-expected-domain"); + let entry_script = meta_content(&html, "sgclaw-entry-script"); + if target_url.as_deref().unwrap_or_default().trim().is_empty() + || expected_domain + .as_deref() + .unwrap_or_default() + .trim() + .is_empty() + || entry_script + .as_deref() + .unwrap_or_default() + .trim() + .is_empty() + { + return Err(AnalyzeSceneError::new( + "generated scene source must declare target url, expected domain, and entry script", + )); + } + + Ok(SceneSourceAnalysis { + scene_kind: SceneKind::ReportCollection, + tool_kind: ToolKind::BrowserScript, + bootstrap: BootstrapAnalysis { + target_url, + expected_domain, + }, + collection_entry_script: entry_script, + source_dir: source_dir.to_path_buf(), + }) +} + +fn meta_content(html: &str, name: &str) -> Option { + for tag in html + .split('<') + .filter(|fragment| fragment.starts_with("meta")) + { + let tag = tag.split('>').next().unwrap_or(tag); + if attr_value(tag, "name").as_deref() == Some(name) { + return attr_value(tag, "content").map(|value| value.trim().to_string()); + } + } + None +} + +fn attr_value(tag: &str, attr: &str) -> Option { + let needle = format!("{attr}="); + let start = tag.find(&needle)? + needle.len(); + let rest = &tag[start..]; + let quote = rest.chars().next()?; + if quote != '"' && quote != '\'' { + return None; + } + let rest = &rest[quote.len_utf8()..]; + let end = rest.find(quote)?; + Some(rest[..end].to_string()) +} diff --git a/tests/fixtures/generated_scene/non_report/index.html b/tests/fixtures/generated_scene/non_report/index.html new file mode 100644 index 0000000..9588e08 --- /dev/null +++ b/tests/fixtures/generated_scene/non_report/index.html @@ -0,0 +1,15 @@ + + + + + 普通门户页面 + + + + + + + + + + diff --git a/tests/scene_generator_test.rs b/tests/scene_generator_test.rs new file mode 100644 index 0000000..18ae063 --- /dev/null +++ b/tests/scene_generator_test.rs @@ -0,0 +1,142 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use sgclaw::compat::scene_platform::registry::load_scene_registry; +use sgclaw::generated_scene::analyzer::{analyze_scene_source, analyze_scene_source_with_hint, SceneKind, ToolKind}; +use sgclaw::generated_scene::generator::{generate_scene_package, GenerateSceneRequest}; + +#[test] +fn analyzer_classifies_supported_report_collection_source() { + let analysis = analyze_scene_source(Path::new( + "tests/fixtures/generated_scene/report_collection", + )) + .unwrap(); + + assert_eq!(analysis.scene_kind, SceneKind::ReportCollection); + assert_eq!(analysis.tool_kind, ToolKind::BrowserScript); + assert_eq!( + analysis.bootstrap.target_url.as_deref(), + Some("http://20.76.57.61:18080/gsllys/tqLinelossStatis/tqQualifyRateMonitor") + ); + assert_eq!( + analysis.bootstrap.expected_domain.as_deref(), + Some("20.76.57.61") + ); + assert_eq!( + analysis.collection_entry_script.as_deref(), + Some("js/report.js") + ); +} + +#[test] +fn generator_writes_registration_ready_package_with_scene_toml() { + let output_root = temp_workspace("sgclaw-scene-generator"); + + generate_scene_package(GenerateSceneRequest { + source_dir: PathBuf::from("tests/fixtures/generated_scene/report_collection"), + scene_id: "sample-report-scene".to_string(), + scene_name: "示例报表场景".to_string(), + output_root: output_root.clone(), + lessons_path: PathBuf::from("docs/superpowers/references/tq-lineloss-lessons-learned.toml"), + }) + .unwrap(); + + let skill_root = output_root.join("skills/sample-report-scene"); + assert!(skill_root.join("SKILL.toml").exists()); + assert!(skill_root.join("SKILL.md").exists()); + assert!(skill_root.join("scene.toml").exists()); + assert!(skill_root + .join("scripts/collect_sample_report_scene.js") + .exists()); + assert!(skill_root + .join("scripts/collect_sample_report_scene.test.js") + .exists()); + assert!(skill_root.join("references/generation-lessons.md").exists()); + assert!(skill_root.join("references/org-dictionary.json").exists()); + let generated_script = + fs::read_to_string(skill_root.join("scripts/collect_sample_report_scene.js")).unwrap(); + assert!(generated_script.contains("return buildBrowserEntrypointResult(args);")); + let generated_manifest = fs::read_to_string(skill_root.join("scene.toml")).unwrap(); + assert!(generated_manifest.contains("resolver = \"dictionary_entity\"")); + assert!(generated_manifest.contains("dictionary_ref = \"references/org-dictionary.json\"")); + assert!(generated_manifest.contains("required = true")); + + let registry = load_scene_registry(&output_root.join("skills")).unwrap(); + let entry = registry + .iter() + .find(|entry| entry.manifest.scene.id == "sample-report-scene") + .expect("generated package should be registration-ready"); + assert_eq!(entry.manifest.scene.kind, "browser_script"); + assert_eq!(entry.manifest.scene.category, "report_collection"); + assert_eq!(entry.manifest.scene.tool, "collect_sample_report_scene"); + assert_eq!(entry.manifest.bootstrap.expected_domain, "20.76.57.61"); +} + +#[test] +fn generator_rejects_non_report_source_with_explicit_reason() { + let err = + analyze_scene_source(Path::new("tests/fixtures/generated_scene/non_report")).unwrap_err(); + + assert!(err + .to_string() + .contains("report/collection browser_script only")); +} + +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 +} + +#[test] +fn analyzer_accepts_missing_meta_with_scene_kind_hint() { + // non_report fixture has no scene-kind meta tag + let analysis = analyze_scene_source_with_hint( + Path::new("tests/fixtures/generated_scene/non_report"), + Some(SceneKind::ReportCollection), + ) + .unwrap(); + + // should succeed, using hint parameter as type + assert_eq!(analysis.scene_kind, SceneKind::ReportCollection); +} + +#[test] +fn analyzer_uses_hint_when_meta_missing() { + let analysis = analyze_scene_source_with_hint( + Path::new("tests/fixtures/generated_scene/non_report"), + Some(SceneKind::Monitoring), + ) + .unwrap(); + + assert_eq!(analysis.scene_kind, SceneKind::Monitoring); +} + +#[test] +fn analyzer_uses_meta_when_present_and_no_hint() { + // report_collection fixture has correct meta tag + let analysis = analyze_scene_source_with_hint( + Path::new("tests/fixtures/generated_scene/report_collection"), + None, + ) + .unwrap(); + + assert_eq!(analysis.scene_kind, SceneKind::ReportCollection); +} + +#[test] +fn analyzer_hint_overrides_meta() { + // user choice takes priority over meta tag + let analysis = analyze_scene_source_with_hint( + Path::new("tests/fixtures/generated_scene/report_collection"), + Some(SceneKind::Monitoring), + ) + .unwrap(); + + assert_eq!(analysis.scene_kind, SceneKind::Monitoring); +}