hitl_tester.test_cases.bms.conftest

Pytest hooks for modifying tests as they run.

  1"""
  2Pytest hooks for modifying tests as they run.
  3"""
  4
  5import html
  6import platform
  7import re
  8import sys
  9import time
 10from datetime import datetime, timedelta, timezone
 11from pathlib import Path
 12
 13import pytest
 14from pytest_metadata.plugin import metadata_key
 15
 16from hitl_tester.modules.bms.bms_hw import BMSHardware
 17from hitl_tester.modules.bms.event_watcher import SerialWatcher
 18from hitl_tester.modules.bms.plateset import Plateset
 19from hitl_tester.modules.html_merger import Duration
 20from hitl_tester.modules.logger import logger
 21
 22_bms = BMSHardware(pytest.flags)  # type: ignore[arg-type]
 23_plateset = Plateset()
 24_serial_watcher = SerialWatcher()
 25
 26
 27def pytest_collection_finish(session):
 28    """Calculate total estimated runtime from docstrings."""
 29    estimated_runtime = timedelta()
 30    for test in session.items:
 31        time_match = (
 32            re.search(
 33                r"""
 34            \|\s*Estimated\sDuration\s*\|\s*                 # Only look at the duration row
 35            (\d+)                                            # Capture digits
 36            \s+                                              # Space
 37            (seconds* | minutes* | hours* | days* | weeks*)  # Capture unit of time (optional "s")
 38            """,
 39                test.function.__doc__,
 40                re.IGNORECASE | re.VERBOSE,
 41            )
 42            if test.function.__doc__
 43            else None
 44        )
 45        if time_match is not None:
 46            unit_name = f"{time_match.group(2).rstrip('s')}s"
 47            estimated_runtime += timedelta(**{unit_name: int(time_match.group(1))})
 48    session.config.stash[metadata_key]["Estimated Runtime (hh:mm:ss)"] = str(Duration(estimated_runtime))
 49
 50
 51def pytest_html_report_title(report):
 52    """Change the html report title."""
 53    report.title = "HITL Report"
 54
 55
 56def pytest_html_results_table_header(cells):
 57    """Generate html report headers."""
 58    del cells[:]
 59    cells.extend(
 60        [
 61            '<th class="sortable" data-column-type="result">Pass / Fail</th>',
 62            "<th>Result</th>",
 63            "<th>Description</th>",
 64            '<th class="sortable time" data-column-type="time">Start Time</th>',
 65            '<th class="sortable" data-column-type="duration">Duration (h:mm:ss.u)</th>',
 66            '<th class="sortable" data-column-type="testId">Test / Docs</th>',
 67        ]
 68    )
 69
 70
 71def pytest_html_results_table_row(report, cells):
 72    """Generate the row for a test in the html report."""
 73    del cells[:]
 74
 75    # Generate URL
 76    test_case, line_number, test_name = report.location
 77    test_name = test_name.partition("[")[0]  # Remove fixture information
 78    test_case_html_path = str(Path(test_case).with_suffix(".html"))
 79    base_link = html.escape(f"https://dev.bms-docs.taf.work/{test_case_html_path}#{test_name}")
 80
 81    # Generate result
 82    pass_fail = report.outcome.title()
 83    result = "<br>".join(report.result_text)
 84    duration = timedelta(seconds=report.duration)
 85    start_time = (datetime.now(timezone.utc) - duration).strftime("%Y/%m/%d %I:%M:%S %p")
 86    safe_test_name = html.escape(test_name)
 87    safe_test_case = html.escape(test_case)
 88    cells.extend(
 89        [
 90            f'<td class="col-result">{pass_fail}</td>',
 91            f"<td>{result}</td>",
 92            f"<td>{report.description}</td>",
 93            f'<td class="col-time">{start_time}</td>',
 94            f'<td class="col-duration">{duration}</td>',
 95            f'<td><a target="_blank" style="color:#337788;" href="{base_link}">{safe_test_name}</a> '
 96            f"(File {safe_test_case}, line {line_number})</td>",
 97        ]
 98    )
 99
100
101@pytest.hookimpl(hookwrapper=True)
102def pytest_runtest_makereport(item, call):  # pylint: disable=unused-argument
103    """Generate the report for one test."""
104    outcome = yield
105    report = outcome.get_result()
106    report.result_text = item.module.logger.html_result_text if hasattr(item.module, "logger") else ""
107    description_match = (
108        re.search(
109            r"""
110    \|\s*Description\s*\|\s*  # Description row
111    (.*?)                     # Capture description text
112    \s*\|                     # End of row
113    """,
114            item.function.__doc__,
115            re.IGNORECASE | re.VERBOSE,
116        )
117        if item.function.__doc__
118        else None
119    )
120    report.description = description_match.group(1) if description_match is not None else ""
121
122
123def pytest_configure(config):
124    """Add custom markers and information to the beginning of the test html report."""
125    config.addinivalue_line("markers", "live_cells: this test uses live cells 🔥")
126    config.addinivalue_line("markers", "sim_cells: this test uses cell simulators")
127
128    terminal_command = html.escape(" ".join(sys.argv))
129    hitl = html.escape(platform.node())
130    test_plan = html.escape(pytest.flags.test_plan.stem)
131    log_folder = html.escape(str(pytest.flags.report_filename.parent))
132    hardware_config = html.escape(pytest.flags.plateset_id)
133    properties = {html.escape(name): html.escape(str(value)) for name, value in pytest.flags.properties.items()}
134
135    config.stash[metadata_key]["Terminal Command"] = f"<pre><code>{terminal_command}</code></pre>"
136    config.stash[metadata_key]["HITL"] = hitl
137    config.stash[metadata_key]["Test Plan"] = test_plan
138    config.stash[metadata_key]["Hardware Config"] = hardware_config
139    config.stash[metadata_key]["Properties"] = properties
140    config.stash[metadata_key]["Logs"] = log_folder
141    config.stash[metadata_key]["Estimated Runtime (hh:mm:ss)"] = "Calculating..."
142
143    # Remove some rows
144    config.stash[metadata_key].pop("Plugins", None)
145    config.stash[metadata_key].pop("Packages", None)
146    # config.stash[metadata_key]["Packages"] = {d.project_name: d.version for d in pkg_resources.working_set}
147
148
149@pytest.fixture(scope="function")
150def cycle_smbus():
151    """Log Serial and SMBus."""
152    # Save old CSV recorder
153    old_cycle_function = _bms.csv.cycle
154    _bms.csv.cycle = _bms.csv.cycle_smbus
155
156    # Run test
157    yield
158
159    # Use old CSV recorder
160    _bms.csv.cycle = old_cycle_function
161
162
163@pytest.fixture(scope="function")
164def serial_watcher():
165    """Log Serial and SMBus."""
166    # Save old CSV recorder
167    old_cycle_function = _bms.csv.cycle
168    _bms.csv.cycle = _bms.csv.cycle_smbus
169
170    # Start serial watcher
171    _serial_watcher.start()
172
173    # Run test
174    yield _serial_watcher
175
176    # Stop serial watcher
177    _serial_watcher.stop()
178
179    # Use old CSV recorder
180    _bms.csv.cycle = old_cycle_function
181
182
183@pytest.fixture(scope="function")
184def reset_cell_sims(request):
185    """
186    Before each test, reset cell sims / BMS and set appropriate temperatures.
187
188    Fixture arguments are provided in an abnormal way, see below tests for details on how to provide these arguments.
189    A default value is used if neither soc nor volts is provided.
190
191    :param float temperature: the initial temperature in C
192    :param float soc: the initial state of charge
193    :param float volts: the initial voltage
194    """
195    default_temperature_c = 23
196    default_soc = 0.50
197    args = getattr(request, "param", {})
198
199    # Reset cell sims
200    starting_temperature = args.get("temperature", default_temperature_c)
201    if len(_bms.cells) > 0:
202        logger.write_info_to_report(f"Setting temperature to {starting_temperature}°C")
203        _plateset.thermistor1 = _plateset.thermistor2 = starting_temperature
204        logger.write_info_to_report("Powering down cell sims")
205        for cell in _bms.cells.values():
206            cell.disengage_safety_protocols = True
207            cell.volts = 0.0001
208        time.sleep(5)
209        for cell in _bms.cells.values():
210            new_soc = args.get("soc") or default_soc or cell.volts_to_soc(args.get("volts"))
211            logger.write_info_to_report(f"Powering up cell sim {cell.id} to {new_soc:%}")
212            cell.state_of_charge = new_soc
213            cell.disengage_safety_protocols = False
214
215    yield  # Run test
216
217
218@pytest.hookimpl(trylast=True)
219def pytest_collection_modifyitems(items):
220    """Check if any tests are marked to use the cell sims."""
221    for item in items:
222        if item.get_closest_marker("sim_cells"):
223            item.fixturenames.append("reset_cell_sims")
def pytest_collection_finish(session):
28def pytest_collection_finish(session):
29    """Calculate total estimated runtime from docstrings."""
30    estimated_runtime = timedelta()
31    for test in session.items:
32        time_match = (
33            re.search(
34                r"""
35            \|\s*Estimated\sDuration\s*\|\s*                 # Only look at the duration row
36            (\d+)                                            # Capture digits
37            \s+                                              # Space
38            (seconds* | minutes* | hours* | days* | weeks*)  # Capture unit of time (optional "s")
39            """,
40                test.function.__doc__,
41                re.IGNORECASE | re.VERBOSE,
42            )
43            if test.function.__doc__
44            else None
45        )
46        if time_match is not None:
47            unit_name = f"{time_match.group(2).rstrip('s')}s"
48            estimated_runtime += timedelta(**{unit_name: int(time_match.group(1))})
49    session.config.stash[metadata_key]["Estimated Runtime (hh:mm:ss)"] = str(Duration(estimated_runtime))

Calculate total estimated runtime from docstrings.

def pytest_html_report_title(report):
52def pytest_html_report_title(report):
53    """Change the html report title."""
54    report.title = "HITL Report"

Change the html report title.

def pytest_html_results_table_header(cells):
57def pytest_html_results_table_header(cells):
58    """Generate html report headers."""
59    del cells[:]
60    cells.extend(
61        [
62            '<th class="sortable" data-column-type="result">Pass / Fail</th>',
63            "<th>Result</th>",
64            "<th>Description</th>",
65            '<th class="sortable time" data-column-type="time">Start Time</th>',
66            '<th class="sortable" data-column-type="duration">Duration (h:mm:ss.u)</th>',
67            '<th class="sortable" data-column-type="testId">Test / Docs</th>',
68        ]
69    )

Generate html report headers.

def pytest_html_results_table_row(report, cells):
72def pytest_html_results_table_row(report, cells):
73    """Generate the row for a test in the html report."""
74    del cells[:]
75
76    # Generate URL
77    test_case, line_number, test_name = report.location
78    test_name = test_name.partition("[")[0]  # Remove fixture information
79    test_case_html_path = str(Path(test_case).with_suffix(".html"))
80    base_link = html.escape(f"https://dev.bms-docs.taf.work/{test_case_html_path}#{test_name}")
81
82    # Generate result
83    pass_fail = report.outcome.title()
84    result = "<br>".join(report.result_text)
85    duration = timedelta(seconds=report.duration)
86    start_time = (datetime.now(timezone.utc) - duration).strftime("%Y/%m/%d %I:%M:%S %p")
87    safe_test_name = html.escape(test_name)
88    safe_test_case = html.escape(test_case)
89    cells.extend(
90        [
91            f'<td class="col-result">{pass_fail}</td>',
92            f"<td>{result}</td>",
93            f"<td>{report.description}</td>",
94            f'<td class="col-time">{start_time}</td>',
95            f'<td class="col-duration">{duration}</td>',
96            f'<td><a target="_blank" style="color:#337788;" href="{base_link}">{safe_test_name}</a> '
97            f"(File {safe_test_case}, line {line_number})</td>",
98        ]
99    )

Generate the row for a test in the html report.

@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
102@pytest.hookimpl(hookwrapper=True)
103def pytest_runtest_makereport(item, call):  # pylint: disable=unused-argument
104    """Generate the report for one test."""
105    outcome = yield
106    report = outcome.get_result()
107    report.result_text = item.module.logger.html_result_text if hasattr(item.module, "logger") else ""
108    description_match = (
109        re.search(
110            r"""
111    \|\s*Description\s*\|\s*  # Description row
112    (.*?)                     # Capture description text
113    \s*\|                     # End of row
114    """,
115            item.function.__doc__,
116            re.IGNORECASE | re.VERBOSE,
117        )
118        if item.function.__doc__
119        else None
120    )
121    report.description = description_match.group(1) if description_match is not None else ""

Generate the report for one test.

def pytest_configure(config):
124def pytest_configure(config):
125    """Add custom markers and information to the beginning of the test html report."""
126    config.addinivalue_line("markers", "live_cells: this test uses live cells 🔥")
127    config.addinivalue_line("markers", "sim_cells: this test uses cell simulators")
128
129    terminal_command = html.escape(" ".join(sys.argv))
130    hitl = html.escape(platform.node())
131    test_plan = html.escape(pytest.flags.test_plan.stem)
132    log_folder = html.escape(str(pytest.flags.report_filename.parent))
133    hardware_config = html.escape(pytest.flags.plateset_id)
134    properties = {html.escape(name): html.escape(str(value)) for name, value in pytest.flags.properties.items()}
135
136    config.stash[metadata_key]["Terminal Command"] = f"<pre><code>{terminal_command}</code></pre>"
137    config.stash[metadata_key]["HITL"] = hitl
138    config.stash[metadata_key]["Test Plan"] = test_plan
139    config.stash[metadata_key]["Hardware Config"] = hardware_config
140    config.stash[metadata_key]["Properties"] = properties
141    config.stash[metadata_key]["Logs"] = log_folder
142    config.stash[metadata_key]["Estimated Runtime (hh:mm:ss)"] = "Calculating..."
143
144    # Remove some rows
145    config.stash[metadata_key].pop("Plugins", None)
146    config.stash[metadata_key].pop("Packages", None)
147    # config.stash[metadata_key]["Packages"] = {d.project_name: d.version for d in pkg_resources.working_set}

Add custom markers and information to the beginning of the test html report.

@pytest.fixture(scope='function')
def cycle_smbus():
150@pytest.fixture(scope="function")
151def cycle_smbus():
152    """Log Serial and SMBus."""
153    # Save old CSV recorder
154    old_cycle_function = _bms.csv.cycle
155    _bms.csv.cycle = _bms.csv.cycle_smbus
156
157    # Run test
158    yield
159
160    # Use old CSV recorder
161    _bms.csv.cycle = old_cycle_function

Log Serial and SMBus.

@pytest.fixture(scope='function')
def serial_watcher():
164@pytest.fixture(scope="function")
165def serial_watcher():
166    """Log Serial and SMBus."""
167    # Save old CSV recorder
168    old_cycle_function = _bms.csv.cycle
169    _bms.csv.cycle = _bms.csv.cycle_smbus
170
171    # Start serial watcher
172    _serial_watcher.start()
173
174    # Run test
175    yield _serial_watcher
176
177    # Stop serial watcher
178    _serial_watcher.stop()
179
180    # Use old CSV recorder
181    _bms.csv.cycle = old_cycle_function

Log Serial and SMBus.

@pytest.fixture(scope='function')
def reset_cell_sims(request):
184@pytest.fixture(scope="function")
185def reset_cell_sims(request):
186    """
187    Before each test, reset cell sims / BMS and set appropriate temperatures.
188
189    Fixture arguments are provided in an abnormal way, see below tests for details on how to provide these arguments.
190    A default value is used if neither soc nor volts is provided.
191
192    :param float temperature: the initial temperature in C
193    :param float soc: the initial state of charge
194    :param float volts: the initial voltage
195    """
196    default_temperature_c = 23
197    default_soc = 0.50
198    args = getattr(request, "param", {})
199
200    # Reset cell sims
201    starting_temperature = args.get("temperature", default_temperature_c)
202    if len(_bms.cells) > 0:
203        logger.write_info_to_report(f"Setting temperature to {starting_temperature}°C")
204        _plateset.thermistor1 = _plateset.thermistor2 = starting_temperature
205        logger.write_info_to_report("Powering down cell sims")
206        for cell in _bms.cells.values():
207            cell.disengage_safety_protocols = True
208            cell.volts = 0.0001
209        time.sleep(5)
210        for cell in _bms.cells.values():
211            new_soc = args.get("soc") or default_soc or cell.volts_to_soc(args.get("volts"))
212            logger.write_info_to_report(f"Powering up cell sim {cell.id} to {new_soc:%}")
213            cell.state_of_charge = new_soc
214            cell.disengage_safety_protocols = False
215
216    yield  # Run test

Before each test, reset cell sims / BMS and set appropriate temperatures.

Fixture arguments are provided in an abnormal way, see below tests for details on how to provide these arguments. A default value is used if neither soc nor volts is provided.

Parameters
  • float temperature: the initial temperature in C
  • float soc: the initial state of charge
  • float volts: the initial voltage
@pytest.hookimpl(trylast=True)
def pytest_collection_modifyitems(items):
219@pytest.hookimpl(trylast=True)
220def pytest_collection_modifyitems(items):
221    """Check if any tests are marked to use the cell sims."""
222    for item in items:
223        if item.get_closest_marker("sim_cells"):
224            item.fixturenames.append("reset_cell_sims")

Check if any tests are marked to use the cell sims.