Coverage for tests/test_bps_reports.py: 100%
226 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-09 09:33 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-09 09:33 +0000
1# This file is part of ctrl_bps.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This software is dual licensed under the GNU General Public License and also
10# under a 3-clause BSD license. Recipients may choose which of these licenses
11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt,
12# respectively. If you choose the GPL option then the following text applies
13# (but note that there is still no warranty even if you opt for BSD instead):
14#
15# This program is free software: you can redistribute it and/or modify
16# it under the terms of the GNU General Public License as published by
17# the Free Software Foundation, either version 3 of the License, or
18# (at your option) any later version.
19#
20# This program is distributed in the hope that it will be useful,
21# but WITHOUT ANY WARRANTY; without even the implied warranty of
22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
23# GNU General Public License for more details.
24#
25# You should have received a copy of the GNU General Public License
26# along with this program. If not, see <https://www.gnu.org/licenses/>.
28"""Tests for reporting mechanism."""
30import dataclasses
31import io
32import logging
33import unittest
35from astropy.table import Table
36from wms_test_utils import TEST_REPORT
38from lsst.ctrl.bps import (
39 DISPLAY_SUMMARY_FIELDS,
40 BaseRunReport,
41 DetailedRunReport,
42 ExitCodesReport,
43 SummaryRunReport,
44 WmsJobReport,
45 WmsRunReport,
46 WmsStates,
47 compile_code_summary,
48 compile_job_summary,
49)
52class FakeRunReport(BaseRunReport):
53 """A fake run report."""
55 def add(self, run_report, use_global_id=False):
56 id_ = run_report.global_wms_id if use_global_id else run_report.wms_id
57 self._table.add_row([id_, run_report.state.name])
60class FakeRunReportTestCase(unittest.TestCase):
61 """Test shared methods."""
63 def setUp(self):
64 self.fields = [("ID", "S"), ("STATE", "S")]
66 self.report = FakeRunReport(self.fields)
67 self.report.add(WmsRunReport(wms_id="2.0", state=WmsStates.RUNNING))
68 self.report.add(WmsRunReport(wms_id="1.0", state=WmsStates.SUCCEEDED))
70 def testEquality(self):
71 """Test if two reports are identical."""
72 other = FakeRunReport(self.fields)
73 other.add(WmsRunReport(wms_id="2.0", state=WmsStates.RUNNING))
74 other.add(WmsRunReport(wms_id="1.0", state=WmsStates.SUCCEEDED))
75 self.assertEqual(self.report, other)
77 def testInequality(self):
78 """Test if two reports are not identical."""
79 other = FakeRunReport(self.fields)
80 other.add(WmsRunReport(wms_id="1.0", state=WmsStates.FAILED))
81 self.assertNotEqual(self.report, other)
83 def testLength(self):
84 self.assertEqual(len(self.report), 2)
86 def testClear(self):
87 """Test clearing the report."""
88 self.report.clear()
89 self.assertEqual(len(self.report), 0)
91 def testSortWithKnownKey(self):
92 """Test sorting the report using known column."""
93 expected_output = io.StringIO()
94 expected = Table(dtype=self.fields)
95 expected.add_row(["1.0", WmsStates.SUCCEEDED.name])
96 expected.add_row(["2.0", WmsStates.RUNNING.name])
97 print(expected, file=expected_output)
99 actual_output = io.StringIO()
100 self.report.sort("ID")
101 print(self.report, file=actual_output)
103 self.assertEqual(actual_output.getvalue(), expected_output.getvalue())
105 expected_output.close()
106 actual_output.close()
108 def testSortWithUnknownKey(self):
109 """Test sorting the report using unknown column."""
110 with self.assertRaises(AttributeError):
111 self.report.sort("foo")
114class SummaryRunReportTestCase(unittest.TestCase):
115 """Test a summary run report."""
117 def setUp(self):
118 self.fields = DISPLAY_SUMMARY_FIELDS
119 self.run = WmsRunReport(
120 wms_id="1.0",
121 global_wms_id="foo#1.0",
122 path="/path/to/run",
123 label="label",
124 run="run",
125 site="SITE1",
126 project="dev",
127 campaign="testing",
128 payload="test",
129 operator="tester",
130 run_summary="foo:1;bar:1",
131 state=WmsStates.RUNNING,
132 jobs=None,
133 total_number_jobs=2,
134 job_state_counts={
135 state: 1 if state in {WmsStates.SUCCEEDED, WmsStates.RUNNING} else 0 for state in WmsStates
136 },
137 job_summary=None,
138 )
139 self.report = SummaryRunReport(self.fields)
141 self.expected = Table(dtype=self.fields)
142 self.expected.add_row(
143 ["", "RUNNING", "50", "1.0", "tester", "dev", "testing", "SITE1", "test", "run"]
144 )
146 self.expected_output = io.StringIO()
147 self.actual_output = io.StringIO()
149 def tearDown(self):
150 self.expected_output.close()
151 self.actual_output.close()
153 def testAddWithNoFlag(self):
154 """Test adding a report for a run with no issues."""
155 self.maxDiff = None
157 print("\n".join(self.expected.pformat(max_lines=-1, max_width=-1)), file=self.expected_output)
159 self.report.add(self.run)
160 print(self.report, file=self.actual_output)
162 self.assertEqual(self.actual_output.getvalue(), self.expected_output.getvalue())
164 def testAddWithFailedFlag(self):
165 """Test adding a run with a failed job."""
166 self.maxDiff = None
168 self.expected["X"][0] = "F"
169 print("\n".join(self.expected.pformat(max_lines=-1, max_width=-1)), file=self.expected_output)
171 # Alter the run report to include a failed job.
172 self.run.job_state_counts = {
173 state: 1 if state in {WmsStates.FAILED, WmsStates.SUCCEEDED} else 0 for state in WmsStates
174 }
175 self.report.add(self.run)
176 print(self.report, file=self.actual_output)
178 self.assertEqual(self.actual_output.getvalue(), self.expected_output.getvalue())
180 def testAddWithHeldFlag(self):
181 """Test adding a run with a held job."""
182 self.maxDiff = None
184 self.expected["X"][0] = "H"
185 print("\n".join(self.expected.pformat(max_lines=-1, max_width=-1)), file=self.expected_output)
187 # Alter the run report to include a held job.
188 self.run.job_state_counts = {
189 state: 1 if state in {WmsStates.SUCCEEDED, WmsStates.HELD} else 0 for state in WmsStates
190 }
191 self.report.add(self.run)
192 print(self.report, file=self.actual_output)
194 self.assertEqual(self.actual_output.getvalue(), self.expected_output.getvalue())
196 def testAddWithDeletedFlag(self):
197 """Test adding a run with a deleted job."""
198 self.maxDiff = None
200 self.expected["X"][0] = "D"
201 print("\n".join(self.expected.pformat(max_lines=-1, max_width=-1)), file=self.expected_output)
203 # Alter the run report to include a deleted job.
204 self.run.job_state_counts = {
205 state: 1 if state in {WmsStates.SUCCEEDED, WmsStates.DELETED} else 0 for state in WmsStates
206 }
207 self.report.add(self.run)
208 print(self.report, file=self.actual_output)
210 self.assertEqual(self.actual_output.getvalue(), self.expected_output.getvalue())
212 def testNoneValues(self):
213 """Test no exception if plugin lets values default to None."""
214 self.maxDiff = None
216 self.run.operator = None
217 self.run.project = None
218 self.run.campaign = None
219 self.run.operator = None
220 self.run.payload = None
221 self.run.site = None
222 self.report.add(self.run)
223 print(self.report, file=self.actual_output)
225 self.expected = Table(dtype=self.fields)
226 self.expected.add_row(["", "RUNNING", "50", "1.0", "", "", "", "", "", "run"])
227 print("\n".join(self.expected.pformat(max_lines=-1, max_width=-1)), file=self.expected_output)
229 self.assertEqual(self.actual_output.getvalue(), self.expected_output.getvalue())
231 def testValueError(self):
232 """Test no exception if plugin lets values default to None."""
233 self.maxDiff = None
235 logger = logging.getLogger("test_bps_report")
236 logger.setLevel(logging.INFO)
238 self.run.run = None
239 with self.assertLogs(level=logging.ERROR) as cm:
240 self.report.add(self.run)
241 print(self.report, file=self.actual_output)
242 self.assertRegex(cm.records[0].getMessage(), "Error when adding summary report row")
244 self.expected = Table(dtype=self.fields)
245 print("\n".join(self.expected.pformat(max_lines=-1, max_width=-1)), file=self.expected_output)
247 self.assertEqual(self.actual_output.getvalue(), self.expected_output.getvalue())
250class DetailedRunReportTestCase(unittest.TestCase):
251 """Test a detailed run report."""
253 def setUp(self):
254 self.fields = [("", "S")] + [(state.name, "I") for state in WmsStates] + [("EXPECTED", "i")]
256 table = Table(dtype=self.fields)
257 table.add_row(
258 ["TOTAL"]
259 + [1 if state in {WmsStates.RUNNING, WmsStates.SUCCEEDED} else 0 for state in WmsStates]
260 + [2]
261 )
262 table.add_row(["foo"] + [1 if state == WmsStates.SUCCEEDED else 0 for state in WmsStates] + [1])
263 table.add_row(["bar"] + [1 if state == WmsStates.RUNNING else 0 for state in WmsStates] + [1])
264 self.expected = DetailedRunReport.from_table(table)
266 self.run = WmsRunReport(
267 wms_id="1.0",
268 global_wms_id="foo#1.0",
269 path="/path/to/run",
270 label="label",
271 run="run",
272 site="TEST",
273 project="dev",
274 campaign="testing",
275 payload="test",
276 operator="tester",
277 run_summary="foo:1;bar:1",
278 state=WmsStates.RUNNING,
279 jobs=[
280 WmsJobReport(wms_id="1.0", name="", label="foo", state=WmsStates.SUCCEEDED),
281 WmsJobReport(wms_id="2.0", name="", label="bar", state=WmsStates.RUNNING),
282 ],
283 total_number_jobs=2,
284 job_state_counts={
285 state: 1 if state in {WmsStates.SUCCEEDED, WmsStates.RUNNING} else 0 for state in WmsStates
286 },
287 job_summary={
288 "foo": {state: 1 if state == WmsStates.SUCCEEDED else 0 for state in WmsStates},
289 "bar": {state: 1 if state == WmsStates.RUNNING else 0 for state in WmsStates},
290 },
291 )
293 self.actual = DetailedRunReport(self.fields)
295 def testAddWithJobSummary(self):
296 """Test adding a run with a job summary."""
297 self.run.jobs = None
298 self.actual.add(self.run)
300 self.assertEqual(self.actual, self.expected)
302 def testAddWithoutJobSummary(self):
303 """Test adding a run without either a job summary or job info."""
304 self.run.jobs = None
305 self.run.job_summary = None
306 self.actual.add(self.run)
308 self.assertEqual(len(self.actual), 1)
309 self.assertRegex(self.actual.message, r"^WARNING.*incomplete")
311 def testAddWithoutRunSummary(self):
312 """Test adding a run without a run summary."""
313 table = Table(dtype=self.fields)
314 table.add_row(
315 ["TOTAL"]
316 + [1 if state in {WmsStates.RUNNING, WmsStates.SUCCEEDED} else 0 for state in WmsStates]
317 + [2]
318 )
319 table.add_row(["bar"] + [1 if state == WmsStates.RUNNING else 0 for state in WmsStates] + [-1])
320 table.add_row(["foo"] + [1 if state == WmsStates.SUCCEEDED else 0 for state in WmsStates] + [-1])
321 expected = DetailedRunReport.from_table(table)
323 self.run.run_summary = None
324 self.actual.add(self.run)
326 self.assertRegex(self.actual.message, r"^WARNING.*sorted alphabetically")
327 self.assertEqual(self.actual, expected)
330class ExitCodesReportTestCase(unittest.TestCase):
331 """Test an exit code report."""
333 def setUp(self):
334 self.fields = [
335 (" ", "S"),
336 ("PAYLOAD ERROR COUNT", "i"),
337 ("PAYLOAD ERROR CODES", "S"),
338 ("INFRASTRUCTURE ERROR COUNT", "i"),
339 ("INFRASTRUCTURE ERROR CODES", "S"),
340 ]
342 table = Table(dtype=self.fields)
343 table.add_row(["foo", 0, "None", 0, "None"])
344 table.add_row(["bar", 2, "1, 2", 2, "3, 4"])
345 self.expected = ExitCodesReport.from_table(table)
347 self.run = WmsRunReport(
348 wms_id="1.0",
349 global_wms_id="foo#1.0",
350 path="/path/to/run",
351 label="label",
352 run="run",
353 project="dev",
354 campaign="testing",
355 payload="test",
356 operator="tester",
357 run_summary="foo:1;bar:1",
358 state=WmsStates.RUNNING,
359 jobs=[
360 WmsJobReport(wms_id="1.0", name="", label="foo", state=WmsStates.SUCCEEDED),
361 WmsJobReport(wms_id="2.0", name="", label="bar", state=WmsStates.RUNNING),
362 ],
363 total_number_jobs=2,
364 job_state_counts={
365 state: 1 if state in {WmsStates.SUCCEEDED, WmsStates.RUNNING} else 0 for state in WmsStates
366 },
367 job_summary={
368 "foo": {state: 1 if state == WmsStates.SUCCEEDED else 0 for state in WmsStates},
369 "bar": {state: 1 if state == WmsStates.RUNNING else 0 for state in WmsStates},
370 },
371 exit_code_summary={
372 "foo": [],
373 "bar": [1, 2, 3, 4],
374 },
375 )
377 self.actual = ExitCodesReport(self.fields)
379 def testAddSuccess(self):
380 """Test adding a run successfully."""
381 self.actual.add(self.run)
383 self.assertEqual(len(self.actual), 2)
384 self.assertEqual(self.actual, self.expected)
386 def testAddFailure(self):
387 """Test adding a run unsuccessfully."""
388 self.run.job_summary = {}
389 self.run.exit_code_summary = {}
391 self.actual.add(self.run)
393 self.assertEqual(len(self.actual), 0)
394 self.assertRegex(self.actual.message, r"^WARNING.*report.*incomplete")
396 def testAddWithoutRunSummary(self):
397 """Test adding a run without a run summary."""
398 self.run.run_summary = None
400 self.actual.add(self.run)
402 self.assertRegex(self.actual.message, r"^WARNING.*sorted alphabetically")
405class CompileJobSummaryTestCase(unittest.TestCase):
406 """Test compiling a job summary."""
408 def setUp(self):
409 self.report = dataclasses.replace(TEST_REPORT)
411 def tearDown(self):
412 pass
414 def testSummaryExists(self):
415 """Test if the existing report is not altered."""
416 # Create a report with a "fake" job summary, i.e., a summary which
417 # differs from the one which would be compiled from the information
418 # about individual jobs.
419 expected = dataclasses.replace(
420 self.report,
421 job_summary={"foo": {state: 1 if state == WmsStates.FAILED else 0 for state in WmsStates}},
422 )
423 result = dataclasses.replace(expected)
425 messages = compile_job_summary(result)
427 self.assertEqual(result, expected)
428 self.assertFalse(messages)
430 def testSummaryMissing(self):
431 """Test if the summary is compiled if necessary."""
432 result = dataclasses.replace(self.report, job_summary=None)
434 messages = compile_job_summary(result)
436 self.assertEqual(result, self.report)
437 self.assertFalse(messages)
439 def testCompilationError(self):
440 """Test if a warning is issued if the summary cannot be compiled."""
441 result = dataclasses.replace(self.report, jobs=None, job_summary=None)
443 messages = compile_job_summary(result)
445 self.assertEqual(len(messages), 1)
446 self.assertRegex(messages[0], r"information.*not available")
449class CompileCodeSummaryTestCase(unittest.TestCase):
450 """Test compiling a code summary."""
452 def setUp(self):
453 self.report = WmsRunReport(
454 wms_id="1.0",
455 global_wms_id="foo#1.0",
456 path="/path/to/run",
457 label="label",
458 run="run",
459 project="dev",
460 campaign="testing",
461 payload="test",
462 operator="tester",
463 run_summary="foo:1;bar:1;baz:1",
464 state=WmsStates.RUNNING,
465 jobs=[
466 WmsJobReport(wms_id="1.0", name="", label="foo", state=WmsStates.SUCCEEDED),
467 WmsJobReport(wms_id="2.0", name="", label="bar", state=WmsStates.FAILED),
468 WmsJobReport(wms_id="3.0", name="", label="baz", state=WmsStates.RUNNING),
469 ],
470 total_number_jobs=3,
471 job_state_counts={
472 state: 1 if state in {WmsStates.SUCCEEDED, WmsStates.FAILED, WmsStates.RUNNING} else 0
473 for state in WmsStates
474 },
475 job_summary={
476 "foo": {state: 1 if state == WmsStates.SUCCEEDED else 0 for state in WmsStates},
477 "bar": {state: 1 if state == WmsStates.FAILED else 0 for state in WmsStates},
478 "baz": {state: 1 if state == WmsStates.RUNNING else 0 for state in WmsStates},
479 },
480 exit_code_summary={"foo": [], "bar": [1], "baz": []},
481 )
483 def tearDown(self):
484 pass
486 def testAddingMissingEntries(self):
487 """Test if the missing entries are added to the summary."""
488 result = dataclasses.replace(self.report, exit_code_summary={"bar": [1]})
490 messages = compile_code_summary(result)
492 self.assertEqual(result, self.report)
493 self.assertFalse(messages)
495 def testDetectingMismatches(self):
496 """Test if a mismatch between exit codes and failures is reported."""
497 expected = dataclasses.replace(self.report, exit_code_summary={"foo": [1], "bar": [1], "baz": []})
498 result = dataclasses.replace(expected)
500 messages = compile_code_summary(result)
502 self.assertEqual(result, expected)
503 self.assertEqual(len(messages), 1)
504 self.assertRegex(messages[0], r"exit codes.*differs.*failures.*labels: foo")
506 def testDetectingOmissions(self):
507 """Test if a failure not reflected in exit codes is reported."""
508 expected = dataclasses.replace(self.report, exit_code_summary={"foo": [], "baz": []})
509 result = dataclasses.replace(expected)
511 messages = compile_code_summary(result)
513 self.assertEqual(result, expected)
514 self.assertEqual(len(messages), 1)
515 self.assertRegex(messages[0], r"exit codes.*not available.*labels: bar")
517 def testDetectingDiscrepancies(self):
518 """Test if multiple discrepancies are reported."""
519 expected = dataclasses.replace(self.report, exit_code_summary={"foo": [], "baz": [1]})
520 result = dataclasses.replace(expected)
522 messages = compile_code_summary(result)
524 self.assertEqual(result, expected)
525 self.assertEqual(len(messages), 2)
526 self.assertRegex(messages[0], r"exit codes.*differs.*failures.*labels: baz")
527 self.assertRegex(messages[1], r"exit codes.*not available.*labels: bar")
529 def testHandlingNoJobSummary(self):
530 """Test if the existing report is not altered if no job summary."""
531 expected = dataclasses.replace(self.report, job_summary=None)
532 result = dataclasses.replace(expected)
534 messages = compile_code_summary(result)
536 self.assertEqual(result, expected)
537 self.assertFalse(messages)
540if __name__ == "__main__":
541 unittest.main()