package com.yunhai.cms.theme; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; /** * 主题注册表纯单元测试(方案 §8.3 A 组 T1-T8):直接 new,不启 Spring。 * 夹具主题在 src/test/resources/templates/themes/(t- 前缀,§8.7), * 断言一律用"包含/不包含",禁止精确数量断言(夹具被全量 IT 拾取)。 */ @DisplayName("ThemeRegistry - 主题包注册与校验(方案 §4.1/§5.1)") class ThemeRegistryTest { private ThemeRegistry registry; @BeforeEach void setUp() { registry = new ThemeRegistry(); registry.load(); } @Test @DisplayName("T1: default 包注册成功且声明 primary 参数(color/cssVar)") void defaultPackageRegisteredWithPrimaryParam() { ThemeInfo info = registry.get(ThemeRegistry.DEFAULT_THEME); assertThat(info).as("default 包必须注册(缺清单或非法则本测试红,§4.1 theme.json 必备)") .isNotNull(); assertThat(info.getName()).isEqualTo("default"); assertThat(info.getTitle()).isNotBlank(); assertThat(info.getSpecVersion()).isEqualTo(1); ThemeParam primary = info.param("primary"); assertThat(primary).isNotNull(); assertThat(primary.getType()).isEqualTo(ThemeParam.TYPE_COLOR); assertThat(primary.getCssVar()).isEqualTo("--primary"); assertThat(primary.getDefaultValue()).isNotBlank(); } @Test @DisplayName("T2: 坏 JSON 的包被剔除(t-broken-json)") void brokenJsonPackageRejected() { assertThat(registry.contains("t-broken-json")).isFalse(); assertThat(registry.contains("t-demo-red")) .as("合法夹具包必须注册成功(防止'全部剔除'假绿)").isTrue(); } @Test @DisplayName("T3: 缺必备模板的包被剔除(t-missing-list:有清单无 list.html)") void missingTemplatePackageRejected() { assertThat(registry.contains("t-missing-list")).isFalse(); assertThat(registry.contains("t-demo-red")).isTrue(); } @Test @DisplayName("T4: name 与目录名不一致的包被剔除(t-mismatch)") void nameMismatchPackageRejected() { assertThat(registry.contains("t-mismatch")).isFalse(); assertThat(registry.contains("t-mismatch-x")).isFalse(); assertThat(registry.contains("t-demo-red")).isTrue(); } @Test @DisplayName("T5: param key 含非法字符的包被剔除(t-bad-key)") void badParamKeyPackageRejected() { assertThat(registry.contains("t-bad-key")).isFalse(); assertThat(registry.contains("t-demo-red")).isTrue(); } @Test @DisplayName("T6: cssVar 出现在非 color 类型参数上被剔除(t-bad-cssvar)") void cssVarOnNonColorParamRejected() { assertThat(registry.contains("t-bad-cssvar")).isFalse(); assertThat(registry.contains("t-demo-red")).isTrue(); } @Test @DisplayName("T7: screenshot 声明的资源缺失被剔除(t-bad-shot)") void missingScreenshotPackageRejected() { assertThat(registry.contains("t-bad-shot")).isFalse(); assertThat(registry.contains("t-demo-red")).isTrue(); } @Test @DisplayName("T8: 列表至少包含 default(护栏用例)") void listContainsDefault() { assertThat(registry.list()) .extracting(ThemeInfo::getName) .contains(ThemeRegistry.DEFAULT_THEME); } @Test @DisplayName("P2-T33: 三新包参数默认值互异(防拷贝忘改的低成本哨兵)") void newPackagesPrimaryDefaultsDiffer() { String defaultPrimary = registry.get(ThemeRegistry.DEFAULT_THEME).param("primary").getDefaultValue(); for (String name : new String[] {"classic-red", "blue-white", "warm-kinder"}) { ThemeParam primary = registry.get(name).param("primary"); assertThat(primary).as("%s 应声明 primary 参数", name).isNotNull(); assertThat(primary.getDefaultValue()) .as("%s 的 primary 默认值不得与 default 相同(拷贝忘改哨兵)", name) .isNotEqualTo(defaultPrimary); } } @Test @DisplayName("高校官网风新包: academic-navy/campus-azure 注册成功,主色与 default/scholar-blue 及彼此互异") void academicSkinsRegisteredWithDistinctPrimary() { String defaultPrimary = registry.get(ThemeRegistry.DEFAULT_THEME).param("primary").getDefaultValue(); String scholarPrimary = registry.get("scholar-blue").param("primary").getDefaultValue(); java.util.Map seen = new java.util.LinkedHashMap<>(); for (String name : new String[] {"academic-navy", "campus-azure"}) { ThemeInfo info = registry.get(name); assertThat(info).as("%s 皮肤包必须注册(清单+必备模板+截图齐全)", name).isNotNull(); assertThat(info.getTitle()).isNotBlank(); ThemeParam primary = info.param("primary"); assertThat(primary).as("%s 应声明 primary 参数", name).isNotNull(); assertThat(primary.getDefaultValue()) .as("%s 的 primary 不得与 default 相同", name).isNotEqualTo(defaultPrimary); assertThat(primary.getDefaultValue()) .as("%s 的 primary 不得与基座 scholar-blue 相同(拷贝忘改哨兵)", name) .isNotEqualTo(scholarPrimary); seen.put(name, primary.getDefaultValue()); } assertThat(seen.get("academic-navy")).isNotEqualTo(seen.get("campus-azure")); } @Test @DisplayName("下线包: cherry-bloom/violet-iris 不再注册(粉/紫两包不符高校官网风,用户否决)") void rejectedPinkSkinsAreGone() { for (String name : new String[] {"cherry-bloom", "violet-iris"}) { assertThat(registry.contains(name)).as("%s 必须已从皮肤库移除", name).isFalse(); assertThat(registry.get(name)).isNull(); } } @Test @DisplayName("高校官网风第二批: poly-blue/agri-forest/jade-normal 注册成功,主色与存量全部互异") void universitySkinsRegisteredWithDistinctPrimary() { String defaultPrimary = registry.get(ThemeRegistry.DEFAULT_THEME).param("primary").getDefaultValue(); String scholarPrimary = registry.get("scholar-blue").param("primary").getDefaultValue(); java.util.Map seen = new java.util.LinkedHashMap<>(); for (String name : new String[] {"poly-blue", "agri-forest", "jade-normal"}) { ThemeInfo info = registry.get(name); assertThat(info).as("%s 皮肤包必须注册(清单+必备模板+截图齐全)", name).isNotNull(); assertThat(info.getTitle()).isNotBlank(); ThemeParam primary = info.param("primary"); assertThat(primary).as("%s 应声明 primary 参数", name).isNotNull(); assertThat(primary.getDefaultValue()) .as("%s 的 primary 不得与 default 相同", name).isNotEqualTo(defaultPrimary); assertThat(primary.getDefaultValue()) .as("%s 的 primary 不得与 scholar-blue 相同(拷贝忘改哨兵)", name) .isNotEqualTo(scholarPrimary); seen.put(name, primary.getDefaultValue()); } assertThat(seen.get("poly-blue")).isNotEqualTo(seen.get("agri-forest")); assertThat(seen.get("poly-blue")).isNotEqualTo(seen.get("jade-normal")); assertThat(seen.get("agri-forest")).isNotEqualTo(seen.get("jade-normal")); } @Test @DisplayName("default 包缺失时 fail-fast(方案 §5.1,启动失败防线)") void defaultMissingFailsFast() { ThemeRegistry empty = new ThemeRegistry() { @Override public void load() { // 模拟扫描不到任何包:load 为空即注册表为空 } }; assertThatCode(empty::verifyDefaultPresent) .as("default 缺失必须抛异常阻止启动(fail-fast,§5.1)") .isInstanceOf(IllegalStateException.class); } // ===== B-7:image 类型参数路径校验与应用真实路径一致(T-6)===== @Test @DisplayName("B-7: image 参数仅 /cms/upload/** 合法(含 context-path),旧 /upload/** 不再放行") void imageParamValidatesAgainstContextUploadPath() { ThemeParam image = new ThemeParam(); image.setKey("heroImage"); image.setType(ThemeParam.TYPE_IMAGE); assertThat(image.isValidValue("/cms/upload/cms/demo/202609/a.jpg")).isTrue(); assertThat(image.isValidValue("/upload/cms/demo/a.jpg")) .as("旧约定缺 /cms 前缀,context-path=/cms 部署下渲染必 404").isFalse(); assertThat(image.isValidValue("javascript:alert(1)")).isFalse(); assertThat(image.isValidValue("https://evil.example/x.png")).isFalse(); assertThat(image.isValidValue(null)).isFalse(); assertThat(image.isValidValue("")).isFalse(); } @Test @DisplayName("B-7/T-6: image 参数端到端——夹具声明 → 注册加载 → filterValidValues 按 /cms/upload/ 过滤") void imageParamFixtureEndToEnd() { ThemeInfo info = registry.get("t-demo-red"); ThemeParam hero = info.param("heroImage"); assertThat(hero).as("夹具包应声明 image 类型参数").isNotNull(); assertThat(hero.getType()).isEqualTo(ThemeParam.TYPE_IMAGE); assertThat(hero.getDefaultValue()).isEqualTo("/cms/upload/cms/t-demo-red/hero.png"); assertThat(info.filterValidValues(java.util.Collections.singletonMap( "heroImage", "/cms/upload/cms/x/202609/b.png"))) .containsKey("heroImage"); assertThat(info.filterValidValues(java.util.Collections.singletonMap( "heroImage", "/upload/cms/x/b.png"))) .as("旧 /upload/** 值应被过滤丢弃,不落库") .doesNotContainKey("heroImage"); } // ===== P-T1 参数迁包级(工作项 T):.meta 覆盖层承载 paramValues ===== @Test @DisplayName("T-T-01: .meta 覆盖层的 paramValues 被注册表读取并经 paramValuesOf 暴露") void metaOverlayParamValuesExposed() throws Exception { java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("pt1-themes"); writeExternalPackage(dir, "t-ext-params", null); java.nio.file.Files.createDirectories(dir.resolve(".meta")); java.nio.file.Files.write(dir.resolve(".meta").resolve("t-ext-params.json"), "{\"paramValues\":{\"primary\":\"#0a5c36\"}}".getBytes(java.nio.charset.StandardCharsets.UTF_8)); ThemeRegistry r = new ThemeRegistry(); r.setThemeDir(dir.toString()); r.load(); assertThat(r.get("t-ext-params").getParamValues()) .as("覆盖层 paramValues 必须进入注册表(包级参数值的唯一来源)") .containsEntry("primary", "#0a5c36"); assertThat(r.paramValuesOf("t-ext-params")).containsEntry("primary", "#0a5c36"); assertThat(r.paramValuesOf("t-demo-red")) .as("无覆盖层的包返回空 Map(值回落 theme.json defaultValue)").isEmpty(); assertThat(r.paramValuesOf("no-such-theme")) .as("未注册主题返回空 Map,调用方免判空").isEmpty(); deleteRecursively(dir); } // ===== W1 外部主题目录(方案 §5.3 T-W1-01..04)===== /** 在临时目录构造一个合规外部包(基于 classpath 夹具 t-demo-red 文件拷贝改名) */ private java.nio.file.Path writeExternalPackage(java.nio.file.Path dir, String name, java.util.function.UnaryOperator themeJsonMutator) throws Exception { java.nio.file.Path pkg = dir.resolve(name); java.nio.file.Files.createDirectories(pkg.resolve("assets")); java.nio.file.Files.createDirectories(pkg.resolve("fragments")); org.springframework.util.FileCopyUtils.copy( new org.springframework.core.io.ClassPathResource("templates/themes/t-demo-red/assets/screenshot.png").getInputStream(), java.nio.file.Files.newOutputStream(pkg.resolve("assets").resolve("screenshot.png"))); for (String t : new String[] {"home.html", "list.html", "detail.html", "single.html"}) { org.springframework.util.FileCopyUtils.copy( new org.springframework.core.io.ClassPathResource("templates/themes/t-demo-red/" + t).getInputStream(), java.nio.file.Files.newOutputStream(pkg.resolve(t))); } org.springframework.util.FileCopyUtils.copy( new org.springframework.core.io.ClassPathResource("templates/themes/t-demo-red/fragments/head.html").getInputStream(), java.nio.file.Files.newOutputStream(pkg.resolve("fragments").resolve("head.html"))); String json = org.springframework.util.FileCopyUtils.copyToString( new java.io.InputStreamReader(new org.springframework.core.io.ClassPathResource( "templates/themes/t-demo-red/theme.json").getInputStream(), java.nio.charset.StandardCharsets.UTF_8)); json = json.replace("\"name\": \"t-demo-red\"", "\"name\": \"" + name + "\"") .replace("\"name\":\"t-demo-red\"", "\"name\":\"" + name + "\""); if (themeJsonMutator != null) { json = themeJsonMutator.apply(json); } java.nio.file.Files.write(pkg.resolve("theme.json"), json.getBytes(java.nio.charset.StandardCharsets.UTF_8)); return pkg; } @Test @DisplayName("T-W1-01: 外部目录包被扫描注册(source=external)") void externalDirPackageScanned() throws Exception { java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("w1-themes"); writeExternalPackage(dir, "t-ext-ok", null); ThemeRegistry r = new ThemeRegistry(); r.setThemeDir(dir.toString()); r.load(); assertThat(r.contains("t-ext-ok")).isTrue(); assertThat(r.sourceOf("t-ext-ok")).isEqualTo("external"); assertThat(r.sourceOf("default")).isEqualTo("builtin"); deleteRecursively(dir); } @Test @DisplayName("T-W1-02: 运行期放入新包 → reload() 热生效,无需重启") void reloadPicksUpNewPackage() throws Exception { java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("w1-themes"); ThemeRegistry r = new ThemeRegistry(); r.setThemeDir(dir.toString()); r.load(); assertThat(r.contains("t-ext-late")).isFalse(); writeExternalPackage(dir, "t-ext-late", null); r.reload(); assertThat(r.contains("t-ext-late")).isTrue(); deleteRecursively(dir); } @Test @DisplayName("T-W1-03: 内置优先于同名外部(外部放 name=default 被拒且留 warn)") void builtinOverridesExternalSameName() throws Exception { java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("w1-themes"); writeExternalPackage(dir, "default", null); // 试图用外部 default 覆盖 ThemeRegistry r = new ThemeRegistry(); r.setThemeDir(dir.toString()); r.load(); assertThat(r.contains("default")).isTrue(); assertThat(r.sourceOf("default")).as("注册结果仍为内置 default").isEqualTo("builtin"); deleteRecursively(dir); } @Test @DisplayName("T-W1-04: reload 缺 default 保护——外部-only 场景破坏 default 后 reload 抛异常且旧集合保持") void reloadWithoutDefaultKeepsOldSet() throws Exception { java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("w1-themes"); writeExternalPackage(dir, "t-ext-a", null); writeExternalPackage(dir, "default", null); // 外部-only 构造:屏蔽 classpath 扫描 ThemeRegistry r = new ThemeRegistry() { @Override protected java.util.List scanClasspathResources() { return java.util.Collections.emptyList(); } }; r.setThemeDir(dir.toString()); r.load(); int before = r.list().size(); assertThat(r.contains("default")).isTrue(); deleteRecursively(dir.resolve("default")); org.assertj.core.api.Assertions.assertThatThrownBy(r::reload) .as("default 缺失时 reload 抛业务异常") .isInstanceOf(IllegalStateException.class); assertThat(r.list().size()).as("reload 失败不回滚旧集合").isEqualTo(before); assertThat(r.contains("t-ext-a")).isTrue(); deleteRecursively(dir); } // ===== v2 工作项 B:首页区块门禁补强(手册 §2.5 坑⑤:引用一致而非 8 形态强制)===== @Test @DisplayName("T-B-10 坑⑤回归:home.html 未引用本包 home-blocks 且无该文件的外部包必须照常注册") void packageWithoutOwnHomeBlocksIsNotEvicted() throws Exception { java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("gate-themes"); java.nio.file.Path pkg = writeExternalPackage(dir, "t-ext-nohb", null); assertThat(java.nio.file.Files.exists(pkg.resolve("home.html"))).isTrue(); assertThat(java.nio.file.Files.exists(pkg.resolve("fragments").resolve("home-blocks.html"))) .as("夹具包本就没有 home-blocks(B 档包同构)").isFalse(); ThemeRegistry r = new ThemeRegistry(); r.setThemeDir(dir.toString()); r.load(); assertThat(r.contains("t-ext-nohb")) .as("缺 home-blocks 不得成为剔除理由,否则 7 套 B 档包静默回落 default").isTrue(); deleteRecursively(dir); } @Test @DisplayName("T-B-11 home.html 把未定义的形态分发给本包 home-blocks → 该包剔除,同目录健康包不受影响") void danglingHomeBlockReferenceEvictsOnlyThatPackage() throws Exception { java.nio.file.Path dir = java.nio.file.Files.createTempDirectory("gate-themes"); java.nio.file.Path pkg = writeExternalPackage(dir, "t-ext-dangling", null); // compact 有定义、image_wall 无定义(模拟派生包补形态时漏写一个) java.nio.file.Files.write(pkg.resolve("fragments").resolve("home-blocks.html"), "
c
".getBytes(java.nio.charset.StandardCharsets.UTF_8)); String home = new String(java.nio.file.Files.readAllBytes(pkg.resolve("home.html")), java.nio.charset.StandardCharsets.UTF_8); java.nio.file.Files.write(pkg.resolve("home.html"), (home + dispatchToOwnHomeBlocks("compact") + dispatchToOwnHomeBlocks("image_wall")) .getBytes(java.nio.charset.StandardCharsets.UTF_8)); writeExternalPackage(dir, "t-ext-neighbour", null); ThemeRegistry r = new ThemeRegistry(); r.setThemeDir(dir.toString()); r.load(); assertThat(r.contains("t-ext-dangling")) .as("引用了本包不存在的 fragment → 首页必 500,必须剔除").isFalse(); assertThat(r.contains("t-ext-neighbour")) .as("剔除范围仅限坏包,不得连带误杀").isTrue(); deleteRecursively(dir); } @Test @DisplayName("T-B-12 门禁补强后 13 套内置包零剔除(含只做 5 形态的 campus-glow)") void allBuiltinPackagesSurviveTheGate() { assertThat(registry.list()).extracting(ThemeInfo::getName).contains( "default", "classic-red", "blue-white", "warm-kinder", "fresh-mint", "mint-breeze", "scholar-blue", "campus-glow", "academic-navy", "campus-azure", "poly-blue", "agri-forest", "jade-normal"); } /** 与 poly-blue/home.html 同构的分发写法:~{${themeBase} + '/fragments/home-blocks' :: 形态(${block})} */ private static String dispatchToOwnHomeBlocks(String form) { return ""; } /** JDK8 递归删除(测试临时目录专用) */ private static void deleteRecursively(java.nio.file.Path path) { if (!java.nio.file.Files.exists(path)) { return; } try (java.util.stream.Stream walk = java.nio.file.Files.walk(path)) { walk.sorted(java.util.Comparator.reverseOrder()).forEach(p -> { try { java.nio.file.Files.delete(p); } catch (java.io.IOException ignored) { } }); } catch (java.io.IOException ignored) { } } }