412 lines
21 KiB
Java
412 lines
21 KiB
Java
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) {
|
||
}
|
||
}
|
||
}
|