Phase C4 executor 의 마지막 `fix(test): clippy + fmt fixes` commit 이 test file 부분만 fmt 적용. workspace 전체 fmt 누락 발견 → cargo fmt --all 적용. 모든 import alphabetical reorder + line wrapping 정합. 추가 untracked artifact 동시 commit: - docs/superpowers/specs/2026-05-28-v0.20-ingest-log-spec.md (491 line, ACCEPT) - docs/superpowers/plans/2026-05-28-v0.20-ingest-log-plan.md (616 line, ACCEPT) workspace test: 1370 passed / 0 failed / 50 ignored, ingest_log_smoke green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
123 lines
4.4 KiB
Rust
123 lines
4.4 KiB
Rust
//! Integration coverage for `ingest_with_config_cancellable`
|
|
//! (p9-fb-04). Asserts the §10 invariants:
|
|
//!
|
|
//! - Cancel set BEFORE the loop starts → no asset is processed.
|
|
//! Terminal event is `Aborted` with all-zero counts.
|
|
//! - Cancel set MID-LOOP → at least one asset committed; remaining
|
|
//! assets skipped; terminal event is `Aborted` with partial counts;
|
|
//! re-running on the same workspace finishes the job (idempotent).
|
|
|
|
mod common;
|
|
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::mpsc;
|
|
|
|
use common::TestEnv;
|
|
use kebab_app::IngestEvent;
|
|
|
|
fn run_with(
|
|
env: &TestEnv,
|
|
cancel: Arc<AtomicBool>,
|
|
progress: Option<mpsc::Sender<IngestEvent>>,
|
|
) -> kebab_core::IngestReport {
|
|
kebab_app::ingest_with_config_cancellable(
|
|
env.config.clone(),
|
|
env.scope(),
|
|
true,
|
|
progress,
|
|
Some(cancel),
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn cancel_before_loop_emits_aborted_with_zero_counts() {
|
|
let env = TestEnv::lexical_only();
|
|
let (tx, rx) = mpsc::channel::<IngestEvent>();
|
|
let cancel = Arc::new(AtomicBool::new(true)); // pre-set
|
|
let report = run_with(&env, cancel, Some(tx));
|
|
|
|
// Report itself surfaces partial counts — no assets processed
|
|
// because the very first iteration check tripped.
|
|
assert_eq!(report.scanned, 3, "scanned reflects discovery, not work");
|
|
assert_eq!(report.new, 0, "no asset committed: {report:?}");
|
|
|
|
// Drain the channel; the terminal event must be Aborted.
|
|
let events: Vec<_> = rx.into_iter().collect();
|
|
let last = events.last().expect("at least one event");
|
|
assert!(
|
|
matches!(last, IngestEvent::Aborted { .. }),
|
|
"expected Aborted, got {last:?}"
|
|
);
|
|
if let IngestEvent::Aborted { counts } = last {
|
|
assert_eq!(counts.new, 0);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn cancel_mid_loop_after_first_asset_keeps_idempotent_resume() {
|
|
// Strategy: subscribe to progress, flip cancel as soon as the
|
|
// first AssetFinished arrives. The ingest loop will see cancel=true
|
|
// on the *next* iteration and break — exactly one asset committed.
|
|
let env = TestEnv::lexical_only();
|
|
let (tx, rx) = mpsc::channel::<IngestEvent>();
|
|
let cancel = Arc::new(AtomicBool::new(false));
|
|
let cancel_for_listener = cancel.clone();
|
|
|
|
// Background listener flips cancel after the first AssetFinished.
|
|
let listener = std::thread::spawn(move || {
|
|
for event in rx {
|
|
if let IngestEvent::AssetFinished { .. } = event {
|
|
cancel_for_listener.store(true, Ordering::Relaxed);
|
|
break;
|
|
}
|
|
}
|
|
// Drain the rest so the channel doesn't fill while ingest
|
|
// continues emitting (until the next iteration check).
|
|
});
|
|
|
|
let report = run_with(&env, cancel, Some(tx));
|
|
listener.join().unwrap();
|
|
|
|
// cancel-mid is timing-dependent: the listener flips cancel
|
|
// after the first AssetFinished, but the loop may have started
|
|
// 1 more asset by the time the next iteration check runs.
|
|
// 0 (race won by listener), 1 (first only), or 2 (one extra
|
|
// slipped in) are all valid outcomes; report.new == 3 means
|
|
// cancel never propagated and is the only failure mode.
|
|
assert!(report.new < 3, "loop should have broken: {report:?}");
|
|
|
|
// Idempotent re-ingest finishes the job.
|
|
let r2 = kebab_app::ingest_with_config(env.config.clone(), env.scope(), true).unwrap();
|
|
assert_eq!(r2.scanned, 3, "re-scan: {r2:?}");
|
|
// Total committed across both runs covers all 3 docs (some New
|
|
// first run, rest New on second; or first run was 0 → all New on
|
|
// second).
|
|
let total_new = report.new + r2.new;
|
|
let total_updated = report.updated + r2.updated;
|
|
assert!(
|
|
total_new + total_updated >= 3,
|
|
"across both runs: report={report:?}, r2={r2:?}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cancel_none_is_uncancellable_default() {
|
|
// ingest_with_config_progress (no cancel) runs to completion.
|
|
let env = TestEnv::lexical_only();
|
|
let (tx, rx) = mpsc::channel::<IngestEvent>();
|
|
let report =
|
|
kebab_app::ingest_with_config_progress(env.config.clone(), env.scope(), true, Some(tx))
|
|
.unwrap();
|
|
assert_eq!(report.scanned, 3);
|
|
assert_eq!(report.new, 3);
|
|
|
|
let events: Vec<_> = rx.into_iter().collect();
|
|
let last = events.last().expect("events");
|
|
assert!(
|
|
matches!(last, IngestEvent::Completed { .. }),
|
|
"expected Completed (no cancel), got {last:?}"
|
|
);
|
|
}
|