diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt index 8281215..e4b9992 100644 --- a/editor/CMakeLists.txt +++ b/editor/CMakeLists.txt @@ -2082,4 +2082,20 @@ add_executable(step346_test tests/step346_test.cpp) target_include_directories(step346_test PRIVATE src) target_link_libraries(step346_test PRIVATE nlohmann_json::nlohmann_json) +add_executable(step347_test tests/step347_test.cpp) +target_include_directories(step347_test PRIVATE src) +target_link_libraries(step347_test PRIVATE nlohmann_json::nlohmann_json) + +add_executable(step348_test tests/step348_test.cpp) +target_include_directories(step348_test PRIVATE src) +target_link_libraries(step348_test PRIVATE nlohmann_json::nlohmann_json) + +add_executable(step349_test tests/step349_test.cpp) +target_include_directories(step349_test PRIVATE src) +target_link_libraries(step349_test PRIVATE nlohmann_json::nlohmann_json) + +add_executable(step350_test tests/step350_test.cpp) +target_include_directories(step350_test PRIVATE src) +target_link_libraries(step350_test PRIVATE nlohmann_json::nlohmann_json) + # Step 12: Dear ImGui shell scaffolding created (main.cpp exists but not built due to dependencies) diff --git a/editor/src/ThemeData.h b/editor/src/ThemeData.h index b77e1fd..373fb4c 100644 --- a/editor/src/ThemeData.h +++ b/editor/src/ThemeData.h @@ -71,17 +71,39 @@ struct WhetstoneTheme { nlohmann::json toJson() const { nlohmann::json j; j["name"] = name; + // Backgrounds j["bg"] = bg.toHex(); j["bgAlt"] = bgAlt.toHex(); j["bgPanel"] = bgPanel.toHex(); j["bgPopup"] = bgPopup.toHex(); + // Text j["text"] = text.toHex(); j["textDim"] = textDim.toHex(); - j["accent"] = accent.toHex(); + j["textAccent"] = textAccent.toHex(); + j["textError"] = textError.toHex(); j["textWarning"] = textWarning.toHex(); + j["textSuccess"] = textSuccess.toHex(); + // Borders + j["border"] = border.toHex(); j["borderFocused"] = borderFocused.toHex(); + // Accent + j["accent"] = accent.toHex(); j["accentHover"] = accentHover.toHex(); + j["accentActive"] = accentActive.toHex(); + // Selection + j["selectionBg"] = selectionBg.toHex(); j["selectionText"] = selectionText.toHex(); + // Scrollbar + j["scrollbar"] = scrollbar.toHex(); j["scrollbarHover"] = scrollbarHover.toHex(); + // Diagnostics j["diagError"] = diagError.toHex(); j["diagWarning"] = diagWarning.toHex(); + j["diagInfo"] = diagInfo.toHex(); j["diagHint"] = diagHint.toHex(); + // Syntax j["syntaxKeyword"] = syntaxKeyword.toHex(); j["syntaxString"] = syntaxString.toHex(); j["syntaxNumber"] = syntaxNumber.toHex(); j["syntaxComment"] = syntaxComment.toHex(); + j["syntaxFunction"] = syntaxFunction.toHex(); j["syntaxType"] = syntaxType.toHex(); + j["syntaxOperator"] = syntaxOperator.toHex(); j["syntaxAnnotation"] = syntaxAnnotation.toHex(); + // Fonts j["fontSizePrimary"] = fontSizePrimary; j["fontSizeUI"] = fontSizeUI; + j["fontSizeSmall"] = fontSizeSmall; return j; } + + static WhetstoneTheme fromJson(const nlohmann::json& j); }; inline WhetstoneTheme getDefaultDarkTheme() { @@ -183,3 +205,38 @@ inline WhetstoneTheme getDefaultLightTheme() { return t; } + +inline WhetstoneTheme WhetstoneTheme::fromJson(const nlohmann::json& j) { + WhetstoneTheme t; + auto hex = [&](const std::string& key, Color4 fallback) -> Color4 { + if (j.contains(key) && j[key].is_string()) return Color4::fromHex(j[key].get()); + return fallback; + }; + auto num = [&](const std::string& key, float fallback) -> float { + if (j.contains(key) && j[key].is_number()) return j[key].get(); + return fallback; + }; + t.name = j.value("name", "Custom"); + auto def = getDefaultDarkTheme(); + t.bg = hex("bg", def.bg); t.bgAlt = hex("bgAlt", def.bgAlt); + t.bgPanel = hex("bgPanel", def.bgPanel); t.bgPopup = hex("bgPopup", def.bgPopup); + t.text = hex("text", def.text); t.textDim = hex("textDim", def.textDim); + t.textAccent = hex("textAccent", def.textAccent); + t.textError = hex("textError", def.textError); t.textWarning = hex("textWarning", def.textWarning); + t.textSuccess = hex("textSuccess", def.textSuccess); + t.border = hex("border", def.border); t.borderFocused = hex("borderFocused", def.borderFocused); + t.accent = hex("accent", def.accent); t.accentHover = hex("accentHover", def.accentHover); + t.accentActive = hex("accentActive", def.accentActive); + t.selectionBg = hex("selectionBg", def.selectionBg); t.selectionText = hex("selectionText", def.selectionText); + t.scrollbar = hex("scrollbar", def.scrollbar); t.scrollbarHover = hex("scrollbarHover", def.scrollbarHover); + t.diagError = hex("diagError", def.diagError); t.diagWarning = hex("diagWarning", def.diagWarning); + t.diagInfo = hex("diagInfo", def.diagInfo); t.diagHint = hex("diagHint", def.diagHint); + t.syntaxKeyword = hex("syntaxKeyword", def.syntaxKeyword); t.syntaxString = hex("syntaxString", def.syntaxString); + t.syntaxNumber = hex("syntaxNumber", def.syntaxNumber); t.syntaxComment = hex("syntaxComment", def.syntaxComment); + t.syntaxFunction = hex("syntaxFunction", def.syntaxFunction); t.syntaxType = hex("syntaxType", def.syntaxType); + t.syntaxOperator = hex("syntaxOperator", def.syntaxOperator); t.syntaxAnnotation = hex("syntaxAnnotation", def.syntaxAnnotation); + t.fontSizePrimary = num("fontSizePrimary", def.fontSizePrimary); + t.fontSizeUI = num("fontSizeUI", def.fontSizeUI); + t.fontSizeSmall = num("fontSizeSmall", def.fontSizeSmall); + return t; +} diff --git a/editor/tests/step347_test.cpp b/editor/tests/step347_test.cpp new file mode 100644 index 0000000..c849aaf --- /dev/null +++ b/editor/tests/step347_test.cpp @@ -0,0 +1,190 @@ +// Step 347: Theme System — Full Theme Roundtrip + Validation (12 tests) +// Tests theme JSON serialization/deserialization, color completeness, contrast + +#include +#include +#include +#include +#include +#include "ThemeData.h" + +int main() { + int passed = 0; + + // Test 1: Dark theme — all semantic colors defined (no pure black/white) + { + auto t = getDefaultDarkTheme(); + assert(!t.bg.isPureBlack()); assert(!t.bg.isPureWhite()); + assert(!t.text.isPureBlack()); assert(!t.text.isPureWhite()); + assert(!t.accent.isPureBlack()); assert(!t.accent.isPureWhite()); + assert(!t.syntaxKeyword.isPureBlack()); + assert(!t.diagError.isPureBlack()); + std::cout << "Test 1 PASSED: Dark theme semantic colors defined\n"; + passed++; + } + + // Test 2: Light theme — all semantic colors defined + { + auto t = getDefaultLightTheme(); + assert(!t.bg.isPureBlack()); assert(!t.bg.isPureWhite()); + assert(!t.text.isPureBlack()); assert(!t.text.isPureWhite()); + assert(!t.accent.isPureBlack()); assert(!t.accent.isPureWhite()); + std::cout << "Test 2 PASSED: Light theme semantic colors defined\n"; + passed++; + } + + // Test 3: Full JSON roundtrip — dark theme + { + auto orig = getDefaultDarkTheme(); + auto j = orig.toJson(); + auto restored = WhetstoneTheme::fromJson(j); + assert(restored.name == orig.name); + assert(restored.bg.toHex() == orig.bg.toHex()); + assert(restored.text.toHex() == orig.text.toHex()); + assert(restored.accent.toHex() == orig.accent.toHex()); + assert(restored.syntaxKeyword.toHex() == orig.syntaxKeyword.toHex()); + assert(restored.diagError.toHex() == orig.diagError.toHex()); + assert(restored.fontSizePrimary == orig.fontSizePrimary); + std::cout << "Test 3 PASSED: Full JSON roundtrip (dark)\n"; + passed++; + } + + // Test 4: Full JSON roundtrip — light theme + { + auto orig = getDefaultLightTheme(); + auto j = orig.toJson(); + auto restored = WhetstoneTheme::fromJson(j); + assert(restored.name == orig.name); + assert(restored.bg.toHex() == orig.bg.toHex()); + assert(restored.syntaxString.toHex() == orig.syntaxString.toHex()); + assert(restored.diagWarning.toHex() == orig.diagWarning.toHex()); + std::cout << "Test 4 PASSED: Full JSON roundtrip (light)\n"; + passed++; + } + + // Test 5: toJson includes all fields + { + auto j = getDefaultDarkTheme().toJson(); + // Check all expected keys + std::vector required = { + "name", "bg", "bgAlt", "bgPanel", "bgPopup", + "text", "textDim", "textAccent", "textError", "textWarning", "textSuccess", + "border", "borderFocused", "accent", "accentHover", "accentActive", + "selectionBg", "selectionText", "scrollbar", "scrollbarHover", + "diagError", "diagWarning", "diagInfo", "diagHint", + "syntaxKeyword", "syntaxString", "syntaxNumber", "syntaxComment", + "syntaxFunction", "syntaxType", "syntaxOperator", "syntaxAnnotation", + "fontSizePrimary", "fontSizeUI", "fontSizeSmall" + }; + for (const auto& key : required) { + assert(j.contains(key)); + } + std::cout << "Test 5 PASSED: toJson includes all " << required.size() << " fields\n"; + passed++; + } + + // Test 6: fromJson with missing fields uses defaults + { + nlohmann::json j; + j["name"] = "Partial"; + j["bg"] = "#ff0000"; + auto t = WhetstoneTheme::fromJson(j); + assert(t.name == "Partial"); + assert(t.bg.toHex() == "#ff0000"); + // Missing fields fall back to dark theme defaults + auto def = getDefaultDarkTheme(); + assert(t.text.toHex() == def.text.toHex()); + assert(t.syntaxKeyword.toHex() == def.syntaxKeyword.toHex()); + std::cout << "Test 6 PASSED: fromJson with partial data uses defaults\n"; + passed++; + } + + // Test 7: fromJson with empty object gives defaults + { + auto t = WhetstoneTheme::fromJson(nlohmann::json::object()); + assert(t.name == "Custom"); // default name + auto def = getDefaultDarkTheme(); + assert(t.bg.toHex() == def.bg.toHex()); + assert(t.fontSizePrimary == def.fontSizePrimary); + std::cout << "Test 7 PASSED: Empty JSON gives default values\n"; + passed++; + } + + // Test 8: All 8 syntax colors distinct in dark theme + { + auto t = getDefaultDarkTheme(); + std::vector hexes = { + t.syntaxKeyword.toHex(), t.syntaxString.toHex(), + t.syntaxNumber.toHex(), t.syntaxComment.toHex(), + t.syntaxFunction.toHex(), t.syntaxType.toHex(), + t.syntaxOperator.toHex(), t.syntaxAnnotation.toHex() + }; + // At least 6 distinct colors (keyword == function is OK by design) + std::set unique(hexes.begin(), hexes.end()); + assert(unique.size() >= 6); + std::cout << "Test 8 PASSED: " << unique.size() << " distinct syntax colors\n"; + passed++; + } + + // Test 9: Color4 arithmetic precision + { + Color4 c = Color4::fromHex("#7aa2f7"); + // Round-trip should be stable + std::string hex1 = c.toHex(); + Color4 c2 = Color4::fromHex(hex1); + assert(c2.toHex() == hex1); + // Alpha channel preserved + Color4 ca = Color4::fromHex("#7aa2f780"); + assert(ca.a < 0.6f); // 0x80 = 128 → 128/255 ≈ 0.502 + std::cout << "Test 9 PASSED: Color4 hex precision and alpha\n"; + passed++; + } + + // Test 10: Font size customization via JSON + { + nlohmann::json j; + j["name"] = "BigFont"; + j["fontSizePrimary"] = 20.0f; + j["fontSizeUI"] = 18.0f; + j["fontSizeSmall"] = 12.0f; + auto t = WhetstoneTheme::fromJson(j); + assert(t.fontSizePrimary == 20.0f); + assert(t.fontSizeUI == 18.0f); + assert(t.fontSizeSmall == 12.0f); + std::cout << "Test 10 PASSED: Font size customization via JSON\n"; + passed++; + } + + // Test 11: Dark vs light theme contrast + { + auto dark = getDefaultDarkTheme(); + auto light = getDefaultLightTheme(); + // Dark: bg is dark, text is light + assert(dark.bg.r < 0.2f && dark.text.r > 0.7f); + // Light: bg is light, text is dark + assert(light.bg.r > 0.8f && light.text.r < 0.2f); + // Both have accent colors + assert(!dark.accent.isPureBlack()); + assert(!light.accent.isPureBlack()); + std::cout << "Test 11 PASSED: Dark/light contrast correct\n"; + passed++; + } + + // Test 12: Theme selection/scrollbar/popup colors defined + { + auto t = getDefaultDarkTheme(); + assert(!t.selectionBg.isPureBlack()); + assert(!t.scrollbar.isPureBlack()); + assert(!t.bgPopup.isPureBlack()); + assert(!t.accentHover.isPureBlack()); + assert(!t.accentActive.isPureBlack()); + // Hover should be lighter than accent for dark theme + assert(t.accentHover.r >= t.accent.r || t.accentHover.g >= t.accent.g); + std::cout << "Test 12 PASSED: Selection/scrollbar/popup colors defined\n"; + passed++; + } + + std::cout << "\nResults: " << passed << "/12\n"; + assert(passed == 12); + return 0; +}