feat: canonicalise author names
Extractors wrote whatever the file said, so one person held several rows: "Sam Newman" beside "Newman, Sam;" beside "Sam Newman.epub", the last because the upload path never stripped the file extension. Tidy on write, in the validator and in the uniqueness lookup alike, and merge the rows that collide.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import pytest
|
||||
|
||||
from chitai.services.matching import (
|
||||
format_author_name,
|
||||
normalize_author,
|
||||
normalize_identifier,
|
||||
normalize_title,
|
||||
@@ -92,6 +93,59 @@ class TestNormalizeAuthor:
|
||||
assert normalize_author("Smith, John, Jr.") == "smith john jr"
|
||||
|
||||
|
||||
class TestFormatAuthorName:
|
||||
"""What gets stored and shown, as opposed to what gets compared."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("written", "expected"),
|
||||
[
|
||||
# A leftover separator from a `DC:creator` list.
|
||||
("Newman, Sam;", "Sam Newman"),
|
||||
("Sam Newman ", "Sam Newman"),
|
||||
(" Dan Vanderkam ", "Dan Vanderkam"),
|
||||
# `Surname, Given` is how EPUBs file a name, not how anyone reads it.
|
||||
("Kleppmann, Martin", "Martin Kleppmann"),
|
||||
("Huxley, Aldous", "Aldous Huxley"),
|
||||
("Liu, Cixin", "Cixin Liu"),
|
||||
# An extension carried in from the filename the name was read out of.
|
||||
("Sam Newman.epub", "Sam Newman"),
|
||||
("Franz Kafka.mobi", "Franz Kafka"),
|
||||
("Brian W. Kernighan.epub", "Brian W. Kernighan"),
|
||||
("", ""),
|
||||
(None, ""),
|
||||
],
|
||||
)
|
||||
def test_names_are_tidied(self, written: str | None, expected: str) -> None:
|
||||
assert format_author_name(written) == expected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"written",
|
||||
[
|
||||
# Two people in one string. Flipping it would invent a third.
|
||||
"Dave Thomas, Andy Hunt",
|
||||
"Mark Richards, Neal Ford",
|
||||
# A compound surname is not recognised, and is left alone rather than
|
||||
# rearranged wrongly.
|
||||
"García Márquez, Gabriel",
|
||||
],
|
||||
)
|
||||
def test_an_unrecognised_form_is_left_alone(self, written: str) -> None:
|
||||
assert format_author_name(written) == written
|
||||
|
||||
def test_case_and_accents_belong_to_the_author(self) -> None:
|
||||
"""Tidying removes what an extractor added; it does not correct spelling."""
|
||||
assert format_author_name("Michał Płachta.epub") == "Michał Płachta"
|
||||
assert format_author_name("Steve McConnell") == "Steve McConnell"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"written", ["Newman, Sam;", "Sam Newman.epub", "Kleppmann, Martin"]
|
||||
)
|
||||
def test_tidying_is_idempotent(self, written: str) -> None:
|
||||
"""`unique_filter` tidies a name that may already be tidy; it must not drift."""
|
||||
once = format_author_name(written)
|
||||
assert format_author_name(once) == once
|
||||
|
||||
|
||||
class TestNormalizeIdentifier:
|
||||
"""Identifiers only help if the same edition produces the same key."""
|
||||
|
||||
|
||||
@@ -895,6 +895,72 @@ class TestDuplicateBooks:
|
||||
assert "title-author" in possible.candidates[0].matched_on
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestAuthorNames:
|
||||
"""One person is one row, however the file happened to spell them."""
|
||||
|
||||
async def test_a_variant_spelling_reuses_the_existing_author(
|
||||
self, books_service: BookService, test_library: m.Library
|
||||
) -> None:
|
||||
"""
|
||||
`as_unique_async` looks a name up before inserting it, so the lookup and the
|
||||
insert have to tidy identically. If they disagree, every variant misses the
|
||||
existing row and then collides with it on the unique index.
|
||||
"""
|
||||
first = await store_book(
|
||||
books_service, test_library, title="Building Microservices", authors=["Sam Newman"]
|
||||
)
|
||||
second = await store_book(
|
||||
books_service,
|
||||
test_library,
|
||||
title="Monolith to Microservices",
|
||||
authors=["Newman, Sam;"],
|
||||
)
|
||||
|
||||
assert [author.name for author in first.authors] == ["Sam Newman"]
|
||||
assert [author.name for author in second.authors] == ["Sam Newman"]
|
||||
assert first.authors[0].id == second.authors[0].id
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("written", "stored"),
|
||||
[
|
||||
("Newman, Sam;", "Sam Newman"),
|
||||
("Sam Newman.epub", "Sam Newman"),
|
||||
(" Sam Newman ", "Sam Newman"),
|
||||
# Two people in one string is left exactly as it was found.
|
||||
("Dave Thomas, Andy Hunt", "Dave Thomas, Andy Hunt"),
|
||||
],
|
||||
)
|
||||
async def test_the_stored_name_is_the_tidy_one(
|
||||
self,
|
||||
books_service: BookService,
|
||||
test_library: m.Library,
|
||||
written: str,
|
||||
stored: str,
|
||||
) -> None:
|
||||
book = await store_book(
|
||||
books_service, test_library, title="Some Book", authors=[written]
|
||||
)
|
||||
|
||||
assert [author.name for author in book.authors] == [stored]
|
||||
|
||||
async def test_an_upload_does_not_keep_the_file_extension(
|
||||
self, books_service: BookService, test_library: m.Library
|
||||
) -> None:
|
||||
"""
|
||||
A filename is the fallback when the file declares no author of its own, and
|
||||
"Franz Kafka.epub" is not a person.
|
||||
"""
|
||||
result = await books_service.create_many_from_files(
|
||||
BooksCreateFromFiles(files=[upload(EPUB, "Some Unknown Book - Ada Lovelace.epub")]),
|
||||
test_library,
|
||||
)
|
||||
|
||||
book = await books_service.get(result.books[0].id)
|
||||
for author in book.authors:
|
||||
assert not author.name.endswith((".epub", ".pdf", ".mobi"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestDuplicateBookGroups:
|
||||
"""The pass over a library someone already has."""
|
||||
|
||||
Reference in New Issue
Block a user