feat(ui): Flutter 正式深色皮肤(ThemeData 层)与显式偏好装配

- theme.dart 新增 PmonDarkColors:转录 CSS dark 块 30 键到 26 个常量
  (含两处反相 text-on-accent #0B0E14 / snackbar-bg #F2F4F9),
  28 个 Web 专属键登记 kDarkExemptTokens 豁免;几何/排印/动效/端侧派生/
  E-Ink 六色不进 Dart,沿用 PmonSpace/PmonRadius/PmonText,不新增刻度
- buildPmonTheme() 重构为 _assemble(palette, brightness),
  buildPmonDarkTheme() 复用同一控件装配,只在 theme.dart 内 new ThemeData;
  浅色分支 fromSeed 逐字段未变,深色分支 copyWith 使 colorScheme.primary
  严格等于 #6E9CFF
- main.dart 装 darkTheme + themeMode(顶层 ValueNotifier,默认 light,
  不用 ThemeMode.system),采集页加最小切换入口
- AppConfig.themeMode 持久化(零依赖 JSON,值域 light|dark,默认不预写键)
- 护栏:theme_guard C-15 改为 C-15'(允许深色但要求 58 键同名、
  不污染 E-Ink、不新增刻度、浅色 18 原色未动);token_parity 新增深色
  parity 组(覆盖 58 / 转录逐项相等 / 反向缺项 / 豁免缺席);
  新增 client/test/dark_theme_test.dart 并登记进 scripts/test.sh

关键约束:深色 tertiary 保持 #8A93A3,显式不同步浅色点修值 #646D7D;
浅色 18 品牌原色与 Core 45 项一个不动;E-Ink 仍纯黑白 / 圆角 0 / 无动画,
看板以 Theme(data: einkTheme()) 局部覆盖,与 themeMode 解耦。

验证:Dart 全量 +627 All tests passed(基线 610,新增 17)、
定向 35/35、run-guards.sh 5 套件 PASS 5 / FAIL 0、dart analyze 9 issues
(2 warning + 7 info,0 error,与基线一致)。

已知边界:本轮仅 ThemeData 层深色皮肤。页面自绘卡片/文字仍直接引用静态
PmonColors.* / PmonText.* / PmonCard.*(11 / 9 / 4 个文件),不走 ThemeData,
深色下整屏仍呈浅色;整屏页面级深色化需改为 Theme.of(context) 查询,
属独立后续阶段。Web localStorage 与 Flutter AppConfig 为两端独立持久化。
This commit is contained in:
PMon Dev committed 2026-11-08 21:16:30 +08:00
1 parent 61f044a752
commit 0d332f6e9a
8 files changed
+748 -71

No files matched your search

+17
View File
@@ -77,6 +77,7 @@ class AppConfig {
this.einkMode = false,
this.einkRefreshSec = 10,
this.einkLandscape = true,
this.themeMode = 'light',
this.configVersion = kConfigVersion,
});
@@ -143,6 +144,17 @@ class AppConfig {
/// 用户可随时在看板里「转屏」切换,选择会被记住。
final bool einkLandscape;
/// 界面主题偏好,值域 `'light' | 'dark'`,默认 `'light'`。
///
/// 由应用内**显式切换**驱动([ThemeMode] 由它换算),**不跟随系统亮度** ——
/// 与 Web 侧「`html[data-iw-theme]` 显式控制、不写 prefers-color-scheme」一致。
///
/// 持久化差异:Web 用 `localStorage['inkwatch.theme']`,Flutter 用本 AppConfig
/// 的零依赖 JSON 配置,两者是不同端的独立持久化,不强行统一。
/// 默认 `'light'` 时 [toJson] **不预写 `theme_mode` 键**(与 Web 侧默认不 set
/// localStorage 同义),只有用户切到 dark 才落盘该键。
final String themeMode;
/// 写这份文件时所用的配置格式版本(见 [kConfigVersion])。
/// 磁盘上缺失该键时读作 0 → 触发一次迁移(见 `config_migration.dart`)。
final int configVersion;
@@ -162,6 +174,7 @@ class AppConfig {
bool? einkMode,
int? einkRefreshSec,
bool? einkLandscape,
String? themeMode,
int? configVersion,
}) {
return AppConfig(
@@ -180,6 +193,7 @@ class AppConfig {
einkMode: einkMode ?? this.einkMode,
einkRefreshSec: einkRefreshSec ?? this.einkRefreshSec,
einkLandscape: einkLandscape ?? this.einkLandscape,
themeMode: themeMode ?? this.themeMode,
configVersion: configVersion ?? this.configVersion,
);
}
@@ -200,6 +214,8 @@ class AppConfig {
'eink_mode': einkMode,
'eink_refresh_sec': einkRefreshSec,
'eink_landscape': einkLandscape,
// 默认 light 不预写键(与 Web 侧 localStorage 默认不 set 同义)
if (themeMode != 'light') 'theme_mode': themeMode,
};
static AppConfig fromJson(Map<String, dynamic> j) => AppConfig(
@@ -217,6 +233,7 @@ class AppConfig {
einkMode: j['eink_mode'] as bool? ?? false,
einkRefreshSec: (j['eink_refresh_sec'] as num?)?.toInt() ?? 10,
einkLandscape: j['eink_landscape'] as bool? ?? true,
themeMode: j['theme_mode'] as String? ?? 'light',
// 缺失按 0:老版本写的文件没有这个键,正是要触发迁移的信号
configVersion: (j['config_version'] as num?)?.toInt() ?? 0,
);
+9
View File
@@ -133,6 +133,14 @@ ConfigNormalization normalizeConfig(AppConfig raw, {int fromVersion = 0}) {
einkSec = kDefaultEinkRefreshSec;
}
// ── 主题偏好:值域严格 'light' | 'dark',非法值回落浅色 ──
// 与 Web 侧 html[data-iw-theme] 的显式取值一致,不接受系统亮度或任意字符串。
var themeMode = raw.themeMode;
if (themeMode != 'light' && themeMode != 'dark') {
fixes.add('主题偏好「$themeMode」不是 light/dark,已回落浅色');
themeMode = 'light';
}
// ── 身份类字段(account_id / 令牌 / 邮箱):**一律原样保留** ──
// 它们的"坏"只会变成一次普通的鉴权失败(服务端会明确拒绝),
// 而误清有效凭据的代价是让用户重新登录 + 重新认领。
@@ -167,6 +175,7 @@ ConfigNormalization normalizeConfig(AppConfig raw, {int fromVersion = 0}) {
lastViewedDeviceId: lastViewed,
samplingIntervalMs: interval,
einkRefreshSec: einkSec,
themeMode: themeMode,
configVersion: kConfigVersion,
),
fixes,
+59 -8
View File
@@ -63,22 +63,60 @@ Future<void> main() async {
});
// 掉帧诊断(默认关;`--dart-define=PMON_LOG_JANK=true` 打开,见 platform/frame_jank_log.dart)
installJankLogger();
// 主题偏好是显式的应用内设置(不跟随系统):runApp 前读一次,
// 避免首屏先闪一下浅色再跳到用户上次选的深色。ConfigStore.load()
// 自带兜底、不抛异常,故不存在"卡在启动"的路径。
final initialCfg = await ConfigStore.load();
appThemeMode.value = themeModeFromConfig(initialCfg.themeMode);
runApp(const PmonApp());
}
/// 应用内**显式**主题偏好 —— 不跟随系统亮度。
///
/// 与 Web 侧「`html[data-iw-theme]` 显式控制、不写 prefers-color-scheme」一致:
/// 这里不采用系统跟随模式(不取 ThemeMode 的 system 值)。默认 light,
/// 切换后由 [setAppThemeMode] 落盘到 `AppConfig.themeMode`
/// (零依赖 JSON 配置,不引入 shared_preferences)。
final ValueNotifier<ThemeMode> appThemeMode =
ValueNotifier<ThemeMode>(ThemeMode.light);
/// 把配置里的 `'light' | 'dark'` 换算成 [ThemeMode](不采用系统跟随模式)。
ThemeMode themeModeFromConfig(String v) =>
v == 'dark' ? ThemeMode.dark : ThemeMode.light;
/// 切换深浅并持久化。先读回磁盘最新值再 copyWith —— 避免把别的页刚写的字段
/// 整份抹掉(与采集页 `_persist` / 查看页同一纪律)。
Future<void> setAppThemeMode(ThemeMode mode) async {
appThemeMode.value = mode;
try {
final onDisk = await ConfigStore.load();
await ConfigStore.save(onDisk.copyWith(
themeMode: mode == ThemeMode.dark ? 'dark' : 'light',
));
} catch (_) {
// 存不下不该让"本次切换"失效
}
}
class PmonApp extends StatelessWidget {
const PmonApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: '墨观',
debugShowCheckedModeBanner: false,
// 主题必须**只有一处装配**(ui/theme.dart):
// 否则系统控件(输入框/菜单/对话框/SnackBar)与我们自绘的卡片会各是一套色与圆角,
// 表现为"弹窗像另一个应用"。旧版这里是就地 new 了一个只有 seedColor 的 ThemeData。
theme: buildPmonTheme(),
home: const HomeShell(),
return ValueListenableBuilder<ThemeMode>(
valueListenable: appThemeMode,
builder: (context, mode, _) => MaterialApp(
title: '墨观',
debugShowCheckedModeBanner: false,
// 主题必须**只有一处装配**(ui/theme.dart):
// 否则系统控件(输入框/菜单/对话框/SnackBar)与我们自绘的卡片会各是一套色与圆角,
// 表现为"弹窗像另一个应用"。旧版这里是就地 new 了一个只有 seedColor 的 ThemeData。
theme: buildPmonTheme(),
darkTheme: buildPmonDarkTheme(),
// 由应用内显式偏好驱动,不采用系统跟随模式。
themeMode: mode,
home: const HomeShell(),
),
);
}
}
@@ -771,6 +809,19 @@ class _ControlPanelPageState extends State<ControlPanelPage>
titleSpacing: PmonSpace.lg,
title: const Text('墨观 采集端'),
actions: [
// 主题切换(最小入口):应用内显式偏好,不跟随系统。
// 图标随当前 brightness 取太阳/月亮;setAppThemeMode 会落盘到 AppConfig。
IconButton(
tooltip: '切换深色 / 浅色',
icon: Icon(Theme.of(context).brightness == Brightness.dark
? Icons.light_mode_outlined
: Icons.dark_mode_outlined),
onPressed: () => unawaited(setAppThemeMode(
Theme.of(context).brightness == Brightness.dark
? ThemeMode.light
: ThemeMode.dark,
)),
),
Padding(
padding: const EdgeInsets.only(right: PmonSpace.lg),
child: Center(child: _badge(core.platformName)),
+189 -36
View File
@@ -99,6 +99,70 @@ abstract final class PmonColors {
static const gap = Color(0xFFB6BCC8);
}
/// 深色皮肤语义色 —— 与管理台唯一真相源 `tokens.css` 的
/// `html[data-iw-theme="dark"]` 块(05 §1.4,58 个同名键覆写)一一对应。
///
/// ── 只转录「Flutter 端确实会消费」的键 ─────────────────────────────
/// 颜色 / 状态 / 图表 / 品牌 / 反馈里,凡 Web 专属的交互态与预混实色一律
/// **保持缺省、不写进 Dart**,理由与浅色侧 `kWebOnlyTokens` 完全一致:
/// · hover/active/五种 `*-border` 预混实色 → Flutter 用 Material `WidgetState`
/// 处理交互态、用 `withValues(alpha:)` 动态算描边(见 [PmonCard.notice]);
/// · `series-2/3/4`、`chart-area/grid/axis`、`*-gap-band/threshold-*` →
/// `viewer_chart.dart` 只画单序列,无多序列 / 预混面积需求;
/// · `logo-*` 四色 → Dart 侧没有消费点;
/// · `mask` / `overlay-shadow` / `focus-ring` 是 CSS 复合值,Flutter 无对应概念。
/// 未转录的 28 个键在 `test/token_parity_test.dart` 的深色豁免表里逐项登记
/// (反向缺项判红),防止有人"好心"补一套用不到的常量。
///
/// 几何 / 排印 / 描边宽度 / 动效 / 端侧派生 / E-Ink 六色**本就不在 dark 块里**
/// (05 §1.4「应保持缺省」),Flutter 已有 `PmonSpace` / `PmonRadius` /
/// `PmonText` 语义档位,一套都不新增。
///
/// ⚠️ [textTertiary] 保持 `#8A93A3`(深底 5.02–6.24:1 达标),**显式不同步**
/// 浅色点修值 `#646D7D` —— 同步会让深底对比跌到 2.98–3.70:1,制造新违规。
abstract final class PmonDarkColors {
// ── 中性面(--iw-page / surface / surface-muted / line)──────────
static const page = Color(0xFF0B0E14);
static const surface = Color(0xFF151A23);
static const line = Color(0xFF333B4C);
static const surfaceMuted = Color(0xFF1E2430);
// ── 文字(--iw-text-primary / secondary / tertiary / on-accent)──
static const textPrimary = Color(0xFFF2F4F9);
static const textSecondary = Color(0xFFB9C2D0);
static const textTertiary = Color(0xFF8A93A3);
/// D3 反相:亮蓝主色底上的文字改深墨色(7.23:1)
static const textOnAccent = Color(0xFF0B0E14);
// ── 主色(--iw-primary / primary-soft / info-text)───────────────
static const primary = Color(0xFF6E9CFF);
static const primarySoft = Color(0xFF18243D);
// ── 状态(--iw-online / suspect / offline / danger 各 base+soft+text)──
static const online = Color(0xFF5CE0A0);
static const onlineSoft = Color(0xFF0D2117);
static const onlineText = Color(0xFF9AEAC3);
static const suspect = Color(0xFFD8790E);
static const suspectSoft = Color(0xFF261B0D);
static const suspectText = Color(0xFFF2A32C);
static const offline = Color(0xFF9CA3AF);
static const offlineText = Color(0xFFB9C2D0);
static const unknownText = Color(0xFFB9C2D0);
static const danger = Color(0xFFF87171);
static const dangerSoft = Color(0xFF2E1518);
static const dangerText = Color(0xFFFCA5A5);
static const infoText = Color(0xFFA8C5FF);
// ── 图表(--iw-chart-line / series-1 / gap)─────────────────────
/// 与 [primary] 同源(= `--iw-chart-line` / `--iw-series-1` 深值)
static const chartLine = primary;
static const gap = Color(0xFF6B7488);
// ── 反馈(--iw-snackbar-bg)────────────────────────────────────
/// D3 反相:深色 Toast 底变浅,文字自动用深 [surface](15.85:1 AAA)
static const snackbarBg = Color(0xFFF2F4F9);
}
/// 间距刻度(只此七档;新增刻度前先问"是否真的需要")
abstract final class PmonSpace {
static const xs = 4.0;
@@ -191,109 +255,198 @@ abstract final class PmonCard {
/// Material 主题装配。**只在这里 new ThemeData** —— 保证控件(输入框/菜单/SnackBar/
/// 对话框)与自绘卡片是同一套颜色与圆角,不会出现"系统控件是旧蓝、自绘是新品"。
ThemeData buildPmonTheme() {
///
/// 浅色 / 深色共用**同一段控件装配**([_assemble]),只传入不同的语义色板
/// ([_lightPalette] / [_darkPalette])—— 不另写一套分叉的控件配置。
/// `buildPmonDarkTheme()` 也走这里,故「只在 theme.dart 内 new ThemeData」的
/// 规矩对深色同样成立(页面里就地 `ThemeData(` 由 theme_guard_test.dart 判红)。
ThemeData buildPmonTheme() => _assemble(_lightPalette, Brightness.light);
/// 深色皮肤主题。色值来自 `tokens.css` 的 `html[data-iw-theme="dark"]`
/// (05 §1.4 的 58 个同名键覆写),由 [PmonDarkColors] 逐项转录。
///
/// 装配结构与 [buildPmonTheme] 完全相同,仅语义色替换为深色板:
/// 圆角 / 间距 / 字号刻度一律沿用 PmonSpace / PmonRadius / PmonText,
/// 不新增任何刻度。E-Ink 看板走独立的 [einkTheme],不受本主题影响。
ThemeData buildPmonDarkTheme() => _assemble(_darkPalette, Brightness.dark);
/// 控件装配用到的语义色子集。**不是新令牌** —— 只是把两套色板喂给同一段装配,
/// 避免把 Web 的「预混实色 / 交互态」概念搬进 Dart。
class _PmonPalette {
const _PmonPalette({
required this.page,
required this.surface,
required this.line,
required this.surfaceMuted,
required this.textPrimary,
required this.textSecondary,
required this.textTertiary,
required this.textOnAccent,
required this.primary,
required this.primarySoft,
required this.snackbarBg,
required this.snackbarText,
});
final Color page;
final Color surface;
final Color line;
final Color surfaceMuted;
final Color textPrimary;
final Color textSecondary;
final Color textTertiary;
final Color textOnAccent;
final Color primary;
final Color primarySoft;
final Color snackbarBg;
final Color snackbarText;
}
const _lightPalette = _PmonPalette(
page: PmonColors.page,
surface: PmonColors.surface,
line: PmonColors.line,
surfaceMuted: PmonColors.surfaceMuted,
textPrimary: PmonColors.textPrimary,
textSecondary: PmonColors.textSecondary,
textTertiary: PmonColors.textTertiary,
textOnAccent: Colors.white,
primary: PmonColors.primary,
primarySoft: PmonColors.primarySoft,
// 浅色 Toast:深底白字(--iw-snackbar-bg 浅色 = textPrimary #111827)
snackbarBg: PmonColors.textPrimary,
snackbarText: Colors.white,
);
const _darkPalette = _PmonPalette(
page: PmonDarkColors.page,
surface: PmonDarkColors.surface,
line: PmonDarkColors.line,
surfaceMuted: PmonDarkColors.surfaceMuted,
textPrimary: PmonDarkColors.textPrimary,
textSecondary: PmonDarkColors.textSecondary,
textTertiary: PmonDarkColors.textTertiary,
textOnAccent: PmonDarkColors.textOnAccent,
primary: PmonDarkColors.primary,
primarySoft: PmonDarkColors.primarySoft,
// D3 反相:深色 Toast 底 = 浅色 #F2F4F9,文字改用深 surface(15.85:1 AAA)
snackbarBg: PmonDarkColors.snackbarBg,
snackbarText: PmonDarkColors.surface,
);
ThemeData _assemble(_PmonPalette p, Brightness brightness) {
final baseScheme = ColorScheme.fromSeed(
seedColor: p.primary,
brightness: brightness,
surface: p.surface,
);
// 深底必须让主色严格落在语义令牌上:fromSeed 会把 primary 推到 tonal 近似值
// (实测 #6E9CFF → #AFC6FF),FilledButton / 选中态就会用到非令牌蓝。
// 浅色分支保持 fromSeed 原始结果,确保 buildPmonTheme() 逐字段不变。
final colorScheme = brightness == Brightness.dark
? baseScheme.copyWith(
primary: p.primary,
onPrimary: p.textOnAccent,
onSurface: p.textPrimary,
)
: baseScheme;
final base = ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorScheme: ColorScheme.fromSeed(
seedColor: PmonColors.primary,
brightness: Brightness.light,
surface: PmonColors.surface,
),
scaffoldBackgroundColor: PmonColors.page,
brightness: brightness,
colorScheme: colorScheme,
scaffoldBackgroundColor: p.page,
splashFactory: InkSparkle.splashFactory,
);
return base.copyWith(
appBarTheme: const AppBarTheme(
backgroundColor: PmonColors.surface,
appBarTheme: AppBarTheme(
backgroundColor: p.surface,
surfaceTintColor: Colors.transparent,
foregroundColor: PmonColors.textPrimary,
foregroundColor: p.textPrimary,
elevation: 0,
scrolledUnderElevation: 0,
centerTitle: false,
titleTextStyle: PmonText.title,
titleTextStyle: PmonText.title.copyWith(color: p.textPrimary),
),
dividerTheme: const DividerThemeData(
color: PmonColors.line, thickness: 1, space: 1),
dividerTheme: DividerThemeData(color: p.line, thickness: 1, space: 1),
cardTheme: base.cardTheme.copyWith(
color: PmonColors.surface,
color: p.surface,
surfaceTintColor: Colors.transparent,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(PmonRadius.md),
side: const BorderSide(color: PmonColors.line),
side: BorderSide(color: p.line),
),
),
inputDecorationTheme: InputDecorationTheme(
isDense: true,
filled: true,
fillColor: PmonColors.surface,
fillColor: p.surface,
contentPadding: const EdgeInsets.symmetric(
horizontal: PmonSpace.md, vertical: PmonSpace.md),
hintStyle: PmonText.caption,
hintStyle: PmonText.caption.copyWith(color: p.textTertiary),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(PmonRadius.sm),
borderSide: const BorderSide(color: PmonColors.line),
borderSide: BorderSide(color: p.line),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(PmonRadius.sm),
borderSide: const BorderSide(color: PmonColors.line),
borderSide: BorderSide(color: p.line),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(PmonRadius.sm),
borderSide: const BorderSide(color: PmonColors.primary, width: 1.5),
borderSide: BorderSide(color: p.primary, width: 1.5),
),
),
snackBarTheme: base.snackBarTheme.copyWith(
behavior: SnackBarBehavior.floating,
backgroundColor: PmonColors.textPrimary,
contentTextStyle: const TextStyle(fontSize: 13, color: Colors.white),
backgroundColor: p.snackbarBg,
contentTextStyle: TextStyle(fontSize: 13, color: p.snackbarText),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(PmonRadius.sm)),
),
popupMenuTheme: base.popupMenuTheme.copyWith(
color: PmonColors.surface,
color: p.surface,
surfaceTintColor: Colors.transparent,
elevation: 2,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(PmonRadius.sm),
side: const BorderSide(color: PmonColors.line),
side: BorderSide(color: p.line),
),
textStyle: PmonText.body,
textStyle: PmonText.body.copyWith(color: p.textPrimary),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: PmonColors.surface,
backgroundColor: p.surface,
surfaceTintColor: Colors.transparent,
elevation: 0,
// 选中态用主色浅底 + 主色图标/文字。
// 不设的话 M3 会按 seed 自动生成一个偏紫的 secondaryContainer,
// 与我们的蓝主色不搭(真机截图里底部导航是紫色的)。
indicatorColor: PmonColors.primarySoft,
indicatorColor: p.primarySoft,
labelTextStyle: WidgetStateProperty.resolveWith((states) => TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: states.contains(WidgetState.selected)
? PmonColors.primary
: PmonColors.textTertiary,
? p.primary
: p.textTertiary,
)),
iconTheme: WidgetStateProperty.resolveWith((states) => IconThemeData(
size: 22,
color: states.contains(WidgetState.selected)
? PmonColors.primary
: PmonColors.textTertiary,
? p.primary
: p.textTertiary,
)),
),
textButtonTheme: TextButtonThemeData(
style: TextButton.styleFrom(
foregroundColor: PmonColors.primary,
foregroundColor: p.primary,
textStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
),
),
listTileTheme: const ListTileThemeData(
iconColor: PmonColors.textTertiary,
textColor: PmonColors.textPrimary,
listTileTheme: ListTileThemeData(
iconColor: p.textTertiary,
textColor: p.textPrimary,
),
);
}
+1
View File
@@ -45,6 +45,7 @@ REGISTERED_TESTS=(
clock_test.dart
config_change_test.dart
config_migration_test.dart
dark_theme_test.dart
device_merge_notice_test.dart
device_model_test.dart
eink_board_test.dart
+162
View File
@@ -0,0 +1,162 @@
/// 深色皮肤行为测试(本阶段新增,登记进 scripts/test.sh)
///
/// 运行:cd client && bash scripts/test.sh test/dark_theme_test.dart
///
/// ── 覆盖面 ────────────────────────────────────────────────────────
/// ① buildPmonDarkTheme() 的 brightness / colorScheme / 关键色值 / 控件语义色
/// ② E-Ink 主题不受深色影响(纯黑白、圆角 0、无动画、看板局部覆盖解耦)
/// ③ 浅色 buildPmonTheme() 逐字段未变(重构只是把装配参数化)
/// ④ 主题偏好持久化(AppConfig 值域 light|dark、默认 light 不预写键)
library;
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pmon_client/host/app_config.dart';
import 'package:pmon_client/host/config_migration.dart';
import 'package:pmon_client/ui/theme.dart';
void main() {
group('① 深色 ThemeData', () {
test('brightness == Brightness.dark,colorScheme 与主色一致', () {
final t = buildPmonDarkTheme();
expect(t.brightness, Brightness.dark);
expect(t.colorScheme.brightness, Brightness.dark);
// 「与主色一致」:primary 严格落在语义令牌上(不是 fromSeed 的 tonal 近似值)
expect(t.colorScheme.primary, PmonDarkColors.primary);
expect(t.colorScheme.primary, const Color(0xFF6E9CFF));
expect(t.colorScheme.surface, PmonDarkColors.surface);
expect(t.colorScheme.onSurface, PmonDarkColors.textPrimary);
expect(t.colorScheme.onPrimary, PmonDarkColors.textOnAccent);
});
test('关键色值:page / surface / textPrimary / primary / tertiary / snackbar', () {
final t = buildPmonDarkTheme();
// page #0B0E14
expect(t.scaffoldBackgroundColor, PmonDarkColors.page);
expect(t.scaffoldBackgroundColor, const Color(0xFF0B0E14));
// surface #151A23(AppBar / 卡片 / 弹窗 / 输入底)
expect(t.appBarTheme.backgroundColor, const Color(0xFF151A23));
expect(t.cardTheme.color, const Color(0xFF151A23));
expect(t.popupMenuTheme.color, PmonDarkColors.surface);
expect(t.inputDecorationTheme.fillColor, PmonDarkColors.surface);
// textPrimary #F2F4F9(AppBar 标题文字)
expect(t.appBarTheme.titleTextStyle?.color, PmonDarkColors.textPrimary);
expect(PmonDarkColors.textPrimary, const Color(0xFFF2F4F9));
// primary #6E9CFF
expect(t.colorScheme.primary, const Color(0xFF6E9CFF));
// tertiary 仍 #8A93A3 —— 显式不同步浅色点修值 #646D7D
expect(PmonDarkColors.textTertiary, const Color(0xFF8A93A3));
expect(PmonDarkColors.textTertiary, isNot(PmonColors.textTertiary));
// snackbar 反相 #F2F4F9:浅底必须配深字,不能白字压浅底
expect(t.snackBarTheme.backgroundColor, PmonDarkColors.snackbarBg);
expect(t.snackBarTheme.backgroundColor, const Color(0xFFF2F4F9));
expect(t.snackBarTheme.contentTextStyle?.color, PmonDarkColors.surface);
expect(t.snackBarTheme.contentTextStyle?.color, isNot(Colors.white));
});
test('控件语义色一并换深色(沿用同一套装配,只替换色板)', () {
final t = buildPmonDarkTheme();
expect(t.dividerTheme.color, PmonDarkColors.line);
expect(t.popupMenuTheme.textStyle?.color, PmonDarkColors.textPrimary);
expect(t.inputDecorationTheme.hintStyle?.color,
PmonDarkColors.textTertiary);
expect(t.inputDecorationTheme.focusedBorder?.borderSide.color,
PmonDarkColors.primary);
expect(t.listTileTheme.iconColor, PmonDarkColors.textTertiary);
expect(t.listTileTheme.textColor, PmonDarkColors.textPrimary);
expect(t.navigationBarTheme.backgroundColor, PmonDarkColors.surface);
expect(t.navigationBarTheme.indicatorColor, PmonDarkColors.primarySoft);
});
});
group('② E-Ink 不受深色影响', () {
test('einkTheme() 仍是纯黑白 / Brightness.light / 无 splash', () {
final e = einkTheme();
expect(e.colorScheme.brightness, Brightness.light);
expect(e.scaffoldBackgroundColor, PmonEink.page);
expect(e.scaffoldBackgroundColor, const Color(0xFFFFFFFF));
expect(e.colorScheme.primary, PmonEink.ink);
expect(e.colorScheme.primary, const Color(0xFF000000));
expect(e.cardTheme.color, PmonEink.box);
expect(e.appBarTheme.backgroundColor, PmonEink.page);
expect(e.splashFactory, NoSplash.splashFactory);
expect(e.snackBarTheme.backgroundColor, PmonEink.ink);
expect(e.snackBarTheme.backgroundColor,
isNot(PmonDarkColors.snackbarBg));
});
test('E-Ink 看板以 einkTheme() 局部覆盖主题,不读 themeMode', () {
final src = File('lib/ui/eink_board.dart').readAsStringSync();
expect(src.length, greaterThan(200), reason: 'eink_board.dart 读不到内容?');
expect(src.contains('Theme('), isTrue);
expect(src.contains('data: einkTheme()'), isTrue,
reason: '看板自带 einkTheme(),与 MaterialApp 的 themeMode 解耦');
expect(src.contains('PmonDarkColors'), isFalse);
expect(src.contains('buildPmonDarkTheme'), isFalse);
expect(src.contains('PmonColors'), isFalse,
reason: '看板只消费 PmonEink.* / PmonEinkText.*,不碰彩色令牌');
expect(src.contains('PmonEinkText'), isTrue);
expect(src.contains('statusGlyph'), isTrue);
});
});
group('③ 浅色主题未变(深色只是新增装配)', () {
test('buildPmonTheme() 仍是 Brightness.light,且色值与重构前基线一致', () {
final t = buildPmonTheme();
expect(t.brightness, Brightness.light);
expect(t.colorScheme.brightness, Brightness.light);
expect(t.scaffoldBackgroundColor, PmonColors.page);
// 重构前 colorScheme 由 fromSeed 派生,primary 不是主色本身。
// 用同一公式动态取基线,钉死「浅色分支没有被 copyWith 改动」。
final fromSeedPrimary = ColorScheme.fromSeed(
seedColor: PmonColors.primary,
brightness: Brightness.light,
).primary;
expect(t.colorScheme.primary, fromSeedPrimary);
expect(t.colorScheme.primary, isNot(PmonColors.primary));
expect(t.appBarTheme.backgroundColor, PmonColors.surface);
expect(t.cardTheme.color, PmonColors.surface);
expect(t.snackBarTheme.backgroundColor, PmonColors.textPrimary);
expect(t.snackBarTheme.contentTextStyle?.color, Colors.white);
// 深浅确实是两套不同的主题
expect(buildPmonDarkTheme().brightness, isNot(t.brightness));
});
});
group('④ 偏好持久化(AppConfig,值域 light|dark,默认 light 不预写键)', () {
test('默认值 light 且 toJson 不预写 theme_mode 键', () {
const cfg = AppConfig();
expect(cfg.themeMode, 'light');
expect(cfg.toJson().containsKey('theme_mode'), isFalse,
reason: '默认 light 不预写键(与 Web localStorage 默认不 set 同义)');
});
test('dark 往返不丢 / 切回 light 不写键', () {
const dark = AppConfig(themeMode: 'dark');
expect(dark.toJson()['theme_mode'], 'dark');
expect(AppConfig.fromJson(dark.toJson()).themeMode, 'dark');
expect(AppConfig.fromJson(const AppConfig().toJson()).themeMode, 'light');
expect(const AppConfig(themeMode: 'light')
.toJson()
.containsKey('theme_mode'), isFalse);
});
test('非法值经 normalizeConfig 回落 light(不跟随系统 / 任意串)', () {
final r = normalizeConfig(const AppConfig(themeMode: 'system'),
fromVersion: kConfigVersion);
expect(r.config.themeMode, 'light');
expect(r.changed, isTrue);
// 合法值零修正(正常启动不制造"已修正"噪音)
final ok = normalizeConfig(const AppConfig(), fromVersion: kConfigVersion);
expect(ok.changed, isFalse);
expect(ok.config.themeMode, 'light');
// 深色合法值原样保留
expect(
normalizeConfig(const AppConfig(themeMode: 'dark'),
fromVersion: kConfigVersion).config.themeMode,
'dark');
});
});
}
+125 -6
View File
@@ -205,21 +205,140 @@ void main() {
);
});
test('C-15:本轮不引入 Flutter 深色皮肤装配', () {
// C-15'(本阶段翻转):正式深色皮肤已授权落地(05 §1 / 13 报告)。
// 旧断言「本轮不引入深色装配」作废,换成与新事实一致的四条护栏:
// 允许深色、键名不越出 CSS dark 块、不污染 E-Ink、不新增刻度、浅色原色未动。
test("C-15':允许正式深色皮肤装配(不跟随系统)", () {
final src = File('lib/ui/theme.dart').readAsStringSync();
for (final forbidden in [
'buildPmonDarkTheme',
for (final required in [
'PmonDarkColors',
'buildPmonDarkTheme',
'Brightness.dark',
]) {
expect(src.contains(required), isTrue,
reason: '深色皮肤是本阶段正式交付(替代旧的「本轮不引入」断言)');
}
final main = File('lib/main.dart').readAsStringSync();
expect(main.contains('darkTheme: buildPmonDarkTheme()'), isTrue,
reason: 'MaterialApp 必须装配深色主题');
expect(main.contains('themeMode:'), isTrue,
reason: 'themeMode 必须由应用内显式偏好驱动');
expect(main.contains('ThemeMode.system'), isFalse,
reason: '不跟随系统亮度(与 Web 侧不写 prefers-color-scheme 一致)');
});
test("C-15':深色键名与 CSS dark 块同名(无新增键名、块恰好 58 键)", () {
// 与 token_parity_test.dart 同口径读唯一真相源(cwd=client/)。
// 「转录 + 豁免 = 58」的逐项覆盖由 token_parity_test.dart 深色 parity 组验证,
// 此处守住「dark 块键数」与「Dart 侧不引用块外键名」两道结构护栏。
const cssCandidates = [
'../../server/business/admin/assets/tokens.css',
'../server/business/admin/assets/tokens.css',
];
String? css;
for (final p in cssCandidates) {
final f = File(p);
if (f.existsSync()) {
css = f.readAsStringSync();
break;
}
}
expect(css, isNotNull, reason: '找不到 tokens.css');
final clean = css!.replaceAll(RegExp(r'/\*[\s\S]*?\*/'), '');
final darkBody = RegExp(r'html\[data-iw-theme="dark"\]\s*\{([\s\S]*?)\}')
.firstMatch(clean)
?.group(1) ??
'';
final darkKeys = RegExp(r'(--iw-[a-z0-9-]+)\s*:')
.allMatches(darkBody)
.map((m) => m.group(1)!)
.toSet();
expect(darkKeys.length, 58, reason: '05 §1.4 的 58 个同名键覆写');
final src = File('lib/ui/theme.dart').readAsStringSync();
final body = RegExp(r'class PmonDarkColors\s*\{([\s\S]*?)\n\}')
.firstMatch(src)
?.group(1) ??
'';
expect(body.length, greaterThan(200), reason: 'PmonDarkColors 读不到内容?');
final mentioned = RegExp(r'--iw-[a-z0-9-]+')
.allMatches(body)
.map((m) => m.group(0)!)
.toSet();
final stray = mentioned.where((k) => !darkKeys.contains(k)).toList();
expect(stray, isEmpty,
reason: 'PmonDarkColors 引用了 dark 块之外的键名(会漏进 E-Ink 分支):\n'
'${stray.join('\n')}');
});
test("C-15':不污染 E-Ink —— einkTheme() 仍是纯黑白 / 无动画独立主题", () {
final src = File('lib/ui/theme.dart').readAsStringSync();
final start = src.indexOf('ThemeData einkTheme()');
expect(start, greaterThanOrEqualTo(0), reason: 'einkTheme() 找不到?');
// einkTheme() 是文件最后一个顶层声明,取到末尾即可覆盖整段函数体。
final body = src.substring(start);
for (final forbidden in [
'PmonDarkColors',
'Brightness.dark',
'buildPmonDarkTheme',
]) {
expect(body.contains(forbidden), isFalse,
reason: 'E-Ink 是 256 灰阶物理设备,深色皮肤一个键都不许进来');
}
expect(body.contains('PmonEink.ink'), isTrue);
expect(body.contains('NoSplash.splashFactory'), isTrue);
expect(body.contains('Brightness.light'), isTrue);
});
test("C-15':不新增圆角/间距/字号刻度 —— PmonDarkColors 只有颜色常量", () {
final src = File('lib/ui/theme.dart').readAsStringSync();
final body = RegExp(r'class PmonDarkColors\s*\{([\s\S]*?)\n\}')
.firstMatch(src)
?.group(1) ??
'';
final clean = body.replaceAll(RegExp(r'//[^\n]*'), '');
expect(RegExp(r'TextStyle\(').hasMatch(clean), isFalse,
reason: '深色不得新增字号档位(沿用 PmonText)');
expect(RegExp(r'fontSize:').hasMatch(clean), isFalse);
expect(RegExp(r'static const \w+\s*=\s*\d').hasMatch(clean), isFalse,
reason: '深色不得新增间距/圆角数字刻度(沿用 PmonSpace / PmonRadius)');
final defs = RegExp(r'static const (\w+)\s*=\s*([^;]+);')
.allMatches(clean)
.toList();
expect(defs.length, greaterThan(20), reason: '深色常量集合不能缩水');
for (final m in defs) {
final rhs = m.group(2)!.trim();
expect(
src.contains(forbidden),
isFalse,
reason: '本轮只在浅色皮肤内修缺陷;深色装配是独立阶段(05 §3.6)',
RegExp(r'^Color\(0xFF[0-9A-Fa-f]{6}\)$').hasMatch(rhs) ||
RegExp(r'^\w+$').hasMatch(rhs), // 语义别名(chartLine = primary)
isTrue,
reason: 'PmonDarkColors.${m.group(1)} 不是颜色常量(=$rhs)',
);
}
});
test("C-15':浅色 18 个品牌原色未被改动(深色只是同名键覆写)", () {
// 18 原色 = PmonColors 里除 6 个 *-text 外的全部静态色值常量。
expect(PmonColors.page, const Color(0xFFF6F7F9));
expect(PmonColors.surface, const Color(0xFFFFFFFF));
expect(PmonColors.line, const Color(0xFFE5E7EB));
expect(PmonColors.surfaceMuted, const Color(0xFFF3F4F6));
expect(PmonColors.textPrimary, const Color(0xFF111827));
expect(PmonColors.textSecondary, const Color(0xFF4B5563));
expect(PmonColors.textTertiary, const Color(0xFF646D7D));
expect(PmonColors.primary, const Color(0xFF2563EB));
expect(PmonColors.primarySoft, const Color(0xFFEFF4FF));
expect(PmonColors.online, const Color(0xFF16A34A));
expect(PmonColors.onlineSoft, const Color(0xFFEAF7EF));
expect(PmonColors.suspect, const Color(0xFFD97706));
expect(PmonColors.suspectSoft, const Color(0xFFFEF6E7));
expect(PmonColors.offline, const Color(0xFF9CA3AF));
expect(PmonColors.danger, const Color(0xFFDC2626));
expect(PmonColors.dangerSoft, const Color(0xFFFDECEC));
expect(PmonColors.chartLine, PmonColors.primary);
expect(PmonColors.gap, const Color(0xFFB6BCC8));
});
test('卡片装饰来自同一处(标准卡不用阴影,靠发丝线分层)', () {
final d = PmonCard.standard();
expect((d.border as Border).top.color, PmonColors.line);
+186 -21
View File
@@ -9,14 +9,15 @@
/// · CSS 改值 / 删键 → 拦住(不只是「Dart 改了要跟 CSS」);
/// · Dart 改值 / 删常量 / 擅自补一套用不到的常量 → 同样拦住。
///
/// ── 三条纪律(05 §3.5 / C-16)────────────────────────────────────
/// 1. 值比对**只读 `tokens.css` 第一个 `:root`(浅色 Core)**。深色 58 键是
/// `html[data-iw-theme="dark"]` 的**同名覆写**,Dart 本轮不实现深色,
/// 故其覆写值全部按 `webOnly` 处理、不进 parity(见最后一组断言)。
/// ── 四条纪律(05 §3.5 / C-16 / 本阶段深色 parity)──────────────────
/// 1. 浅色值比对**只读 `tokens.css` 第一个 `:root`(浅色 Core)**。
/// 2. Web 专属键必须登记在 `kWebOnlyTokens` 白名单,且 Dart 侧**确实缺席**;
/// 若有人「好心」补一套用不到的常量,判红(防止重演两处漂移)。
/// 3. 文件读不到内容直接 fail(`expect(src.length, greaterThan(200))`)——
/// 照抄 `theme_guard_test.dart:56` 的自检,避免路径写错静默通过。
/// 4. **深色已落地**(本阶段):`html[data-iw-theme="dark"]` 的 58 个同名键
/// 另起一组 parity —— 转录项逐项比 `PmonDarkColors`、未转录的 Web 专属键
/// 登记 `kDarkExemptTokens` 豁免,两向缺项都判红(见 group ④)。
library;
import 'dart:io';
@@ -131,9 +132,8 @@ const List<ParityEntry> kTokenParity = [
(cssKey: '--iw-fs-metric-unit', dartExpr: 'PmonText.metricUnit', kind: 'dartOnly'),
];
/// Web 专属键白名单(05 §3.3 / C-16)。
/// 注:深色 58 键是**同名覆写**(键名与本表 / `both` 项重合),其覆写值按
/// C-16 全部视为 webOnly、不进 parity 值比对 —— 见「深色块」那组断言。
/// 浅色 Web 专属键白名单(05 §3.3 / C-16)。
/// 深色块的转录 / 豁免另见 [kDarkTranscribed] / [kDarkExemptTokens](group ④)。
const Set<String> kWebOnlyTokens = {
'--iw-surface-hover',
'--iw-line-strong',
@@ -176,11 +176,91 @@ const List<({String cssKey, String dartName})> kEinkFontParity = [
(cssKey: '--iw-eink-fs-caption', dartName: 'caption'),
];
// ── 深色 parity(本阶段新增):58 键 = 转录 30 + 豁免 28 ─────────────
/// 转录项:CSS dark 块键 ↔ `PmonDarkColors` 常量名。
/// 只列「Flutter 真的会消费」的键 —— 与浅色 PmonColors 同名语义,外加两处
/// D3 反相(text-on-accent / snackbar-bg);别名复用
/// (offline-soft / unknown-soft → surfaceMuted、unknown → offline、
/// series-1 → chartLine)与浅色侧 `kTokenParity` 的写法一致。
const List<({String cssKey, String dartName})> kDarkTranscribed = [
(cssKey: '--iw-page', dartName: 'page'),
(cssKey: '--iw-surface', dartName: 'surface'),
(cssKey: '--iw-surface-muted', dartName: 'surfaceMuted'),
(cssKey: '--iw-line', dartName: 'line'),
(cssKey: '--iw-text-primary', dartName: 'textPrimary'),
(cssKey: '--iw-text-secondary', dartName: 'textSecondary'),
(cssKey: '--iw-text-tertiary', dartName: 'textTertiary'),
(cssKey: '--iw-text-on-accent', dartName: 'textOnAccent'),
(cssKey: '--iw-primary', dartName: 'primary'),
(cssKey: '--iw-primary-soft', dartName: 'primarySoft'),
(cssKey: '--iw-info-text', dartName: 'infoText'),
(cssKey: '--iw-online', dartName: 'online'),
(cssKey: '--iw-online-soft', dartName: 'onlineSoft'),
(cssKey: '--iw-online-text', dartName: 'onlineText'),
(cssKey: '--iw-suspect', dartName: 'suspect'),
(cssKey: '--iw-suspect-soft', dartName: 'suspectSoft'),
(cssKey: '--iw-suspect-text', dartName: 'suspectText'),
(cssKey: '--iw-offline', dartName: 'offline'),
(cssKey: '--iw-offline-soft', dartName: 'surfaceMuted'),
(cssKey: '--iw-offline-text', dartName: 'offlineText'),
(cssKey: '--iw-unknown', dartName: 'offline'),
(cssKey: '--iw-unknown-soft', dartName: 'surfaceMuted'),
(cssKey: '--iw-unknown-text', dartName: 'unknownText'),
(cssKey: '--iw-danger', dartName: 'danger'),
(cssKey: '--iw-danger-soft', dartName: 'dangerSoft'),
(cssKey: '--iw-danger-text', dartName: 'dangerText'),
(cssKey: '--iw-chart-line', dartName: 'chartLine'),
(cssKey: '--iw-series-1', dartName: 'chartLine'),
(cssKey: '--iw-gap', dartName: 'gap'),
(cssKey: '--iw-snackbar-bg', dartName: 'snackbarBg'),
];
/// 豁免项:dark 块里**未转录**的 Web 专属键(05 §1.4 / 浅色 kWebOnlyTokens
/// 同一纪律)。逐项登记、Dart 侧保持缺省,理由见 `theme.dart` 的 PmonDarkColors
/// 文件头:交互态用 `WidgetState`、描边用 `withValues(alpha:)` 动态算、
/// 多序列 / Logo / 预混面积在 Flutter 端没有消费点。
const Set<String> kDarkExemptTokens = {
// Web 交互态 / 预混实色描边
'--iw-surface-hover',
'--iw-line-strong',
'--iw-primary-hover',
'--iw-primary-active',
'--iw-info-border',
'--iw-online-border',
'--iw-suspect-border',
'--iw-offline-border',
'--iw-unknown-border',
'--iw-danger-hover',
'--iw-danger-active',
'--iw-danger-border',
// 图表:单序列之外 / 预混面积 / 网格 / 阈值带
'--iw-chart-area',
'--iw-chart-grid',
'--iw-chart-axis',
'--iw-chart-gap-band',
'--iw-chart-threshold-band',
'--iw-chart-threshold-line',
'--iw-series-2',
'--iw-series-3',
'--iw-series-4',
// 品牌(Dart 无消费点)
'--iw-logo-bg',
'--iw-logo-wave',
'--iw-logo-accent',
'--iw-logo-mono',
// 反馈:CSS 复合值(Dart 无对应概念)
'--iw-mask',
'--iw-overlay-shadow',
'--iw-focus-ring',
};
// ── 解析结果(main 里一次性装载)──────────────────────────────────
late Map<String, String> cssCore; // 第一个 :root(浅色 Core)
late Map<String, String> cssEngineering; // 第二个 :root(工程化补充)
late Map<String, String> cssDark; // html[data-iw-theme="dark"] 块
late String cssClean;
late Map<String, String> dartColors; // PmonColors: name → FFRRGGBB
late Map<String, String> darkColors; // PmonDarkColors: name → FFRRGGBB
late Map<String, String> einkColors; // PmonEink: name → FFRRGGBB
// ⚠ PmonColors.page 与 PmonEink.page 同名,必须按 class 分段解析,
// 否则后者会覆盖前者,`--iw-page` 会被误判成 #FFFFFF 漂移。
@@ -262,6 +342,22 @@ String? resolveDart(String expr) {
return null;
}
/// 深色侧专用:只查 PmonDarkColors。
/// ⚠️ 两套色板字段名同名(page / surface / primary…),**绝不能**复用
/// [resolveDart] —— 那样会取到浅色 PmonColors 的值,深色 parity 会假通过。
String? resolveDark(String name) =>
darkColors[name] ?? darkColors[dartAliases[name]];
/// kebab-case → camelCase(反推「豁免键若被误补,会叫什么 Dart 常量名」)。
String _kebabToCamel(String s) {
final parts = s.split('-');
return parts.first +
parts
.skip(1)
.map((p) => p.isEmpty ? '' : p[0].toUpperCase() + p.substring(1))
.join();
}
void main() {
setUpAll(() {
// 唯一可编辑真相源在管理台 assets 下。
@@ -293,12 +389,20 @@ void main() {
reason: 'tokens.css 应有浅色 Core + 工程化补充两个 :root 块');
cssCore = _parseKv(roots[0].group(1)!);
cssEngineering = _parseKv(roots[1].group(1)!);
final darkBody = RegExp(r'html\[data-iw-theme="dark"\]\s*\{([\s\S]*?)\}')
.firstMatch(cssClean)
?.group(1) ??
'';
expect(darkBody.length, greaterThan(200),
reason: 'tokens.css 的 html[data-iw-theme="dark"] 块读不到内容?');
cssDark = _parseKv(darkBody);
const dartPath = 'lib/ui/theme.dart';
final dartSrc = File(dartPath).readAsStringSync();
expect(dartSrc.length, greaterThan(200), reason: '$dartPath 读不到内容?');
dartClean = dartSrc.replaceAll(RegExp(r'//[^\n]*'), '');
dartColors = _parseColors(_classBody(dartClean, 'PmonColors'));
darkColors = _parseColors(_classBody(dartClean, 'PmonDarkColors'));
einkColors = _parseColors(_classBody(dartClean, 'PmonEink'));
dartAliases = <String, String>{};
for (final m in RegExp(r'static const (\w+)\s*=\s*(\w+);')
@@ -419,28 +523,89 @@ void main() {
expect(einkFontSizes.length, 6, reason: 'E-Ink 字阶必须是完整的 6 档');
});
test('深色 58 键是同名覆写:零新增键名、不进 parity 值比对', () {
final darkBody = RegExp(r'html\[data-iw-theme="dark"\]\s*\{([\s\S]*?)\}')
.firstMatch(cssClean)
?.group(1) ??
'';
final darkKeys = RegExp(r'(--iw-[a-z0-9-]+)\s*:')
.allMatches(darkBody)
.map((m) => m.group(1)!)
.toList();
test('深色 58 键是同名覆写:零新增键名 + 层叠顺序', () {
final darkKeys = cssDark.keys.toList();
expect(darkKeys.length, 58, reason: '05 §1.4 的 58 个同名键覆写');
expect(darkKeys.toSet().length, 58, reason: '深色块不得有重复键');
for (final k in darkKeys) {
expect(cssCore.containsKey(k), isTrue,
reason: '$k 只在深色块出现 —— 新增键名会漏进 E-Ink 分支(B2)');
}
// 不进 parity:值比对的唯一数据源是首个 :root;Dart 侧本轮不实现深色
expect(dartClean.contains('PmonDarkColors'), isFalse);
expect(dartClean.contains('buildPmonDarkTheme'), isFalse);
expect(dartClean.contains('Brightness.dark'), isFalse);
// dark 块必须排在 E-Ink 块之前(G2 层叠顺序)
expect(cssClean.indexOf('html[data-iw-theme="dark"]'),
lessThan(cssClean.indexOf('html[data-iw-surface="eink"]')));
// 深色**现在进 parity**(本阶段已落地 PmonDarkColors),逐项比对见 group ④。
expect(dartClean.contains('PmonDarkColors'), isTrue);
expect(dartClean.contains('buildPmonDarkTheme'), isTrue);
});
});
group('④ 深色 parity:CSS dark 块 ↔ PmonDarkColors(双向)', () {
test('转录 + 豁免恰好覆盖 58 键,且都在 CSS dark 块里(数量一致)', () {
final covered = <String>{
...kDarkTranscribed.map((e) => e.cssKey),
...kDarkExemptTokens,
};
expect(covered.length, 58,
reason: '转录项 + 豁免项必须合计 58(同名且数量一致)');
expect(cssDark.keys.toSet().length, 58, reason: 'CSS dark 块恰好 58 键');
final missing = cssDark.keys.where((k) => !covered.contains(k)).toList();
expect(missing, isEmpty,
reason: 'CSS dark 块新增了键却没登记进转录/豁免表:\n'
'${missing.join('\n')}');
final dangling = covered.where((k) => !cssDark.containsKey(k)).toList();
expect(dangling, isEmpty,
reason: '转录/豁免表里有 CSS dark 块不存在的键:\n'
'${dangling.join('\n')}');
});
test('转录项逐项值相等(CSS dark 覆写值 == PmonDarkColors)', () {
final offenders = <String>[];
var checked = 0;
for (final e in kDarkTranscribed) {
checked++;
final cssVal = cssDark.containsKey(e.cssKey)
? normalizeCss(cssDark[e.cssKey]!)
: null;
final dartVal = resolveDark(e.dartName);
if (cssVal == null || dartVal == null || cssVal != dartVal) {
offenders.add('${e.cssKey.padRight(30)} css=${cssVal ?? "<缺>"}'
' PmonDarkColors.${e.dartName.padRight(16)} '
'dart=${dartVal ?? "<缺>"}');
}
}
expect(checked, kDarkTranscribed.length);
expect(offenders, isEmpty,
reason: '深色令牌漂移(cssKey / cssValue / dartName / dartValue):\n'
'${offenders.join('\n')}');
});
test('反向缺项:PmonDarkColors 每个颜色常量都必须已登记', () {
final registered = kDarkTranscribed.map((e) => e.dartName).toSet();
final missing = darkColors.keys
.where((k) => !registered.contains(k))
.toList();
expect(missing, isEmpty,
reason: 'theme.dart 新增了深色常量却没登记进转录表:\n'
'${missing.join('\n')}');
});
test('豁免项:Web 专属键已登记、未被误转录、Dart 侧确实缺席', () {
final transcribed = kDarkTranscribed.map((e) => e.cssKey).toSet();
final offenders = <String>[];
for (final k in kDarkExemptTokens) {
if (transcribed.contains(k)) {
offenders.add('$k 既登记豁免又被转录(重复登记)');
}
final camel = _kebabToCamel(k.replaceFirst('--iw-', ''));
if (darkColors.containsKey(camel)) {
offenders.add('$k: PmonDarkColors 擅自补了 $camel');
}
}
expect(kDarkExemptTokens.length, 58 - kDarkTranscribed.length,
reason: '豁免项数量 = 58 − 转录项数量');
expect(offenders, isEmpty,
reason: '未转录的 Web 专属键必须「CSS 有 / Dart 无」:\n'
'${offenders.join('\n')}');
});
});
}