v0.3.6-pre.002

This commit is contained in:
2026-09-01 10:45:39 +02:00
parent ecfc30a94b
commit a25d8dd55a
17 changed files with 1087 additions and 18 deletions

View File

@@ -0,0 +1,22 @@
// file: crates/ksp-job-api/unit_tests/cancellation.rs
// version: 1
#[test]
fn pre_002_cancellation_is_shared_and_idempotent() {
let token = crate::JobCancellationToken::new();
let clone = token.clone();
assert!(!token.is_cancellation_requested());
assert!(clone.cancel());
assert!(token.is_cancellation_requested());
assert!(!token.cancel());
assert!(!clone.cancel());
return;
}
#[test]
fn pre_002_default_token_starts_without_cancellation() {
let token = crate::JobCancellationToken::default();
assert!(!token.is_cancellation_requested());
assert_eq!(std::format!("{token:?}"), "JobCancellationToken { cancellation_requested: false }");
return;
}

View File

@@ -0,0 +1,57 @@
// file: crates/ksp-job-api/unit_tests/identity.rs
// version: 1
#[test]
fn pre_002_identity_accepts_safe_codes_at_exact_bounds() {
let job_id = crate::JobId::new("a".repeat(crate::MAX_JOB_ID_BYTES));
let kind = crate::JobKindCode::new("backfill.raw:solana-1");
assert!(job_id.is_ok());
assert!(kind.is_ok());
let job_id = match job_id {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
let kind = match kind {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return,
};
assert_eq!(job_id.as_str().len(), crate::MAX_JOB_ID_BYTES);
assert_eq!(kind.as_str(), "backfill.raw:solana-1");
return;
}
#[test]
fn pre_002_identity_rejects_empty_oversized_and_unsafe_values() {
for value in [std::string::String::new(), "a".repeat(crate::MAX_JOB_ID_BYTES + 1), "unsafe/value".to_string(), "unicode-é".to_string()] {
let rejected = crate::JobId::new(value);
assert!(rejected.is_err());
let error = match rejected {
std::result::Result::Err(value) => value,
std::result::Result::Ok(_) => continue,
};
assert_eq!(error.code(), crate::ERROR_CODE_JOB_ID_INVALID);
assert_eq!(error.context().len(), 1);
assert_eq!(error.context()[0].key(), "field");
assert_eq!(error.context()[0].value(), "job_id");
}
for value in [std::string::String::new(), "b".repeat(crate::MAX_JOB_KIND_CODE_BYTES + 1), "backfill raw".to_string()] {
let rejected = crate::JobKindCode::new(value);
assert!(rejected.is_err());
let error = match rejected {
std::result::Result::Err(value) => value,
std::result::Result::Ok(_) => continue,
};
assert_eq!(error.code(), crate::ERROR_CODE_JOB_KIND_INVALID);
assert_eq!(error.context()[0].value(), "job_kind");
}
return;
}
#[test]
fn pre_002_private_validator_uses_exact_safe_code_alphabet() {
assert!(super::valid_job_code("AZaz09_.:-", 10));
for rejected in ["slash/value", "space value", "line\nbreak", "é"] {
assert!(!super::valid_job_code(rejected, crate::MAX_JOB_ID_BYTES));
}
return;
}

View File

@@ -0,0 +1,153 @@
// file: crates/ksp-job-api/unit_tests/lifecycle.rs
// version: 1
fn lifecycle() -> std::option::Option<crate::JobLifecycle> {
let id = match crate::JobId::new("job-001") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::option::Option::None,
};
let kind = match crate::JobKindCode::new("backfill_raw") {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => return std::option::Option::None,
};
return std::option::Option::Some(crate::JobLifecycle::new(id, kind));
}
#[test]
fn pre_002_lifecycle_accepts_every_planned_terminal_path() {
for completion in [crate::JobCompletion::Complete, crate::JobCompletion::Partial] {
let lifecycle = lifecycle();
assert!(lifecycle.is_some());
let mut lifecycle = match lifecycle {
std::option::Option::Some(value) => value,
std::option::Option::None => continue,
};
assert!(lifecycle.start().is_ok());
assert!(lifecycle.complete(completion).is_ok());
assert_eq!(lifecycle.state(), crate::JobState::Completed(completion));
assert!(lifecycle.state().is_terminal());
assert_eq!(lifecycle.state().completion(), std::option::Option::Some(completion));
}
let lifecycle = lifecycle();
assert!(lifecycle.is_some());
let mut lifecycle = match lifecycle {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
assert!(lifecycle.start().is_ok());
assert!(lifecycle.fail().is_ok());
assert_eq!(lifecycle.state(), crate::JobState::Failed);
let lifecycle = lifecycle();
assert!(lifecycle.is_some());
let mut lifecycle = match lifecycle {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
assert!(lifecycle.mark_cancelled().is_ok());
assert_eq!(lifecycle.state(), crate::JobState::Cancelled);
return;
}
#[test]
fn pre_002_cancelling_allows_cancel_complete_or_fail() {
for terminal in [crate::JobState::Cancelled, crate::JobState::Completed(crate::JobCompletion::Complete), crate::JobState::Failed] {
let lifecycle = lifecycle();
assert!(lifecycle.is_some());
let mut lifecycle = match lifecycle {
std::option::Option::Some(value) => value,
std::option::Option::None => continue,
};
assert!(lifecycle.start().is_ok());
assert!(lifecycle.mark_cancelling().is_ok());
let result = match terminal {
crate::JobState::Cancelled => lifecycle.mark_cancelled(),
crate::JobState::Completed(completion) => lifecycle.complete(completion),
crate::JobState::Failed => lifecycle.fail(),
_ => return,
};
assert!(result.is_ok());
assert_eq!(lifecycle.state(), terminal);
}
return;
}
#[test]
fn pre_002_invalid_transition_preserves_source_state_and_reports_safe_context() {
let lifecycle = lifecycle();
assert!(lifecycle.is_some());
let mut lifecycle = match lifecycle {
std::option::Option::Some(value) => value,
std::option::Option::None => return,
};
let rejected = lifecycle.complete(crate::JobCompletion::Complete);
assert!(rejected.is_err());
assert_eq!(lifecycle.state(), crate::JobState::Created);
let error = match rejected {
std::result::Result::Err(value) => value,
std::result::Result::Ok(_) => return,
};
assert_eq!(error.code(), crate::ERROR_CODE_JOB_TRANSITION_INVALID);
assert_eq!(error.context().len(), 2);
assert_eq!(error.context()[0].key(), "source_state");
assert_eq!(error.context()[0].value(), "created");
assert_eq!(error.context()[1].key(), "target_state");
assert_eq!(error.context()[1].value(), "completed");
return;
}
#[test]
fn pre_002_every_terminal_state_rejects_later_mutation() {
let terminals = [crate::JobState::Completed(crate::JobCompletion::Complete), crate::JobState::Cancelled, crate::JobState::Failed];
for terminal in terminals {
let lifecycle = lifecycle();
assert!(lifecycle.is_some());
let mut lifecycle = match lifecycle {
std::option::Option::Some(value) => value,
std::option::Option::None => continue,
};
if terminal == crate::JobState::Cancelled {
assert!(lifecycle.mark_cancelled().is_ok());
} else {
assert!(lifecycle.start().is_ok());
let terminal_result = match terminal {
crate::JobState::Completed(completion) => lifecycle.complete(completion),
crate::JobState::Failed => lifecycle.fail(),
_ => return,
};
assert!(terminal_result.is_ok());
}
let rejected = lifecycle.start();
assert!(rejected.is_err());
assert_eq!(lifecycle.state(), terminal);
}
return;
}
#[test]
fn pre_002_transition_matrix_is_exact() {
let states = [
crate::JobState::Created,
crate::JobState::Running,
crate::JobState::Cancelling,
crate::JobState::Completed(crate::JobCompletion::Complete),
crate::JobState::Cancelled,
crate::JobState::Failed,
];
for source in states {
for target in states {
let expected = matches!(
(source, target),
(crate::JobState::Created, crate::JobState::Running)
| (crate::JobState::Created, crate::JobState::Cancelled)
| (crate::JobState::Running, crate::JobState::Cancelling)
| (crate::JobState::Running, crate::JobState::Completed(_))
| (crate::JobState::Running, crate::JobState::Failed)
| (crate::JobState::Cancelling, crate::JobState::Completed(_))
| (crate::JobState::Cancelling, crate::JobState::Cancelled)
| (crate::JobState::Cancelling, crate::JobState::Failed)
);
assert_eq!(super::allowed_transition(source, target), expected, "unexpected transition matrix result for {source:?} -> {target:?}");
}
}
return;
}