gobby-wiki 0.7.0

Gobby wiki CLI shell
use super::*;

#[test]
fn production_ingest_applies_degradation_matrix() {
    let temp = tempfile::tempdir().expect("tempdir");
    let vision = FakeVisionClient;

    let no_ffmpeg = ingest_with_media(
        temp.path(),
        FakeVideoMediaExtractor {
            audio_bytes: Vec::new(),
            frames: Vec::new(),
            fail_audio: Some("ffmpeg is unavailable"),
            fail_frames: Some("ffmpeg is unavailable"),
        },
        TranscriptionEndpoint::Available(Box::new(FakeTranscriptionClient)),
        VisionEndpoint::Available(&vision),
        "no-ffmpeg.mp4",
    )
    .expect("no ffmpeg degrades");
    assert_asset_preserved(temp.path(), &no_ffmpeg, b"video bytes");
    let no_ffmpeg_doc = read_derived(temp.path(), &no_ffmpeg);
    assert!(no_ffmpeg_doc.contains("file_size_bytes: 11"));
    assert!(
        no_ffmpeg_doc.contains("audio:ffmpeg_unavailable")
            || no_ffmpeg_doc.contains("frames:ffmpeg_unavailable")
    );

    let frames_fail = ingest_with_media(
        temp.path(),
        FakeVideoMediaExtractor {
            audio_bytes: b"extracted audio".to_vec(),
            frames: Vec::new(),
            fail_audio: None,
            fail_frames: Some("frame extraction failed"),
        },
        TranscriptionEndpoint::Available(Box::new(FakeTranscriptionClient)),
        VisionEndpoint::Available(&vision),
        "frames-fail.mp4",
    )
    .expect("frame extraction degrades");
    let frames_fail_doc = read_derived(temp.path(), &frames_fail);
    assert!(frames_fail_doc.contains("media_degradation: \"frames:extraction_failed\""));
    assert!(frames_fail_doc.contains("Audio-first transcript from extracted video audio."));
    assert!(frames_fail_doc.contains("No frame samples recorded."));

    let vision_unavailable = ingest_with_media(
        temp.path(),
        FakeVideoMediaExtractor {
            audio_bytes: b"extracted audio".to_vec(),
            frames: vec![(0, b"frame-zero".to_vec())],
            fail_audio: None,
            fail_frames: None,
        },
        TranscriptionEndpoint::Available(Box::new(FakeTranscriptionClient)),
        VisionEndpoint::Unavailable(crate::vision::VisionDegradation {
            reason: gobby_core::degradation::ModalityDegradationReason::Disabled,
            fallback: "skip frames".to_string(),
        }),
        "vision-unavailable.mp4",
    )
    .expect("vision unavailable degrades");
    let vision_unavailable_doc = read_derived(temp.path(), &vision_unavailable);
    assert!(vision_unavailable_doc.contains("media_degradation: \"frames:vision_unavailable\""));
    assert!(vision_unavailable_doc.contains("No frame samples recorded."));

    let transcription_unavailable = ingest_with_media(
        temp.path(),
        FakeVideoMediaExtractor {
            audio_bytes: b"extracted audio".to_vec(),
            frames: vec![(0, b"frame-zero".to_vec())],
            fail_audio: None,
            fail_frames: None,
        },
        TranscriptionEndpoint::Unavailable(crate::transcribe::TranscriptionDegradation {
            reason: gobby_core::degradation::ModalityDegradationReason::Disabled,
            fallback: "skip audio".to_string(),
        }),
        VisionEndpoint::Available(&vision),
        "transcription-unavailable.mp4",
    )
    .expect("transcription unavailable degrades");
    let transcription_unavailable_doc = read_derived(temp.path(), &transcription_unavailable);
    assert!(transcription_unavailable_doc.contains("transcription_status: degraded"));
    assert!(transcription_unavailable_doc.contains("transcription_degradation: unavailable"));
    assert!(transcription_unavailable_doc.contains("disabled: skip audio"));

    let stt_fail = ingest_with_media(
        temp.path(),
        FakeVideoMediaExtractor {
            audio_bytes: b"extracted audio".to_vec(),
            frames: vec![(0, b"frame-zero".to_vec())],
            fail_audio: None,
            fail_frames: None,
        },
        TranscriptionEndpoint::Available(Box::new(FailingTranscriptionClient)),
        VisionEndpoint::Available(&vision),
        "stt-fail.mp4",
    )
    .expect("stt degrades");
    let stt_fail_doc = read_derived(temp.path(), &stt_fail);
    assert!(stt_fail_doc.contains("transcription_status: degraded"));
    assert!(stt_fail_doc.contains("transcription_degradation: transcription_error"));
    assert!(stt_fail_doc.contains("frame stt-fail.mp4.frame-0000.jpg has 10 bytes"));

    #[cfg(feature = "ai")]
    {
        let _chunks = crate::ai::chunk::install_test_chunks(vec![
            crate::ai::chunk::AudioChunk {
                start_ms: 0,
                end_ms: 10_000,
                file_name: "chunk-0.wav".to_string(),
                path: PathBuf::from("chunk-0.wav"),
                bytes: vec![b'w', b'a', b'v'],
            },
            crate::ai::chunk::AudioChunk {
                start_ms: 9_000,
                end_ms: 19_000,
                file_name: "chunk-1.wav".to_string(),
                path: PathBuf::from("chunk-1.wav"),
                bytes: vec![b'w', b'a', b'v'],
            },
        ]);
        let partial = ingest_with_media(
            temp.path(),
            FakeVideoMediaExtractor {
                audio_bytes: vec![b'a'; crate::ai::chunk::MAX_AUDIO_UPLOAD_BYTES + 1],
                frames: Vec::new(),
                fail_audio: None,
                fail_frames: None,
            },
            TranscriptionEndpoint::Available(Box::new(ScriptedChunkTranscriptionClient {
                outputs: RefCell::new(vec![
                    Ok(transcript_output(
                        "en",
                        false,
                        "transcribe",
                        &[(0, 1_000, "completed chunk")],
                    )),
                    Err(WikiError::Config {
                        detail: "provider failed mid chunk".to_string(),
                    }),
                ]),
            })),
            VisionEndpoint::Unavailable(crate::vision::VisionDegradation {
                reason: gobby_core::degradation::ModalityDegradationReason::Disabled,
                fallback: "skip frames".to_string(),
            }),
            "partial-chunk.mp4",
        )
        .expect("partial chunk aggregate degrades");
        let partial_doc = read_derived(temp.path(), &partial);
        assert!(partial_doc.contains("transcription_partial: \"true\""));
        assert!(partial_doc.contains("transcription_missing_ranges: 9000-19000"));
        assert!(partial_doc.contains("[00:00:00] completed chunk"));
    }
}

#[test]
fn video_media_degradation_classifies_only_unavailable_ffmpeg_errors() {
    let unavailable = video_media_degradation(
        "audio",
        "extraction_failed",
        WikiError::Config {
            detail: "ffmpeg executable not found on PATH".to_string(),
        },
    );
    assert_eq!(unavailable.reason, "ffmpeg_unavailable");

    let failed = video_media_degradation(
        "frames",
        "extraction_failed",
        WikiError::Config {
            detail: "ffmpeg frame extraction failed".to_string(),
        },
    );
    assert_eq!(failed.reason, "extraction_failed");
}

#[test]
fn frame_vision_failure_keeps_sample_without_description() {
    let frame = temp_file_with_bytes(".jpg", b"frame-zero").expect("frame temp");

    let described = describe_frame_images(
        "lecture.mp4",
        vec![(0, frame)],
        VisionEndpoint::Available(&FailingVisionClient),
    )
    .expect("vision failure should keep frame sample");

    assert_eq!(described.samples.len(), 1);
    assert_eq!(described.paths.len(), 1);
    assert!(described.descriptions.is_empty());
    assert!(described.paths[0].exists(), "frame should be persisted");
    cleanup_kept_temp_frames(&described.paths);
}