Files
Mangalord/backend/tests/api_page_analysis.rs
MechaCat02 25aba3ac58 feat(analysis): position-aware OCR seam dedup for sliced pages
Boundary text duplicated across slices (often with slightly different,
cropped transcriptions) survived the text-only merge. The OCR pass now
asks for a per-piece vertical position and the merge uses it:

- OcrResult gains optional `y` (fraction 0..1 of the slice); the Pass-A
  OCR schema/prompt request it (combined path leaves it None). Not
  persisted.
- merge_ocr takes each slice's working-image y-band and maps `y` to a
  page-global position. A seam pair is a duplicate when position-close
  (same kind) OR text-similar, so a mis-OCR'd boundary line is caught even
  when the text differs. The kept copy is the one more central in its slice
  (less cropped); falls back to keep-longer when positions are missing.
- Self-calibrates the model's y values (pixels vs. fraction) and ignores
  degenerate columns, so a bad localizer can't over-merge.

Tests: position pairs differing texts and keeps the less-cropped copy;
text-only fallback (dedup/keep-longer/non-adjacent/order) still holds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 23:17:55 +02:00

254 lines
7.9 KiB
Rust

//! Integration tests for `repo::page_analysis` — the transactional writer
//! that persists AI page-analysis output (OCR text, global auto-tags,
//! content warnings, the weighted search tsvector) and the `analyze_page`
//! job enqueue. Each `#[sqlx::test]` gets a fresh migrated DB.
use mangalord::domain::page_analysis::{
AnalysisStatus, OcrResult, SafetyFlag, VisionAnalysis,
};
use mangalord::repo::page_analysis;
use sqlx::PgPool;
use uuid::Uuid;
/// Seed a manga → chapter → page chain and return the page id.
async fn seed_page(pool: &PgPool) -> Uuid {
let manga_id: Uuid =
sqlx::query_scalar("INSERT INTO mangas (title) VALUES ('M') RETURNING id")
.fetch_one(pool)
.await
.unwrap();
let chapter_id: Uuid = sqlx::query_scalar(
"INSERT INTO chapters (manga_id, number) VALUES ($1, 1) RETURNING id",
)
.bind(manga_id)
.fetch_one(pool)
.await
.unwrap();
sqlx::query_scalar(
"INSERT INTO pages (chapter_id, page_number, storage_key, content_type) \
VALUES ($1, 1, 'mangas/x/p1.png', 'image/png') RETURNING id",
)
.bind(chapter_id)
.fetch_one(pool)
.await
.unwrap()
}
fn analysis(
ocr: &[(&str, &str)],
tags: &[&str],
scene: &str,
nsfw: bool,
warnings: &[&str],
) -> VisionAnalysis {
VisionAnalysis {
ocr_results: ocr
.iter()
.map(|(text, kind)| OcrResult {
text: (*text).to_string(),
kind: (*kind).to_string(),
y: None,
})
.collect(),
tagging_results: tags.iter().map(|t| t.to_string()).collect(),
scene_description: scene.to_string(),
safety_flag: SafetyFlag {
is_nsfw: nsfw,
content_type: warnings.iter().map(|w| w.to_string()).collect(),
},
}
}
async fn count(pool: &PgPool, table: &str, page_id: Uuid) -> i64 {
sqlx::query_scalar(&format!("SELECT count(*) FROM {table} WHERE page_id = $1"))
.bind(page_id)
.fetch_one(pool)
.await
.unwrap()
}
#[sqlx::test(migrations = "./migrations")]
async fn persist_writes_ocr_tags_warnings_and_search_doc(pool: PgPool) {
let page_id = seed_page(&pool).await;
let a = analysis(
&[("Hello", "speech"), ("BOOM", "sfx")],
&["action", "city"],
"A rainy street at night.",
true,
&["violence"],
);
page_analysis::persist_analysis(&pool, page_id, &a, "test-model")
.await
.unwrap();
assert_eq!(count(&pool, "page_ocr_text", page_id).await, 2);
assert_eq!(count(&pool, "page_auto_tags", page_id).await, 2);
assert_eq!(count(&pool, "page_content_warnings", page_id).await, 1);
let row = page_analysis::load(&pool, page_id).await.unwrap().unwrap();
assert_eq!(row.status, AnalysisStatus::Done);
assert!(row.is_nsfw);
assert_eq!(row.scene_description.as_deref(), Some("A rainy street at night."));
assert_eq!(row.model.as_deref(), Some("test-model"));
// search_doc must be populated (non-null, non-empty).
let has_doc: bool = sqlx::query_scalar(
"SELECT search_doc IS NOT NULL AND search_doc != ''::tsvector \
FROM page_analysis WHERE page_id = $1",
)
.bind(page_id)
.fetch_one(&pool)
.await
.unwrap();
assert!(has_doc, "search_doc should be a populated tsvector");
}
#[sqlx::test(migrations = "./migrations")]
async fn persist_is_idempotent_and_replaces_prior_result(pool: PgPool) {
let page_id = seed_page(&pool).await;
page_analysis::persist_analysis(
&pool,
page_id,
&analysis(&[("one", "speech")], &["alpha", "beta"], "first", true, &["gore"]),
"m1",
)
.await
.unwrap();
// Re-analysis with different output fully replaces the prior rows.
page_analysis::persist_analysis(
&pool,
page_id,
&analysis(&[("two", "narration")], &["beta", "gamma"], "second", false, &[]),
"m2",
)
.await
.unwrap();
// Exactly one analysis row, reflecting the second pass.
let total: i64 = sqlx::query_scalar("SELECT count(*) FROM page_analysis WHERE page_id = $1")
.bind(page_id)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(total, 1);
let row = page_analysis::load(&pool, page_id).await.unwrap().unwrap();
assert!(!row.is_nsfw);
assert_eq!(row.scene_description.as_deref(), Some("second"));
assert_eq!(row.model.as_deref(), Some("m2"));
// Auto-tags are {beta, gamma}, NOT the union with the first pass.
let tag_names: Vec<String> = sqlx::query_scalar(
"SELECT t.name FROM page_auto_tags pat JOIN tags t ON t.id = pat.tag_id \
WHERE pat.page_id = $1 ORDER BY t.name",
)
.bind(page_id)
.fetch_all(&pool)
.await
.unwrap();
assert_eq!(tag_names, vec!["beta", "gamma"]);
// Warnings cleared (second pass had none).
assert_eq!(count(&pool, "page_content_warnings", page_id).await, 0);
assert_eq!(count(&pool, "page_ocr_text", page_id).await, 1);
}
#[sqlx::test(migrations = "./migrations")]
async fn persist_leaves_user_page_tags_untouched(pool: PgPool) {
let page_id = seed_page(&pool).await;
let user_id: Uuid = sqlx::query_scalar(
"INSERT INTO users (username, password_hash) VALUES ('alice', 'x') RETURNING id",
)
.fetch_one(&pool)
.await
.unwrap();
// A personal page tag the user added.
mangalord::repo::page_tag::upsert(&pool, user_id, page_id, "favourite")
.await
.unwrap();
// The auto-tagger proposes its own (overlapping + new) tags.
page_analysis::persist_analysis(
&pool,
page_id,
&analysis(&[], &["favourite", "robot"], "", false, &[]),
"m",
)
.await
.unwrap();
// The user's personal tag survives untouched.
let mine = mangalord::repo::page_tag::list_for_page(&pool, user_id, page_id)
.await
.unwrap();
assert_eq!(mine, vec!["favourite"]);
// And the global auto-tags are separate rows.
assert_eq!(count(&pool, "page_auto_tags", page_id).await, 2);
}
#[sqlx::test(migrations = "./migrations")]
async fn enqueue_for_page_inserts_analyze_page_job(pool: PgPool) {
let page_id = seed_page(&pool).await;
page_analysis::enqueue_for_page(&pool, page_id, false)
.await
.unwrap();
let payload: serde_json::Value = sqlx::query_scalar(
"SELECT payload FROM crawler_jobs WHERE payload->>'kind' = 'analyze_page'",
)
.fetch_one(&pool)
.await
.unwrap();
assert_eq!(payload["page_id"].as_str().unwrap(), page_id.to_string());
assert_eq!(payload["force"].as_bool(), Some(false));
}
#[sqlx::test(migrations = "./migrations")]
async fn mark_failed_writes_a_failed_row(pool: PgPool) {
let page_id = seed_page(&pool).await;
page_analysis::mark_failed(&pool, page_id, "model timed out")
.await
.unwrap();
let row = page_analysis::load(&pool, page_id).await.unwrap().unwrap();
assert_eq!(row.status, AnalysisStatus::Failed);
assert_eq!(row.error.as_deref(), Some("model timed out"));
}
#[sqlx::test(migrations = "./migrations")]
async fn search_doc_ranks_speech_above_sfx(pool: PgPool) {
let page_id = seed_page(&pool).await;
// "alpha" is speech (weight A), "beta" is sfx (weight D).
page_analysis::persist_analysis(
&pool,
page_id,
&analysis(&[("alpha", "speech"), ("beta", "sfx")], &[], "", false, &[]),
"m",
)
.await
.unwrap();
let (ra, rb): (f32, f32) = sqlx::query_as(
"SELECT ts_rank(search_doc, plainto_tsquery('simple', 'alpha')), \
ts_rank(search_doc, plainto_tsquery('simple', 'beta')) \
FROM page_analysis WHERE page_id = $1",
)
.bind(page_id)
.fetch_one(&pool)
.await
.unwrap();
assert!(ra > 0.0 && rb > 0.0, "both terms should match the doc");
assert!(
ra > rb,
"speech (weight A) must rank higher than sfx (weight D): {ra} vs {rb}"
);
}