feat(theme): canonical theme/namespace lists + ship Summer Night (T-371, T-478)
The bundled-theme list and the Tier-0 i18n namespace list were each hand-maintained in 3+ places that had drifted: the testmode harness loaded 8 themes (catppuccin silently unvalidated) while the app and contrast gate loaded 10, and the i18n gate checked 4 of the shipped namespaces. Export one canonical const each — kBundledThemePaths and kTier0Namespaces — and have the app, the testmode harness, and the a11y gates iterate them. Drift-proof meta-assertions: - contrast gate fails if any theme YAML on disk is absent from kBundledThemePaths (so a theme can't sit unvalidated). - i18n gate derives its subjects from assets/i18n/en_us/ at test time and asserts en/nl key parity for every shipped catalog (26, was 4), plus that every kTier0Namespaces entry has a shipped catalog. Surfaced summer-night.yaml: a legacy-ported palette on disk, unbundled and never contrast-checked. Per the maintainer's call, ship it: flesh the palette to full token + syntax coverage (honouring the v1.2 colours, clide derivation pattern for the new keys) and add a structurally identical summer-night-hc sibling that clears the strict extended gate. Both pass. No licenses.yaml change: summer-night is clide's own (ported from legacy clide v1.2 under legacy/, MIT); the only third-party palette, Catppuccin, is already acknowledged; no fonts added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -10,24 +12,15 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
/// Themes whose name ends in `-hc` (high-contrast) or `-cb`
|
||||
/// (colour-blind) additionally have to clear the stricter
|
||||
/// [extendedPairs] set — D-69.
|
||||
///
|
||||
/// The subject list is [kBundledThemePaths] — the SAME list main.dart loads
|
||||
/// and the testmode harness validates (T-371). A meta-test asserts every
|
||||
/// `.yaml` on disk is in that list, so a new theme cannot ship unvalidated.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('contrast — all bundled themes meet WCAG AA', () {
|
||||
const bundledPaths = [
|
||||
'lib/kernel/src/theme/themes/clide.yaml',
|
||||
'lib/kernel/src/theme/themes/midnight.yaml',
|
||||
'lib/kernel/src/theme/themes/paper.yaml',
|
||||
'lib/kernel/src/theme/themes/terminal.yaml',
|
||||
'lib/kernel/src/theme/themes/clide-hc.yaml',
|
||||
'lib/kernel/src/theme/themes/midnight-hc.yaml',
|
||||
'lib/kernel/src/theme/themes/paper-hc.yaml',
|
||||
'lib/kernel/src/theme/themes/terminal-hc.yaml',
|
||||
'lib/kernel/src/theme/themes/catppuccin-mocha.yaml',
|
||||
'lib/kernel/src/theme/themes/catppuccin-mocha-hc.yaml',
|
||||
];
|
||||
|
||||
for (final path in bundledPaths) {
|
||||
for (final path in kBundledThemePaths) {
|
||||
test('theme: $path', () async {
|
||||
final def = await const ThemeLoader().fromAsset(rootBundle, path);
|
||||
const resolver = ThemeResolver();
|
||||
@@ -57,4 +50,19 @@ void main() {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Drift guard (T-371): a theme YAML on disk that isn't in kBundledThemePaths
|
||||
// would never be loaded, shipped, or contrast-checked. Fail loudly so a new
|
||||
// theme has to be added to the canonical list (which auto-validates it above)
|
||||
// rather than sit on disk unvalidated.
|
||||
test('every theme YAML on disk is in kBundledThemePaths', () {
|
||||
final onDisk = Directory(
|
||||
kThemesDir,
|
||||
).listSync().whereType<File>().map((f) => f.path).where((p) => p.endsWith('.yaml')).map((p) => p.replaceAll(r'\', '/')).toSet();
|
||||
final bundled = kBundledThemePaths.toSet();
|
||||
final unbundled = onDisk.difference(bundled);
|
||||
expect(unbundled, isEmpty, reason: 'theme YAML(s) on disk but not in kBundledThemePaths (so unvalidated/unshipped): $unbundled');
|
||||
final missing = bundled.difference(onDisk);
|
||||
expect(missing, isEmpty, reason: 'kBundledThemePaths references missing file(s): $missing');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,54 +1,61 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:flutter/services.dart' show rootBundle;
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
/// Asserts every bundled i18n catalog is well-formed and every key the
|
||||
/// Tier-0 built-ins ask for actually resolves.
|
||||
/// i18n coverage gate.
|
||||
///
|
||||
/// The second check is important: in a text-driven i18n system missing
|
||||
/// keys show the placeholder, so a runtime lookup test wouldn't "fail"
|
||||
/// on a typo — we have to assert the keys exist up front.
|
||||
/// The subject list is derived from the shipped catalogs on disk
|
||||
/// (`assets/i18n/en_us/*.json`), NOT a hand-maintained list — so a newly
|
||||
/// added catalog is validated automatically and cannot ship unchecked
|
||||
/// (T-371; the old gate hand-enumerated 4 of the namespaces and silently
|
||||
/// covered less as the app grew).
|
||||
///
|
||||
/// Per catalog we assert: the en_US catalog loads and is non-empty, and the
|
||||
/// nl_NL pack is at exact key parity (no missing or extra keys) — so a locale
|
||||
/// switch never falls back to English for a shipped string, and a stray
|
||||
/// translation key can't rot unnoticed.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
/// (namespace, key) pairs referenced by Tier-0 built-ins. Extend when
|
||||
/// new keys land.
|
||||
const referenced = <String, List<String>>{
|
||||
'builtin.welcome': ['title', 'subtitle', 'open-project', 'open-project.hint', 'tab.title'],
|
||||
'builtin.ipc-status': ['connected', 'connected.hint', 'disconnected', 'disconnected.hint'],
|
||||
'builtin.theme-picker': ['modal.title', 'modal.cancel', 'modal.cancel.hint', 'row.select.hint'],
|
||||
'builtin.default-layout': ['command.reset', 'preset.classic'],
|
||||
};
|
||||
const enDir = 'assets/i18n/en_us';
|
||||
final namespaces = Directory(
|
||||
enDir,
|
||||
).listSync().whereType<File>().where((f) => f.path.endsWith('.json')).map((f) => f.uri.pathSegments.last.replaceAll('.json', '')).toList()..sort();
|
||||
|
||||
group('i18n coverage (Tier 0)', () {
|
||||
for (final entry in referenced.entries) {
|
||||
final ns = entry.key;
|
||||
test('$ns catalog contains every referenced key', () async {
|
||||
group('i18n coverage — every shipped catalog', () {
|
||||
test('the asset dir actually ships catalogs', () {
|
||||
expect(namespaces, isNotEmpty, reason: 'no catalogs found under $enDir');
|
||||
});
|
||||
|
||||
for (final ns in namespaces) {
|
||||
test('$ns: en_US loads non-empty, nl_NL at key parity', () async {
|
||||
final loader = AssetCatalogLoader(bundle: rootBundle);
|
||||
final catalog = await loader.load(ns, const Locale('en', 'US'));
|
||||
expect(catalog, isNotEmpty, reason: 'catalog for "$ns" failed to load (asset path wrong?)');
|
||||
for (final key in entry.value) {
|
||||
expect(catalog.containsKey(key), isTrue, reason: 'namespace "$ns" catalog is missing key "$key"');
|
||||
}
|
||||
final en = await loader.load(ns, const Locale('en', 'US'));
|
||||
expect(en, isNotEmpty, reason: 'en_US catalog for "$ns" failed to load (asset path wrong?)');
|
||||
final nl = await loader.load(ns, const Locale('nl', 'NL'));
|
||||
expect(nl, isNotEmpty, reason: 'nl_NL catalog for "$ns" missing or empty');
|
||||
|
||||
final enKeys = en.keys.toSet();
|
||||
final nlKeys = nl.keys.toSet();
|
||||
expect(nlKeys.difference(enKeys), isEmpty, reason: 'nl_NL "$ns" has keys absent from en_US: ${nlKeys.difference(enKeys)}');
|
||||
expect(enKeys.difference(nlKeys), isEmpty, reason: 'nl_NL "$ns" is missing keys present in en_US: ${enKeys.difference(nlKeys)}');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// The bundled Dutch pack (T-462) must load and cover the same Tier-0 keys, so
|
||||
// a locale switch never falls back to English for a built-in label.
|
||||
group('i18n coverage — Dutch pack (nl-NL)', () {
|
||||
for (final entry in referenced.entries) {
|
||||
final ns = entry.key;
|
||||
test('$ns nl_NL catalog covers every referenced key', () async {
|
||||
final loader = AssetCatalogLoader(bundle: rootBundle);
|
||||
final catalog = await loader.load(ns, const Locale('nl', 'NL'));
|
||||
expect(catalog, isNotEmpty, reason: 'nl_NL catalog for "$ns" failed to load');
|
||||
for (final key in entry.value) {
|
||||
expect(catalog.containsKey(key), isTrue, reason: 'nl_NL "$ns" missing key "$key"');
|
||||
}
|
||||
});
|
||||
}
|
||||
// The Tier-0 preload list (loaded at boot, before its owning extension
|
||||
// activates) must name only catalogs that actually ship — else boot preloads
|
||||
// a missing namespace. The reverse isn't required: most catalogs load lazily
|
||||
// on extension activation, not at boot.
|
||||
group('i18n coverage — Tier-0 preload set is honest', () {
|
||||
test('every kTier0Namespaces entry has a shipped en_US catalog', () {
|
||||
final shipped = namespaces.toSet();
|
||||
for (final ns in kTier0Namespaces) {
|
||||
expect(shipped.contains(ns), isTrue, reason: 'kTier0Namespaces names "$ns" but no $enDir/$ns.json ships');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user