diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/artcraft/plan_generate_image_artcraft_gpt_image_1.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/artcraft/plan_generate_image_artcraft_gpt_image_1.rs index ab7e2f3aef..cd0185ad67 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/artcraft/plan_generate_image_artcraft_gpt_image_1.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/artcraft/plan_generate_image_artcraft_gpt_image_1.rs @@ -108,7 +108,7 @@ fn plan_quality(quality: Option) -> ArtcraftGptImage1Quality { Some(CommonQuality::Low) => ArtcraftGptImage1Quality::Low, Some(CommonQuality::Medium) => ArtcraftGptImage1Quality::Medium, Some(CommonQuality::High) => ArtcraftGptImage1Quality::High, - None => ArtcraftGptImage1Quality::High, // Legacy handler default + None => ArtcraftGptImage1Quality::High, } } diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/artcraft/plan_generate_image_artcraft_gpt_image_1p5.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/artcraft/plan_generate_image_artcraft_gpt_image_1p5.rs index 52bacde1eb..42ee2a59cb 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/artcraft/plan_generate_image_artcraft_gpt_image_1p5.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/artcraft/plan_generate_image_artcraft_gpt_image_1p5.rs @@ -1,4 +1,5 @@ use crate::api::common_aspect_ratio::CommonAspectRatio; +use crate::api::common_quality::CommonQuality; use crate::api::image_list_ref::ImageListRef; use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; use crate::errors::artcraft_router_error::ArtcraftRouterError; @@ -30,13 +31,14 @@ pub fn plan_generate_image_artcraft_gpt_image_1p5<'a>( let image_inputs = resolve_image_list_ref(request.image_inputs)?; let image_size = plan_image_size(request.aspect_ratio, strategy)?; + let quality = plan_quality(request.quality); let num_images = plan_num_images(request.image_batch_count, strategy)?; Ok(ImageGenerationPlan::ArtcraftGptImage1p5(PlanArtcraftGptImage1p5 { prompt: request.prompt, image_inputs, image_size, - quality: GptImage1p5MultiFunctionImageGenQuality::Medium, + quality, num_images, idempotency_token: request.get_or_generate_idempotency_token(), })) @@ -48,9 +50,19 @@ fn resolve_image_list_ref<'a>( match image_list_ref { None => Ok(None), Some(ImageListRef::MediaFileTokens(tokens)) => Ok(Some(tokens)), - Some(ImageListRef::Urls(_)) => { - Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) - } + // Omni-gen distillation hydrates media tokens to URLs before running the + // Artcraft cost path. Cost only depends on quality + size + num_images, so + // URL-form inputs are accepted and dropped. + Some(ImageListRef::Urls(_)) => Ok(None), + } +} + +fn plan_quality(quality: Option) -> GptImage1p5MultiFunctionImageGenQuality { + match quality { + Some(CommonQuality::Low) => GptImage1p5MultiFunctionImageGenQuality::Low, + Some(CommonQuality::Medium) => GptImage1p5MultiFunctionImageGenQuality::Medium, + Some(CommonQuality::High) => GptImage1p5MultiFunctionImageGenQuality::High, + None => GptImage1p5MultiFunctionImageGenQuality::High, } } @@ -195,10 +207,10 @@ mod tests { // ── Quality ────────────────────────────────────────────────────────────── #[test] - fn quality_defaults_to_medium() { + fn quality_defaults_to_high() { let request = base_gpt_image_1p5_image_request(); let ImageGenerationPlan::ArtcraftGptImage1p5(plan) = request.build().unwrap() else { panic!("expected ArtcraftGptImage1p5") }; - assert!(matches!(plan.quality, GptImage1p5MultiFunctionImageGenQuality::Medium)); + assert!(matches!(plan.quality, GptImage1p5MultiFunctionImageGenQuality::High)); } // ── Num images ────────────────────────────────────────────────────────────── @@ -277,17 +289,16 @@ mod tests { // ── Image inputs ───────────────────────────────────────────────────────── #[test] - fn url_image_inputs_returns_error() { + fn url_image_inputs_are_accepted_for_cost_path() { let urls = vec!["https://example.com/image.jpg".to_string()]; let request = GenerateImageRequest { image_inputs: Some(ImageListRef::Urls(&urls)), ..base_gpt_image_1p5_image_request() }; - let result = request.build(); - assert!(matches!( - result, - Err(ArtcraftRouterError::Client(ClientError::ArtcraftOnlySupportsMediaTokens)) - )); + let ImageGenerationPlan::ArtcraftGptImage1p5(plan) = request.build().unwrap() else { + panic!("expected ArtcraftGptImage1p5") + }; + assert!(plan.image_inputs.is_none()); } #[test] diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_gpt_image_1.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_gpt_image_1.rs index 456dd2b0b9..b35bde7b27 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_gpt_image_1.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_gpt_image_1.rs @@ -86,7 +86,7 @@ fn plan_quality(quality: Option) -> FalGptImage1Quality { Some(CommonQuality::Low) => FalGptImage1Quality::Low, Some(CommonQuality::Medium) => FalGptImage1Quality::Medium, Some(CommonQuality::High) => FalGptImage1Quality::High, - None => FalGptImage1Quality::Medium, // Fal API default + None => FalGptImage1Quality::High, } } @@ -286,10 +286,10 @@ mod tests { // ── Quality default ────────────────────────────────────────────────────── #[test] - fn quality_defaults_to_medium() { + fn quality_defaults_to_high() { let request = base_fal_request(); let plan = build_plan(&request); - assert!(matches!(plan.quality, FalGptImage1Quality::Medium)); + assert!(matches!(plan.quality, FalGptImage1Quality::High)); } // ── Image size mapping ─────────────────────────────────────────────────── diff --git a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_gpt_image_1p5.rs b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_gpt_image_1p5.rs index 663a396b38..8283c0f56a 100644 --- a/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_gpt_image_1p5.rs +++ b/crates/api_clients/artcraft/artcraft_router/src/generate/generate_image/plan/fal/plan_generate_image_fal_gpt_image_1p5.rs @@ -1,4 +1,5 @@ use crate::api::common_aspect_ratio::CommonAspectRatio; +use crate::api::common_quality::CommonQuality; use crate::api::image_list_ref::ImageListRef; use crate::client::request_mismatch_mitigation_strategy::RequestMismatchMitigationStrategy; use crate::errors::artcraft_router_error::ArtcraftRouterError; @@ -53,13 +54,14 @@ pub fn plan_generate_image_fal_gpt_image_1p5<'a>( let strategy = request.request_mismatch_mitigation_strategy; let image_urls = resolve_image_list_ref(request.image_inputs)?; let image_size = plan_image_size(request.aspect_ratio); + let quality = plan_quality(request.quality); let num_images = plan_num_images(request.image_batch_count, strategy)?; Ok(ImageGenerationPlan::FalGptImage1p5(PlanFalGptImage1p5 { prompt: request.prompt, image_urls, image_size, - quality: FalGptImage1p5Quality::Medium, + quality, num_images, })) } @@ -76,6 +78,15 @@ fn resolve_image_list_ref( } } +fn plan_quality(quality: Option) -> FalGptImage1p5Quality { + match quality { + Some(CommonQuality::Low) => FalGptImage1p5Quality::Low, + Some(CommonQuality::Medium) => FalGptImage1p5Quality::Medium, + Some(CommonQuality::High) => FalGptImage1p5Quality::High, + None => FalGptImage1p5Quality::High, + } +} + fn plan_image_size(aspect_ratio: Option) -> Option { use FalGptImage1p5ImageSize as S; match aspect_ratio { diff --git a/crates/service/web/storyteller_web/src/configs/omni_gen/image_models.rs b/crates/service/web/storyteller_web/src/configs/omni_gen/image_models.rs index a9b96565a9..20ed8284ec 100644 --- a/crates/service/web/storyteller_web/src/configs/omni_gen/image_models.rs +++ b/crates/service/web/storyteller_web/src/configs/omni_gen/image_models.rs @@ -223,6 +223,12 @@ fn build_omni_gen_image_models() -> Vec { CommonAspectRatio::TallTwoByThree, ]), aspect_ratio_default: Some(CommonAspectRatio::Square), + quality_options: Some(vec![ + CommonQuality::High, + CommonQuality::Medium, + CommonQuality::Low, + ]), + default_quality: Some(CommonQuality::High), batch_size_min: Some(1), batch_size_max: Some(4), batch_size_default: Some(1), diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/tests/mod.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/tests/mod.rs index c5aada3109..3eb77153f4 100644 --- a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/tests/mod.rs +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/tests/mod.rs @@ -3,6 +3,7 @@ mod test_flux_1_schnell; mod test_flux_pro_1p1; mod test_flux_pro_1p1_ultra; mod test_gpt_image_1; +mod test_gpt_image_1p5; mod test_nano_banana; mod test_nano_banana_2; mod test_nano_banana_pro; diff --git a/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/tests/test_gpt_image_1p5.rs b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/tests/test_gpt_image_1p5.rs new file mode 100644 index 0000000000..45ccce7f96 --- /dev/null +++ b/crates/service/web/storyteller_web/src/http_server/endpoints/omni_gen/generate/image/tests/test_gpt_image_1p5.rs @@ -0,0 +1,734 @@ +//! Tests for omni-gen against the GPT Image 1.5 model. +//! +//! GPT Image 1.5 is multi-function: text-to-image and image-to-image (edit). +//! +//! Artcraft-tier pricing (what the user is billed): +//! Low – 1¢ (any size) +//! Medium – 3¢ (square/unset) / 5¢ (wide or tall) ← default when quality unset +//! High – 13¢ (square/unset) / 20¢ (wide or tall) +//! +//! The Fal execution plan maps quality from the omni request (defaulting to +//! Medium when unspecified) and maps aspect ratios to three image sizes: +//! Square (1024×1024), Wide (1536×1024), Tall (1024×1536). +//! Auto/unset maps to None (API default). + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use artcraft_api_defs::omni_gen::cost_and_generate_requests::omni_gen_image_cost_and_generate_request::OmniGenImageCostAndGenerateRequest; + use enums::common::generation::common_aspect_ratio::CommonAspectRatio; + use enums::common::generation::common_image_model::CommonImageModel; + use enums::common::generation::common_quality::CommonQuality; + use tokens::tokens::media_files::MediaFileToken; + use url::Url; + + use crate::http_server::endpoints::omni_gen::generate::image::distill_image_request::{ + distill_image_request, DistilledImageRequest, + }; + + // ── Helpers ──────────────────────────────────────────────────────────────── + + fn make_request( + prompt: Option<&str>, + aspect_ratio: Option, + quality: Option, + image_batch_count: Option, + image_media_tokens: Option>, + ) -> OmniGenImageCostAndGenerateRequest { + OmniGenImageCostAndGenerateRequest { + idempotency_token: Some("11111111-1111-1111-1111-111111111111".to_string()), + model: Some(CommonImageModel::GptImage1p5), + prompt: prompt.map(|s| s.to_string()), + image_media_tokens, + resolution: None, + aspect_ratio, + quality, + image_batch_count, + horizontal_angle: None, + vertical_angle: None, + zoom: None, + } + } + + fn fake_image_refs(n: usize) -> (Vec, HashMap) { + let mut tokens = Vec::with_capacity(n); + let mut map = HashMap::with_capacity(n); + for i in 0..n { + let token = MediaFileToken::new_from_str(&format!("mf_test{:028}", i)); + let url = Url::parse(&format!("https://fake.example.com/img{}.png", i)).unwrap(); + map.insert(token.clone(), url); + tokens.push(token); + } + (tokens, map) + } + + fn distill_text(request: &OmniGenImageCostAndGenerateRequest) -> DistilledImageRequest { + let empty: HashMap = HashMap::new(); + distill_image_request(request, Some(&empty)) + .expect("distill_image_request should succeed for GPT Image 1.5 (text)") + } + + fn distill_edit( + request: &OmniGenImageCostAndGenerateRequest, + hydration: &HashMap, + ) -> DistilledImageRequest { + distill_image_request(request, Some(hydration)) + .expect("distill_image_request should succeed for GPT Image 1.5 (edit)") + } + + // ──────────────────────────────────────────────────────────────────────────── + // COST + // ──────────────────────────────────────────────────────────────────────────── + mod cost { + use super::*; + + mod text { + use super::*; + + fn cost( + quality: Option, + aspect_ratio: Option, + batch: Option, + ) -> u64 { + let request = make_request(Some("p"), aspect_ratio, quality, batch, None); + distill_text(&request).cost.cost_in_usd_cents.unwrap() + } + + // ── Default quality (None → Medium) ────────────────────────────────── + + #[test] + fn default_quality_square_costs_3() { + assert_eq!(cost(None, Some(CommonAspectRatio::Square), Some(1)), 3); + } + + #[test] + fn default_quality_wide_costs_5() { + assert_eq!(cost(None, Some(CommonAspectRatio::WideSixteenByNine), Some(1)), 5); + } + + #[test] + fn default_quality_tall_costs_5() { + assert_eq!(cost(None, Some(CommonAspectRatio::TallNineBySixteen), Some(1)), 5); + } + + #[test] + fn default_quality_unset_size_costs_3() { + assert_eq!(cost(None, None, Some(1)), 3); + } + + #[test] + fn default_quality_auto_costs_3() { + assert_eq!(cost(None, Some(CommonAspectRatio::Auto), Some(1)), 3); + } + + // ── Low quality (1¢/image regardless of size) ──────────────────────── + + #[test] + fn low_square_costs_1() { + assert_eq!(cost(Some(CommonQuality::Low), Some(CommonAspectRatio::Square), Some(1)), 1); + } + + #[test] + fn low_wide_costs_1() { + assert_eq!(cost(Some(CommonQuality::Low), Some(CommonAspectRatio::WideSixteenByNine), Some(1)), 1); + } + + #[test] + fn low_tall_costs_1() { + assert_eq!(cost(Some(CommonQuality::Low), Some(CommonAspectRatio::TallNineBySixteen), Some(1)), 1); + } + + #[test] + fn low_unset_costs_1() { + assert_eq!(cost(Some(CommonQuality::Low), None, Some(1)), 1); + } + + #[test] + fn low_four_images_costs_4() { + assert_eq!(cost(Some(CommonQuality::Low), None, Some(4)), 4); + } + + #[test] + fn low_batch_above_max_clamps() { + assert_eq!(cost(Some(CommonQuality::Low), None, Some(7)), 4); + } + + // ── Medium quality (3¢ square, 5¢ wide/tall) ───────────────────────── + + #[test] + fn medium_square_costs_3() { + assert_eq!(cost(Some(CommonQuality::Medium), Some(CommonAspectRatio::Square), Some(1)), 3); + } + + #[test] + fn medium_wide_costs_5() { + assert_eq!(cost(Some(CommonQuality::Medium), Some(CommonAspectRatio::WideSixteenByNine), Some(1)), 5); + } + + #[test] + fn medium_tall_costs_5() { + assert_eq!(cost(Some(CommonQuality::Medium), Some(CommonAspectRatio::TallNineBySixteen), Some(1)), 5); + } + + #[test] + fn medium_square_four_images_costs_12() { + assert_eq!(cost(Some(CommonQuality::Medium), Some(CommonAspectRatio::Square), Some(4)), 12); + } + + #[test] + fn medium_wide_four_images_costs_20() { + assert_eq!(cost(Some(CommonQuality::Medium), Some(CommonAspectRatio::WideSixteenByNine), Some(4)), 20); + } + + // ── High quality (13¢ square, 20¢ wide/tall) ───────────────────────── + + #[test] + fn high_square_costs_13() { + assert_eq!(cost(Some(CommonQuality::High), Some(CommonAspectRatio::Square), Some(1)), 13); + } + + #[test] + fn high_wide_costs_20() { + assert_eq!(cost(Some(CommonQuality::High), Some(CommonAspectRatio::WideSixteenByNine), Some(1)), 20); + } + + #[test] + fn high_tall_costs_20() { + assert_eq!(cost(Some(CommonQuality::High), Some(CommonAspectRatio::TallNineBySixteen), Some(1)), 20); + } + + #[test] + fn high_unset_costs_13() { + assert_eq!(cost(Some(CommonQuality::High), None, Some(1)), 13); + } + + #[test] + fn high_square_four_images_costs_52() { + assert_eq!(cost(Some(CommonQuality::High), Some(CommonAspectRatio::Square), Some(4)), 52); + } + + #[test] + fn high_wide_four_images_costs_80() { + assert_eq!(cost(Some(CommonQuality::High), Some(CommonAspectRatio::WideSixteenByNine), Some(4)), 80); + } + + // ── Batch scaling ──────────────────────────────────────────────────── + + #[test] + fn medium_square_batch_scaling() { + assert_eq!(cost(Some(CommonQuality::Medium), Some(CommonAspectRatio::Square), Some(1)), 3); + assert_eq!(cost(Some(CommonQuality::Medium), Some(CommonAspectRatio::Square), Some(2)), 6); + assert_eq!(cost(Some(CommonQuality::Medium), Some(CommonAspectRatio::Square), Some(3)), 9); + assert_eq!(cost(Some(CommonQuality::Medium), Some(CommonAspectRatio::Square), Some(4)), 12); + } + + // ── Metadata flags ─────────────────────────────────────────────────── + + #[test] + fn cost_metadata_flags_are_default() { + let request = make_request(Some("p"), None, None, Some(1), None); + let distilled = distill_text(&request); + assert!(!distilled.cost.is_free); + assert!(!distilled.cost.is_unlimited); + assert!(!distilled.cost.is_rate_limited); + assert!(!distilled.cost.has_watermark); + } + + #[test] + fn cost_is_independent_of_prompt() { + let with_prompt = distill_text(&make_request(Some("a cat"), None, None, Some(1), None)); + let without_prompt = distill_text(&make_request(None, None, None, Some(1), None)); + assert_eq!(with_prompt.cost.cost_in_usd_cents, without_prompt.cost.cost_in_usd_cents); + } + } + + mod edit { + use super::*; + + fn cost_edit( + quality: Option, + aspect_ratio: Option, + batch: Option, + num_input_images: usize, + ) -> u64 { + let (tokens, hydration) = fake_image_refs(num_input_images); + let request = make_request(Some("p"), aspect_ratio, quality, batch, Some(tokens)); + distill_edit(&request, &hydration).cost.cost_in_usd_cents.unwrap() + } + + // ── Edit mode costs match text mode (no per-input-image surcharge for + // 1.5 — the artcraft cost estimator for 1.5 does not add input + // image token costs like 1.0 does) ────────────────────────────────── + + #[test] + fn edit_default_quality_square_one_output_one_input() { + assert_eq!(cost_edit(None, Some(CommonAspectRatio::Square), Some(1), 1), 3); + } + + #[test] + fn edit_default_quality_wide_one_output_one_input() { + assert_eq!(cost_edit(None, Some(CommonAspectRatio::WideSixteenByNine), Some(1), 1), 5); + } + + #[test] + fn edit_low_square_one_output_one_input() { + assert_eq!(cost_edit(Some(CommonQuality::Low), Some(CommonAspectRatio::Square), Some(1), 1), 1); + } + + #[test] + fn edit_medium_square_one_output_one_input() { + assert_eq!(cost_edit(Some(CommonQuality::Medium), Some(CommonAspectRatio::Square), Some(1), 1), 3); + } + + #[test] + fn edit_medium_wide_one_output_one_input() { + assert_eq!(cost_edit(Some(CommonQuality::Medium), Some(CommonAspectRatio::WideSixteenByNine), Some(1), 1), 5); + } + + #[test] + fn edit_medium_tall_one_output_one_input() { + assert_eq!(cost_edit(Some(CommonQuality::Medium), Some(CommonAspectRatio::TallNineBySixteen), Some(1), 1), 5); + } + + #[test] + fn edit_high_square_one_output_one_input() { + assert_eq!(cost_edit(Some(CommonQuality::High), Some(CommonAspectRatio::Square), Some(1), 1), 13); + } + + #[test] + fn edit_high_wide_one_output_one_input() { + assert_eq!(cost_edit(Some(CommonQuality::High), Some(CommonAspectRatio::WideSixteenByNine), Some(1), 1), 20); + } + + #[test] + fn edit_high_square_four_outputs_one_input() { + assert_eq!(cost_edit(Some(CommonQuality::High), Some(CommonAspectRatio::Square), Some(4), 1), 52); + } + + #[test] + fn edit_cost_independent_of_input_image_count() { + for num_refs in [1usize, 2, 3, 5] { + assert_eq!( + cost_edit(Some(CommonQuality::Medium), None, Some(2), num_refs), + 6, + "expected 6¢ regardless of {} input images", + num_refs, + ); + } + } + + #[test] + fn edit_batch_above_max_clamps() { + assert_eq!(cost_edit(Some(CommonQuality::High), Some(CommonAspectRatio::Square), Some(9), 1), 52); + } + + #[test] + fn edit_and_text_cost_match() { + for q in [CommonQuality::Low, CommonQuality::Medium, CommonQuality::High] { + let text = distill_text(&make_request(Some("p"), None, Some(q), Some(2), None)) + .cost.cost_in_usd_cents.unwrap(); + let (tokens, hydration) = fake_image_refs(3); + let edit = distill_edit( + &make_request(Some("p"), None, Some(q), Some(2), Some(tokens)), + &hydration, + ).cost.cost_in_usd_cents.unwrap(); + assert_eq!( + text, edit, + "expected text/edit cost to match for quality {:?}", q, + ); + } + } + + #[test] + fn cost_metadata_flags_are_default() { + let (tokens, hydration) = fake_image_refs(1); + let request = make_request(Some("p"), None, None, Some(1), Some(tokens)); + let distilled = distill_edit(&request, &hydration); + assert!(!distilled.cost.is_free); + assert!(!distilled.cost.is_unlimited); + assert!(!distilled.cost.is_rate_limited); + assert!(!distilled.cost.has_watermark); + } + } + } + + // ──────────────────────────────────────────────────────────────────────────── + // PLAN + // ──────────────────────────────────────────────────────────────────────────── + mod plan { + use super::*; + + use artcraft_router::generate::generate_image::image_generation_plan::ImageGenerationPlan; + use artcraft_router::generate::generate_image::plan::fal::plan_generate_image_fal_gpt_image_1p5::{ + FalGptImage1p5ImageSize, FalGptImage1p5NumImages, FalGptImage1p5Quality, PlanFalGptImage1p5, + }; + + fn with_text_plan)>( + request: &OmniGenImageCostAndGenerateRequest, + assertion: F, + ) { + let distilled = distill_text(request); + match distilled.plan() { + ImageGenerationPlan::FalGptImage1p5(plan) => assertion(plan), + other => panic!("expected ImageGenerationPlan::FalGptImage1p5, got {:?}", other), + } + } + + fn with_edit_plan)>( + request: &OmniGenImageCostAndGenerateRequest, + hydration: &HashMap, + assertion: F, + ) { + let distilled = distill_edit(request, hydration); + match distilled.plan() { + ImageGenerationPlan::FalGptImage1p5(plan) => assertion(plan), + other => panic!("expected ImageGenerationPlan::FalGptImage1p5, got {:?}", other), + } + } + + mod text { + use super::*; + + // ── Quality mapping ─────────────────────────────────────────────────── + + #[test] + fn default_quality_is_medium() { + with_text_plan(&make_request(Some("p"), None, None, Some(1), None), |plan| { + assert!(matches!(plan.quality, FalGptImage1p5Quality::Medium)); + }); + } + + #[test] + fn low_quality_passes_through() { + with_text_plan(&make_request(Some("p"), None, Some(CommonQuality::Low), Some(1), None), |plan| { + assert!(matches!(plan.quality, FalGptImage1p5Quality::Low)); + }); + } + + #[test] + fn medium_quality_passes_through() { + with_text_plan(&make_request(Some("p"), None, Some(CommonQuality::Medium), Some(1), None), |plan| { + assert!(matches!(plan.quality, FalGptImage1p5Quality::Medium)); + }); + } + + #[test] + fn high_quality_passes_through() { + with_text_plan(&make_request(Some("p"), None, Some(CommonQuality::High), Some(1), None), |plan| { + assert!(matches!(plan.quality, FalGptImage1p5Quality::High)); + }); + } + + // ── Mode detection ──────────────────────────────────────────────────── + + #[test] + fn text_mode_has_empty_image_urls() { + with_text_plan(&make_request(Some("p"), None, None, Some(1), None), |plan| { + assert!(plan.image_urls.is_empty()); + }); + } + + // ── Image size mappings ─────────────────────────────────────────────── + + #[test] + fn default_image_size_is_none() { + with_text_plan(&make_request(Some("p"), None, None, Some(1), None), |plan| { + assert!(plan.image_size.is_none()); + }); + } + + #[test] + fn auto_variants_yield_none() { + for ar in [CommonAspectRatio::Auto, CommonAspectRatio::Auto2k, CommonAspectRatio::Auto4k] { + with_text_plan(&make_request(Some("p"), Some(ar), None, Some(1), None), |plan| { + assert!(plan.image_size.is_none(), "expected None for {:?}", ar); + }); + } + } + + #[test] + fn square_yields_square() { + for ar in [CommonAspectRatio::Square, CommonAspectRatio::SquareHd] { + with_text_plan(&make_request(Some("p"), Some(ar), None, Some(1), None), |plan| { + assert!(matches!(plan.image_size, Some(FalGptImage1p5ImageSize::Square)), "expected Square for {:?}", ar); + }); + } + } + + #[test] + fn wide_variants_yield_wide() { + let wide_ars = [ + CommonAspectRatio::WideFiveByFour, + CommonAspectRatio::WideFourByThree, + CommonAspectRatio::WideThreeByTwo, + CommonAspectRatio::WideSixteenByNine, + CommonAspectRatio::WideTwentyOneByNine, + CommonAspectRatio::Wide, + ]; + for ar in wide_ars { + with_text_plan(&make_request(Some("p"), Some(ar), None, Some(1), None), |plan| { + assert!(matches!(plan.image_size, Some(FalGptImage1p5ImageSize::Wide)), "expected Wide for {:?}", ar); + }); + } + } + + #[test] + fn tall_variants_yield_tall() { + let tall_ars = [ + CommonAspectRatio::TallFourByFive, + CommonAspectRatio::TallThreeByFour, + CommonAspectRatio::TallTwoByThree, + CommonAspectRatio::TallNineBySixteen, + CommonAspectRatio::TallNineByTwentyOne, + CommonAspectRatio::Tall, + ]; + for ar in tall_ars { + with_text_plan(&make_request(Some("p"), Some(ar), None, Some(1), None), |plan| { + assert!(matches!(plan.image_size, Some(FalGptImage1p5ImageSize::Tall)), "expected Tall for {:?}", ar); + }); + } + } + + // ── Num images mapping ──────────────────────────────────────────────── + + #[test] + fn default_batch_count_is_one() { + with_text_plan(&make_request(Some("p"), None, None, None, None), |plan| { + assert!(matches!(plan.num_images, FalGptImage1p5NumImages::One)); + }); + } + + #[test] + fn batch_direct_mapping() { + let cases = [ + (1u16, FalGptImage1p5NumImages::One), + (2, FalGptImage1p5NumImages::Two), + (3, FalGptImage1p5NumImages::Three), + (4, FalGptImage1p5NumImages::Four), + ]; + for (count, expected) in cases { + with_text_plan(&make_request(Some("p"), None, None, Some(count), None), |plan| { + assert!( + std::mem::discriminant(&plan.num_images) == std::mem::discriminant(&expected), + "expected {:?} for count {}", expected, count, + ); + }); + } + } + + #[test] + fn batch_above_four_clamps_to_four() { + with_text_plan(&make_request(Some("p"), None, None, Some(9), None), |plan| { + assert!(matches!(plan.num_images, FalGptImage1p5NumImages::Four)); + }); + } + + // ── Prompt passthrough ──────────────────────────────────────────────── + + #[test] + fn prompt_is_passed_through() { + with_text_plan(&make_request(Some("a corgi in a hat"), None, None, Some(1), None), |plan| { + assert_eq!(plan.prompt, Some("a corgi in a hat")); + }); + } + + #[test] + fn missing_prompt_is_none() { + with_text_plan(&make_request(None, None, None, Some(1), None), |plan| { + assert_eq!(plan.prompt, None); + }); + } + } + + mod edit { + use super::*; + + // ── Mode detection / image-url passthrough ──────────────────────────── + + #[test] + fn edit_mode_populates_image_urls() { + let (tokens, hydration) = fake_image_refs(1); + with_edit_plan( + &make_request(Some("p"), None, None, Some(1), Some(tokens)), + &hydration, + |plan| { + assert_eq!(plan.image_urls.len(), 1); + assert!(plan.image_urls[0].starts_with("https://fake.example.com/")); + }, + ); + } + + #[test] + fn edit_mode_with_two_image_refs() { + let (tokens, hydration) = fake_image_refs(2); + with_edit_plan( + &make_request(Some("p"), None, None, Some(1), Some(tokens)), + &hydration, + |plan| { assert_eq!(plan.image_urls.len(), 2); }, + ); + } + + #[test] + fn edit_mode_with_five_image_refs() { + let (tokens, hydration) = fake_image_refs(5); + with_edit_plan( + &make_request(Some("p"), None, None, Some(1), Some(tokens)), + &hydration, + |plan| { assert_eq!(plan.image_urls.len(), 5); }, + ); + } + + #[test] + fn edit_image_urls_are_hydrated_from_map() { + let (tokens, hydration) = fake_image_refs(3); + let expected: Vec = tokens.iter() + .map(|t| hydration.get(t).unwrap().to_string()) + .collect(); + with_edit_plan( + &make_request(Some("p"), None, None, Some(1), Some(tokens.clone())), + &hydration, + |plan| { assert_eq!(plan.image_urls, expected); }, + ); + } + + // ── Quality mapping in edit mode ────────────────────────────────────── + + #[test] + fn default_quality_is_medium_in_edit_mode() { + let (tokens, hydration) = fake_image_refs(1); + with_edit_plan( + &make_request(Some("p"), None, None, Some(1), Some(tokens)), + &hydration, + |plan| { assert!(matches!(plan.quality, FalGptImage1p5Quality::Medium)); }, + ); + } + + #[test] + fn low_quality_passes_through_in_edit_mode() { + let (tokens, hydration) = fake_image_refs(1); + with_edit_plan( + &make_request(Some("p"), None, Some(CommonQuality::Low), Some(1), Some(tokens)), + &hydration, + |plan| { assert!(matches!(plan.quality, FalGptImage1p5Quality::Low)); }, + ); + } + + #[test] + fn high_quality_passes_through_in_edit_mode() { + let (tokens, hydration) = fake_image_refs(1); + with_edit_plan( + &make_request(Some("p"), None, Some(CommonQuality::High), Some(1), Some(tokens)), + &hydration, + |plan| { assert!(matches!(plan.quality, FalGptImage1p5Quality::High)); }, + ); + } + + // ── Image size in edit mode ────────────────────────────────────────── + + #[test] + fn edit_default_image_size_is_none() { + let (tokens, hydration) = fake_image_refs(1); + with_edit_plan( + &make_request(Some("p"), None, None, Some(1), Some(tokens)), + &hydration, + |plan| { assert!(plan.image_size.is_none()); }, + ); + } + + #[test] + fn edit_square_yields_square() { + let (tokens, hydration) = fake_image_refs(1); + with_edit_plan( + &make_request(Some("p"), Some(CommonAspectRatio::Square), None, Some(1), Some(tokens)), + &hydration, + |plan| { assert!(matches!(plan.image_size, Some(FalGptImage1p5ImageSize::Square))); }, + ); + } + + #[test] + fn edit_wide_yields_wide() { + let (tokens, hydration) = fake_image_refs(1); + with_edit_plan( + &make_request(Some("p"), Some(CommonAspectRatio::WideSixteenByNine), None, Some(1), Some(tokens)), + &hydration, + |plan| { assert!(matches!(plan.image_size, Some(FalGptImage1p5ImageSize::Wide))); }, + ); + } + + #[test] + fn edit_tall_yields_tall() { + let (tokens, hydration) = fake_image_refs(1); + with_edit_plan( + &make_request(Some("p"), Some(CommonAspectRatio::TallNineBySixteen), None, Some(1), Some(tokens)), + &hydration, + |plan| { assert!(matches!(plan.image_size, Some(FalGptImage1p5ImageSize::Tall))); }, + ); + } + + #[test] + fn edit_auto_yields_none() { + let (tokens, hydration) = fake_image_refs(1); + with_edit_plan( + &make_request(Some("p"), Some(CommonAspectRatio::Auto), None, Some(1), Some(tokens)), + &hydration, + |plan| { assert!(plan.image_size.is_none()); }, + ); + } + + // ── Num images in edit mode ────────────────────────────────────────── + + #[test] + fn edit_default_batch_count_is_one() { + let (tokens, hydration) = fake_image_refs(1); + with_edit_plan( + &make_request(Some("p"), None, None, None, Some(tokens)), + &hydration, + |plan| { assert!(matches!(plan.num_images, FalGptImage1p5NumImages::One)); }, + ); + } + + #[test] + fn edit_batch_of_four_yields_four() { + let (tokens, hydration) = fake_image_refs(1); + with_edit_plan( + &make_request(Some("p"), None, None, Some(4), Some(tokens)), + &hydration, + |plan| { assert!(matches!(plan.num_images, FalGptImage1p5NumImages::Four)); }, + ); + } + + #[test] + fn edit_batch_above_four_clamps_to_four() { + let (tokens, hydration) = fake_image_refs(1); + with_edit_plan( + &make_request(Some("p"), None, None, Some(9), Some(tokens)), + &hydration, + |plan| { assert!(matches!(plan.num_images, FalGptImage1p5NumImages::Four)); }, + ); + } + + // ── Prompt passthrough in edit mode ──────────────────────────────────── + + #[test] + fn edit_prompt_is_passed_through() { + let (tokens, hydration) = fake_image_refs(1); + with_edit_plan( + &make_request(Some("make it shiny"), None, None, Some(1), Some(tokens)), + &hydration, + |plan| { assert_eq!(plan.prompt, Some("make it shiny")); }, + ); + } + + #[test] + fn edit_missing_prompt_is_none() { + let (tokens, hydration) = fake_image_refs(1); + with_edit_plan( + &make_request(None, None, None, Some(1), Some(tokens)), + &hydration, + |plan| { assert_eq!(plan.prompt, None); }, + ); + } + } + } +}