aboutsummaryrefslogtreecommitdiff
path: root/src/tests/test_metainiutils.cpp
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-05-01 10:09:18 -0500
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-05-01 10:09:18 -0500
commit5d1fb2964a172e3444f18ca2ea823ee36784e699 (patch)
treeb658255c6465f049a6041cf8ee5970ff2521ca5a /src/tests/test_metainiutils.cpp
parent17756f8337bc71abf10aa0e7612dc31f2e0a588a (diff)
Fix meta.ini duplicate keys and SKSE Log Redirector mapped folders
meta.ini: Qt6 QSettings IniFormat is case-sensitive on Linux. Pre-existing CamelCase keys + lowercase setValue() left both casings in the file, so mods grew duplicate entries on every install. Added MetaIniUtils:: normalizeMetaIniCase() to fold keys to lowercase and dedupe per-section (keeping non-empty values) before any QSettings open in readMeta, saveMeta, doInstall, and createMod. SKSE Log Redirector: deployExternalMappings created per-file symlinks under the redirected destination, so any new log file the game wrote post-deploy ended up as a real file outside the mapping. For isDirectory && createTarget mappings, now publish a single directory symlink dst -> src when the destination is missing, an existing symlink, or empty. Falls back to per-file symlinks when dst already has real content (preserves user data). Cleanup removes the symlink, leaving no files behind. Tests under src/tests/ (gtest, opt-in via BUILD_TESTING): - test_metainiutils: 6 tests covering no-op cases, case-only dedup, multi-line continuations, per-section scoping, and end-to-end verification that QSettings duplication is fixed. - test_external_dir_mapping: 6 tests covering directory symlink deploy, flow-through writes, idempotency, empty-dir replacement, real-file fallback, and cleanup. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'src/tests/test_metainiutils.cpp')
-rw-r--r--src/tests/test_metainiutils.cpp190
1 files changed, 190 insertions, 0 deletions
diff --git a/src/tests/test_metainiutils.cpp b/src/tests/test_metainiutils.cpp
new file mode 100644
index 0000000..aef5998
--- /dev/null
+++ b/src/tests/test_metainiutils.cpp
@@ -0,0 +1,190 @@
+#include <gtest/gtest.h>
+
+#include <QByteArray>
+#include <QDir>
+#include <QFile>
+#include <QSettings>
+#include <QString>
+#include <QTemporaryDir>
+
+#include "metainiutils.h"
+
+namespace
+{
+
+QString writeIni(const QTemporaryDir& dir, const char* contents)
+{
+ const QString path = QDir(dir.path()).filePath("meta.ini");
+ QFile f(path);
+ EXPECT_TRUE(f.open(QIODevice::WriteOnly | QIODevice::Truncate));
+ f.write(contents);
+ f.close();
+ return path;
+}
+
+QByteArray readAll(const QString& path)
+{
+ QFile f(path);
+ EXPECT_TRUE(f.open(QIODevice::ReadOnly));
+ return f.readAll();
+}
+
+} // namespace
+
+// Sanity: empty / non-existent file is a no-op.
+TEST(MetaIniUtils, NoOpOnMissingFile)
+{
+ QTemporaryDir dir;
+ ASSERT_TRUE(dir.isValid());
+ const QString path = QDir(dir.path()).filePath("nope.ini");
+ EXPECT_FALSE(MetaIniUtils::normalizeMetaIniCase(path));
+}
+
+// Sanity: file with only lowercase keys is left untouched.
+TEST(MetaIniUtils, NoOpOnAlreadyLowercase)
+{
+ QTemporaryDir dir;
+ ASSERT_TRUE(dir.isValid());
+ const auto* contents = "[General]\n"
+ "author=Aether\n"
+ "category=\"11,\"\n";
+ const QString path = writeIni(dir, contents);
+ EXPECT_FALSE(MetaIniUtils::normalizeMetaIniCase(path));
+ EXPECT_EQ(QByteArray(contents), readAll(path));
+}
+
+// The reported bug: pre-existing CamelCase keys + lowercase setValue() leaves
+// duplicates on Linux/Qt6. Normalize must fold case-only duplicates.
+TEST(MetaIniUtils, DedupesCaseOnlyDuplicates)
+{
+ QTemporaryDir dir;
+ ASSERT_TRUE(dir.isValid());
+ // Mirrors what ModInfoRegular::saveMeta() leaves behind when meta.ini
+ // already had CamelCase keys: both casings coexist, and the lowercase
+ // copy may even be empty because readMeta() couldn't see the original.
+ const auto* contents =
+ "[General]\n"
+ "Author=Aether\n"
+ "Category=11\n"
+ "Color=@Variant(\\0\\0\\0\\x43\\x1\\xff\\xff\\x43\\x43\\xff\\xff\\xff\\xff\\0\\0)\n"
+ "Comments=keep me\n"
+ "Converted=false\n"
+ "author=\n"
+ "category=\"11,\"\n"
+ "comments=\n"
+ "converted=true\n";
+ const QString path = writeIni(dir, contents);
+ EXPECT_TRUE(MetaIniUtils::normalizeMetaIniCase(path));
+
+ // Open via QSettings and verify exactly one canonical lowercase key per
+ // logical setting.
+ QSettings s(path, QSettings::IniFormat);
+ EXPECT_EQ(QStringList({"author", "category", "color", "comments", "converted"}),
+ s.allKeys());
+
+ // Empty lowercase duplicate should NOT clobber the non-empty CamelCase
+ // value — `Comments=keep me` survives over `comments=`.
+ EXPECT_EQ("Aether", s.value("author").toString());
+ EXPECT_EQ("keep me", s.value("comments").toString());
+
+ // For non-empty duplicates the latest write wins, mirroring last-writer
+ // semantics so QSettings doesn't reintroduce stale data.
+ EXPECT_EQ("11,", s.value("category").toString());
+ EXPECT_TRUE(s.value("converted").toBool());
+
+ // Color value (containing escaped binary) round-trips intact.
+ const QByteArray after = readAll(path);
+ EXPECT_TRUE(after.contains(
+ "color=@Variant(\\0\\0\\0\\x43\\x1\\xff\\xff\\x43\\x43\\xff\\xff\\xff\\xff\\0\\0)"))
+ << after.toStdString();
+ EXPECT_FALSE(after.contains("Author="));
+ EXPECT_FALSE(after.contains("Color="));
+}
+
+// QSettings IniFormat supports multi-line values via trailing-`\` line
+// continuation. Continuation lines must travel with their key when the key is
+// renamed to lowercase, otherwise the value gets corrupted.
+TEST(MetaIniUtils, PreservesLineContinuations)
+{
+ QTemporaryDir dir;
+ ASSERT_TRUE(dir.isValid());
+ const auto* contents = "[General]\n"
+ "NotesField=line one\\\n"
+ " continued line two\n"
+ "Author=Aether\n";
+ const QString path = writeIni(dir, contents);
+ EXPECT_TRUE(MetaIniUtils::normalizeMetaIniCase(path));
+
+ const QByteArray after = readAll(path);
+ EXPECT_TRUE(after.contains("notesfield=line one\\\n"));
+ EXPECT_TRUE(after.contains(" continued line two"));
+ EXPECT_FALSE(after.contains("NotesField="));
+}
+
+// Plugin settings live in nested `[Plugins\<name>]` sections. The dedupe
+// logic must scope per-section — `Foo=` under [General] must not collide
+// with `foo=` under [Plugins\Whatever].
+TEST(MetaIniUtils, DedupesPerSection)
+{
+ QTemporaryDir dir;
+ ASSERT_TRUE(dir.isValid());
+ const auto* contents = "[General]\n"
+ "Foo=top\n"
+ "[Plugins\\Sample]\n"
+ "Foo=plugin\n"
+ "foo=plugin-dup\n";
+ const QString path = writeIni(dir, contents);
+ EXPECT_TRUE(MetaIniUtils::normalizeMetaIniCase(path));
+
+ QSettings s(path, QSettings::IniFormat);
+ EXPECT_EQ("top", s.value("foo").toString());
+ s.beginGroup("Plugins/Sample");
+ EXPECT_EQ("plugin-dup", s.value("foo").toString());
+ s.endGroup();
+}
+
+// End-to-end: reproduce the exact symptom from the bug report — install
+// path opens QSettings, writes lowercase keys; without normalization the
+// resulting file ends up with CamelCase + lowercase duplicates.
+TEST(MetaIniUtils, FixesQSettingsDuplicationOnLinux)
+{
+ QTemporaryDir dir;
+ ASSERT_TRUE(dir.isValid());
+ // Pre-existing meta.ini in CamelCase (e.g. shipped inside the mod
+ // archive, or migrated from MO2 Windows).
+ const auto* preexisting = "[General]\n"
+ "Author=Aether\n"
+ "Category=11\n"
+ "Comments=keep me\n";
+ const QString path = writeIni(dir, preexisting);
+
+ // Without normalization, lowercase setValue adds NEW keys alongside the
+ // CamelCase ones — confirm the bug exists in the bare QSettings flow.
+ {
+ QSettings s(path, QSettings::IniFormat);
+ s.setValue("author", "Aether");
+ s.setValue("category", "11,");
+ s.setValue("comments", "");
+ }
+ const QByteArray dirty = readAll(path);
+ EXPECT_TRUE(dirty.contains("Author=Aether"));
+ EXPECT_TRUE(dirty.contains("author=Aether"));
+
+ // Normalize and re-run the same install-style writes. After this the
+ // file must have exactly one casing per key.
+ EXPECT_TRUE(MetaIniUtils::normalizeMetaIniCase(path));
+ {
+ QSettings s(path, QSettings::IniFormat);
+ s.setValue("author", "Aether");
+ s.setValue("category", "11,");
+ }
+
+ const QByteArray clean = readAll(path);
+ EXPECT_FALSE(clean.contains("Author="));
+ EXPECT_FALSE(clean.contains("Category="));
+ EXPECT_FALSE(clean.contains("Comments="));
+ EXPECT_TRUE(clean.contains("author=Aether"));
+ EXPECT_TRUE(clean.contains("category=\"11,\""));
+ // The non-empty original Comments value survived the empty duplicate.
+ EXPECT_TRUE(clean.contains("comments=keep me"));
+}