This commit is contained in:
Brandon Thomas committed 2026-05-14 21:24:01 -04:00
1 parent 479d27cd49
commit 74f0fb1731
9 files changed
+1089

No files matched your search

@@ -194,3 +194,208 @@ fn resolve_image_urls(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fmt::Debug;
use crate::api::common_image_model::CommonImageModel;
use crate::api::provider::Provider;
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: CommonImageModel::GptImage1,
provider: Provider::Fal,
prompt: Some("a cat in space".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn unwrap_t2i(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> GptImage1TextToImageRequest {
let ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalGptImage1(
FalGptImage1RequestState::TextToImage(req)
)
) = result.expect("build should succeed") else {
panic!("expected GPT Image 1 text-to-image request")
};
req
}
fn unwrap_edit(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> GptImage1EditImageRequest {
let ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalGptImage1(
FalGptImage1RequestState::EditImage(req)
)
) = result.expect("build should succeed") else {
panic!("expected GPT Image 1 edit-image request")
};
req
}
fn assert_debug<T: Debug>(actual: T, expected: &str) {
assert_eq!(format!("{:?}", actual), expected);
}
#[test]
fn build2_routes_to_gpt_image_1_request() {
let req = unwrap_t2i(base_builder().build2());
assert_eq!(req.prompt, "a cat in space");
}
#[test]
fn mode_detection_is_based_on_image_urls() {
let text_req = unwrap_t2i(build_fal_gpt_image_1(base_builder()));
assert_eq!(text_req.prompt, "a cat in space");
let edit_req = unwrap_edit(build_fal_gpt_image_1(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
..base_builder()
}));
assert_eq!(edit_req.image_urls, vec!["https://example.com/img.jpg"]);
}
#[test]
fn media_file_tokens_are_rejected() {
let result = build_fal_gpt_image_1(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::MediaFileTokens(vec![])),
..base_builder()
});
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
));
}
#[test]
fn num_images_maps_exhaustively_for_text_and_edit() {
let text_cases = [
(1, "One"),
(2, "Two"),
(3, "Three"),
(4, "Four"),
];
for (count, expected) in text_cases {
let req = unwrap_t2i(build_fal_gpt_image_1(GenerateImageRequestBuilder {
image_batch_count: Some(count),
..base_builder()
}));
assert_debug(req.num_images, expected);
}
for (count, expected) in text_cases {
let req = unwrap_edit(build_fal_gpt_image_1(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
image_batch_count: Some(count),
..base_builder()
}));
assert_debug(req.num_images, expected);
}
}
#[test]
fn num_images_rejects_zero_and_handles_overflow_by_strategy() {
assert!(matches!(
build_fal_gpt_image_1(GenerateImageRequestBuilder {
image_batch_count: Some(0),
..base_builder()
}),
Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))
));
assert!(matches!(
build_fal_gpt_image_1(GenerateImageRequestBuilder {
image_batch_count: Some(5),
..base_builder()
}),
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))
));
let req = unwrap_t2i(build_fal_gpt_image_1(GenerateImageRequestBuilder {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_builder()
}));
assert_debug(req.num_images, "Four");
}
#[test]
fn quality_maps_exhaustively_for_text_and_edit() {
let cases = [
(None, "Some(High)"),
(Some(CommonQuality::Low), "Some(Low)"),
(Some(CommonQuality::Medium), "Some(Medium)"),
(Some(CommonQuality::High), "Some(High)"),
];
for (quality, expected) in cases {
let req = unwrap_t2i(build_fal_gpt_image_1(GenerateImageRequestBuilder {
quality,
..base_builder()
}));
assert_debug(req.quality, expected);
}
for (quality, expected) in cases {
let req = unwrap_edit(build_fal_gpt_image_1(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
quality,
..base_builder()
}));
assert_debug(req.quality, expected);
}
}
#[test]
fn aspect_ratio_maps_exhaustively_for_text_and_edit() {
let cases = [
(None, "None"),
(Some(CommonAspectRatio::Auto), "None"),
(Some(CommonAspectRatio::Auto2k), "None"),
(Some(CommonAspectRatio::Auto3k), "None"),
(Some(CommonAspectRatio::Auto4k), "None"),
(Some(CommonAspectRatio::Square), "Some(Square)"),
(Some(CommonAspectRatio::SquareHd), "Some(Square)"),
(Some(CommonAspectRatio::WideThreeByTwo), "Some(Horizontal)"),
(Some(CommonAspectRatio::WideFourByThree), "Some(Horizontal)"),
(Some(CommonAspectRatio::WideFiveByFour), "Some(Horizontal)"),
(Some(CommonAspectRatio::WideSixteenByNine), "Some(Horizontal)"),
(Some(CommonAspectRatio::WideTwentyOneByNine), "Some(Horizontal)"),
(Some(CommonAspectRatio::Wide), "Some(Horizontal)"),
(Some(CommonAspectRatio::TallTwoByThree), "Some(Vertical)"),
(Some(CommonAspectRatio::TallThreeByFour), "Some(Vertical)"),
(Some(CommonAspectRatio::TallFourByFive), "Some(Vertical)"),
(Some(CommonAspectRatio::TallNineBySixteen), "Some(Vertical)"),
(Some(CommonAspectRatio::TallNineByTwentyOne), "Some(Vertical)"),
(Some(CommonAspectRatio::Tall), "Some(Vertical)"),
];
for (aspect_ratio, expected) in cases {
let req = unwrap_t2i(build_fal_gpt_image_1(GenerateImageRequestBuilder {
aspect_ratio,
..base_builder()
}));
assert_debug(req.image_size, expected);
}
for (aspect_ratio, expected) in cases {
let req = unwrap_edit(build_fal_gpt_image_1(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
aspect_ratio,
..base_builder()
}));
assert_debug(req.image_size, expected);
}
}
}
@@ -29,3 +29,93 @@ impl FalGptImage1CostState {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use fal_client::requests::api::image::edit::gpt_image_1_edit_image::api::{
GptImage1EditImageNumImages, GptImage1EditImageQuality, GptImage1EditImageRequest,
GptImage1EditImageSize,
};
use fal_client::requests::api::image::text::gpt_image_1_text_to_image::api::{
GptImage1TextToImageNumImages, GptImage1TextToImageQuality,
GptImage1TextToImageRequest, GptImage1TextToImageSize,
};
#[test]
fn text_to_image_cost_spots() {
let cases = [
(
GptImage1TextToImageNumImages::One,
Some(GptImage1TextToImageQuality::High),
Some(GptImage1TextToImageSize::Square),
17,
),
(
GptImage1TextToImageNumImages::Three,
Some(GptImage1TextToImageQuality::Medium),
Some(GptImage1TextToImageSize::Horizontal),
21,
),
(
GptImage1TextToImageNumImages::Four,
Some(GptImage1TextToImageQuality::Low),
Some(GptImage1TextToImageSize::Vertical),
8,
),
];
for (num_images, quality, image_size, expected) in cases {
let request = FalGptImage1RequestState::TextToImage(GptImage1TextToImageRequest {
prompt: "test".to_string(),
num_images,
image_size,
quality,
background: None,
output_format: None,
});
let cost = FalGptImage1CostState::from_request(&request).estimate_cost();
assert_eq!(cost.cost_in_usd_cents, Some(expected));
}
}
#[test]
fn edit_image_cost_spots() {
let cases = [
(
GptImage1EditImageNumImages::One,
Some(GptImage1EditImageQuality::High),
Some(GptImage1EditImageSize::Horizontal),
25,
),
(
GptImage1EditImageNumImages::Two,
Some(GptImage1EditImageQuality::Medium),
Some(GptImage1EditImageSize::Square),
10,
),
(
GptImage1EditImageNumImages::Four,
Some(GptImage1EditImageQuality::Low),
Some(GptImage1EditImageSize::Auto),
8,
),
];
for (num_images, quality, image_size, expected) in cases {
let request = FalGptImage1RequestState::EditImage(GptImage1EditImageRequest {
prompt: "test".to_string(),
image_urls: vec!["https://example.com/image.png".to_string()],
num_images,
mask_image_url: None,
image_size,
quality,
input_fidelity: None,
background: None,
output_format: None,
});
let cost = FalGptImage1CostState::from_request(&request).estimate_cost();
assert_eq!(cost.cost_in_usd_cents, Some(expected));
}
}
}
@@ -68,3 +68,58 @@ async fn send_fal_request<T: FalEndpoint>(
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::api::image::edit::gpt_image_1_edit_image::api::{
GptImage1EditImageNumImages, GptImage1EditImageQuality, GptImage1EditImageSize,
};
use fal_client::requests::api::image::text::gpt_image_1_text_to_image::api::{
GptImage1TextToImageNumImages, GptImage1TextToImageQuality,
GptImage1TextToImageSize,
};
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
fn client_without_webhook() -> RouterFalWebhookOptionalClient {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
.expect("Failed to read fal_api_key.txt");
RouterFalWebhookOptionalClient::new(FalApiKey::from_str(secret.trim()))
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_text_to_image_queue() {
let request = FalGptImage1RequestState::TextToImage(GptImage1TextToImageRequest {
prompt: "a small ceramic robot reading by a window".to_string(),
num_images: GptImage1TextToImageNumImages::One,
image_size: Some(GptImage1TextToImageSize::Square),
quality: Some(GptImage1TextToImageQuality::Low),
background: None,
output_format: None,
});
let response = request.send(&client_without_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some());
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_edit_image_queue() {
let request = FalGptImage1RequestState::EditImage(GptImage1EditImageRequest {
prompt: "make the image feel like a quiet watercolor study".to_string(),
image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()],
num_images: GptImage1EditImageNumImages::One,
mask_image_url: None,
image_size: Some(GptImage1EditImageSize::Square),
quality: Some(GptImage1EditImageQuality::Low),
input_fidelity: None,
background: None,
output_format: None,
});
let response = request.send(&client_without_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some());
}
}
@@ -194,3 +194,208 @@ fn resolve_image_urls(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fmt::Debug;
use crate::api::common_image_model::CommonImageModel;
use crate::api::provider::Provider;
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: CommonImageModel::GptImage1p5,
provider: Provider::Fal,
prompt: Some("a cat in space".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn unwrap_t2i(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> GptImage1p5TextToImageRequest {
let ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalGptImage1p5(
FalGptImage1p5RequestState::TextToImage(req)
)
) = result.expect("build should succeed") else {
panic!("expected GPT Image 1.5 text-to-image request")
};
req
}
fn unwrap_edit(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> GptImage1p5EditImageRequest {
let ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalGptImage1p5(
FalGptImage1p5RequestState::EditImage(req)
)
) = result.expect("build should succeed") else {
panic!("expected GPT Image 1.5 edit-image request")
};
req
}
fn assert_debug<T: Debug>(actual: T, expected: &str) {
assert_eq!(format!("{:?}", actual), expected);
}
#[test]
fn build2_routes_to_gpt_image_1p5_request() {
let req = unwrap_t2i(base_builder().build2());
assert_eq!(req.prompt, "a cat in space");
}
#[test]
fn mode_detection_is_based_on_image_urls() {
let text_req = unwrap_t2i(build_fal_gpt_image_1p5(base_builder()));
assert_eq!(text_req.prompt, "a cat in space");
let edit_req = unwrap_edit(build_fal_gpt_image_1p5(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
..base_builder()
}));
assert_eq!(edit_req.image_urls, vec!["https://example.com/img.jpg"]);
}
#[test]
fn media_file_tokens_are_rejected() {
let result = build_fal_gpt_image_1p5(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::MediaFileTokens(vec![])),
..base_builder()
});
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
));
}
#[test]
fn num_images_maps_exhaustively_for_text_and_edit() {
let cases = [
(1, "One"),
(2, "Two"),
(3, "Three"),
(4, "Four"),
];
for (count, expected) in cases {
let req = unwrap_t2i(build_fal_gpt_image_1p5(GenerateImageRequestBuilder {
image_batch_count: Some(count),
..base_builder()
}));
assert_debug(req.num_images, expected);
}
for (count, expected) in cases {
let req = unwrap_edit(build_fal_gpt_image_1p5(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
image_batch_count: Some(count),
..base_builder()
}));
assert_debug(req.num_images, expected);
}
}
#[test]
fn num_images_rejects_zero_and_handles_overflow_by_strategy() {
assert!(matches!(
build_fal_gpt_image_1p5(GenerateImageRequestBuilder {
image_batch_count: Some(0),
..base_builder()
}),
Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))
));
assert!(matches!(
build_fal_gpt_image_1p5(GenerateImageRequestBuilder {
image_batch_count: Some(5),
..base_builder()
}),
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))
));
let req = unwrap_t2i(build_fal_gpt_image_1p5(GenerateImageRequestBuilder {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_builder()
}));
assert_debug(req.num_images, "Four");
}
#[test]
fn quality_maps_exhaustively_for_text_and_edit() {
let cases = [
(None, "Some(High)"),
(Some(CommonQuality::Low), "Some(Low)"),
(Some(CommonQuality::Medium), "Some(Medium)"),
(Some(CommonQuality::High), "Some(High)"),
];
for (quality, expected) in cases {
let req = unwrap_t2i(build_fal_gpt_image_1p5(GenerateImageRequestBuilder {
quality,
..base_builder()
}));
assert_debug(req.quality, expected);
}
for (quality, expected) in cases {
let req = unwrap_edit(build_fal_gpt_image_1p5(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
quality,
..base_builder()
}));
assert_debug(req.quality, expected);
}
}
#[test]
fn aspect_ratio_maps_exhaustively_for_text_and_edit() {
let cases = [
(None, "None"),
(Some(CommonAspectRatio::Auto), "None"),
(Some(CommonAspectRatio::Auto2k), "None"),
(Some(CommonAspectRatio::Auto3k), "None"),
(Some(CommonAspectRatio::Auto4k), "None"),
(Some(CommonAspectRatio::Square), "Some(Square)"),
(Some(CommonAspectRatio::SquareHd), "Some(Square)"),
(Some(CommonAspectRatio::WideThreeByTwo), "Some(Wide)"),
(Some(CommonAspectRatio::WideFourByThree), "Some(Wide)"),
(Some(CommonAspectRatio::WideFiveByFour), "Some(Wide)"),
(Some(CommonAspectRatio::WideSixteenByNine), "Some(Wide)"),
(Some(CommonAspectRatio::WideTwentyOneByNine), "Some(Wide)"),
(Some(CommonAspectRatio::Wide), "Some(Wide)"),
(Some(CommonAspectRatio::TallTwoByThree), "Some(Tall)"),
(Some(CommonAspectRatio::TallThreeByFour), "Some(Tall)"),
(Some(CommonAspectRatio::TallFourByFive), "Some(Tall)"),
(Some(CommonAspectRatio::TallNineBySixteen), "Some(Tall)"),
(Some(CommonAspectRatio::TallNineByTwentyOne), "Some(Tall)"),
(Some(CommonAspectRatio::Tall), "Some(Tall)"),
];
for (aspect_ratio, expected) in cases {
let req = unwrap_t2i(build_fal_gpt_image_1p5(GenerateImageRequestBuilder {
aspect_ratio,
..base_builder()
}));
assert_debug(req.image_size, expected);
}
for (aspect_ratio, expected) in cases {
let req = unwrap_edit(build_fal_gpt_image_1p5(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
aspect_ratio,
..base_builder()
}));
assert_debug(req.image_size, expected);
}
}
}
@@ -29,3 +29,93 @@ impl FalGptImage1p5CostState {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use fal_client::requests::api::image::edit::gpt_image_1p5_edit_image::api::{
GptImage1p5EditImageNumImages, GptImage1p5EditImageQuality,
GptImage1p5EditImageRequest, GptImage1p5EditImageSize,
};
use fal_client::requests::api::image::text::gpt_image_1p5_text_to_image::api::{
GptImage1p5TextToImageNumImages, GptImage1p5TextToImageQuality,
GptImage1p5TextToImageRequest, GptImage1p5TextToImageSize,
};
#[test]
fn text_to_image_cost_spots() {
let cases = [
(
GptImage1p5TextToImageNumImages::One,
Some(GptImage1p5TextToImageQuality::High),
Some(GptImage1p5TextToImageSize::Square),
13,
),
(
GptImage1p5TextToImageNumImages::Three,
Some(GptImage1p5TextToImageQuality::Medium),
Some(GptImage1p5TextToImageSize::Wide),
15,
),
(
GptImage1p5TextToImageNumImages::Four,
Some(GptImage1p5TextToImageQuality::Low),
Some(GptImage1p5TextToImageSize::Tall),
4,
),
];
for (num_images, quality, image_size, expected) in cases {
let request = FalGptImage1p5RequestState::TextToImage(GptImage1p5TextToImageRequest {
prompt: "test".to_string(),
num_images,
image_size,
background: None,
quality,
output_format: None,
});
let cost = FalGptImage1p5CostState::from_request(&request).estimate_cost();
assert_eq!(cost.cost_in_usd_cents, Some(expected));
}
}
#[test]
fn edit_image_cost_spots() {
let cases = [
(
GptImage1p5EditImageNumImages::One,
Some(GptImage1p5EditImageQuality::High),
Some(GptImage1p5EditImageSize::Wide),
20,
),
(
GptImage1p5EditImageNumImages::Two,
Some(GptImage1p5EditImageQuality::Medium),
Some(GptImage1p5EditImageSize::Square),
6,
),
(
GptImage1p5EditImageNumImages::Four,
Some(GptImage1p5EditImageQuality::Low),
Some(GptImage1p5EditImageSize::Tall),
4,
),
];
for (num_images, quality, image_size, expected) in cases {
let request = FalGptImage1p5RequestState::EditImage(GptImage1p5EditImageRequest {
prompt: "test".to_string(),
image_urls: vec!["https://example.com/image.png".to_string()],
num_images,
mask_image_url: None,
image_size,
background: None,
quality,
input_fidelity: None,
output_format: None,
});
let cost = FalGptImage1p5CostState::from_request(&request).estimate_cost();
assert_eq!(cost.cost_in_usd_cents, Some(expected));
}
}
}
@@ -68,3 +68,59 @@ async fn send_fal_request<T: FalEndpoint>(
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::api::image::edit::gpt_image_1p5_edit_image::api::{
GptImage1p5EditImageNumImages, GptImage1p5EditImageQuality,
GptImage1p5EditImageSize,
};
use fal_client::requests::api::image::text::gpt_image_1p5_text_to_image::api::{
GptImage1p5TextToImageNumImages, GptImage1p5TextToImageQuality,
GptImage1p5TextToImageSize,
};
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
fn client_without_webhook() -> RouterFalWebhookOptionalClient {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
.expect("Failed to read fal_api_key.txt");
RouterFalWebhookOptionalClient::new(FalApiKey::from_str(secret.trim()))
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_text_to_image_queue() {
let request = FalGptImage1p5RequestState::TextToImage(GptImage1p5TextToImageRequest {
prompt: "a tiny brass observatory in a snowy field".to_string(),
num_images: GptImage1p5TextToImageNumImages::One,
image_size: Some(GptImage1p5TextToImageSize::Square),
background: None,
quality: Some(GptImage1p5TextToImageQuality::Low),
output_format: None,
});
let response = request.send(&client_without_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some());
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_edit_image_queue() {
let request = FalGptImage1p5RequestState::EditImage(GptImage1p5EditImageRequest {
prompt: "turn this into a softly lit editorial photograph".to_string(),
image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()],
num_images: GptImage1p5EditImageNumImages::One,
mask_image_url: None,
image_size: Some(GptImage1p5EditImageSize::Square),
background: None,
quality: Some(GptImage1p5EditImageQuality::Low),
input_fidelity: None,
output_format: None,
});
let response = request.send(&client_without_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some());
}
}
@@ -219,3 +219,240 @@ fn resolve_image_urls(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fmt::Debug;
use crate::api::common_image_model::CommonImageModel;
use crate::api::provider::Provider;
fn base_builder() -> GenerateImageRequestBuilder {
GenerateImageRequestBuilder {
model: CommonImageModel::GptImage2,
provider: Provider::Fal,
prompt: Some("a cat in space".to_string()),
image_inputs: None,
resolution: None,
aspect_ratio: None,
quality: None,
image_batch_count: None,
horizontal_angle: None,
vertical_angle: None,
zoom: None,
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::ErrorOut,
generation_mode_mismatch_strategy: None,
idempotency_token: None,
}
}
fn unwrap_t2i(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> GptImage2TextToImageRequest {
let ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalGptImage2(
FalGptImage2RequestState::TextToImage(req)
)
) = result.expect("build should succeed") else {
panic!("expected GPT Image 2 text-to-image request")
};
req
}
fn unwrap_edit(result: Result<ImageGenerationDraftOrRequest, ArtcraftRouterError>) -> GptImage2EditImageRequest {
let ImageGenerationDraftOrRequest::Request(
ImageGenerationRequest::FalGptImage2(
FalGptImage2RequestState::EditImage(req)
)
) = result.expect("build should succeed") else {
panic!("expected GPT Image 2 edit-image request")
};
req
}
fn assert_debug<T: Debug>(actual: T, expected: &str) {
assert_eq!(format!("{:?}", actual), expected);
}
#[test]
fn build2_routes_to_gpt_image_2_request() {
let req = unwrap_t2i(base_builder().build2());
assert_eq!(req.prompt, "a cat in space");
}
#[test]
fn mode_detection_is_based_on_image_urls() {
let text_req = unwrap_t2i(build_fal_gpt_image_2(base_builder()));
assert_eq!(text_req.prompt, "a cat in space");
let edit_req = unwrap_edit(build_fal_gpt_image_2(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
..base_builder()
}));
assert_eq!(edit_req.image_urls, vec!["https://example.com/img.jpg"]);
}
#[test]
fn media_file_tokens_are_rejected() {
let result = build_fal_gpt_image_2(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::MediaFileTokens(vec![])),
..base_builder()
});
assert!(matches!(
result,
Err(ArtcraftRouterError::Client(ClientError::FalOnlySupportsUrls))
));
}
#[test]
fn num_images_maps_exhaustively_for_text_and_edit() {
let cases = [
(1, "One"),
(2, "Two"),
(3, "Three"),
(4, "Four"),
];
for (count, expected) in cases {
let req = unwrap_t2i(build_fal_gpt_image_2(GenerateImageRequestBuilder {
image_batch_count: Some(count),
..base_builder()
}));
assert_debug(req.num_images, expected);
}
for (count, expected) in cases {
let req = unwrap_edit(build_fal_gpt_image_2(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
image_batch_count: Some(count),
..base_builder()
}));
assert_debug(req.num_images, expected);
}
}
#[test]
fn num_images_rejects_zero_and_handles_overflow_by_strategy() {
assert!(matches!(
build_fal_gpt_image_2(GenerateImageRequestBuilder {
image_batch_count: Some(0),
..base_builder()
}),
Err(ArtcraftRouterError::Client(ClientError::UserRequestedZeroGenerations))
));
assert!(matches!(
build_fal_gpt_image_2(GenerateImageRequestBuilder {
image_batch_count: Some(5),
..base_builder()
}),
Err(ArtcraftRouterError::Client(ClientError::ModelDoesNotSupportOption { .. }))
));
let req = unwrap_t2i(build_fal_gpt_image_2(GenerateImageRequestBuilder {
image_batch_count: Some(5),
request_mismatch_mitigation_strategy: RequestMismatchMitigationStrategy::PayMoreUpgrade,
..base_builder()
}));
assert_debug(req.num_images, "Four");
}
#[test]
fn quality_maps_exhaustively_for_text_and_edit() {
let cases = [
(None, "Some(High)"),
(Some(CommonQuality::Low), "Some(Low)"),
(Some(CommonQuality::Medium), "Some(Medium)"),
(Some(CommonQuality::High), "Some(High)"),
];
for (quality, expected) in cases {
let req = unwrap_t2i(build_fal_gpt_image_2(GenerateImageRequestBuilder {
quality,
..base_builder()
}));
assert_debug(req.quality, expected);
}
for (quality, expected) in cases {
let req = unwrap_edit(build_fal_gpt_image_2(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
quality,
..base_builder()
}));
assert_debug(req.quality, expected);
}
}
#[test]
fn resolution_maps_exhaustively_for_text_and_edit() {
let cases = [
(None, "None"),
(Some(CommonResolution::HalfK), "Some(OneK)"),
(Some(CommonResolution::FourEightyP), "Some(OneK)"),
(Some(CommonResolution::SevenTwentyP), "Some(OneK)"),
(Some(CommonResolution::OneK), "Some(OneK)"),
(Some(CommonResolution::TenEightyP), "Some(TwoK)"),
(Some(CommonResolution::TwoK), "Some(TwoK)"),
(Some(CommonResolution::ThreeK), "Some(ThreeK)"),
(Some(CommonResolution::FourK), "Some(FourK)"),
];
for (resolution, expected) in cases {
let req = unwrap_t2i(build_fal_gpt_image_2(GenerateImageRequestBuilder {
resolution,
..base_builder()
}));
assert_debug(req.resolution, expected);
}
for (resolution, expected) in cases {
let req = unwrap_edit(build_fal_gpt_image_2(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
resolution,
..base_builder()
}));
assert_debug(req.resolution, expected);
}
}
#[test]
fn aspect_ratio_maps_exhaustively_for_text_and_edit() {
let cases = [
(None, "None", "None"),
(Some(CommonAspectRatio::Auto), "None", "Some(Auto)"),
(Some(CommonAspectRatio::Auto2k), "None", "Some(Auto)"),
(Some(CommonAspectRatio::Auto3k), "None", "Some(Auto)"),
(Some(CommonAspectRatio::Auto4k), "None", "Some(Auto)"),
(Some(CommonAspectRatio::Square), "Some(Square)", "Some(Square)"),
(Some(CommonAspectRatio::SquareHd), "Some(SquareHd)", "Some(SquareHd)"),
(Some(CommonAspectRatio::WideThreeByTwo), "Some(Landscape16x9)", "Some(Landscape16x9)"),
(Some(CommonAspectRatio::WideFourByThree), "Some(Landscape4x3)", "Some(Landscape4x3)"),
(Some(CommonAspectRatio::WideFiveByFour), "Some(Landscape4x3)", "Some(Landscape4x3)"),
(Some(CommonAspectRatio::WideSixteenByNine), "Some(Landscape16x9)", "Some(Landscape16x9)"),
(Some(CommonAspectRatio::WideTwentyOneByNine), "Some(Landscape16x9)", "Some(Landscape16x9)"),
(Some(CommonAspectRatio::Wide), "Some(Landscape16x9)", "Some(Landscape16x9)"),
(Some(CommonAspectRatio::TallTwoByThree), "Some(Portrait16x9)", "Some(Portrait16x9)"),
(Some(CommonAspectRatio::TallThreeByFour), "Some(Portrait4x3)", "Some(Portrait4x3)"),
(Some(CommonAspectRatio::TallFourByFive), "Some(Portrait4x3)", "Some(Portrait4x3)"),
(Some(CommonAspectRatio::TallNineBySixteen), "Some(Portrait16x9)", "Some(Portrait16x9)"),
(Some(CommonAspectRatio::TallNineByTwentyOne), "Some(Portrait16x9)", "Some(Portrait16x9)"),
(Some(CommonAspectRatio::Tall), "Some(Portrait16x9)", "Some(Portrait16x9)"),
];
for (aspect_ratio, expected_t2i, _) in cases {
let req = unwrap_t2i(build_fal_gpt_image_2(GenerateImageRequestBuilder {
aspect_ratio,
..base_builder()
}));
assert_debug(req.image_size, expected_t2i);
}
for (aspect_ratio, _, expected_edit) in cases {
let req = unwrap_edit(build_fal_gpt_image_2(GenerateImageRequestBuilder {
image_inputs: Some(ImageListRef::Urls(vec!["https://example.com/img.jpg".to_string()])),
aspect_ratio,
..base_builder()
}));
assert_debug(req.image_size, expected_edit);
}
}
}
@@ -29,3 +29,99 @@ impl FalGptImage2CostState {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use fal_client::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution;
use fal_client::requests::api::image::edit::gpt_image_2_edit_image::api::{
GptImage2EditImageNumImages, GptImage2EditImageQuality, GptImage2EditImageRequest,
GptImage2EditImageSize,
};
use fal_client::requests::api::image::text::gpt_image_2_text_to_image::api::{
GptImage2TextToImageNumImages, GptImage2TextToImageQuality,
GptImage2TextToImageRequest, GptImage2TextToImageSize,
};
#[test]
fn text_to_image_cost_spots() {
let cases = [
(
GptImage2TextToImageNumImages::One,
Some(GptImage2TextToImageQuality::High),
Some(GptImage2TextToImageSize::Landscape4x3),
Some(GptImage2Resolution::OneK),
16,
),
(
GptImage2TextToImageNumImages::Two,
Some(GptImage2TextToImageQuality::Medium),
Some(GptImage2TextToImageSize::Landscape16x9),
Some(GptImage2Resolution::TwoK),
11,
),
(
GptImage2TextToImageNumImages::Four,
Some(GptImage2TextToImageQuality::Low),
Some(GptImage2TextToImageSize::Square),
Some(GptImage2Resolution::FourK),
10,
),
];
for (num_images, quality, image_size, resolution, expected) in cases {
let request = FalGptImage2RequestState::TextToImage(GptImage2TextToImageRequest {
prompt: "test".to_string(),
num_images,
image_size,
resolution,
quality,
output_format: None,
});
let cost = FalGptImage2CostState::from_request(&request).estimate_cost();
assert_eq!(cost.cost_in_usd_cents, Some(expected));
}
}
#[test]
fn edit_image_cost_spots() {
let cases = [
(
GptImage2EditImageNumImages::One,
Some(GptImage2EditImageQuality::High),
Some(GptImage2EditImageSize::Portrait4x3),
Some(GptImage2Resolution::TwoK),
24,
),
(
GptImage2EditImageNumImages::Three,
Some(GptImage2EditImageQuality::Medium),
Some(GptImage2EditImageSize::SquareHd),
Some(GptImage2Resolution::TwoK),
21,
),
(
GptImage2EditImageNumImages::Four,
Some(GptImage2EditImageQuality::Low),
Some(GptImage2EditImageSize::Auto),
Some(GptImage2Resolution::OneK),
10,
),
];
for (num_images, quality, image_size, resolution, expected) in cases {
let request = FalGptImage2RequestState::EditImage(GptImage2EditImageRequest {
prompt: "test".to_string(),
image_urls: vec!["https://example.com/image.png".to_string()],
num_images,
mask_url: None,
image_size,
resolution,
quality,
output_format: None,
});
let cost = FalGptImage2CostState::from_request(&request).estimate_cost();
assert_eq!(cost.cost_in_usd_cents, Some(expected));
}
}
}
@@ -68,3 +68,58 @@ async fn send_fal_request<T: FalEndpoint>(
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fal_client::creds::fal_api_key::FalApiKey;
use fal_client::requests::api::image::common::gpt_image_2_resolution::GptImage2Resolution;
use fal_client::requests::api::image::edit::gpt_image_2_edit_image::api::{
GptImage2EditImageNumImages, GptImage2EditImageQuality, GptImage2EditImageSize,
};
use fal_client::requests::api::image::text::gpt_image_2_text_to_image::api::{
GptImage2TextToImageNumImages, GptImage2TextToImageQuality,
GptImage2TextToImageSize,
};
use test_data::web::image_urls::JUNO_AT_LAKE_IMAGE_URL;
fn client_without_webhook() -> RouterFalWebhookOptionalClient {
let secret = std::fs::read_to_string("/Users/bt/Artcraft/credentials/fal_api_key.txt")
.expect("Failed to read fal_api_key.txt");
RouterFalWebhookOptionalClient::new(FalApiKey::from_str(secret.trim()))
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_text_to_image_queue() {
let request = FalGptImage2RequestState::TextToImage(GptImage2TextToImageRequest {
prompt: "a polished product photo of a glass compass".to_string(),
num_images: GptImage2TextToImageNumImages::One,
image_size: Some(GptImage2TextToImageSize::Square),
resolution: Some(GptImage2Resolution::OneK),
quality: Some(GptImage2TextToImageQuality::Low),
output_format: None,
});
let response = request.send(&client_without_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some());
}
#[tokio::test]
#[ignore] // requires real API key, incurs cost
async fn send_edit_image_queue() {
let request = FalGptImage2RequestState::EditImage(GptImage2EditImageRequest {
prompt: "make this look like an elegant magazine cover image".to_string(),
image_urls: vec![JUNO_AT_LAKE_IMAGE_URL.to_string()],
num_images: GptImage2EditImageNumImages::One,
mask_url: None,
image_size: Some(GptImage2EditImageSize::Square),
resolution: Some(GptImage2Resolution::OneK),
quality: Some(GptImage2EditImageQuality::Low),
output_format: None,
});
let response = request.send(&client_without_webhook()).await.expect("send should succeed");
let payload = response.get_fal_payload().expect("expected Fal payload");
assert!(payload.request_id.is_some());
}
}