import 'dart:convert'; import 'dart:io'; import 'package:etm_powersync_app/models/load_config_entry.dart'; import 'package:etm_powersync_app/services/load_priority.dart'; import 'package:flutter_test/flutter_test.dart'; List _load(String fixture) { final raw = File('test/fixtures/$fixture').readAsStringSync(); final map = jsonDecode(raw) as Map; return (map['loadConfigs'] as List) .cast>() .map(LoadConfigEntry.new) .toList(); } void main() { // ─────────────────────────────────────────────────────────────────────────── group('§7-1 — aller-retour neutre', () { test('le dump réel de .75 se réécrit à l\'identique, charges utiles vides comprises', () { final entries = _load('loadconfig_hems75.json'); final payload = flattenToPayload(groupByDomain(entries).groups); // Même nombre d'entrées, mêmes maps — clé par clé, y compris sgReady:{} et // powerLevels:[] que la sérialisation par méta-objet du plugin produit. expect(payload.length, entries.length); for (var i = 0; i < payload.length; i++) { expect(payload[i], equals(entries[i].raw), reason: 'la charge ${entries[i].id} a été altérée par l\'aller-retour'); } }); test('une clé inconnue de l\'app est transportée telle quelle', () { // Simule une box plus récente qui aurait ajouté un champ que l'app ne modélise pas. final entry = LoadConfigEntry({ 'id': 'x', 'label': 'X', 'adapter': 'relay-router', 'mode': 'fixed', 'enabled': true, 'priority': 1, 'champInconnuDeLApp': {'imbriqué': [1, 2, 3]}, }); final patched = entry.patched(priority: 7); expect(patched['champInconnuDeLApp'], equals({'imbriqué': [1, 2, 3]})); expect(patched['priority'], 7); }); }); // ─────────────────────────────────────────────────────────────────────────── group('§7-2 — aplatissement réversible', () { test('config contiguë : lire → deux niveaux → réaplatir donne l\'ordre plat d\'origine', () { final entries = _load('loadconfig_multidomain.json'); final grouped = groupByDomain(entries); expect(grouped.isContiguous, isTrue); final payload = flattenToPayload(grouped.groups); final replayed = [ for (final m in payload) '${m['id']}:${m['priority']}', ]; final original = [ for (final e in grouped.flatOrder) '${e.id}:${e.priority}', ]; expect(replayed, equals(original)); }); test('les deux niveaux sont bien reconstitués dans l\'ordre d\'apparition', () { final grouped = groupByDomain(_load('loadconfig_multidomain.json')); expect(grouped.groups.map((g) => g.domain).toList(), equals(['ev', 'ecs', 'hvac', kUnclassifiedDomain])); expect(grouped.groups.first.loads.length, 2); }); test('le rang de domaine vient de la liste à plat, PAS du défaut app', () { // Ordre en place volontairement contraire à kDefaultDomainOrder : ecs avant ev. final entries = [ LoadConfigEntry({'id': 'a', 'domain': 'ecs', 'priority': 1, 'adapter': 'relay-router'}), LoadConfigEntry({'id': 'b', 'domain': 'ev', 'priority': 2, 'adapter': 'etmvariableload'}), ]; final grouped = groupByDomain(entries); expect(grouped.isContiguous, isTrue); // L'ordre RÉEL de la box est respecté, le défaut app ne s'impose pas à la lecture. expect(grouped.groups.map((g) => g.domain).toList(), equals(['ecs', 'ev'])); expect(kDefaultDomainOrder.first, 'ev'); // le défaut existe mais ne s'applique pas ici }); }); // ─────────────────────────────────────────────────────────────────────────── group('§7-3 — config non contiguë', () { test('l\'entrelacement est détecté et nommé', () { final grouped = groupByDomain(_load('loadconfig_interleaved.json')); expect(grouped.isContiguous, isFalse); expect(grouped.interleavedDomains, contains('ev')); }); test('l\'ordre RÉEL de la box est exposé, pas une version regroupée', () { final grouped = groupByDomain(_load('loadconfig_interleaved.json')); expect( grouped.flatOrder.map((e) => e.domain ?? kUnclassifiedDomain).toList(), equals(['ev', 'ecs', 'ev', 'hvac', kUnclassifiedDomain]), reason: 'flatOrder doit montrer l\'arbitrage tel qu\'il est, entrelacement compris', ); }); test('la normalisation est un geste explicite, jamais un effet de la lecture', () { final grouped = groupByDomain(_load('loadconfig_interleaved.json')); // La lecture seule ne réordonne rien : les priorités d'origine sont intactes. expect(grouped.flatOrder.map((e) => e.priority).toList(), equals([1, 2, 3, 4, 5])); expect(grouped.flatOrder.map((e) => e.id).first, '{11111111-1111-4111-8111-111111111111}'); // Ce n'est qu'en appelant explicitement la normalisation que l'ordre change. final normalized = flattenToPayload(applyDefaultDomainOrder(grouped.groups)); final evRanks = [ for (final m in normalized) if (m['domain'] == 'ev') m['priority'] as int, ]; expect(evRanks, equals([1, 2]), reason: 'après normalisation, ev est contigu et en tête'); }); }); // ─────────────────────────────────────────────────────────────────────────── group('§4 — domaine : saisie, jamais déduction', () { test('une charge sans domain tombe en « non classé » et n\'est jamais rangée d\'office', () { final grouped = groupByDomain(_load('loadconfig_multidomain.json')); final unclassified = grouped.groups.firstWhere((g) => g.isUnclassified); expect(unclassified.loads.map((e) => e.id), contains('seche-serviettes')); // Le sèche-serviettes est un relay-router, comme le chauffe-eau (ecs). // Il ne doit PAS avoir été déduit comme ECS : un relais n'est pas forcément un ECS. final ecs = grouped.groups.firstWhere((g) => g.domain == 'ecs'); expect(ecs.loads.map((e) => e.id), isNot(contains('seche-serviettes'))); }); test('un domain vide ou blanc est traité comme absent, pas comme un domaine nommé', () { final e = LoadConfigEntry({'id': 'a', 'domain': ' ', 'priority': 1}); expect(e.domain, isNull); }); test('un domaine inconnu de l\'app est conservé, pas écrasé', () { final grouped = groupByDomain([ LoadConfigEntry({'id': 'a', 'domain': 'pool-heater', 'priority': 1}), ]); expect(grouped.groups.single.domain, 'pool-heater'); // Et il part en queue à la normalisation, sans disparaître. expect(applyDefaultDomainOrder(grouped.groups).single.domain, 'pool-heater'); }); }); // ─────────────────────────────────────────────────────────────────────────── group('claimedThingIds — miroir de loadconfig.cpp:150-166', () { test('relay-router revendique ses relais', () { final e = _load('loadconfig_hems75.json').single; expect(e.claimedThingIds, { '{8538782f-2c8c-4a30-bfce-c8140f791c9b}', '{2ebe6bef-829b-4695-9d11-ddb4c16c5448}', '{b033b212-1adb-4df0-ba2b-8fa477de52a2}', }); }); test('etmvariableload revendique le Thing qui porte son propre id', () { final e = LoadConfigEntry({ 'id': '{11111111-1111-4111-8111-111111111111}', 'adapter': 'etmvariableload', }); expect(e.claimedThingIds, {'{11111111-1111-4111-8111-111111111111}'}); }); test('sg-ready revendique les relais de tous ses états', () { final e = _load('loadconfig_multidomain.json') .firstWhere((c) => c.id == 'clim-salon'); expect(e.claimedThingIds, { '{aaaa1111-0000-4000-8000-000000000001}', '{aaaa1111-0000-4000-8000-000000000002}', }); }); test('les formes avec et sans accolades sont normalisées', () { final withBraces = LoadConfigEntry({ 'id': 'x', 'adapter': 'relay-router', 'relays': [{'thingId': '{ABCD1234-0000-4000-8000-000000000001}', 'powerW': 1}], }); final without = LoadConfigEntry({ 'id': 'y', 'adapter': 'relay-router', 'relays': [{'thingId': 'abcd1234-0000-4000-8000-000000000001', 'powerW': 1}], }); expect(withBraces.claimedThingIds, equals(without.claimedThingIds)); }); }); // ─────────────────────────────────────────────────────────────────────────── group('§4 — miroir de validateSet()', () { test('identifiant en double refusé', () { final c = validateSet([ LoadConfigEntry({'id': 'dup', 'priority': 1}), LoadConfigEntry({'id': 'dup', 'priority': 2}), ]); expect(c?.kind, 'duplicateId'); expect(c?.id, 'dup'); }); test('un Thing commandé par deux charges ACTIVES est refusé', () { final relay = [{'thingId': '{8538782f-2c8c-4a30-bfce-c8140f791c9b}', 'powerW': 500}]; final c = validateSet([ LoadConfigEntry({'id': 'a', 'label': 'A', 'adapter': 'relay-router', 'enabled': true, 'relays': relay}), LoadConfigEntry({'id': 'b', 'label': 'B', 'adapter': 'relay-router', 'enabled': true, 'relays': relay}), ]); expect(c?.kind, 'thingClaimedTwice'); expect(c?.firstLabel, 'A'); expect(c?.secondLabel, 'B'); }); test('une charge DÉSACTIVÉE ne revendique rien — pas de conflit', () { final relay = [{'thingId': '{8538782f-2c8c-4a30-bfce-c8140f791c9b}', 'powerW': 500}]; final c = validateSet([ LoadConfigEntry({'id': 'a', 'label': 'A', 'adapter': 'relay-router', 'enabled': true, 'relays': relay}), LoadConfigEntry({'id': 'b', 'label': 'B', 'adapter': 'relay-router', 'enabled': false, 'relays': relay}), ]); expect(c, isNull); }); test('la config réelle de .75 passe la validation', () { expect(validateSet(_load('loadconfig_hems75.json')), isNull); }); }); }