Repository navigation
Expand file tree
/
Copy pathtest_coordinator.py
More file actions
2164 lines (1862 loc) · 119 KB
/
Copy pathtest_coordinator.py
File metadata and controls
2164 lines (1862 loc) · 119 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# fmt: off
# pylint: disable=line-too-long
"""Unit tests for the discovery catalogue coordinator (coordinator.py) - container validation and report collection."""
import inspect
from datetime import datetime
from mock_base import MockBase
from coordinator import CAPABILITY_KEYS, CONTAINER_SPEC, Coordinator, NOT_APPLICABLE_DEFAULTS, Redactor, SCHEMA_VERSION, SECTION_SPEC, VOCAB_CONTAINERS, inverter_definition, inverter_record, validate_report
from config import INVERTER_DEF
def _coordinator():
"""A Coordinator on a bare MockBase."""
base = MockBase()
return base, Coordinator(base)
def test_report_keeps_valid_containers():
"""A well-formed inverter record survives validation intact."""
base, coordinator = _coordinator()
coordinator.report("givtcp", {"inverters": [{
"device_id": "givtcp:SN1",
"inverter_type": "GE",
"functions": ["solar", "battery"],
"hardware_ids": {"serial": "SN1"},
"info": {"firmware": "D0.451"},
"ratings": {"soc_max": 9.5, "battery_rate_max": 3600},
"entities": {"soc_kw": {"entity_id": "sensor.predbat_givtcp_0_soc_kw", "domain": "sensor", "access": "r", "unit": "kWh"}},
}]})
record = coordinator.reports["givtcp"]["inverters"][0]
assert record["hardware_ids"] == {"serial": "SN1"}
assert record["ratings"]["soc_max"] == 9.5
assert record["functions"] == ["solar", "battery"]
assert record["entities"]["soc_kw"]["entity_id"] == "sensor.predbat_givtcp_0_soc_kw"
print("PASS: valid containers preserved")
return 0
def test_ratings_rejects_non_numeric():
"""A numbers-only container drops a string value rather than emitting it - the structural safety guarantee."""
base, coordinator = _coordinator()
coordinator.report("givtcp", {"inverters": [{"device_id": "givtcp:SN1", "ratings": {"battery_kwh": 9.5, "owner": "Bob Smith, 12 Acacia Ave"}}]})
ratings = coordinator.reports["givtcp"]["inverters"][0]["ratings"]
assert ratings == {"battery_kwh": 9.5}, ratings
print("PASS: non-numeric dropped from ratings")
return 0
def test_vocabulary_rejects_free_text():
"""Vocabulary lists take short lowercase tokens only, so free text cannot ride in on one."""
base, coordinator = _coordinator()
coordinator.report("givtcp", {"inverters": [{"device_id": "givtcp:SN1", "functions": ["battery", "My Home Battery!", "SOLAR"]}]})
assert coordinator.reports["givtcp"]["inverters"][0]["functions"] == ["battery"]
print("PASS: vocabulary constraint enforced")
return 0
def test_info_rejects_email_shaped_and_long_strings():
"""info takes vendor descriptors, not addresses - an @ or an over-long string is dropped."""
base, coordinator = _coordinator()
coordinator.report("ohme", {"chargers": [{"device_id": "ohme:CH1", "info": {"vendor": "Ohme", "login": "bob@example.com", "notes": "x" * 200}}]})
assert coordinator.reports["ohme"]["chargers"][0]["info"] == {"vendor": "Ohme"}
print("PASS: info rejects email-shaped and over-long values")
return 0
def test_credential_named_field_rejected():
"""A field whose NAME trips is_secret_key is refused in any container, however it is nested."""
base, coordinator = _coordinator()
coordinator.report("fox", {"inverters": [{"device_id": "fox:SN1", "info": {"model": "H3", "api_key": "abcd1234", "auth_token": "zzz"}}]})
assert coordinator.reports["fox"]["inverters"][0]["info"] == {"model": "H3"}
print("PASS: credential-named fields refused")
return 0
def test_entities_credential_named_key_rejected():
"""An entities descriptor KEYED by a credential-shaped name is refused too - the guard covers every container, not just info."""
base, coordinator = _coordinator()
coordinator.report("fox", {"inverters": [{
"device_id": "fox:SN1",
"entities": {
"soc_kw": {"entity_id": "sensor.predbat_fox_0_soc_kw", "domain": "sensor", "access": "r"},
"api_key": {"entity_id": "sensor.predbat_fox_0_api_key", "domain": "sensor", "access": "r"},
},
}]})
entities = coordinator.reports["fox"]["inverters"][0]["entities"]
assert "soc_kw" in entities
assert "api_key" not in entities
print("PASS: entities key named for a credential refused")
return 0
def test_entities_free_text_unit_dropped_legitimate_descriptor_intact():
"""A free-text value in a typed descriptor field is dropped, while a fully legitimate descriptor survives with every field intact."""
base, coordinator = _coordinator()
coordinator.report("givtcp", {"inverters": [{
"device_id": "givtcp:SN1",
"entities": {
"charge_rate": {
"entity_id": "number.predbat_givtcp_0_charge_rate",
"domain": "number", "access": "rw", "unit": "W",
"device_class": "power", "min": 0, "max": 3600, "step": 100, "precision": 0,
},
"soc_kw": {
"entity_id": "sensor.predbat_givtcp_0_soc_kw",
"domain": "sensor", "access": "r", "unit": "not a real unit " * 6,
},
},
}]})
entities = coordinator.reports["givtcp"]["inverters"][0]["entities"]
assert entities["charge_rate"] == {
"entity_id": "number.predbat_givtcp_0_charge_rate",
"domain": "number", "access": "rw", "unit": "W",
"device_class": "power", "min": 0, "max": 3600, "step": 100, "precision": 0,
}, entities["charge_rate"]
assert entities["soc_kw"]["entity_id"] == "sensor.predbat_givtcp_0_soc_kw"
assert "unit" not in entities["soc_kw"]
print("PASS: free-text descriptor field dropped, legitimate descriptor intact")
return 0
def test_entities_options_preserves_realistic_values():
"""options takes bounded strings, not vocabulary tokens - realistic select values like time strings and mixed-case labels survive."""
base, coordinator = _coordinator()
coordinator.report("givtcp", {"inverters": [{
"device_id": "givtcp:SN1",
"entities": {
"charge_start_time": {"entity_id": "select.predbat_givtcp_0_charge_start_time", "domain": "select", "access": "rw", "options": ["00:00", "00:30"]},
"pause_mode": {"entity_id": "select.predbat_givtcp_0_pause_mode", "domain": "select", "access": "rw", "options": ["Disabled", "PauseCharge"]},
},
}]})
entities = coordinator.reports["givtcp"]["inverters"][0]["entities"]
assert entities["charge_start_time"]["options"] == ["00:00", "00:30"], entities["charge_start_time"]
assert entities["pause_mode"]["options"] == ["Disabled", "PauseCharge"], entities["pause_mode"]
print("PASS: options preserves realistic time and label values")
return 0
def test_coverage_accepts_numbers_booleans_and_vocabulary_lists():
"""coverage accepts numbers, booleans and lists of vocabulary tokens - Solcast's pv10/pv50/pv90 variants must survive whole."""
base, coordinator = _coordinator()
coordinator.report("solcast", {"forecasts": [{"device_id": "solcast:site1", "kind": "solar", "coverage": {"horizon_hours": 168, "variants": ["pv10", "pv50"]}}]})
coverage = coordinator.reports["solcast"]["forecasts"][0]["coverage"]
assert coverage == {"horizon_hours": 168, "variants": ["pv10", "pv50"]}, coverage
print("PASS: coverage accepts numbers and vocabulary-token lists")
return 0
def test_validate_report_never_raises_on_non_dict():
"""A non-dict report (None, a list, ...) never raises - it is treated as empty, per the 'never
raises' contract. "automatic" is absent, not defaulted to True: an empty/broken report carries
no evidence the component has any such concept, matching validate_report()'s "only carry
'automatic' through when the raw report actually provided one" rule."""
base, coordinator = _coordinator()
coordinator.report("broken", None)
assert coordinator.reports["broken"] == {"schema_version": SCHEMA_VERSION}
coordinator.report("broken2", ["not", "a", "dict"])
assert coordinator.reports["broken2"] == {"schema_version": SCHEMA_VERSION}
print("PASS: non-dict report never raises")
return 0
def test_validate_report_never_raises_on_a_non_list_section():
"""Review round: a section that is not a list of records is dropped whole, never iterated.
The "never raises" contract has to hold for a malformed SECTION exactly as it already does for
a malformed report: a scalar ({"inverters": 1}) is not iterable at all, so it raised TypeError
straight out of report(), and a bare string would have iterated as its characters - one log
line per character, for a report that was simply the wrong shape.
"""
base, coordinator = _coordinator()
coordinator.report("broken", {"inverters": 1, "meters": "octopus:1", "cars": {"device_id": "x"}})
assert coordinator.reports["broken"] == {"schema_version": SCHEMA_VERSION}, coordinator.reports["broken"]
# A valid section alongside a malformed one still survives - the drop is per section, not per report
coordinator.report("mixed", {"inverters": 1, "meters": [{"device_id": "octopus:m1", "direction": "import"}]})
assert "inverters" not in coordinator.reports["mixed"]
assert coordinator.reports["mixed"]["meters"][0]["device_id"] == "octopus:m1"
print("PASS: a non-list section is dropped rather than iterated")
return 0
def test_automatic_field_absent_when_component_omits_it():
"""A component with no automatic-config concept (Solcast: solar-forecast sourcing is a plain
apps.yaml choice, never something this catalogue auto-wires) omits "automatic" from its report
entirely, and neither the cleaned report nor the assembled catalogue's component status may
default it to True on its behalf - that would claim an auto-config relationship that does not
exist. A component that DOES report it (every other v1 reporter) keeps whatever value it sent,
explicit False included."""
base, coordinator = _coordinator()
coordinator.report("solar", {"forecasts": [{"device_id": "solcast:site1", "kind": "solar"}]})
coordinator.report("ohme", {"chargers": [{"device_id": "ohme:CH1"}], "automatic": False})
assert "automatic" not in coordinator.reports["solar"], coordinator.reports["solar"]
assert coordinator.reports["ohme"]["automatic"] is False
components = coordinator.assemble()["components"]
assert "automatic" not in components["solar"], components["solar"]
assert components["ohme"]["automatic"] is False
print("PASS: automatic is absent when a component's report never provided it, and preserved otherwise")
return 0
def test_unknown_container_dropped():
"""A container this schema does not know is dropped - there is no untyped path into the catalogue."""
base, coordinator = _coordinator()
coordinator.report("givtcp", {"inverters": [{"device_id": "givtcp:SN1", "scratchpad": {"anything": "at all"}}]})
assert "scratchpad" not in coordinator.reports["givtcp"]["inverters"][0]
print("PASS: unknown container dropped")
return 0
def test_record_without_device_id_dropped():
"""device_id is the one genuinely required field; a record without it cannot be referred to, so it is dropped."""
base, coordinator = _coordinator()
coordinator.report("givtcp", {"inverters": [{"hardware_ids": {"serial": "SN1"}}, {"device_id": "givtcp:SN2"}]})
records = coordinator.reports["givtcp"]["inverters"]
assert len(records) == 1 and records[0]["device_id"] == "givtcp:SN2"
print("PASS: record without device_id dropped")
return 0
def test_report_is_idempotent_and_versioned():
"""Re-reporting replaces the prior report, and the schema version is stamped."""
base, coordinator = _coordinator()
coordinator.report("givtcp", {"inverters": [{"device_id": "givtcp:A"}]})
coordinator.report("givtcp", {"inverters": [{"device_id": "givtcp:A"}, {"device_id": "givtcp:B"}]})
assert len(coordinator.reports["givtcp"]["inverters"]) == 2
assert coordinator.reports["givtcp"]["schema_version"] == SCHEMA_VERSION
print("PASS: report replaces and is versioned")
return 0
def test_meter_sub_record_validated():
"""A meter's nested tariff record is validated with the same container rules."""
base, coordinator = _coordinator()
coordinator.report("octopus", {"meters": [{
"device_id": "octopus:m1", "direction": "import",
"account_ids": {"mpan": "1234567890123"},
"tariff": {"info": {"tariff_code": "E-1R-AGILE-24-10-01-A"}, "flags": ["agile"], "ratings": {"standing_charge_p": 47.5}, "secret_token": "nope"},
}]})
tariff = coordinator.reports["octopus"]["meters"][0]["tariff"]
assert tariff["info"]["tariff_code"] == "E-1R-AGILE-24-10-01-A"
assert tariff["flags"] == ["agile"]
assert "secret_token" not in tariff
print("PASS: nested tariff record validated")
return 0
def test_credential_guard_fires_inside_sub_record_container():
"""The credential guard is shared code, so it fires identically for a key nested inside a sub-record's container (tariff.info), not just at record level."""
base, coordinator = _coordinator()
coordinator.report("octopus", {"meters": [{
"device_id": "octopus:m1", "direction": "import",
"tariff": {"info": {"tariff_code": "E-1R-AGILE-24-10-01-A", "api_key": "abcd1234"}},
}]})
tariff_info = coordinator.reports["octopus"]["meters"][0]["tariff"]["info"]
assert tariff_info == {"tariff_code": "E-1R-AGILE-24-10-01-A"}, tariff_info
print("PASS: credential guard fires inside a sub-record container")
return 0
class _StubRegistry:
"""Stands in for Components so assemble() can derive a status for every registry entry, and load_salt() can find a stub Storage."""
def __init__(self, active=(), alive=(), errors=None, all_names=(), components=None):
"""Record which component names are active, alive, failed to load, known at all, and any stub components (e.g. storage) registered by name."""
self._active = set(active)
self._alive = set(alive)
self._errors = errors or {}
self._all = list(all_names)
self._components = components or {}
def get_all(self):
"""Every component name the registry knows."""
return list(self._all)
def is_active(self, name):
"""Whether the component was constructed."""
return name in self._active
def is_alive(self, name):
"""Whether the component is running and fresh."""
return name in self._alive
def load_error(self, name):
"""Why the component failed to construct, or None."""
return self._errors.get(name)
def get_component(self, name):
"""The stub component registered under this name, or None."""
return self._components.get(name)
def test_inverter_record_omits_empty_containers():
"""An empty or unset container is left out of the record rather than written as {} or [].
Every reporter previously wrote its own `if info: record["info"] = info` ladder. Centralising
it means a reporter can pass whatever it gathered and let the builder decide, and the shape of
an inverters record lives in exactly one place.
"""
record = inverter_record("fox:ABC123", inverter_type="FoxCloud", composition="direct", functions=["solar", "battery"], info={}, ratings={}, entities={}, serials=[])
assert record == {"device_id": "fox:ABC123", "inverter_type": "FoxCloud", "composition": "direct", "functions": ["solar", "battery"]}, record
print("PASS: inverter_record omits empty containers")
return 0
def test_inverter_record_keeps_everything_populated():
"""Every populated field survives, including a falsy-but-real rating like 0 and control=False.
control is the one top-level field where a falsy value is itself the fact: False means a
monitor-only inverter. A drop rule "simplified" to `if not value: continue` would silently
turn every monitor-only inverter into one whose control is unknown, so it is pinned here.
"""
record = inverter_record(
"fox:ABC123",
inverter_type="FoxCloud",
control=False,
composition="gateway",
serials=["S1", "S2"],
measures_meter="fox:meter:M1",
functions=["solar"],
capabilities={"support_charge_freeze": True},
hardware_ids={"serial": "ABC123"},
info={"model": "H3-10.0"},
ratings={"battery_kwh": 10.4, "max_charge_w": 0},
entities={"charge_rate": {"entity_id": "number.fox_abc123_charge_rate", "domain": "number", "access": "rw"}},
)
assert record["serials"] == ["S1", "S2"]
assert record["measures_meter"] == "fox:meter:M1"
assert record["capabilities"] == {"support_charge_freeze": True}
assert record["hardware_ids"] == {"serial": "ABC123"}
assert record["ratings"]["max_charge_w"] == 0, "a real zero rating is data, not an empty container"
assert record["entities"]["charge_rate"]["domain"] == "number"
assert record["control"] is False, "control=False is a fact (monitor-only), not an empty value: {}".format(record)
print("PASS: inverter_record keeps every populated field")
return 0
def test_inverter_record_round_trips_through_validation():
"""A record the builder produced survives validate_report() unchanged.
The builder's whole job is producing something the coordinator will accept. If a field name
here ever drifts from SECTION_SPEC's structural list, validation silently drops it - so pin
that the two agree rather than trusting they do.
"""
record = inverter_record("fox:ABC123", inverter_type="FoxCloud", composition="direct", serials=["S1"], measures_meter="fox:meter:M1", functions=["solar"], hardware_ids={"serial": "ABC123"}, info={"model": "H3"}, ratings={"battery_kwh": 10.4})
cleaned = validate_report({"inverters": [record]}, "fox", print)["inverters"][0]
for field in ("device_id", "inverter_type", "composition", "serials", "measures_meter", "functions", "hardware_ids", "info", "ratings"):
assert field in cleaned, "{} was dropped by validate_report - builder and SECTION_SPEC disagree: {}".format(field, cleaned)
assert cleaned == record, "validate_report() changed a built record:\n built: {}\n cleaned: {}".format(record, cleaned)
print("PASS: a built record survives validation with every field intact")
return 0
def test_inverter_record_parameters_match_the_inverters_schema():
"""inverter_record()'s parameters are exactly the inverters section's fields - no more, no fewer.
The builder spells the schema out as named parameters so a mistyped field is a TypeError at
the call site. That only holds while its parameter list and the coordinator's schema agree:
rename a container in CONTAINER_SPEC, or add a structural field to SECTION_SPEC, and the builder
would go on emitting the old name, which validate_report() then silently drops from every
reporter's records at once. This passes today; its job is to fail the day the two drift.
"""
parameters = set(inspect.signature(inverter_record).parameters)
assert "device_id" in parameters, "device_id is the record's identity and must stay a parameter: {}".format(sorted(parameters))
schema = (set(SECTION_SPEC["inverters"]["structural"]) - {"device_id"}) | set(CONTAINER_SPEC) | set(VOCAB_CONTAINERS)
fields = parameters - {"device_id"}
assert fields == schema, "inverter_record() and the inverters schema disagree - missing from the builder: {}, not in the schema: {}".format(sorted(schema - fields), sorted(fields - schema))
print("PASS: inverter_record's parameters are exactly the inverters schema")
return 0
def test_inverter_record_fields_are_keyword_only():
"""Only device_id may be passed positionally; every other field must be named.
Fifteen optional parameters in a row are an easy place to slip by one: accepted positionally,
inverter_record("x:1", "direct", True) would quietly build inverter_type="direct",
control=True - a wrong record with nothing to say so.
"""
try:
inverter_record("gecloud:x", "direct", True)
except TypeError:
pass
else:
raise AssertionError("a positional second argument must be a TypeError, not silently taken as inverter_type")
parameters = inspect.signature(inverter_record).parameters
positional = [name for name, parameter in parameters.items() if parameter.kind is not inspect.Parameter.KEYWORD_ONLY]
assert positional == ["device_id"], "only device_id may be positional, got {}".format(positional)
print("PASS: inverter_record's schema fields are keyword-only")
return 0
def test_inverter_record_copies_the_callers_containers():
"""The record holds its own copies of the containers, never the caller's live objects.
refresh_discovery() stores the report it filed and compares the next build against it with ==.
If the record held the caller's own list - a reporter passing serials=self.serials - then
growing that list would grow the STORED report too, the rebuilt report would compare equal to
it, and the change would never be filed: a report frozen at its first state, the exact bug
refresh_discovery() exists to prevent.
"""
live_serials = ["S1"]
live_info = {"model": "GIV-GATEWAY"}
first = inverter_record("gecloud:gateway001", composition="gateway", serials=live_serials, info=live_info)
live_serials.append("S2")
live_info["firmware"] = "ARM 1"
second = inverter_record("gecloud:gateway001", composition="gateway", serials=live_serials, info=live_info)
assert first != second, "a rebuilt record must differ once the caller's live state has moved on: {}".format(first)
assert first["serials"] == ["S1"], "the first record's serials changed under it: {}".format(first["serials"])
assert first["info"] == {"model": "GIV-GATEWAY"}, "the first record's info changed under it: {}".format(first["info"])
print("PASS: inverter_record copies the caller's containers, so live state cannot freeze a report")
return 0
def test_inverter_record_normalises_sets_and_tuples():
"""A set or frozenset becomes a sorted list, a tuple becomes a list, and an empty frozenset is omitted.
validate_report() keeps only a list for a structural or vocabulary field, so anything else
would be silently dropped from the catalogue. A set is sorted rather than listed as-is: string
hashing is randomised per process, so list(some_set) comes out in a different order after a
restart and two dumps of the same hardware would diff for no reason.
"""
record = inverter_record(
"gecloud:gateway001",
serials={"S5", "S3", "S1", "S4", "S2"},
functions=frozenset({"solar", "battery"}),
capabilities=frozenset(),
flags=("monitor_only",),
)
assert record["serials"] == ["S1", "S2", "S3", "S4", "S5"], "a set should become a sorted list: {}".format(record.get("serials"))
assert record["functions"] == ["battery", "solar"], "a frozenset should become a sorted list: {}".format(record.get("functions"))
assert "capabilities" not in record, "an empty frozenset is empty and must be omitted: {}".format(record)
assert record["flags"] == ["monitor_only"], "a tuple should become a list: {}".format(record.get("flags"))
assert "capabilities" not in inverter_record("gecloud:gateway001", capabilities={}), "an empty capabilities dict must be omitted too"
print("PASS: inverter_record sorts sets, lists tuples and omits an empty frozenset")
return 0
def test_inverter_record_sorts_a_mixed_type_set_without_raising():
"""A set mixing types that cannot be compared is sorted by string form rather than raising.
A bare sorted() raises TypeError on {1, "a"}. build_discovery() is called from
refresh_discovery(), which catches it - so the component survives, but its whole report is
withheld, on every cycle, because nothing changes the offending data. Sorting by str() makes
every element comparable, and leaves the order of an all-string set exactly as before.
"""
record = inverter_record("x:1", serials={1, "a"})
assert record["serials"] == [1, "a"], "a mixed-type set should sort by string form: {}".format(record.get("serials"))
print("PASS: inverter_record sorts a mixed-type set by string form instead of raising")
return 0
def test_inverter_record_drops_an_empty_string():
"""An empty string is unset, like None - but control=False is a real value and is kept."""
record = inverter_record("fox:ABC123", inverter_type="", measures_meter="", composition="direct", control=False)
assert "inverter_type" not in record, "an empty inverter_type is unset and must be omitted: {}".format(record)
assert "measures_meter" not in record, "an empty measures_meter is unset and must be omitted: {}".format(record)
assert record["composition"] == "direct"
assert record["control"] is False, "control=False is a bool, not an empty string, and must be kept: {}".format(record)
print("PASS: inverter_record drops an empty string but keeps control=False")
return 0
def test_assemble_merges_sections_and_tags_source():
"""Records from several components merge into one list per section, each tagged with its source."""
base, coordinator = _coordinator()
coordinator.report("givtcp", {"inverters": [{"device_id": "givtcp:A", "inverter_type": "GE"}]})
coordinator.report("gecloud", {"inverters": [{"device_id": "gecloud:b", "inverter_type": "GEC"}]})
catalogue = coordinator.assemble()
sources = sorted(record["source"] for record in catalogue["inverters"])
assert sources == ["gecloud", "givtcp"], sources
assert catalogue["schema_version"] == SCHEMA_VERSION
assert catalogue["generated"]
print("PASS: sections merged and tagged with source")
return 0
def test_catalogue_reassembles_so_a_post_assembly_report_reaches_it():
"""Final review, Ruling R42: catalogue() used to return `self.assembled or self.assemble()`,
and assemble() was only ever called once, at the startup barrier - so a report filed AFTER
that point (a retry, a rediscovered device, a changed tariff or vehicle, a later-discovered
forecast site) could never reach a dump, no matter how many times catalogue() was called
afterwards. catalogue() must re-assemble on every call so later work is always reflected."""
base, coordinator = _coordinator()
coordinator.report("givtcp", {"inverters": [{"device_id": "givtcp:A", "inverter_type": "GE"}]})
coordinator.assemble()
first = coordinator.catalogue()
assert len(first["inverters"]) == 1, first["inverters"]
# A second inverter is reported AFTER assemble() has already run once - simulating GivTCP's own
# rediscovery, GE Cloud's retry, or any report arriving after the startup barrier.
coordinator.report("givtcp", {"inverters": [{"device_id": "givtcp:A", "inverter_type": "GE"}, {"device_id": "givtcp:B", "inverter_type": "GE"}]})
second = coordinator.catalogue()
assert len(second["inverters"]) == 2, "a report filed after assemble() already ran must still reach a later catalogue() call: {}".format(second["inverters"])
# catalogue_raw() must behave identically - see its own docstring.
assert len(coordinator.catalogue_raw()["inverters"]) == 2
print("PASS: catalogue() re-assembles on every call, so a post-assembly report reaches it")
return 0
def test_assemble_component_status():
"""A configured component gets a status distinguishing silent from timed out from failed.
A component the user never configured is absent entirely rather than carrying a status: the
registry holds 30 entries and a handful can report, so listing them all restated a static list
from components.py on every dump. Absence from the map is its status.
"""
base, coordinator = _coordinator()
base.components = _StubRegistry(active=["givtcp", "octopus", "gecloud"], alive=["givtcp", "octopus"],
errors={"fox": "No module named 'protobuf'"},
all_names=["givtcp", "octopus", "gecloud", "fox", "solis"])
coordinator.report("givtcp", {"inverters": [{"device_id": "givtcp:A"}]})
components = coordinator.assemble()["components"]
assert components["givtcp"]["status"] == "ok"
assert components["octopus"]["status"] == "no_report" # alive, capable of reporting, silent
assert components["gecloud"]["status"] == "not_started" # active but not alive
assert components["fox"]["status"] == "load_error"
assert "solis" not in components, "a component that was never configured must not appear at all"
print("PASS: component statuses derived, unconfigured components omitted")
return 0
class _SilentComponent:
"""A component with no build_discovery() - working as intended, never going to report."""
class _ReportingComponent:
"""A component that carries a reporter, so silence from it is an anomaly worth showing."""
def build_discovery(self):
"""Report nothing - this stub exists only to be recognised as reporter-carrying."""
return None
def test_component_status_omits_components_that_cannot_report():
"""An active component with no build_discovery() is left out; one that has it and stayed silent is kept.
This is the distinction that makes the map worth reading. Before it, "no_report" covered both
storage/web/db/ha - which are working perfectly and will never report - and a component whose
reporter genuinely failed to fire. The first group is the overwhelming majority, so it buried
the second. Dropping it is what turns a silent reporter back into a visible anomaly.
"""
base, coordinator = _coordinator()
base.components = _StubRegistry(
active=["storage", "givtcp"],
alive=["storage", "givtcp"],
all_names=["storage", "givtcp"],
components={"storage": _SilentComponent(), "givtcp": _ReportingComponent()},
)
components = coordinator.assemble()["components"]
assert "storage" not in components, "a component with no reporter must not be filed as no_report"
assert components["givtcp"]["status"] == "no_report", "a component that CAN report and did not is the anomaly worth keeping"
print("PASS: only components capable of reporting are held to account for silence")
return 0
def test_component_status_reported_at_set_only_for_ok():
"""reported_at carries a UTC timestamp for a component that has actually reported, and stays
None for every other status - the field was previously never populated anywhere, so a
published catalogue showed reported_at: null forever even for a component reporting normally."""
base, coordinator = _coordinator()
base.components = _StubRegistry(active=["givtcp", "octopus"], alive=["octopus"], all_names=["givtcp", "octopus", "solis"])
coordinator.report("givtcp", {"inverters": [{"device_id": "givtcp:A"}]})
components = coordinator.assemble()["components"]
assert components["givtcp"]["status"] == "ok"
reported_at = components["givtcp"]["reported_at"]
assert reported_at, "an 'ok' component must carry a reported_at timestamp"
datetime.fromisoformat(reported_at) # must parse as a real ISO-8601 timestamp, not just be truthy
assert components["octopus"]["reported_at"] is None, "a component that has not reported must not have a timestamp"
assert "solis" not in components, "an unconfigured component is absent, so it has no reported_at at all"
print("PASS: reported_at is set only for a component with status ok")
return 0
def test_observations_duplicate_serial():
"""The same serial claimed by two components is recorded, not resolved."""
base, coordinator = _coordinator()
coordinator.report("givtcp", {"inverters": [{"device_id": "givtcp:SN1", "hardware_ids": {"serial": "SA2242"}}]})
coordinator.report("gecloud", {"inverters": [{"device_id": "gecloud:sn1", "hardware_ids": {"serial": "sa2242"}}]})
conflicts = coordinator.assemble()["observations"]["conflicts"]
duplicate = [entry for entry in conflicts if entry["kind"] == "duplicate_serial"]
assert duplicate and sorted(duplicate[0]["claimed_by"]) == ["gecloud", "givtcp"]
assert any(entry["kind"] == "multiple_inverter_sources" for entry in conflicts)
print("PASS: duplicate serial and multiple sources observed")
return 0
def test_observations_duplicate_serial_sees_a_gateways_fronted_batteries():
"""Review round: a gateway record claims the batteries it fronts, so GE Cloud + GivTCP on the same battery collides.
GE Cloud in gateway composition reports the GATEWAY's serial in hardware_ids and the fronted
battery serials in the structural `serials` list, while GivTCP reports each battery's own
serial in hardware_ids. Grouping only on hardware_ids.serial therefore missed the single most
likely real-world double-claim - two components controlling the same physical battery - which
is the observation this catalogue release exists to count.
"""
base, coordinator = _coordinator()
coordinator.report("gecloud", {"inverters": [{"device_id": "gecloud:GW1", "hardware_ids": {"serial": "GW1"}, "composition": "gateway", "serials": ["BAT001", "BAT002"]}]})
coordinator.report("givtcp", {"inverters": [{"device_id": "givtcp:BAT001", "hardware_ids": {"serial": "bat001"}}]})
conflicts = coordinator.assemble()["observations"]["conflicts"]
duplicate = [entry for entry in conflicts if entry["kind"] == "duplicate_serial"]
assert duplicate, "a battery fronted by a gateway and claimed directly by GivTCP is a duplicate serial: {}".format(conflicts)
assert duplicate[0]["serial"] == "bat001", duplicate
assert sorted(duplicate[0]["claimed_by"]) == ["gecloud", "givtcp"], duplicate
# The gateway's own serials must not collide with themselves - one source cannot conflict with itself
base, coordinator = _coordinator()
coordinator.report("gecloud", {"inverters": [{"device_id": "gecloud:GW1", "hardware_ids": {"serial": "GW1"}, "composition": "gateway", "serials": ["BAT001", "BAT002"]}]})
assert not [entry for entry in coordinator.assemble()["observations"]["conflicts"] if entry["kind"] == "duplicate_serial"], "a gateway alone claims nothing twice"
print("PASS: a gateway's fronted serials take part in duplicate_serial detection")
return 0
def test_component_load_error_carries_no_free_text():
"""Review round: a load_error status records THAT a component failed, never the exception text.
components.load_error() is the raw str() of whatever construction raised - unbounded free text
that could carry a URL, an account value or a credential from a component's own error message.
Every other value in this document reaches it through a typed container that cannot hold one;
a dump gets attached to a public issue, so the one field outside that boundary is removed
rather than trusted to the shape guard. The message stays in the log and on the
component-status entity, which is where a maintainer already reads it.
"""
base, coordinator = _coordinator()
base.components = _StubRegistry(all_names=["fox"], errors={"fox": "auth failed for https://api.example.com/?key=SECRET123"})
entry = coordinator.assemble()["components"]["fox"]
assert entry["status"] == "load_error", entry
assert "error" not in entry, "the raw exception text must not be published into the catalogue: {}".format(entry)
assert "SECRET123" not in str(coordinator.catalogue()), "no part of an exception message may reach the published catalogue"
print("PASS: a load_error status carries no free-text exception message")
return 0
def test_observations_contested_cars_and_meters():
"""A charger component and an intelligent-device component both claiming cars is recorded, as are two import meters."""
base, coordinator = _coordinator()
coordinator.report("ohme", {"chargers": [{"device_id": "ohme:CH1"}], "cars": [{"device_id": "ohme:v1"}]})
coordinator.report("octopus", {"cars": [{"device_id": "octopus:d1"}], "meters": [{"device_id": "octopus:m1", "direction": "import"}]})
coordinator.report("kraken", {"meters": [{"device_id": "kraken:m1", "direction": "import"}]})
conflicts = coordinator.assemble()["observations"]["conflicts"]
kinds = {entry["kind"] for entry in conflicts}
assert "contested_car_slots" in kinds and "multiple_import_meters" in kinds, kinds
print("PASS: contested cars and multiple import meters observed")
return 0
def test_observations_resulting_config():
"""The config discovery did NOT set is recorded alongside it, for comparison."""
base, coordinator = _coordinator()
base.args.update({"num_inverters": 2, "num_cars": 1, "inverter_type": ["GE", "GEC"]})
coordinator.report("givtcp", {"inverters": [{"device_id": "givtcp:A"}]})
resulting = coordinator.assemble()["observations"]["resulting_config"]
assert resulting["num_inverters"] == 2 and resulting["inverter_type"] == ["GE", "GEC"]
print("PASS: resulting config recorded")
return 0
def _redacting_coordinator():
"""A coordinator with a fixed salt, so pseudonym tokens are reproducible inside one test."""
base, coordinator = _coordinator()
coordinator.salt = "test-salt-0001"
return base, coordinator
def test_account_ids_pseudonymised_and_stable():
"""account_ids values never appear in the clear, and the same value maps to the same token throughout."""
base, coordinator = _redacting_coordinator()
coordinator.report(
"octopus",
{
"meters": [{"device_id": "octopus:1234567890123", "direction": "import", "account_ids": {"mpan": "1234567890123", "account": "A-1234ABCD"}}],
"programmes": [{"device_id": "axle:site1", "kind": "vpp", "meter": "octopus:1234567890123"}],
},
)
catalogue = coordinator.catalogue()
text = str(catalogue)
assert "1234567890123" not in text, "raw MPAN must not survive redaction"
assert "A-1234ABCD" not in text
meter = catalogue["meters"][0]
assert meter["account_ids"]["mpan"].startswith("#")
# the cross-link still resolves to the same meter
assert catalogue["programmes"][0]["meter"] == meter["device_id"]
print("PASS: account ids pseudonymised, cross-link preserved")
return 0
def test_account_id_noted_in_two_forms_across_records_resolves_to_one_token():
"""Final review, Ruling R41: _note() used to derive the token from whichever textual form it
was handed and overwrite any mapping an earlier call had already made for a shared variant, so
the SAME real-world identifier noted twice in different forms produced two different,
order-dependent tokens - defeating the stability a maintainer relies on to correlate records
within one dump. Reproduces the review's own two verified cases in one test: an MPAN reported
as a string by one component and as a float by another, and an account id reported in two
case/separator forms by two components - each pair must resolve to exactly one token."""
base, coordinator = _redacting_coordinator()
coordinator.report("octopus", {"meters": [{"device_id": "octopus:1234567890123", "direction": "import", "account_ids": {"mpan": "1234567890123"}}]})
coordinator.report("kraken", {"meters": [{"device_id": "kraken:1234567890123", "direction": "import", "account_ids": {"mpan": 1234567890123.0}}]})
coordinator.report("ohme", {"chargers": [{"device_id": "ohme:CH1", "account_ids": {"account": "AB-12CD34"}}]})
coordinator.report("solis", {"inverters": [{"device_id": "solis:INV1", "account_ids": {"account": "ab_12cd34"}}]})
catalogue = coordinator.catalogue()
mpan_tokens = {record["source"]: record["account_ids"]["mpan"] for record in catalogue["meters"]}
assert mpan_tokens["octopus"] == mpan_tokens["kraken"], "a string mpan and its float echo must resolve to one token: {}".format(mpan_tokens)
account_tokens = {record["source"]: record["account_ids"]["account"] for record in catalogue["chargers"] + catalogue["inverters"] if "account_ids" in record}
assert account_tokens["ohme"] == account_tokens["solis"], "'AB-12CD34' and 'ab_12cd34' noted in different records must resolve to one token: {}".format(account_tokens)
print("PASS: an identifier noted in two different textual forms across two records resolves to exactly one token")
return 0
def test_note_returns_same_token_when_called_twice_with_different_variants():
"""Redactor._note() unit-level: calling it a second time with a DIFFERENT textual form of an
already-noted value must return the SAME token, not mint a fresh one - the fix builds the full
variant set before minting anything and reuses whatever token any variant already maps to.
Covers both axes _note() composes: numeric (string vs float) and identifier (case/separator)."""
redactor = Redactor("test-salt-0001")
token_a = redactor._note("1234567890123", substring=True)
token_b = redactor._note(1234567890123.0, substring=True)
assert token_a == token_b, "a string and its float echo must mint/reuse the same token: {} != {}".format(token_a, token_b)
token_c = redactor._note("AB-12CD34", substring=True)
token_d = redactor._note("ab_12cd34", substring=True)
assert token_c == token_d, "case/separator variants must mint/reuse the same token: {} != {}".format(token_c, token_d)
# And the reverse order - the variant noted FIRST here was noted SECOND above - to prove this
# is genuinely order-independent, not merely "the second call wins" restated.
redactor2 = Redactor("test-salt-0001")
token_e = redactor2._note(1234567890123.0, substring=True)
token_f = redactor2._note("1234567890123", substring=True)
assert token_e == token_f, "order must not matter: {} != {}".format(token_e, token_f)
print("PASS: _note() reuses one token across different textual forms of an identifier, regardless of call order")
return 0
def test_cross_link_resolves_without_coincidental_substring():
"""The device_id/meter cross-link resolves even when device_id does not literally embed the account
identifier as a substring - proving the link is via noting the owning record's device_id, not a
coincidence of the previous test's device_id happening to spell out the raw MPAN."""
base, coordinator = _redacting_coordinator()
coordinator.report(
"octopus",
{
"meters": [{"device_id": "octopus:m1", "direction": "import", "account_ids": {"mpan": "1234567890123"}}],
"programmes": [{"device_id": "axle:site1", "kind": "vpp", "meter": "octopus:m1"}],
},
)
catalogue = coordinator.catalogue()
meter = catalogue["meters"][0]
assert meter["device_id"] != "octopus:m1", "device_id of a record with account_ids must itself be pseudonymised"
assert catalogue["programmes"][0]["meter"] == meter["device_id"]
print("PASS: cross-link resolves without a coincidental substring match")
return 0
def test_measures_meter_cross_link_resolves():
"""The measures_meter cross-link (inverter -> meter) resolves the same way as meter (programme -> meter)."""
base, coordinator = _redacting_coordinator()
coordinator.report(
"givtcp",
{
"meters": [{"device_id": "givtcp:m1", "direction": "import", "account_ids": {"mpan": "1234567890123"}}],
"inverters": [{"device_id": "givtcp:inv1", "measures_meter": "givtcp:m1"}],
},
)
catalogue = coordinator.catalogue()
meter = catalogue["meters"][0]
assert meter["device_id"] != "givtcp:m1", "this test is only load-bearing if device_id actually changed - it must not stay equal by both sides going unredacted"
assert catalogue["inverters"][0]["measures_meter"] == meter["device_id"]
print("PASS: measures_meter cross-link resolves")
return 0
def test_pseudonym_differs_across_salts():
"""The same value under a different installation salt produces a different token."""
base_a, coordinator_a = _redacting_coordinator()
base_b, coordinator_b = _coordinator()
coordinator_b.salt = "a-different-salt"
report = {"meters": [{"device_id": "octopus:m", "direction": "import", "account_ids": {"mpan": "1234567890123"}}]}
coordinator_a.report("octopus", report)
coordinator_b.report("octopus", report)
token_a = coordinator_a.catalogue()["meters"][0]["account_ids"]["mpan"]
token_b = coordinator_b.catalogue()["meters"][0]["account_ids"]["mpan"]
assert token_a != token_b
print("PASS: tokens differ across installations")
return 0
def test_pseudonym_substituted_inside_entity_ids():
"""A vendor that embeds an identifier in an entity name does not republish what the field just hid."""
base, coordinator = _redacting_coordinator()
coordinator.report(
"solar",
{
"forecasts": [
{
"device_id": "solcast:abcdef123456",
"kind": "solar",
"account_ids": {"site_id": "abcdef123456"},
"entities": {"pv_forecast_today": {"entity_id": "sensor.predbat_solcast_abcdef123456_today", "domain": "sensor", "access": "r"}},
}
]
},
)
catalogue = coordinator.catalogue()
assert "abcdef123456" not in str(catalogue), catalogue["forecasts"][0]["entities"]
entity = catalogue["forecasts"][0]["entities"]["pv_forecast_today"]
assert entity["domain"] == "sensor" and entity["access"] == "r", "legitimate neighbouring descriptor fields are untouched"
print("PASS: pseudonymised value substituted inside entity ids")
return 0
def test_serials_and_tariff_codes_stay_clear():
"""Hardware identity and product codes stay readable - they are what makes a bug report diagnosable."""
base, coordinator = _redacting_coordinator()
coordinator.report("givtcp", {"inverters": [{"device_id": "givtcp:SA2242G123", "hardware_ids": {"serial": "SA2242G123"}, "info": {"firmware": "D0.451"}}]})
coordinator.report("octopus", {"meters": [{"device_id": "octopus:m", "direction": "import", "tariff": {"info": {"tariff_code": "E-1R-AGILE-24-10-01-A"}}}]})
text = str(coordinator.catalogue())
assert "SA2242G123" in text and "D0.451" in text and "E-1R-AGILE-24-10-01-A" in text
print("PASS: serials, firmware and tariff codes kept clear")
return 0
def test_misfiled_identifier_caught_by_shape_guard():
"""An MPAN is a number, so ratings accepts it - the shape guard pseudonymises it anyway and logs."""
base, coordinator = _redacting_coordinator()
coordinator.report("octopus", {"meters": [{"device_id": "octopus:m", "direction": "import", "ratings": {"standing_charge_p": 47.5, "supply_number": 1234567890123}}]})
catalogue = coordinator.catalogue()
assert "1234567890123" not in str(catalogue)
assert catalogue["meters"][0]["ratings"]["standing_charge_p"] == 47.5, "a real measurement is untouched"
print("PASS: misfiled identifier caught by the shape guard")
return 0
def test_misfiled_identifier_inside_entity_descriptor_caught():
"""An MPAN misfiled into a nested entity descriptor field (e.g. its "max") is still caught: entities
values are descriptor dicts, not scalars, so the shape guard has to recurse into them rather than
stringify the whole descriptor - the exact gap Correction 2 exists to close."""
base, coordinator = _redacting_coordinator()
coordinator.report(
"octopus",
{
"meters": [
{
"device_id": "octopus:m",
"direction": "import",
"entities": {"supply_number": {"entity_id": "sensor.predbat_octopus_m_supply_number", "domain": "sensor", "access": "r", "max": 1234567890123}},
}
]
},
)
catalogue = coordinator.catalogue()
assert "1234567890123" not in str(catalogue)
entity = catalogue["meters"][0]["entities"]["supply_number"]
assert entity["entity_id"] == "sensor.predbat_octopus_m_supply_number", "the descriptor itself survives, only the misfiled value is pseudonymised"
print("PASS: identifier misfiled inside a nested entity descriptor field caught")
return 0
def test_misfiled_email_inside_entity_id_caught():
"""entity_id is kept verbatim by validation (unlike every other descriptor field, which is cleaned by
type), so it is the one realistic path an email-shaped value can reach a clear container - info and
every other string field already refuse "@" at validation. The shape guard is what stands between a
buggy component's entity_id and a public dump."""
base, coordinator = _redacting_coordinator()
coordinator.report("ohme", {"chargers": [{"device_id": "ohme:CH1", "info": {"vendor": "Ohme"}, "entities": {"login": {"entity_id": "sensor.bob@example.com", "domain": "sensor", "access": "r"}}}]})
assert "bob@example.com" in str(coordinator.catalogue_raw()), "sanity check: the email really does reach the raw catalogue unguarded"
catalogue = coordinator.catalogue()
assert "bob@example.com" not in str(catalogue)
assert catalogue["chargers"][0]["info"]["vendor"] == "Ohme", "a legitimate neighbouring field is untouched"
print("PASS: email-shaped entity_id caught by the shape guard")
return 0
def test_misfiled_identifier_inside_vocabulary_list_caught():
"""A vocabulary token pattern allows digits, so an MPAN-shaped value misfiled into a flags/functions
list is still caught, even though vocabulary lists are validated and walked differently from the
dict-shaped clear containers."""
base, coordinator = _redacting_coordinator()
coordinator.report("octopus", {"meters": [{"device_id": "octopus:m", "direction": "import", "tariff": {"flags": ["agile", "1234567890123"]}}]})
catalogue = coordinator.catalogue()
assert "1234567890123" not in str(catalogue)
assert "agile" in catalogue["meters"][0]["tariff"]["flags"]
print("PASS: misfiled identifier inside a vocabulary list caught")
return 0
def test_catalogue_raw_is_unredacted():
"""catalogue_raw is the in-process view and keeps originals, so the redacted path is demonstrably doing work."""
base, coordinator = _redacting_coordinator()
coordinator.report("octopus", {"meters": [{"device_id": "octopus:m", "direction": "import", "account_ids": {"mpan": "1234567890123"}}]})
assert "1234567890123" in str(coordinator.catalogue_raw())
print("PASS: raw catalogue retains originals")
return 0
# --- Review round 2: root cause A - the shape guard's digit detection was too narrow ---
def test_misfiled_float_identifier_caught():
"""str(1234567890123.0) is '1234567890123.0', which an anchored ^\\d{10,}$ pattern rejects even
though _clean_number accepts floats happily - any component that passes an API number through
float() must not be able to launder an identifier that way."""
base, coordinator = _redacting_coordinator()
coordinator.report("octopus", {"meters": [{"device_id": "octopus:m", "direction": "import", "ratings": {"standing_charge_p": 47.5, "supply_number": 1234567890123.0}}]})
catalogue = coordinator.catalogue()
assert "1234567890123" not in str(catalogue)
assert catalogue["meters"][0]["ratings"]["standing_charge_p"] == 47.5
print("PASS: a misfiled identifier reported as a float is still caught")
return 0
def test_misfiled_identifier_embedded_or_separated_caught():
"""An identifier does not have to be a bare digit string to leak: embedded in prose ("MPAN
1234567890123"), broken up by formatting separators ("1234-5678-90123"), or written as a phone
number ("+447700900123") - only whitespace was stripped before, so all three previously
survived."""
base, coordinator = _redacting_coordinator()
coordinator.report(
"octopus",
{
"meters": [
{"device_id": "octopus:m1", "direction": "import", "info": {"supply": "MPAN 1234567890123"}},
{"device_id": "octopus:m2", "direction": "import", "info": {"ref": "1234-5678-90123"}},
{"device_id": "octopus:m3", "direction": "import", "info": {"contact": "+447700900123"}},
]
},
)
catalogue = coordinator.catalogue()
text = str(catalogue)
assert "1234567890123" not in text
assert "1234-5678-90123" not in text and "5678" not in text
assert "447700900123" not in text
assert catalogue["meters"][1]["device_id"] == "octopus:m2", "an ordinary device_id is a completely different shape and is untouched"
print("PASS: an embedded, separator-formatted or phone-shaped identifier is caught")
return 0
def test_misfiled_identifier_grouped_by_underscore_or_comma_caught():
"""Adversarial pass: an identifier grouped with "_" (a Python-style numeric literal) or ","
(thousands separators) reads identically to a human as the same identifier, but was found to
survive redaction untouched when only whitespace/hyphen/slash/dot were stripped before the
digit-run search - fixed by widening the stripped separator set rather than narrowing this
test to only the separators already handled."""
base, coordinator = _redacting_coordinator()
coordinator.report(
"octopus",
{
"meters": [
{"device_id": "octopus:m1", "direction": "import", "info": {"ref": "1_234_567_890_123"}, "ratings": {"standing_charge_p": 47.5}},
{"device_id": "octopus:m2", "direction": "import", "info": {"ref": "1,234,567,890,123"}},
]
},
)
catalogue = coordinator.catalogue()
text = str(catalogue)
assert "1_234_567_890_123" not in text and "1,234,567,890,123" not in text
assert "1234567890123" not in text
assert catalogue["meters"][0]["ratings"]["standing_charge_p"] == 47.5, "a real measurement is untouched"
print("PASS: an underscore- or comma-grouped identifier is caught")
return 0
def test_location_shaped_key_pseudonymised_regardless_of_value_shape():
"""latitude/longitude cannot be recognised as a pair from one value, so the guard checks the
KEY NAME instead - this sidesteps the pair problem entirely and cannot false-positive on an
ordinary rating like battery_kwh: 9.5."""
base, coordinator = _redacting_coordinator()
coordinator.report("solar", {"forecasts": [{"device_id": "solcast:site1", "kind": "solar", "ratings": {"latitude": 51.5074, "longitude": -0.1278, "horizon_hours": 168}}]})
catalogue = coordinator.catalogue()
ratings = catalogue["forecasts"][0]["ratings"]
assert str(ratings["latitude"]).startswith("#") and ratings["latitude"] != 51.5074
assert str(ratings["longitude"]).startswith("#") and ratings["longitude"] != -0.1278
assert ratings["horizon_hours"] == 168, "an ordinary rating under an unrelated key name is untouched"
print("PASS: a location-named key is pseudonymised regardless of its value's shape")
return 0
# --- Review round 3: NEW-1 - the shape guard, widened for round 2, over-corrected on ordinary floats ---
def test_legitimate_long_float_survives_unchanged_and_still_numeric():
"""NEW-1 (Critical): stripping "." and searching the whole repr for a 10+ digit run means any
float whose repr carries that many digits after the decimal point gets pseudonymised - and a
numbers-only container silently starts handing consumers a str. An efficiency, a percentage or
a unit conversion routinely produces exactly such a float. The guard must judge a NUMERIC
value on its INTEGER PART's digit count, not its full stringified repr. Also asserts a genuine
identifier on the SAME record WAS redacted, so this is not a false pass against a no-op
redact() - the same defect fixed in the timestamp test, applied here too."""
base, coordinator = _redacting_coordinator()
coordinator.report(
"givtcp",
{
"inverters": [
{
"device_id": "givtcp:inv1",
"account_ids": {"owner_ref": "1234567890123"},