feat(ui): theme 契约(类型+默认值+合并+Provider)

子游戏只写要改的字段, 缺省集中在 DEFAULT_THEME 一处(spec §4.3,
第二准则的唯一例外)。ThemeProvider 未 init 即读抛错, 不兜底。
皮肤构建期定死, 故静态读取不做响应式。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 22:38:26 +08:00
co-authored by Claude Opus 5
parent cd064756d6
commit 5a1afedfd3
9 changed files with 184 additions and 0 deletions
@@ -0,0 +1,43 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { DEFAULT_THEME } from '../../YouleNexus/assets/framework/ui/theme/default-theme.ts';
import { resolveTheme } from '../../YouleNexus/assets/framework/ui/theme/resolve-theme.ts';
import { ThemeProvider } from '../../YouleNexus/assets/framework/ui/theme/theme-provider.ts';
test('resolveTheme 不传配置时返回默认主题', () => {
const t = resolveTheme();
assert.deepEqual(t, DEFAULT_THEME);
});
test('resolveTheme 只覆盖给出的字段,其余取默认', () => {
const t = resolveTheme({ colors: { primary: '#C8102E' } });
assert.equal(t.colors.primary, '#C8102E');
assert.equal(t.colors.accent, DEFAULT_THEME.colors.accent);
assert.equal(t.fonts.sizeBody, DEFAULT_THEME.fonts.sizeBody);
assert.equal(t.layout.myInfoAnchorX, DEFAULT_THEME.layout.myInfoAnchorX);
});
test('resolveTheme 分组独立合并,互不影响', () => {
const t = resolveTheme({ layout: { myInfoAnchorX: 130 } });
assert.equal(t.layout.myInfoAnchorX, 130);
assert.equal(t.layout.nicknameMaxChars, DEFAULT_THEME.layout.nicknameMaxChars);
assert.deepEqual(t.colors, DEFAULT_THEME.colors);
});
test('resolveTheme 不修改 DEFAULT_THEME 本身', () => {
const before = DEFAULT_THEME.colors.primary;
resolveTheme({ colors: { primary: '#000000' } });
assert.equal(DEFAULT_THEME.colors.primary, before);
});
test('ThemeProvider 未 init 就读取会抛错(第二准则:不兜底)', () => {
ThemeProvider.reset();
assert.throws(() => ThemeProvider.get(), /未初始化/);
});
test('ThemeProvider init 后可读回同一份主题', () => {
ThemeProvider.reset();
const t = resolveTheme({ colors: { primary: '#123456' } });
ThemeProvider.init(t);
assert.equal(ThemeProvider.get().colors.primary, '#123456');
});