From b962f498ab659a7d70c124a498224f674bcb52c9 Mon Sep 17 00:00:00 2001 From: bvkay Date: Wed, 19 Aug 2026 07:51:23 +0800 Subject: [PATCH 1/2] fix: the data-level grid's links shipped in the UA default, near-invisible on the navy tiles The survey drawer's data-level grid renders three anchor sites - the tile identifiers (.dl-id: DOIs and SARIG PIDs), the "Levels per Rees et al. 2019" citation (.dl-cite) and the instruments platform PID (.dl-instr) - and NO rule in the sheet ever selected any of them. With no author colour declared they took the user agent's link colours: a dark blue that is all but unreadable on --panel-2 #1E2B4F, turning browser-purple once followed. The owner caught it in a screenshot of the deployed drawer. This is the same defect the .meta td a rule already fixed for the summary tables, and it takes the same fix, reused rather than reinvented: var(--copper), no underline until hover - the exact value .dsub a.orglink (organisation ROR links) and .surveymeta a (publication DOIs) already carry. :visited is stated EXPLICITLY, the only :visited rule in the sheet: an identifier is not consumed by being clicked, so a followed DOI must read identically to an unfollowed one, and saying so keeps that true if a later edit ever narrows the base selector. The muted tiles' "not yet recorded" state is deliberately NOT in these selectors - an absence is a statement, not a followable link, so it stays --muted. Two pins, each with its own failure mode. test_data_level_link_rules_reuse_the_established_treatment reads the established rules' value OUT OF THE SHEET and requires the three new rules to equal it, so inventing a second accent fails as loudly as declaring none; it needs no Node and adds no skip. The negative pin proves the state text is neither an anchor nor accent-painted. The cascade half - that these rules actually SELECT the anchors the grid renders - lands with the drawer changes in the next commit, where the rendered DOM to match against exists. RED-proven: with this CSS reverted and the tests kept, the pin fails with "the tile identifier links (DOIs / SARIG PIDs): index.html declares no colour for '.dl-id a' - the UA default ships". --- portal/index.html | 13 +++ portal/tests/test_drawer_link_styling.py | 128 +++++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 portal/tests/test_drawer_link_styling.py diff --git a/portal/index.html b/portal/index.html index ecee786..fc73f0e 100644 --- a/portal/index.html +++ b/portal/index.html @@ -203,6 +203,19 @@ .sechead .dl-count{font-weight:400;color:var(--muted);text-transform:none;letter-spacing:0} .dl-cite{font-size:11.5px;color:var(--muted);margin-top:6px} .dl-instr{font-size:12px;color:var(--muted);margin-top:4px} + /* Drawer-polish lane (owner, 2026-08-19). The grid's three link sites - the tile identifiers (.dl-id), + the Rees et al. 2019 citation (.dl-cite) and the instruments platform PID (.dl-instr) - had NO colour + rule of their own and fell back to the user agent's link colours: a near-invisible dark blue on the + navy tiles, going purple once followed. Exactly the defect the .meta td a rule above fixed for the + summary tables, and it takes exactly that treatment - the site accent, underline on hover only, the + same value .dsub a.orglink and .surveymeta a already use. Nothing new is invented here. + :visited is stated EXPLICITLY rather than left to the cascade: an identifier is not "consumed" by + being clicked, so a followed DOI must read identically to an unfollowed one, and saying so keeps the + rule true if a later edit ever narrows the base selector. The muted tiles' "not yet recorded" text is + NOT a link and is deliberately absent from these selectors - it stays --muted. */ + .dl-id a,.dl-cite a,.dl-instr a{color:var(--copper);text-decoration:none} + .dl-id a:visited,.dl-cite a:visited,.dl-instr a:visited{color:var(--copper)} + .dl-id a:hover,.dl-cite a:hover,.dl-instr a:hover{text-decoration:underline} /* R5: the Files tab NCI-level list — single column of full-width rows (not the 2-col tile grid). */ .filelist{display:flex;flex-direction:column;gap:6px} .filelist .prod{width:100%} diff --git a/portal/tests/test_drawer_link_styling.py b/portal/tests/test_drawer_link_styling.py new file mode 100644 index 0000000..7845731 --- /dev/null +++ b/portal/tests/test_drawer_link_styling.py @@ -0,0 +1,128 @@ +"""Drawer-polish lane (owner screenshot evidence, 2026-08-19): the survey data-level grid's links. + +The grid shipped with THREE anchor sites that no CSS rule ever coloured - the tile identifier links +(.dl-id), the "Levels per Rees et al. 2019" citation link (.dl-cite) and the instruments platform-PID link +(.dl-instr). With no author colour declared they fell back to the user agent's link colours, which on the +navy (--panel-2 #1E2B4F) tiles are a near-invisible dark blue, going browser-purple once followed. It is +the same defect the `.meta td a` rule already fixed for the summary tables, and it takes the same fix. + +WHAT EACH LAYER PROVES (the three are deliberately different failure modes, not three spellings of one): + + * here, test_data_level_link_rules_reuse_the_established_treatment - the SHEET declares the treatment for + all three containers, at the value the portal's established link rules already use (read out of the + sheet, never hard-coded here: the lane's instruction was reuse, not a new colour), with :visited stated + explicitly. FAILS IF a rule is missing, if someone invents a second accent, or if :visited is left to + the browser. Needs no Node - it reads index.html. + * here, test_unrecorded_tile_state_text_stays_muted_not_link_coloured - the negative: an absent level's + "not yet recorded" is a statement, not a link, and must not be painted the accent. + * tools/interaction_test.js (section DP) - the CASCADE: every anchor the grid actually renders is + SELECTED by an accent rule, asserted with element.matches() against the real index.html stylesheet in + jsdom. That is the layer that catches a container being renamed or a new link site being added out of + the rules' reach; a string pin here could not. + +Neither layer proves the RENDERED colour (jsdom resolves no custom properties and computes no cascade +beyond selector matching) - that remains a browser-eye check, and the owner's screenshot is the report. +""" +import re +import shutil +from pathlib import Path + +import pytest + +from test_related_identifiers_render import _render + +ROOT = Path(__file__).resolve().parent.parent # portal/ +INDEX = ROOT / "index.html" + +# The three containers the grid puts links in, with the drawer.js site that emits each. +LINK_CONTAINERS = { + ".dl-id": "the tile identifier links (DOIs / SARIG PIDs)", + ".dl-cite": "the 'Levels per Rees et al. 2019' citation link", + ".dl-instr": "the instruments platform-PID link", +} + + +def _stylesheet(): + css = INDEX.read_text(encoding="utf-8") + css = css.split("", 1)[0] + return re.sub(r"/\*.*?\*/", "", css, flags=re.S) + + +def _rules(css): + """Flat (selector_list, declarations) pairs, descending into @media/@supports blocks. + + A naive `([^{}]+)\\{([^{}]*)\\}` scan would mis-parse this sheet - it carries @media and @keyframes + blocks - so walk the braces instead and recurse one level into any at-rule that holds rules. + """ + out, i, n = [], 0, len(css) + while i < n: + brace = css.find("{", i) + if brace < 0: + break + prelude = css[i:brace].strip() + depth, j = 1, brace + 1 + while j < n and depth: + if css[j] == "{": + depth += 1 + elif css[j] == "}": + depth -= 1 + j += 1 + body = css[brace + 1:j - 1] + if prelude.startswith("@"): + if not prelude.startswith("@keyframes"): # keyframe stops are not selectors + out.extend(_rules(body)) + else: + out.append((prelude, body)) + i = j + return out + + +def _colour_for(rules, selector): + """The `color:` value declared by the rule whose selector list contains `selector`, else None.""" + for prelude, body in rules: + if selector in [s.strip() for s in prelude.split(",")]: + m = re.search(r"(?:^|;)\s*color\s*:\s*([^;]+)", body) + if m: + return m.group(1).strip() + return None + + +def test_data_level_link_rules_reuse_the_established_treatment(): + """Each of the grid's three link containers gets a descendant-anchor colour AND an explicit :visited + colour, both equal to the value the portal's established link rules already carry. FAILS (RED before + this lane) IF any container has no colour rule - which is exactly how the DOI, citation and + platform-PID links shipped in the UA default - or IF a new accent is invented instead of reused.""" + rules = _rules(_stylesheet()) + # The established treatment, READ OUT OF THE SHEET: the organisation ROR link in the drawer subline and + # the publication DOIs inside .surveymeta. If those two ever disagree, this pin says so before comparing. + org = _colour_for(rules, ".dsub a.orglink") + pubs = _colour_for(rules, ".surveymeta a") + assert org and pubs, "the established link rules (.dsub a.orglink / .surveymeta a) are gone from index.html" + assert org == pubs, f"the two established link treatments disagree: {org!r} vs {pubs!r}" + for cls, what in LINK_CONTAINERS.items(): + got = _colour_for(rules, f"{cls} a") + assert got, f"{what}: index.html declares no colour for '{cls} a' - the UA default ships" + assert got == org, f"{what}: '{cls} a' uses {got!r}, not the established treatment {org!r}" + vis = _colour_for(rules, f"{cls} a:visited") + assert vis, f"{what}: no ':visited' colour for '{cls} a' - a followed link may go browser-purple" + assert vis == org, f"{what}: '{cls} a:visited' uses {vis!r}, not the established treatment {org!r}" + + +@pytest.mark.skipif(shutil.which("node") is None, reason="Node.js not available") +def test_unrecorded_tile_state_text_stays_muted_not_link_coloured(tmp_path): + """The muted tiles' 'not yet recorded' state is a STATEMENT OF ABSENCE, not a link. It must stay in + the .dl-state span with no anchor, and no rule may paint .dl-state the accent (which would read as a + followable identifier). FAILS IF the state text is ever wrapped in an anchor or accent-coloured.""" + _station, story, _card = _render(tmp_path, {}) # nothing recorded: all six tiles muted + states = re.findall(r'(.*?)', story) + assert len(states) == 6, f"expected six muted state lines, got {len(states)}:\n{story}" + for s in states: + assert s.strip() == "not yet recorded", f"unexpected state copy: {s!r}" + assert " Date: Wed, 19 Aug 2026 07:52:32 +0800 Subject: [PATCH 2/2] feat: `entire` IS the umbrella record, so it fills the Collection slot - and the grid says what it is for Two owner changes to the survey drawer's data-level grid (2026-08-19), plus the cascade half of the link-styling pin from the previous commit. SLOT MAPPING. The `identifies` vocabulary's `entire` means ONE record covering all levels - the shape _template/survey.yaml directs a state-survey landing page to - which is precisely the umbrella record the Collection slot names. It was mapped to no slot at all, so it fell into the extra-tile bucket: Gawler Phase 2, whose only umbrella identifier is its GSSA/SARIG record (identifies: entire, no `collection` row), read "1 of 6 recorded" with an EMPTY Collection tile and the record itself hanging below the grid as an orphan seventh tile - the drawer denying a deposit the survey had plainly made. `entire` now ALIASES the Collection slot (SLOT_ALIASES, stated as data next to the slot table rather than buried in the resolver, so the next vocabulary addition has an obvious place to go). COLLISION RULE, implemented, commented at the alias table and pinned three ways: a survey carrying BOTH `collection` and `entire` gives the slot to the EXACT key and renders `entire` as an extra tile. Nothing is ever silently dropped - the extra-tile rule stays the section's one answer to "recorded, but not one of the six" - and "N of 6" keeps counting SLOTS, so a colliding pair tallies one, never two. Declaration order is irrelevant: the exact match wins wherever it sits in the list, and both the pytest and interaction fixtures declare `entire` FIRST so an order-dependent implementation fails. Resolving slots through aliases makes "this row's identifies is not a slot key" an unsafe proxy for "no slot took it", so the resolver now records the rows the six slots CONSUMED and derives the extras bucket from that set. The consumed set also makes single-consumption structural: no row can fill two slots, and none can be both a slot and an extra. COPY. The section head becomes "Data at every level: N of 6 recorded" (owner-approved wording from the design mockup) - it names what the grid is FOR, the deposit chain level by level, rather than the identifier machinery it is made of. The STATION drawer's "Persistent identifiers & instruments" block is untouched and keeps its name; the interaction pins assert BOTH, so the rename cannot silently revert and the card pin that asserts the block is absent does not go vacuous by losing its string. RED PROOFS (source reverted, tests kept): * `entire`-only -> "the `entire` row must FILL a slot, leaving exactly the six tiles, got 7" * copy -> "the survey detail must carry a 'Data at every level:' section head" * cascade -> "LINKCSS: no accent rule selects the grid anchor in 'dl-id'" (CSS reverted alone) CONTROL MUTATIONS on the shipped code, because the collision fixture is green before AND after by design (today's behaviour already equals the collision outcome, which is the point): * alias-resolved-before-exact -> caught by three pins, incl. "the exact `collection` row must win slot 1 over the `entire` alias" * extras derived from slot KEYS instead of the consumed set -> caught by the `entire`-only pins, which see the row rendered twice (slot AND orphan extra) The interaction leg is the layer that asserts the CASCADE for the previous commit's rules: it loads the real index.html in jsdom and requires an accent rule to SELECT each anchor the grid renders, and a :visited form of it to exist, via element.matches(). jsdom resolves no custom properties, so this proves selection and the declared value, never the painted pixel; the rendered colour remains a browser-eye check. --- portal/src/drawer.js | 49 +++++++-- portal/tests/test_identifies_level_render.py | 89 ++++++++++++++- portal/tools/interaction_test.js | 109 ++++++++++++++++++- 3 files changed, 225 insertions(+), 22 deletions(-) diff --git a/portal/src/drawer.js b/portal/src/drawer.js index bf5d87a..63f71c5 100644 --- a/portal/src/drawer.js +++ b/portal/src/drawer.js @@ -1500,7 +1500,7 @@ function surveyBundleTiles(slug){ `
${esc(L[0])}${esc(L[1])}${r.size?" · "+esc(fmtBytes(r.size)):""}
`; }).join(""); } -// ---- survey-drawer lane (ruling 4, amended 2026-08-18): the survey PERSISTENT IDENTIFIERS tile grid ------ +// ---- survey-drawer lane (ruling 4, amended 2026-08-18): the survey DATA AT EVERY LEVEL tile grid -------- // The block used to be a collapsed
of whatever single-value identifier rows happened to be // recorded, so its LENGTH varied per survey and a reader could not see what a survey had NOT deposited. // It is now a DATA-LEVEL grid: six fixed slots, always all six, rendered in the Downloads tile treatment. @@ -1525,6 +1525,18 @@ const DATA_LEVEL_SLOTS=[ ["level2","Level 2","derived frequency-domain processed data: transfer functions"], ["level3","Level 3","derived modelling inputs and outputs"], ]; +// SLOT ALIASES (drawer-polish lane, owner 2026-08-19). `entire` - ONE record covering all levels, the +// shape the survey template gives a state-survey landing page - IS the umbrella record the Collection +// slot names, so it FILLS that slot instead of falling through to the extra-tile bucket. Gawler Phase 2 +// is the case that forced this: its only umbrella identifier is the GSSA/SARIG record (identifies: +// entire), so the drawer read "1 of 6 recorded" with an empty Collection tile and an orphan hanging +// under the grid, when the survey plainly HAS deposited its umbrella record. +// COLLISION RULE: when a survey carries BOTH `collection` and `entire`, the EXACT key takes the slot and +// the alias renders as an EXTRA tile below the six. Two properties this preserves, in order: nothing is +// ever silently dropped (the extra-tile rule is the section's one answer to "recorded, but not one of the +// six"), and "N of 6" counts SLOTS, so a colliding pair tallies one, never two. Declaration order in the +// survey.yaml is irrelevant - the exact match wins wherever it sits in the list. +const SLOT_ALIASES={collection:["entire"]}; // One tile. UNRECORDED is the owner's explicit ruling: muted BUT VISIBLE (.prod.dis + a hollow dot + // "not yet recorded"), never omitted, so the deposit chain has the same shape on every survey and a gap is // legible as a gap. RECORDED renders the identifier with the SAME resolution honesty every other identifier @@ -1544,21 +1556,31 @@ function dataLevelTile(name,desc,row){ const attrs=act?Object.entries(act).map(([k,v])=>`data-${k}="${escAttr(v)}"`).join(" "):""; return `
`+ `
${head}${relatedIdLink(row.identifier,row.identifier_type)}${tag}
`;} -// The whole section: the six fixed slots, then any identifier that maps to NO slot as an EXTRA tile below -// them. Nothing is ever silently dropped - the `identifies` vocabulary may grow, and a row this build does +// The whole section: the six fixed slots, then any identifier NO slot claimed (directly or through +// SLOT_ALIASES) as an EXTRA tile below them. Nothing is ever silently dropped - the `identifies` +// vocabulary may grow, and a row this build does // not model must still be visible rather than vanishing between releases. "N of 6" counts the six FIXED // slots only (an extra tile is not one of the six), per the slot-mapping ruling. function surveyDataLevelsHtml(m){ m=m||{}; const rels=(m.related_identifiers||[]).filter(r=>r&&typeof r==="object"&&r.identifier); - const rowFor=k=>rels.find(r=>r.identifies===k); - const have=DATA_LEVEL_SLOTS.filter(([k])=>!!rowFor(k)).length; - const tiles=DATA_LEVEL_SLOTS.map(([k,name,desc])=>dataLevelTile(name,desc,rowFor(k))).join(""); - // Unmapped rows: an out-of-slot `identifies` (e.g. `entire`, one record covering all levels) or a legacy - // row that predates the level model and carries only a DataCite relation. Labelled by the same tables the - // retired Related-identifiers block used, so the label vocabulary is unchanged for these rows. - const slotKeys=DATA_LEVEL_SLOTS.map(([k])=>k); - const extras=rels.filter(r=>slotKeys.indexOf(r.identifies)<0).map(r=>{ + // Resolve the six slots ONCE, recording which rows they consumed. With aliases in play, "this row's + // identifies is not a slot key" is no longer a safe proxy for "no slot took it", and getting that wrong + // would either drop a row or render it twice - so the consumed set is tracked explicitly and the extras + // bucket is derived from it. `taken` also makes single-consumption structural: no row can fill two slots. + const taken=[],slotRows=[]; + DATA_LEVEL_SLOTS.forEach(([k])=>{ + const pick=key=>rels.find(r=>r.identifies===key&&taken.indexOf(r)<0); + const row=pick(k)||(SLOT_ALIASES[k]||[]).map(pick).find(Boolean)||null; // exact key first, then aliases + if(row)taken.push(row); + slotRows.push(row);}); + const have=slotRows.filter(Boolean).length; + const tiles=DATA_LEVEL_SLOTS.map(([,name,desc],i)=>dataLevelTile(name,desc,slotRows[i])).join(""); + // Unclaimed rows: an out-of-slot `identifies`, the alias that LOST a collision (a survey declaring both + // `collection` and `entire`), or a legacy row that predates the level model and carries only a DataCite + // relation. Labelled by the same tables the retired Related-identifiers block used, so the label + // vocabulary is unchanged for these rows. + const extras=rels.filter(r=>taken.indexOf(r)<0).map(r=>{ const label=(r.identifies&&IDENTIFIES_LABELS[r.identifies])||RELATION_LABELS[r.relation]||(r.relation?String(r.relation):"Related identifier"); return dataLevelTile(label,"recorded identifier outside the six data levels",r);}).join(""); // The project RAiD is a PROJECT identifier, not a data level, so it has no slot - but it was visible in @@ -1566,7 +1588,10 @@ function surveyDataLevelsHtml(m){ // same extra-tile mechanism, which is the section's one rule for "recorded, but not one of the six". const raidRow=(m.raid&&!String(m.raid).startsWith("TODO"))?{identifier:String(m.raid),identifier_type:"URL"}:null; const raid=raidRow?dataLevelTile("Project RAiD","the research activity this survey was acquired under",raidRow):""; - return `
Persistent identifiers: ${have} of 6 recorded
`+ + // Owner-approved wording from the design mockup (2026-08-19): the head names what the grid is FOR - the + // deposit chain, level by level - rather than the identifier machinery it happens to be made of. The + // STATION drawer's own "Persistent identifiers & instruments" block (identifiersHtml) keeps its name. + return `
Data at every level: ${have} of 6 recorded
`+ `
${tiles}${extras}${raid}
`+ // The citability IS the point of using a published scheme, so the grid says which one, in print. `
`+ diff --git a/portal/tests/test_identifies_level_render.py b/portal/tests/test_identifies_level_render.py index 7ca8847..089b85c 100644 --- a/portal/tests/test_identifies_level_render.py +++ b/portal/tests/test_identifies_level_render.py @@ -32,8 +32,10 @@ def test_related_block_labels_by_level_when_identifies_present(tmp_path): # the level label REPLACES the relation label for an identifies row assert "Derived from: " not in station, "an identifies row still showed the relation label:\n" + station # ---- SURVEY GRID SLOT MAPPING (ruling 4 + the slot-mapping ruling) ------------------------------- - # collection -> slot 1, raw_packed -> slot 2. `entire` maps to NO slot, so it must NOT consume one and - # must NOT vanish: it renders as an EXTRA tile below the six, and the header count stays 2 of 6. + # collection -> slot 1, raw_packed -> slot 2. This fixture carries BOTH `collection` and `entire`, so + # the drawer-polish COLLISION RULE applies (owner, 2026-08-19): `entire` is an ALIAS for the Collection + # slot, but the exact `collection` match wins the slot and `entire` falls to the extra-tile bucket - + # nothing silently dropped, and the header count tallies the SLOT (2 of 6), never both rows. tiles = re.findall(r'
]*>.*?
', story, re.S) assert len(tiles) == 7, f"expected the six fixed slots plus ONE extra tile, got {len(tiles)}:\n{story}" assert tiles[0].startswith('
'1 of 6' + an orphan.""" + extra = {"related_identifiers": [ + {"identifier": "https://pid.sarig.sa.gov.au/dataset/mesac487", "identifier_type": "URL", + "relation": "IsVariantFormOf", "custodian": "GSSA/SARIG", "identifies": "entire"}, + {"identifier": "https://pid.sarig.sa.gov.au/dataset/mesac525", "identifier_type": "URL", + "relation": "IsSourceOf", "custodian": "GSSA/SARIG", "identifies": "level3"}]} + _station, story, _card = _render(tmp_path, extra) + tiles = re.findall(r'
]*>.*?
', story, re.S) + assert len(tiles) == 6, \ + f"the `entire` row must FILL a slot, leaving exactly the six tiles, got {len(tiles)}:\n{story}" + assert "Collection<" in tiles[0] and "mesac487" in tiles[0], \ + "slot 1 (Collection) did not take the `entire` row:\n" + tiles[0] + assert "dis" not in tiles[0].split(">")[0], \ + "the Collection slot still renders MUTED despite the `entire` row filling it:\n" + tiles[0] + assert "Level 3<" in tiles[5] and "mesac525" in tiles[5], \ + "slot 6 (Level 3) did not take the level3 row:\n" + tiles[5] + assert "2 of 6 recorded" in story, \ + "the count must read '2 of 6 recorded' once `entire` fills the Collection slot:\n" + story + # the orphan is gone: `entire`'s own vocabulary label must no longer head a tile of its own + assert "Entire dataset" not in story, \ + "the `entire` row rendered as an extra tile as well as filling the Collection slot:\n" + story + + +@pytest.mark.skipif(shutil.which("node") is None, reason="Node.js not available") +def test_collection_and_entire_collision_gives_the_slot_to_collection(tmp_path): + """COLLISION RULE: a survey carrying BOTH `collection` and `entire` gives slot 1 to `collection` (the + exact match beats the alias) and renders `entire` as an EXTRA tile - nothing silently dropped, and the + count stays 1 of 6 (one SLOT recorded, not two rows). FAILS IF the alias steals the slot from the exact + match, IF the losing row vanishes, or IF the count double-tallies the pair.""" + extra = {"related_identifiers": [ + {"identifier": "10.25914/umbrella", "identifier_type": "DOI", "relation": "IsVariantFormOf", + "custodian": "GA", "identifies": "entire"}, + {"identifier": "10.25914/exact-collection", "identifier_type": "DOI", "relation": "IsPartOf", + "custodian": "NCI", "identifies": "collection"}]} + _station, story, _card = _render(tmp_path, extra) + tiles = re.findall(r'
]*>.*?
', story, re.S) + assert len(tiles) == 7, f"expected the six slots plus ONE extra tile, got {len(tiles)}:\n{story}" + # declared `entire` FIRST in the list, so a naive "first matching row wins" would hand it the slot + assert "Collection<" in tiles[0] and "10.25914/exact-collection" in tiles[0], \ + "the exact `collection` row must win slot 1 over the `entire` alias:\n" + tiles[0] + assert "10.25914/umbrella" not in tiles[0], "the `entire` alias took the slot from the exact match:\n" + tiles[0] + assert "Entire dataset" in tiles[6] and "10.25914/umbrella" in tiles[6], \ + "the collision-losing `entire` row was dropped instead of rendering as an extra tile:\n" + story + assert "1 of 6 recorded" in story, \ + "the count must tally the SLOT (1 of 6), never both colliding rows:\n" + story + + +@pytest.mark.skipif(shutil.which("node") is None, reason="Node.js not available") +def test_data_level_section_header_copy(tmp_path): + """Owner-approved wording from the design mockup: the grid's section head reads 'Data at every level: + N of 6 recorded', not the old 'Persistent identifiers:'. The STATION drawer's own identifiers block + keeps its name - that surface is explicitly untouched. FAILS (RED before the copy change) IF the survey + grid still heads 'Persistent identifiers:' or IF the station block loses its heading.""" + _station, story, _card = _render(tmp_path, {}) + assert "Data at every level: " in story, "the grid's section head did not take the approved wording:\n" + story + assert "Persistent identifiers:" not in story, \ + "the old 'Persistent identifiers:' head survives on the survey drawer:\n" + story + # non-vacuous: the STATION rollup keeps the old name, so the string is not simply gone from the portal + station2, _s2, _c2 = _render(tmp_path, {"related_identifiers": [ + {"identifier": "10.25914/x", "identifier_type": "DOI", "relation": "IsPartOf", + "identifies": "collection"}]}) + assert re.search(r"Persistent identifiers (&|&) instruments", station2), \ + "the untouched STATION identifiers block lost its heading:\n" + station2 @pytest.mark.skipif(shutil.which("node") is None, reason="Node.js not available") diff --git a/portal/tools/interaction_test.js b/portal/tools/interaction_test.js index 3ee0884..fa611b3 100644 --- a/portal/tools/interaction_test.js +++ b/portal/tools/interaction_test.js @@ -2321,7 +2321,11 @@ async function bootFreshWindow(dataMap, url) { ok(/DOI/.test(cardA1) && cardA1.indexOf("licence ?") >= 0, "E1: slim card must keep the licence + DOI badges"); // absent (moved to detail): identifiers rollup, APA cite block, spatial extent, coord-QC stats, // per-format availability matrix (EDI/time-series/MTH5 badges), the completeness/smoothness check. + // Both header strings are pinned absent: the station rollup's "Persistent identifiers & instruments" + // (what the card used to carry) AND the survey grid's "Data at every level" head, which the drawer-polish + // lane renamed it to. Pinning only the old string would have gone vacuous the moment it was renamed. ok(cardA1.indexOf("Persistent identifiers") < 0, "E1: the identifiers block must NOT be on the slim card"); + ok(cardA1.indexOf("Data at every level") < 0, "E1: the data-level grid must NOT be on the slim card"); ok(cardA1.indexOf('class="cite"') < 0, "E1: the APA citation block must NOT be on the slim card"); ok(cardA1.indexOf("extent") < 0, "E1: the spatial extent must NOT be on the slim card"); ok(cardA1.indexOf("coord QC") < 0, "E1: the coordinate-QC flag stat must NOT be on the slim card"); @@ -2425,11 +2429,16 @@ async function bootFreshWindow(dataMap, url) { // FIXED slots in the Rees et al. 2019 / NCI scheme, so the deposit chain has the same shape on every // survey. Alpha records no related_identifiers at all, which is exactly the case the old surface hid and // this one must state: 0 of 6, six MUTED-BUT-VISIBLE tiles, none of them omitted. - ok(![...drwE.querySelectorAll("details")].some(d => /Persistent identifiers:/.test(d.querySelector("summary") ? d.querySelector("summary").textContent : "")), + // COPY (drawer-polish lane, owner 2026-08-19): the head reads "Data at every level: N of 6 recorded". + // The old "Persistent identifiers:" wording is pinned GONE from the survey drawer below, so this rename + // cannot silently revert; the station drawer's own identifiers block is untouched and keeps its name. + ok(![...drwE.querySelectorAll("details")].some(d => /Data at every level:/.test(d.querySelector("summary") ? d.querySelector("summary").textContent : "")), "ruling 4: the identifiers rollup must NO LONGER be a collapsed
- it is an open tile grid"); - const idHead = [...drwE.querySelectorAll(".sechead")].find(h => /Persistent identifiers:/.test(h.textContent)); - ok(idHead, "ruling 4: the survey detail must carry a 'Persistent identifiers:' section head"); - ok(/Persistent identifiers:\s*0 of 6 recorded/.test(idHead.textContent), + const idHead = [...drwE.querySelectorAll(".sechead")].find(h => /Data at every level:/.test(h.textContent)); + ok(idHead, "drawer-polish: the survey detail must carry a 'Data at every level:' section head"); + ok(!/Persistent identifiers:/.test(drwE.innerHTML), + "drawer-polish: the old 'Persistent identifiers:' head must be GONE from the survey drawer"); + ok(/Data at every level:\s*0 of 6 recorded/.test(idHead.textContent), "ruling 4: the header count must read '0 of 6 recorded' for Alpha (no related_identifiers), got: " + JSON.stringify(idHead.textContent)); const idGrid = idHead.nextElementSibling; ok(idGrid && idGrid.classList.contains("prodgrid"), "ruling 4: the identifiers head must be followed by a .prodgrid (the Downloads tile treatment)"); @@ -2464,7 +2473,7 @@ async function bootFreshWindow(dataMap, url) { // release notes last. Ruling 3: the trailing "Related surveys" block is REMOVED. const H = drwE.innerHTML, at = s => H.indexOf(s); const oDesc = at('class="dim"'), oScatter = at("Downloads<"), - oFund = at(">Funding<"), oPubs = at("Related publications"), oIds = at("Persistent identifiers:"), + oFund = at(">Funding<"), oPubs = at("Related publications"), oIds = at("Data at every level:"), oRel = at("Release notes"); ok(oDesc >= 0 && oScatter > oDesc, "E4: description (1) must come before the geographic footprint (2)"); ok(oScatter < oSummary, "E4: footprint (2) must come before the station/period stats (3)"); @@ -3749,6 +3758,93 @@ async function bootFreshWindow(dataMap, url) { JSON.stringify([xmlBtn.textContent, h5Btn.textContent, ediBtn.textContent])); A.setSelected([]); + // ---- DP. DRAWER-POLISH LANE (owner feedback 2026-08-19): slot mapping + the grid's link treatment ---- + // Driven on Delta, which every earlier section has finished with, so nothing above is perturbed. + // + // (1) `entire` FILLS the Collection slot. `entire` means ONE record covering all levels - the umbrella + // record the Collection slot names - so it belongs in that slot, not in the extra-tile bucket it used + // to fall into. The fixture is Gawler Phase 2's real shape: a GSSA/SARIG umbrella landing page + // (identifies: entire, NO `collection` row) plus its level3 models record. RED on the pre-lane build: + // the Collection tile stayed muted, the head read "1 of 6 recorded", and the umbrella record hung + // below the grid as an orphan seventh tile. + A.setSMETA("Delta Survey", { instrument_pid: "10.82388/bt6orvhn", related_identifiers: [ + { identifier: "https://pid.sarig.sa.gov.au/dataset/mesac487", identifier_type: "URL", relation: "IsVariantFormOf", custodian: "GSSA/SARIG", identifies: "entire" }, + { identifier: "https://pid.sarig.sa.gov.au/dataset/mesac525", identifier_type: "URL", relation: "IsSourceOf", custodian: "GSSA/SARIG", identifies: "level3" }, + ] }); + A.openSurvey("Delta Survey"); + const drwDP = doc.getElementById("drawer"); + const dpHead = () => [...drwDP.querySelectorAll(".sechead")].find(h => /Data at every level:/.test(h.textContent)); + const dpTiles = () => [...dpHead().nextElementSibling.querySelectorAll(".prod")]; + ok(dpHead(), "SLOT: the survey drawer must carry the 'Data at every level:' section head"); + ok(/2 of 6 recorded/.test(dpHead().textContent), + "SLOT: an `entire`-only survey must count 2 of 6 (Collection + Level 3), got: " + JSON.stringify(dpHead().textContent)); + ok(dpTiles().length === 6, + "SLOT: `entire` must FILL the Collection slot, leaving exactly six tiles and no orphan extra, got " + dpTiles().length); + ok(!dpTiles()[0].classList.contains("dis") && /mesac487/.test(dpTiles()[0].innerHTML), + "SLOT: slot 1 (Collection) must be filled by the `entire` umbrella record, got: " + JSON.stringify(dpTiles()[0].textContent.slice(0, 60))); + ok(/mesac525/.test(dpTiles()[5].innerHTML), "SLOT: slot 6 (Level 3) must still take the level3 row"); + ok(!/Entire dataset/.test(drwDP.innerHTML), + "SLOT: a row that filled a slot must not ALSO render as an extra tile"); + + // (2) COLLISION RULE: a survey carrying BOTH `collection` and `entire` gives the slot to the EXACT key; + // the alias renders as an extra tile (nothing is ever silently dropped) and "N of 6" tallies the + // SLOT, never the pair. `entire` is declared FIRST here on purpose: an implementation that took the + // first row matching either key would hand it the slot and fail this pin visibly. + A.setSMETA("Delta Survey", { related_identifiers: [ + { identifier: "10.25914/umbrella", identifier_type: "DOI", relation: "IsVariantFormOf", custodian: "GA", identifies: "entire" }, + { identifier: "10.25914/exact", identifier_type: "DOI", relation: "IsPartOf", custodian: "NCI", identifies: "collection" }, + ] }); + A.openSurvey("Delta Survey"); + ok(/1 of 6 recorded/.test(dpHead().textContent), + "COLLISION: the count must tally the SLOT (1 of 6), not both colliding rows, got: " + JSON.stringify(dpHead().textContent)); + ok(dpTiles().length === 7, "COLLISION: six slots plus the collision-losing extra tile, got " + dpTiles().length); + // Match on the FULL identifier, not the bare word: the Collection slot's own description ("the umbrella + // record for everything this survey deposited") carries the word `umbrella` and would false-positive. + ok(/10\.25914\/exact/.test(dpTiles()[0].innerHTML) && !/10\.25914\/umbrella/.test(dpTiles()[0].innerHTML), + "COLLISION: the exact `collection` row must win slot 1 over the `entire` alias"); + ok(/Entire dataset/.test(dpTiles()[6].textContent) && /10\.25914\/umbrella/.test(dpTiles()[6].innerHTML), + "COLLISION: the losing `entire` row must survive as an extra tile, never be dropped"); + + // (3) LINK TREATMENT. index.html is the REAL page in this harness, so this asserts the CASCADE rather + // than a string: for every anchor the grid renders, some rule in the document's own stylesheet that + // sets the accent colour must SELECT that anchor - and the :visited form of the rule must exist too, + // so a followed DOI can never fall back to the browser's purple. RED before this lane: NO rule in + // the sheet selected these anchors at all, so the DOIs, the Rees citation and the platform PID + // rendered in the UA's dark blue on the navy tiles (the owner's screenshot). jsdom resolves no + // custom properties, so this proves SELECTION and the declared value, not the painted pixel. + A.setSMETA("Delta Survey", { instrument_pid: "10.82388/bt6orvhn", related_identifiers: [ + { identifier: "10.25914/link-collection", identifier_type: "DOI", relation: "IsPartOf", custodian: "NCI", identifies: "collection" }, + ] }); + A.openSurvey("Delta Survey"); + const sheetRules = [...doc.styleSheets].flatMap(s => { try { return [...s.cssRules]; } catch (e) { return []; } }); + const accentRules = sheetRules.filter(r => r.style && /var\(--copper\)/.test(r.style.color || "")); + ok(accentRules.length > 0, "LINKCSS: no rule in index.html sets color:var(--copper) at all - the probe is broken, not the sheet"); + // jsdom (like a real browser) refuses to match :visited from script, so strip the pseudo-class before + // matching and require it in the SELECTOR TEXT instead - the two halves together are the real claim. + const selectedBy = (el, wantVisited) => accentRules.some(r => String(r.selectorText).split(",").some(sel => { + sel = sel.trim(); + if (wantVisited !== (sel.indexOf(":visited") >= 0)) return false; + try { return el.matches(sel.replace(/:visited/g, "")); } catch (e) { return false; } + })); + const gridRoot = dpHead().parentElement; + const gridAnchors = [...gridRoot.querySelectorAll(".dl-id a, .dl-cite a, .dl-instr a")]; + ok(gridAnchors.length === 3, + "LINKCSS: expected the three grid link sites (tile identifier, Rees citation, platform PID), got " + gridAnchors.length); + gridAnchors.forEach(a => { + const where = a.parentElement.className || a.parentElement.tagName; + ok(selectedBy(a, false), "LINKCSS: no accent rule selects the grid anchor in '" + where + "' (" + a.getAttribute("href") + ")"); + ok(selectedBy(a, true), "LINKCSS: no accent :visited rule selects the grid anchor in '" + where + "' - it may go browser-purple once followed"); + }); + // The negative: an unrecorded level's state text is a statement, not a link, and no accent rule may take it. + A.setSMETA("Delta Survey", { instrument_pid: null, related_identifiers: [] }); + A.openSurvey("Delta Survey"); + const stateSpans = [...dpHead().parentElement.querySelectorAll(".dl-state")]; + ok(stateSpans.length === 6, "LINKCSS: expected six muted 'not yet recorded' states, got " + stateSpans.length); + stateSpans.forEach(s => { + ok(!s.querySelector("a"), "LINKCSS: the 'not yet recorded' state must never be a link"); + ok(!selectedBy(s, false), "LINKCSS: the muted state text must not be painted the link accent"); + }); + console.log("INTERACTION PASSED (tree country+org toggles, UX5 collections-group-first + push-sync + O1 no-nested-member-list + collapse INVARIANT + caret click-target + gating-off + D8 tour-restore x3 exit paths, collection route+Back, Find (+F3 keyboard nav: ArrowDown active-descendant/Enter-activates/Esc-clears), survey route, intro panel, tour v4 incl. Find-demo real-input+dropdown + tree-browse kalkaroo-degrade + exit hooks on Next/Back/close + drawer-open+restore, empty-state intro, year filter+hints, downloadable-only, go-to-place removal, screening(advanced) collapse, recently-added, C1b embargo access panel, PID links survey_pid/collection_pid/instrument pid + hostile-pid inert, ver-chip-in-footer, one-header-help-button, UX4 AusLAMP partition+membership+label→slug + non-member LPMT clusters + empty-set degrade + O5 radiusForZoom-one-step-smaller/weightForZoom pins+monotone + A1 colour-identical-all-modes + O4 tooltip station+survey-only, still-counted-across-containers, card-desc-from-yaml + hostile-blurb-inert + fallback, dimensionality-hidden-strike/skew-kept, C20 arrow-panel+Parkinson-label+south-sign-mapping + error-bars-present/absent + no-tipper-state, C22 citation-honesty no-DOI-placeholder-free + with-DOI-kept + NCI-byte-pin + txt-no-DOI-note, " + "UX6-Wave-C drawer-tabs+ARIA + sticky-header-download/cite + section-role-chips + yx-square/xy-circle-markers + full-station-response-modal(all-panels+identity-header+honest-coords+2x)+Esc/click-out+focus-return+non-tipper-no-arrow-panel + C1b-fence-under-tabs, " + "UX7b U6 panel-retitles (Discover-heading/Explore-data/API-access) + U7 welcome-popup first-visit-modal + role=dialog + focus-in + checkbox-persistence-matrix(tour/browse/Esc/click-out × ticked/unticked) + take-tour-starts-tour + help-panel-on-demand-no-persist + empty-state-popup + U8 card-anchor side-pick/no-overlap/caret-aim(4 sides) + U9 copper-Next + U10 dim-0.78, " + @@ -3761,6 +3857,7 @@ async function bootFreshWindow(dataMap, url) { "all-four-off empty map reads '0 shown' and restores, inert cluster row, affordance hint at the top, select-lens never captures a type toggle), " + "UX6-Wave-E slim-card field-set+removed-blocks-absent + discovery sort/count/compact + completeness-not-a-ranking fence + E2 identifiers-rollup N-of-M+collapsed-list + E4 detail-section-order + E6 collScatter AU-outline-beneath-dots+per-survey-legend+view-on-map fitBounds + E7 drawer role=dialog+focus-in+focus-restore, " + "CLEANUP-WAVE recently-added-single-strip+30day-build-window (rail #recentSide deleted, leak fixed) + facet-swap(Open-licence+data-type chips, DOI/tipper gone)+survey-search(name/org/region/blurb) + rail-hidden-on-surveys/collections/detail + drawer-scrim(non-map click-close) + collections-redesign(one-rich-card+full-abstract+two-column-hero, intro/collnote deleted), " + - "CARD-POLISH one-attribution-box(single .attn, names ORCID/ROR-linked in place, text == attributionText) + contributors-above-Downloads + lineage software(station-level-wins/survey-fallback/no-invented-version, node == prov row) + AusMT-Provenance-title + formats(served-only, no ticks/(pipeline), embargoed claims nothing) + publication-node-from-pubs(short cite + N-more, no fabricated et al., none-recorded when empty))"); + "CARD-POLISH one-attribution-box(single .attn, names ORCID/ROR-linked in place, text == attributionText) + contributors-above-Downloads + lineage software(station-level-wins/survey-fallback/no-invented-version, node == prov row) + AusMT-Provenance-title + formats(served-only, no ticks/(pipeline), embargoed claims nothing) + publication-node-from-pubs(short cite + N-more, no fabricated et al., none-recorded when empty), " + + "DRAWER-POLISH slot-alias(`entire` fills Collection: 2-of-6 + six tiles + no orphan) + collision-rule(exact `collection` wins the slot, alias survives as an extra, count tallies the slot) + 'Data at every level' head + LINKCSS accent rule SELECTS all three grid link sites incl. :visited, muted state text excluded)"); process.exit(0); })().catch(e => die((e && e.stack) || String(e)));