//! 픽스처 실기 통합 테스트 — `.fixtures\manifest.json`의 기대값을 **실제 파이프라인**으로 검증한다. //! //! 스캔 → 메타 → 썸네일 → 지문 → 중복 탐지를 순서대로 돌리고, manifest가 "잡혀야 한다"고 //! 말한 변형이 정말 같은 그룹에 들어갔는지 / "잡히면 안 된다"는 것이 안 묶였는지 확인한다. //! 이 순서 자체가 검증 대상이다 — 이미지 지문은 썸네일에서 뽑으므로 앞 단계가 빠지면 못 나온다. //! //! 픽스처가 없으면 조용히 통과한다 (`.\scripts\gen-fixtures.ps1`로 생성). //! manifest의 expect/stage 의미는 그 스크립트 헤더에 적혀 있다. use archive_db::Db; use archive_indexer::{dedupe, ffmpeg::FfTools, meta, pipeline, sigq, thumbq::ThumbQueue}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::atomic::AtomicBool; use std::sync::Arc; use std::time::Duration; fn repo_root() -> PathBuf { // crates/archive-indexer → src-tauri → 리포 루트 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../..").canonicalize().unwrap() } fn tools() -> Option { let bin = repo_root().join("src-tauri/binaries"); let triple = if cfg!(all(windows, target_arch = "x86_64")) { "x86_64-pc-windows-msvc" } else if cfg!(all(windows, target_arch = "aarch64")) { "aarch64-pc-windows-msvc" } else if cfg!(all(target_os = "macos", target_arch = "aarch64")) { "aarch64-apple-darwin" } else { "x86_64-apple-darwin" }; let ext = if cfg!(windows) { ".exe" } else { "" }; let (ffmpeg, ffprobe) = ( bin.join(format!("ffmpeg-{triple}{ext}")), bin.join(format!("ffprobe-{triple}{ext}")), ); (ffmpeg.is_file() && ffprobe.is_file()).then_some(FfTools { ffmpeg, ffprobe }) } /// manifest 한 항목. struct Entry { /// 파일명만 (경로 제외) name: String, /// 같은 원본에서 나온 것들의 공통 키 (음성은 None) group: Option, variant: String, expect: String, stage: i64, } fn load_manifest(kind: &str) -> Option<(PathBuf, Vec)> { let fixtures = repo_root().join(".fixtures"); let text = std::fs::read_to_string(fixtures.join("manifest.json")).ok()?; let json: serde_json::Value = serde_json::from_str(&text).expect("manifest.json 파싱 실패"); let arr = json.get(kind)?.as_array()?.clone(); let entries: Vec = arr .iter() .map(|e| Entry { name: e["file"].as_str().unwrap().rsplit('/').next().unwrap().to_string(), group: e["group"].as_str().map(|s| s.to_string()), variant: e["variant"].as_str().unwrap_or("").to_string(), expect: e["expect"].as_str().unwrap_or("").to_string(), stage: e["stage"].as_i64().unwrap_or(1), }) .collect(); let dir = fixtures.join(if kind == "images" { "images" } else { "videos" }); dir.is_dir().then_some((dir, entries)) } fn open_db(dir: &Path) -> Arc { Arc::new(Db::open(&dir.join("t.db")).unwrap()) } fn add_source(db: &Arc, root: &Path) -> i64 { let r = root.to_string_lossy().into_owned(); db.with_write(move |conn| { conn.execute("INSERT INTO sources(kind,name,root) VALUES('local','fx',?1)", [&r])?; Ok(conn.last_insert_rowid()) }) .unwrap() } /// file_id → 파일명 fn names(db: &Arc) -> HashMap { db.with_read(|conn| { let mut stmt = conn.prepare("SELECT id, name FROM files WHERE deleted_at IS NULL")?; let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?; rows.collect() }) .unwrap() } /// 탐지된 그룹 — (kind, 파일명 집합). kind 0=완전 동일 1=유사 이미지 2=유사 영상. #[derive(Debug)] struct Group { kind: i64, members: HashSet, } fn groups_by_name(db: &Arc, names: &HashMap) -> Vec { let rows: Vec<(i64, i64, i64)> = db .with_read(|conn| { let mut stmt = conn.prepare( "SELECT dm.group_id, dg.kind, dm.file_id FROM dupe_members dm JOIN dupe_groups dg ON dg.id = dm.group_id WHERE dg.status = 'open'", )?; let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?; rows.collect() }) .unwrap(); let mut by_gid: HashMap = HashMap::new(); for (gid, kind, fid) in rows { if let Some(n) = names.get(&fid) { by_gid .entry(gid) .or_insert_with(|| Group { kind, members: HashSet::new() }) .members .insert(n.clone()); } } by_gid.into_values().collect() } /// 탐지 결과를 사람이 읽을 형태로 찍는다 (`--nocapture`). 점수·거리가 실제로 채워지는지도 보인다. fn dump(db: &Arc, groups: &[Group]) { let scores: Vec<(i64, i64, Option)> = db .with_read(|conn| { let mut stmt = conn.prepare( "SELECT id, kind, score FROM dupe_groups WHERE status='open' ORDER BY kind, id", )?; let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?; rows.collect() }) .unwrap(); let kind_name = |k: i64| match k { 0 => "완전동일", 1 => "유사이미지", _ => "유사영상", }; println!("── 탐지 결과: 그룹 {}개", groups.len()); for (_, kind, score) in &scores { let _ = (kind, score); } let mut sorted: Vec<&Group> = groups.iter().collect(); sorted.sort_by_key(|g| (g.kind, g.members.len())); for g in sorted { let mut m: Vec<&str> = g.members.iter().map(|s| s.as_str()).collect(); m.sort_unstable(); println!(" [{}] {}", kind_name(g.kind), m.join(", ")); } let dists: Vec = db .with_read(|conn| { let mut stmt = conn.prepare( "SELECT distance FROM dupe_members WHERE distance IS NOT NULL AND is_keeper = 0", )?; let rows = stmt.query_map([], |r| r.get(0))?; rows.collect() }) .unwrap(); let sc: Vec = scores.iter().filter_map(|(_, _, s)| *s).collect(); println!( " 거리 채워진 멤버 {}개(최대 {:?}) · 점수 채워진 그룹 {}/{}개", dists.len(), dists.iter().max(), sc.len(), scores.len() ); } /// 썸네일이 다 만들어질 때까지 기다린다 (없으면 이미지 지문이 안 나온다). fn build_thumbs(db: &Arc, sid: i64, thumb_root: &Path, tools: Option) { let want: i64 = db .with_read(|c| { c.query_row( "SELECT count(*) FROM files WHERE kind IN (0,1) AND deleted_at IS NULL", [], |r| r.get(0), ) }) .unwrap(); // 완료를 이벤트로 기다린다 — 썸네일 워커는 저우선순위라 폴링+마감은 부하에서 흔들린다. // 마감(60초 무진전)은 정말 멈춘 경우를 위한 안전장치다. let (tx, rx) = std::sync::mpsc::channel::(); let done = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let d2 = done.clone(); let q = ThumbQueue::start(db.clone(), thumb_root.to_path_buf(), tools, 4, move |ids| { let n = d2.fetch_add(ids.len(), std::sync::atomic::Ordering::SeqCst) + ids.len(); let _ = tx.send(n); }); q.enqueue_pending(Some(sid)); while (done.load(std::sync::atomic::Ordering::SeqCst) as i64) < want { if rx.recv_timeout(Duration::from_secs(60)).is_err() { break; } } } /// manifest 기대값과 실제 그룹을 대조한다. 반환: 실패 메시지들. /// /// 판정 기준은 "**같은 원본 계열의 다른 파일과 한 그룹에 있는가**"다. 원본 파일 자신과 /// 직접 묶일 필요는 없다 — 완전 동일 그룹의 keeper만 유사 패스에 남기는 설계(같은 파일이 /// 두 그룹에 중복 표시되는 것을 막는다) 때문에, 바이트 동일 사본을 경유해 연결되는 것이 정상이다. /// `rotation`은 회전·반전 탐지를 켠 실행인지. 꺼져 있으면 rot90/flip은 단정하지 않는다 /// (그 두 변형만 8변형 질의를 필요로 한다 — 나머지 단계 3 항목은 기본 경로에서 잡힌다). fn check(entries: &[Entry], groups: &[Group], rotation: bool) -> Vec { let mut fails = Vec::new(); let mut member_of: HashMap<&str, Vec> = HashMap::new(); for (i, g) in groups.iter().enumerate() { for n in &g.members { member_of.entry(n.as_str()).or_default().push(i); } } // manifest 그룹 → 그 계열의 파일명들 let mut family: HashMap<&str, Vec<&str>> = HashMap::new(); for e in entries { if let Some(g) = e.group.as_deref() { family.entry(g).or_default().push(e.name.as_str()); } } // 이 실행에서 단정할 단계인가. 단계 1·3·4 모두 구현됐다 — // 단 rot90/flip은 회전 옵션을 켠 실행에서만 본다. let asserted = |e: &Entry| match e.stage { 1 | 4 => true, 3 => rotation || !(e.variant == "rot90" || e.variant == "flip"), _ => false, }; for e in entries { let mine = member_of.get(e.name.as_str()).cloned().unwrap_or_default(); match e.expect.as_str() { "exact" | "similar" | "segment" if asserted(e) => { let Some(g) = e.group.as_deref() else { continue }; let siblings: Vec<&str> = family .get(g) .map(|v| v.iter().copied().filter(|n| *n != e.name.as_str()).collect()) .unwrap_or_default(); let together = mine .iter() .any(|i| siblings.iter().any(|s| groups[*i].members.contains(*s))); if !together { fails.push(format!( "{} ({}): {g} 계열과 같은 그룹에 없다 (소속 그룹 {}개)", e.name, e.variant, mine.len() )); } // 바이트까지 같은 사본은 '완전 동일' 그룹(kind 0)이어야 한다 if e.expect == "exact" && !mine.iter().any(|i| groups[*i].kind == 0) { fails.push(format!("{}: 바이트 동일인데 완전 동일 그룹이 아니다", e.name)); } } // 묶이면 안 되는 것 "none" if e.stage == 1 => { if !mine.is_empty() { let others: Vec<&str> = groups[mine[0]] .members .iter() .filter(|n| n.as_str() != e.name) .map(|s| s.as_str()) .collect(); fails.push(format!("{}: 묶이면 안 되는데 묶였다 — {:?}", e.name, others)); } } _ => {} // 단계 3·4 대상은 지금 무관 (잡혀도 실패 아님) } } fails } #[test] fn fixture_images_group_as_manifest_expects() { let Some((dir, entries)) = load_manifest("images") else { eprintln!("픽스처 없음 — 건너뜀 (scripts\\gen-fixtures.ps1 실행)"); return; }; let tmp = tempfile::tempdir().unwrap(); let thumbs = tempfile::tempdir().unwrap(); let db = open_db(tmp.path()); let sid = add_source(&db, &dir); let cancel = AtomicBool::new(false); let pause = AtomicBool::new(false); pipeline::scan_source(&db, sid, &dir, &cancel, |_| {}).unwrap(); meta::extract_image_meta(&db, sid, &cancel).unwrap(); build_thumbs(&db, sid, thumbs.path(), None); let n = sigq::run_image_sigs(&db, thumbs.path(), &cancel, &pause).unwrap(); assert!(n > 0, "이미지 지문이 하나도 생성되지 않았다"); let stats = dedupe::run(&db, dedupe::Scope::Source(sid), 5, false, false, &cancel, |_| {}).unwrap(); assert_eq!(stats.skipped_no_sig, 0, "지문 없이 빠진 파일이 있다"); let names = names(&db); let groups = groups_by_name(&db, &names); dump(&db, &groups); // cargo test -- --nocapture 로 실제 결과를 본다 let fails = check(&entries, &groups, false); assert!( fails.is_empty(), "manifest 기대와 다름 ({}건):\n {}\n그룹 {}개: {:?}", fails.len(), fails.join("\n "), groups.len(), groups ); // 정지 이미지는 시간축이 없다 — 비교 뷰가 그대로 겹칠 수 있게 오프셋 0이어야 한다 let bad: i64 = db .with_read(|c| { c.query_row( "SELECT count(*) FROM dupe_members WHERE offset_ms IS NULL OR offset_ms <> 0", [], |r| r.get(0), ) }) .unwrap(); assert_eq!(bad, 0, "이미지 멤버의 offset_ms가 0이 아니다"); } /// 회전·반전본은 8변형 질의를 켰을 때만 잡힌다 — 켜면 잡히고, 무관한 그림은 여전히 안 묶인다. #[test] fn fixture_rotated_copies_found_when_enabled() { let Some((dir, entries)) = load_manifest("images") else { eprintln!("픽스처 없음 — 건너뜀"); return; }; let tmp = tempfile::tempdir().unwrap(); let thumbs = tempfile::tempdir().unwrap(); let db = open_db(tmp.path()); let sid = add_source(&db, &dir); let cancel = AtomicBool::new(false); let pause = AtomicBool::new(false); pipeline::scan_source(&db, sid, &dir, &cancel, |_| {}).unwrap(); meta::extract_image_meta(&db, sid, &cancel).unwrap(); build_thumbs(&db, sid, thumbs.path(), None); sigq::run_image_sigs(&db, thumbs.path(), &cancel, &pause).unwrap(); // 먼저 꺼진 상태 — 회전본이 자기 계열과 묶이지 않아야 한다(그게 옵션의 존재 이유다) dedupe::run(&db, dedupe::Scope::Source(sid), 5, false, false, &cancel, |_| {}).unwrap(); let names = names(&db); let off_groups = groups_by_name(&db, &names); let rot_grouped_off = off_groups.iter().any(|g| { g.members.iter().any(|m| m.contains("_rot90")) && g.members.len() >= 2 }); assert!(!rot_grouped_off, "옵션이 꺼졌는데 회전본이 묶였다"); // 켜고 다시 — 이번엔 잡혀야 한다 dedupe::run(&db, dedupe::Scope::Source(sid), 5, false, true, &cancel, |_| {}).unwrap(); let groups = groups_by_name(&db, &names); dump(&db, &groups); let fails = check(&entries, &groups, true); assert!( fails.is_empty(), "회전 옵션을 켠 결과가 manifest 기대와 다름 ({}건):\n {}", fails.len(), fails.join("\n ") ); } /// 영상 경로 — 프레임 지문까지 만들어야 하므로 느리다(영상 8편 × 30초). #[test] fn fixture_videos_group_as_manifest_expects() { let Some(t) = tools() else { eprintln!("사이드카 없음 — 건너뜀"); return; }; let Some((dir, entries)) = load_manifest("videos") else { eprintln!("픽스처 없음 — 건너뜀 (scripts\\gen-fixtures.ps1 실행)"); return; }; let tmp = tempfile::tempdir().unwrap(); let thumbs = tempfile::tempdir().unwrap(); let db = open_db(tmp.path()); let sid = add_source(&db, &dir); let cancel = AtomicBool::new(false); let pause = AtomicBool::new(false); pipeline::scan_source(&db, sid, &dir, &cancel, |_| {}).unwrap(); // 영상 길이가 있어야 커버리지 판정을 할 수 있다 meta::extract_video_meta(&db, sid, &cancel, &t).unwrap(); build_thumbs(&db, sid, thumbs.path(), Some(t.clone())); let n = sigq::run_video_sigs(&db, &t, None, &cancel, &pause).unwrap(); assert!(n > 0, "영상 지문이 하나도 생성되지 않았다"); let stats = dedupe::run(&db, dedupe::Scope::Source(sid), 5, true, false, &cancel, |_| {}).unwrap(); assert_eq!(stats.skipped_no_sig, 0, "지문 없이 빠진 영상이 있다"); let names = names(&db); let groups = groups_by_name(&db, &names); dump(&db, &groups); // cargo test -- --nocapture 로 실제 결과를 본다 let fails = check(&entries, &groups, false); assert!( fails.is_empty(), "manifest 기대와 다름 ({}건):\n {}\n그룹 {}개: {:?}", fails.len(), fails.join("\n "), groups.len(), groups ); // ── 비교 재생용 정렬값 ──────────────────────────────────────────── // 비교 뷰는 `offset_ms`(keeper 시각 − 멤버 시각)로 두 영상을 맞춘다. 이 값이 틀리면 // 나란히 놓고도 계속 다른 장면을 보게 되므로, 아는 답과 대조한다. // manifest의 expectOffsetMs는 vidA 기준이고 keeper는 vidA가 아닐 수 있으므로 // (rank_keeper: 해상도 → 파일크기순 → vidA_reenc가 가장 크다) **간격**만 본다. let rows: Vec<(String, Option, Option, Option)> = db .with_read(|conn| { let mut stmt = conn.prepare( "SELECT f.name, dm.offset_ms, dm.seg_start_ms, dm.seg_end_ms FROM dupe_members dm JOIN files f ON f.id = dm.file_id JOIN dupe_groups dg ON dg.id = dm.group_id WHERE dg.kind = 2", )?; let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)))?; rows.collect() }) .unwrap(); assert!(!rows.is_empty(), "영상 그룹 멤버가 없다"); let by_name: HashMap<&str, (Option, Option, Option)> = rows.iter().map(|(n, o, s, e)| (n.as_str(), (*o, *s, *e))).collect(); println!("── 비교 재생 정렬값 (keeper 기준)"); for (n, o, s, e) in &rows { println!(" {n:<22} offset={o:?} seg={s:?}..{e:?}"); } let keeper_name: &str = rows .iter() .map(|(n, _, _, _)| n.as_str()) .find(|n| by_name[*n].0 == Some(0)) .expect("offset 0인 keeper가 있어야 한다"); let off = |n: &str| by_name.get(n).and_then(|(o, _, _)| *o); // 같은 원본을 그대로 재인코딩/워터마크/fps변환한 것은 keeper와 시간축이 같다 for n in ["vidA_reenc.mp4", "vidA_wm.mp4", "vidA_fps24.mp4", "vidA.mp4"] { if n == keeper_name { continue; } if let Some(o) = off(n) { assert!(o.abs() <= 1000, "{n}: 시간축이 같아야 한다 (offset {o}ms)"); } } // 인트로 5초가 붙은 사본과 10초 지점을 잘라낸 사본은 그만큼 어긋나 있어야 한다. // keeper가 vidA 계열 원본(오프셋 0)이라는 전제 아래 간격을 확인한다. if let (Some(intro), Some(clip)) = (off("vidA_intro.mp4"), off("vidA_clip.mp4")) { assert!( (intro.abs() - 5000).abs() <= 1000, "인트로 삽입본은 5초 어긋나야 한다 (offset {intro}ms)" ); assert!( (clip.abs() - 10000).abs() <= 1000, "10초 지점 구간 추출본은 10초 어긋나야 한다 (offset {clip}ms)" ); assert_eq!(intro.signum(), -clip.signum(), "두 사본은 반대 방향으로 어긋난다"); } // 일치 구간은 keeper 시간축 안에 있어야 한다 (비교 뷰가 '구간으로 이동'에 쓴다) for (n, _, s, e) in &rows { if let (Some(s), Some(e)) = (s, e) { assert!(s < e, "{n}: 구간이 뒤집혔다 ({s}..{e})"); assert!(*s >= 0, "{n}: 구간 시작이 음수 ({s})"); } } }