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>
254 lines
7.9 KiB
Rust
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}"
|
|
);
|
|
}
|