diff --git a/tests/pyproject.toml b/tests/pyproject.toml index 72df969ba..fc0bc5a57 100644 --- a/tests/pyproject.toml +++ b/tests/pyproject.toml @@ -50,6 +50,11 @@ dependencies = [ # MINT anything: the assertion is a request for a token, exchanged at the # deployed endpoint, exactly as a real service does it. "pyjwt[crypto]>=2.9", + # Reads the .xlsx half of every export a journey downloads. A workbook is a + # zip of XML, so a hand-rolled reader is possible and is a liability: the + # assertion would then depend on test code nobody reviews as carefully as + # the product. Read-only here — the suite writes no spreadsheets. + "openpyxl>=3.1", ] [dependency-groups] diff --git a/tests/stand/ui/downloads.py b/tests/stand/ui/downloads.py new file mode 100644 index 000000000..0275e44f9 --- /dev/null +++ b/tests/stand/ui/downloads.py @@ -0,0 +1,133 @@ +"""Read back what the browser downloaded, and what the grid behind it showed. + +A download event and a byte count prove the transport worked, which was never +the part in doubt. What a journey has to prove is that the file holds the table +the user was looking at — and CSV and XLSX are written by different code +(client-side for timeseries exports, `rust_xlsxwriter` beside the `csv` crate +for evidence exports), so the two formats are read back and compared against +each other as well as against the grid. + +Every cell — from a CSV string, an XLSX number, or a rendered DOM node — is put +through one normalizer, so `29`, `29.0` and a formatted `"+2,705"` compare as +the same value while an empty cell stays distinct from a zero. That distinction +is the whole point: a serializer that turns a missing value into 0 passes any +assertion that only counts rows. +""" + +from __future__ import annotations + +import csv +from datetime import date, datetime +from pathlib import Path + +from openpyxl import load_workbook +from playwright.sync_api import Locator, Page + +#: A table read out of an export or off the screen: normalized cells, row-wise, +#: header rows included. +Table = list[list[str]] + +#: What the SPA renders in a cell that has no value — em dash, en dash or +#: hyphen. Read as empty, so a rendered row compares against its export. +_EMPTY_MARKERS = frozenset({"\u2014", "\u2013", "-", ""}) + +#: Digit-group separators and signs the UI inserts and no exporter emits: +#: narrow no-break space, no-break space, comma, plus. +_GROUPING = ("\u202f", "\u00a0", ",", "+") + + +def download_export( + page: Page, menu_item: str, *, into: Path, exact: bool = True +) -> tuple[str, Table]: + """Click an export menu item, save the file, and read it back as a table.""" + with page.expect_download() as download_info: + page.get_by_role("menuitem", name=menu_item, exact=exact).click() + + download = download_info.value + destination = into / download.suggested_filename + download.save_as(destination) + + return download.suggested_filename, read_export(destination) + + +def read_export(path: Path) -> Table: + if path.suffix == ".csv": + return _read_csv(path) + if path.suffix == ".xlsx": + return _read_xlsx(path) + raise AssertionError(f"no reader for {path.name}; the suite reads .csv and .xlsx") + + +def rendered_rows(table: Locator) -> Table: + """The grid as the browser rendered it, normalized like an exported table. + + A virtualized grid keeps only its visible window in the DOM, so this is the + window, not the whole result — compare it against the head of an export and + take the row total from `aria-rowcount`. + """ + rows: Table = [] + for row in table.get_by_role("row").all(): + cells = row.locator('[role="cell"], [role="columnheader"], td, th') + rows.append([_cell(text) for text in cells.all_inner_texts()]) + + return rows + + +def claimed_row_count(table: Locator) -> int: + """What the grid says its full result set holds, virtualization aside.""" + declared = table.get_attribute("aria-rowcount") + assert declared is not None, "grid declares no aria-rowcount to reconcile the export against" + + return int(declared) + + +def data_rows(table: Table, *, after: int) -> Table: + """Everything below the header rows, blank padding dropped.""" + return [row for row in table[after:] if any(cell for cell in row)] + + +def _read_csv(path: Path) -> Table: + # The exporters lead with a BOM so a spreadsheet keeps non-ASCII names. + text = path.read_text(encoding="utf-8-sig") + + return [[_cell(value) for value in row] for row in csv.reader(text.splitlines()) if any(row)] + + +def _read_xlsx(path: Path) -> Table: + workbook = load_workbook(path, read_only=True, data_only=True) + try: + sheet = workbook.worksheets[0] + rows = [[_cell(value) for value in row] for row in sheet.iter_rows(values_only=True)] + finally: + workbook.close() + + return [row for row in rows if any(row)] + + +def _cell(value: object) -> str: + if value is None: + return "" + if isinstance(value, bool): + return str(value) + if isinstance(value, datetime): + return value.date().isoformat() + if isinstance(value, date): + return value.isoformat() + if isinstance(value, int | float): + return _number(float(value)) + + text = " ".join(str(value).split()) + if text in _EMPTY_MARKERS: + return "" + + plain = text.replace("\u2212", "-") # the UI signs negatives with U+2212 + for separator in _GROUPING: + plain = plain.replace(separator, "") + try: + return _number(float(plain)) + except ValueError: + return text + + +def _number(value: float) -> str: + return str(int(value)) if value.is_integer() else repr(value) diff --git a/tests/stand/ui/test_collaboration_card_evidence.py b/tests/stand/ui/test_collaboration_card_evidence.py index b883ecf3c..9d55291a0 100644 --- a/tests/stand/ui/test_collaboration_card_evidence.py +++ b/tests/stand/ui/test_collaboration_card_evidence.py @@ -24,6 +24,7 @@ from insight_stand import PersonaSession from playwright.sync_api import Page, expect +from .downloads import Table, claimed_row_count, download_export, rendered_rows from .flows import sign_in from .pages.person_view import PersonView @@ -66,11 +67,23 @@ def test_collaboration_card_menu_opens_and_exports_supporting_data( "summary-grain evidence has no per-record reference, so nothing should offer to copy one" ) - evidence.export().click() - with page.expect_download() as download_info: - page.get_by_role("menuitem", name="CSV", exact=True).click() - download = download_info.value - assert download.suggested_filename.endswith(".csv") - destination = tmp_path / download.suggested_filename - download.save_as(destination) - assert destination.stat().st_size > 0 + exported: dict[str, Table] = {} + for menu_item, suffix in (("CSV", ".csv"), ("Excel", ".xlsx")): + evidence.export().click() + filename, table_out = download_export(page, menu_item, into=tmp_path) + assert filename.endswith(suffix), filename + exported[suffix] = table_out + + assert exported[".csv"] == exported[".xlsx"], ( + "a summary-grain value is serialized separately per format; both must read the same" + ) + + header, *records = exported[".csv"] + assert len(records) == claimed_row_count(table), ( + "the export holds a different number of days than the grid claims" + ) + + on_screen = rendered_rows(table) + assert on_screen[0][1:] == header, "exported columns differ from the rendered ones" + for shown, written in zip(on_screen[1:], records, strict=False): + assert shown[1:] == written, "a rendered day differs from the one exported" diff --git a/tests/stand/ui/test_git_output_timeseries.py b/tests/stand/ui/test_git_output_timeseries.py index 42b4abd6e..f8ca1493e 100644 --- a/tests/stand/ui/test_git_output_timeseries.py +++ b/tests/stand/ui/test_git_output_timeseries.py @@ -6,9 +6,10 @@ the table presentation, switches presentations, or produces browser downloads. Those behaviors exist only after the component renders and handles user input. -Metric values are not asserted because the stand manifest declares no golden -metrics. The journey instead verifies the stable structure built from seeded Git -activity and that both export paths produce non-empty browser downloads. +Metric values are not asserted against anything this suite invented, because the +stand manifest declares no golden metrics. What is asserted is a reconciliation: +the numbers in both downloaded files are the numbers the deployed table renders, +which holds whatever the seed contains. """ from __future__ import annotations @@ -21,12 +22,21 @@ from insight_stand import PersonaSession from playwright.sync_api import Page, expect +from .downloads import Table, download_export, rendered_rows from .flows import sign_in from .pages.person_view import PersonView # Quality vector of this module's tests. pytestmark = pytest.mark.reliability +#: A row of the timeseries proper, as opposed to a header or a totals row: it +#: leads with the bucket it covers. +BUCKET = re.compile(r"\d{4}-\d{2}-\d{2}") + + +def timeseries_rows(exported: Table) -> Table: + return [row for row in exported if row and BUCKET.fullmatch(row[0])] + @pytest.mark.requires_seed("dev_lead") def test_git_output_repository_timeseries_switches_views_and_downloads( @@ -55,16 +65,40 @@ def test_git_output_repository_timeseries_switches_views_and_downloads( expect(table.get_by_role("cell", name="Total", exact=True)).to_be_visible() expect(table.get_by_role("cell", name="Grand total", exact=True)).to_be_visible() + exported: dict[str, Table] = {} for menu_item, suffix in (("CSV (.csv)", ".csv"), ("Excel (.xlsx)", ".xlsx")): git_output.export().click() - with page.expect_download() as download_info: - page.get_by_role("menuitem", name=menu_item).click() - download = download_info.value - assert download.suggested_filename.startswith("output-by-repository_") - assert download.suggested_filename.endswith(suffix) - destination = tmp_path / download.suggested_filename - download.save_as(destination) - assert destination.stat().st_size > 0 + filename, table_out = download_export(page, menu_item, into=tmp_path, exact=False) + assert filename.startswith("output-by-repository_"), filename + assert filename.endswith(suffix), filename + exported[suffix] = table_out + + assert timeseries_rows(exported[".csv"]) == timeseries_rows(exported[".xlsx"]), ( + "the two formats are written by different code and must still carry the same series" + ) + + on_screen = rendered_rows(table) + repository = on_screen[0][1] + assert exported[".csv"][0][0] == on_screen[0][0], ( + "exported bucket header differs from the table's" + ) + assert all(column.startswith(f"{repository} —") for column in exported[".csv"][0][1:]), ( + f"csv columns are not the rendered repository's: {exported['.csv'][0]}" + ) + assert repository in exported[".xlsx"][0], ( + f"xlsx columns lost the repository: {exported['.xlsx'][0]}" + ) + + rendered_series = [row for row in on_screen if row and BUCKET.fullmatch(row[0])] + assert rendered_series, "no bucket rows on screen to reconcile the export against" + for shown, written in zip(rendered_series, timeseries_rows(exported[".csv"]), strict=True): + bucket, commits, merged, lines = shown + assert [commits, merged] == written[1:3], ( + f"{bucket}: counts on screen and in the export differ" + ) + assert [n.replace(",", "") for n in re.findall(r"[\d,]+", lines)] == written[3:5], ( + f"{bucket}: the rendered lines cell and the exported columns differ" + ) git_output.chart_view().click() expect(git_output.metric_selector()).to_be_visible() diff --git a/tests/stand/ui/test_metric_evidence_drilldown.py b/tests/stand/ui/test_metric_evidence_drilldown.py index a28049245..961ee1e39 100644 --- a/tests/stand/ui/test_metric_evidence_drilldown.py +++ b/tests/stand/ui/test_metric_evidence_drilldown.py @@ -3,8 +3,8 @@ Why this is a browser test and not an API test: the analytics suite exercises the evidence endpoints directly, but it cannot prove that a user can traverse the deployed SPA from the personal dashboard into Git output, select a concrete -repository-and-time bucket, and receive the nested supporting-data dialog with -working browser downloads. +repository-and-time bucket, and receive the nested supporting-data dialog whose +downloads carry the records it displays. """ from __future__ import annotations @@ -17,6 +17,7 @@ from insight_stand import PersonaSession from playwright.sync_api import Page, expect +from .downloads import Table, claimed_row_count, download_export, rendered_rows from .flows import sign_in from .pages.person_view import PersonView @@ -58,12 +59,23 @@ def test_git_commit_bucket_opens_and_exports_supporting_data( copy_ref.click() expect(evidence.dialog.get_by_role("button", name="Copied")).to_be_visible() + exported: dict[str, Table] = {} for menu_item, suffix in (("CSV", ".csv"), ("Excel", ".xlsx")): evidence.export().click() - with page.expect_download() as download_info: - page.get_by_role("menuitem", name=menu_item, exact=True).click() - download = download_info.value - assert download.suggested_filename.endswith(suffix) - destination = tmp_path / download.suggested_filename - download.save_as(destination) - assert destination.stat().st_size > 0 + filename, table_out = download_export(page, menu_item, into=tmp_path) + assert filename.endswith(suffix), filename + exported[suffix] = table_out + + assert exported[".csv"] == exported[".xlsx"], ( + "one export serializes each format separately; both must carry the same records" + ) + + header, *records = exported[".csv"] + assert len(records) == claimed_row_count(table), ( + "the export holds a different number of records than the grid claims" + ) + + on_screen = rendered_rows(table) + assert on_screen[0][1:] == header, "exported columns differ from the rendered ones" + for shown, written in zip(on_screen[1:], records, strict=False): + assert shown[1:] == written, "a rendered record differs from the one exported" diff --git a/tests/uv.lock b/tests/uv.lock index 7b0413a2b..98c5f2053 100644 --- a/tests/uv.lock +++ b/tests/uv.lock @@ -325,6 +325,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c6/4d/556cb290170f41b97ce50fd872e10a266f141d7a38352bb3071e4ae61f41/datamodel_code_generator-0.71.0-py3-none-any.whl", hash = "sha256:680b68338d59e98a0559eeb54d8e5ca33c35b3ec0bef922ec2cc783f2cb28e9a", size = 452379, upload-time = "2026-07-24T15:32:02.467Z" }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + [[package]] name = "genson" version = "1.4.0" @@ -465,6 +474,7 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "httpx" }, + { name = "openpyxl" }, { name = "playwright" }, { name = "pydantic" }, { name = "pyjwt", extra = ["crypto"] }, @@ -482,6 +492,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.27" }, + { name = "openpyxl", specifier = ">=3.1" }, { name = "playwright", specifier = "==1.62.0" }, { name = "pydantic", specifier = ">=2.13" }, { name = "pyjwt", extras = ["crypto"], specifier = ">=2.9" }, @@ -673,6 +684,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + [[package]] name = "packaging" version = "26.2"