Merge branch 'test/jimeng-unit' into feat/embed-desktop
This commit is contained in:
@@ -384,4 +384,101 @@ mod tests {
|
||||
Err(JimengError::Disabled)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_body_accepts_all_allowed_aspect_ratios() {
|
||||
for ratio in ALLOWED_ASPECT_RATIOS {
|
||||
let req = VideoRequest {
|
||||
prompt: Some("x".into()),
|
||||
image_urls: vec![],
|
||||
frames: 121,
|
||||
aspect_ratio: Some(ratio.to_string()),
|
||||
seed: None,
|
||||
};
|
||||
let body = build_video_body(&req)
|
||||
.unwrap_or_else(|e| panic!("ratio {ratio} should be allowed: {e:?}"));
|
||||
assert_eq!(body["aspect_ratio"], ratio);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_body_accepts_both_frame_counts() {
|
||||
for frames in ALLOWED_FRAMES {
|
||||
let req = VideoRequest {
|
||||
prompt: Some("x".into()),
|
||||
image_urls: vec![],
|
||||
frames,
|
||||
aspect_ratio: None,
|
||||
seed: None,
|
||||
};
|
||||
let body = build_video_body(&req)
|
||||
.unwrap_or_else(|e| panic!("frames {frames} should be allowed: {e:?}"));
|
||||
assert_eq!(body["frames"], frames);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_body_includes_prompt_and_image_together() {
|
||||
// Both inputs supplied (image-to-video with a guiding prompt): both end
|
||||
// up in the body.
|
||||
let req = VideoRequest {
|
||||
prompt: Some("a dragon".into()),
|
||||
image_urls: vec!["https://x/a.jpg".into(), "https://x/b.jpg".into()],
|
||||
frames: 241,
|
||||
aspect_ratio: Some("9:16".into()),
|
||||
seed: Some(42),
|
||||
};
|
||||
let body = build_video_body(&req).unwrap();
|
||||
assert_eq!(body["prompt"], "a dragon");
|
||||
assert_eq!(body["image_urls"][1], "https://x/b.jpg");
|
||||
assert_eq!(body["frames"], 241);
|
||||
assert_eq!(body["seed"], 42);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_body_drops_whitespace_prompt_but_keeps_image() {
|
||||
// A whitespace-only prompt is not a real prompt: it must not be written
|
||||
// to the body, yet the image alone satisfies the "one of" requirement.
|
||||
let req = VideoRequest {
|
||||
prompt: Some(" ".into()),
|
||||
image_urls: vec!["https://x/a.jpg".into()],
|
||||
frames: 121,
|
||||
aspect_ratio: None,
|
||||
seed: None,
|
||||
};
|
||||
let body = build_video_body(&req).unwrap();
|
||||
let obj = body.as_object().unwrap();
|
||||
assert!(!obj.contains_key("prompt"), "blank prompt must be omitted");
|
||||
assert_eq!(body["image_urls"][0], "https://x/a.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_body_omits_absent_optional_fields() {
|
||||
let req = VideoRequest {
|
||||
prompt: Some("x".into()),
|
||||
image_urls: vec![],
|
||||
frames: 121,
|
||||
aspect_ratio: None,
|
||||
seed: None,
|
||||
};
|
||||
let body = build_video_body(&req).unwrap();
|
||||
let obj = body.as_object().unwrap();
|
||||
assert!(!obj.contains_key("aspect_ratio"));
|
||||
assert!(!obj.contains_key("seed"));
|
||||
assert!(!obj.contains_key("image_urls"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_poll_parameters_differ_image_vs_video() {
|
||||
// The submit→poll state machine reads interval/timeout from config; lock
|
||||
// the shipped defaults so a regression in JimengConfig is caught here.
|
||||
let cfg = JimengConfig::default();
|
||||
assert_eq!(cfg.image_poll_interval_secs, 2);
|
||||
assert_eq!(cfg.image_timeout_secs, 120);
|
||||
assert_eq!(cfg.video_poll_interval_secs, 8);
|
||||
assert_eq!(cfg.video_timeout_secs, 600);
|
||||
// Video is the longer-running capability: bigger interval and timeout.
|
||||
assert!(cfg.video_poll_interval_secs > cfg.image_poll_interval_secs);
|
||||
assert!(cfg.video_timeout_secs > cfg.image_timeout_secs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,4 +349,122 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, JimengError::Cancelled));
|
||||
}
|
||||
|
||||
/// A non-success code carrying a (bogus) `done` status in `data`. Used to
|
||||
/// prove `decide` consults the business code *before* `data.status`.
|
||||
fn err_code_with_done_status(code: i64) -> GetResultResponse {
|
||||
GetResultResponse {
|
||||
code,
|
||||
message: Some("m".into()),
|
||||
data: Some(GetResultData {
|
||||
status: Some("done".into()),
|
||||
image_urls: Some(vec!["https://x/should-be-ignored.png".into()]),
|
||||
binary_data_base64: None,
|
||||
video_url: Some("https://x/should-be-ignored.mp4".into()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decide_checks_code_before_status() {
|
||||
// Fatal code wins over a "done" status: must Fail, never Done.
|
||||
assert!(matches!(
|
||||
decide(JimengCapability::Image, &err_code_with_done_status(50411)),
|
||||
PollDecision::Fail(JimengError::Api { code: 50411, .. })
|
||||
));
|
||||
// Retryable code wins over a "done" status: must Backoff, never Done.
|
||||
assert_eq!(
|
||||
decide(JimengCapability::Video, &err_code_with_done_status(50429)),
|
||||
PollDecision::Backoff(50429)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decide_not_found_is_terminal() {
|
||||
assert!(matches!(
|
||||
decide(JimengCapability::Video, &resp(10000, Some("not_found"), None)),
|
||||
PollDecision::Fail(JimengError::NotFound)
|
||||
));
|
||||
}
|
||||
|
||||
/// Driver variant that records every sleep duration instead of sleeping, so
|
||||
/// backoff math is observable. `interval` is 1s to make the multipliers read
|
||||
/// as whole seconds.
|
||||
async fn run_sequence_capturing_sleeps(
|
||||
capability: JimengCapability,
|
||||
seq: Vec<GetResultResponse>,
|
||||
) -> (Result<JimengOutput, JimengError>, Vec<Duration>) {
|
||||
let idx = RefCell::new(0usize);
|
||||
let seq = RefCell::new(seq);
|
||||
let sleeps = RefCell::new(Vec::new());
|
||||
let out = poll_until_done(
|
||||
capability,
|
||||
Duration::from_secs(1),
|
||||
Duration::from_secs(3600),
|
||||
|| async {
|
||||
let mut i = idx.borrow_mut();
|
||||
let s = seq.borrow();
|
||||
let item = s.get(*i).cloned().unwrap_or_else(|| s.last().cloned().unwrap());
|
||||
*i += 1;
|
||||
Ok(item)
|
||||
},
|
||||
|| async { false },
|
||||
|d| {
|
||||
sleeps.borrow_mut().push(d);
|
||||
async {}
|
||||
},
|
||||
)
|
||||
.await;
|
||||
(out, sleeps.into_inner())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn driver_backoff_grows_exponentially_and_caps() {
|
||||
// Five retryable codes then done: delays double (1,2,4,8) and cap at 8.
|
||||
let seq = vec![
|
||||
resp(50429, None, None),
|
||||
resp(50429, None, None),
|
||||
resp(50429, None, None),
|
||||
resp(50429, None, None),
|
||||
resp(50429, None, None),
|
||||
resp(10000, Some("done"), Some("https://x/v.mp4")),
|
||||
];
|
||||
let (out, sleeps) = run_sequence_capturing_sleeps(JimengCapability::Video, seq).await;
|
||||
assert!(out.is_ok());
|
||||
let secs: Vec<u64> = sleeps.iter().map(Duration::as_secs).collect();
|
||||
assert_eq!(secs, vec![1, 2, 4, 8, 8]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn driver_backoff_resets_after_a_normal_wait() {
|
||||
// retryable, retryable, then a normal Wait (generating) must reset the
|
||||
// multiplier back to 1 before the next retryable backs off afresh.
|
||||
let seq = vec![
|
||||
resp(50429, None, None), // backoff -> sleep 1, mult 1->2
|
||||
resp(50429, None, None), // backoff -> sleep 2, mult 2->4
|
||||
resp(10000, Some("generating"), None), // Wait -> sleep 1, mult reset to 1
|
||||
resp(50429, None, None), // backoff -> sleep 1, mult 1->2
|
||||
resp(10000, Some("done"), Some("https://x/v.mp4")),
|
||||
];
|
||||
let (out, sleeps) = run_sequence_capturing_sleeps(JimengCapability::Video, seq).await;
|
||||
assert!(out.is_ok());
|
||||
let secs: Vec<u64> = sleeps.iter().map(Duration::as_secs).collect();
|
||||
assert_eq!(secs, vec![1, 2, 1, 1]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn driver_retryable_then_fatal_stops() {
|
||||
// A transient code may be followed by a fatal one; the fatal must end
|
||||
// the loop with its Api error rather than retrying forever.
|
||||
let seq = vec![
|
||||
resp(50429, None, None), // retryable QPS
|
||||
resp(50411, None, None), // fatal input moderation
|
||||
];
|
||||
let (out, _sleeps) =
|
||||
run_sequence_capturing_sleeps(JimengCapability::Image, seq).await;
|
||||
assert!(matches!(
|
||||
out,
|
||||
Err(JimengError::Api { code: 50411, retryable: false, .. })
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,4 +379,73 @@ mod tests {
|
||||
assert_eq!(parsed.code, 50429);
|
||||
assert!(parsed.data.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_audio_moderation_codes_are_capability_independent() {
|
||||
// 50516/50517 are documented as "video only" but `classify` does not
|
||||
// gate them by capability — they are retryable for both. Pin that
|
||||
// actual behavior so a future capability gate is a conscious change.
|
||||
for code in [50516, 50517] {
|
||||
assert_eq!(classify(code, JimengCapability::Video), CodeClass::Retryable);
|
||||
assert_eq!(classify(code, JimengCapability::Image), CodeClass::Retryable);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adjacent_copyright_codes_split_by_class() {
|
||||
// Input copyright pre-moderation (50518) is fatal; output copyright
|
||||
// post-moderation (50519) is retryable — adjacent codes, opposite class.
|
||||
assert_eq!(classify(50518, JimengCapability::Image), CodeClass::Fatal);
|
||||
assert_eq!(classify(50519, JimengCapability::Image), CodeClass::Retryable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moderation_service_exception_range_is_fatal() {
|
||||
// The whole 50520..=50522 upstream-exception range is fatal for both
|
||||
// capabilities (covers the inclusive upper bound 50522 explicitly).
|
||||
for code in [50520, 50521, 50522] {
|
||||
assert_eq!(classify(code, JimengCapability::Image), CodeClass::Fatal);
|
||||
assert_eq!(classify(code, JimengCapability::Video), CodeClass::Fatal);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_error_is_fatal_for_image_via_classify() {
|
||||
// Complements the `retryable()` helper test with a direct CodeClass
|
||||
// assertion: 50500/50501 are Fatal for image, Retryable for video.
|
||||
for code in [50500, 50501] {
|
||||
assert_eq!(classify(code, JimengCapability::Image), CodeClass::Fatal);
|
||||
assert_eq!(classify(code, JimengCapability::Video), CodeClass::Retryable);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_code_is_fatal() {
|
||||
// Defensive: out-of-domain codes never loop the poller.
|
||||
assert_eq!(classify(-1, JimengCapability::Image), CodeClass::Fatal);
|
||||
assert!(!retryable(-1, JimengCapability::Video));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn done_image_response_parses_base64() {
|
||||
// Default GetResult output (no return_url) carries base64, not urls.
|
||||
let raw = r#"{"code":10000,"message":"Success","data":{"status":"done","binary_data_base64":["aGVsbG8="]}}"#;
|
||||
let parsed: GetResultResponse = serde_json::from_str(raw).unwrap();
|
||||
assert_eq!(parsed.code, CODE_SUCCESS);
|
||||
assert_eq!(parsed.task_status(), TaskStatus::Done);
|
||||
let data = parsed.data.unwrap();
|
||||
assert!(data.image_urls.is_none());
|
||||
assert_eq!(data.binary_data_base64.unwrap(), vec!["aGVsbG8=".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_with_absent_data_field_parses_to_none() {
|
||||
// `data` omitted entirely (not just null) must still decode (serde
|
||||
// default) and report Unknown status without panicking.
|
||||
let submit: SubmitResponse = serde_json::from_str(r#"{"code":50429}"#).unwrap();
|
||||
assert!(submit.data.is_none());
|
||||
let result: GetResultResponse = serde_json::from_str(r#"{"code":50429}"#).unwrap();
|
||||
assert!(result.data.is_none());
|
||||
assert_eq!(result.task_status(), TaskStatus::Unknown);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user