mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-02-12 16:52:51 +00:00
Merge pull request #39 from dougborg/pr/localization-fixes
Replace deprecated localization APIs and add test coverage
This commit is contained in:
12
.github/workflows/workflow.yml
vendored
12
.github/workflows/workflow.yml
vendored
@@ -58,8 +58,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Validate localizations
|
||||
run: dart run scripts/validate_localizations.dart
|
||||
- name: Run tests
|
||||
run: flutter test
|
||||
|
||||
- name: Generate icons and splash screens
|
||||
run: |
|
||||
@@ -110,8 +110,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Validate localizations
|
||||
run: dart run scripts/validate_localizations.dart
|
||||
- name: Run tests
|
||||
run: flutter test
|
||||
|
||||
- name: Generate icons and splash screens
|
||||
run: |
|
||||
@@ -155,8 +155,8 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
|
||||
- name: Validate localizations
|
||||
run: dart run scripts/validate_localizations.dart
|
||||
- name: Run tests
|
||||
run: flutter test
|
||||
|
||||
- name: Generate icons and splash screens
|
||||
run: |
|
||||
|
||||
@@ -80,9 +80,9 @@ fi
|
||||
# Build the dart-define arguments
|
||||
DART_DEFINE_ARGS="--dart-define=OSM_PROD_CLIENTID=$OSM_PROD_CLIENTID --dart-define=OSM_SANDBOX_CLIENTID=$OSM_SANDBOX_CLIENTID"
|
||||
|
||||
# Validate localizations before building
|
||||
echo "Validating localizations..."
|
||||
dart run scripts/validate_localizations.dart || exit 1
|
||||
# Run tests before building
|
||||
echo "Running tests..."
|
||||
flutter test || exit 1
|
||||
echo
|
||||
|
||||
appver=$(grep "version:" pubspec.yaml | head -1 | cut -d ':' -f 2 | tr -d ' ' | cut -d '+' -f 1)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
177
test/services/localization_service_test.dart
Normal file
177
test/services/localization_service_test.dart
Normal 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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user