Scaffold the Flutter app with Italian l10n and region configuration
Creates the app skeleton and the seam everything else in the project hangs off: region-specific data lives in an asset file, not in code, so adding a region later is a new JSON file rather than a refactor. Notable choices: - applicationId and namespace are it.nuvolari.app rather than the doubled it.nuvolari.nuvolari that `flutter create` produces, with the Kotlin package and the iOS bundle identifiers moved to match. - SDK levels are pinned instead of inherited from `flutter.*`. Google Play requires API 36, and that is a release blocker rather than something to let a Flutter upgrade change silently. minSdk 24 is the highest floor the planned dependencies impose. - Riverpod and Dio-free for now, no code generation: see docs/stack-decisions.md. - Italian is the l10n source language, so app_it.arb is the template rather than a translation of an English original. The region parser rejects rather than repairs. An inverted bounding box, a map centre outside its own bounds, an unknown adapter name, a duplicate zone code or an empty attribution list all throw with the offending field named. Each of those would otherwise fail silently and visibly wrong: a swapped latitude and longitude renders the radar in the wrong place, an unknown adapter falling back to mock would show demo frames where live data was expected, and a missing attribution is a licence violation rather than a cosmetic gap. Tests run against the asset that actually ships and against a captured copy of the live ARPA CAP feed, so the eleven zone codes in the config are checked against the eleven the feed really emits rather than against a list retyped from documentation. Verified: dart format clean, flutter analyze 0 issues, 50 tests passing, flutter build appbundle --debug produces an AAB with applicationId it.nuvolari.app, minSdk 24, targetSdk 36. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nuvolari/core/region/geo.dart';
|
||||
|
||||
void main() {
|
||||
group('GeoBounds.fromJson', () {
|
||||
test('parses [west, south, east, north]', () {
|
||||
final bounds = GeoBounds.fromJson(<double>[6.55, 43.95, 9.30, 46.55]);
|
||||
|
||||
expect(bounds.west, 6.55);
|
||||
expect(bounds.south, 43.95);
|
||||
expect(bounds.east, 9.30);
|
||||
expect(bounds.north, 46.55);
|
||||
});
|
||||
|
||||
test('accepts integers as well as doubles', () {
|
||||
final bounds = GeoBounds.fromJson(<num>[6, 44, 9, 46]);
|
||||
|
||||
expect(bounds.west, 6.0);
|
||||
expect(bounds.north, 46.0);
|
||||
});
|
||||
|
||||
test('rejects a list of the wrong length', () {
|
||||
expect(
|
||||
() => GeoBounds.fromJson(<double>[6.55, 43.95, 9.30]),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects non-numeric entries', () {
|
||||
expect(
|
||||
() => GeoBounds.fromJson(<Object>[6.55, '43.95', 9.30, 46.55]),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a value that is not a list', () {
|
||||
expect(() => GeoBounds.fromJson('6.55,43.95'), throwsFormatException);
|
||||
});
|
||||
|
||||
// Swapping latitude and longitude produces an inverted box. Normalising it
|
||||
// would render the radar in the wrong place with no error, so it must throw.
|
||||
test('rejects an inverted box rather than normalising it', () {
|
||||
expect(
|
||||
() => GeoBounds.fromJson(<double>[9.30, 43.95, 6.55, 46.55]),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => GeoBounds.fromJson(<double>[6.55, 46.55, 9.30, 43.95]),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a degenerate box', () {
|
||||
expect(
|
||||
() => GeoBounds.fromJson(<double>[6.55, 43.95, 6.55, 46.55]),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects out-of-range coordinates', () {
|
||||
expect(
|
||||
() => GeoBounds.fromJson(<double>[-181, 43.95, 9.30, 46.55]),
|
||||
throwsFormatException,
|
||||
);
|
||||
expect(
|
||||
() => GeoBounds.fromJson(<double>[6.55, 43.95, 9.30, 91]),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('GeoBounds', () {
|
||||
const piemonte = GeoBounds(
|
||||
west: 6.55,
|
||||
south: 43.95,
|
||||
east: 9.30,
|
||||
north: 46.55,
|
||||
);
|
||||
|
||||
test('computes its centre', () {
|
||||
expect(piemonte.center.longitude, closeTo(7.925, 1e-9));
|
||||
expect(piemonte.center.latitude, closeTo(45.25, 1e-9));
|
||||
});
|
||||
|
||||
test('contains points inside and on the edge', () {
|
||||
expect(piemonte.contains(const GeoPoint(7.686, 45.070)), isTrue);
|
||||
expect(piemonte.contains(const GeoPoint(6.55, 43.95)), isTrue);
|
||||
});
|
||||
|
||||
test('excludes points outside', () {
|
||||
// Milan is outside the region but inside the padded box, which is
|
||||
// intended: the box is a render extent, not an administrative boundary.
|
||||
expect(piemonte.contains(const GeoPoint(9.19, 45.46)), isTrue);
|
||||
// Venice, well outside.
|
||||
expect(piemonte.contains(const GeoPoint(12.33, 45.44)), isFalse);
|
||||
// Palermo, south.
|
||||
expect(piemonte.contains(const GeoPoint(13.36, 38.12)), isFalse);
|
||||
});
|
||||
|
||||
test('round-trips through toJson', () {
|
||||
expect(GeoBounds.fromJson(piemonte.toJson()), piemonte);
|
||||
});
|
||||
});
|
||||
|
||||
group('WebMercator', () {
|
||||
test('maps the prime meridian and equator to the centre', () {
|
||||
expect(WebMercator.xFromLongitude(0), closeTo(0.5, 1e-12));
|
||||
expect(WebMercator.yFromLatitude(0), closeTo(0.5, 1e-12));
|
||||
});
|
||||
|
||||
test('maps the antimeridian to the edges', () {
|
||||
expect(WebMercator.xFromLongitude(-180), closeTo(0.0, 1e-12));
|
||||
expect(WebMercator.xFromLongitude(180), closeTo(1.0, 1e-12));
|
||||
});
|
||||
|
||||
test('y decreases as latitude increases', () {
|
||||
expect(
|
||||
WebMercator.yFromLatitude(46.55),
|
||||
lessThan(WebMercator.yFromLatitude(43.95)),
|
||||
);
|
||||
});
|
||||
|
||||
// Beyond the Mercator limit the projection diverges; clamping keeps the
|
||||
// transform finite instead of producing infinity.
|
||||
test('clamps latitudes beyond the Mercator limit', () {
|
||||
expect(WebMercator.yFromLatitude(89.9).isFinite, isTrue);
|
||||
expect(WebMercator.yFromLatitude(-89.9).isFinite, isTrue);
|
||||
expect(
|
||||
WebMercator.yFromLatitude(90),
|
||||
closeTo(WebMercator.yFromLatitude(WebMercator.maxLatitude), 1e-12),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nuvolari/core/region/region_config.dart';
|
||||
import 'package:nuvolari/core/region/region_repository.dart';
|
||||
|
||||
/// An in-memory bundle so repository behaviour can be tested without the real
|
||||
/// asset bundle, which `flutter test` does not populate.
|
||||
class _InMemoryAssetBundle extends CachingAssetBundle {
|
||||
_InMemoryAssetBundle(this._contents);
|
||||
|
||||
final Map<String, String> _contents;
|
||||
|
||||
@override
|
||||
Future<ByteData> load(String key) async {
|
||||
final value = _contents[key];
|
||||
if (value == null) {
|
||||
throw FlutterError('asset not found: $key');
|
||||
}
|
||||
return ByteData.sublistView(Uint8List.fromList(utf8.encode(value)));
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
const assetPath = 'assets/regions/piemonte.json';
|
||||
|
||||
// These assertions run against the file that actually ships, so a bad edit to
|
||||
// the asset fails the build rather than the app at run time.
|
||||
group('the shipped Piemonte asset', () {
|
||||
late RegionConfig config;
|
||||
late String source;
|
||||
|
||||
setUpAll(() {
|
||||
source = File(assetPath).readAsStringSync();
|
||||
config = RegionConfig.parse(source);
|
||||
});
|
||||
|
||||
test('exists and parses', () {
|
||||
expect(File(assetPath).existsSync(), isTrue);
|
||||
expect(config.id, 'piemonte');
|
||||
expect(config.displayName, 'Piemonte');
|
||||
expect(config.timeZone, 'Europe/Rome');
|
||||
});
|
||||
|
||||
test('is declared in pubspec.yaml', () {
|
||||
final pubspec = File('pubspec.yaml').readAsStringSync();
|
||||
|
||||
expect(pubspec, contains('assets/regions/'));
|
||||
});
|
||||
|
||||
test('bounds contain the major Piedmont cities', () {
|
||||
final cities = <String, List<double>>{
|
||||
'Torino': <double>[7.686, 45.070],
|
||||
'Cuneo': <double>[7.549, 44.393],
|
||||
'Novara': <double>[8.622, 45.446],
|
||||
'Alessandria': <double>[8.615, 44.913],
|
||||
'Verbania': <double>[8.552, 45.921],
|
||||
'Asti': <double>[8.206, 44.900],
|
||||
'Biella': <double>[8.054, 45.563],
|
||||
'Vercelli': <double>[8.418, 45.322],
|
||||
};
|
||||
|
||||
for (final entry in cities.entries) {
|
||||
final inside =
|
||||
entry.value[0] >= config.bounds.west &&
|
||||
entry.value[0] <= config.bounds.east &&
|
||||
entry.value[1] >= config.bounds.south &&
|
||||
entry.value[1] <= config.bounds.north;
|
||||
expect(inside, isTrue, reason: '${entry.key} is outside the bbox');
|
||||
}
|
||||
});
|
||||
|
||||
// The eleven codes below are the ones the ARPA CAP feed actually emits.
|
||||
// Note that the Italian alphabet is used, so J and K do not exist.
|
||||
test('declares the eleven ARPA alert zones', () {
|
||||
const expectedCodes = <String>[
|
||||
'Piem-A',
|
||||
'Piem-B',
|
||||
'Piem-C',
|
||||
'Piem-D',
|
||||
'Piem-E',
|
||||
'Piem-F',
|
||||
'Piem-G',
|
||||
'Piem-H',
|
||||
'Piem-I',
|
||||
'Piem-L',
|
||||
'Piem-M',
|
||||
];
|
||||
|
||||
expect(
|
||||
config.alertZones.map((zone) => zone.code),
|
||||
orderedEquals(expectedCodes),
|
||||
);
|
||||
});
|
||||
|
||||
test('every alert zone has a name and at least one province', () {
|
||||
for (final zone in config.alertZones) {
|
||||
expect(zone.name, isNotEmpty, reason: '${zone.code} has no name');
|
||||
expect(
|
||||
zone.provinces,
|
||||
isNotEmpty,
|
||||
reason: '${zone.code} has no provinces',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('matches the zone codes present in the captured CAP feed', () {
|
||||
final cap = File('test/fixtures/arpa_cap_allerta.xml').readAsStringSync();
|
||||
final codesInFeed = RegExp(r'<areaDesc>([^<]+)</areaDesc>')
|
||||
.allMatches(cap)
|
||||
.map((match) => match.group(1)!)
|
||||
.toSet();
|
||||
|
||||
expect(codesInFeed, isNotEmpty);
|
||||
for (final code in codesInFeed) {
|
||||
expect(
|
||||
config.zoneByCode(code),
|
||||
isNotNull,
|
||||
reason: 'the CAP feed emits $code but the region config omits it',
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Radar-DPC is CC BY-SA and OpenStreetMap is ODbL: both credits are legal
|
||||
// obligations, not decoration, so their absence must fail the build.
|
||||
test('carries the mandatory attributions', () {
|
||||
final ids = config.attributions.map((a) => a.id).toSet();
|
||||
|
||||
expect(ids, containsAll(<String>['dpc', 'osm']));
|
||||
|
||||
final dpc = config.attributions.firstWhere((a) => a.id == 'dpc');
|
||||
expect(dpc.license, 'CC BY-SA');
|
||||
expect(dpc.text, contains('Radar-DPC'));
|
||||
|
||||
final osm = config.attributions.firstWhere((a) => a.id == 'osm');
|
||||
expect(osm.license, 'ODbL');
|
||||
expect(osm.text, contains('OpenStreetMap'));
|
||||
});
|
||||
|
||||
test('links the official alert bulletin', () {
|
||||
expect(config.alerts.officialBulletinUrl, startsWith('https://'));
|
||||
expect(config.alerts.officialBulletinUrl, contains('arpa.piemonte.it'));
|
||||
});
|
||||
|
||||
// ARPA Piemonte radar needs an authorization we do not have, so it must not
|
||||
// be reachable from configuration yet.
|
||||
test('does not offer the ARPA radar adapter', () {
|
||||
expect(
|
||||
config.radar.availableAdapters,
|
||||
isNot(contains(RadarAdapter.arpa)),
|
||||
);
|
||||
});
|
||||
|
||||
test('defaults to the offline mock radar source', () {
|
||||
expect(config.radar.defaultAdapter, RadarAdapter.mock);
|
||||
expect(config.radar.product, 'VMI');
|
||||
expect(config.radar.frameInterval, const Duration(minutes: 5));
|
||||
});
|
||||
});
|
||||
|
||||
group('RegionRepository', () {
|
||||
test('loads and parses a region from the bundle', () async {
|
||||
final source = File(assetPath).readAsStringSync();
|
||||
final repository = RegionRepository(
|
||||
bundle: _InMemoryAssetBundle(<String, String>{assetPath: source}),
|
||||
);
|
||||
|
||||
final config = await repository.load('piemonte');
|
||||
|
||||
expect(config.id, 'piemonte');
|
||||
});
|
||||
|
||||
test('builds the asset path from the region id', () {
|
||||
expect(RegionRepository.assetPathFor('piemonte'), assetPath);
|
||||
expect(
|
||||
RegionRepository.assetPathFor('lombardia'),
|
||||
'assets/regions/lombardia.json',
|
||||
);
|
||||
});
|
||||
|
||||
test('propagates a parse failure instead of falling back', () async {
|
||||
final repository = RegionRepository(
|
||||
bundle: _InMemoryAssetBundle(<String, String>{assetPath: '{"id": 1}'}),
|
||||
);
|
||||
|
||||
await expectLater(
|
||||
repository.load('piemonte'),
|
||||
throwsA(isA<FormatException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('propagates a missing asset', () async {
|
||||
final repository = RegionRepository(
|
||||
bundle: _InMemoryAssetBundle(<String, String>{}),
|
||||
);
|
||||
|
||||
await expectLater(repository.load('atlantis'), throwsA(isA<Error>()));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:nuvolari/core/region/region_config.dart';
|
||||
|
||||
/// A minimal valid document. Individual tests mutate a copy of this so each one
|
||||
/// states exactly the one thing it is about.
|
||||
Map<String, Object?> validDocument() => <String, Object?>{
|
||||
'id': 'testregion',
|
||||
'displayName': 'Test Region',
|
||||
'timeZone': 'Europe/Rome',
|
||||
'bbox': <double>[6.55, 43.95, 9.30, 46.55],
|
||||
'map': <String, Object?>{
|
||||
'center': <double>[7.95, 45.25],
|
||||
'zoom': <String, Object?>{'min': 6.0, 'max': 13.0, 'initial': 7.2},
|
||||
},
|
||||
'sources': <String, Object?>{
|
||||
'radar': <String, Object?>{
|
||||
'defaultAdapter': 'mock',
|
||||
'availableAdapters': <String>['mock', 'dpc'],
|
||||
'product': 'VMI',
|
||||
'frameIntervalMinutes': 5,
|
||||
},
|
||||
'forecast': <String, Object?>{
|
||||
'defaultAdapter': 'metno',
|
||||
'availableAdapters': <String>['metno'],
|
||||
},
|
||||
'alerts': <String, Object?>{
|
||||
'defaultAdapter': 'arpaCap',
|
||||
'availableAdapters': <String>['arpaCap'],
|
||||
'officialBulletinUrl': 'https://example.org/bulletin.pdf',
|
||||
},
|
||||
},
|
||||
'alertZones': <Object?>[
|
||||
<String, Object?>{
|
||||
'code': 'Test-A',
|
||||
'name': 'Zone A',
|
||||
'provinces': <String>['AA'],
|
||||
},
|
||||
],
|
||||
'attributions': <Object?>[
|
||||
<String, Object?>{
|
||||
'id': 'src',
|
||||
'text': 'Source',
|
||||
'license': 'CC BY-SA',
|
||||
'url': 'https://example.org',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
RegionConfig parseWith(void Function(Map<String, Object?> doc) mutate) {
|
||||
final doc = validDocument();
|
||||
mutate(doc);
|
||||
return RegionConfig.fromJson(doc);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('RegionConfig.fromJson', () {
|
||||
test('parses a complete document', () {
|
||||
final config = RegionConfig.fromJson(validDocument());
|
||||
|
||||
expect(config.id, 'testregion');
|
||||
expect(config.displayName, 'Test Region');
|
||||
expect(config.timeZone, 'Europe/Rome');
|
||||
expect(config.bounds.west, 6.55);
|
||||
expect(config.map.center.longitude, 7.95);
|
||||
expect(config.map.zoom.initial, 7.2);
|
||||
expect(config.radar.defaultAdapter, RadarAdapter.mock);
|
||||
expect(config.radar.product, 'VMI');
|
||||
expect(config.radar.frameInterval, const Duration(minutes: 5));
|
||||
expect(config.forecast.defaultAdapter, ForecastAdapter.metno);
|
||||
expect(config.alerts.defaultAdapter, AlertAdapter.arpaCap);
|
||||
expect(config.alertZones, hasLength(1));
|
||||
expect(config.attributions, hasLength(1));
|
||||
});
|
||||
|
||||
test('looks zones up by their feed code', () {
|
||||
final config = RegionConfig.fromJson(validDocument());
|
||||
|
||||
expect(config.zoneByCode('Test-A')?.name, 'Zone A');
|
||||
expect(config.zoneByCode('Test-Z'), isNull);
|
||||
});
|
||||
|
||||
test('reports the missing field by name', () {
|
||||
expect(
|
||||
() => parseWith((doc) => doc.remove('displayName')),
|
||||
throwsA(
|
||||
isA<FormatException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('displayName'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// An unknown adapter must not silently fall back: falling back to mock
|
||||
// would show demo frames where live data was expected.
|
||||
test('rejects an unknown radar adapter', () {
|
||||
expect(
|
||||
() => parseWith((doc) {
|
||||
final radar =
|
||||
(doc['sources']! as Map<String, Object?>)['radar']!
|
||||
as Map<String, Object?>;
|
||||
radar['defaultAdapter'] = 'satellite';
|
||||
radar['availableAdapters'] = <String>['satellite'];
|
||||
}),
|
||||
throwsA(
|
||||
isA<FormatException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('satellite'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a default adapter missing from the available list', () {
|
||||
expect(
|
||||
() => parseWith((doc) {
|
||||
final radar =
|
||||
(doc['sources']! as Map<String, Object?>)['radar']!
|
||||
as Map<String, Object?>;
|
||||
radar['defaultAdapter'] = 'dpc';
|
||||
radar['availableAdapters'] = <String>['mock'];
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a map centre outside the bounding box', () {
|
||||
expect(
|
||||
() => parseWith((doc) {
|
||||
(doc['map']! as Map<String, Object?>)['center'] = <double>[
|
||||
12.33,
|
||||
45.44,
|
||||
];
|
||||
}),
|
||||
throwsA(
|
||||
isA<FormatException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('outside bbox'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects an initial zoom outside the min/max range', () {
|
||||
expect(
|
||||
() => parseWith((doc) {
|
||||
(doc['map']! as Map<String, Object?>)['zoom'] = <String, Object?>{
|
||||
'min': 6.0,
|
||||
'max': 13.0,
|
||||
'initial': 20.0,
|
||||
};
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a non-positive frame interval', () {
|
||||
expect(
|
||||
() => parseWith((doc) {
|
||||
((doc['sources']! as Map<String, Object?>)['radar']!
|
||||
as Map<String, Object?>)['frameIntervalMinutes'] =
|
||||
0;
|
||||
}),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects duplicate alert zone codes', () {
|
||||
expect(
|
||||
() => parseWith((doc) {
|
||||
doc['alertZones'] = <Object?>[
|
||||
<String, Object?>{
|
||||
'code': 'Test-A',
|
||||
'name': 'Zone A',
|
||||
'provinces': <String>['AA'],
|
||||
},
|
||||
<String, Object?>{
|
||||
'code': 'Test-A',
|
||||
'name': 'Zone A again',
|
||||
'provinces': <String>['BB'],
|
||||
},
|
||||
];
|
||||
}),
|
||||
throwsA(
|
||||
isA<FormatException>().having(
|
||||
(e) => e.message,
|
||||
'message',
|
||||
contains('duplicate'),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// Every source we render carries a credit obligation, so a config with no
|
||||
// attributions is a licensing bug and must not load.
|
||||
test('rejects an empty attributions list', () {
|
||||
expect(
|
||||
() => parseWith((doc) => doc['attributions'] = <Object?>[]),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects an empty alert zone list', () {
|
||||
expect(
|
||||
() => parseWith((doc) => doc['alertZones'] = <Object?>[]),
|
||||
throwsFormatException,
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts an attribution with no stated license', () {
|
||||
final config = parseWith((doc) {
|
||||
doc['attributions'] = <Object?>[
|
||||
<String, Object?>{
|
||||
'id': 'arpa',
|
||||
'text': 'Arpa Piemonte',
|
||||
'license': null,
|
||||
'url': 'https://www.arpa.piemonte.it',
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
expect(config.attributions.single.license, isNull);
|
||||
expect(config.attributions.single.text, 'Arpa Piemonte');
|
||||
});
|
||||
});
|
||||
|
||||
group('RegionConfig.parse', () {
|
||||
test('parses JSON text', () {
|
||||
final config = RegionConfig.parse(jsonEncode(validDocument()));
|
||||
|
||||
expect(config.id, 'testregion');
|
||||
});
|
||||
|
||||
test('rejects JSON that is not an object', () {
|
||||
expect(() => RegionConfig.parse('[1, 2, 3]'), throwsFormatException);
|
||||
});
|
||||
|
||||
test('rejects malformed JSON', () {
|
||||
expect(() => RegionConfig.parse('{not json'), throwsFormatException);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user