Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions tests/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
133 changes: 133 additions & 0 deletions tests/stand/ui/downloads.py
Original file line number Diff line number Diff line change
@@ -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)
29 changes: 21 additions & 8 deletions tests/stand/ui/test_collaboration_card_evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
56 changes: 45 additions & 11 deletions tests/stand/ui/test_git_output_timeseries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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()
Expand Down
30 changes: 21 additions & 9 deletions tests/stand/ui/test_metric_evidence_drilldown.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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"
23 changes: 23 additions & 0 deletions tests/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.