Files
Mangalord/backend/tests/api_page_search.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

267 lines
8.1 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Integration tests for the page content-search endpoint
//! `GET /v1/me/page-search`: multi-tag AND across (user auto) tags,
//! weighted OCR/scene text ranking, and content-warning include/exclude.
mod common;
use axum::http::StatusCode;
use axum::Router;
use serde_json::json;
use sqlx::PgPool;
use tower::ServiceExt;
use uuid::Uuid;
use mangalord::domain::page_analysis::{OcrResult, SafetyFlag, VisionAnalysis};
use mangalord::repo;
/// Seed a manga + chapter and return the chapter id for adding pages.
async fn seed_chapter(pool: &PgPool, title: &str) -> Uuid {
let manga_id: Uuid =
sqlx::query_scalar("INSERT INTO mangas (title) VALUES ($1) RETURNING id")
.bind(title)
.fetch_one(pool)
.await
.unwrap();
sqlx::query_scalar("INSERT INTO chapters (manga_id, number) VALUES ($1, 1) RETURNING id")
.bind(manga_id)
.fetch_one(pool)
.await
.unwrap()
}
async fn add_page(pool: &PgPool, chapter_id: Uuid, n: i32) -> Uuid {
sqlx::query_scalar(
"INSERT INTO pages (chapter_id, page_number, storage_key, content_type) \
VALUES ($1, $2, $3, 'image/png') RETURNING id",
)
.bind(chapter_id)
.bind(n)
.bind(format!("k/{n}.png"))
.fetch_one(pool)
.await
.unwrap()
}
fn analysis(
ocr: &[(&str, &str)],
auto_tags: &[&str],
nsfw: bool,
warnings: &[&str],
) -> VisionAnalysis {
VisionAnalysis {
ocr_results: ocr
.iter()
.map(|(t, k)| OcrResult {
text: (*t).into(),
kind: (*k).into(),
y: None,
})
.collect(),
tagging_results: auto_tags.iter().map(|t| t.to_string()).collect(),
scene_description: String::new(),
safety_flag: SafetyFlag {
is_nsfw: nsfw,
content_type: warnings.iter().map(|w| w.to_string()).collect(),
},
}
}
async fn user_id_for(pool: &PgPool, username: &str) -> Uuid {
repo::user::find_by_username(pool, username)
.await
.unwrap()
.unwrap()
.id
}
async fn search(app: &Router, cookie: &str, query: &str) -> serde_json::Value {
let resp = app
.clone()
.oneshot(common::get_with_cookie(
&format!("/api/v1/me/page-search?{query}"),
cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "search {query} failed");
common::body_json(resp).await
}
fn page_ids(body: &serde_json::Value) -> Vec<String> {
body["items"]
.as_array()
.unwrap()
.iter()
.map(|i| i["page_id"].as_str().unwrap().to_string())
.collect()
}
#[sqlx::test(migrations = "./migrations")]
async fn multi_tag_and_across_user_and_auto_tags(pool: PgPool) {
let h = common::harness(pool.clone());
let (username, cookie) = common::register_user(&h.app).await;
let uid = user_id_for(&pool, &username).await;
let ch = seed_chapter(&pool, "M").await;
let p1 = add_page(&pool, ch, 1).await;
let p2 = add_page(&pool, ch, 2).await;
// p1: auto-tag "b"; user adds tag "a" → has both a (user) and b (auto).
repo::page_analysis::persist_analysis(&pool, p1, &analysis(&[], &["b"], false, &[]), "m")
.await
.unwrap();
repo::page_tag::upsert(&pool, uid, p1, "a").await.unwrap();
// p2: only auto-tag "a".
repo::page_analysis::persist_analysis(&pool, p2, &analysis(&[], &["a"], false, &[]), "m")
.await
.unwrap();
// tags=a,b (AND) → only p1 satisfies both.
let body = search(&h.app, &cookie, "tags=a,b").await;
assert_eq!(page_ids(&body), vec![p1.to_string()]);
// tags=a alone → both (p1 via user tag, p2 via auto tag).
let body = search(&h.app, &cookie, "tags=a").await;
let mut ids = page_ids(&body);
ids.sort();
let mut want = vec![p1.to_string(), p2.to_string()];
want.sort();
assert_eq!(ids, want);
}
#[sqlx::test(migrations = "./migrations")]
async fn text_search_ranks_speech_above_sfx(pool: PgPool) {
let h = common::harness(pool.clone());
let (_u, cookie) = common::register_user(&h.app).await;
let ch = seed_chapter(&pool, "M").await;
let speech_page = add_page(&pool, ch, 1).await;
let sfx_page = add_page(&pool, ch, 2).await;
repo::page_analysis::persist_analysis(
&pool,
speech_page,
&analysis(&[("alpha", "speech")], &[], false, &[]),
"m",
)
.await
.unwrap();
repo::page_analysis::persist_analysis(
&pool,
sfx_page,
&analysis(&[("alpha", "sfx")], &[], false, &[]),
"m",
)
.await
.unwrap();
let body = search(&h.app, &cookie, "text=alpha").await;
let ids = page_ids(&body);
assert_eq!(ids.len(), 2, "both pages match the term");
assert_eq!(
ids[0],
speech_page.to_string(),
"speech (weight A) must rank above sfx (weight D)"
);
}
#[sqlx::test(migrations = "./migrations")]
async fn content_warning_include_and_exclude(pool: PgPool) {
let h = common::harness(pool.clone());
let (_u, cookie) = common::register_user(&h.app).await;
let ch = seed_chapter(&pool, "M").await;
let sexual = add_page(&pool, ch, 1).await;
let gore = add_page(&pool, ch, 2).await;
repo::page_analysis::persist_analysis(
&pool,
sexual,
&analysis(&[("x", "speech")], &["nsfw-tag"], true, &["sexual"]),
"m",
)
.await
.unwrap();
repo::page_analysis::persist_analysis(
&pool,
gore,
&analysis(&[("x", "speech")], &["nsfw-tag"], true, &["gore"]),
"m",
)
.await
.unwrap();
// include sexual → only the sexual page.
let body = search(&h.app, &cookie, "tags=nsfw-tag&cw_include=sexual").await;
assert_eq!(page_ids(&body), vec![sexual.to_string()]);
// and it carries the warning + nsfw flag in the row.
assert_eq!(body["items"][0]["is_nsfw"], true);
assert_eq!(body["items"][0]["content_warnings"][0], "sexual");
// exclude sexual → only the gore page.
let body = search(&h.app, &cookie, "tags=nsfw-tag&cw_exclude=sexual").await;
assert_eq!(page_ids(&body), vec![gore.to_string()]);
}
#[sqlx::test(migrations = "./migrations")]
async fn text_only_search_needs_no_tags(pool: PgPool) {
let h = common::harness(pool.clone());
let (_u, cookie) = common::register_user(&h.app).await;
let ch = seed_chapter(&pool, "M").await;
let p = add_page(&pool, ch, 1).await;
repo::page_analysis::persist_analysis(
&pool,
p,
&analysis(&[("dragon", "speech")], &[], false, &[]),
"m",
)
.await
.unwrap();
// The previously-501 text param now returns results (no tags needed).
let body = search(&h.app, &cookie, "text=dragon").await;
assert_eq!(page_ids(&body), vec![p.to_string()]);
}
#[sqlx::test(migrations = "./migrations")]
async fn search_without_any_filter_is_rejected(pool: PgPool) {
let h = common::harness(pool.clone());
let (_u, cookie) = common::register_user(&h.app).await;
let resp = h
.app
.clone()
.oneshot(common::get_with_cookie("/api/v1/me/page-search", &cookie))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[sqlx::test(migrations = "./migrations")]
async fn unknown_content_warning_is_rejected(pool: PgPool) {
let h = common::harness(pool.clone());
let (_u, cookie) = common::register_user(&h.app).await;
let resp = h
.app
.clone()
.oneshot(common::get_with_cookie(
"/api/v1/me/page-search?cw_include=spicy",
&cookie,
))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[sqlx::test(migrations = "./migrations")]
async fn requires_authentication(pool: PgPool) {
let h = common::harness(pool.clone());
let resp = h
.app
.clone()
.oneshot(common::get("/api/v1/me/page-search?text=x"))
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let _ = json!({}); // silence unused import in some configs
}