#include #include #include #include "LibraryText.h" using library::authorKey; using library::fold; namespace { // The same text in both Unicode normal forms. Every fold test runs both, because // a card holding only one form makes a one-sided test pass by accident — which // is exactly how a fold built on utf8ComposeNfc() survives until the first file // arrives from the other kind of machine. struct NormalisationPair { const char* nfc; // precomposed: e-acute is one codepoint const char* nfd; // decomposed: e followed by a combining acute const char* expected; }; constexpr NormalisationPair PAIRS[] = { {"pand\xC3\xA9mie", "pande\xCC\x81mie", "pandemie"}, {"\xC3\x89" "clipse totale", "E\xCC\x81" "clipse totale", "eclipse totale"}, {"M\xC3\xA9moires", "Me\xCC\x81moires", "memoires"}, {"Ang\xC3\xA9lina", "Ange\xCC\x81lina", "angelina"}, {"R\xC3\xA9" "camier", "Re\xCC\x81" "camier", "recamier"}, {"Derri\xC3\xA8re les collines", "Derrie\xCC\x80re les collines", "derriere les collines"}, }; } // namespace TEST(LibraryFold, BothNormalisationsAgree) { for (const auto& p : PAIRS) { EXPECT_EQ(fold(p.nfc), p.expected) << "NFC input: " << p.nfc; EXPECT_EQ(fold(p.nfd), p.expected) << "NFD input: " << p.nfd; EXPECT_EQ(fold(p.nfc), fold(p.nfd)) << "forms disagree for " << p.expected; } } TEST(LibraryFold, LettersWithoutCanonicalDecomposition) { // These have no NFD form at all, so a decompose-only fold silently deletes // them. "Søren" losing its last letter is the case that motivated the map. EXPECT_EQ(fold("S\xC3\xB8ren"), "soren"); EXPECT_EQ(fold("\xC3\x98rsted"), "orsted"); EXPECT_EQ(fold("\xC3\x86" "sop"), "aesop"); EXPECT_EQ(fold("Stra\xC3\x9F" "e"), "strasse"); EXPECT_EQ(fold("\xC5\x81odz"), "lodz"); EXPECT_EQ(fold("s\xC5\x93ur"), "soeur"); } TEST(LibraryFold, ApostrophesSurviveInNamesAndElisions) { // U+2019 is what exporters actually emit; folding it to a space would split // "O'Malley" into two tokens and change both its sort place and its search. // ("Malley", not "Brien": C++ hex escapes are greedy, so \x99B would parse // as the single escape 0x99B.) EXPECT_EQ(fold("O\xE2\x80\x99Malley"), "o'malley"); EXPECT_EQ(fold("O'Malley"), "o'malley"); EXPECT_EQ(fold("L\xE2\x80\x99\xC3\x89n\xC3\xA9ide"), "l'eneide"); } TEST(LibraryFold, TypographicDashesAndQuotesFoldLikeTheirAsciiForms) { // An em dash used to stay inside the fold word while an ASCII hyphen broke // it, so the same title written both ways sorted and searched differently. EXPECT_EQ(library::fold("a\u2014b"), library::fold("a-b")); EXPECT_EQ(library::fold("a\u2013b"), library::fold("a-b")); EXPECT_EQ(library::fold("\u201Cquoted\u201D"), library::fold("\"quoted\"")); } TEST(LibraryFold, PunctuationSeparatesAndSpaceRunsCollapse) { EXPECT_EQ(fold("Le juge Untel.T2.Le po\xC3\xA8me"), "le juge untel t2 le poeme"); EXPECT_EQ(fold(" spaced out "), "spaced out"); EXPECT_EQ(fold("a---b"), "a b"); EXPECT_EQ(fold("2085 _ Artificial"), "2085 artificial"); EXPECT_EQ(fold(""), ""); EXPECT_EQ(fold("!!!"), ""); } TEST(LibraryFold, PreservesHebrewLettersAndDropsNiqqud) { EXPECT_EQ(fold("\u05E9\u05B8\u05C1\u05DC\u05D5\u05B9\u05DD"), "\u05E9\u05DC\u05D5\u05DD"); EXPECT_TRUE(library::matchesQuery(fold("\u05E9\u05DC\u05D5\u05DD \u05E2\u05D5\u05DC\u05DD"), fold("\u05E9\u05DC"))); EXPECT_FALSE(authorKey("\u05E2\u05DE\u05D5\u05E1 \u05E2\u05D5\u05D6").empty()); } TEST(LibraryFold, GroupInitialUsesUnicodeLettersAndBucketsNumbers) { EXPECT_EQ(library::foldedGroupInitial(fold("Alpha")), static_cast('a')); EXPECT_EQ(library::foldedGroupInitial(fold("\u05E9\u05DC\u05D5\u05DD")), 0x05E9u); EXPECT_EQ(library::foldedGroupInitial(fold("\u041A\u043D\u0438\u0433\u0430")), 0x041Au); EXPECT_EQ(library::foldedGroupInitial(fold("\u4E66")), 0x4E66u); EXPECT_EQ(library::foldedGroupInitial(fold("2085")), 0u); EXPECT_EQ(library::foldedGroupInitial(fold("\u0662\u0660\u0668\u0665")), 0u); EXPECT_EQ(library::foldedGroupInitial(fold("!!!")), 0u); } TEST(LibraryFold, LeadingWordsArePreservedForSortingAndSearch) { for (const auto& [title, expected] : { std::pair{"The Iliad", "the iliad"}, std::pair{"A Tale of Two Cities", "a tale of two cities"}, std::pair{"An Ideal Husband", "an ideal husband"}, std::pair{"I Am Number Four", "i am number four"}, std::pair{"I, Robot", "i robot"}, std::pair{"O Pioneers!", "o pioneers"}, std::pair{"Die Trying", "die trying"}, std::pair{"Les Misérables", "les miserables"}, std::pair{"L’Énéide", "l'eneide"}, std::pair{"The", "the"}, }) { const std::string folded = fold(title); EXPECT_EQ(folded, expected) << title; EXPECT_EQ(library::foldedGroupInitial(folded), static_cast(expected[0])) << title; } } TEST(LibraryAuthorKey, OrderAndPunctuationDoNotMatter) { const std::string expected = authorKey("Lu Xun"); EXPECT_FALSE(expected.empty()); for (const char* spelling : {"Lu, Xun", "Xun, Lu", "Lu Xun_", "Lu Xun [Xun, Lu]", " lu xun "}) { EXPECT_EQ(authorKey(spelling), expected) << spelling; } } TEST(LibraryAuthorKey, InitialsAreIgnored) { EXPECT_EQ(authorKey("Herbert G Wells"), authorKey("Herbert Wells")); EXPECT_EQ(authorKey("Wells, Herbert G."), authorKey("Herbert Wells")); } TEST(LibraryAuthorKey, SecondaryAuthorsAndBracketsDropped) { EXPECT_EQ(authorKey("Emile Erckmann; Alexandre Chatrian"), authorKey("Emile Erckmann")); EXPECT_EQ(authorKey("George Sand [Sand, George]"), authorKey("George Sand")); } TEST(LibraryAuthorKey, FilesystemUnderscoreStandsInForAFullStop) { // The one input where the key's cleanup and cleanPersonName's differ before // folding: an underscore the filesystem took instead of a full stop. Both // reduce to the same initial, which fold() then drops as a one-letter token. EXPECT_EQ(authorKey("Herbert G_ Wells"), authorKey("Herbert Wells")); EXPECT_EQ(authorKey("Wells_ Herbert"), authorKey("Herbert Wells")); } TEST(LibraryAuthorKey, DistinctPeopleDoNotCollide) { EXPECT_NE(authorKey("Mary Wollstonecraft"), authorKey("Charlotte Bronte")); EXPECT_NE(authorKey("Victor Hugo"), authorKey("Jules Verne")); } TEST(LibraryAuthorKey, FitsTheRecordFieldWithoutCollapsingToAForename) { const std::string key = authorKey("Bartholomew Fitzgerald Wellington"); ASSERT_FALSE(key.empty()); EXPECT_LE(key.size(), library::AUTHOR_KEY_MAX_BYTES); EXPECT_NE(key.back(), ' '); // Sorting puts a short forename first, so cutting on a token boundary would // reduce this to "mary" and merge every Alex in the library. The byte cut must // keep enough of the surname to discriminate. const std::string mary = authorKey("Wollstonecraft, Mary"); EXPECT_GT(mary.size(), 5u); EXPECT_NE(mary, "mary"); EXPECT_NE(mary, authorKey("Mary Trevelyan")); // A truncated key stays a prefix of the untruncated one, so grouping is stable // however long the name is. EXPECT_EQ(authorKey("Wollstonecraft, Maryse").rfind("mary", 0), 0u); EXPECT_FALSE(authorKey("Nebuchadnezzarson").empty()); EXPECT_TRUE(authorKey("").empty()); EXPECT_TRUE(authorKey("Q. X. Z.").empty()); // initials only: no identity } // --- matchesQuery ------------------------------------------------------------ // // Cases taken from the shape of the accented and // apostrophised titles real cards hold — what a naive matcher gets wrong. TEST(MatchesQuery, EmptyQueryMatchesEverything) { EXPECT_TRUE(library::matchesQuery(library::fold("Wuthering Heights"), "")); } TEST(MatchesQuery, WholeWordMatches) { EXPECT_TRUE(library::matchesQuery(library::fold("Wuthering Heights"), library::fold("heights"))); } TEST(MatchesQuery, PrefixOfOneWordIsEnough) { EXPECT_TRUE(library::matchesQuery(library::fold("Wuthering Heights"), library::fold("hei"))); } // The point of the whole design: six keypresses instead of ten, on a panel where // each one costs a full repaint. TEST(MatchesQuery, EveryWordMayBeAbbreviated) { EXPECT_TRUE(library::matchesQuery(library::fold("Wuthering Heights"), library::fold("wut hei"))); } TEST(MatchesQuery, WordsNeedNotBeInOrder) { EXPECT_TRUE(library::matchesQuery(library::fold("Wuthering Heights"), library::fold("heights wuthering"))); } TEST(MatchesQuery, EveryWordMustHit) { EXPECT_FALSE(library::matchesQuery(library::fold("Wuthering Heights"), library::fold("wuthering blue"))); } // A prefix, not a substring: "eights" is inside "heights" but starts no word. TEST(MatchesQuery, MidWordDoesNotMatch) { EXPECT_FALSE(library::matchesQuery(library::fold("Wuthering Heights"), library::fold("eights"))); } TEST(MatchesQuery, AccentsAreIgnoredOnBothSides) { EXPECT_TRUE(library::matchesQuery(library::fold("L'Énéide"), library::fold("eneide"))); EXPECT_TRUE(library::matchesQuery(library::fold("L'Eneide"), library::fold("énéide"))); EXPECT_TRUE(library::matchesQuery(library::fold("Éluard"), library::fold("eluard"))); } TEST(MatchesQuery, ApostropheSplitsWords) { EXPECT_TRUE(library::matchesQuery(library::fold("Le bureau d'à côté"), library::fold("cote"))); } TEST(MatchesQuery, CaseIsIgnored) { EXPECT_TRUE(library::matchesQuery(library::fold("Wuthering Heights"), library::fold("HEIGHTS"))); } // The stored fold is capped at 96 bytes, so a query word beyond that cannot be // found. Asserted rather than left implicit: it is the one place a search can // honestly fail to find a book that is really there. TEST(MatchesQuery, LongTitlesAreOnlySearchableWithinTheStoredFold) { const std::string longTitle(120, 'a'); const std::string folded = library::fold(longTitle + " needle").substr(0, 96); EXPECT_FALSE(library::matchesQuery(folded, library::fold("needle"))); } // --- inverted author names --------------------------------------------------- TEST(CleanPersonName, InvertedNameIsTurnedRound) { EXPECT_EQ(library::cleanPersonName("Austen, Jane"), "Jane Austen"); EXPECT_EQ(library::cleanPersonName("Wollstonecraft, Mary"), "Mary Wollstonecraft"); } TEST(CleanPersonName, PlainNameIsUntouched) { EXPECT_EQ(library::cleanPersonName("Emily Bronte"), "Emily Bronte"); } // Two commas mean a suffix or a list, not an inversion — leave it alone rather // than scramble it. TEST(CleanPersonName, MultipleCommasAreLeftAlone) { // The trailing full stop is stripped by the existing noise rules. EXPECT_EQ(library::cleanPersonName("Smith, John, Jr."), "Smith, John, Jr"); } TEST(CleanPersonName, DanglingCommaIsNotAnInversion) { EXPECT_EQ(library::cleanPersonName("Austen,"), "Austen"); } // --- surnameKey -------------------------------------------------------------- TEST(SurnameKey, SurnameLeadsThenGivenNames) { EXPECT_EQ(library::surnameKey("Herman Melville"), "melville herman"); EXPECT_EQ(library::surnameKey("Mary Wollstonecraft"), "wollstonecraft mary"); } TEST(SurnameKey, SingleWordKeysOnItself) { EXPECT_EQ(library::surnameKey("Voltaire"), "voltaire"); } TEST(SurnameKey, AccentsAreFolded) { EXPECT_EQ(library::surnameKey("Paul Éluard"), "eluard paul"); } TEST(SurnameKey, ThreeWordNamesTakeTheLast) { EXPECT_EQ(library::surnameKey("Herbert G. Wells"), "wells herbert g"); } TEST(SurnameKey, EmptyStaysEmpty) { EXPECT_EQ(library::surnameKey(""), ""); } // The whole point of keying off the DISPLAY name: the spelling vote has already // made every book by one author show one name, so a group cannot land in two // places even though "Victor Hugo" and "Hugo Victor" both exist in the wild. TEST(SurnameKey, HarmonisedDisplayNameKeepsAGroupTogether) { EXPECT_NE(library::surnameKey("Victor Hugo"), library::surnameKey("Hugo Victor")); } TEST(LibraryPath, RootDoesNotGainASecondSeparator) { EXPECT_EQ(library::joinLibraryPath("/", "book.epub"), "/book.epub"); EXPECT_EQ(library::joinLibraryPath("", "book.epub"), "/book.epub"); } TEST(LibraryPath, NestedFolderGetsOneSeparator) { EXPECT_EQ(library::joinLibraryPath("/Books", "book.epub"), "/Books/book.epub"); }