Cockatrice/tests/font_config_test.cpp
BruebachL 3d24597134
[Fonts] Retire max font size setting into theme CardText min/max range (#7544)
* [Fonts] Retire max font size setting into theme CardText min/max range

The per-card text size lives in the theme now, so a theme can pick the size
it was designed for instead of every user overriding it globally.

- CardText stores family + max point size; CardTextMinSize stores the
  overlay shrink-to-fit floor (omitted from fonts.toml when 1).
- ThemeManager::cardTextMaxSize()/cardTextMinSize() resolve the range with
  the retired defaults (12 max, 1 min), and cardTextMaxSize() falls back to
  the CardText role size that #7541 made live.
- The overlay widget starts at the explicit size when one is set (deck
  preview), else the theme max, and shrinks down to the theme floor.
- The appearance page's "Maximum font size" spin is gone; the range is
  edited in the theme's font grid, whose min/max spins now keep the saved
  range consistent with each other.
- Migration: appearance/maxFontSize is seeded into every user theme folder
  and into the active theme's folder (created when the theme is a shipped
  one that only exists in the system directory). The retired
  game/maxfontsize -> appearance/maxFontSize mapping stays, so users still
  on global.ini keep their value through the chain.

* [Fonts] Resolve CardText sizes against the shipped theme

Seeding themes/<name>/fonts.toml made getAvailableThemes() resolve the user dir, which shadowed the shipped theme.cfg and stylesheet: styleName came back empty and QSS was cleared. Fall back to the shipped theme dir for theme.cfg and QSS when the user dir exists, and resolve CardText sizes through FontConfig's 6..72 range so a migrated 80..100 renders at the size the editor shows. Insert CardTextMaxSize inside [Fonts] when another section follows.

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
2026-11-09 14:02:59 +01:00

380 lines
13 KiB
C++

#include "interface/theme_config.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QFont>
#include <QIODevice>
#include <QString>
#include <QStringLiteral>
#include <QTemporaryDir>
#include <QTextStream>
#include <gtest/gtest.h>
#include <string>
namespace
{
// Writes content to a file inside tmp, creating parents as needed.
QString writeTempFile(QTemporaryDir &tmp, const QString &relativePath, const QString &content)
{
const QString filePath = QDir(tmp.path()).absoluteFilePath(relativePath);
EXPECT_TRUE(QDir().mkpath(QFileInfo(filePath).absolutePath()));
QFile file(filePath);
EXPECT_TRUE(file.open(QIODevice::WriteOnly | QIODevice::Text));
QTextStream out(&file);
out << content;
file.close();
return filePath;
}
QFont makeFont(const QString &family, int pointSize)
{
QFont font(family);
font.setPointSize(pointSize);
return font;
}
} // namespace
TEST(FontConfigTest, FileNameIsFontsToml)
{
EXPECT_EQ(FontConfig::fileName(), QStringLiteral("fonts.toml"));
}
TEST(FontConfigTest, EmptyByDefault)
{
FontConfig cfg;
EXPECT_FALSE(cfg.hasFonts());
EXPECT_TRUE(cfg.toToml().isEmpty());
EXPECT_EQ(cfg, FontConfig());
EXPECT_EQ(cfg, FontConfig::fromFile("/nonexistent/path/fonts.toml"));
EXPECT_EQ(cfg, FontConfig::fromThemeDir(QString()));
}
TEST(FontConfigTest, RoundTripPreservesFamilyAndSize)
{
QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
FontConfig cfg;
cfg.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 10);
cfg.fonts[AppFont::ChatLog] = makeFont("JetBrains Mono", 9);
cfg.fonts[AppFont::CardText] = makeFont("Noto Serif", 11);
cfg.fonts[AppFont::DeckList] = makeFont("Roboto", 12);
ASSERT_TRUE(cfg.hasFonts());
const QString filePath = writeTempFile(tmp, "fonts.toml", cfg.toToml());
const FontConfig loaded = FontConfig::fromFile(filePath);
EXPECT_EQ(loaded, cfg);
EXPECT_EQ(loaded.fonts[AppFont::InterfaceDefault].family(), QStringLiteral("Inter"));
EXPECT_EQ(loaded.fonts[AppFont::InterfaceDefault].pointSize(), 10);
EXPECT_EQ(loaded.fonts[AppFont::ChatLog].family(), QStringLiteral("JetBrains Mono"));
EXPECT_EQ(loaded.fonts[AppFont::ChatLog].pointSize(), 9);
EXPECT_EQ(loaded.fonts[AppFont::CardText].family(), QStringLiteral("Noto Serif"));
EXPECT_EQ(loaded.fonts[AppFont::CardText].pointSize(), 11);
EXPECT_EQ(loaded.fonts[AppFont::DeckList].family(), QStringLiteral("Roboto"));
EXPECT_EQ(loaded.fonts[AppFont::DeckList].pointSize(), 12);
}
TEST(FontConfigTest, RoundTripStripsWeightAndItalic)
{
QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
QFont styled("Inter");
styled.setPointSize(10);
styled.setBold(true);
styled.setItalic(true);
FontConfig cfg;
cfg.fonts[AppFont::ChatLog] = styled;
const QString filePath = writeTempFile(tmp, "fonts.toml", cfg.toToml());
const FontConfig loaded = FontConfig::fromFile(filePath);
ASSERT_TRUE(loaded.fonts.contains(AppFont::ChatLog));
EXPECT_EQ(loaded.fonts[AppFont::ChatLog].family(), QStringLiteral("Inter"));
EXPECT_EQ(loaded.fonts[AppFont::ChatLog].pointSize(), 10);
EXPECT_FALSE(loaded.fonts[AppFont::ChatLog].bold());
EXPECT_FALSE(loaded.fonts[AppFont::ChatLog].italic());
EXPECT_EQ(loaded.fonts[AppFont::ChatLog], makeFont("Inter", 10));
}
TEST(FontConfigTest, ToTomlFormat)
{
FontConfig cfg;
cfg.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 10);
const QString toml = cfg.toToml();
EXPECT_TRUE(toml.contains(QStringLiteral("[Fonts]")));
EXPECT_TRUE(toml.contains(QStringLiteral("InterfaceDefault")));
EXPECT_TRUE(toml.contains(QStringLiteral("\"Inter, 10\"")));
}
TEST(FontConfigTest, MalformedLinesAreSkipped)
{
QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
const QString filePath = writeTempFile(tmp, "fonts.toml",
"# a comment\n"
"; another comment\n"
"\n"
"StrayBeforeSection = \"Inter, 10\"\n"
"\n"
"[Other]\n"
"ChatLog = \"ShouldBeIgnored, 10\"\n"
"\n"
"[Fonts]\n"
"InterfaceDefault = \"Inter, 10\"\n"
"ChatLog = \"JetBrains Mono\", 9 # inline comment\n"
"CardText = Noto Serif, 11\n"
"AppFont::DeckList = \"Roboto, 12\"\n"
"UnknownRole = \"Inter, 10\"\n"
"QPalette::Window = \"Inter, 10\"\n"
"NoComma = \"Inter\"\n"
"EmptyFamily = \", 10\"\n"
"ZeroSize = \"Inter, 0\"\n"
"NegativeSize = \"Inter, -3\"\n"
"NotANumber = \"Inter, big\"\n"
"NotAnEntry\n");
const FontConfig loaded = FontConfig::fromFile(filePath);
ASSERT_TRUE(loaded.hasFonts());
EXPECT_EQ(loaded.fonts.size(), 4);
EXPECT_EQ(loaded.fonts[AppFont::InterfaceDefault], makeFont("Inter", 10));
EXPECT_EQ(loaded.fonts[AppFont::ChatLog], makeFont("JetBrains Mono", 9));
EXPECT_EQ(loaded.fonts[AppFont::CardText], makeFont("Noto Serif", 11));
EXPECT_EQ(loaded.fonts[AppFont::DeckList], makeFont("Roboto", 12));
}
TEST(FontConfigTest, FromThemeDirReadsFontsToml)
{
QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
const QString themeDir = QDir(tmp.path()).absoluteFilePath("MyTheme");
ASSERT_TRUE(QDir().mkpath(themeDir));
FontConfig cfg;
cfg.fonts[AppFont::CardText] = makeFont("Noto Serif", 11);
writeTempFile(tmp, "MyTheme/fonts.toml", cfg.toToml());
const FontConfig loaded = FontConfig::fromThemeDir(themeDir);
EXPECT_EQ(loaded, cfg);
// A theme directory without fonts.toml loads as empty.
const QString bareDir = QDir(tmp.path()).absoluteFilePath("BareTheme");
ASSERT_TRUE(QDir().mkpath(bareDir));
EXPECT_FALSE(FontConfig::fromThemeDir(bareDir).hasFonts());
}
TEST(FontConfigTest, InequalityComparesRoles)
{
FontConfig left;
left.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 10);
FontConfig right = left;
EXPECT_EQ(left, right);
EXPECT_FALSE(left != right);
right.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 11);
EXPECT_NE(left, right);
right = left;
right.fonts[AppFont::ChatLog] = makeFont("Inter", 10);
EXPECT_NE(left, right);
}
TEST(FontConfigTest, CardTextMinRoundTrip)
{
QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
FontConfig cfg;
cfg.fonts[AppFont::CardText] = makeFont("Noto Serif", 14);
cfg.cardTextMinSize = 3;
ASSERT_TRUE(cfg.hasFonts());
const QString filePath = writeTempFile(tmp, "fonts.toml", cfg.toToml());
const FontConfig loaded = FontConfig::fromFile(filePath);
EXPECT_EQ(loaded, cfg);
EXPECT_EQ(loaded.fonts[AppFont::CardText].pointSize(), 14);
EXPECT_EQ(loaded.cardTextMinSize, 3);
}
TEST(FontConfigTest, CardTextMinDefaultIsOmitted)
{
FontConfig cfg;
cfg.fonts[AppFont::CardText] = makeFont("Noto Serif", 12);
EXPECT_EQ(cfg.cardTextMinSize, 1);
const QString toml = cfg.toToml();
EXPECT_TRUE(toml.contains(QStringLiteral("CardText")));
EXPECT_FALSE(toml.contains(QStringLiteral("CardTextMinSize")));
QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
const QString filePath = writeTempFile(tmp, "fonts.toml", toml);
EXPECT_EQ(FontConfig::fromFile(filePath).cardTextMinSize, 1);
}
TEST(FontConfigTest, MalformedMinIsSkipped)
{
QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
const QString filePath = writeTempFile(tmp, "fonts.toml",
"[Fonts]\n"
"CardText = \"Noto Serif, 14\"\n"
"CardTextMinSize = big\n");
// Each malformed value parses independently, so rewrite the file per case.
const QStringList badValues = {"big", "0", "-3", "", "Inter, 10"};
for (const QString &bad : badValues) {
const QString badPath = writeTempFile(
tmp, "bad.toml", QString("[Fonts]\nCardText = \"Noto Serif, 14\"\nCardTextMinSize = %1\n").arg(bad));
const FontConfig loaded = FontConfig::fromFile(badPath);
EXPECT_EQ(loaded.cardTextMinSize, 1) << "bad value: " << bad.toStdString();
ASSERT_TRUE(loaded.fonts.contains(AppFont::CardText));
EXPECT_EQ(loaded.fonts[AppFont::CardText].pointSize(), 14);
}
// The valid file still loads.
EXPECT_EQ(FontConfig::fromFile(filePath).cardTextMinSize, 1);
}
TEST(FontConfigTest, CardTextMaxRoundTrip)
{
QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
FontConfig cfg;
cfg.fonts[AppFont::CardText] = makeFont("Noto Serif", 14);
cfg.cardTextMaxSize = 18;
ASSERT_TRUE(cfg.hasFonts());
const QString filePath = writeTempFile(tmp, "fonts.toml", cfg.toToml());
const FontConfig loaded = FontConfig::fromFile(filePath);
EXPECT_EQ(loaded, cfg);
EXPECT_EQ(loaded.cardTextMaxSize, 18);
EXPECT_EQ(loaded.fonts[AppFont::CardText].pointSize(), 14);
}
TEST(FontConfigTest, CardTextMaxSizeWithoutFamily)
{
// The retired maxFontSize setting migrates to a size-only key, so the
// family keeps its existing resolution instead of being pinned to Serif.
FontConfig cfg;
cfg.cardTextMaxSize = 18;
ASSERT_TRUE(cfg.hasFonts());
QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
const QString filePath = writeTempFile(tmp, "fonts.toml", cfg.toToml());
const FontConfig loaded = FontConfig::fromFile(filePath);
EXPECT_TRUE(loaded.fonts.isEmpty());
EXPECT_EQ(loaded.cardTextMaxSize, 18);
EXPECT_EQ(loaded, cfg);
}
TEST(FontConfigTest, MigratedCardTextMaxResolvesIntoTheEditorsRange)
{
// appearance/maxFontSize came from a control that allowed values above 72
// and the migration seeds it verbatim, so fonts.toml can hold a size the
// font editor cannot show. 72 is what the editor displays and would save,
// so the board has to draw 72 too instead of rendering 100 until the next
// save silently rewrites the theme.
QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
const QString filePath = writeTempFile(tmp, "fonts.toml", "[Fonts]\nCardTextMaxSize = 100\n");
const FontConfig migrated = FontConfig::fromFile(filePath);
ASSERT_EQ(migrated.cardTextMaxSize, 100);
EXPECT_EQ(migrated.resolvedCardTextMaxSize(), 72);
}
TEST(FontConfigTest, CardTextSizesResolveIntoTheEditorsRange)
{
// The same bound applies when the size comes from the CardText family, a
// stored size below the editor's control floor rises to it, and the max is
// never below the resolved shrink-to-fit floor.
FontConfig fromFamily;
fromFamily.fonts[AppFont::CardText] = makeFont("Noto Serif", 100);
EXPECT_EQ(fromFamily.resolvedCardTextMaxSize(), 72);
FontConfig belowFloor;
belowFloor.cardTextMaxSize = 3;
EXPECT_EQ(belowFloor.resolvedCardTextMaxSize(), 6);
FontConfig narrow;
narrow.cardTextMinSize = 20;
narrow.cardTextMaxSize = 12;
EXPECT_EQ(narrow.resolvedCardTextMinSize(), 20);
EXPECT_EQ(narrow.resolvedCardTextMaxSize(), 20);
}
TEST(FontConfigTest, CardTextMaxDefaultIsOmitted)
{
FontConfig cfg;
cfg.fonts[AppFont::CardText] = makeFont("Noto Serif", 12);
EXPECT_EQ(cfg.cardTextMaxSize, 0);
const QString toml = cfg.toToml();
EXPECT_FALSE(toml.contains(QStringLiteral("CardTextMaxSize")));
QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
const QString filePath = writeTempFile(tmp, "fonts.toml", toml);
EXPECT_EQ(FontConfig::fromFile(filePath).cardTextMaxSize, 0);
}
TEST(FontConfigTest, MalformedMaxIsSkipped)
{
QTemporaryDir tmp;
ASSERT_TRUE(tmp.isValid());
const QStringList badValues = {"big", "0", "-3", "", "Inter, 18"};
for (const QString &bad : badValues) {
const QString badPath = writeTempFile(
tmp, "bad.toml", QString("[Fonts]\nCardText = \"Noto Serif, 14\"\nCardTextMaxSize = %1\n").arg(bad));
const FontConfig loaded = FontConfig::fromFile(badPath);
EXPECT_EQ(loaded.cardTextMaxSize, 0) << "bad value: " << bad.toStdString();
ASSERT_TRUE(loaded.fonts.contains(AppFont::CardText));
EXPECT_EQ(loaded.fonts[AppFont::CardText].pointSize(), 14);
}
}
TEST(FontConfigTest, InequalityComparesMinSize)
{
FontConfig left;
left.fonts[AppFont::CardText] = makeFont("Noto Serif", 12);
left.cardTextMinSize = 1;
FontConfig right = left;
right.cardTextMinSize = 2;
EXPECT_NE(left, right);
}
TEST(FontConfigTest, InequalityComparesMaxSize)
{
FontConfig left;
left.fonts[AppFont::CardText] = makeFont("Noto Serif", 12);
left.cardTextMaxSize = 12;
FontConfig right = left;
right.cardTextMaxSize = 18;
EXPECT_NE(left, right);
}
// Every test brings its own main(): GTest is not guaranteed to be found on the build machines, and
// the fallback then links the bare gtest library without gtest_main.
int main(int argc, char **argv)
{
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}