"""Clicking a column header to sort, on any table. **Why this is a module and not part of `columns.py`.** The holdings table has a registry — every column declared once, with a chooser. The FY report has three tables of three different shapes (a holdings snapshot, dividend income, CGT disposals), none of which is a list of `Holding`, and none of which wants a column chooser on a tax report. Forcing them through the registry would mean three more registries. Sorting is the only thing they share, so sorting is the only thing that got shared. The two rules worth stating out loud, because every naive implementation gets them wrong: * **Blanks sort last in BOTH directions.** `sorted(reverse=True)` reverses the blanks along with everything else, so descending puts the empty rows on top. * **The sort key is never the displayed value.** "Ticker" renders a link and a currency badge; "DRP" renders "yes" or nothing. Sorting the rendered string sorts "1,234.50" as text and puts a row with no data wherever the browser felt like. """ from __future__ import annotations from decimal import Decimal import pytest from app import sorting def rows(*values): """Rows carrying a value and their original position, so ties can be checked for stability.""" return [{"v": v, "n": i} for i, v in enumerate(values)] def values(result): return [r["v"] for r in result] def get(row): return row["v"] # --------------------------------------------------------------------------- # # Ordering # --------------------------------------------------------------------------- # def test_it_sorts_ascending(): result = sorting.apply(rows(3, 1, 2), get, descending=False) assert values(result) == [1, 2, 3] def test_it_sorts_descending(): result = sorting.apply(rows(3, 1, 2), get, descending=True) assert values(result) == [3, 2, 1] def test_none_sorts_last_ascending(): result = sorting.apply(rows(3, None, 1), get, descending=False) assert values(result) == [1, 3, None] def test_none_sorts_last_descending_too(): """The one every naive comparator gets wrong: `reverse=True` moves the blanks to the top, so a closed position with no gain % becomes the most interesting row on the page.""" result = sorting.apply(rows(3, None, 1), get, descending=True) assert values(result) == [3, 1, None] def test_an_empty_string_counts_as_blank(): """An instrument with no name recorded is missing data, not a name that sorts before every other name.""" result = sorting.apply(rows("pear", "", "apple"), get, descending=False) assert values(result) == ["apple", "pear", ""] def test_an_empty_string_is_last_descending_as_well(): result = sorting.apply(rows("pear", "", "apple"), get, descending=True) assert values(result) == ["pear", "apple", ""] def test_zero_is_not_blank(): """Nought is a number and belongs in the ordering. Treating falsiness as absence would hide every position that has not moved today.""" result = sorting.apply(rows(Decimal("2"), Decimal("0"), None), get, descending=False) assert values(result) == [Decimal("0"), Decimal("2"), None] def test_false_is_not_blank(): result = sorting.apply(rows(True, False, None), get, descending=False) assert values(result) == [False, True, None] def test_ties_keep_their_original_order(): """So a secondary sort is whatever the page's natural order was — for holdings, grouped by asset class then ticker.""" result = sorting.apply(rows(1, 1, 1), get, descending=False) assert [r["n"] for r in result] == [0, 1, 2] def test_ties_keep_their_original_order_descending_too(): """`sorted(reverse=True)` is stable, but reversing a sorted list is not — which is the tempting way to write this.""" result = sorting.apply(rows(1, 1, 1), get, descending=True) assert [r["n"] for r in result] == [0, 1, 2] def test_decimals_and_ints_sort_together(): result = sorting.apply(rows(Decimal("1.5"), 1, Decimal("2")), get, descending=False) assert values(result) == [1, Decimal("1.5"), Decimal("2")] def test_the_input_list_is_not_mutated(): original = rows(3, 1, 2) sorting.apply(original, get, descending=False) assert values(original) == [3, 1, 2] def test_an_unsortable_mix_is_left_alone_rather_than_raising(): """A column whose values are not comparable to each other would otherwise take the whole page down with a TypeError. The table is worth more than the ordering.""" result = sorting.apply(rows("a", 1), get, descending=False) assert values(result) == ["a", 1] # --------------------------------------------------------------------------- # # Reading the request # --------------------------------------------------------------------------- # ALLOWED = {"ticker", "units", "gain_reporting"} def test_no_parameters_means_unsorted(): sort = sorting.read({}, "holdings", ALLOWED) assert sort.key is None assert sort.descending is False def test_it_reads_its_own_table_only(): """Two tables on one page must not sort each other. The FY report has three.""" query = {"holdings_sort": "units", "income_sort": "cash"} assert sorting.read(query, "holdings", ALLOWED).key == "units" assert sorting.read(query, "income", {"cash"}).key == "cash" def test_direction_defaults_to_ascending(): assert sorting.read({"holdings_sort": "units"}, "holdings", ALLOWED).descending is False def test_descending_is_read(): sort = sorting.read({"holdings_sort": "units", "holdings_dir": "desc"}, "holdings", ALLOWED) assert sort.descending is True def test_an_unknown_column_is_ignored(): """A query parameter names an attribute to sort by, so it is caller-supplied input reaching a lookup. Anything not on the allow-list is dropped and the table renders unsorted — never a 500, and never an attribute nobody meant to expose.""" sort = sorting.read({"holdings_sort": "password_hash"}, "holdings", ALLOWED) assert sort.key is None def test_a_nonsense_direction_is_treated_as_ascending(): sort = sorting.read({"holdings_sort": "units", "holdings_dir": "sideways"}, "holdings", ALLOWED) assert sort.descending is False # --------------------------------------------------------------------------- # # The header link # --------------------------------------------------------------------------- # def test_a_fresh_column_links_to_ascending(): sort = sorting.read({}, "holdings", ALLOWED) assert sort.href("units") == "?holdings_sort=units" def test_the_active_column_links_to_the_other_direction(): sort = sorting.read({"holdings_sort": "units"}, "holdings", ALLOWED) assert sort.href("units") == "?holdings_sort=units&holdings_dir=desc" def test_a_descending_column_links_back_to_ascending(): sort = sorting.read({"holdings_sort": "units", "holdings_dir": "desc"}, "holdings", ALLOWED) assert sort.href("units") == "?holdings_sort=units" def test_switching_column_starts_ascending_again(): sort = sorting.read({"holdings_sort": "units", "holdings_dir": "desc"}, "holdings", ALLOWED) assert sort.href("ticker") == "?holdings_sort=ticker" def test_other_query_parameters_survive(): """The FY report lives at `/?fy=2026`. A header link that dropped that would sort the wrong year — silently, and with plausible numbers.""" sort = sorting.read({"fy": "2026"}, "snapshot", {"units"}) assert sort.href("units") == "?fy=2026&snapshot_sort=units" def test_another_tables_sort_survives(): query = {"income_sort": "cash", "income_dir": "desc"} sort = sorting.read(query, "snapshot", {"units"}) href = sort.href("units") assert "income_sort=cash" in href and "income_dir=desc" in href def test_the_link_is_url_encoded(): sort = sorting.read({"q": "a b&c"}, "holdings", ALLOWED) assert " " not in sort.href("units") assert "a+b%26c" in sort.href("units") or "a%20b%26c" in sort.href("units") # --------------------------------------------------------------------------- # # What the header shows # --------------------------------------------------------------------------- # def test_only_the_sorted_column_gets_an_arrow(): """An arrow on every header is decoration; it stops saying which one is active, which is the entire job.""" sort = sorting.read({"holdings_sort": "units"}, "holdings", ALLOWED) assert sort.arrow("units") assert sort.arrow("ticker") == "" assert sort.arrow("gain_reporting") == "" def test_the_arrow_points_the_way_it_is_sorted(): up = sorting.read({"holdings_sort": "units"}, "holdings", ALLOWED) down = sorting.read({"holdings_sort": "units", "holdings_dir": "desc"}, "holdings", ALLOWED) assert up.arrow("units") != down.arrow("units") def test_aria_sort_says_what_the_arrow_says(): """The arrow is a glyph. A screen reader needs the table semantics.""" sort = sorting.read({"holdings_sort": "units"}, "holdings", ALLOWED) assert sort.aria("units") == "ascending" assert sort.aria("ticker") == "none" down = sorting.read({"holdings_sort": "units", "holdings_dir": "desc"}, "holdings", ALLOWED) assert down.aria("units") == "descending" @pytest.mark.parametrize("key", ["units", "ticker", "gain_reporting"]) def test_nothing_is_marked_sorted_when_nothing_is(key): sort = sorting.read({}, "holdings", ALLOWED) assert sort.arrow(key) == "" assert sort.aria(key) == "none" # --------------------------------------------------------------------------- # # Through the routes — the half that proves any of the above is wired up # --------------------------------------------------------------------------- # import re # noqa: E402 from freezegun import freeze_time # noqa: E402 import fixture_portfolio as ref # noqa: E402 from test_routes import bind_to_only_portfolio, make_login, session_csrf # noqa: E402 HTML = {"accept": "text/html"} def tickers_in_order(page: str, table_class: str = "holdings") -> list[str]: """The ticker of each row, top to bottom, from the named table. Reads the rendered HTML rather than a context variable on purpose: the ordering is only real if it survives the template. """ table = re.search(rf'