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/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. `
Levels per Rees et al. 2019
`+ 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 " 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)));