test: generated nav manifest guards the sidebar truth

test/nav_manifest_test.dart derives docs/nav.generated.json from
the _pages list (order = Cmd numbers), the labelOf switch, and both
.arb files. On any nav change it regenerates the manifest and fails
once with instructions to commit + mirror it to
fai_chain/docs/studio/, where the platform repo's docs_consistency
gate checks the operator guide against it — cross-repo nav drift
becomes a red gate instead of quietly rotting docs.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-12 23:27:49 +02:00
parent 09c901b75e
commit ed2c5ace26
2 changed files with 171 additions and 0 deletions

53
docs/nav.generated.json Normal file
View file

@ -0,0 +1,53 @@
{
"_generated": "by test/nav_manifest_test.dart — do not edit. Mirror to fai_chain/docs/studio/nav.generated.json (same content).",
"pages": [
{
"id": "welcome",
"cmd": 1,
"label_de": "Willkommen",
"label_en": "Welcome"
},
{
"id": "store",
"cmd": 2,
"label_de": "Store",
"label_en": "Store"
},
{
"id": "doctor",
"cmd": 3,
"label_de": "Diagnose",
"label_en": "Doctor"
},
{
"id": "flows",
"cmd": 4,
"label_de": "Flows",
"label_en": "Flows"
},
{
"id": "audit",
"cmd": 5,
"label_de": "Protokoll",
"label_en": "Audit"
},
{
"id": "approvals",
"cmd": 6,
"label_de": "Freigaben",
"label_en": "Approvals"
},
{
"id": "runs",
"cmd": 7,
"label_de": "Läufe",
"label_en": "Runs"
},
{
"id": "federation",
"cmd": 8,
"label_de": "Föderation",
"label_en": "Federation"
}
]
}

118
test/nav_manifest_test.dart Normal file
View file

@ -0,0 +1,118 @@
// Nav-manifest guard. The sidebar truth lives in three places in
// this repo: the `_pages` list (ids + order = Cmd numbers), the
// `labelOf` switch (id l10n key), and the two .arb files (key
// DE/EN label). The operator guide documenting that nav lives in
// the PLATFORM repo (fai_chain/docs/studio/operator-guide.md),
// which this repo's CI cannot see — so the truth is exported as a
// committed manifest, `docs/nav.generated.json`, and the platform
// repo's docs_consistency test checks the guide against it.
//
// This test derives the manifest from the sources. When it differs
// from the committed file it REWRITES the file and fails once with
// instructions commit the regenerated manifest and mirror it to
// fai_chain/docs/studio/nav.generated.json so the cross-repo guard
// stays truthful. Add/rename/reorder a page and forget the guide:
// the platform gate goes loudly red instead of the docs quietly
// rotting.
import 'dart:convert';
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
const _manifestPath = 'docs/nav.generated.json';
const _mirrorHint =
'fai_chain/docs/studio/nav.generated.json (same content)';
/// Extract the ordered page ids from the `_pages` list in main.dart.
List<String> _pageIds(String mainDart) {
final block = RegExp(
r'static const _pages = <_NavPage>\[(.*?)\n \];',
dotAll: true,
).firstMatch(mainDart);
if (block == null) {
fail('could not locate the _pages block in lib/main.dart');
}
return RegExp(r"id: '([a-z-]+)'")
.allMatches(block.group(1)!)
.map((m) => m.group(1)!)
.toList();
}
/// Extract the id l10n-key mapping from the `labelOf` switch.
Map<String, String> _labelKeys(String mainDart) {
final keys = <String, String>{};
for (final m in RegExp(
r"case '([a-z-]+)':\s*\n\s*return l\.(nav\w+);",
).allMatches(mainDart)) {
keys[m.group(1)!] = m.group(2)!;
}
if (keys.isEmpty) {
fail('could not parse the labelOf switch in lib/main.dart');
}
return keys;
}
Map<String, dynamic> _arb(String path) =>
jsonDecode(File(path).readAsStringSync()) as Map<String, dynamic>;
void main() {
test('docs/nav.generated.json matches the sidebar truth', () {
final mainDart = File('lib/main.dart').readAsStringSync();
final ids = _pageIds(mainDart);
final labelKeys = _labelKeys(mainDart);
final de = _arb('lib/l10n/app_de.arb');
final en = _arb('lib/l10n/app_en.arb');
final pages = <Map<String, dynamic>>[];
for (var i = 0; i < ids.length; i++) {
final key = labelKeys[ids[i]];
expect(
key,
isNotNull,
reason:
"page '${ids[i]}' has no labelOf case — add it so the "
'sidebar can localize the label',
);
final labelDe = de[key] as String?;
final labelEn = en[key] as String?;
expect(
labelDe,
isNotNull,
reason: "l10n key '$key' missing from app_de.arb",
);
expect(
labelEn,
isNotNull,
reason: "l10n key '$key' missing from app_en.arb",
);
pages.add({
'id': ids[i],
'cmd': i + 1,
'label_de': labelDe,
'label_en': labelEn,
});
}
const encoder = JsonEncoder.withIndent(' ');
final manifest =
'${encoder.convert({
'_generated': 'by test/nav_manifest_test.dart — do not edit. '
'Mirror to $_mirrorHint.',
'pages': pages,
})}\n';
final file = File(_manifestPath);
final current = file.existsSync() ? file.readAsStringSync() : null;
if (current != manifest) {
file.parent.createSync(recursive: true);
file.writeAsStringSync(manifest);
fail(
'the sidebar nav changed: $_manifestPath was regenerated. '
'Commit it, mirror it to $_mirrorHint, and update the '
'operator guide — the platform repo\'s docs_consistency '
'test checks the guide against this manifest.',
);
}
});
}