Files
YschcmsService/src/test/java/com/yunhai/cms/theme/ThemeRegistryTest.java
T

412 lines
21 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<String, String> 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<String, String> 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<String> 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<org.springframework.core.io.Resource> 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"),
"<section th:fragment=\"compact(block)\">c</section>".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 "<th:block th:case=\"'" + form + "'\"><th:block th:replace=\"~{${themeBase} + "
+ "'/fragments/home-blocks' :: " + form + "(${block})}\" /></th:block>";
}
/** JDK8 递归删除(测试临时目录专用) */
private static void deleteRecursively(java.nio.file.Path path) {
if (!java.nio.file.Files.exists(path)) {
return;
}
try (java.util.stream.Stream<java.nio.file.Path> 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) {
}
}
}