summaryrefslogtreecommitdiff
path: root/test/hil/test_hil_select.py
blob: 5e6b16759cbb08daa189e8b1b01a411bb2588c7b (plain)
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
#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
# Unit tests for hil_select.py — pure logic, no hardware, no git. Run directly:
#   python3 test/hil/test_hil_select.py
import glob
import json
import os
import sys
import unittest

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import hil_flash
import hil_select
from hil_examples import device_tests, dual_tests, host_test

REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))


def real_rosters():
    """The actual rig rosters, for regression tests that need real-world data
    (a specific board/family/only-list) rather than the synthetic ROSTER above."""
    rosters = []
    for name in ('tinyusb.json', 'hfp.json'):
        path = os.path.join(REPO, 'test/hil', name)
        with open(path) as f:
            rosters.append((f'test/hil/{name}', json.load(f)['boards']))
    return rosters


def roster_flashers():
    """(roster path, board) for every board in the live rosters, `boards-skip`
    included: a parked board's flasher name must still dispatch, so that unparking it
    is not what discovers the name went stale."""
    for name in ('tinyusb.json', 'hfp.json'):
        path = os.path.join(REPO, 'test/hil', name)
        with open(path) as f:
            cfg = json.load(f)
        for key in ('boards', 'boards-skip'):
            for b in cfg.get(key, []):
                yield f'test/hil/{name}', b


def on_roster(tc, *names):
    """The subset of `names` currently in the live rig rosters, skipping the test
    when none are. Parking/unparking a board is routine rig maintenance and must not
    fail this suite: CI runs it right before the selector and treats a failure as
    'selector unusable', dropping PR scoping and annotating the run."""
    have = {b['name'] for _, boards in real_rosters() for b in boards}
    got = [n for n in names if n in have]
    if not got:
        tc.skipTest(f'not in the rig roster: {", ".join(names)}')
    return got


ROSTER = [
    # device-only, rp2040 family
    {'name': 'raspberry_pi_pico', 'uid': 'u1', 'flasher': {'name': 'openocd'},
     'tests': {'device': True, 'host': True, 'dual': True}},
    # device-only, stm32f4 family
    {'name': 'stm32f407disco', 'uid': 'u2', 'flasher': {'name': 'jlink'},
     'tests': {'device': True, 'host': False, 'dual': False}},
    # host-only board
    {'name': 'raspberry_pi_pico2', 'uid': 'u3', 'flasher': {'name': 'openocd'},
     'tests': {'device': False, 'host': True, 'dual': False}},
    # only-list board (espressif-style), flashed by the CI leg that splits on esptool
    {'name': 'espressif_s3_devkitm', 'uid': 'u4', 'flasher': {'name': 'esptool'},
     'tests': {'only': ['device/cdc_msc_freertos', 'host/device_info']}},
]
ROSTERS = [('test/hil/tinyusb.json', ROSTER)]


def sel(files):
    return hil_select.classify(files, REPO, ROSTERS)


class TestPortRule(unittest.TestCase):
    def test_dcd_rp2040_selects_pico_family_only(self):
        s = sel(['src/portable/raspberrypi/rp2040/dcd_rp2040.c'])
        self.assertFalse(s['full'])
        self.assertIn('raspberry_pi_pico', s['boards'])
        self.assertNotIn('stm32f407disco', s['boards'])
        self.assertNotIn('espressif_s3_devkitm', s['boards'])
        # device role: no host tests in pico's list
        self.assertTrue(all(not t.startswith('host/') for t in s['boards']['raspberry_pi_pico']))
        # host-only boards drop out entirely on a device-role change
        self.assertNotIn('raspberry_pi_pico2', s['boards'])

    def test_shared_port_file_is_both_roles(self):
        s = sel(['src/portable/synopsys/dwc2/dwc2_common.c'])
        self.assertFalse(s['full'])
        self.assertNotIn('raspberry_pi_pico', s['boards'])  # rp2040 is not a dwc2 family
        self.assertIn('stm32f407disco', s['boards'])        # stm32f4 is


class TestCoreRoleRule(unittest.TestCase):
    def test_usbd_selects_all_device_tests_everywhere(self):
        s = sel(['src/device/usbd.c'])
        self.assertFalse(s['full'])
        self.assertNotIn('raspberry_pi_pico2', s['boards'])  # host-only board dropped
        pico = s['boards']['raspberry_pi_pico']
        self.assertTrue(set(device_tests).issubset(set(pico)))
        self.assertTrue(set(dual_tests).issubset(set(pico)))   # dual survives device role
        self.assertTrue(all(not t.startswith('host/') for t in pico))
        # only-list board: selection intersects its only-list
        esp = s['boards']['espressif_s3_devkitm']
        self.assertEqual(esp, ['device/cdc_msc_freertos'])

    def test_host_change_drops_device(self):
        s = sel(['src/host/usbh.c'])
        self.assertFalse(s['full'])
        self.assertIn('raspberry_pi_pico2', s['boards'])
        self.assertNotIn('stm32f407disco', s['boards'])  # device-only board dropped


class TestClassRule(unittest.TestCase):
    def test_cdc_device_selects_cdc_examples_only(self):
        s = sel(['src/class/cdc/cdc_device.c'])
        self.assertFalse(s['full'])
        pico = s['boards']['raspberry_pi_pico']
        self.assertIn('device/cdc_msc', pico)
        self.assertIn('device/cdc_dual_ports', pico)
        self.assertNotIn('device/msc_dual_lun', pico)   # CFG_TUD_CDC 0 there
        self.assertNotIn('device/usbtest', pico)        # CFG_TUD_CDC 0 there
        self.assertTrue(all(not t.startswith('host/') for t in pico))

    def test_msc_host_selects_host_side(self):
        s = sel(['src/class/msc/msc_host.c'])
        self.assertFalse(s['full'])
        self.assertNotIn('stm32f407disco', s['boards'])  # device-only board
        pico2 = s['boards']['raspberry_pi_pico2']
        self.assertIn('host/msc_file_explorer', pico2)
        self.assertTrue(all(not t.startswith('device/') for t in pico2))


class TestClassIncludeEdges(unittest.TestCase):
    """A class header another class includes reaches that class's examples too.
    src/class/midi/midi{,2}_{device,host}.h include class/audio/audio.h, so
    midi_test's firmware contains audio.h - but the class rule derives macros from
    the directory name alone, so an audio.h change used to select only
    device/audio_test_freertos. On boards that skip that example the per-board
    intersection emptied and an audio.h-only PR ran ZERO HIL on them."""
    def test_edges_derived_from_includes(self):
        edges = hil_select.class_include_edges(REPO)
        self.assertEqual(edges.get('audio/audio.h'), {'midi'})
        self.assertEqual(edges.get('cdc/cdc.h'), {'net'})

    def test_audio_header_selects_midi_example(self):
        s = hil_select.classify(['src/class/audio/audio.h'], REPO, real_rosters())
        self.assertFalse(s['full'])
        # every board that runs device/midi_test at all must run it here (boards with
        # a tests.only list, e.g. espressif, run the freertos examples instead)
        by_name = {b['name']: b for _, bs in real_rosters() for b in bs}
        checked = 0
        for name, tests in s['boards'].items():
            if 'device/midi_test' in hil_select.board_tests(by_name[name]):
                self.assertIn('device/midi_test', tests, name)
                checked += 1
        self.assertTrue(checked)

    def test_audio_header_reaches_boards_that_skip_audio(self):
        # both skip device/audio_test_freertos: without the midi edge their
        # intersection is empty and they drop out of the selection entirely
        boards = on_roster(self, 'metro_m4_express', 'nrf54lm20dk')
        s = hil_select.classify(['src/class/audio/audio.h'], REPO, real_rosters())
        for board in boards:
            self.assertEqual(s['boards'].get(board), ['device/midi_test'], board)

    def test_edge_is_per_header_not_per_class(self):
        # midi includes audio.h, not audio_device.h: an audio_device change must
        # not drag midi's examples in
        s = hil_select.classify(['src/class/audio/audio_device.c'], REPO, real_rosters())
        self.assertFalse(s['full'])
        for tests in s['boards'].values():
            if tests != 'all':
                self.assertNotIn('device/midi_test', tests)


class TestFallbackRules(unittest.TestCase):
    def test_unknown_tool_is_full(self):
        s = sel(['tools/random_new_script.py'])
        self.assertTrue(s['full'])

    def test_docs_only_is_empty_not_full(self):
        s = sel(['docs/info/contributing.rst', 'README.rst'])
        self.assertFalse(s['full'])
        self.assertEqual(s['boards'], {})

    def test_bsp_family_selects_family_boards(self):
        s = sel(['hw/bsp/rp2040/family.cmake'])
        self.assertFalse(s['full'])
        self.assertIn('raspberry_pi_pico', s['boards'])
        self.assertEqual(s['boards']['raspberry_pi_pico'], 'all')
        self.assertNotIn('stm32f407disco', s['boards'])

    def test_bsp_board_narrows_to_board(self):
        s = sel(['hw/bsp/rp2040/boards/raspberry_pi_pico/board.h'])
        self.assertFalse(s['full'])
        self.assertEqual(list(s['boards'].keys()), ['raspberry_pi_pico'])

    def test_example_change_selects_that_example(self):
        s = sel(['examples/device/cdc_msc/src/main.c'])
        self.assertFalse(s['full'])
        self.assertEqual(s['boards']['raspberry_pi_pico'], ['device/cdc_msc'])

    def test_core_common_is_full(self):
        for f in ['src/tusb.c', 'src/common/tusb_fifo.c', 'src/osal/osal_freertos.h']:
            self.assertTrue(sel([f])['full'], f)

    def test_board_test_example_is_full(self):
        # board_test is the park/teardown firmware hil_test.py flashes on every board,
        # not an unlisted example: a regression there must not skip the whole rig
        for f in ['examples/device/board_test/src/main.c',
                  'examples/device/board_test/CMakeLists.txt']:
            self.assertTrue(sel([f])['full'], f)

    def test_harness_is_full(self):
        for f in ['test/hil/hil_test.py', '.github/workflows/build.yml', 'hw/mcu/nxp/x.c', 'lib/foo/x.c']:
            self.assertTrue(sel([f])['full'], f)

    def test_mixed_roles_no_pruning(self):
        s = sel(['src/device/usbd.c', 'src/host/usbh.c'])
        self.assertFalse(s['full'])
        self.assertIn('raspberry_pi_pico2', s['boards'])
        self.assertIn('stm32f407disco', s['boards'])

    def test_cmakelists_and_requirements_are_full(self):
        for f in ['src/CMakeLists.txt', 'examples/CMakeLists.txt',
                  'examples/device/CMakeLists.txt', 'test/hil/requirements.txt']:
            self.assertTrue(sel([f])['full'], f)

    def test_docs_txt_is_noncode(self):
        s = sel(['docs/info/changelog.txt'])
        self.assertFalse(s['full'])
        self.assertEqual(s['boards'], {})


class TestArgsEmission(unittest.TestCase):
    def test_args_for_scoped_selection(self):
        s = sel(['src/portable/raspberrypi/rp2040/dcd_rp2040.c'])
        args = hil_select.selection_args(s, ROSTERS)
        a = args['tinyusb.json']
        self.assertIn('-b raspberry_pi_pico', a)
        self.assertNotIn('stm32f407disco', a)
        self.assertIn('-bt raspberry_pi_pico:', a)   # device-only subset of a device+host board

    def test_args_full_is_empty(self):
        s = sel(['tools/random_new_script.py'])
        self.assertEqual(hil_select.selection_args(s, ROSTERS), {'tinyusb.json': ''})

    def test_args_all_board_gets_bare_b(self):
        s = sel(['hw/bsp/rp2040/boards/raspberry_pi_pico/board.h'])
        a = hil_select.selection_args(s, ROSTERS)['tinyusb.json']
        self.assertIn('-b raspberry_pi_pico', a)
        self.assertNotIn('-bt', a)

    def test_args_by_flasher_splits_esp_from_the_rest(self):
        s = sel(['src/device/usbd.c'])
        per = hil_select.selection_args_by_flasher(s, ROSTERS)['tinyusb.json']
        self.assertIn('espressif_s3_devkitm', per['esptool'])
        self.assertIn('raspberry_pi_pico', per['openocd'])
        self.assertNotIn('espressif_s3_devkitm', per.get('openocd', '') + per.get('jlink', ''))

    def test_args_by_flasher_omits_a_flasher_with_no_selected_board(self):
        # the esp CI leg must see no args at all here, not a filter matching zero boards
        s = sel(['hw/bsp/rp2040/boards/raspberry_pi_pico/board.h'])
        per = hil_select.selection_args_by_flasher(s, ROSTERS)['tinyusb.json']
        self.assertEqual(per, {'openocd': '-b raspberry_pi_pico'})

    def test_args_by_flasher_full_is_empty(self):
        s = sel(['tools/random_new_script.py'])
        self.assertEqual(hil_select.selection_args_by_flasher(s, ROSTERS), {'tinyusb.json': {}})

    def test_cli_diff_file(self):
        import subprocess, tempfile, json as j
        with tempfile.NamedTemporaryFile('w', suffix='.txt', delete=False) as f:
            f.write('src/class/cdc/cdc_device.c\n')
            path = f.name
        r = subprocess.run([sys.executable, os.path.join(REPO, 'test/hil/hil_select.py'),
                            '--diff-file', path, os.path.join(REPO, 'test/hil/tinyusb.json')],
                           capture_output=True, text=True)
        self.assertEqual(r.returncode, 0, r.stderr)
        out = j.loads(r.stdout)
        self.assertFalse(out['full'])
        self.assertIn('tinyusb.json', out['args'])
        self.assertTrue(any('cdc_device' in line for line in out['reasons']))
        os.unlink(path)


class TestRealRosterPortFamilies(unittest.TestCase):
    """Regression for port_families() missing espressif's dwc2 reference, which
    lives in a component CMakeLists.txt rather than family.cmake/family.mk."""
    def test_dwc2_change_selects_espressif_boards(self):
        boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
        s = hil_select.classify(['src/portable/synopsys/dwc2/dcd_dwc2.c'], REPO, real_rosters())
        self.assertFalse(s['full'])
        for board in boards:
            self.assertIn(board, s['boards'])


class TestOptionGatedPort(unittest.TestCase):
    """Regression: family_support.cmake compiles some ports from a build option
    (MAX3421_HOST=1 -> hcd_max3421.c), so a board's family file never names them."""
    # host-side option board (max3421 as host controller), off any max3421 family
    OPT_ROSTER = [('test/hil/opt.json', [
        {'name': 'fake_dual_board', 'uid': 'o1', 'flasher': {'name': 'jlink'},
         'build': {'args': ['MAX3421_HOST=1']},
         'tests': {'device': True, 'host': False, 'dual': True}},
        {'name': 'fake_host_board', 'uid': 'o2', 'flasher': {'name': 'jlink'},
         'variant': [{'name': 'fake_host_board', 'flags': '-DMAX3421_HOST=1'}],
         'tests': {'device': False, 'host': True, 'dual': False}},
        {'name': 'fake_off_board', 'uid': 'o3', 'flasher': {'name': 'jlink'},
         'variant': [{'name': 'fake_off_board', 'defines': ['MAX3421_HOST=0']}],
         'tests': {'device': True, 'host': True, 'dual': True}},
    ])]

    def test_real_roster_max3421_selects_option_board(self):
        boards = on_roster(self, 'metro_m4_express')
        s = hil_select.classify(['src/portable/analog/max3421/hcd_max3421.c'], REPO, real_rosters())
        self.assertFalse(s['full'])
        for board in boards:
            self.assertIn(board, s['boards'])

    def test_option_selects_via_args_defines_and_flags(self):
        s = hil_select.classify(['src/portable/analog/max3421/hcd_max3421.c'], REPO, self.OPT_ROSTER)
        self.assertFalse(s['full'])
        self.assertIn('fake_dual_board', s['boards'])    # build.args
        self.assertIn('fake_host_board', s['boards'])    # variant flags
        self.assertNotIn('fake_off_board', s['boards'])  # variant defines, but =0

    def test_device_role_port_does_not_pull_host_only_option_board(self):
        s = hil_select.classify(['src/portable/analog/max3421/dcd_max3421.c'], REPO, self.OPT_ROSTER)
        self.assertFalse(s['full'])
        self.assertNotIn('fake_host_board', s['boards'])  # host-only board, device change
        self.assertIn('fake_dual_board', s['boards'])     # device-capable option board

    def test_gates_parsed_from_family_support(self):
        self.assertEqual(hil_select.port_option_gates(REPO).get('analog/max3421'),
                         {'MAX3421_HOST'})

    def test_board_cmake_option_counts(self):
        """A board can enable a gated port in its own BSP rather than via the roster
        (hw/bsp/espressif/boards/*/board.cmake -> set(MAX3421_HOST 1)); board_options()
        must see those too, or such a board joining the roster is silently dropped."""
        self.assertIn('MAX3421_HOST',
                      hil_select.bsp_board_options('adafruit_feather_esp32s3', REPO))
        self.assertIn('CFG_TUH_RPI_PIO_USB',
                      hil_select.bsp_board_options('adafruit_fruit_jam', REPO))
        # commented-out `# set(MAX3421_HOST 1)` must not count
        self.assertNotIn('MAX3421_HOST',
                         hil_select.bsp_board_options('feather_nrf52840_express', REPO))

    def test_board_cmake_option_selects_off_family_board(self):
        # adafruit_feather_esp32s3 is not on any rig roster; stand it in as one to
        # prove the BSP-sourced option alone pulls a max3421 change onto the board
        roster = [('test/hil/opt.json', [
            {'name': 'adafruit_feather_esp32s3', 'uid': 'o1', 'flasher': {'name': 'esptool'},
             'tests': {'device': False, 'host': True, 'dual': False}}])]
        s = hil_select.classify(['src/portable/analog/max3421/hcd_max3421.c'], REPO, roster)
        self.assertFalse(s['full'])
        self.assertIn('adafruit_feather_esp32s3', s['boards'])

    def test_board_mk_option_is_ignored(self):
        """Make-only options must not select: HIL CI builds with CMake exclusively, so
        hw/bsp/nrf/boards/nrf5340dk/board.mk's MAX3421_HOST compiles nothing here."""
        roster = [('test/hil/opt.json', [
            {'name': 'nrf5340dk', 'uid': 'o1', 'flasher': {'name': 'jlink'},
             'tests': {'device': False, 'host': True, 'dual': False}}])]
        s = hil_select.classify(['src/portable/analog/max3421/hcd_max3421.c'], REPO, roster)
        self.assertFalse(s['full'])
        self.assertEqual(s['boards'], {})


class TestPortFamiliesCmakeOnly(unittest.TestCase):
    """port_families() is CMake-only (HIL CI never builds with Make) and matches on
    'port_dir/' so a port dir is not a prefix of a sibling."""
    def test_make_only_family_is_not_a_family(self):
        # hw/bsp/pic32mz has family.mk but no family.cmake
        self.assertEqual(hil_select.port_families('microchip/pic32mz', REPO), set())

    def test_prefix_port_does_not_inherit_sibling_families(self):
        # bare-substring matching let 'microchip/pic' match '.../microchip/pic32mz/...'
        self.assertEqual(hil_select.port_families('microchip/pic', REPO), set())

    def test_make_only_port_forces_full(self):
        s = sel(['src/portable/microchip/pic32mz/dcd_pic32mz.c'])
        self.assertTrue(s['full'])
        self.assertTrue(any('no board family' in r for r in s['reasons']), s['reasons'])

    def test_cmake_families_still_found(self):
        self.assertEqual(hil_select.port_families('raspberrypi/rp2040', REPO), {'rp2040'})
        self.assertIn('stm32f4', hil_select.port_families('synopsys/dwc2', REPO))


class TestPortFamiliesCoverage(unittest.TestCase):
    """Systematic guard: every real dcd_*/hcd_* port directory should map to at
    least one board family, so a future family.cmake/CMakeLists.txt layout that
    port_families() doesn't scan fails loudly instead of silently dropping boards
    (as espressif's dwc2 reference did - see TestRealRosterPortFamilies)."""
    # Ports with no board family: not a bug, just not wired into any rig board.
    # Add here (with a reason) only if port_families() legitimately can't find one.
    # A port listed here force-fulls (fail-open), so it is never under-selected.
    NO_FAMILY = {
        'template',           # reference/example port, not built by any board
        # hw/bsp/pic32mz has family.mk only (no family.cmake), and port_families()
        # is CMake-only because HIL CI builds every board with CMake - so this port
        # is compiled for no HIL board.
        'microchip/pic32mz',
        'microchip/pic',      # same: only ever referenced from pic32mz's family.mk
    }

    @staticmethod
    def _dcd_hcd_ports():
        portable_root = os.path.join(REPO, 'src/portable')
        ports = []
        for entry in sorted(os.listdir(portable_root)):
            d = os.path.join(portable_root, entry)
            if not os.path.isdir(d):
                continue
            if glob.glob(os.path.join(d, 'dcd_*.c')) or glob.glob(os.path.join(d, 'hcd_*.c')):
                ports.append(entry)
                continue
            for sub in sorted(os.listdir(d)):
                sd = os.path.join(d, sub)
                if os.path.isdir(sd) and (glob.glob(os.path.join(sd, 'dcd_*.c')) or
                                           glob.glob(os.path.join(sd, 'hcd_*.c'))):
                    ports.append(f'{entry}/{sub}')
        return ports

    def test_every_port_maps_to_a_family(self):
        ports = self._dcd_hcd_ports()
        self.assertTrue(ports)  # sanity: the scan itself found something
        for port in ports:
            if port in self.NO_FAMILY:
                continue
            fams = hil_select.port_families(port, REPO)
            self.assertTrue(fams, f'{port}: no family references this port '
                                   f'(port_families() scan gap, or add to NO_FAMILY)')


class TestRealRosterOnlyListTests(unittest.TestCase):
    """Regression for roster-only-list tests (e.g. espressif's hid_composite_freertos)
    being invisible to the selector because it only knew the shared hil_examples lists."""
    def test_only_list_example_change_selects_it(self):
        boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
        s = hil_select.classify(['examples/device/hid_composite_freertos/src/main.c'], REPO, real_rosters())
        self.assertFalse(s['full'])
        for board in boards:
            self.assertEqual(s['boards'][board], ['device/hid_composite_freertos'])

    def test_class_change_includes_only_list_boards(self):
        boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
        s = hil_select.classify(['src/class/hid/hid_device.c'], REPO, real_rosters())
        self.assertFalse(s['full'])
        for board in boards:
            self.assertIn(board, s['boards'])


class TestPortAndCoreRoleUseExtras(unittest.TestCase):
    """Regression: the port rule and core-role rule must thread the roster-only
    test universe (extras) the same way the class rule already does, so a DCD
    or device-stack change doesn't silently drop espressif's only-list tests
    (e.g. hid_composite_freertos) that aren't in the shared device_tests list."""
    def test_dcd_change_includes_only_list_test(self):
        boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
        s = hil_select.classify(['src/portable/synopsys/dwc2/dcd_dwc2.c'], REPO, real_rosters())
        self.assertFalse(s['full'])
        for board in boards:
            tests = s['boards'][board]
            self.assertIn('device/hid_composite_freertos', tests)
            self.assertIn('device/cdc_msc_freertos', tests)
            self.assertIn('device/audio_test_freertos', tests)
            self.assertIn('device/usbtest', tests)

    def test_core_device_change_includes_only_list_test(self):
        boards = on_roster(self, 'espressif_s3_devkitm', 'espressif_p4_function_ev')
        s = hil_select.classify(['src/device/usbd.c'], REPO, real_rosters())
        self.assertFalse(s['full'])
        for board in boards:
            tests = s['boards'][board]
            self.assertIn('device/hid_composite_freertos', tests)
            self.assertIn('device/cdc_msc_freertos', tests)
            self.assertIn('device/audio_test_freertos', tests)
            self.assertIn('device/usbtest', tests)

    def test_host_change_does_not_leak_device_only_list_test(self):
        s = hil_select.classify(['src/host/usbh.c'], REPO, real_rosters())
        self.assertFalse(s['full'])
        for board, tests in s['boards'].items():
            if tests == 'all':
                continue
            self.assertNotIn('device/hid_composite_freertos', tests, board)


class TestFamilies(unittest.TestCase):
    """`families` exists for consumers that build (not just test) the diff: most
    families have no rig board, so `boards` alone would compile nothing for them."""
    def test_off_rig_port_still_reports_family(self):
        s = sel(['src/portable/microchip/samx7x/dcd_samx7x.c'])
        self.assertFalse(s['full'])
        self.assertEqual(s['boards'], {})        # no same7x board on the rig
        self.assertEqual(s['families'], ['same7x'])

    def test_port_families_are_reported(self):
        s = sel(['src/portable/raspberrypi/rp2040/dcd_rp2040.c'])
        self.assertIn('rp2040', s['families'])

    def test_bsp_family_and_board_report_family(self):
        self.assertEqual(sel(['hw/bsp/rp2040/family.cmake'])['families'], ['rp2040'])
        self.assertEqual(sel(['hw/bsp/rp2040/boards/raspberry_pi_pico/board.h'])['families'],
                         ['rp2040'])

    def test_docs_only_has_no_families(self):
        self.assertEqual(sel(['docs/info/contributing.rst'])['families'], [])

    def test_full_selection_still_reports_families(self):
        """A full-matrix file must not hide the families of the other changed files:
        consumers that build from `families` (e.g. /pre-pr) ignore `boards` when full."""
        s = sel(['src/common/tusb_fifo.c', 'src/portable/microchip/samx7x/dcd_samx7x.c'])
        self.assertTrue(s['full'])
        self.assertIn('same7x', s['families'])
        # full stays full: every roster board, and no args to narrow the run
        self.assertEqual(set(s['boards']), {b['name'] for b in ROSTER})
        self.assertTrue(all(v == 'all' for v in s['boards'].values()))
        self.assertEqual(hil_select.selection_args(s, ROSTERS), {'tinyusb.json': ''})
        self.assertEqual(hil_select.selection_args_by_flasher(s, ROSTERS), {'tinyusb.json': {}})

    def test_family_order_does_not_matter(self):
        # same as above with the full-matrix file last (was the only order that worked)
        s = sel(['src/portable/microchip/samx7x/dcd_samx7x.c', 'src/common/tusb_fifo.c'])
        self.assertTrue(s['full'])
        self.assertIn('same7x', s['families'])


class TestGitDiffArgv(unittest.TestCase):
    def test_diff_disables_rename_detection(self):
        """Without --no-renames git reports only a rename's destination, so moving an
        HIL-relevant file to a non-code path would be classified as non-code only."""
        self.assertIn('--no-renames', hil_select.GIT_DIFF_ARGV)


class TestPortWithoutFamilyIsFull(unittest.TestCase):
    """A port dir no family file references must widen (full matrix), not silently
    contribute zero boards — the fail-open contract."""
    def test_unreferenced_port_forces_full(self):
        orig = hil_select.port_families
        hil_select.port_families = lambda port_dir, repo_root: set()
        try:
            s = sel(['src/portable/vendor/newip/dcd_newip.c'])
        finally:
            hil_select.port_families = orig
        self.assertTrue(s['full'])
        self.assertTrue(any('no board family' in r for r in s['reasons']), s['reasons'])


class TestRosterFlashersDispatch(unittest.TestCase):
    """hil_test and hil_pool_check resolve a board's flasher with a bare
    getattr(hil_flash, f'flash_{name}'), and hil_test does it inside a redirect_stdout —
    so a renamed or typo'd roster name raises an AttributeError whose output is swallowed,
    with nothing pointing at the roster as the thing to edit. Renaming a flash_*/reset_*
    pair without updating every roster must fail here instead."""

    def test_flash_and_reset_exist_for_every_roster_flasher(self):
        for path, board in roster_flashers():
            name = board['flasher']['name'].lower()
            for fn in (f'flash_{name}', f'reset_{name}'):
                self.assertTrue(callable(getattr(hil_flash, fn, None)),
                                f'{path}: {board["name"]} uses flasher "{name}" '
                                f'but hil_flash.{fn} does not exist')

    def test_firmware_suffix_known_for_every_roster_flasher(self):
        """find_firmware falls back to accepting .elf-or-.bin when a flasher is missing
        from FLASHER_SUFFIX, silently restoring the mismatch that map exists to catch."""
        for path, board in roster_flashers():
            name = board['flasher']['name'].lower()
            self.assertIn(name, hil_flash.FLASHER_SUFFIX,
                          f'{path}: {board["name"]} uses flasher "{name}" '
                          f'with no hil_flash.FLASHER_SUFFIX entry')


if __name__ == '__main__':
    unittest.main(verbosity=1)