#include #include #include #include #include #include #include #include #ifdef USE_SDL3 #include #include #else #include #endif #include #include #include "engine/load_file.hpp" #include "engine/palette.h" #include "engine/point.hpp" #include "engine/rectangle.hpp" #include "engine/render/primitive_render.hpp" #include "engine/render/text_render.hpp" #include "engine/size.hpp" #include "engine/surface.hpp" #include "utils/paths.h" #include "utils/png.h" #include "utils/sdl_compat.h" #include "utils/sdl_wrap.h" #include "utils/str_cat.hpp" #include "utils/surface_to_png.hpp" // Invoke with --update_expected to update the expected files with actual results. static bool UpdateExpected; namespace devilution { namespace { constexpr char FixturesPath[] = "test/fixtures/text_render_integration_test/"; struct TestFixture { std::string name; int width; int height; std::string_view fmt; std::vector args {}; TextRenderOptions opts { .flags = UiFlags::ColorUiGold }; friend void PrintTo(const TestFixture &f, std::ostream *os) { *os << f.name; } }; const TestFixture Fixtures[] { TestFixture { .name = "basic", .width = 96, .height = 15, .fmt = "DrawString", }, TestFixture { .name = "basic-colors", .width = 186, .height = 15, .fmt = "{}{}{}{}", .args = { { "Draw", UiFlags::ColorUiSilver }, { "String", UiFlags::ColorUiGold }, { "With", UiFlags::ColorUiSilverDark }, { "Colors", UiFlags::ColorUiGoldDark }, }, }, TestFixture { .name = "horizontal_overflow", .width = 50, .height = 28, .fmt = "Horizontal", }, TestFixture { .name = "horizontal_overflow-colors", .width = 50, .height = 28, .fmt = "{}{}", .args = { { "Hori", UiFlags::ColorUiGold }, { "zontal", UiFlags::ColorUiSilverDark }, }, }, TestFixture { .name = "kerning_fit_spacing", .width = 120, .height = 15, .fmt = "KerningFitSpacing", .opts = { .flags = UiFlags::KerningFitSpacing | UiFlags::ColorUiSilver, }, }, TestFixture { .name = "kerning_fit_spacing-colors", .width = 120, .height = 15, .fmt = "{}{}{}", .args = { { "Kerning", UiFlags::ColorUiSilver }, { "Fit", UiFlags::ColorUiGold }, { "Spacing", UiFlags::ColorUiSilverDark }, }, .opts = { .flags = UiFlags::KerningFitSpacing, }, }, TestFixture { .name = "kerning_fit_spacing__align_center", .width = 170, .height = 15, .fmt = "KerningFitSpacing | AlignCenter", .opts = { .flags = UiFlags::KerningFitSpacing | UiFlags::AlignCenter | UiFlags::ColorUiSilver, }, }, TestFixture { .name = "kerning_fit_spacing__align_center-colors", .width = 170, .height = 15, .fmt = "{}{}{}", .args = { { "KerningFitSpacing", UiFlags::ColorUiSilver }, { " | ", UiFlags::ColorUiGold }, { "AlignCenter", UiFlags::ColorUiSilverDark }, }, .opts = { .flags = UiFlags::KerningFitSpacing | UiFlags::AlignCenter, }, }, TestFixture { .name = "kerning_fit_spacing__align_center__newlines", .width = 170, .height = 42, .fmt = "KerningFitSpacing | AlignCenter\nShort line\nAnother overly long line", .opts = { .flags = UiFlags::KerningFitSpacing | UiFlags::AlignCenter | UiFlags::ColorUiSilver, }, }, TestFixture { .name = "kerning_fit_spacing__align_center__newlines_in_fmt-colors", .width = 170, .height = 42, .fmt = "{}\n{}\n{}", .args = { { "KerningFitSpacing | AlignCenter", UiFlags::ColorUiSilver }, { "Short line", UiFlags::ColorUiGold }, { "Another overly long line", UiFlags::ColorUiSilverDark }, }, .opts = { .flags = UiFlags::KerningFitSpacing | UiFlags::AlignCenter, }, }, TestFixture { .name = "kerning_fit_spacing__align_center__newlines_in_value-colors", .width = 170, .height = 42, .fmt = "{}{}", .args = { { "KerningFitSpacing | AlignCenter\nShort line\nAnother overly ", UiFlags::ColorUiSilver }, { "long line", UiFlags::ColorUiGold }, }, .opts = { .flags = UiFlags::KerningFitSpacing | UiFlags::AlignCenter, }, }, TestFixture { .name = "kerning_fit_spacing__align_right", .width = 170, .height = 15, .fmt = "KerningFitSpacing | AlignRight", .opts = { .flags = UiFlags::KerningFitSpacing | UiFlags::AlignRight | UiFlags::ColorUiSilver, }, }, TestFixture { .name = "kerning_fit_spacing__align_right-colors", .width = 170, .height = 15, .fmt = "{}{}{}", .args = { { "KerningFitSpacing", UiFlags::ColorUiSilver }, { " | ", UiFlags::ColorUiGold }, { "AlignRight", UiFlags::ColorUiSilverDark }, }, .opts = { .flags = UiFlags::KerningFitSpacing | UiFlags::AlignRight, }, }, TestFixture { .name = "vertical_overflow", .width = 36, .height = 20, .fmt = "One\nTwo", }, TestFixture { .name = "vertical_overflow-colors", .width = 36, .height = 20, .fmt = "{}\n{}", .args = { { "One", UiFlags::ColorUiGold }, { "Two", UiFlags::ColorUiSilverDark }, }, }, TestFixture { .name = "cursor-start", .width = 120, .height = 15, .fmt = "Hello World", .opts = { .flags = UiFlags::ColorUiGold, .cursorPosition = 0, // Cursor at start .cursorStatic = true, }, }, TestFixture { .name = "cursor-middle", .width = 120, .height = 15, .fmt = "Hello World", .opts = { .flags = UiFlags::ColorUiGold, .cursorPosition = 5, // Cursor after "Hello", .cursorStatic = true, }, }, TestFixture { .name = "cursor-end", .width = 120, .height = 15, .fmt = "Hello World", .opts = { .flags = UiFlags::ColorUiGold, .cursorPosition = 11, // Cursor at end .cursorStatic = true, }, }, TestFixture { .name = "multiline_cursor-end_first_line", .width = 100, .height = 50, .fmt = "First line\nSecond line", .opts = { .flags = UiFlags::ColorUiGold, .cursorPosition = 10, // Cursor at end of first line .cursorStatic = true, }, }, TestFixture { .name = "multiline_cursor-start_second_line", .width = 100, .height = 50, .fmt = "First line\nSecond line", .opts = { .flags = UiFlags::ColorUiGold, .cursorPosition = 11, // Cursor at start of second line .cursorStatic = true, }, }, TestFixture { .name = "multiline_cursor-middle_second_line", .width = 100, .height = 50, .fmt = "First line\nSecond line", .opts = { .flags = UiFlags::ColorUiGold, .cursorPosition = 14, // Cursor at second line, at the 'o' of "Second" .cursorStatic = true, }, }, TestFixture { .name = "multiline_cursor-end_second_line", .width = 100, .height = 50, .fmt = "First line\nSecond line", .opts = { .flags = UiFlags::ColorUiGold, .cursorPosition = 22, // Cursor at start of second line .cursorStatic = true, }, }, TestFixture { .name = "highlight-partial", .width = 120, .height = 15, .fmt = "Hello World", .opts = { .flags = UiFlags::ColorUiGold, .highlightRange = { 5, 10 }, // Highlight " Worl" .highlightColor = PAL8_BLUE, }, }, TestFixture { .name = "highlight-full", .width = 120, .height = 15, .fmt = "Hello World", .opts = { .flags = UiFlags::ColorUiGold, .highlightRange = { 0, 11 }, // Highlight entire text .highlightColor = PAL8_BLUE, }, }, TestFixture { .name = "multiline_highlight", .width = 70, .height = 50, .fmt = "Hello\nWorld", .opts = { .flags = UiFlags::ColorUiGold, .highlightRange = { 3, 8 }, // Highlight "lo\nWo" .highlightColor = PAL8_BLUE, }, }, }; SDLPaletteUniquePtr LoadPalette() { struct Color { uint8_t r, g, b; }; std::array palData; LoadFileInMem("ui_art\\diablo.pal", palData); SDLPaletteUniquePtr palette = SDLWrap::AllocPalette(static_cast(palData.size())); for (unsigned i = 0; i < palData.size(); i++) { palette->colors[i] = SDL_Color { palData[i].r, palData[i].g, palData[i].b, SDL_ALPHA_OPAQUE }; } return palette; } void DrawWithBorder(const Surface &out, const Rectangle &area, tl::function_ref fn) { const uint8_t debugColor = PAL8_RED; DrawHorizontalLine(out, area.position, area.size.width, debugColor); DrawHorizontalLine(out, area.position + Displacement { 0, area.size.height - 1 }, area.size.width, debugColor); DrawVerticalLine(out, area.position, area.size.height, debugColor); DrawVerticalLine(out, area.position + Displacement { area.size.width - 1, 0 }, area.size.height, debugColor); fn(Rectangle { Point { area.position.x + 1, area.position.y + 1 }, Size { area.size.width - 2, area.size.height - 2 } }); } bool MaybeUpdateExpected(const std::string &actualPath, const std::string &expectedPath) { if (!UpdateExpected) return false; CopyFileOverwrite(actualPath.c_str(), expectedPath.c_str()); std::clog << "⬆️ Updated expected file at " << expectedPath << std::endl; return true; } class TextRenderIntegrationTest : public ::testing::TestWithParam { public: static void SetUpTestSuite() { palette = LoadPalette(); } static void TearDownTestSuite() { palette = nullptr; } protected: static SDLPaletteUniquePtr palette; }; SDLPaletteUniquePtr TextRenderIntegrationTest::palette; TEST_P(TextRenderIntegrationTest, RenderAndCompareTest) { const TestFixture &fixture = GetParam(); OwnedSurface out = OwnedSurface { fixture.width + 20, fixture.height + 20 }; SDL_SetSurfacePalette(out.surface, palette.get()); ASSERT_NE(out.surface, nullptr); DrawWithBorder(out, Rectangle { Point { 10, 10 }, Size { fixture.width, fixture.height } }, [&](const Rectangle &rect) { if (fixture.args.empty()) { DrawString(out, fixture.fmt, rect, fixture.opts); } else { DrawStringWithColors(out, fixture.fmt, fixture.args, rect, fixture.opts); } }); const std::string actualPath = StrCat(paths::BasePath(), FixturesPath, GetParam().name, "-Actual.png"); const std::string expectedPath = StrCat(paths::BasePath(), FixturesPath, GetParam().name, ".png"); SDL_IOStream *actual = SDL_IOFromFile(actualPath.c_str(), "wb"); ASSERT_NE(actual, nullptr) << SDL_GetError(); const tl::expected result = WriteSurfaceToFilePng(out, actual); ASSERT_TRUE(result.has_value()) << result.error(); // We compare pixels rather than PNG file contents because different // versions of SDL may use different PNG encoders. SDLSurfaceUniquePtr actualSurface { LoadPNG(actualPath.c_str()) }; ASSERT_NE(actualSurface, nullptr) << SDL_GetError(); SDLSurfaceUniquePtr expectedSurface { LoadPNG(expectedPath.c_str()) }; ASSERT_NE(expectedSurface, nullptr) << SDL_GetError(); ASSERT_NE(actualSurface->pixels, nullptr); ASSERT_NE(expectedSurface->pixels, nullptr); if ((actualSurface->h != expectedSurface->h || actualSurface->w != expectedSurface->w) && MaybeUpdateExpected(actualPath, expectedPath)) { return; } ASSERT_EQ(actualSurface->h, expectedSurface->h); ASSERT_EQ(actualSurface->w, expectedSurface->w); for (int y = 0; y < expectedSurface->h; y++) { for (int x = 0; x < expectedSurface->w; x++) { const uint8_t actualPixel = reinterpret_cast(actualSurface->pixels)[y * actualSurface->pitch + x]; const uint8_t expectedPixel = reinterpret_cast(expectedSurface->pixels)[y * expectedSurface->pitch + x]; if (actualPixel != expectedPixel) { if (MaybeUpdateExpected(actualPath, expectedPath)) return; ASSERT_TRUE(false) << "Images are different at (" << x << ", " << y << ") " << static_cast(actualPixel) << " != " << static_cast(expectedPixel); } } } } INSTANTIATE_TEST_SUITE_P(GoldenTests, TextRenderIntegrationTest, testing::ValuesIn(Fixtures), [](const testing::TestParamInfo &info) { std::string name = info.param.name; std::replace(name.begin(), name.end(), '-', '_'); return name; }); } // namespace } // namespace devilution int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); if (argc >= 2) { for (int i = 1; i < argc; ++i) { if (argv[i] != std::string_view("--update_expected")) { std::cerr << "unknown argument: " << argv[i] << "\nUsage: " << argv[0] << " [--update_expected]" << "\n"; return 64; } } UpdateExpected = true; } return RUN_ALL_TESTS(); }