aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSulfurNitride <SulfurNitride@users.noreply.github.com>2026-05-01 23:12:44 -0500
committerSulfurNitride <SulfurNitride@users.noreply.github.com>2026-05-01 23:12:44 -0500
commit4a15d47e96ff5870bb9a1c2f52a7c41e4bf4b63b (patch)
treee56d8c9e6eb4752175a0f1ec5864107a63554f2a
parent1f198921b2c1f23f132d25552cacb650af253d15 (diff)
meta.ini: revert to upstream CamelCase, canonicalize on read
Restore upstream MO2's exact key cases (gameName, installationFile, nexusDescription, etc.) in readMeta/saveMeta/doInstall/createMod — matches mod-shipped meta.ini cases, so on Linux Qt6's case-sensitive QSettings IniFormat, setValue updates in place without producing a duplicate key. normalizeMetaIniCase() repurposed: instead of folding everything to lowercase (which mismatched the upstream-style setValue calls and caused the very dupes it was trying to fix), it now canonicalizes known per-mod meta.ini keys to their upstream CamelCase, deduping case-insensitive collisions per section. Cleans up dirty files written during the 5d1fb29 -> 1f19892 window so they collapse to a single canonical key per setting on first read. Tests updated to assert canonical-CamelCase output. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
-rw-r--r--src/src/installationmanager.cpp12
-rw-r--r--src/src/metainiutils.cpp71
-rw-r--r--src/src/metainiutils.h20
-rw-r--r--src/src/modinforegular.cpp52
-rw-r--r--src/src/organizercore.cpp4
-rw-r--r--src/tests/test_metainiutils.cpp148
6 files changed, 173 insertions, 134 deletions
diff --git a/src/src/installationmanager.cpp b/src/src/installationmanager.cpp
index 09e2a36..3e58a2a 100644
--- a/src/src/installationmanager.cpp
+++ b/src/src/installationmanager.cpp
@@ -549,8 +549,8 @@ InstallationResult InstallationManager::doInstall(GuessedValue<QString>& modName
// overwrite settings only if they are actually are available or haven't been set
// before
- if ((gameName != "") || !settingsFile.contains("gamename")) {
- settingsFile.setValue("gamename", gameName);
+ if ((gameName != "") || !settingsFile.contains("gameName")) {
+ settingsFile.setValue("gameName", gameName);
}
if ((modID != 0) || !settingsFile.contains("modid")) {
settingsFile.setValue("modid", modID);
@@ -561,15 +561,15 @@ InstallationResult InstallationManager::doInstall(GuessedValue<QString>& modName
VersionInfo(settingsFile.value("version").toString()))))) {
settingsFile.setValue("version", version);
}
- if (!newestVersion.isEmpty() || !settingsFile.contains("newestversion")) {
- settingsFile.setValue("newestversion", newestVersion);
+ if (!newestVersion.isEmpty() || !settingsFile.contains("newestVersion")) {
+ settingsFile.setValue("newestVersion", newestVersion);
}
// issue #51 used to overwrite the manually set categories
if (!settingsFile.contains("category")) {
settingsFile.setValue("category", QString::number(categoryID));
}
- settingsFile.setValue("nexusfilestatus", fileCategoryID);
- settingsFile.setValue("installationfile", storeMetaPath(m_CurrentFile));
+ settingsFile.setValue("nexusFileStatus", fileCategoryID);
+ settingsFile.setValue("installationFile", storeMetaPath(m_CurrentFile));
settingsFile.setValue("repository", repository);
if (!merge) {
diff --git a/src/src/metainiutils.cpp b/src/src/metainiutils.cpp
index 7af170f..4ba38b6 100644
--- a/src/src/metainiutils.cpp
+++ b/src/src/metainiutils.cpp
@@ -24,6 +24,54 @@ bool endsInOddBackslashes(const QByteArray& line)
return (n % 2) == 1;
}
+// Canonical case for keys that appear in per-mod meta.ini, taken from
+// upstream MO2's setValue/value calls in modinforegular.cpp,
+// installationmanager.cpp, and organizercore.cpp.
+const QHash<QByteArray, QByteArray>& canonicalKeyMap()
+{
+ static const QHash<QByteArray, QByteArray> map = {
+ {"author", "author"},
+ {"category", "category"},
+ {"color", "color"},
+ {"comments", "comments"},
+ {"converted", "converted"},
+ {"endorsed", "endorsed"},
+ {"fileid", "fileid"},
+ {"gamename", "gameName"},
+ {"hascustomurl", "hasCustomURL"},
+ {"ignoredversion", "ignoredVersion"},
+ {"installationfile", "installationFile"},
+ {"lastnexusquery", "lastNexusQuery"},
+ {"lastnexusupdate", "lastNexusUpdate"},
+ {"modid", "modid"},
+ {"newestversion", "newestVersion"},
+ {"nexuscategory", "nexusCategory"},
+ {"nexusdescription", "nexusDescription"},
+ {"nexusfilestatus", "nexusFileStatus"},
+ {"nexuslastmodified", "nexusLastModified"},
+ {"notes", "notes"},
+ {"repository", "repository"},
+ {"tracked", "tracked"},
+ {"uploader", "uploader"},
+ {"uploaderurl", "uploaderUrl"},
+ {"url", "url"},
+ {"validated", "validated"},
+ {"version", "version"},
+ };
+ return map;
+}
+
+// Returns the canonical case for `key` if known, otherwise `key` unchanged.
+QByteArray canonicalize(const QByteArray& key)
+{
+ const auto& map = canonicalKeyMap();
+ const auto it = map.find(key.toLower());
+ if (it != map.end()) {
+ return it.value();
+ }
+ return key;
+}
+
} // namespace
bool normalizeMetaIniCase(const QString& path)
@@ -52,7 +100,6 @@ bool normalizeMetaIniCase(const QString& path)
struct Entry
{
bool isKey = false;
- QByteArray key; // lowercased
QByteArray rawLine; // for non-key lines
QByteArray fullKeyLine; // for key lines, possibly multi-line via \\\n
};
@@ -61,7 +108,7 @@ bool normalizeMetaIniCase(const QString& path)
{
QByteArray header; // [Name] line, empty for the implicit pre-header section
QList<Entry> entries;
- QHash<QByteArray, qsizetype> indexByKey;
+ QHash<QByteArray, qsizetype> indexByLowerKey;
};
QList<Section> sections;
@@ -104,21 +151,21 @@ bool normalizeMetaIniCase(const QString& path)
full.append(rawLines[i]);
}
- const int fullEq = full.indexOf('=');
- const QByteArray rawKey = full.left(fullEq).trimmed();
- const QByteArray valuePart = full.mid(fullEq); // includes '='
- const QByteArray lowerKey = rawKey.toLower();
- if (rawKey != lowerKey) {
+ const int fullEq = full.indexOf('=');
+ const QByteArray rawKey = full.left(fullEq).trimmed();
+ const QByteArray valuePart = full.mid(fullEq); // includes '='
+ const QByteArray lowerKey = rawKey.toLower();
+ const QByteArray canonicalKey = canonicalize(rawKey);
+ if (rawKey != canonicalKey) {
changed = true;
}
Entry e;
e.isKey = true;
- e.key = lowerKey;
- e.fullKeyLine = lowerKey + valuePart;
+ e.fullKeyLine = canonicalKey + valuePart;
- auto it = cur.indexByKey.find(lowerKey);
- if (it != cur.indexByKey.end()) {
+ auto it = cur.indexByLowerKey.find(lowerKey);
+ if (it != cur.indexByLowerKey.end()) {
Entry& prev = cur.entries[it.value()];
const QByteArray newVal =
valuePart.size() > 1 ? valuePart.mid(1).trimmed() : QByteArray();
@@ -135,7 +182,7 @@ bool normalizeMetaIniCase(const QString& path)
changed = true;
} else {
cur.entries.push_back(std::move(e));
- cur.indexByKey.insert(lowerKey, cur.entries.size() - 1);
+ cur.indexByLowerKey.insert(lowerKey, cur.entries.size() - 1);
}
}
sections.push_back(std::move(cur));
diff --git a/src/src/metainiutils.h b/src/src/metainiutils.h
index c043742..0086747 100644
--- a/src/src/metainiutils.h
+++ b/src/src/metainiutils.h
@@ -6,17 +6,19 @@
namespace MetaIniUtils
{
-// Pre-normalize a Qt INI file so QSettings (case-sensitive in IniFormat on
-// Linux/Qt6) does not produce duplicate-cased keys when `setValue("foo", x)`
-// is invoked on a file already containing `Foo=...`.
+// Canonicalize the keys in a per-mod meta.ini to the case used by upstream
+// MO2's setValue/value calls (e.g. "gameName", "installationFile").
//
-// Lowercases all keys, deduplicates per-section (keeping the latest
-// non-empty value), and preserves comments, blank lines, and ordering of
-// surviving keys. Multi-line values (trailing `\` continuations) are
-// preserved as a single logical entry.
+// Qt6's QSettings IniFormat is case-sensitive on Linux, so a meta.ini
+// containing both "gameName=" and "gamename=" surfaces as two distinct
+// keys. This helper folds case-insensitively-equal keys to a single entry
+// using the canonical CamelCase, keeping the non-empty value when one is
+// blank, otherwise the later occurrence wins.
//
-// No-op if the file does not exist or has no case mismatches / duplicates.
-// Returns true if the file was modified.
+// Unknown keys keep their original case and are still deduped.
+//
+// No-op if the file does not exist, is empty, or contains no case
+// mismatches / duplicates. Returns true if the file was modified.
bool normalizeMetaIniCase(const QString& path);
} // namespace MetaIniUtils
diff --git a/src/src/modinforegular.cpp b/src/src/modinforegular.cpp
index 3978f2e..c59706d 100644
--- a/src/src/modinforegular.cpp
+++ b/src/src/modinforegular.cpp
@@ -113,21 +113,21 @@ void ModInfoRegular::readMeta()
QSettings metaFile(metaPath, QSettings::IniFormat);
m_Comments = metaFile.value("comments", "").toString();
m_Notes = metaFile.value("notes", "").toString();
- QString const tempGameName = metaFile.value("gamename", m_GameName).toString();
+ QString const tempGameName = metaFile.value("gameName", m_GameName).toString();
if (tempGameName.size())
m_GameName = tempGameName;
m_NexusID = metaFile.value("modid", -1).toInt();
m_Version.parse(metaFile.value("version", "").toString());
- m_NewestVersion = metaFile.value("newestversion", "").toString();
- m_IgnoredVersion = metaFile.value("ignoredversion", "").toString();
+ m_NewestVersion = metaFile.value("newestVersion", "").toString();
+ m_IgnoredVersion = metaFile.value("ignoredVersion", "").toString();
m_InstallationFile =
- loadMetaPath(metaFile.value("installationfile", "").toString());
- m_NexusDescription = metaFile.value("nexusdescription", "").toString();
- m_NexusFileStatus = metaFile.value("nexusfilestatus", "1").toInt();
- m_NexusCategory = metaFile.value("nexuscategory", 0).toInt();
+ loadMetaPath(metaFile.value("installationFile", "").toString());
+ m_NexusDescription = metaFile.value("nexusDescription", "").toString();
+ m_NexusFileStatus = metaFile.value("nexusFileStatus", "1").toInt();
+ m_NexusCategory = metaFile.value("nexusCategory", 0).toInt();
m_Author = metaFile.value("author", "").toString();
m_Uploader = metaFile.value("uploader", "").toString();
- m_UploaderUrl = metaFile.value("uploaderurl", "").toString();
+ m_UploaderUrl = metaFile.value("uploaderUrl", "").toString();
m_Repository = metaFile.value("repository", "Nexus").toString();
m_Converted = metaFile.value("converted", false).toBool();
m_Validated = metaFile.value("validated", false).toBool();
@@ -181,8 +181,8 @@ void ModInfoRegular::readMeta()
// always read the url
m_CustomURL = metaFile.value("url").toString();
- if (metaFile.contains("hascustomurl")) {
- m_HasCustomURL = metaFile.value("hascustomurl").toBool();
+ if (metaFile.contains("hasCustomURL")) {
+ m_HasCustomURL = metaFile.value("hasCustomURL").toBool();
} else {
if (m_NexusID > 0) {
// the mod id is valid, disable the custom url
@@ -196,13 +196,13 @@ void ModInfoRegular::readMeta()
}
m_LastNexusQuery = QDateTime::fromString(
- metaFile.value("lastnexusquery", "").toString(), Qt::ISODate);
+ metaFile.value("lastNexusQuery", "").toString(), Qt::ISODate);
m_LastNexusUpdate = QDateTime::fromString(
- metaFile.value("lastnexusupdate", "").toString(), Qt::ISODate);
+ metaFile.value("lastNexusUpdate", "").toString(), Qt::ISODate);
m_NexusLastModified = QDateTime::fromString(
- metaFile.value("nexuslastmodified", QDateTime::currentDateTimeUtc()).toString(),
+ metaFile.value("nexusLastModified", QDateTime::currentDateTimeUtc()).toString(),
Qt::ISODate);
- m_NexusCategory = metaFile.value("nexuscategory", 0).toInt();
+ m_NexusCategory = metaFile.value("nexusCategory", 0).toInt();
m_Color = metaFile.value("color", QColor()).value<QColor>();
m_TrackedState = metaFile.value("tracked", false).toBool()
? TrackedState::TRACKED_TRUE
@@ -285,26 +285,26 @@ void ModInfoRegular::saveMeta()
temp.erase(m_PrimaryCategory);
metaFile.setValue("category", QString("%1").arg(m_PrimaryCategory) + "," +
SetJoin(temp, ","));
- metaFile.setValue("newestversion", m_NewestVersion.canonicalString());
- metaFile.setValue("ignoredversion", m_IgnoredVersion.canonicalString());
+ metaFile.setValue("newestVersion", m_NewestVersion.canonicalString());
+ metaFile.setValue("ignoredVersion", m_IgnoredVersion.canonicalString());
metaFile.setValue("version", m_Version.canonicalString());
- metaFile.setValue("installationfile", storeMetaPath(m_InstallationFile));
+ metaFile.setValue("installationFile", storeMetaPath(m_InstallationFile));
metaFile.setValue("repository", m_Repository);
- metaFile.setValue("gamename", m_GameName);
+ metaFile.setValue("gameName", m_GameName);
metaFile.setValue("modid", m_NexusID);
metaFile.setValue("comments", m_Comments);
metaFile.setValue("notes", m_Notes);
- metaFile.setValue("nexusdescription", m_NexusDescription);
+ metaFile.setValue("nexusDescription", m_NexusDescription);
metaFile.setValue("url", m_CustomURL);
- metaFile.setValue("hascustomurl", m_HasCustomURL);
- metaFile.setValue("nexusfilestatus", m_NexusFileStatus);
- metaFile.setValue("lastnexusquery", m_LastNexusQuery.toString(Qt::ISODate));
- metaFile.setValue("lastnexusupdate", m_LastNexusUpdate.toString(Qt::ISODate));
- metaFile.setValue("nexuslastmodified", m_NexusLastModified.toString(Qt::ISODate));
- metaFile.setValue("nexuscategory", m_NexusCategory);
+ metaFile.setValue("hasCustomURL", m_HasCustomURL);
+ metaFile.setValue("nexusFileStatus", m_NexusFileStatus);
+ metaFile.setValue("lastNexusQuery", m_LastNexusQuery.toString(Qt::ISODate));
+ metaFile.setValue("lastNexusUpdate", m_LastNexusUpdate.toString(Qt::ISODate));
+ metaFile.setValue("nexusLastModified", m_NexusLastModified.toString(Qt::ISODate));
+ metaFile.setValue("nexusCategory", m_NexusCategory);
metaFile.setValue("author", m_Author);
metaFile.setValue("uploader", m_Uploader);
- metaFile.setValue("uploaderurl", m_UploaderUrl);
+ metaFile.setValue("uploaderUrl", m_UploaderUrl);
metaFile.setValue("converted", m_Converted);
metaFile.setValue("validated", m_Validated);
metaFile.setValue("color", m_Color);
diff --git a/src/src/organizercore.cpp b/src/src/organizercore.cpp
index bc28969..8b3171e 100644
--- a/src/src/organizercore.cpp
+++ b/src/src/organizercore.cpp
@@ -964,9 +964,9 @@ MOBase::IModInterface* OrganizerCore::createMod(GuessedValue<QString>& name)
if (!result.merged()) {
settingsFile.setValue("modid", 0);
settingsFile.setValue("version", "");
- settingsFile.setValue("newestversion", "");
+ settingsFile.setValue("newestVersion", "");
settingsFile.setValue("category", 0);
- settingsFile.setValue("installationfile", "");
+ settingsFile.setValue("installationFile", "");
settingsFile.remove("installedFiles");
settingsFile.beginWriteArray("installedFiles", 0);
diff --git a/src/tests/test_metainiutils.cpp b/src/tests/test_metainiutils.cpp
index aef5998..faec068 100644
--- a/src/tests/test_metainiutils.cpp
+++ b/src/tests/test_metainiutils.cpp
@@ -40,96 +40,99 @@ TEST(MetaIniUtils, NoOpOnMissingFile)
EXPECT_FALSE(MetaIniUtils::normalizeMetaIniCase(path));
}
-// Sanity: file with only lowercase keys is left untouched.
-TEST(MetaIniUtils, NoOpOnAlreadyLowercase)
+// Sanity: file with only canonical-case keys is left untouched.
+TEST(MetaIniUtils, NoOpOnAlreadyCanonical)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const auto* contents = "[General]\n"
"author=Aether\n"
- "category=\"11,\"\n";
+ "category=\"11,\"\n"
+ "gameName=Skyrim\n"
+ "installationFile=Foo.7z\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)
+// The reported bug: pre-existing CamelCase keys + a lowercased duplicate
+// (left over from the 5d1fb29 era) coexist on Linux/Qt6. Normalize must fold
+// them back to the upstream MO2 case.
+TEST(MetaIniUtils, FoldsLowercaseDuplicatesToUpstreamCase)
{
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.
+ // Mirrors the dirty meta.ini in the bug report: CamelCase keys with
+ // empty/default values from the broken saveMeta(), and lowercase keys
+ // carrying the real preserved values.
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";
+ "gameName=SkyrimSE\n"
+ "gamename=Skyrim\n"
+ "installationFile=\n"
+ "installationfile=Sweet Mother HD-4947-2-0.7z\n"
+ "newestVersion=\n"
+ "newestversion=2.0.0.0\n"
+ "lastNexusQuery=2026-05-01T17:28:22Z\n"
+ "lastnexusquery=2026-05-01T17:27:42Z\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.
+ // Open via QSettings using the upstream CamelCase keys.
QSettings s(path, QSettings::IniFormat);
- EXPECT_EQ(QStringList({"author", "category", "color", "comments", "converted"}),
- s.allKeys());
+ // Each logical setting collapses to a single canonical-case key.
+ QStringList keys = s.allKeys();
+ std::sort(keys.begin(), keys.end());
+ EXPECT_EQ(QStringList({"gameName", "installationFile", "lastNexusQuery",
+ "newestVersion"}),
+ keys);
- // 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());
+ // Empty CamelCase line dropped in favor of the non-empty lowercase value.
+ EXPECT_EQ("Sweet Mother HD-4947-2-0.7z",
+ s.value("installationFile").toString());
+ EXPECT_EQ("2.0.0.0", s.value("newestVersion").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());
+ // Both halves were non-empty for gameName and lastNexusQuery — last writer
+ // wins, mirroring saveMeta()'s order-of-writes.
+ EXPECT_EQ("Skyrim", s.value("gameName").toString());
+ EXPECT_EQ("2026-05-01T17:27:42Z", s.value("lastNexusQuery").toString());
- // 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="));
+ EXPECT_FALSE(after.contains("gamename="));
+ EXPECT_FALSE(after.contains("installationfile="));
+ EXPECT_FALSE(after.contains("newestversion="));
+ EXPECT_FALSE(after.contains("lastnexusquery="));
}
// 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.
+// continuation. Continuation lines must travel with their key when the key
+// is canonicalized, otherwise the value gets corrupted.
TEST(MetaIniUtils, PreservesLineContinuations)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const auto* contents = "[General]\n"
- "NotesField=line one\\\n"
+ "nexusdescription=line one\\\n"
" continued line two\n"
- "Author=Aether\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("nexusDescription=line one\\\n"));
EXPECT_TRUE(after.contains(" continued line two"));
- EXPECT_FALSE(after.contains("NotesField="));
+ EXPECT_FALSE(after.contains("nexusdescription="));
}
// 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].
+// logic must scope per-section — a key under [General] must not collide
+// with the same lowercase key under [Plugins\Whatever].
TEST(MetaIniUtils, DedupesPerSection)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
const auto* contents = "[General]\n"
- "Foo=top\n"
+ "gameName=Skyrim\n"
"[Plugins\\Sample]\n"
"Foo=plugin\n"
"foo=plugin-dup\n";
@@ -137,54 +140,41 @@ TEST(MetaIniUtils, DedupesPerSection)
EXPECT_TRUE(MetaIniUtils::normalizeMetaIniCase(path));
QSettings s(path, QSettings::IniFormat);
- EXPECT_EQ("top", s.value("foo").toString());
+ EXPECT_EQ("Skyrim", s.value("gameName").toString());
s.beginGroup("Plugins/Sample");
- EXPECT_EQ("plugin-dup", s.value("foo").toString());
+ // Unknown plugin key keeps its first-seen case, last-writer wins on value.
+ 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)
+// End-to-end: an upstream-style saveMeta sequence over a normalized file
+// updates in place — no second case-mismatched key appears.
+TEST(MetaIniUtils, UpstreamSaveMetaProducesNoDupes)
{
QTemporaryDir dir;
ASSERT_TRUE(dir.isValid());
- // Pre-existing meta.ini in CamelCase (e.g. shipped inside the mod
- // archive, or migrated from MO2 Windows).
+ // Dirty file from the 5d1fb29 era — CamelCase + lowercase coexist.
const auto* preexisting = "[General]\n"
- "Author=Aether\n"
- "Category=11\n"
- "Comments=keep me\n";
+ "gameName=SkyrimSE\n"
+ "gamename=Skyrim\n"
+ "installationFile=\n"
+ "installationfile=Foo.7z\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.
+ // Normalize folds lowercase dupes back into the upstream CamelCase keys.
EXPECT_TRUE(MetaIniUtils::normalizeMetaIniCase(path));
+
+ // Upstream-style saveMeta: CamelCase setValue on every key.
{
QSettings s(path, QSettings::IniFormat);
- s.setValue("author", "Aether");
- s.setValue("category", "11,");
+ s.setValue("gameName", s.value("gameName"));
+ s.setValue("installationFile", s.value("installationFile"));
}
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"));
+ EXPECT_TRUE(clean.contains("gameName=Skyrim"));
+ EXPECT_TRUE(clean.contains("installationFile=Foo.7z"));
+ // No lowercase dupes resurrected.
+ EXPECT_FALSE(clean.contains("gamename="));
+ EXPECT_FALSE(clean.contains("installationfile="));
}