diff --git a/crates/op-orchestrator/src/design_system.rs b/crates/op-orchestrator/src/design_system.rs index d08ba096e..5c83ad6f5 100644 --- a/crates/op-orchestrator/src/design_system.rs +++ b/crates/op-orchestrator/src/design_system.rs @@ -250,11 +250,7 @@ fn extract_code_fence(text: &str) -> Option<&str> { let after_open = &text[fence_start + 3..]; // Skip optional "json" language tag and the following newline - let content_start = if after_open.starts_with("json") { - &after_open[4..] - } else { - after_open - }; + let content_start = after_open.strip_prefix("json").unwrap_or(after_open); // Skip leading newline let content_start = content_start.strip_prefix('\n').unwrap_or(content_start); diff --git a/crates/op-orchestrator/src/design_system_tests.rs b/crates/op-orchestrator/src/design_system_tests.rs index 1da1e6054..1273371a1 100644 --- a/crates/op-orchestrator/src/design_system_tests.rs +++ b/crates/op-orchestrator/src/design_system_tests.rs @@ -1,553 +1,551 @@ //! Tests for `design_system.rs` — A1 step 1 (failing tests first, TDD). //! B1 tests appended at the bottom. -#[cfg(test)] -mod tests { - use crate::design_system::{default_design_system, parse_design_system, DesignSystem}; - use crate::types::{DesignRequest, Progress}; +use crate::design_system::{default_design_system, parse_design_system, DesignSystem}; +use crate::types::{DesignRequest, Progress}; - // ── parse_design_system: direct JSON round-trip ─────────────────────────── +// ── parse_design_system: direct JSON round-trip ─────────────────────────── - #[test] - fn parse_design_system_direct_json() { - let ds = default_design_system(); - let json = serde_json::to_string(ds).expect("serialize default"); - let parsed = parse_design_system(&json); - assert_eq!(parsed.palette, ds.palette); - assert_eq!(parsed.aesthetic, ds.aesthetic); - } +#[test] +fn parse_design_system_direct_json() { + let ds = default_design_system(); + let json = serde_json::to_string(ds).expect("serialize default"); + let parsed = parse_design_system(&json); + assert_eq!(parsed.palette, ds.palette); + assert_eq!(parsed.aesthetic, ds.aesthetic); +} - // ── parse_design_system: code-fence stripping ───────────────────────────── +// ── parse_design_system: code-fence stripping ───────────────────────────── - #[test] - fn parse_design_system_strips_json_code_fence() { - let ds = default_design_system(); - let inner = serde_json::to_string(ds).expect("serialize"); - let fenced = format!("```json\n{inner}\n```"); - let parsed = parse_design_system(&fenced); - assert_eq!(parsed.palette, ds.palette); - } +#[test] +fn parse_design_system_strips_json_code_fence() { + let ds = default_design_system(); + let inner = serde_json::to_string(ds).expect("serialize"); + let fenced = format!("```json\n{inner}\n```"); + let parsed = parse_design_system(&fenced); + assert_eq!(parsed.palette, ds.palette); +} - #[test] - fn parse_design_system_strips_bare_code_fence() { - let ds = default_design_system(); - let inner = serde_json::to_string(ds).expect("serialize"); - let fenced = format!("```\n{inner}\n```"); - let parsed = parse_design_system(&fenced); - assert_eq!(parsed.palette, ds.palette); - } +#[test] +fn parse_design_system_strips_bare_code_fence() { + let ds = default_design_system(); + let inner = serde_json::to_string(ds).expect("serialize"); + let fenced = format!("```\n{inner}\n```"); + let parsed = parse_design_system(&fenced); + assert_eq!(parsed.palette, ds.palette); +} - // ── parse_design_system: brace extraction ──────────────────────────────── +// ── parse_design_system: brace extraction ──────────────────────────────── - #[test] - fn parse_design_system_brace_extract() { - let ds = default_design_system(); - let inner = serde_json::to_string(ds).expect("serialize"); - let wrapped = format!("Some preamble text\n{inner}\nSome trailing text."); - let parsed = parse_design_system(&wrapped); - assert_eq!(parsed.palette, ds.palette); - } +#[test] +fn parse_design_system_brace_extract() { + let ds = default_design_system(); + let inner = serde_json::to_string(ds).expect("serialize"); + let wrapped = format!("Some preamble text\n{inner}\nSome trailing text."); + let parsed = parse_design_system(&wrapped); + assert_eq!(parsed.palette, ds.palette); +} - // ── parse_design_system: fallback to DEFAULT on garbage ────────────────── +// ── parse_design_system: fallback to DEFAULT on garbage ────────────────── - #[test] - fn parse_design_system_fallback_on_garbage() { - let ds = default_design_system(); - let parsed = parse_design_system("not json at all"); - assert_eq!(parsed.palette, ds.palette); - assert_eq!(parsed.aesthetic, ds.aesthetic); - } +#[test] +fn parse_design_system_fallback_on_garbage() { + let ds = default_design_system(); + let parsed = parse_design_system("not json at all"); + assert_eq!(parsed.palette, ds.palette); + assert_eq!(parsed.aesthetic, ds.aesthetic); +} - #[test] - fn parse_design_system_fallback_on_missing_palette() { - // Valid JSON but missing palette field → fallback - let ds = default_design_system(); - let parsed = parse_design_system(r#"{"aesthetic": "flat"}"#); - assert_eq!(parsed.palette, ds.palette); - } +#[test] +fn parse_design_system_fallback_on_missing_palette() { + // Valid JSON but missing palette field → fallback + let ds = default_design_system(); + let parsed = parse_design_system(r#"{"aesthetic": "flat"}"#); + assert_eq!(parsed.palette, ds.palette); +} - // ── DEFAULT_DESIGN_SYSTEM round-trips via serde ─────────────────────────── +// ── DEFAULT_DESIGN_SYSTEM round-trips via serde ─────────────────────────── - #[test] - fn default_design_system_serde_round_trip() { - let ds = default_design_system(); - let json = serde_json::to_string(ds).expect("serialize"); - let back: DesignSystem = serde_json::from_str(&json).expect("deserialize"); - assert_eq!(back.palette, ds.palette); - assert_eq!(back.typography.heading_font, ds.typography.heading_font); - assert_eq!(back.typography.body_font, ds.typography.body_font); - assert_eq!(back.typography.scale, ds.typography.scale); - assert_eq!(back.spacing.unit, ds.spacing.unit); - assert_eq!(back.spacing.scale, ds.spacing.scale); - assert_eq!(back.radius, ds.radius); - assert_eq!(back.aesthetic, ds.aesthetic); - } +#[test] +fn default_design_system_serde_round_trip() { + let ds = default_design_system(); + let json = serde_json::to_string(ds).expect("serialize"); + let back: DesignSystem = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(back.palette, ds.palette); + assert_eq!(back.typography.heading_font, ds.typography.heading_font); + assert_eq!(back.typography.body_font, ds.typography.body_font); + assert_eq!(back.typography.scale, ds.typography.scale); + assert_eq!(back.spacing.unit, ds.spacing.unit); + assert_eq!(back.spacing.scale, ds.spacing.scale); + assert_eq!(back.radius, ds.radius); + assert_eq!(back.aesthetic, ds.aesthetic); +} - // ── DEFAULT_DESIGN_SYSTEM exact values (port faithful to TS) ───────────── +// ── DEFAULT_DESIGN_SYSTEM exact values (port faithful to TS) ───────────── - #[test] - fn default_design_system_palette_values() { - let p = &default_design_system().palette; - assert_eq!(p["background"], "#F8FAFC"); - assert_eq!(p["surface"], "#FFFFFF"); - assert_eq!(p["text"], "#0F172A"); - assert_eq!(p["textSecondary"], "#475569"); - assert_eq!(p["primary"], "#2563EB"); - assert_eq!(p["primaryLight"], "#DBEAFE"); - assert_eq!(p["accent"], "#0EA5E9"); - assert_eq!(p["border"], "#E2E8F0"); - } +#[test] +fn default_design_system_palette_values() { + let p = &default_design_system().palette; + assert_eq!(p["background"], "#F8FAFC"); + assert_eq!(p["surface"], "#FFFFFF"); + assert_eq!(p["text"], "#0F172A"); + assert_eq!(p["textSecondary"], "#475569"); + assert_eq!(p["primary"], "#2563EB"); + assert_eq!(p["primaryLight"], "#DBEAFE"); + assert_eq!(p["accent"], "#0EA5E9"); + assert_eq!(p["border"], "#E2E8F0"); +} - #[test] - fn default_design_system_typography_values() { - let t = &default_design_system().typography; - assert_eq!(t.heading_font, "Space Grotesk"); - assert_eq!(t.body_font, "Inter"); - assert_eq!(t.scale, vec![14.0, 16.0, 20.0, 28.0, 40.0, 56.0]); - } +#[test] +fn default_design_system_typography_values() { + let t = &default_design_system().typography; + assert_eq!(t.heading_font, "Space Grotesk"); + assert_eq!(t.body_font, "Inter"); + assert_eq!(t.scale, vec![14.0, 16.0, 20.0, 28.0, 40.0, 56.0]); +} - #[test] - fn default_design_system_spacing_values() { - let s = &default_design_system().spacing; - assert_eq!(s.unit, 8.0); - assert_eq!(s.scale, vec![8.0, 16.0, 24.0, 32.0, 48.0, 64.0]); - } +#[test] +fn default_design_system_spacing_values() { + let s = &default_design_system().spacing; + assert_eq!(s.unit, 8.0); + assert_eq!(s.scale, vec![8.0, 16.0, 24.0, 32.0, 48.0, 64.0]); +} - #[test] - fn default_design_system_radius_values() { - assert_eq!(default_design_system().radius, vec![8.0, 12.0, 16.0]); - } +#[test] +fn default_design_system_radius_values() { + assert_eq!(default_design_system().radius, vec![8.0, 12.0, 16.0]); +} - #[test] - fn default_design_system_aesthetic_value() { - assert_eq!(default_design_system().aesthetic, "clean modern blue"); - } +#[test] +fn default_design_system_aesthetic_value() { + assert_eq!(default_design_system().aesthetic, "clean modern blue"); +} - // ── DesignRequest.visual_ref_enabled defaults to false ──────────────────── +// ── DesignRequest.visual_ref_enabled defaults to false ──────────────────── - #[test] - fn design_request_visual_ref_enabled_defaults_false() { - // JSON without `visualRefEnabled` field - let json = r#"{"prompt":"test","concurrency":1}"#; - let req: DesignRequest = serde_json::from_str(json).expect("deserialize"); - assert!( - !req.visual_ref_enabled, - "visual_ref_enabled should default to false" - ); - } +#[test] +fn design_request_visual_ref_enabled_defaults_false() { + // JSON without `visualRefEnabled` field + let json = r#"{"prompt":"test","concurrency":1}"#; + let req: DesignRequest = serde_json::from_str(json).expect("deserialize"); + assert!( + !req.visual_ref_enabled, + "visual_ref_enabled should default to false" + ); +} - #[test] - fn design_request_visual_ref_enabled_can_be_set_true() { - let json = r#"{"prompt":"test","concurrency":1,"visualRefEnabled":true}"#; - let req: DesignRequest = serde_json::from_str(json).expect("deserialize"); - assert!(req.visual_ref_enabled); - } +#[test] +fn design_request_visual_ref_enabled_can_be_set_true() { + let json = r#"{"prompt":"test","concurrency":1,"visualRefEnabled":true}"#; + let req: DesignRequest = serde_json::from_str(json).expect("deserialize"); + assert!(req.visual_ref_enabled); +} - #[test] - fn design_request_visual_ref_enabled_literal_compiles() { - // Verify that the field can be written in struct literal form - let req = DesignRequest { - prompt: "test".into(), - model: None, - provider: None, - design_md: None, - concurrency: 1, - append_context: None, - validation_enabled: true, - visual_ref_enabled: false, - }; - assert!(!req.visual_ref_enabled); - } +#[test] +fn design_request_visual_ref_enabled_literal_compiles() { + // Verify that the field can be written in struct literal form + let req = DesignRequest { + prompt: "test".into(), + model: None, + provider: None, + design_md: None, + concurrency: 1, + append_context: None, + validation_enabled: true, + visual_ref_enabled: false, + }; + assert!(!req.visual_ref_enabled); +} - // ── Progress::VisualRef* variants compile and pattern-match ────────────── +// ── Progress::VisualRef* variants compile and pattern-match ────────────── - #[test] - fn progress_visual_ref_variants_compile() { - let variants = vec![ - Progress::VisualRefStarted, - Progress::VisualRefDesignSystem { var_count: 25 }, - Progress::VisualRefHtmlGenerated { byte_len: 4096 }, - Progress::VisualRefScreenshotReady { skipped: false }, - Progress::VisualRefFallback { - reason: "LLM returned empty HTML".into(), - }, - ]; - for v in variants { - match v { - Progress::VisualRefStarted => {} - Progress::VisualRefDesignSystem { var_count } => { - assert_eq!(var_count, 25); - } - Progress::VisualRefHtmlGenerated { byte_len } => { - assert_eq!(byte_len, 4096); - } - Progress::VisualRefScreenshotReady { skipped } => { - assert!(!skipped); - } - Progress::VisualRefFallback { reason } => { - assert!(!reason.is_empty()); - } - _ => {} +#[test] +fn progress_visual_ref_variants_compile() { + let variants = vec![ + Progress::VisualRefStarted, + Progress::VisualRefDesignSystem { var_count: 25 }, + Progress::VisualRefHtmlGenerated { byte_len: 4096 }, + Progress::VisualRefScreenshotReady { skipped: false }, + Progress::VisualRefFallback { + reason: "LLM returned empty HTML".into(), + }, + ]; + for v in variants { + match v { + Progress::VisualRefStarted => {} + Progress::VisualRefDesignSystem { var_count } => { + assert_eq!(var_count, 25); } + Progress::VisualRefHtmlGenerated { byte_len } => { + assert_eq!(byte_len, 4096); + } + Progress::VisualRefScreenshotReady { skipped } => { + assert!(!skipped); + } + Progress::VisualRefFallback { reason } => { + assert!(!reason.is_empty()); + } + _ => {} } } +} - // ── DesignSystem struct fields exist and are accessible ─────────────────── +// ── DesignSystem struct fields exist and are accessible ─────────────────── - #[test] - fn design_system_struct_fields_accessible() { - let ds = DesignSystem { - palette: { - let mut m = std::collections::BTreeMap::new(); - m.insert("background".to_string(), "#F8FAFC".to_string()); - m - }, - typography: crate::design_system::Typography { - heading_font: "Space Grotesk".into(), - body_font: "Inter".into(), - scale: vec![14.0, 16.0], - }, - spacing: crate::design_system::Spacing { - unit: 8.0, - scale: vec![8.0, 16.0], - }, - radius: vec![8.0], - aesthetic: "clean".into(), - }; - assert_eq!(ds.palette["background"], "#F8FAFC"); - assert_eq!(ds.typography.heading_font, "Space Grotesk"); - assert_eq!(ds.spacing.unit, 8.0); - } +#[test] +fn design_system_struct_fields_accessible() { + let ds = DesignSystem { + palette: { + let mut m = std::collections::BTreeMap::new(); + m.insert("background".to_string(), "#F8FAFC".to_string()); + m + }, + typography: crate::design_system::Typography { + heading_font: "Space Grotesk".into(), + body_font: "Inter".into(), + scale: vec![14.0, 16.0], + }, + spacing: crate::design_system::Spacing { + unit: 8.0, + scale: vec![8.0, 16.0], + }, + radius: vec![8.0], + aesthetic: "clean".into(), + }; + assert_eq!(ds.palette["background"], "#F8FAFC"); + assert_eq!(ds.typography.heading_font, "Space Grotesk"); + assert_eq!(ds.spacing.unit, 8.0); +} - // ── Task B1: generate_design_system ────────────────────────────────────── +// ── Task B1: generate_design_system ────────────────────────────────────── - /// Scripted LLM returning valid JSON design-system → parsed DesignSystem - /// (not the default — the LLM-provided values win). - #[tokio::test] - async fn generate_design_system_happy_path() { - use crate::design_system::generate_design_system; - use crate::test_support::{ScriptResponse, ScriptedLlm}; - use crate::types::AbortFlag; +/// Scripted LLM returning valid JSON design-system → parsed DesignSystem +/// (not the default — the LLM-provided values win). +#[tokio::test] +async fn generate_design_system_happy_path() { + use crate::design_system::generate_design_system; + use crate::test_support::{ScriptResponse, ScriptedLlm}; + use crate::types::AbortFlag; - // Craft a valid JSON that differs from DEFAULT_DESIGN_SYSTEM so we - // can confirm the LLM value was used. - // Build the JSON string using serde_json to avoid raw-string delimiter conflicts. - let custom_json = serde_json::json!({ - "palette": { - "background": "\u{23}111111", - "surface": "\u{23}222222", - "text": "\u{23}FFFFFF", - "textSecondary": "\u{23}AAAAAA", - "primary": "\u{23}FF0000", - "primaryLight": "\u{23}FF9999", - "accent": "\u{23}00FF00", - "border": "\u{23}333333" - }, - "typography": { - "headingFont": "Roboto", - "bodyFont": "Open Sans", - "scale": [12.0_f64, 14.0, 18.0, 24.0, 36.0, 48.0] - }, - "spacing": { "unit": 4.0_f64, "scale": [4.0_f64, 8.0, 12.0, 16.0, 24.0, 32.0] }, - "radius": [4.0_f64, 8.0, 12.0], - "aesthetic": "dark minimal" + // Craft a valid JSON that differs from DEFAULT_DESIGN_SYSTEM so we + // can confirm the LLM value was used. + // Build the JSON string using serde_json to avoid raw-string delimiter conflicts. + let custom_json = serde_json::json!({ + "palette": { + "background": "\u{23}111111", + "surface": "\u{23}222222", + "text": "\u{23}FFFFFF", + "textSecondary": "\u{23}AAAAAA", + "primary": "\u{23}FF0000", + "primaryLight": "\u{23}FF9999", + "accent": "\u{23}00FF00", + "border": "\u{23}333333" + }, + "typography": { + "headingFont": "Roboto", + "bodyFont": "Open Sans", + "scale": [12.0_f64, 14.0, 18.0, 24.0, 36.0, 48.0] + }, + "spacing": { "unit": 4.0_f64, "scale": [4.0_f64, 8.0, 12.0, 16.0, 24.0, 32.0] }, + "radius": [4.0_f64, 8.0, 12.0], + "aesthetic": "dark minimal" + }) + .to_string(); + + let llm = ScriptedLlm::new(vec![ScriptResponse::Text(custom_json.to_string())]); + let abort = AbortFlag::new(); + let ds = generate_design_system("a dark app", &llm, None, None, &abort).await; + + // LLM-provided values must win over default + assert_eq!(ds.palette["background"], "#111111"); + assert_eq!(ds.typography.heading_font, "Roboto"); + assert_eq!(ds.typography.body_font, "Open Sans"); + assert_eq!(ds.aesthetic, "dark minimal"); + assert_eq!(ds.radius, vec![4.0, 8.0, 12.0]); +} + +/// Scripted LLM returning garbage → fallback to DEFAULT_DESIGN_SYSTEM. +#[tokio::test] +async fn generate_design_system_garbage_falls_back_to_default() { + use crate::design_system::generate_design_system; + use crate::test_support::{ScriptResponse, ScriptedLlm}; + use crate::types::AbortFlag; + + let llm = ScriptedLlm::new(vec![ScriptResponse::Text( + "not valid json at all!".to_string(), + )]); + let abort = AbortFlag::new(); + let ds = generate_design_system("any prompt", &llm, None, None, &abort).await; + let default = default_design_system(); + + assert_eq!(ds.palette, default.palette); + assert_eq!(ds.aesthetic, default.aesthetic); +} + +/// LLM returning JSON wrapped in code fence → parsed correctly. +#[tokio::test] +async fn generate_design_system_code_fence_response() { + use crate::design_system::generate_design_system; + use crate::test_support::{ScriptResponse, ScriptedLlm}; + use crate::types::AbortFlag; + + let ds_default = default_design_system(); + let inner = serde_json::to_string(ds_default).unwrap(); + let fenced = format!("```json\n{inner}\n```"); + + let llm = ScriptedLlm::new(vec![ScriptResponse::Text(fenced)]); + let abort = AbortFlag::new(); + let ds = generate_design_system("prompt", &llm, None, None, &abort).await; + assert_eq!(ds.palette, ds_default.palette); +} + +// ── Task B1: design_system_to_seed_commands ─────────────────────────────── + +/// DEFAULT_DESIGN_SYSTEM → expected number of SetVariable* commands. +/// Faithful to TS `designSystemToVariables` (L134-156): typography is NOT +/// seeded into document variables, only colors + spacing + radius. +/// 8 palette (color) + 6 spacing scale + 3 radius = 17. +#[test] +fn seed_commands_default_count() { + use crate::design_system::design_system_to_seed_commands; + let ds = default_design_system(); + let cmds = design_system_to_seed_commands(ds); + assert_eq!( + cmds.len(), + 17, + "expected 17 seed commands (8 palette + 6 spacing + 3 radius), got {}", + cmds.len() + ); +} + +/// DEFAULT_DESIGN_SYSTEM → no typography variables emitted. +/// Faithful to TS — typography reaches the LLM via prompt context, not vars. +#[test] +fn seed_commands_no_typography_variables() { + use crate::design_system::design_system_to_seed_commands; + use op_editor_core::EditorCommand; + let ds = default_design_system(); + let cmds = design_system_to_seed_commands(ds); + + let has_font_var = cmds.iter().any(|c| match c { + EditorCommand::SetVariableColor { name, .. } + | EditorCommand::SetVariableScalar { name, .. } => { + name.starts_with("font-") || name.starts_with("typography-") + } + _ => false, + }); + assert!( + !has_font_var, + "typography MUST NOT be seeded into document variables (faithful to TS)" + ); +} + +/// Palette colors → SetVariableColor with kebab-case names. +#[test] +fn seed_commands_palette_color_names() { + use crate::design_system::design_system_to_seed_commands; + use op_editor_core::EditorCommand; + + let ds = default_design_system(); + let cmds = design_system_to_seed_commands(ds); + + // Collect all SetVariableColor names + let color_names: Vec = cmds + .iter() + .filter_map(|c| { + if let EditorCommand::SetVariableColor { name, .. } = c { + Some(name.clone()) + } else { + None + } }) - .to_string(); + .collect(); - let llm = ScriptedLlm::new(vec![ScriptResponse::Text(custom_json.to_string())]); - let abort = AbortFlag::new(); - let ds = generate_design_system("a dark app", &llm, None, None, &abort).await; + // Verify all 8 palette keys are present in kebab-case + assert!( + color_names.contains(&"color-background".to_string()), + "missing color-background" + ); + assert!( + color_names.contains(&"color-text".to_string()), + "missing color-text" + ); + assert!( + color_names.contains(&"color-text-secondary".to_string()), + "missing color-text-secondary (textSecondary → text-secondary)" + ); + assert!( + color_names.contains(&"color-primary-light".to_string()), + "missing color-primary-light (primaryLight → primary-light)" + ); + assert_eq!(color_names.len(), 8, "expected 8 color variables"); +} - // LLM-provided values must win over default - assert_eq!(ds.palette["background"], "#111111"); - assert_eq!(ds.typography.heading_font, "Roboto"); - assert_eq!(ds.typography.body_font, "Open Sans"); - assert_eq!(ds.aesthetic, "dark minimal"); - assert_eq!(ds.radius, vec![4.0, 8.0, 12.0]); +/// Palette color value is correctly mapped. +#[test] +fn seed_commands_palette_color_value() { + use crate::design_system::design_system_to_seed_commands; + use op_editor_core::EditorCommand; + + let ds = default_design_system(); + let cmds = design_system_to_seed_commands(ds); + + let bg_cmd = cmds.iter().find( + |c| matches!(c, EditorCommand::SetVariableColor { name, .. } if name == "color-background"), + ); + assert!(bg_cmd.is_some(), "missing color-background command"); + if let Some(EditorCommand::SetVariableColor { hex, .. }) = bg_cmd { + assert_eq!(hex, "#F8FAFC", "wrong color-background value"); } +} - /// Scripted LLM returning garbage → fallback to DEFAULT_DESIGN_SYSTEM. - #[tokio::test] - async fn generate_design_system_garbage_falls_back_to_default() { - use crate::design_system::generate_design_system; - use crate::test_support::{ScriptResponse, ScriptedLlm}; - use crate::types::AbortFlag; +/// Spacing scale → SetVariableScalar::Number with spacing-xs/sm/... names. +#[test] +fn seed_commands_spacing_scale_names() { + use crate::design_system::design_system_to_seed_commands; + use op_editor_core::{EditorCommand, VariableScalarPayload}; - let llm = ScriptedLlm::new(vec![ScriptResponse::Text( - "not valid json at all!".to_string(), - )]); - let abort = AbortFlag::new(); - let ds = generate_design_system("any prompt", &llm, None, None, &abort).await; - let default = default_design_system(); + let ds = default_design_system(); + let cmds = design_system_to_seed_commands(ds); - assert_eq!(ds.palette, default.palette); - assert_eq!(ds.aesthetic, default.aesthetic); - } - - /// LLM returning JSON wrapped in code fence → parsed correctly. - #[tokio::test] - async fn generate_design_system_code_fence_response() { - use crate::design_system::generate_design_system; - use crate::test_support::{ScriptResponse, ScriptedLlm}; - use crate::types::AbortFlag; - - let ds_default = default_design_system(); - let inner = serde_json::to_string(ds_default).unwrap(); - let fenced = format!("```json\n{inner}\n```"); - - let llm = ScriptedLlm::new(vec![ScriptResponse::Text(fenced)]); - let abort = AbortFlag::new(); - let ds = generate_design_system("prompt", &llm, None, None, &abort).await; - assert_eq!(ds.palette, ds_default.palette); - } - - // ── Task B1: design_system_to_seed_commands ─────────────────────────────── - - /// DEFAULT_DESIGN_SYSTEM → expected number of SetVariable* commands. - /// Faithful to TS `designSystemToVariables` (L134-156): typography is NOT - /// seeded into document variables, only colors + spacing + radius. - /// 8 palette (color) + 6 spacing scale + 3 radius = 17. - #[test] - fn seed_commands_default_count() { - use crate::design_system::design_system_to_seed_commands; - let ds = default_design_system(); - let cmds = design_system_to_seed_commands(ds); - assert_eq!( - cmds.len(), - 17, - "expected 17 seed commands (8 palette + 6 spacing + 3 radius), got {}", - cmds.len() - ); - } - - /// DEFAULT_DESIGN_SYSTEM → no typography variables emitted. - /// Faithful to TS — typography reaches the LLM via prompt context, not vars. - #[test] - fn seed_commands_no_typography_variables() { - use crate::design_system::design_system_to_seed_commands; - use op_editor_core::EditorCommand; - let ds = default_design_system(); - let cmds = design_system_to_seed_commands(ds); - - let has_font_var = cmds.iter().any(|c| match c { - EditorCommand::SetVariableColor { name, .. } - | EditorCommand::SetVariableScalar { name, .. } => { - name.starts_with("font-") || name.starts_with("typography-") + let spacing_names: Vec = cmds + .iter() + .filter_map(|c| { + if let EditorCommand::SetVariableScalar { + name, + scalar: VariableScalarPayload::Number(_), + } = c + { + if name.starts_with("spacing-") { + return Some(name.clone()); + } } - _ => false, - }); - assert!( - !has_font_var, - "typography MUST NOT be seeded into document variables (faithful to TS)" - ); - } + None + }) + .collect(); - /// Palette colors → SetVariableColor with kebab-case names. - #[test] - fn seed_commands_palette_color_names() { - use crate::design_system::design_system_to_seed_commands; - use op_editor_core::EditorCommand; + assert!( + spacing_names.contains(&"spacing-xs".to_string()), + "missing spacing-xs" + ); + assert!( + spacing_names.contains(&"spacing-sm".to_string()), + "missing spacing-sm" + ); + assert!( + spacing_names.contains(&"spacing-md".to_string()), + "missing spacing-md" + ); + assert!( + spacing_names.contains(&"spacing-lg".to_string()), + "missing spacing-lg" + ); + assert!( + spacing_names.contains(&"spacing-xl".to_string()), + "missing spacing-xl" + ); + assert!( + spacing_names.contains(&"spacing-2xl".to_string()), + "missing spacing-2xl" + ); + assert_eq!(spacing_names.len(), 6, "expected 6 spacing variables"); +} - let ds = default_design_system(); - let cmds = design_system_to_seed_commands(ds); +/// Radius steps → SetVariableScalar::Number with radius-sm/md/lg names. +#[test] +fn seed_commands_radius_names() { + use crate::design_system::design_system_to_seed_commands; + use op_editor_core::{EditorCommand, VariableScalarPayload}; - // Collect all SetVariableColor names - let color_names: Vec = cmds - .iter() - .filter_map(|c| { - if let EditorCommand::SetVariableColor { name, .. } = c { - Some(name.clone()) - } else { - None + let ds = default_design_system(); + let cmds = design_system_to_seed_commands(ds); + + let radius_names: Vec = cmds + .iter() + .filter_map(|c| { + if let EditorCommand::SetVariableScalar { + name, + scalar: VariableScalarPayload::Number(_), + } = c + { + if name.starts_with("radius-") { + return Some(name.clone()); } - }) - .collect(); + } + None + }) + .collect(); - // Verify all 8 palette keys are present in kebab-case - assert!( - color_names.contains(&"color-background".to_string()), - "missing color-background" - ); - assert!( - color_names.contains(&"color-text".to_string()), - "missing color-text" - ); - assert!( - color_names.contains(&"color-text-secondary".to_string()), - "missing color-text-secondary (textSecondary → text-secondary)" - ); - assert!( - color_names.contains(&"color-primary-light".to_string()), - "missing color-primary-light (primaryLight → primary-light)" - ); - assert_eq!(color_names.len(), 8, "expected 8 color variables"); - } + assert!( + radius_names.contains(&"radius-sm".to_string()), + "missing radius-sm" + ); + assert!( + radius_names.contains(&"radius-md".to_string()), + "missing radius-md" + ); + assert!( + radius_names.contains(&"radius-lg".to_string()), + "missing radius-lg" + ); + assert_eq!(radius_names.len(), 3, "expected 3 radius variables"); +} - /// Palette color value is correctly mapped. - #[test] - fn seed_commands_palette_color_value() { - use crate::design_system::design_system_to_seed_commands; - use op_editor_core::EditorCommand; +// ── Task B1: design_system_to_prompt_context ───────────────────────────── - let ds = default_design_system(); - let cmds = design_system_to_seed_commands(ds); +/// `design_system_to_prompt_context` produces the exact TS template. +#[test] +fn prompt_context_exact_template() { + use crate::design_system::design_system_to_prompt_context; - let bg_cmd = cmds.iter().find(|c| { - matches!(c, EditorCommand::SetVariableColor { name, .. } if name == "color-background") - }); - assert!(bg_cmd.is_some(), "missing color-background command"); - if let Some(EditorCommand::SetVariableColor { hex, .. }) = bg_cmd { - assert_eq!(hex, "#F8FAFC", "wrong color-background value"); - } - } + let ds = default_design_system(); + let ctx = design_system_to_prompt_context(ds); - /// Spacing scale → SetVariableScalar::Number with spacing-xs/sm/... names. - #[test] - fn seed_commands_spacing_scale_names() { - use crate::design_system::design_system_to_seed_commands; - use op_editor_core::{EditorCommand, VariableScalarPayload}; + // Verify structural lines (port of TS L161-170 format) + assert!( + ctx.starts_with("DESIGN SYSTEM (use these values consistently):"), + "wrong header: {ctx}" + ); + assert!(ctx.contains("Colors: bg #F8FAFC"), "missing Colors line"); + assert!(ctx.contains("surface #FFFFFF"), "missing surface"); + assert!(ctx.contains("text #0F172A"), "missing text"); + assert!(ctx.contains("muted #475569"), "missing muted"); + assert!(ctx.contains("primary #2563EB"), "missing primary"); + assert!(ctx.contains("primaryLight #DBEAFE"), "missing primaryLight"); + assert!(ctx.contains("accent #0EA5E9"), "missing accent"); + assert!(ctx.contains("border #E2E8F0"), "missing border"); + assert!( + ctx.contains(r#"Fonts: heading "Space Grotesk""#), + "missing heading font" + ); + assert!(ctx.contains(r#"body "Inter""#), "missing body font"); + assert!( + ctx.contains("Type scale: 14, 16, 20, 28, 40, 56px"), + "wrong type scale line: {ctx}" + ); + assert!( + ctx.contains("Spacing: 8, 16, 24, 32, 48, 64px (8px grid)"), + "wrong spacing line: {ctx}" + ); + assert!( + ctx.contains("Radius: 8, 12, 16px"), + "wrong radius line: {ctx}" + ); + assert!( + ctx.contains("Style: clean modern blue"), + "wrong style line: {ctx}" + ); +} - let ds = default_design_system(); - let cmds = design_system_to_seed_commands(ds); +/// Byte-exact match of the TS template for DEFAULT_DESIGN_SYSTEM. +#[test] +fn prompt_context_byte_exact() { + use crate::design_system::design_system_to_prompt_context; - let spacing_names: Vec = cmds - .iter() - .filter_map(|c| { - if let EditorCommand::SetVariableScalar { - name, - scalar: VariableScalarPayload::Number(_), - } = c - { - if name.starts_with("spacing-") { - return Some(name.clone()); - } - } - None - }) - .collect(); + let ds = default_design_system(); + let ctx = design_system_to_prompt_context(ds); - assert!( - spacing_names.contains(&"spacing-xs".to_string()), - "missing spacing-xs" - ); - assert!( - spacing_names.contains(&"spacing-sm".to_string()), - "missing spacing-sm" - ); - assert!( - spacing_names.contains(&"spacing-md".to_string()), - "missing spacing-md" - ); - assert!( - spacing_names.contains(&"spacing-lg".to_string()), - "missing spacing-lg" - ); - assert!( - spacing_names.contains(&"spacing-xl".to_string()), - "missing spacing-xl" - ); - assert!( - spacing_names.contains(&"spacing-2xl".to_string()), - "missing spacing-2xl" - ); - assert_eq!(spacing_names.len(), 6, "expected 6 spacing variables"); - } - - /// Radius steps → SetVariableScalar::Number with radius-sm/md/lg names. - #[test] - fn seed_commands_radius_names() { - use crate::design_system::design_system_to_seed_commands; - use op_editor_core::{EditorCommand, VariableScalarPayload}; - - let ds = default_design_system(); - let cmds = design_system_to_seed_commands(ds); - - let radius_names: Vec = cmds - .iter() - .filter_map(|c| { - if let EditorCommand::SetVariableScalar { - name, - scalar: VariableScalarPayload::Number(_), - } = c - { - if name.starts_with("radius-") { - return Some(name.clone()); - } - } - None - }) - .collect(); - - assert!( - radius_names.contains(&"radius-sm".to_string()), - "missing radius-sm" - ); - assert!( - radius_names.contains(&"radius-md".to_string()), - "missing radius-md" - ); - assert!( - radius_names.contains(&"radius-lg".to_string()), - "missing radius-lg" - ); - assert_eq!(radius_names.len(), 3, "expected 3 radius variables"); - } - - // ── Task B1: design_system_to_prompt_context ───────────────────────────── - - /// `design_system_to_prompt_context` produces the exact TS template. - #[test] - fn prompt_context_exact_template() { - use crate::design_system::design_system_to_prompt_context; - - let ds = default_design_system(); - let ctx = design_system_to_prompt_context(ds); - - // Verify structural lines (port of TS L161-170 format) - assert!( - ctx.starts_with("DESIGN SYSTEM (use these values consistently):"), - "wrong header: {ctx}" - ); - assert!(ctx.contains("Colors: bg #F8FAFC"), "missing Colors line"); - assert!(ctx.contains("surface #FFFFFF"), "missing surface"); - assert!(ctx.contains("text #0F172A"), "missing text"); - assert!(ctx.contains("muted #475569"), "missing muted"); - assert!(ctx.contains("primary #2563EB"), "missing primary"); - assert!(ctx.contains("primaryLight #DBEAFE"), "missing primaryLight"); - assert!(ctx.contains("accent #0EA5E9"), "missing accent"); - assert!(ctx.contains("border #E2E8F0"), "missing border"); - assert!( - ctx.contains(r#"Fonts: heading "Space Grotesk""#), - "missing heading font" - ); - assert!(ctx.contains(r#"body "Inter""#), "missing body font"); - assert!( - ctx.contains("Type scale: 14, 16, 20, 28, 40, 56px"), - "wrong type scale line: {ctx}" - ); - assert!( - ctx.contains("Spacing: 8, 16, 24, 32, 48, 64px (8px grid)"), - "wrong spacing line: {ctx}" - ); - assert!( - ctx.contains("Radius: 8, 12, 16px"), - "wrong radius line: {ctx}" - ); - assert!( - ctx.contains("Style: clean modern blue"), - "wrong style line: {ctx}" - ); - } - - /// Byte-exact match of the TS template for DEFAULT_DESIGN_SYSTEM. - #[test] - fn prompt_context_byte_exact() { - use crate::design_system::design_system_to_prompt_context; - - let ds = default_design_system(); - let ctx = design_system_to_prompt_context(ds); - - let expected = "DESIGN SYSTEM (use these values consistently):\n\ + let expected = "DESIGN SYSTEM (use these values consistently):\n\ Colors: bg #F8FAFC, surface #FFFFFF, text #0F172A, muted #475569, primary #2563EB, primaryLight #DBEAFE, accent #0EA5E9, border #E2E8F0\n\ Fonts: heading \"Space Grotesk\", body \"Inter\"\n\ Type scale: 14, 16, 20, 28, 40, 56px\n\ @@ -555,9 +553,8 @@ Spacing: 8, 16, 24, 32, 48, 64px (8px grid)\n\ Radius: 8, 12, 16px\n\ Style: clean modern blue"; - assert_eq!( - ctx, expected, - "prompt context does not match TS template byte-exactly" - ); - } + assert_eq!( + ctx, expected, + "prompt context does not match TS template byte-exactly" + ); } diff --git a/crates/op-orchestrator/src/visual_ref_tests.rs b/crates/op-orchestrator/src/visual_ref_tests.rs index 102cb5cf0..5f6f70aa3 100644 --- a/crates/op-orchestrator/src/visual_ref_tests.rs +++ b/crates/op-orchestrator/src/visual_ref_tests.rs @@ -1,684 +1,680 @@ //! Tests for `visual_ref.rs` — S4 B2 + C1. -#[cfg(test)] -mod tests { - use crate::design_system::default_design_system; - use crate::test_support::{ - ScriptResponse, ScriptedLlm, SkippedPreValidator, SkippedScreenshotProvider, - SkippedVisionLlmClient, VecDocSink, - }; - use crate::types::{ - AbortFlag, CallRequest, DesignRequest, LlmChunk, LlmClient, LlmError, OrchestratorError, - Progress, ValidationProviders, VisualRefProvider, - }; - use crate::visual_ref::{ - build_enhanced_prompt, execute_visual_ref_orchestration, extract_structure_summary, - generate_design_code, - }; - use futures::stream::BoxStream; - use std::sync::Mutex; +use crate::design_system::default_design_system; +use crate::test_support::{ + ScriptResponse, ScriptedLlm, SkippedPreValidator, SkippedScreenshotProvider, + SkippedVisionLlmClient, VecDocSink, +}; +use crate::types::{ + AbortFlag, CallRequest, DesignRequest, LlmChunk, LlmClient, LlmError, OrchestratorError, + Progress, ValidationProviders, VisualRefProvider, +}; +use crate::visual_ref::{ + build_enhanced_prompt, execute_visual_ref_orchestration, extract_structure_summary, + generate_design_code, +}; +use futures::stream::BoxStream; +use std::sync::Mutex; - // ── generate_design_code ────────────────────────────────────────────────── +// ── generate_design_code ────────────────────────────────────────────────── - #[tokio::test] - async fn generate_design_code_returns_llm_output_verbatim() { - let expected = "Hello"; - let llm = ScriptedLlm::new(vec![ScriptResponse::Text(expected.to_string())]); - let abort = AbortFlag::new(); - let ds = default_design_system(); +#[tokio::test] +async fn generate_design_code_returns_llm_output_verbatim() { + let expected = "Hello"; + let llm = ScriptedLlm::new(vec![ScriptResponse::Text(expected.to_string())]); + let abort = AbortFlag::new(); + let ds = default_design_system(); - let result = - generate_design_code("a login page", ds, 1440.0, 900.0, &llm, None, None, &abort).await; + let result = + generate_design_code("a login page", ds, 1440.0, 900.0, &llm, None, None, &abort).await; - assert_eq!(result, expected); - } + assert_eq!(result, expected); +} - #[tokio::test] - async fn generate_design_code_returns_empty_on_llm_error() { - use crate::types::LlmError; - let llm = ScriptedLlm::new(vec![ScriptResponse::Fail(LlmError { - message: "timeout".into(), - aborted: false, - })]); - let abort = AbortFlag::new(); - let ds = default_design_system(); +#[tokio::test] +async fn generate_design_code_returns_empty_on_llm_error() { + use crate::types::LlmError; + let llm = ScriptedLlm::new(vec![ScriptResponse::Fail(LlmError { + message: "timeout".into(), + aborted: false, + })]); + let abort = AbortFlag::new(); + let ds = default_design_system(); - let result = - generate_design_code("a dashboard", ds, 1440.0, 900.0, &llm, None, None, &abort).await; + let result = + generate_design_code("a dashboard", ds, 1440.0, 900.0, &llm, None, None, &abort).await; - assert_eq!(result, ""); - } + assert_eq!(result, ""); +} - #[tokio::test] - async fn generate_design_code_respects_model_and_provider() { - // This test just checks the call doesn't panic with model/provider set. - let llm = ScriptedLlm::new(vec![ScriptResponse::Text("".to_string())]); - let abort = AbortFlag::new(); - let ds = default_design_system(); +#[tokio::test] +async fn generate_design_code_respects_model_and_provider() { + // This test just checks the call doesn't panic with model/provider set. + let llm = ScriptedLlm::new(vec![ScriptResponse::Text("".to_string())]); + let abort = AbortFlag::new(); + let ds = default_design_system(); - let result = generate_design_code( - "a settings page", - ds, - 375.0, - 812.0, - &llm, - Some("claude-3-5-sonnet"), - Some("anthropic"), - &abort, - ) - .await; + let result = generate_design_code( + "a settings page", + ds, + 375.0, + 812.0, + &llm, + Some("claude-3-5-sonnet"), + Some("anthropic"), + &abort, + ) + .await; - assert!(!result.is_empty()); - } + assert!(!result.is_empty()); +} - // ── extract_structure_summary ───────────────────────────────────────────── +// ── extract_structure_summary ───────────────────────────────────────────── - #[test] - fn extract_structure_summary_header_line() { - let result = extract_structure_summary("
"); - assert_eq!( - result.lines().next().unwrap(), - "DESIGN REFERENCE STRUCTURE:" - ); - } +#[test] +fn extract_structure_summary_header_line() { + let result = extract_structure_summary("
"); + assert_eq!( + result.lines().next().unwrap(), + "DESIGN REFERENCE STRUCTURE:" + ); +} - #[test] - fn extract_structure_summary_section_with_class() { - let html = r#"

Welcome

"#; - let result = extract_structure_summary(html); +#[test] +fn extract_structure_summary_section_with_class() { + let html = r#"

Welcome

"#; + let result = extract_structure_summary(html); + assert!( + result.contains("- Section: hero"), + "expected Section: hero in:\n{result}" + ); + assert!( + result.contains("- H1: \"Welcome\""), + "expected H1: Welcome in:\n{result}" + ); +} + +#[test] +fn extract_structure_summary_section_h1_cta() { + // This is the byte-exact test from the plan: + // `extract_structure_summary("

Hero

CTA
")` → exact format + // Note:
has no class/id → no Section line. + //

→ H1 line. + // has no class → no CTA line. + let html = "

Hero

CTA
"; + let result = extract_structure_summary(html); + let lines: Vec<&str> = result.lines().collect(); + assert_eq!(lines[0], "DESIGN REFERENCE STRUCTURE:"); + assert_eq!(lines[1], "- H1: \"Hero\""); + // Only 2 lines (header + H1) — no section/CTA since no class attrs + assert_eq!(lines.len(), 2); +} + +#[test] +fn extract_structure_summary_skips_bem_modifier_classes() { + // classOrId containing `__` must be skipped + let html = r#"
content
"#; + let result = extract_structure_summary(html); + assert!( + !result.contains("hero__inner"), + "BEM modifier should be skipped" + ); +} + +#[test] +fn extract_structure_summary_cta_with_btn_class() { + let html = r#""#; + let result = extract_structure_summary(html); + assert!( + result.contains("- CTA: \"Get Started\""), + "expected CTA in:\n{result}" + ); +} + +#[test] +fn extract_structure_summary_cta_with_cta_class() { + let html = "Learn More"; + let result = extract_structure_summary(html); + assert!( + result.contains("- CTA: \"Learn More\""), + "expected CTA in:\n{result}" + ); +} + +#[test] +fn extract_structure_summary_cta_with_button_class() { + let html = "Sign Up"; + let result = extract_structure_summary(html); + assert!( + result.contains("- CTA: \"Sign Up\""), + "expected CTA in:\n{result}" + ); +} + +#[test] +fn extract_structure_summary_headings_truncated_to_60_chars() { + let long_title = "A".repeat(80); + let html = format!("

{long_title}

"); + let result = extract_structure_summary(&html); + // Should be truncated to 60 chars + let expected_content: String = "A".repeat(60); + assert!( + result.contains(&format!("- H2: \"{expected_content}\"")), + "should truncate to 60 chars:\n{result}" + ); +} + +#[test] +fn extract_structure_summary_cta_text_truncated_to_30_chars() { + let long_text = "B".repeat(50); + let html = format!(r#""#); + let result = extract_structure_summary(&html); + let expected_text: String = "B".repeat(30); + assert!( + result.contains(&format!("- CTA: \"{expected_text}\"")), + "should truncate to 30 chars:\n{result}" + ); +} + +#[test] +fn extract_structure_summary_fallback_when_no_structure() { + let html = "
plain
"; + let result = extract_structure_summary(html); + assert!( + result.contains("(HTML structure extracted"), + "expected fallback line:\n{result}" + ); +} + +#[test] +fn extract_structure_summary_section_with_id_attribute() { + let html = r#"
content
"#; + let result = extract_structure_summary(html); + assert!( + result.contains("- Section: about"), + "expected Section: about in:\n{result}" + ); +} + +#[test] +fn extract_structure_summary_all_heading_levels() { + let html = "

One

Two

Three

Four

Five
Six
"; + let result = extract_structure_summary(html); + for (level, text) in [ + (1, "One"), + (2, "Two"), + (3, "Three"), + (4, "Four"), + (5, "Five"), + (6, "Six"), + ] { assert!( - result.contains("- Section: hero"), - "expected Section: hero in:\n{result}" - ); - assert!( - result.contains("- H1: \"Welcome\""), - "expected H1: Welcome in:\n{result}" - ); - } - - #[test] - fn extract_structure_summary_section_h1_cta() { - // This is the byte-exact test from the plan: - // `extract_structure_summary("

Hero

CTA
")` → exact format - // Note:
has no class/id → no Section line. - //

→ H1 line. - // has no class → no CTA line. - let html = "

Hero

CTA
"; - let result = extract_structure_summary(html); - let lines: Vec<&str> = result.lines().collect(); - assert_eq!(lines[0], "DESIGN REFERENCE STRUCTURE:"); - assert_eq!(lines[1], "- H1: \"Hero\""); - // Only 2 lines (header + H1) — no section/CTA since no class attrs - assert_eq!(lines.len(), 2); - } - - #[test] - fn extract_structure_summary_skips_bem_modifier_classes() { - // classOrId containing `__` must be skipped - let html = r#"
content
"#; - let result = extract_structure_summary(html); - assert!( - !result.contains("hero__inner"), - "BEM modifier should be skipped" - ); - } - - #[test] - fn extract_structure_summary_cta_with_btn_class() { - let html = r#""#; - let result = extract_structure_summary(html); - assert!( - result.contains("- CTA: \"Get Started\""), - "expected CTA in:\n{result}" - ); - } - - #[test] - fn extract_structure_summary_cta_with_cta_class() { - let html = "Learn More"; - let result = extract_structure_summary(html); - assert!( - result.contains("- CTA: \"Learn More\""), - "expected CTA in:\n{result}" - ); - } - - #[test] - fn extract_structure_summary_cta_with_button_class() { - let html = "Sign Up"; - let result = extract_structure_summary(html); - assert!( - result.contains("- CTA: \"Sign Up\""), - "expected CTA in:\n{result}" - ); - } - - #[test] - fn extract_structure_summary_headings_truncated_to_60_chars() { - let long_title = "A".repeat(80); - let html = format!("

{long_title}

"); - let result = extract_structure_summary(&html); - // Should be truncated to 60 chars - let expected_content: String = "A".repeat(60); - assert!( - result.contains(&format!("- H2: \"{expected_content}\"")), - "should truncate to 60 chars:\n{result}" - ); - } - - #[test] - fn extract_structure_summary_cta_text_truncated_to_30_chars() { - let long_text = "B".repeat(50); - let html = format!(r#""#); - let result = extract_structure_summary(&html); - let expected_text: String = "B".repeat(30); - assert!( - result.contains(&format!("- CTA: \"{expected_text}\"")), - "should truncate to 30 chars:\n{result}" - ); - } - - #[test] - fn extract_structure_summary_fallback_when_no_structure() { - let html = "
plain
"; - let result = extract_structure_summary(html); - assert!( - result.contains("(HTML structure extracted"), - "expected fallback line:\n{result}" - ); - } - - #[test] - fn extract_structure_summary_section_with_id_attribute() { - let html = r#"
content
"#; - let result = extract_structure_summary(html); - assert!( - result.contains("- Section: about"), - "expected Section: about in:\n{result}" - ); - } - - #[test] - fn extract_structure_summary_all_heading_levels() { - let html = "

One

Two

Three

Four

Five
Six
"; - let result = extract_structure_summary(html); - for (level, text) in [ - (1, "One"), - (2, "Two"), - (3, "Three"), - (4, "Four"), - (5, "Five"), - (6, "Six"), - ] { - assert!( - result.contains(&format!("- H{level}: \"{text}\"")), - "missing H{level} in:\n{result}" - ); - } - } - - #[test] - fn extract_structure_summary_strips_tags_from_headings() { - let html = "

Hello World

"; - let result = extract_structure_summary(html); - assert!( - result.contains("- H1: \"Hello World\""), - "should strip tags:\n{result}" - ); - } - - // ── build_enhanced_prompt ───────────────────────────────────────────────── - - #[test] - fn build_enhanced_prompt_exact_template() { - let original = "Design a login screen"; - let structure = "DESIGN REFERENCE STRUCTURE:\n- H1: \"Login\""; - let ds_context = "DESIGN SYSTEM (use these values consistently):\nColors: bg #F8FAFC"; - - let result = build_enhanced_prompt(original, structure, ds_context); - - let expected = format!( - "{original}\n\n{structure}\n\n{ds_context}\n\nIMPORTANT: Follow the design reference structure closely. The design system colors, fonts, and spacing have already been determined — use them consistently. The reference structure shows the intended layout — match its section order and composition." - ); - assert_eq!(result, expected); - } - - #[test] - fn build_enhanced_prompt_contains_important_instruction() { - let result = build_enhanced_prompt("p", "s", "d"); - assert!(result.contains("IMPORTANT: Follow the design reference structure closely.")); - assert!(result.contains("use them consistently")); - assert!(result.contains("match its section order and composition.")); - } - - #[test] - fn build_enhanced_prompt_double_newline_separators() { - let result = build_enhanced_prompt("p", "s", "d"); - // Check the exact separators: p\n\ns\n\nd\n\nIMPORTANT... - assert!(result.starts_with("p\n\ns\n\nd\n\nIMPORTANT:")); - } - - #[test] - fn build_enhanced_prompt_empty_inputs() { - let result = build_enhanced_prompt("", "", ""); - // Should still produce the IMPORTANT line - assert!(result.contains("IMPORTANT:")); - assert_eq!(result, "\n\n\n\n\n\nIMPORTANT: Follow the design reference structure closely. The design system colors, fonts, and spacing have already been determined — use them consistently. The reference structure shows the intended layout — match its section order and composition."); - } - - // ── execute_visual_ref_orchestration — C1 tests ─────────────────────────── - - // Shared test fixtures --------------------------------------------------- - - fn make_request() -> DesignRequest { - DesignRequest { - prompt: "a landing page".into(), - model: None, - provider: None, - design_md: None, - concurrency: 1, - append_context: None, - validation_enabled: false, - visual_ref_enabled: true, - } - } - - fn stub_providers() -> ValidationProviders<'static> { - ValidationProviders { - pre_validator: &SkippedPreValidator, - screenshot: &SkippedScreenshotProvider, - vision: &SkippedVisionLlmClient, - system_prompt: String::new(), - } - } - - /// Minimal valid plan JSON for the Orchestrator. - const PLAN_JSON: &str = r##"{ - "rootFrame": { "id": "root", "name": "Page", "width": 1200, "height": 800, - "layout": "vertical", "gap": 0, - "fill": [{ "type": "solid", "color": "#FFFFFF" }] }, - "subtasks": [ - { "id": "hero", "label": "Hero", "region": { "width": 1200, "height": 400 } } - ] -}"##; - - fn node_json(prefix: &str) -> String { - format!( - r#"[{{"type":"frame","id":"{prefix}-1","name":"Sec","x":0,"y":0,"width":1200,"height":300,"children":[]}}]"# - ) - } - - fn default_ds_json() -> String { - let ds = default_design_system(); - serde_json::to_string(ds).expect("serialize default DS") - } - - /// An `LlmClient` that records every `CallRequest` it sees while still - /// returning scripted responses in order. Used to verify the enhanced - /// prompt reaches the underlying orchestrator. - struct RecordingLlm { - responses: Mutex>, - recorded: Mutex>, - } - - impl RecordingLlm { - fn new(responses: Vec) -> Self { - Self { - responses: Mutex::new(responses.into()), - recorded: Mutex::new(Vec::new()), - } - } - - fn calls(&self) -> Vec { - self.recorded.lock().unwrap().clone() - } - } - - impl LlmClient for RecordingLlm { - fn call(&self, req: CallRequest) -> BoxStream<'static, Result> { - self.recorded.lock().unwrap().push(req); - let next = self.responses.lock().unwrap().pop_front(); - let items: Vec> = match next { - Some(ScriptResponse::Text(t)) => vec![Ok(LlmChunk::Text(t))], - Some(ScriptResponse::Fail(e)) => vec![Err(e)], - None => vec![Err(LlmError { - message: "RecordingLlm exhausted".into(), - aborted: false, - })], - }; - Box::pin(futures::stream::iter(items)) - } - } - - // A `VisualRefProvider` that returns Some(base64) for any call. - struct MockVisualRefProvider; - impl VisualRefProvider for MockVisualRefProvider { - fn render_html_to_screenshot(&self, _html: &str, _w: f64, _h: f64) -> Option { - Some("base64screenshot==".to_string()) - } - } - - // A `VisualRefProvider` that always returns None. - struct NoneVisualRefProvider; - impl VisualRefProvider for NoneVisualRefProvider { - fn render_html_to_screenshot(&self, _html: &str, _w: f64, _h: f64) -> Option { - None - } - } - - // ── Test 1: None screenshot → still runs enhanced orchestration ────────── - - /// When `VisualRefProvider` returns `None`, the pipeline does NOT fall back - /// to plain orchestration — it emits - /// `Progress::VisualRefScreenshotReady { skipped: true }` and continues - /// to the enhanced `Orchestrator::run`. No `VisualRefFallback` event is - /// emitted. Matches TS `visual-ref-orchestrator.ts:109-122` semantics. - #[tokio::test] - async fn execute_visual_ref_none_screenshot_still_runs_enhanced_orchestration() { - // LLM call order: - // [0] generate_design_system → DS JSON - // [1] generate_design_code → HTML (contains

Hero

) - // [2] Orchestrator planning → PLAN_JSON - // [3] Orchestrator subtask → node_json - let llm = RecordingLlm::new(vec![ - ScriptResponse::Text(default_ds_json()), - ScriptResponse::Text("

Hero

".into()), - ScriptResponse::Text(PLAN_JSON.into()), - ScriptResponse::Text(node_json("hero")), - ]); - let mut sink = VecDocSink::new(); - let mut events: Vec = Vec::new(); - let providers = stub_providers(); - - let result = execute_visual_ref_orchestration( - &mut sink, - &llm, - &providers, - &NoneVisualRefProvider, - make_request(), - &mut |p| events.push(p), - &AbortFlag::new(), - ) - .await; - - assert!(result.is_ok(), "expected Ok, got {:?}", result.err()); - - // All four pre-orchestrator events - assert!( - events - .iter() - .any(|e| matches!(e, Progress::VisualRefStarted)), - "missing VisualRefStarted in {:?}", - events - ); - assert!( - events - .iter() - .any(|e| matches!(e, Progress::VisualRefDesignSystem { .. })), - "missing VisualRefDesignSystem in {:?}", - events - ); - assert!( - events - .iter() - .any(|e| matches!(e, Progress::VisualRefHtmlGenerated { .. })), - "missing VisualRefHtmlGenerated in {:?}", - events - ); - assert!( - events - .iter() - .any(|e| matches!(e, Progress::VisualRefScreenshotReady { skipped: true })), - "expected VisualRefScreenshotReady{{skipped:true}} in {:?}", - events - ); - - // CRITICAL: no VisualRefFallback should be emitted when screenshot is None. - let no_fallback = events - .iter() - .all(|e| !matches!(e, Progress::VisualRefFallback { .. })); - assert!( - no_fallback, - "stage 3 None → must NOT emit VisualRefFallback; got {:?}", - events - ); - - // Orchestrator emits Planning when it runs. - assert!( - events.iter().any(|e| matches!(e, Progress::Planning)), - "expected Planning event from enhanced Orchestrator::run, got {:?}", - events - ); - - // CRITICAL: the planning call (call index 2, after DS + codegen) must - // have received the ENHANCED prompt (not the original "a landing page"). - // The enhanced prompt contains the IMPORTANT-instruction tail. - let calls = llm.calls(); - assert!( - calls.len() >= 3, - "expected ≥3 LLM calls (DS + codegen + planning), got {}", - calls.len() - ); - let planning_user_prompt = &calls[2].user_prompt; - assert!( - planning_user_prompt - .contains("IMPORTANT: Follow the design reference structure closely."), - "planning user prompt should carry the enhanced-prompt instruction tail; got:\n{}", - planning_user_prompt - ); - // It should also carry the structure summary marker (since HTML had

Hero

). - assert!( - planning_user_prompt.contains("DESIGN REFERENCE STRUCTURE:"), - "planning user prompt should carry the structure summary header; got:\n{}", - planning_user_prompt - ); - } - - // ── Test 2: MockVisualRefProvider → all 5 stages + Orchestrator::run ────── - - /// When `VisualRefProvider` returns `Some(base64)`, all 5 stages run and - /// `Orchestrator::run` is called with the enhanced prompt. - #[tokio::test] - async fn execute_visual_ref_with_screenshot_runs_all_stages() { - // LLM call order: - // [0] generate_design_system → DS JSON - // [1] generate_design_code → HTML - // [2] Orchestrator planning → PLAN_JSON - // [3] Orchestrator subtask → node_json - let llm = ScriptedLlm::new(vec![ - ScriptResponse::Text(default_ds_json()), - ScriptResponse::Text("

Hero

".into()), - ScriptResponse::Text(PLAN_JSON.into()), - ScriptResponse::Text(node_json("hero")), - ]); - let mut sink = VecDocSink::new(); - let mut events: Vec = Vec::new(); - let providers = stub_providers(); - - let result = execute_visual_ref_orchestration( - &mut sink, - &llm, - &providers, - &MockVisualRefProvider, - make_request(), - &mut |p| events.push(p), - &AbortFlag::new(), - ) - .await; - - assert!(result.is_ok(), "expected Ok, got {:?}", result.err()); - - // VisualRefStarted - assert!( - events - .iter() - .any(|e| matches!(e, Progress::VisualRefStarted)), - "missing VisualRefStarted" - ); - // VisualRefDesignSystem - assert!( - events - .iter() - .any(|e| matches!(e, Progress::VisualRefDesignSystem { .. })), - "missing VisualRefDesignSystem" - ); - // VisualRefHtmlGenerated - assert!( - events - .iter() - .any(|e| matches!(e, Progress::VisualRefHtmlGenerated { .. })), - "missing VisualRefHtmlGenerated" - ); - // VisualRefScreenshotReady { skipped: false } - assert!( - events - .iter() - .any(|e| matches!(e, Progress::VisualRefScreenshotReady { skipped: false })), - "missing VisualRefScreenshotReady{{skipped:false}}" - ); - // No fallback - let no_fallback = events - .iter() - .all(|e| !matches!(e, Progress::VisualRefFallback { .. })); - assert!(no_fallback, "unexpected VisualRefFallback in {:?}", events); - - // Orchestrator events present (Planning at minimum) - let has_planning = events.iter().any(|e| matches!(e, Progress::Planning)); - assert!(has_planning, "expected Planning in {:?}", events); - } - - // ── Test 3: generate_design_code returns empty → fallback ───────────────── - - /// When `generate_design_code` returns an empty string (LLM fails or returns - /// nothing), the function emits `VisualRefFallback` and falls back. - #[tokio::test] - async fn execute_visual_ref_empty_html_falls_back() { - // LLM call order: - // [0] generate_design_system → DS JSON - // [1] generate_design_code → empty (simulated via LLM error) - // [2] Orchestrator planning → PLAN_JSON (fallback runs Orchestrator) - // [3] Orchestrator subtask → node_json - use crate::types::LlmError; - let llm = ScriptedLlm::new(vec![ - ScriptResponse::Text(default_ds_json()), - ScriptResponse::Fail(LlmError { - message: "codegen timeout".into(), - aborted: false, - }), - ScriptResponse::Text(PLAN_JSON.into()), - ScriptResponse::Text(node_json("hero")), - ]); - let mut sink = VecDocSink::new(); - let mut events: Vec = Vec::new(); - let providers = stub_providers(); - - let result = execute_visual_ref_orchestration( - &mut sink, - &llm, - &providers, - &MockVisualRefProvider, - make_request(), - &mut |p| events.push(p), - &AbortFlag::new(), - ) - .await; - - assert!(result.is_ok(), "expected Ok, got {:?}", result.err()); - - let has_fallback = events - .iter() - .any(|e| matches!(e, Progress::VisualRefFallback { .. })); - assert!( - has_fallback, - "expected VisualRefFallback for empty HTML in {:?}", - events - ); - // No VisualRefHtmlGenerated (never got past code gen) - let no_html_event = events - .iter() - .all(|e| !matches!(e, Progress::VisualRefHtmlGenerated { .. })); - assert!( - no_html_event, - "unexpected VisualRefHtmlGenerated when HTML empty" - ); - } - - // ── Test 4: abort before stage 1 → Err(Aborted) ────────────────────────── - - /// When the abort flag is set before the call, the function returns - /// `Err(OrchestratorError::Aborted)`. - #[tokio::test] - async fn execute_visual_ref_abort_before_stage_1_returns_aborted() { - let llm = ScriptedLlm::new(vec![]); - let mut sink = VecDocSink::new(); - let providers = stub_providers(); - let abort = AbortFlag::new(); - abort.set(); // fire before any call - - let result = execute_visual_ref_orchestration( - &mut sink, - &llm, - &providers, - &NoneVisualRefProvider, - make_request(), - &mut |_| {}, - &abort, - ) - .await; - - assert!( - matches!(result, Err(OrchestratorError::Aborted)), - "expected Aborted, got {:?}", - result - ); - } - - // ── Test 5: DesignSystem var_count matches seeded commands ───────────────── - - /// `VisualRefDesignSystem.var_count` equals the number of commands emitted - /// by `design_system_to_seed_commands(default)` = 17. - #[tokio::test] - async fn execute_visual_ref_ds_progress_reports_correct_var_count() { - // Use NoneVisualRefProvider to keep test simple (fallback after screenshot) - let llm = ScriptedLlm::new(vec![ - ScriptResponse::Text(default_ds_json()), - ScriptResponse::Text("page".into()), - ScriptResponse::Text(PLAN_JSON.into()), - ScriptResponse::Text(node_json("hero")), - ]); - let mut sink = VecDocSink::new(); - let mut events: Vec = Vec::new(); - let providers = stub_providers(); - - let _ = execute_visual_ref_orchestration( - &mut sink, - &llm, - &providers, - &NoneVisualRefProvider, - make_request(), - &mut |p| events.push(p), - &AbortFlag::new(), - ) - .await; - - // Find the VisualRefDesignSystem event and check var_count - let ds_event = events.iter().find_map(|e| { - if let Progress::VisualRefDesignSystem { var_count } = e { - Some(*var_count) - } else { - None - } - }); - assert!( - ds_event.is_some(), - "missing VisualRefDesignSystem event in {:?}", - events - ); - // DEFAULT_DESIGN_SYSTEM: 8 palette + 6 spacing + 3 radius = 17 - assert_eq!( - ds_event.unwrap(), - 17, - "expected 17 vars from DEFAULT_DESIGN_SYSTEM" + result.contains(&format!("- H{level}: \"{text}\"")), + "missing H{level} in:\n{result}" ); } } + +#[test] +fn extract_structure_summary_strips_tags_from_headings() { + let html = "

Hello World

"; + let result = extract_structure_summary(html); + assert!( + result.contains("- H1: \"Hello World\""), + "should strip tags:\n{result}" + ); +} + +// ── build_enhanced_prompt ───────────────────────────────────────────────── + +#[test] +fn build_enhanced_prompt_exact_template() { + let original = "Design a login screen"; + let structure = "DESIGN REFERENCE STRUCTURE:\n- H1: \"Login\""; + let ds_context = "DESIGN SYSTEM (use these values consistently):\nColors: bg #F8FAFC"; + + let result = build_enhanced_prompt(original, structure, ds_context); + + let expected = format!( + "{original}\n\n{structure}\n\n{ds_context}\n\nIMPORTANT: Follow the design reference structure closely. The design system colors, fonts, and spacing have already been determined — use them consistently. The reference structure shows the intended layout — match its section order and composition." + ); + assert_eq!(result, expected); +} + +#[test] +fn build_enhanced_prompt_contains_important_instruction() { + let result = build_enhanced_prompt("p", "s", "d"); + assert!(result.contains("IMPORTANT: Follow the design reference structure closely.")); + assert!(result.contains("use them consistently")); + assert!(result.contains("match its section order and composition.")); +} + +#[test] +fn build_enhanced_prompt_double_newline_separators() { + let result = build_enhanced_prompt("p", "s", "d"); + // Check the exact separators: p\n\ns\n\nd\n\nIMPORTANT... + assert!(result.starts_with("p\n\ns\n\nd\n\nIMPORTANT:")); +} + +#[test] +fn build_enhanced_prompt_empty_inputs() { + let result = build_enhanced_prompt("", "", ""); + // Should still produce the IMPORTANT line + assert!(result.contains("IMPORTANT:")); + assert_eq!(result, "\n\n\n\n\n\nIMPORTANT: Follow the design reference structure closely. The design system colors, fonts, and spacing have already been determined — use them consistently. The reference structure shows the intended layout — match its section order and composition."); +} + +// ── execute_visual_ref_orchestration — C1 tests ─────────────────────────── + +// Shared test fixtures --------------------------------------------------- + +fn make_request() -> DesignRequest { + DesignRequest { + prompt: "a landing page".into(), + model: None, + provider: None, + design_md: None, + concurrency: 1, + append_context: None, + validation_enabled: false, + visual_ref_enabled: true, + } +} + +fn stub_providers() -> ValidationProviders<'static> { + ValidationProviders { + pre_validator: &SkippedPreValidator, + screenshot: &SkippedScreenshotProvider, + vision: &SkippedVisionLlmClient, + system_prompt: String::new(), + } +} + +/// Minimal valid plan JSON for the Orchestrator. +const PLAN_JSON: &str = r##"{ + "rootFrame": { "id": "root", "name": "Page", "width": 1200, "height": 800, + "layout": "vertical", "gap": 0, + "fill": [{ "type": "solid", "color": "#FFFFFF" }] }, + "subtasks": [ +{ "id": "hero", "label": "Hero", "region": { "width": 1200, "height": 400 } } + ] +}"##; + +fn node_json(prefix: &str) -> String { + format!( + r#"[{{"type":"frame","id":"{prefix}-1","name":"Sec","x":0,"y":0,"width":1200,"height":300,"children":[]}}]"# + ) +} + +fn default_ds_json() -> String { + let ds = default_design_system(); + serde_json::to_string(ds).expect("serialize default DS") +} + +/// An `LlmClient` that records every `CallRequest` it sees while still +/// returning scripted responses in order. Used to verify the enhanced +/// prompt reaches the underlying orchestrator. +struct RecordingLlm { + responses: Mutex>, + recorded: Mutex>, +} + +impl RecordingLlm { + fn new(responses: Vec) -> Self { + Self { + responses: Mutex::new(responses.into()), + recorded: Mutex::new(Vec::new()), + } + } + + fn calls(&self) -> Vec { + self.recorded.lock().unwrap().clone() + } +} + +impl LlmClient for RecordingLlm { + fn call(&self, req: CallRequest) -> BoxStream<'static, Result> { + self.recorded.lock().unwrap().push(req); + let next = self.responses.lock().unwrap().pop_front(); + let items: Vec> = match next { + Some(ScriptResponse::Text(t)) => vec![Ok(LlmChunk::Text(t))], + Some(ScriptResponse::Fail(e)) => vec![Err(e)], + None => vec![Err(LlmError { + message: "RecordingLlm exhausted".into(), + aborted: false, + })], + }; + Box::pin(futures::stream::iter(items)) + } +} + +// A `VisualRefProvider` that returns Some(base64) for any call. +struct MockVisualRefProvider; +impl VisualRefProvider for MockVisualRefProvider { + fn render_html_to_screenshot(&self, _html: &str, _w: f64, _h: f64) -> Option { + Some("base64screenshot==".to_string()) + } +} + +// A `VisualRefProvider` that always returns None. +struct NoneVisualRefProvider; +impl VisualRefProvider for NoneVisualRefProvider { + fn render_html_to_screenshot(&self, _html: &str, _w: f64, _h: f64) -> Option { + None + } +} + +// ── Test 1: None screenshot → still runs enhanced orchestration ────────── + +/// When `VisualRefProvider` returns `None`, the pipeline does NOT fall back +/// to plain orchestration — it emits +/// `Progress::VisualRefScreenshotReady { skipped: true }` and continues +/// to the enhanced `Orchestrator::run`. No `VisualRefFallback` event is +/// emitted. Matches TS `visual-ref-orchestrator.ts:109-122` semantics. +#[tokio::test] +async fn execute_visual_ref_none_screenshot_still_runs_enhanced_orchestration() { + // LLM call order: + // [0] generate_design_system → DS JSON + // [1] generate_design_code → HTML (contains

Hero

) + // [2] Orchestrator planning → PLAN_JSON + // [3] Orchestrator subtask → node_json + let llm = RecordingLlm::new(vec![ + ScriptResponse::Text(default_ds_json()), + ScriptResponse::Text("

Hero

".into()), + ScriptResponse::Text(PLAN_JSON.into()), + ScriptResponse::Text(node_json("hero")), + ]); + let mut sink = VecDocSink::new(); + let mut events: Vec = Vec::new(); + let providers = stub_providers(); + + let result = execute_visual_ref_orchestration( + &mut sink, + &llm, + &providers, + &NoneVisualRefProvider, + make_request(), + &mut |p| events.push(p), + &AbortFlag::new(), + ) + .await; + + assert!(result.is_ok(), "expected Ok, got {:?}", result.err()); + + // All four pre-orchestrator events + assert!( + events + .iter() + .any(|e| matches!(e, Progress::VisualRefStarted)), + "missing VisualRefStarted in {:?}", + events + ); + assert!( + events + .iter() + .any(|e| matches!(e, Progress::VisualRefDesignSystem { .. })), + "missing VisualRefDesignSystem in {:?}", + events + ); + assert!( + events + .iter() + .any(|e| matches!(e, Progress::VisualRefHtmlGenerated { .. })), + "missing VisualRefHtmlGenerated in {:?}", + events + ); + assert!( + events + .iter() + .any(|e| matches!(e, Progress::VisualRefScreenshotReady { skipped: true })), + "expected VisualRefScreenshotReady{{skipped:true}} in {:?}", + events + ); + + // CRITICAL: no VisualRefFallback should be emitted when screenshot is None. + let no_fallback = events + .iter() + .all(|e| !matches!(e, Progress::VisualRefFallback { .. })); + assert!( + no_fallback, + "stage 3 None → must NOT emit VisualRefFallback; got {:?}", + events + ); + + // Orchestrator emits Planning when it runs. + assert!( + events.iter().any(|e| matches!(e, Progress::Planning)), + "expected Planning event from enhanced Orchestrator::run, got {:?}", + events + ); + + // CRITICAL: the planning call (call index 2, after DS + codegen) must + // have received the ENHANCED prompt (not the original "a landing page"). + // The enhanced prompt contains the IMPORTANT-instruction tail. + let calls = llm.calls(); + assert!( + calls.len() >= 3, + "expected ≥3 LLM calls (DS + codegen + planning), got {}", + calls.len() + ); + let planning_user_prompt = &calls[2].user_prompt; + assert!( + planning_user_prompt.contains("IMPORTANT: Follow the design reference structure closely."), + "planning user prompt should carry the enhanced-prompt instruction tail; got:\n{}", + planning_user_prompt + ); + // It should also carry the structure summary marker (since HTML had

Hero

). + assert!( + planning_user_prompt.contains("DESIGN REFERENCE STRUCTURE:"), + "planning user prompt should carry the structure summary header; got:\n{}", + planning_user_prompt + ); +} + +// ── Test 2: MockVisualRefProvider → all 5 stages + Orchestrator::run ────── + +/// When `VisualRefProvider` returns `Some(base64)`, all 5 stages run and +/// `Orchestrator::run` is called with the enhanced prompt. +#[tokio::test] +async fn execute_visual_ref_with_screenshot_runs_all_stages() { + // LLM call order: + // [0] generate_design_system → DS JSON + // [1] generate_design_code → HTML + // [2] Orchestrator planning → PLAN_JSON + // [3] Orchestrator subtask → node_json + let llm = ScriptedLlm::new(vec![ + ScriptResponse::Text(default_ds_json()), + ScriptResponse::Text("

Hero

".into()), + ScriptResponse::Text(PLAN_JSON.into()), + ScriptResponse::Text(node_json("hero")), + ]); + let mut sink = VecDocSink::new(); + let mut events: Vec = Vec::new(); + let providers = stub_providers(); + + let result = execute_visual_ref_orchestration( + &mut sink, + &llm, + &providers, + &MockVisualRefProvider, + make_request(), + &mut |p| events.push(p), + &AbortFlag::new(), + ) + .await; + + assert!(result.is_ok(), "expected Ok, got {:?}", result.err()); + + // VisualRefStarted + assert!( + events + .iter() + .any(|e| matches!(e, Progress::VisualRefStarted)), + "missing VisualRefStarted" + ); + // VisualRefDesignSystem + assert!( + events + .iter() + .any(|e| matches!(e, Progress::VisualRefDesignSystem { .. })), + "missing VisualRefDesignSystem" + ); + // VisualRefHtmlGenerated + assert!( + events + .iter() + .any(|e| matches!(e, Progress::VisualRefHtmlGenerated { .. })), + "missing VisualRefHtmlGenerated" + ); + // VisualRefScreenshotReady { skipped: false } + assert!( + events + .iter() + .any(|e| matches!(e, Progress::VisualRefScreenshotReady { skipped: false })), + "missing VisualRefScreenshotReady{{skipped:false}}" + ); + // No fallback + let no_fallback = events + .iter() + .all(|e| !matches!(e, Progress::VisualRefFallback { .. })); + assert!(no_fallback, "unexpected VisualRefFallback in {:?}", events); + + // Orchestrator events present (Planning at minimum) + let has_planning = events.iter().any(|e| matches!(e, Progress::Planning)); + assert!(has_planning, "expected Planning in {:?}", events); +} + +// ── Test 3: generate_design_code returns empty → fallback ───────────────── + +/// When `generate_design_code` returns an empty string (LLM fails or returns +/// nothing), the function emits `VisualRefFallback` and falls back. +#[tokio::test] +async fn execute_visual_ref_empty_html_falls_back() { + // LLM call order: + // [0] generate_design_system → DS JSON + // [1] generate_design_code → empty (simulated via LLM error) + // [2] Orchestrator planning → PLAN_JSON (fallback runs Orchestrator) + // [3] Orchestrator subtask → node_json + use crate::types::LlmError; + let llm = ScriptedLlm::new(vec![ + ScriptResponse::Text(default_ds_json()), + ScriptResponse::Fail(LlmError { + message: "codegen timeout".into(), + aborted: false, + }), + ScriptResponse::Text(PLAN_JSON.into()), + ScriptResponse::Text(node_json("hero")), + ]); + let mut sink = VecDocSink::new(); + let mut events: Vec = Vec::new(); + let providers = stub_providers(); + + let result = execute_visual_ref_orchestration( + &mut sink, + &llm, + &providers, + &MockVisualRefProvider, + make_request(), + &mut |p| events.push(p), + &AbortFlag::new(), + ) + .await; + + assert!(result.is_ok(), "expected Ok, got {:?}", result.err()); + + let has_fallback = events + .iter() + .any(|e| matches!(e, Progress::VisualRefFallback { .. })); + assert!( + has_fallback, + "expected VisualRefFallback for empty HTML in {:?}", + events + ); + // No VisualRefHtmlGenerated (never got past code gen) + let no_html_event = events + .iter() + .all(|e| !matches!(e, Progress::VisualRefHtmlGenerated { .. })); + assert!( + no_html_event, + "unexpected VisualRefHtmlGenerated when HTML empty" + ); +} + +// ── Test 4: abort before stage 1 → Err(Aborted) ────────────────────────── + +/// When the abort flag is set before the call, the function returns +/// `Err(OrchestratorError::Aborted)`. +#[tokio::test] +async fn execute_visual_ref_abort_before_stage_1_returns_aborted() { + let llm = ScriptedLlm::new(vec![]); + let mut sink = VecDocSink::new(); + let providers = stub_providers(); + let abort = AbortFlag::new(); + abort.set(); // fire before any call + + let result = execute_visual_ref_orchestration( + &mut sink, + &llm, + &providers, + &NoneVisualRefProvider, + make_request(), + &mut |_| {}, + &abort, + ) + .await; + + assert!( + matches!(result, Err(OrchestratorError::Aborted)), + "expected Aborted, got {:?}", + result + ); +} + +// ── Test 5: DesignSystem var_count matches seeded commands ───────────────── + +/// `VisualRefDesignSystem.var_count` equals the number of commands emitted +/// by `design_system_to_seed_commands(default)` = 17. +#[tokio::test] +async fn execute_visual_ref_ds_progress_reports_correct_var_count() { + // Use NoneVisualRefProvider to keep test simple (fallback after screenshot) + let llm = ScriptedLlm::new(vec![ + ScriptResponse::Text(default_ds_json()), + ScriptResponse::Text("page".into()), + ScriptResponse::Text(PLAN_JSON.into()), + ScriptResponse::Text(node_json("hero")), + ]); + let mut sink = VecDocSink::new(); + let mut events: Vec = Vec::new(); + let providers = stub_providers(); + + let _ = execute_visual_ref_orchestration( + &mut sink, + &llm, + &providers, + &NoneVisualRefProvider, + make_request(), + &mut |p| events.push(p), + &AbortFlag::new(), + ) + .await; + + // Find the VisualRefDesignSystem event and check var_count + let ds_event = events.iter().find_map(|e| { + if let Progress::VisualRefDesignSystem { var_count } = e { + Some(*var_count) + } else { + None + } + }); + assert!( + ds_event.is_some(), + "missing VisualRefDesignSystem event in {:?}", + events + ); + // DEFAULT_DESIGN_SYSTEM: 8 palette + 6 spacing + 3 radius = 17 + assert_eq!( + ds_event.unwrap(), + 17, + "expected 17 vars from DEFAULT_DESIGN_SYSTEM" + ); +}