Merge branch 'test/jimeng-unit' into feat/embed-desktop

This commit is contained in:
boos
2026-06-23 18:41:30 +08:00
3 changed files with 284 additions and 0 deletions
+97
View File
@@ -384,4 +384,101 @@ mod tests {
Err(JimengError::Disabled) 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);
}
} }
+118
View File
@@ -349,4 +349,122 @@ mod tests {
.unwrap_err(); .unwrap_err();
assert!(matches!(err, JimengError::Cancelled)); 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, .. })
));
}
} }
+69
View File
@@ -379,4 +379,73 @@ mod tests {
assert_eq!(parsed.code, 50429); assert_eq!(parsed.code, 50429);
assert!(parsed.data.is_none()); 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);
}
} }