// SPDX-License-Identifier: GPL-3.0-or-later #include "glossarystore.h" #include #include #include #include #include #include using namespace Qt::Literals::StringLiterals; class GlossaryStoreTest : public QObject { Q_OBJECT private Q_SLOTS: void saveLoadMerge() { QTemporaryDir temp; QStandardPaths::setTestModeEnabled(true); const QString directory = temp.path() + u"/series"_s; GlossaryStore::Glossary original{directory, {{u"Lin"_s, u"Lâm"_s}}}; QVERIFY(GlossaryStore::save(original)); const auto loaded = GlossaryStore::load(directory); QCOMPARE(loaded.entries.size(), 1); const auto merged = GlossaryStore::merge(loaded, {{u"Lin"_s, u"Other"_s}, {u"Sect"_s, u"Tông"_s}}); QCOMPARE(merged.entries.size(), 2); QCOMPARE(merged.entries.at(0).target, u"Lâm"_s); QCOMPARE(merged.entries.at(1).source, u"Sect"_s); } void groupsEpisodesAndSkipDuplicateSources() { QTemporaryDir temp; QStandardPaths::setTestModeEnabled(true); const QString directory = temp.path() + u"/series"_s; GlossaryStore::Glossary existing{directory, {{u"Lin"_s, u"Lâm"_s, u"Episode 1"_s}}, {u"Episode 1"_s}}; const auto merged = GlossaryStore::merge(existing, {{u"lin"_s, u"Khác"_s}, {u"Sect"_s, u"Tông"_s}}, u"Episode 2"_s); const QStringList expectedEpisodes{u"Episode 1"_s, u"Episode 2"_s}; QCOMPARE(merged.episodes, expectedEpisodes); QCOMPARE(merged.entries.size(), 2); QCOMPARE(merged.entries.at(1).episode, u"Episode 2"_s); QVERIFY(GlossaryStore::save(merged)); const auto loaded = GlossaryStore::load(directory); QCOMPARE(loaded.episodes, merged.episodes); QCOMPARE(loaded.entries.at(0).episode, u"Episode 1"_s); } void regroupLegacyTermsFromOriginalSubtitles() { const GlossaryStore::Glossary original{ u"/series"_s, {{u"Ye Xiu"_s, u"Diệp Tu đã sửa"_s, {}}, {u"Club"_s, u"Câu lạc bộ"_s, {}}, {u"ID"_s, u"ID"_s, {}}, {u"Other"_s, u"Khác"_s, u"02 - The Skilled Supervisor"_s}}, {u"02 - The Skilled Supervisor"_s}}; const auto episode0 = GlossaryStore::assignEpisode(original, {u"Ye Xiu watches a video."_s, u"Other players are here."_s}, u"00 - For The Glory"_s); QCOMPARE(episode0.entries.size(), original.entries.size()); QCOMPARE(episode0.entries.at(0).episode, u"00 - For The Glory"_s); QCOMPARE(episode0.entries.at(0).target, u"Diệp Tu đã sửa"_s); QVERIFY(episode0.entries.at(1).episode.isEmpty()); QVERIFY(episode0.entries.at(2).episode.isEmpty()); // "video" is not "ID". QCOMPARE(episode0.entries.at(3).episode, u"02 - The Skilled Supervisor"_s); // Metadata-only migration must preserve existing translation cache keys. QCOMPARE(GlossaryStore::serialize(episode0), GlossaryStore::serialize(original)); const auto episode2 = GlossaryStore::assignEpisode(episode0, {u"Ye Xiu's CLUB."_s}, u"02 - The Skilled Supervisor"_s); QCOMPARE(episode2.entries.at(0).episode, u"00 - For The Glory"_s); QCOMPARE(episode2.entries.at(1).episode, u"02 - The Skilled Supervisor"_s); const auto episode3 = GlossaryStore::assignEpisode(episode2, {u"Your ID?"_s}, u"03 - Adventurers"_s); QCOMPARE(episode3.entries.at(2).episode, u"03 - Adventurers"_s); const auto repeat = GlossaryStore::assignEpisode(episode3, {u"Your ID?"_s}, u"03 - Adventurers"_s); QCOMPARE(repeat.entries.size(), original.entries.size()); QCOMPARE(repeat.episodes, episode3.episodes); QTemporaryDir temp; const QString path = temp.filePath(u"grouped.json"_s); QVERIFY(GlossaryStore::exportFile(path, episode3)); GlossaryStore::Glossary reloaded; QVERIFY(GlossaryStore::importFile(path, &reloaded)); for (qsizetype i = 0; i < repeat.entries.size(); ++i) { QCOMPARE(reloaded.entries.at(i).episode, repeat.entries.at(i).episode); QCOMPARE(reloaded.entries.at(i).target, repeat.entries.at(i).target); } } void matchOriginalTerms_data() { QTest::addColumn("term"); QTest::addColumn("texts"); QTest::addColumn("matches"); QTest::newRow("case insensitive") << u"Club"_s << QStringList{u"The CLUB is here."_s} << true; QTest::newRow("substring") << u"Ye"_s << QStringList{u"Yesterday was good."_s} << false; QTest::newRow("possessive") << u"Ye"_s << QStringList{u"Ye's weapon."_s} << true; QTest::newRow("word prefix") << u"Club"_s << QStringList{u"Clubhouse"_s} << false; QTest::newRow("word suffix") << u"Club"_s << QStringList{u"Fanclub"_s} << false; QTest::newRow("literal punctuation") << u"C++"_s << QStringList{u"Learn C++."_s} << true; QTest::newRow("punctuation is not regex") << u"C++"_s << QStringList{u"CCCC"_s} << false; QTest::newRow("unicode normalization") << u"Diệp"_s << QStringList{QString(u"DIỆP's weapon"_s).normalized(QString::NormalizationForm_D)} << true; QTest::newRow("unicode boundary") << u"Ye"_s << QStringList{u"Ye秋"_s} << false; QTest::newRow("wrapped phrase") << u" account card "_s << QStringList{u"Your account\ncard is here."_s} << true; QTest::newRow("separate cues") << u"account card"_s << QStringList{u"account"_s, u"card"_s} << false; QTest::newRow("target is not source") << u"Ye"_s << QStringList{u"Diệp Tu"_s} << false; } void matchOriginalTerms() { QFETCH(QString, term); QFETCH(QStringList, texts); QFETCH(bool, matches); const GlossaryStore::Glossary original{{}, {{term, u"Giữ bản dịch"_s, {}}}, {}}; const auto grouped = GlossaryStore::assignEpisode(original, texts, u"Episode 2"_s); QCOMPARE(grouped.entries.first().episode, matches ? u"Episode 2"_s : QString()); QCOMPARE(grouped.entries.first().source, term); QCOMPARE(grouped.entries.first().target, u"Giữ bản dịch"_s); } void episodeSortingIsNumericAndIndependentOfLocale() { const QLocale previousLocale; QLocale::setDefault(QLocale::c()); const QStringList input{u"Episode 10"_s, u"Episode 2"_s, u"Episode 0"_s, u"Episode 3"_s, {}, u"Episode 2"_s}; const auto sorted = GlossaryStore::sortedEpisodes(input); QLocale::setDefault(previousLocale); const QStringList expected{{}, u"Episode 0"_s, u"Episode 2"_s, u"Episode 3"_s, u"Episode 10"_s}; QCOMPARE(sorted, expected); } void suggestionsAreVerifiedAgainstSubtitles() { const GlossaryStore::Glossary original{{}, {{u"Club"_s, u"Bản dịch đã sửa"_s, {}}}, {}}; const auto merged = GlossaryStore::merge(original, {{u"CLUB"_s, u"Bản dịch khác"_s}, {u"Ye"_s, u"Diệp"_s}, {u"Made up"_s, u"Bịa"_s}}); const auto grouped = GlossaryStore::assignEpisode(merged, {u"Ye joins the Club."_s}, u"02 - Episode"_s); QCOMPARE(grouped.entries.size(), 3); QCOMPARE(grouped.entries.at(0).episode, u"02 - Episode"_s); QCOMPARE(grouped.entries.at(0).target, u"Bản dịch đã sửa"_s); QCOMPARE(grouped.entries.at(1).episode, u"02 - Episode"_s); QVERIFY(grouped.entries.at(2).episode.isEmpty()); } void unassignedTermsBelongToFirstEpisodeSeen() { const GlossaryStore::Glossary legacy{ {}, {{u"Old term"_s, u"Thuật ngữ cũ"_s, {}}, {u"Existing"_s, u"Đã có"_s, u"Episode 1"_s}}, {u"Episode 1"_s}}; const auto grouped = GlossaryStore::assignUnassigned(legacy, u"Episode 0"_s); QCOMPARE(grouped.entries.at(0).episode, u"Episode 0"_s); QCOMPARE(grouped.entries.at(1).episode, u"Episode 1"_s); const auto repeat = GlossaryStore::assignUnassigned(grouped, u"Episode 2"_s); QCOMPARE(repeat.entries.at(0).episode, u"Episode 0"_s); QCOMPARE(repeat.entries.at(1).episode, u"Episode 1"_s); QVERIFY(!repeat.episodes.contains(QString())); } void saveFailureReturnsError() { QTemporaryDir temp; QVERIFY(temp.isValid()); QStandardPaths::setTestModeEnabled(true); const QString appData = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation); const QString glossaries = appData + u"/glossaries"_s; QDir(glossaries).removeRecursively(); QVERIFY(QDir().mkpath(appData)); QFile blocker(glossaries); QVERIFY(blocker.open(QIODevice::WriteOnly)); blocker.write("blocked"); blocker.close(); GlossaryStore::Glossary glossary{temp.path(), {{u"Original"_s, u"Translation"_s}}}; QString error; QVERIFY(!GlossaryStore::save(glossary, &error)); QVERIFY(!error.isEmpty()); QFile::remove(glossaries); } void portableRoundTrip() { QTemporaryDir temp; const QString path = temp.filePath(u"glossary.json"_s); const GlossaryStore::Glossary original{ u"/original/machine/series"_s, {{u"Lin"_s, u"Lâm"_s, {}}, {u"Sect"_s, u"Tông môn"_s, u"Tập 2"_s}}, {u"Tập 2"_s, u"Tập 3 (empty)"_s}}; QString error; QVERIFY2(GlossaryStore::exportFile(path, original, &error), qPrintable(error)); QFile file(path); QVERIFY(file.open(QIODevice::ReadOnly)); const auto object = QJsonDocument::fromJson(file.readAll()).object(); QVERIFY(!object.contains(u"directory"_s)); QCOMPARE(object.value(u"version"_s).toInt(), 1); GlossaryStore::Glossary imported; QVERIFY2(GlossaryStore::importFile(path, &imported, &error), qPrintable(error)); QVERIFY(imported.directory.isEmpty()); QCOMPARE(imported.episodes, original.episodes); QCOMPARE(imported.entries.size(), 2); QVERIFY(imported.entries.at(0).episode.isEmpty()); QCOMPARE(imported.entries.at(1).target, u"Tông môn"_s); QCOMPARE(imported.entries.at(1).episode, u"Tập 2"_s); } void mergeImportedKeepsEditsAndEpisodeOwnership() { const GlossaryStore::Glossary existing{ u"/current/series"_s, {{u"Lin"_s, u"Lâm đã sửa"_s, u"Tập 1"_s}}, {u"Tập 1"_s}}; const GlossaryStore::Glossary imported{ u"/other/series"_s, {{u" lin "_s, u"Khác"_s, u"Tập 2"_s}, {u"Sect"_s, u"Tông"_s, u"Tập 2"_s}, {u"SECT"_s, u"Khác nữa"_s, u"Tập 3"_s}, {u"Master"_s, u"Sư phụ"_s, {}}}, {u"Tập 2"_s, u"Tập 3"_s}}; const auto merged = GlossaryStore::mergeImported(existing, imported); QCOMPARE(merged.directory, existing.directory); QCOMPARE(merged.entries.size(), 3); QCOMPARE(merged.entries.at(0).target, u"Lâm đã sửa"_s); QCOMPARE(merged.entries.at(0).episode, u"Tập 1"_s); QCOMPARE(merged.entries.at(1).target, u"Tông"_s); QCOMPARE(merged.entries.at(1).episode, u"Tập 2"_s); QVERIFY(merged.entries.at(2).episode.isEmpty()); const QStringList expected{u"Tập 1"_s, u"Tập 2"_s, u"Tập 3"_s}; QCOMPARE(merged.episodes, expected); QCOMPARE(GlossaryStore::mergeImported(merged, imported).entries.size(), 3); } void importLegacyFile() { QTemporaryDir temp; const QString path = temp.filePath(u"old.json"_s); QFile file(path); QVERIFY(file.open(QIODevice::WriteOnly)); file.write(R"({"directory":"/old/path","entries":[{"source":"Lin","target":"Lam"}]})"); file.close(); GlossaryStore::Glossary imported; QString error; QVERIFY2(GlossaryStore::importFile(path, &imported, &error), qPrintable(error)); QCOMPARE(imported.entries.size(), 1); QVERIFY(imported.entries.at(0).episode.isEmpty()); QVERIFY(imported.directory.isEmpty()); } void importRejectsInvalidFiles_data() { QTest::addColumn("content"); QTest::newRow("broken json") << QByteArray("{"); QTest::newRow("wrong root") << QByteArray("[]"); QTest::newRow("missing entries") << QByteArray("{}"); QTest::newRow("wrong version") << QByteArray(R"({"version":2,"entries":[]})"); QTest::newRow("wrong episodes") << QByteArray(R"({"entries":[],"episodes":[7]})"); QTest::newRow("wrong entry") << QByteArray(R"({"entries":[7]})"); QTest::newRow("incomplete after valid entry") << QByteArray(R"({"entries":[{"source":"Lin","target":"Lam"},{"source":"Sect","target":""}]})"); QTest::newRow("wrong episode") << QByteArray(R"({"entries":[{"source":"Lin","target":"Lam","episode":7}]})"); } void importRejectsInvalidFiles() { QFETCH(QByteArray, content); QTemporaryDir temp; const QString path = temp.filePath(u"invalid.json"_s); QFile file(path); QVERIFY(file.open(QIODevice::WriteOnly)); file.write(content); file.close(); GlossaryStore::Glossary glossary{u"/unchanged"_s, {{u"Keep"_s, u"Giữ"_s, {}}}, {}}; QString error; QVERIFY(!GlossaryStore::importFile(path, &glossary, &error)); QVERIFY(!error.isEmpty()); QCOMPARE(glossary.directory, u"/unchanged"_s); QCOMPARE(glossary.entries.size(), 1); QCOMPARE(glossary.entries.at(0).source, u"Keep"_s); } void fileAccessErrors() { QTemporaryDir temp; GlossaryStore::Glossary glossary; QString error; QVERIFY(!GlossaryStore::importFile(temp.filePath(u"missing.json"_s), &glossary, &error)); QVERIFY(!error.isEmpty()); error.clear(); QVERIFY(!GlossaryStore::exportFile(temp.path(), glossary, &error)); QVERIFY(!error.isEmpty()); } void saveRejectsIncompleteEntry() { QString error; QVERIFY(!GlossaryStore::save({QDir::tempPath(), {{u"Original"_s, {}}}}, &error)); QVERIFY(error.contains(u"both"_s)); } }; QTEST_MAIN(GlossaryStoreTest) #include "glossarystoretest.moc"