Replace deprecated localization APIs and add test coverage

Use AssetManifest.loadFromAssetBundle instead of manually parsing the
deprecated AssetManifest.json. Fix a broken localization key reference
(queue.cameraWithIndex → queue.itemWithIndex).

Replace the standalone scripts/validate_localizations.dart with proper
flutter tests (11 tests across two groups): file integrity checks
(directory exists, en.json present, valid JSON structure, language code
file names, deep key-completeness across all locales) and t() lookup
tests (nested resolution, missing-key fallback, parameter substitution,
partial-path fallback).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Doug Borg
2026-02-07 13:40:00 -07:00
parent e559b86400
commit 61a2a99bbc
5 changed files with 204 additions and 188 deletions

View File

@@ -112,7 +112,7 @@ class QueueSection extends StatelessWidget {
? Colors.red
: _getUploadModeColor(upload.uploadMode),
),
title: Text(locService.t('queue.cameraWithIndex', params: [(index + 1).toString()]) +
title: Text(locService.t('queue.itemWithIndex', params: [(index + 1).toString()]) +
(upload.error ? locService.t('queue.error') : "") +
(upload.completing ? locService.t('queue.completing') : "")),
subtitle: Text(

View File

@@ -25,42 +25,32 @@ class LocalizationService extends ChangeNotifier {
Future<void> _discoverAvailableLanguages() async {
_availableLanguages = [];
try {
// Get the asset manifest to find all localization files
final manifestContent = await rootBundle.loadString('AssetManifest.json');
final Map<String, dynamic> manifestMap = json.decode(manifestContent);
// Find all .json files in lib/localizations/
final localizationFiles = manifestMap.keys
.where((String key) => key.startsWith('lib/localizations/') && key.endsWith('.json'))
final assetManifest = await AssetManifest.loadFromAssetBundle(rootBundle);
final localizationAssets = assetManifest.listAssets()
.where((path) => path.startsWith('lib/localizations/') && path.endsWith('.json'))
.toList();
for (final filePath in localizationFiles) {
// Extract language code from filename (e.g., 'lib/localizations/pt.json' -> 'pt')
final fileName = filePath.split('/').last;
final languageCode = fileName.substring(0, fileName.length - 5); // Remove '.json'
for (final assetPath in localizationAssets) {
try {
// Try to load and parse the file to ensure it's valid
final jsonString = await rootBundle.loadString(filePath);
final jsonString = await rootBundle.loadString(assetPath);
final parsedJson = json.decode(jsonString);
// Basic validation - ensure it has the expected structure
if (parsedJson is Map && parsedJson.containsKey('language')) {
final languageCode = assetPath.split('/').last.replaceAll('.json', '');
_availableLanguages.add(languageCode);
debugPrint('Found localization: $languageCode');
}
} catch (e) {
debugPrint('Failed to load localization file $filePath: $e');
debugPrint('Failed to load localization file $assetPath: $e');
}
}
} catch (e) {
debugPrint('Failed to read AssetManifest.json: $e');
// If manifest reading fails, we'll have an empty list
// The system will handle this gracefully by falling back to 'en' in _loadSavedLanguage
debugPrint('Failed to load asset manifest: $e');
_availableLanguages = ['en'];
}
debugPrint('Available languages: $_availableLanguages');
}
@@ -119,28 +109,31 @@ class LocalizationService extends ChangeNotifier {
notifyListeners();
}
String t(String key, {List<String>? params}) {
String t(String key, {List<String>? params}) =>
lookup(_strings, key, params: params);
/// Pure lookup function used by [t] and available for testing.
static String lookup(Map<String, dynamic> strings, String key,
{List<String>? params}) {
List<String> keys = key.split('.');
dynamic current = _strings;
dynamic current = strings;
for (String k in keys) {
if (current is Map && current.containsKey(k)) {
current = current[k];
} else {
// Return the key as fallback for missing translations
return key;
}
}
String result = current is String ? current : key;
// Replace parameters if provided - replace first occurrence only for each parameter
if (params != null) {
for (int i = 0; i < params.length; i++) {
result = result.replaceFirst('{}', params[i]);
}
}
return result;
}

View File

@@ -106,7 +106,7 @@ packages:
source: hosted
version: "1.1.2"
collection:
dependency: transitive
dependency: "direct main"
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
@@ -601,7 +601,7 @@ packages:
source: hosted
version: "1.1.0"
path_provider:
dependency: transitive
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"

View File

@@ -1,154 +0,0 @@
#!/usr/bin/env dart
import 'dart:convert';
import 'dart:io';
const String localizationsDir = 'lib/localizations';
const String referenceFile = 'en.json';
void main() async {
print('🌍 Validating localization files...\n');
try {
final result = await validateLocalizations();
if (result) {
print('✅ All localization files are valid!');
exit(0);
} else {
print('❌ Localization validation failed!');
exit(1);
}
} catch (e) {
print('💥 Error during validation: $e');
exit(1);
}
}
Future<bool> validateLocalizations() async {
// Get all JSON files in localizations directory
final locDir = Directory(localizationsDir);
if (!locDir.existsSync()) {
print('❌ Localizations directory not found: $localizationsDir');
return false;
}
final jsonFiles = locDir
.listSync()
.where((file) => file.path.endsWith('.json'))
.map((file) => file.path.split('/').last)
.toList();
if (jsonFiles.isEmpty) {
print('❌ No JSON localization files found');
return false;
}
print('📁 Found ${jsonFiles.length} localization files:');
for (final file in jsonFiles) {
print('$file');
}
print('');
// Load reference file (English)
final refFile = File('$localizationsDir/$referenceFile');
if (!refFile.existsSync()) {
print('❌ Reference file not found: $referenceFile');
return false;
}
Map<String, dynamic> referenceData;
try {
final refContent = await refFile.readAsString();
referenceData = json.decode(refContent) as Map<String, dynamic>;
} catch (e) {
print('❌ Failed to parse reference file $referenceFile: $e');
return false;
}
final referenceKeys = _extractAllKeys(referenceData);
print('🔑 Reference file ($referenceFile) has ${referenceKeys.length} keys');
bool allValid = true;
// Validate each localization file
for (final fileName in jsonFiles) {
if (fileName == referenceFile) continue; // Skip reference file
print('\n🔍 Validating $fileName...');
final file = File('$localizationsDir/$fileName');
Map<String, dynamic> fileData;
try {
final content = await file.readAsString();
fileData = json.decode(content) as Map<String, dynamic>;
} catch (e) {
print(' ❌ Failed to parse $fileName: $e');
allValid = false;
continue;
}
final fileKeys = _extractAllKeys(fileData);
final validation = _validateKeys(referenceKeys, fileKeys, fileName);
if (validation.isValid) {
print(' ✅ Structure matches reference (${fileKeys.length} keys)');
} else {
print(' ❌ Structure validation failed:');
for (final error in validation.errors) {
print('$error');
}
allValid = false;
}
}
return allValid;
}
/// Extract all nested keys from a JSON object using dot notation
/// Example: {"user": {"name": "John"}} -> ["user.name"]
Set<String> _extractAllKeys(Map<String, dynamic> data, {String prefix = ''}) {
final keys = <String>{};
for (final entry in data.entries) {
final key = prefix.isEmpty ? entry.key : '$prefix.${entry.key}';
if (entry.value is Map<String, dynamic>) {
// Recurse into nested objects
keys.addAll(_extractAllKeys(entry.value as Map<String, dynamic>, prefix: key));
} else {
// Add leaf key
keys.add(key);
}
}
return keys;
}
class ValidationResult {
final bool isValid;
final List<String> errors;
ValidationResult({required this.isValid, required this.errors});
}
ValidationResult _validateKeys(Set<String> referenceKeys, Set<String> fileKeys, String fileName) {
final errors = <String>[];
// Find missing keys
final missingKeys = referenceKeys.difference(fileKeys);
if (missingKeys.isNotEmpty) {
errors.add('Missing ${missingKeys.length} keys: ${missingKeys.take(5).join(', ')}${missingKeys.length > 5 ? '...' : ''}');
}
// Find extra keys
final extraKeys = fileKeys.difference(referenceKeys);
if (extraKeys.isNotEmpty) {
errors.add('Extra ${extraKeys.length} keys not in reference: ${extraKeys.take(5).join(', ')}${extraKeys.length > 5 ? '...' : ''}');
}
return ValidationResult(
isValid: errors.isEmpty,
errors: errors,
);
}

View File

@@ -0,0 +1,177 @@
import 'dart:convert';
import 'dart:io';
import 'package:deflockapp/services/localization_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
const String localizationsDir = 'lib/localizations';
/// Recursively extract all dot-notation leaf keys from a JSON map.
Set<String> extractLeafKeys(Map<String, dynamic> data, {String prefix = ''}) {
final keys = <String>{};
for (final entry in data.entries) {
final key = prefix.isEmpty ? entry.key : '$prefix.${entry.key}';
if (entry.value is Map<String, dynamic>) {
keys.addAll(
extractLeafKeys(entry.value as Map<String, dynamic>, prefix: key),
);
} else {
keys.add(key);
}
}
return keys;
}
void main() {
// ── Group 1: Localization file integrity ──────────────────────────────
group('Localization file integrity', () {
late Directory locDir;
late List<File> jsonFiles;
setUpAll(() {
locDir = Directory(localizationsDir);
if (locDir.existsSync()) {
jsonFiles = locDir
.listSync()
.whereType<File>()
.where((f) => f.path.endsWith('.json'))
.toList();
} else {
jsonFiles = <File>[];
}
});
test('localization directory exists and contains JSON files', () {
expect(locDir.existsSync(), isTrue);
expect(jsonFiles, isNotEmpty);
});
test('en.json exists (required fallback language)', () {
final enFile = File('$localizationsDir/en.json');
expect(enFile.existsSync(), isTrue);
});
test('every JSON file is valid JSON with a language.name key', () {
for (final file in jsonFiles) {
final name = p.basename(file.path);
final content = file.readAsStringSync();
final Map<String, dynamic> data;
try {
data = json.decode(content) as Map<String, dynamic>;
} catch (e) {
fail('$name is not valid JSON: $e');
return; // unreachable, keeps analyzer happy
}
expect(
data['language'],
isA<Map>(),
reason: '$name missing "language" object',
);
expect(
(data['language'] as Map)['name'],
isA<String>(),
reason: '$name missing "language.name" string',
);
}
});
test('file names are valid 2-3 letter language codes', () {
final codePattern = RegExp(r'^[a-z]{2,3}$');
for (final file in jsonFiles) {
final code = p.basenameWithoutExtension(file.path);
expect(
codePattern.hasMatch(code),
isTrue,
reason: '"$code" is not a valid 2-3 letter language code',
);
}
});
test('every locale file has exactly the same keys as en.json', () {
final enData = json.decode(
File('$localizationsDir/en.json').readAsStringSync(),
) as Map<String, dynamic>;
final referenceKeys = extractLeafKeys(enData);
for (final file in jsonFiles) {
final name = p.basename(file.path);
if (name == 'en.json') continue;
final data = json.decode(file.readAsStringSync())
as Map<String, dynamic>;
final fileKeys = extractLeafKeys(data);
final missing = referenceKeys.difference(fileKeys);
final extra = fileKeys.difference(referenceKeys);
expect(
missing,
isEmpty,
reason: '$name is missing keys: $missing',
);
expect(
extra,
isEmpty,
reason: '$name has extra keys not in en.json: $extra',
);
}
});
});
// ── Group 2: t() translation lookup ───────────────────────────────────
group('t() translation lookup', () {
late Map<String, dynamic> enData;
setUpAll(() {
enData = json.decode(
File('$localizationsDir/en.json').readAsStringSync(),
) as Map<String, dynamic>;
});
test('simple nested key lookup', () {
expect(
LocalizationService.lookup(enData, 'app.title'),
equals('DeFlock'),
);
});
test('deeper nested key lookup', () {
expect(
LocalizationService.lookup(enData, 'actions.cancel'),
equals('Cancel'),
);
});
test('missing key returns the key string as fallback', () {
expect(
LocalizationService.lookup(enData, 'this.key.does.not.exist'),
equals('this.key.does.not.exist'),
);
});
test('single {} parameter substitution', () {
expect(
LocalizationService.lookup(enData, 'node.title', params: ['42']),
equals('Node #42'),
);
});
test('multiple {} parameter substitution', () {
expect(
LocalizationService.lookup(enData, 'proximityAlerts.rangeInfo',
params: ['50', '500', 'm', '200']),
equals('Range: 50-500 m (default: 200)'),
);
});
test('partial path resolving to a Map returns the key as fallback', () {
expect(
LocalizationService.lookup(enData, 'actions'),
equals('actions'),
);
});
});
}