diff --git a/tests/scene_generator_modes_test.rs b/tests/scene_generator_modes_test.rs new file mode 100644 index 0000000..98c1433 --- /dev/null +++ b/tests/scene_generator_modes_test.rs @@ -0,0 +1,318 @@ +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use sgclaw::generated_scene::analyzer::SceneKind; +use sgclaw::generated_scene::generator::{generate_scene_package, GenerateSceneRequest}; +use sgclaw::generated_scene::ir::{ + ApiEndpointIr, ModeConditionIr, ModeIr, NormalizeRulesIr, SceneIdDiagnosticsIr, SceneIr, + WorkflowArchetype, +}; + +fn make_test_mode( + name: &str, + url: &str, + content_type: Option<&str>, + response_path: &str, +) -> ModeIr { + ModeIr { + name: name.to_string(), + label: Some(name.to_string()), + condition: Some(ModeConditionIr { + field: "period_mode".to_string(), + operator: "equals".to_string(), + value: serde_json::Value::String(name.to_string()), + }), + api_endpoint: Some(ApiEndpointIr { + name: format!("{}_endpoint", name), + url: url.to_string(), + method: "POST".to_string(), + content_type: content_type.map(|s| s.to_string()), + description: None, + }), + column_defs: vec![("id".to_string(), "ID".to_string())], + request_template: serde_json::json!({}), + normalize_rules: Some(NormalizeRulesIr { + rules_type: "validate_required".to_string(), + required_fields: vec!["id".to_string()], + filter_null: true, + }), + response_path: response_path.to_string(), + } +} + +fn make_test_scene_ir(modes: Vec) -> SceneIr { + let is_multi = modes.len() > 1; + SceneIr { + scene_id: "test-scene".to_string(), + scene_id_diagnostics: SceneIdDiagnosticsIr::default(), + scene_name: "Test Scene".to_string(), + scene_kind: "report_collection".to_string(), + workflow_archetype: Some(if is_multi { + WorkflowArchetype::MultiModeRequest + } else { + WorkflowArchetype::SingleRequestTable + }), + bootstrap: Default::default(), + params: Vec::new(), + modes, + default_mode: Some("month".to_string()), + mode_switch_field: Some("period_mode".to_string()), + workflow_steps: vec![ + sgclaw::generated_scene::ir::WorkflowStepIr { + step_type: "request".to_string(), + description: Some("select mode and query corresponding endpoint".to_string()), + ..Default::default() + }, + sgclaw::generated_scene::ir::WorkflowStepIr { + step_type: "transform".to_string(), + description: Some("normalize mode-specific table rows".to_string()), + ..Default::default() + }, + ], + workflow_evidence: Default::default(), + request_template: serde_json::Value::Null, + response_path: "".to_string(), + normalize_rules: None, + artifact_contract: Default::default(), + validation_hints: Default::default(), + evidence: Vec::new(), + readiness: Default::default(), + api_endpoints: Vec::new(), + static_params: Default::default(), + column_defs: Vec::new(), + confidence: 0.0, + uncertainties: Vec::new(), + } +} + +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 1: Single mode generates MODES array (routes through compile_multi_mode_request) +#[test] +fn test_single_mode_generates_modes_array() { + let output_root = temp_workspace("sgclaw-single-mode-test"); + let modes = vec![make_test_mode( + "month", + "http://example.com/api/month", + None, + "data", + )]; + let scene_ir = make_test_scene_ir(modes); + + // Use SingleRequestTable archetype - the compile path should auto-wrap into multi-mode + 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(), + method: "POST".to_string(), + content_type: None, + description: None, + }]; + + generate_scene_package(GenerateSceneRequest { + source_dir: PathBuf::from("tests/fixtures/generated_scene/report_collection"), + scene_id: "single-mode-scene".to_string(), + scene_name: "Single Mode Scene".to_string(), + scene_kind: Some(SceneKind::ReportCollection), + target_url: None, + output_root: output_root.clone(), + lessons_path: None, + scene_info_json: None, + scene_ir_json: Some(scene_ir), + }) + .unwrap(); + + let skill_root = output_root.join("skills/single-mode-scene"); + let generated_script = + 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" + ); +} + +/// Test 2: Multi-mode generates mode routing (detectMode and MODES.find) +#[test] +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", + ), + ]; + let scene_ir = make_test_scene_ir(modes); + + generate_scene_package(GenerateSceneRequest { + source_dir: PathBuf::from("tests/fixtures/generated_scene/report_collection"), + scene_id: "multi-mode-scene".to_string(), + scene_name: "Multi Mode Scene".to_string(), + scene_kind: Some(SceneKind::ReportCollection), + target_url: None, + output_root: output_root.clone(), + lessons_path: None, + scene_info_json: None, + scene_ir_json: Some(scene_ir), + }) + .unwrap(); + + let skill_root = output_root.join("skills/multi-mode-scene"); + let generated_script = + fs::read_to_string(skill_root.join("scripts/collect_multi_mode_scene.js")).unwrap(); + + assert!( + generated_script.contains("function detectMode"), + "Generated JS should contain 'detectMode' function for multi-mode routing" + ); + assert!( + generated_script.contains("MODES.find"), + "Generated JS should contain 'MODES.find' for mode selection" + ); +} + +/// Test 3: Form-urlencoded request body uses Object.entries().join('&') not JSON.stringify +#[test] +fn test_form_urlencoded_request_body() { + let output_root = temp_workspace("sgclaw-form-urlencoded-test"); + let modes = vec![make_test_mode( + "month", + "http://example.com/api/month", + Some("application/x-www-form-urlencoded"), + "data", + )]; + let scene_ir = make_test_scene_ir(modes); + + generate_scene_package(GenerateSceneRequest { + source_dir: PathBuf::from("tests/fixtures/generated_scene/report_collection"), + scene_id: "form-urlencoded-scene".to_string(), + scene_name: "Form URL Encoded Scene".to_string(), + scene_kind: Some(SceneKind::ReportCollection), + target_url: None, + output_root: output_root.clone(), + lessons_path: None, + scene_info_json: None, + scene_ir_json: Some(scene_ir), + }) + .unwrap(); + + let skill_root = output_root.join("skills/form-urlencoded-scene"); + let generated_script = + fs::read_to_string(skill_root.join("scripts/collect_form_urlencoded_scene.js")).unwrap(); + + // The buildModeRequest function should use Object.entries for form-urlencoded + assert!( + generated_script.contains("Object.entries(requestBody)"), + "Generated JS should use Object.entries for form-urlencoded body encoding" + ); + assert!( + generated_script.contains(".join('&')"), + "Generated JS should join form-urlencoded entries with '&'" + ); + // Verify the conditional exists in buildModeRequest + assert!( + generated_script.contains("application/x-www-form-urlencoded"), + "Generated JS should reference form-urlencoded content type" + ); +} + +/// Test 4: Response path extraction uses mode.responsePath in the template +#[test] +fn test_response_path_extraction_in_template() { + let output_root = temp_workspace("sgclaw-response-path-test"); + let modes = vec![make_test_mode( + "month", + "http://example.com/api/month", + None, + "data.list", + )]; + let scene_ir = make_test_scene_ir(modes); + + generate_scene_package(GenerateSceneRequest { + source_dir: PathBuf::from("tests/fixtures/generated_scene/report_collection"), + scene_id: "response-path-scene".to_string(), + scene_name: "Response Path Scene".to_string(), + scene_kind: Some(SceneKind::ReportCollection), + target_url: None, + output_root: output_root.clone(), + lessons_path: None, + scene_info_json: None, + scene_ir_json: Some(scene_ir), + }) + .unwrap(); + + let skill_root = output_root.join("skills/response-path-scene"); + let generated_script = + fs::read_to_string(skill_root.join("scripts/collect_response_path_scene.js")).unwrap(); + + // The multi-mode template uses mode.responsePath for response extraction + assert!( + generated_script.contains("mode.responsePath"), + "Generated JS should use 'mode.responsePath' for per-mode response extraction" + ); + // The safeGet call should reference the mode's responsePath + assert!( + generated_script.contains("safeGet(raw, mode.responsePath"), + "Generated JS should call safeGet with mode.responsePath" + ); +} + +/// Test 5: processData flag in $.ajax call with correct conditional +#[test] +fn test_process_data_flag_in_ajax() { + let output_root = temp_workspace("sgclaw-process-data-test"); + let modes = vec![make_test_mode( + "month", + "http://example.com/api/month", + Some("application/x-www-form-urlencoded"), + "data", + )]; + let scene_ir = make_test_scene_ir(modes); + + generate_scene_package(GenerateSceneRequest { + source_dir: PathBuf::from("tests/fixtures/generated_scene/report_collection"), + scene_id: "process-data-scene".to_string(), + scene_name: "Process Data Scene".to_string(), + scene_kind: Some(SceneKind::ReportCollection), + target_url: None, + output_root: output_root.clone(), + lessons_path: None, + scene_info_json: None, + scene_ir_json: Some(scene_ir), + }) + .unwrap(); + + let skill_root = output_root.join("skills/process-data-scene"); + let generated_script = + fs::read_to_string(skill_root.join("scripts/collect_process_data_scene.js")).unwrap(); + + // The $.ajax call should contain processData flag + assert!( + generated_script.contains("processData:"), + "Generated JS $.ajax call should contain 'processData:' flag" + ); + // processData should be false for form-urlencoded (negated condition) + assert!( + 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" + ); +}