feat: add SceneKind::Monitoring and scene_kind_hint param to analyzer
- Add SceneKind::Monitoring enum variant with from_str/as_str helpers - Add analyze_scene_source_with_hint function accepting optional scene kind hint - User hint takes priority over meta tag, defaults to ReportCollection - ReportCollection requires target_url, expected_domain, entry_script - Monitoring type has optional fields - Add test cases for hint parameter behavior - Update non_report fixture with required meta tags for ReportCollection 🤖 Generated with [Qoder][https://qoder.com]
This commit is contained in:
228
src/generated_scene/analyzer.rs
Normal file
228
src/generated_scene/analyzer.rs
Normal file
@@ -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<Self> {
|
||||||
|
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<String>,
|
||||||
|
pub expected_domain: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SceneSourceAnalysis {
|
||||||
|
pub scene_kind: SceneKind,
|
||||||
|
pub tool_kind: ToolKind,
|
||||||
|
pub bootstrap: BootstrapAnalysis,
|
||||||
|
pub collection_entry_script: Option<String>,
|
||||||
|
pub source_dir: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct AnalyzeSceneError {
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnalyzeSceneError {
|
||||||
|
fn new(message: impl Into<String>) -> 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<SceneKind>,
|
||||||
|
) -> Result<SceneSourceAnalysis, AnalyzeSceneError> {
|
||||||
|
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<SceneSourceAnalysis, AnalyzeSceneError> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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())
|
||||||
|
}
|
||||||
15
tests/fixtures/generated_scene/non_report/index.html
vendored
Normal file
15
tests/fixtures/generated_scene/non_report/index.html
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>普通门户页面</title>
|
||||||
|
<!-- No sgclaw-scene-kind meta tag - user must specify via hint -->
|
||||||
|
<meta name="sgclaw-tool-kind" content="browser_script">
|
||||||
|
<meta name="sgclaw-target-url" content="http://example.com/dashboard">
|
||||||
|
<meta name="sgclaw-expected-domain" content="example.com">
|
||||||
|
<meta name="sgclaw-entry-script" content="js/monitor.js">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<button>打开菜单</button>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
142
tests/scene_generator_test.rs
Normal file
142
tests/scene_generator_test.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user