aboutsummaryrefslogtreecommitdiff
path: root/src/tests
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
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')
-rw-r--r--src/tests/CMakeLists.txt53
-rw-r--r--src/tests/test_external_dir_mapping.cpp277
-rw-r--r--src/tests/test_metainiutils.cpp190
3 files changed, 520 insertions, 0 deletions
diff --git a/src/tests/CMakeLists.txt b/src/tests/CMakeLists.txt
new file mode 100644
index 0000000..8e71366
--- /dev/null
+++ b/src/tests/CMakeLists.txt
@@ -0,0 +1,53 @@
+cmake_minimum_required(VERSION 3.16)
+
+find_package(Qt6 REQUIRED COMPONENTS Core)
+find_package(GTest REQUIRED)
+
+# ---------------------------------------------------------------------------
+# meta.ini case-normalization helper tests
+# ---------------------------------------------------------------------------
+add_executable(test_metainiutils EXCLUDE_FROM_ALL
+ test_metainiutils.cpp
+ ${CMAKE_SOURCE_DIR}/src/src/metainiutils.cpp
+)
+set_target_properties(test_metainiutils PROPERTIES
+ AUTOMOC OFF
+ CXX_STANDARD 23
+ CXX_STANDARD_REQUIRED ON
+)
+target_include_directories(test_metainiutils PRIVATE
+ ${CMAKE_SOURCE_DIR}/src/src
+)
+target_link_libraries(test_metainiutils PRIVATE
+ Qt6::Core
+ GTest::gtest
+ GTest::gtest_main
+)
+add_test(NAME test_metainiutils COMMAND test_metainiutils)
+
+# ---------------------------------------------------------------------------
+# External directory mapping (SKSE Log Redirector style) tests
+#
+# Exercises the deploy/cleanup logic for `isDirectory && createTarget`
+# mappings — directory-symlink path plus the per-file fallback when the
+# destination already has real content. The behavior is mirrored from
+# FuseConnector::deployExternalMappings so the test can run without
+# bringing in libfuse or the rest of the organizer dependency tree.
+# ---------------------------------------------------------------------------
+add_executable(test_external_dir_mapping EXCLUDE_FROM_ALL
+ test_external_dir_mapping.cpp
+)
+set_target_properties(test_external_dir_mapping PROPERTIES
+ AUTOMOC OFF
+ CXX_STANDARD 23
+ CXX_STANDARD_REQUIRED ON
+)
+target_link_libraries(test_external_dir_mapping PRIVATE
+ GTest::gtest
+ GTest::gtest_main
+)
+add_test(NAME test_external_dir_mapping COMMAND test_external_dir_mapping)
+
+# Register a "fluorine-tests" umbrella target that builds every test exe.
+add_custom_target(fluorine-tests
+ DEPENDS test_metainiutils test_external_dir_mapping)
diff --git a/src/tests/test_external_dir_mapping.cpp b/src/tests/test_external_dir_mapping.cpp
new file mode 100644
index 0000000..439fbfc
--- /dev/null
+++ b/src/tests/test_external_dir_mapping.cpp
@@ -0,0 +1,277 @@
+// Tests the createTarget directory-mapping contract used by
+// FuseConnector::deployExternalMappings (src/src/fuseconnector.cpp:758).
+//
+// The SKSE Log Redirector plugin family publishes a mapping with
+// `isDirectory=true, createTarget=true` so that any file the game writes
+// under the redirected destination — even files that did not exist at deploy
+// time — flows through to the source. Per-file symlinks miss new writes;
+// a single directory symlink does not. This test mirrors the production
+// algorithm and verifies its contract on both happy paths and edge cases.
+//
+// The mirrored algorithm (kept intentionally close to the production code):
+//
+// if isDirectory && createTarget:
+// ensure src exists
+// if dst missing OR dst is a symlink OR dst is empty dir:
+// remove dst (if symlink/empty dir), create dir symlink dst -> src
+// else (dst has real content):
+// fall back to per-file symlinks (current behavior, preserves data)
+
+#include <gtest/gtest.h>
+
+#include <cerrno>
+#include <filesystem>
+#include <fstream>
+#include <string>
+#include <system_error>
+
+namespace fs = std::filesystem;
+
+namespace
+{
+
+struct DeployResult
+{
+ bool dirSymlinkCreated = false;
+ bool fellBackToPerFile = false;
+ std::vector<fs::path> createdLinks;
+};
+
+DeployResult deployDirectoryMapping(const fs::path& src, const fs::path& dst,
+ bool createTarget)
+{
+ DeployResult result;
+ std::error_code ec;
+
+ if (createTarget) {
+ if (!fs::exists(src, ec)) {
+ fs::create_directories(src, ec);
+ if (ec) {
+ ec.clear();
+ }
+ }
+ const bool dstExists = fs::exists(dst, ec);
+ ec.clear();
+ const bool dstIsLink = dstExists && fs::is_symlink(dst, ec);
+ ec.clear();
+ const bool dstIsEmpty = dstExists && fs::is_directory(dst, ec) &&
+ fs::is_empty(dst, ec);
+ ec.clear();
+
+ if (!dstExists || dstIsLink || dstIsEmpty) {
+ if (dstIsLink || dstIsEmpty) {
+ fs::remove(dst, ec);
+ ec.clear();
+ }
+ fs::create_directory_symlink(src, dst, ec);
+ if (!ec) {
+ result.dirSymlinkCreated = true;
+ result.createdLinks.push_back(dst);
+ return result;
+ }
+ }
+ }
+
+ // Fall back: per-file symlinks under src.
+ result.fellBackToPerFile = true;
+ if (!fs::exists(src, ec)) {
+ return result;
+ }
+ for (auto it = fs::recursive_directory_iterator(
+ src, fs::directory_options::skip_permission_denied);
+ it != fs::recursive_directory_iterator(); ++it) {
+ const auto& entry = *it;
+ const auto rel = fs::relative(entry.path(), src, ec);
+ if (ec || rel.empty()) {
+ continue;
+ }
+ const fs::path destPath = dst / rel;
+ if (entry.is_directory(ec)) {
+ fs::create_directories(destPath, ec);
+ } else {
+ fs::create_directories(destPath.parent_path(), ec);
+ if (fs::exists(destPath, ec) && !fs::is_symlink(destPath, ec)) {
+ continue; // never clobber real files
+ }
+ if (fs::is_symlink(destPath, ec)) {
+ fs::remove(destPath, ec);
+ }
+ fs::create_symlink(entry.path(), destPath, ec);
+ if (!ec) {
+ result.createdLinks.push_back(destPath);
+ }
+ }
+ }
+ return result;
+}
+
+class TempRoot
+{
+public:
+ TempRoot()
+ {
+ char tmpl[] = "/tmp/fluorine-extmap-XXXXXX";
+ const char* d = mkdtemp(tmpl);
+ if (d != nullptr) {
+ m_path = d;
+ }
+ }
+ ~TempRoot()
+ {
+ if (!m_path.empty()) {
+ std::error_code ec;
+ fs::remove_all(m_path, ec);
+ }
+ }
+ const fs::path& path() const { return m_path; }
+
+private:
+ fs::path m_path;
+};
+
+void writeFile(const fs::path& p, const std::string& contents)
+{
+ fs::create_directories(p.parent_path());
+ std::ofstream out(p);
+ out << contents;
+}
+
+} // namespace
+
+// Happy path: a fresh enable of the SKSE Log Redirector. Source (Skyrim
+// Special Edition) has logs, destination (Skyrim/) doesn't exist yet — we
+// publish a single directory symlink.
+TEST(ExternalDirMapping, DeploysDirSymlinkForFreshDestination)
+{
+ TempRoot tmp;
+ ASSERT_FALSE(tmp.path().empty());
+
+ const fs::path src = tmp.path() / "Skyrim Special Edition";
+ const fs::path dst = tmp.path() / "Skyrim";
+ writeFile(src / "skse64.log", "log A\n");
+ writeFile(src / "papyrus.0.log", "log B\n");
+
+ auto result = deployDirectoryMapping(src, dst, /*createTarget=*/true);
+ EXPECT_TRUE(result.dirSymlinkCreated);
+ EXPECT_FALSE(result.fellBackToPerFile);
+ EXPECT_TRUE(fs::is_symlink(dst));
+ EXPECT_EQ(src, fs::read_symlink(dst));
+}
+
+// Game writes a NEW log file at the destination AFTER deploy. With a
+// directory symlink the write transparently goes through to source — the
+// per-file approach would have left this as a real file in dst.
+TEST(ExternalDirMapping, NewWritesFlowThroughDirSymlink)
+{
+ TempRoot tmp;
+ ASSERT_FALSE(tmp.path().empty());
+ const fs::path src = tmp.path() / "src";
+ const fs::path dst = tmp.path() / "dst";
+ fs::create_directories(src);
+
+ auto result = deployDirectoryMapping(src, dst, /*createTarget=*/true);
+ ASSERT_TRUE(result.dirSymlinkCreated);
+
+ // Game emits a new log AFTER deploy, into the *destination*.
+ writeFile(dst / "new.log", "fresh data");
+
+ // The file must exist physically under src, not as a real file at dst.
+ EXPECT_TRUE(fs::exists(src / "new.log"));
+ EXPECT_FALSE(fs::is_symlink(src / "new.log"));
+ // Reading through dst returns the same bytes.
+ std::ifstream f(dst / "new.log");
+ std::string buf((std::istreambuf_iterator<char>(f)),
+ std::istreambuf_iterator<char>());
+ EXPECT_EQ("fresh data", buf);
+}
+
+// Idempotency: re-running deploy on an existing symlink is a no-op rebuild,
+// not a corruption — the destination still resolves to source afterwards.
+TEST(ExternalDirMapping, IsIdempotentOnExistingSymlink)
+{
+ TempRoot tmp;
+ ASSERT_FALSE(tmp.path().empty());
+ const fs::path src = tmp.path() / "src";
+ const fs::path dst = tmp.path() / "dst";
+ fs::create_directories(src);
+
+ ASSERT_TRUE(deployDirectoryMapping(src, dst, true).dirSymlinkCreated);
+ ASSERT_TRUE(deployDirectoryMapping(src, dst, true).dirSymlinkCreated);
+ EXPECT_TRUE(fs::is_symlink(dst));
+ EXPECT_EQ(src, fs::read_symlink(dst));
+}
+
+// Empty pre-existing destination dir is safe to replace with a symlink —
+// happens when an earlier MO2 run created tracked dirs but no files.
+TEST(ExternalDirMapping, ReplacesEmptyDestinationDir)
+{
+ TempRoot tmp;
+ ASSERT_FALSE(tmp.path().empty());
+ const fs::path src = tmp.path() / "src";
+ const fs::path dst = tmp.path() / "dst";
+ fs::create_directories(src);
+ fs::create_directories(dst);
+ ASSERT_TRUE(fs::is_directory(dst));
+ ASSERT_FALSE(fs::is_symlink(dst));
+
+ auto result = deployDirectoryMapping(src, dst, true);
+ EXPECT_TRUE(result.dirSymlinkCreated);
+ EXPECT_TRUE(fs::is_symlink(dst));
+}
+
+// Safety contract: when the destination dir already has *real* files
+// (from the user's existing Skyrim install or an unrelated tool), we must
+// NEVER replace it with a symlink — that would orphan the user's data.
+// Fall back to per-file symlinks of source contents.
+TEST(ExternalDirMapping, FallsBackWhenDestinationHasRealFiles)
+{
+ TempRoot tmp;
+ ASSERT_FALSE(tmp.path().empty());
+ const fs::path src = tmp.path() / "src";
+ const fs::path dst = tmp.path() / "dst";
+ writeFile(src / "skse64.log", "from src");
+ writeFile(dst / "user-data.txt", "USER MUST KEEP");
+
+ auto result = deployDirectoryMapping(src, dst, /*createTarget=*/true);
+ EXPECT_FALSE(result.dirSymlinkCreated);
+ EXPECT_TRUE(result.fellBackToPerFile);
+ EXPECT_FALSE(fs::is_symlink(dst));
+ EXPECT_TRUE(fs::exists(dst / "user-data.txt"));
+
+ // Source files surfaced as symlinks under dst.
+ EXPECT_TRUE(fs::is_symlink(dst / "skse64.log"));
+ // User's own file stays put as a real file, not a symlink.
+ EXPECT_FALSE(fs::is_symlink(dst / "user-data.txt"));
+}
+
+// Cleanup contract: removing the recorded symlink (and its parent dir if
+// empty) leaves no leftover real files. This is what
+// FuseConnector::cleanupExternalMappings does — `fs::is_symlink` /
+// `fs::remove` work for directory symlinks too.
+TEST(ExternalDirMapping, CleanupRemovesDirSymlinkAndLeavesNoFiles)
+{
+ TempRoot tmp;
+ ASSERT_FALSE(tmp.path().empty());
+ const fs::path src = tmp.path() / "src";
+ const fs::path dst = tmp.path() / "dst";
+ writeFile(src / "skse64.log", "x");
+
+ auto result = deployDirectoryMapping(src, dst, true);
+ ASSERT_TRUE(result.dirSymlinkCreated);
+
+ // Game writes a brand new file post-deploy.
+ writeFile(dst / "new.log", "y");
+ EXPECT_TRUE(fs::exists(src / "new.log"));
+
+ // Simulate cleanup: cleanup loop calls is_symlink + remove.
+ std::error_code ec;
+ for (const auto& p : result.createdLinks) {
+ if (fs::is_symlink(p, ec)) {
+ fs::remove(p, ec);
+ }
+ }
+ EXPECT_FALSE(fs::exists(dst));
+ // Source survives — user's actual data is preserved on plugin disable.
+ EXPECT_TRUE(fs::exists(src / "skse64.log"));
+ EXPECT_TRUE(fs::exists(src / "new.log"));
+}
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"));
+}