diff --git a/scripts/seed_gauge_display.py b/scripts/seed_gauge_display.py index ba8271b7..2f55d4dd 100644 --- a/scripts/seed_gauge_display.py +++ b/scripts/seed_gauge_display.py @@ -13,8 +13,11 @@ - basin: river with any fork modifier stripped (``Umpqua`` for all N/S/mainstem rows in the Umpqua drainage) - fork_rank: ``0`` for fork rows, ``9`` for mainstem → forks sort first - - fork_label: ``north`` / ``south`` / ``east`` / ``west`` / ``middle`` - (empty for mainstem) — distinguishes forks in the same basin + - fork_label: ``north`` / ``south`` / ``east`` / ``west`` / ``middle`` / + ``little`` / ``yankee`` (empty for mainstem) — distinguishes forks in the + same basin. Alphabetical by default; a basin listed in ``_FORK_ORDER`` + gets a ``00-``/``01-``… rank prefix instead, ordering its forks by + confluence (``99-`` for one the list doesn't name). - elev_key: ``10000 - elevation`` zero-padded so higher elevation sorts first (upstream ≈ higher); NULL → sentinel pushing row to end - da_key: drainage_area zero-padded so smaller catchment sorts first @@ -50,6 +53,70 @@ DEFAULT_CACHE = str(GAUGE_METADATA_CACHE) _DIRECTIONS = ("North", "South", "East", "West", "Middle") +# "Little X" rivers that feed the X they are named for, and so belong in X's +# basin group — sorted ahead of the mainstem by fork_rank 0, the same as X's +# directional forks. +# +# Membership is a reviewed domain claim, not something derivable from the name +# or the HUC, and it does not generalize: the Little White Salmon shares HUC8 +# 17070105 with the White Salmon yet reaches the Columbia independently, while +# the Little Salmon (17060210) and Little Deschutes (17070302) each sit in a +# different HUC8 from the river they flow into. So the rule is an allowlist — +# a "Little X" not named here keeps its own "little x" basin, which is the +# status quo and the safe default for a river nobody has adjudicated yet. +_LITTLE_TRIBUTARIES = frozenset( + { + "little deschutes", # → Deschutes, joins near La Pine + "little north santiam", # → North Santiam + "little salmon", # → Salmon, joins at Riggins + "little sandy", # → Sandy, via the Bull Run + } +) +_LITTLE_RE = re.compile(r"^Little\s+", re.IGNORECASE) +# Forks named for something other than a direction, peeled like one so the row +# joins its parent's basin ("Yankee Fork Salmon" → Salmon/yankee). +# +# An allowlist rather than a general "{Word} Fork {Basin}" rule, which would +# also reclassify "Coast Fork Willamette" — true of the river, but a page +# reorder nobody asked for. ("Oak Grove Fork" and "Clark Fork" name no basin +# after "Fork", so no rule would touch them either way.) +# +# Unlike a direction, a name only marks a fork when "Fork" actually follows: +# "North Umpqua" is a fork, but "Yankee Creek" is a creek. The peel below +# therefore requires the literal "Fork" after these, which also keeps the next +# entry from misreading, say, "Bear Creek" as ("Creek", "bear"). +_NAMED_FORKS = ("Yankee",) +# Basins whose forks are ordered explicitly instead of alphabetically, listed +# upstream → downstream by where each fork's water joins the mainstem. The +# default sort is the fork label itself, which carries no geography: it reads +# well where the alphabet cooperates, but strands "yankee" behind "south". +# +# The Salmon sequence is the club's own, taken from reach.csv's curated +# "Salmon ag NN": ag 01 Yankee Fork, ag 02 EF Salmon, ag 03 MF, ag 04 EF of SF, +# ag 06-08 SF, ag 09-10 Little. Of those, the forks carrying a gauge give the +# order below. +# +# Gauge elevation is NOT the authority, though it looks like one: EFSF's gauge +# is the highest in the basin at 6466 ft, yet its water reaches the mainstem +# last of the four — it is a fork of the *South* Fork, so it arrives at the SF +# confluence, well downstream of the Middle Fork's. +# +# Caveat: basin_and_fork keeps only the first modifier, so EF Salmon (ag 02) +# and EF of SF Salmon (ag 04) both reduce to "east" and cannot both be placed. +# Only the latter has a gauge today; adding an EF Salmon gauge means splitting +# the label, not reordering this list. +# +# Only the named basin is affected; everywhere else stays alphabetical. A fork +# absent from its basin's list keeps its bare label, which sorts after the +# ranked ones and so lands at the end of the fork group, ahead of the mainstem. +_FORK_ORDER = { + "salmon": ("yankee", "middle", "east", "south", "little"), +} +# Rank given to a fork inside a ranked basin that the basin's list doesn't +# name. Above any real index, so such a fork sorts after every curated one +# regardless of its initial letter (and still ahead of the mainstem, which is +# ranked by fork_rank 9 one field over). +_UNRANKED_FORK = "99" _DIRECTION_LETTERS = { "N": "North", "S": "South", @@ -192,9 +259,9 @@ def _collapse_whitespace(s: str) -> str: def basin_and_fork(river: str) -> tuple[str, str]: """Split a normalized river name into (basin, fork_label). - Iteratively peels off ``{direction} [Fork]`` and ``of [the] {direction} + Iteratively peels off ``{modifier} [Fork]`` and ``of [the] {modifier} [Fork]`` prefixes until what remains is the base river; the *first* - direction seen is returned as the primary fork label (so + modifier seen is returned as the primary fork label (so ``North Fork of Middle Fork Willamette`` → basin ``Willamette``, fork ``north``, which still groups with other Willamette tributaries). @@ -203,31 +270,81 @@ def basin_and_fork(river: str) -> tuple[str, str]: ``Hood`` → (``Hood``, ``"")`` # mainstem ``East Fork of South Fork Salmon`` → (``Salmon``, ``east``) ``North Fork of Middle Fork Willamette`` → (``Willamette``, ``north``) + + A leading ``Little`` peels only for the rivers in ``_LITTLE_TRIBUTARIES``, + and peels *before* the directional pass so the remainder still reduces to + its basin:: + + ``Little Deschutes`` → (``Deschutes``, ``little``) + ``Little North Santiam`` → (``Santiam``, ``little``) + ``Little White Salmon`` → (``Little White Salmon``, ``"")`` # own river + ``Little`` → (``Little``, ``"")`` # own river + + A ``_NAMED_FORKS`` entry peels only with an explicit ``Fork`` after it, + so ``Yankee Fork Salmon`` → (``Salmon``, ``yankee``) while + ``Yankee Creek`` stays (``Yankee Creek``, ``""``). """ if not river: return "", "" - dir_re = "|".join(_DIRECTIONS) + alts = [rf"(?P{'|'.join(_DIRECTIONS)})(?:\s+Fork)?"] + if _NAMED_FORKS: + alts.append(rf"(?P{'|'.join(_NAMED_FORKS)})\s+Fork") peel_re = re.compile( - rf"^(?:of\s+(?:the\s+)?)?(?P{dir_re})(?:\s+Fork)?\s+", + rf"^(?:of\s+(?:the\s+)?)?(?:{'|'.join(alts)})\s+", re.IGNORECASE, ) s = river dirs_seen: list[str] = [] + if s.strip().casefold() in _LITTLE_TRIBUTARIES: + m = _LITTLE_RE.match(s) + if m: + dirs_seen.append("little") + s = s[m.end() :] while True: m = peel_re.match(s) if not m: break - dirs_seen.append(m.group("dir").lower()) + # Exactly one of the two alternatives matched; "named" is absent + # entirely when _NAMED_FORKS is empty, hence groupdict(). + token = m.group("dir") or m.groupdict().get("named") or "" + dirs_seen.append(token.lower()) s = s[m.end() :] if dirs_seen and s.strip(): return s.strip(), dirs_seen[0] return river, "" +def _rank_fork(basin: str, fork: str) -> str: + """Prefix a curated basin's fork label so it sorts in confluence order. + + ``yankee`` → ``00-yankee`` in the Salmon basin. A basin with no entry in + ``_FORK_ORDER`` is returned untouched and keeps sorting alphabetically. + + Inside a ranked basin, a fork the list doesn't name is prefixed too — with + a rank above every real one, so it lands at the end of the fork group *by + construction*. It used to be returned bare, which put it after the ranked + forks only when its own first letter happened to fall later: true for + ``north``/``west``, false the moment ``_NAMED_FORKS`` gains an early + letter. A "Coast Fork Salmon" would have led the basin. + + The rank is numeric rather than ``chr(ord("a") + i)``, which would walk + past ``z`` — and then into ``|``, the field delimiter — at 26 forks. + """ + if not fork: + return fork + order = _FORK_ORDER.get(basin.lower()) + if order is None: + return fork + if fork not in order: + return f"{_UNRANKED_FORK}-{fork}" + return f"{order.index(fork):02d}-{fork}" + + def build_sort_name(river: str, elevation: float | None, drainage_area: float | None) -> str: """Compose the single alphabetical key described in the module docstring.""" basin, fork = basin_and_fork(river or "") fork_rank = "0" if fork else "9" + fork = _rank_fork(basin, fork) # Elevation DESC: invert so higher → smaller numeric. Sentinel 15000 for # NULL pushes rows without elevation to the end of their group. if elevation is not None: diff --git a/src/kayak/parsers/nwrfc_textplot.py b/src/kayak/parsers/nwrfc_textplot.py index 70ebfe3e..b309a3ba 100644 --- a/src/kayak/parsers/nwrfc_textplot.py +++ b/src/kayak/parsers/nwrfc_textplot.py @@ -10,12 +10,19 @@ * ``pe=HG`` on a gage-only station — 1 value column (Stage). * ``pe=HG`` on a rated station — 2 value columns (Stage + Discharge), which we emit as gauge + flow for the same timestamp. +* ``pe=TW`` (water temperature) — 1 value column (Temperature), already + in °F, which is the unit the rest of the pipeline stores. The schema is inferred from the column-header row at the top of the -table; pages without a recognisable header fall back to a 1-column -flow/inflow heuristic (covers truncated/error bodies and test fixtures). +table. A page with *no* header row falls back to a 1-column flow/inflow +heuristic (covers truncated/error bodies and test fixtures). A page whose +header row *is* present but names a column we don't map — ``pe=HP``'s +"Pool Height", say — yields nothing at all: the label is a known-unknown, +and guessing "flow" there would republish a pool elevation or a +temperature as a discharge. Stale is recoverable; wrong is not. """ +import logging import re from datetime import UTC, datetime @@ -24,10 +31,13 @@ from kayak.parsers.registry import register from kayak.utils.conversions import parse_datetime, safe_float +logger = logging.getLogger(__name__) + _LABEL_TO_DTYPE = { "stage": DataType.gauge, "discharge": DataType.flow, "inflow": DataType.inflow, + "temperature": DataType.temperature, } @@ -63,6 +73,10 @@ def parse_records( tz = "PDT" if "(pdt)" in header_lower else "PST" if "(pst)" in header_lower else None value_dtypes = self._infer_value_columns(text) + if not value_dtypes: + # Header named a column we don't map (already logged). Refuse the + # page rather than let a zero-width row regex quietly match nothing. + return [] # Build the row regex: datetime + N value cells (one each). value_re = r"\s*]*>\s*([\d.]+)\s*" * len(value_dtypes) @@ -93,10 +107,19 @@ def _infer_value_columns(text: str) -> list[DataType]: Date/Time (PDT)StageDischarge The observed columns are everything up to the *second* Date/Time - cell (which begins the forecast half). If no such header is - present — truncated body, error page, or the simplified shape - used in unit tests — fall back to a 1-column schema and infer - flow vs. inflow from the surrounding text. + cell (which begins the forecast half). + + Returns ``[]`` when that header is present but names a column + outside ``_LABEL_TO_DTYPE``. The heuristic below is only for + bodies with *no* header — truncated, error pages, or the + simplified shape used in unit tests — where a 1-column + flow/inflow guess is the best available. Once a header has + parsed, the page has told us its schema, and an unmapped label + means we don't understand it: emitting nothing (a visibly stale + gauge) beats relabelling the column as flow. Note this refusal is + per-page and total, because the 1-column fallback would otherwise + re-capture column 1 under the wrong type and corrupt an adjacent + *known* column too. """ m = re.search( r"\s*]*>\s*Date/Time[^<]*" @@ -110,16 +133,28 @@ def _infer_value_columns(text: str) -> list[DataType]: (i for i, c in enumerate(cells) if "date/time" in c.lower()), len(cells), ) + observed = [c.strip() for c in cells[:forecast_split]] dtypes: list[DataType] = [] - for c in cells[:forecast_split]: - dt = _LABEL_TO_DTYPE.get(c.strip().lower()) + for c in observed: + dt = _LABEL_TO_DTYPE.get(c.lower()) if dt is None: - dtypes = [] - break + logger.error( + "unmapped textPlot column %r in header %r; storing nothing", + c, + observed, + ) + return [] dtypes.append(dt) - if dtypes: - return dtypes - + # Unconditional: a header that parsed is the page stating its + # schema, so the heuristic below must stay out of it. An empty + # observed half (forecast_split == 0) is still a statement — it + # says there are no observed columns — and returning [] here is + # what keeps `if dtypes:` from quietly handing it to the flow + # guess, the one shape this whole method argues against. + return dtypes + + # Reached only when the body carries no header row at all: truncated + # responses, error pages, the simplified unit fixtures. if ">inflow<" in text.lower(): return [DataType.inflow] return [DataType.flow] diff --git a/tests/test_parsers/test_nwrfc_textplot.py b/tests/test_parsers/test_nwrfc_textplot.py index fad1b8f9..cb07e34a 100644 --- a/tests/test_parsers/test_nwrfc_textplot.py +++ b/tests/test_parsers/test_nwrfc_textplot.py @@ -73,6 +73,44 @@ """ +# pe=TW response: water temperature (°F), observed-only — the page carries +# no forecast half, so the header row has a single Date/Time cell and the +# data rows trail empty spacer cells. +# Captured live from LAPO3 (LITTLE DESCHUTES--NR LAPINE) on 2026-07-15. +TEXTPLOT_TW_TEMPERATURE = """\ + +LITTLE DESCHUTES--NR LAPINE (LAPO3)

+ + + + +
Observed
Date/Time (PDT)Temperature
2024-06-15 08:1569.4  
2024-06-15 08:0069.6  
+""" + +# pe=HP: reservoir pool elevation, a column we deliberately do not map. +# Shape captured live from DETO3 (Detroit Lake) on 2026-07-15, where the +# real page serves ~1031 rows of pool height in feet. +TEXTPLOT_HP_POOL_HEIGHT = """\ + + + + + +
Observed
Date/Time (PDT)Pool Height
2024-06-15 08:151543.71
2024-06-15 08:001543.702
+""" + +# A known column (Stage) sitting next to an unmapped one. The 1-column +# fallback used to re-capture Stage as flow, corrupting a column we do +# understand — so an unmapped label has to void the whole page. +TEXTPLOT_MIXED_KNOWN_AND_UNKNOWN = """\ + + + + + +
Date/Time (PDT)StagePool HeightDate/Time (PDT)StagePool Height
2024-06-15 15:459.731543.712024-06-15 17:009.711543.70
+""" + # pe=HG on a gage-only NWRFC station: only Stage appears in the observed # half. Captured live from OCUO3 (Willamette Upper Falls) on 2026-05-11. TEXTPLOT_HG_STAGE_ONLY = """\ @@ -231,3 +269,74 @@ def test_hg_stage_only_emits_gauge(self, session): obs = session.query(Observation).filter_by(source_id=src.id).all() assert all(o.data_type == DataType.gauge for o in obs) assert sorted(o.value for o in obs) == [54.08, 54.08] + + +class TestNWRFCTextPlotTW: + def test_tw_emits_temperature(self, session): + """pe=TW yields temperature values in °F. + + Regression guard: before ``Temperature`` was in ``_LABEL_TO_DTYPE`` + the header lookup failed, the column-inference bailed to its + 1-column ``flow`` fallback, and a 69.4 °F reading was stored as + 69.4 cfs — silently publishing a temperature as a discharge. + """ + src = _make_source(session, name="tw_temperature") + parser = NWRFCTextPlotParser( + url="https://www.nwrfc.noaa.gov/station/flowplot/textPlot.cgi?id=LAPO3&pe=TW", + session=session, + source_id=src.id, + ) + count = parser.parse(TEXTPLOT_TW_TEMPERATURE) + assert count == 2 + obs = session.query(Observation).filter_by(source_id=src.id).all() + assert all(o.data_type == DataType.temperature for o in obs) + assert sorted(o.value for o in obs) == [69.4, 69.6] + + def test_tw_header_infers_temperature_column(self): + """The observed half of a pe=TW page is a single Temperature column.""" + parser = NWRFCTextPlotParser.__new__(NWRFCTextPlotParser) + assert parser._infer_value_columns(TEXTPLOT_TW_TEMPERATURE) == [DataType.temperature] + + +class TestNWRFCTextPlotUnmappedColumn: + """A parsed header naming a column we don't map must store nothing. + + The old code fell through to a 1-column ``flow`` guess, which is how a + 69.4 °F reading would have become 69.4 cfs. The same fallback still + reaches ``pe=HP``, which NWRFC serves today: Detroit Lake's pool + elevation of 1543.71 ft would post as 1543.71 cfs. + """ + + def test_pool_height_stores_nothing(self, session): + src = _make_source(session, name="hp_pool") + parser = NWRFCTextPlotParser( + url="https://www.nwrfc.noaa.gov/station/flowplot/textPlot.cgi?id=DETO3&pe=HP", + session=session, + source_id=src.id, + ) + assert parser.parse(TEXTPLOT_HP_POOL_HEIGHT) == 0 + assert session.query(Observation).filter_by(source_id=src.id).all() == [] + + def test_unmapped_column_does_not_corrupt_a_known_neighbour(self, session): + """Stage must not be re-emitted as flow just because a sibling is unknown.""" + src = _make_source(session, name="hp_mixed") + parser = NWRFCTextPlotParser( + url="https://www.nwrfc.noaa.gov/station/flowplot/textPlot.cgi?id=DETO3&pe=HG", + session=session, + source_id=src.id, + ) + assert parser.parse(TEXTPLOT_MIXED_KNOWN_AND_UNKNOWN) == 0 + obs = session.query(Observation).filter_by(source_id=src.id).all() + assert obs == [] + assert not any(o.value == 9.73 for o in obs), "stage leaked as another data type" + + def test_infer_returns_empty_rather_than_guessing_flow(self): + parser = NWRFCTextPlotParser.__new__(NWRFCTextPlotParser) + assert parser._infer_value_columns(TEXTPLOT_HP_POOL_HEIGHT) == [] + assert parser._infer_value_columns(TEXTPLOT_MIXED_KNOWN_AND_UNKNOWN) == [] + + def test_headerless_bodies_still_use_the_heuristic(self): + """The fallback survives for the case it was actually written for.""" + parser = NWRFCTextPlotParser.__new__(NWRFCTextPlotParser) + assert parser._infer_value_columns(TEXTPLOT_FLOW) == [DataType.flow] + assert parser._infer_value_columns(TEXTPLOT_INFLOW) == [DataType.inflow] diff --git a/tests/test_scripts/test_seed_gauge_display.py b/tests/test_scripts/test_seed_gauge_display.py new file mode 100644 index 00000000..9bae1c0b --- /dev/null +++ b/tests/test_scripts/test_seed_gauge_display.py @@ -0,0 +1,254 @@ +"""Tests for the gauge display/sort-key helpers in ``seed_gauge_display.py``. + +``sort_name`` is the whole row order of gauges.html (the page sorts +alphabetically on it), so the basin/fork split is load-bearing for what a +reader sees. +""" + +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "scripts")) + +from seed_gauge_display import ( + _FORK_ORDER, + _rank_fork, + basin_and_fork, + build_display_name, + build_sort_name, +) + + +class TestBasinAndFork: + @pytest.mark.parametrize( + ("river", "expected"), + [ + # Mainstems — nothing to peel. + ("Hood", ("Hood", "")), + ("Deschutes", ("Deschutes", "")), + # Directional forks, spelled both ways. + ("North Fork Alsea", ("Alsea", "north")), + ("North Umpqua", ("Umpqua", "north")), + # Compound forks: the first modifier wins and keeps the row with + # its basin's other tributaries. + ("East Fork of South Fork Salmon", ("Salmon", "east")), + ("North Fork of Middle Fork Willamette", ("Willamette", "north")), + ], + ) + def test_directional_forks(self, river, expected): + assert basin_and_fork(river) == expected + + @pytest.mark.parametrize( + ("river", "expected"), + [ + ("Little Deschutes", ("Deschutes", "little")), + ("Little Sandy", ("Sandy", "little")), + ("Little Salmon", ("Salmon", "little")), + # Both modifiers peel; "little" wins as the first seen, so this + # sorts with the Santiam forks — ahead of North Santiam. + ("Little North Santiam", ("Santiam", "little")), + ], + ) + def test_little_peels_for_known_tributaries(self, river, expected): + """An allowlisted "Little X" groups with X instead of forming its own basin.""" + assert basin_and_fork(river) == expected + + @pytest.mark.parametrize( + ("river", "expected"), + [ + # Reaches the Columbia on its own, despite sharing HUC8 17070105 + # with the White Salmon — so it is not a fork of it. + ("Little White Salmon", ("Little White Salmon", "")), + # A river actually named "Little" — no parent to peel onto. + ("Little", ("Little", "")), + # Not adjudicated, so it keeps the status-quo basin. + ("Little Nestucca", ("Little Nestucca", "")), + ], + ) + def test_little_stays_put_when_not_a_known_tributary(self, river, expected): + """ "Little" is not a blanket modifier — independent rivers keep their basin.""" + assert basin_and_fork(river) == expected + + def test_named_fork_peels_to_parent_basin(self): + """A fork named for something other than a direction still peels.""" + assert basin_and_fork("Yankee Fork Salmon") == ("Salmon", "yankee") + + @pytest.mark.parametrize( + "river", + [ + # A named fork peels only with an explicit "Fork" after it — + # otherwise "Yankee" would eat the first word of any river. + "Yankee Creek", + "Yankee Boy Creek", + "Yankee", + ], + ) + def test_named_fork_requires_the_word_fork(self, river): + assert basin_and_fork(river) == (river, "") + + def test_direction_still_peels_without_the_word_fork(self): + """The relaxation stays for directions: "North Umpqua" IS a fork.""" + assert basin_and_fork("North Umpqua") == ("Umpqua", "north") + + @pytest.mark.parametrize( + "river", + [ + # A general "{Word} Fork {Basin}" rule would reclassify this; the + # allowlist deliberately does not. + "Coast Fork Willamette", + # No basin follows "Fork", so there is nothing to peel onto. + "Oak Grove Fork", + "Clark Fork", + ], + ) + def test_other_fork_names_are_untouched(self, river): + assert basin_and_fork(river) == (river, "") + + def test_empty(self): + assert basin_and_fork("") == ("", "") + + +class TestCuratedForkOrder: + def test_fork_order_matches_the_clubs_own_reach_curation(self): + """_FORK_ORDER must agree with an authority outside this module. + + kayak_data's reach.csv already curates this basin, via `sort_name` + "Salmon ag NN": ag 01 Yankee Fork, ag 02 EF Salmon, ag 03 MF, + ag 04 EF of SF, ag 06-08 SF, ag 09-10 Little. Restricted to the + forks that carry a gauge, that is the tuple below. + + Asserted against that citation rather than against a list mirroring + the constant, which would pass for whatever order happened to be + shipped. If this fails, re-derive it from reach.csv — do not simply + paste in the new value. + """ + assert _FORK_ORDER["salmon"] == ("yankee", "middle", "east", "south", "little") + + def test_yankee_fork_precedes_middle_fork_salmon(self): + """The user-visible requirement, stated independently of the prefixes.""" + yankee = build_sort_name("Yankee Fork Salmon", 5950.0, 189.0) + mf = build_sort_name("Middle Fork Salmon", 4384.4, 1042.0) + assert yankee < mf + + def test_efsf_gauge_sorts_below_middle_fork_despite_being_higher(self): + """Elevation must not drive fork order. + + EFSF's gauge is the basin's highest (6466 ft), but it is a fork of + the South Fork and joins the mainstem downstream of the MF. Ranking + forks by gauge elevation put it first; the club's curation puts it + fourth. This is the case that caught that error. + """ + efsf = build_sort_name("East Fork South Fork Salmon", 6466.0, 19.3) + mf = build_sort_name("Middle Fork Salmon", 4384.4, 1042.0) + assert efsf > mf, "EFSF must not lead the basin on gauge elevation" + assert efsf < build_sort_name("South Fork Salmon", 3750.0, 330.0) + + def test_curated_forks_still_precede_the_mainstem(self): + assert build_sort_name("Little Salmon", 1755.28, 576.0) < build_sort_name( + "Salmon", 5900.0, 807.0 + ) + + def test_other_basins_stay_alphabetical(self): + """_FORK_ORDER names only the Salmon basin; nothing else is re-ranked.""" + assert build_sort_name("North Santiam", 655.0, 654.0) == "santiam|0north|009345|000654" + assert build_sort_name("Middle Fork Willamette", 600.0, 100.0).startswith( + "willamette|0middle|" + ) + + @pytest.mark.parametrize("label", ["west", "north", "coast", "bear", "any"]) + def test_unlisted_fork_in_ranked_basin_sorts_after_every_ranked_fork(self, label): + """Placement must be structural, not an accident of the initial letter. + + The bare-label version of this passed only because the reachable + unlisted labels (`north`, `west`) happen to fall after the `a-`..`e-` + prefixes it was compared against. `coast` and `bear` are the cases + that exposed it: under that scheme a Coast Fork Salmon led the basin, + ahead of the curated order. Testing only `west` is how it survived. + """ + unranked = _rank_fork("Salmon", label) + for ranked in _FORK_ORDER["salmon"]: + assert unranked > _rank_fork("Salmon", ranked), ( + f"{label!r} must sort after ranked fork {ranked!r}" + ) + + def test_unlisted_fork_still_precedes_the_mainstem(self): + """End-to-end via a label that actually peels today ("West" is a direction). + + `coast`/`bear` can only be reached through _rank_fork until someone + adds them to _NAMED_FORKS — which is exactly the edit that used to + break the ordering, hence the parametrized test above. + """ + west = build_sort_name("West Fork Salmon", 5000.0, 50.0) + assert west.startswith("salmon|099-west|") + assert west > build_sort_name("Little Salmon", 1755.28, 576.0) # after ranked + assert west < build_sort_name("Salmon", 5900.0, 807.0) # before mainstem + + def test_unranked_basin_is_untouched(self): + """A basin with no _FORK_ORDER entry keeps bare, alphabetical labels.""" + assert _rank_fork("Santiam", "north") == "north" + assert _rank_fork("Umpqua", "south") == "south" + + def test_rank_prefix_cannot_reach_the_field_delimiter(self): + """Numeric ranks, so 26+ forks can't walk 'a'+i past 'z' into '|'.""" + for i, fork in enumerate(_FORK_ORDER["salmon"]): + assert _rank_fork("Salmon", fork) == f"{i:02d}-{fork}" + assert "|" not in _rank_fork("Salmon", fork) + + +class TestBuildSortName: + def test_little_deschutes_sorts_ahead_of_mainstem_deschutes(self): + """The Little Deschutes must precede every mainstem Deschutes gauge. + + Gauge 26 (display_name "Deschutes at La Pine"; its internal name + is Deschutes_Wickiup_merge) is the first mainstem row by + elevation; fork_rank 0 puts the Little Deschutes ahead of it + regardless of the fork's own elevation/DA. + """ + little = build_sort_name("Little Deschutes", None, None) + wickiup = build_sort_name("Deschutes", 4257.41, 483.0) + assert little == "deschutes|0little|999999|999999" + assert wickiup == "deschutes|9|005743|000483" + assert little < wickiup + + def test_forks_precede_mainstem_in_same_basin(self): + assert build_sort_name("Little Sandy", 720.0, 23.0) < build_sort_name("Sandy", 720.0, 23.0) + + def test_little_salmon_precedes_mainstem_salmon(self): + assert build_sort_name("Little Salmon", 2000.0, 576.0) < build_sort_name( + "Salmon", 2000.0, 576.0 + ) + + def test_little_north_santiam_precedes_north_santiam(self): + """ "little" sorts before "north" within the shared Santiam basin.""" + assert build_sort_name("Little North Santiam", 655.0, 112.0) < build_sort_name( + "North Santiam", 655.0, 112.0 + ) + + def test_little_white_salmon_keeps_its_own_basin(self): + """Not a fork, so it neither joins nor jumps the White Salmon group.""" + assert build_sort_name("Little White Salmon", 925.0, None) == ( + "little white salmon|9|009075|999999" + ) + + def test_null_metadata_sorts_to_end_of_its_group(self): + """NULL elevation/DA fall back to sentinels, not to the front.""" + assert build_sort_name("Deschutes", None, None) == "deschutes|9|999999|999999" + assert build_sort_name("Deschutes", 4257.41, 483.0) < build_sort_name( + "Deschutes", None, None + ) + + def test_elevation_descends_and_da_ascends(self): + """Upstream (higher, smaller catchment) sorts first.""" + high = build_sort_name("Deschutes", 4257.41, 483.0) + low = build_sort_name("Deschutes", 167.54, 10500.0) + assert high < low + + +class TestBuildDisplayName: + def test_river_at_location(self): + assert build_display_name("Little Deschutes", "La Pine") == "Little Deschutes at La Pine" + + def test_river_only(self): + assert build_display_name("Little Deschutes", "") == "Little Deschutes"