size the invited tab to its content and line the tables up (#19)
Browse files- size the invited tab to its content and line the tables up (aba62528278072637aea5c14935c772c57695ef9)
- app.py +5 -1
- invited.py +96 -19
- tests/test_invited.py +165 -0
app.py
CHANGED
|
@@ -964,8 +964,12 @@ def _invited_html():
|
|
| 964 |
who is invited is worse than a slow page.
|
| 965 |
"""
|
| 966 |
doc = invited.document(config.HF_TOKEN)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 967 |
return (f'<iframe title="invited teams" srcdoc="{html.escape(doc)}" '
|
| 968 |
-
f'scrolling="no" '
|
| 969 |
f'style="width:100%;border:0;display:block;min-height:400px"></iframe>')
|
| 970 |
|
| 971 |
|
|
|
|
| 964 |
who is invited is worse than a slow page.
|
| 965 |
"""
|
| 966 |
doc = invited.document(config.HF_TOKEN)
|
| 967 |
+
# No scrolling="no": the document self-sizes, but if that script ever fails
|
| 968 |
+
# to run the frame keeps its default height, and suppressing the scrollbar
|
| 969 |
+
# turns that into silent truncation. That is exactly how this shipped the
|
| 970 |
+
# first time -- the page was cut off partway down the second of six tables
|
| 971 |
+
# with no way to reach the rest. Degrade to a scrollbar instead.
|
| 972 |
return (f'<iframe title="invited teams" srcdoc="{html.escape(doc)}" '
|
|
|
|
| 973 |
f'style="width:100%;border:0;display:block;min-height:400px"></iframe>')
|
| 974 |
|
| 975 |
|
invited.py
CHANGED
|
@@ -22,20 +22,75 @@ import config
|
|
| 22 |
# shortlist cannot silently reorder what participants read.
|
| 23 |
_ORDER = [(t, d) for t in config.TRACKS for d in config.DIVISIONS]
|
| 24 |
|
|
|
|
|
|
|
|
|
|
| 25 |
CSS = """
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
"""
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
def load_shortlist_report(token: str | None) -> dict | None:
|
| 41 |
"""The raw shortlist file, or None if it is not there yet."""
|
|
@@ -60,15 +115,34 @@ def _rows(subtrack: dict) -> list[dict]:
|
|
| 60 |
return sorted(rows, key=lambda t: t.get("rank") or 0)
|
| 61 |
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
def document(token: str | None) -> str:
|
| 64 |
-
"""The whole page, as a standalone HTML document for an iframe srcdoc.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
rep = load_shortlist_report(token)
|
| 66 |
esc = html.escape
|
| 67 |
-
out = [
|
| 68 |
|
| 69 |
if not rep or not rep.get("subtracks"):
|
| 70 |
out.append("<p class=empty>The invited teams have not been published "
|
| 71 |
"yet. They appear here once the shortlist is final.</p>")
|
|
|
|
| 72 |
return "".join(out)
|
| 73 |
|
| 74 |
by_sub = {(st["track"], st["division"]): st for st in rep["subtracks"]}
|
|
@@ -99,8 +173,10 @@ def document(token: str | None) -> str:
|
|
| 99 |
if not rows:
|
| 100 |
out.append("<p class=empty>No teams invited in this sub-track.</p>")
|
| 101 |
continue
|
| 102 |
-
out.append("<table><tr><th>#</th><th>Team</th>
|
| 103 |
-
|
|
|
|
|
|
|
| 104 |
for i, t in enumerate(rows, 1):
|
| 105 |
score = t.get("primary_score")
|
| 106 |
try:
|
|
@@ -111,15 +187,16 @@ def document(token: str | None) -> str:
|
|
| 111 |
params = (f"{params/1e9:g}B"
|
| 112 |
if isinstance(params, (int, float)) and params else "—")
|
| 113 |
out.append(
|
| 114 |
-
f"<tr><td class=
|
| 115 |
f"<td class=team>{esc(str(t.get('team_name') or ''))}</td>"
|
| 116 |
-
f"<td>{esc(str(t.get('hf_user') or ''))}</td>"
|
| 117 |
-
f"<td>{esc(str(t.get('model_name') or ''))}</td>"
|
| 118 |
f"<td class=num>{score}</td>"
|
| 119 |
f"<td class=num>{esc(params)}</td></tr>")
|
| 120 |
-
out.append("</table>")
|
| 121 |
|
| 122 |
out.append("<p class=note>Ranked by each sub-track's own validation metric, "
|
| 123 |
"open-weight entries only, deduplicated by team. Scores are the "
|
| 124 |
"validation results already shown on the Validation board.</p>")
|
|
|
|
| 125 |
return "".join(out)
|
|
|
|
| 22 |
# shortlist cannot silently reorder what participants read.
|
| 23 |
_ORDER = [(t, d) for t in config.TRACKS for d in config.DIVISIONS]
|
| 24 |
|
| 25 |
+
# Deliberately the same variables, fonts and border colours as results._DOC_CSS,
|
| 26 |
+
# because this tab sits beside the two boards and a different table style reads
|
| 27 |
+
# as a different system rather than a third view of the same data.
|
| 28 |
CSS = """
|
| 29 |
+
:root{--lb-border:#e4e1f0;--lb-text:#1b1830;--lb-sec:#6a6585;--lb-faint:#9b96b3;
|
| 30 |
+
--lb-head:#f6f5fc;--lb-hover:#faf9fe;
|
| 31 |
+
--lb-mono:'IBM Plex Mono',ui-monospace,SFMono-Regular,Menlo,monospace;
|
| 32 |
+
--lb-sans:'IBM Plex Sans',ui-sans-serif,system-ui,-apple-system,sans-serif}
|
| 33 |
+
*{box-sizing:border-box}
|
| 34 |
+
html,body{margin:0;padding:0}
|
| 35 |
+
body{font-family:var(--lb-sans);color:var(--lb-text);background:transparent;
|
| 36 |
+
font-size:14px;padding:2px 0 12px}
|
| 37 |
+
h3{font-size:1.05rem;font-weight:700;letter-spacing:-.01em;margin:1.5rem 0 .5rem;
|
| 38 |
+
padding-bottom:.4rem;border-bottom:2px solid var(--lb-border)}
|
| 39 |
+
p.note{color:var(--lb-sec);font-size:.87rem;margin:.2rem 0 1.1rem;line-height:1.5}
|
| 40 |
+
/* Fixed layout + a shared colgroup is what makes all six tables line up. With
|
| 41 |
+
auto layout each table sizes to its own longest cell, so the columns stepped
|
| 42 |
+
left and right between sub-tracks and it read as six unrelated tables. */
|
| 43 |
+
table{border-collapse:collapse;width:100%;table-layout:fixed;
|
| 44 |
+
margin:.3rem 0 1.2rem;font-size:.87rem}
|
| 45 |
+
th,td{border:1px solid var(--lb-border);padding:.4rem .6rem;text-align:left;
|
| 46 |
+
vertical-align:top}
|
| 47 |
+
th{background:var(--lb-head);font-weight:600;white-space:nowrap}
|
| 48 |
+
tbody tr:hover{background:var(--lb-hover)}
|
| 49 |
+
/* Team, account and model WRAP: model_name is free text from participants and
|
| 50 |
+
reaches 80+ chars, so nowrap here is what pushed the table off the page. */
|
| 51 |
+
td.team,td.acct,td.model{white-space:normal;overflow-wrap:anywhere}
|
| 52 |
+
td.team{font-weight:600}
|
| 53 |
+
td.acct,td.model{color:var(--lb-sec)}
|
| 54 |
+
td.num,th.num{text-align:right;white-space:nowrap;
|
| 55 |
+
font-variant-numeric:tabular-nums;font-family:var(--lb-mono)}
|
| 56 |
+
td.rank{text-align:right;color:var(--lb-faint);font-family:var(--lb-mono)}
|
| 57 |
+
.selfrep{color:var(--lb-sec);font-weight:400;font-size:.85em}
|
| 58 |
+
.empty{color:var(--lb-sec);font-style:italic}
|
| 59 |
"""
|
| 60 |
|
| 61 |
+
# One colgroup for every table, so a column is in the same place in all six.
|
| 62 |
+
COLS = ("<colgroup><col style='width:2.8rem'><col style='width:20%'>"
|
| 63 |
+
"<col style='width:15%'><col><col style='width:8.5rem'>"
|
| 64 |
+
"<col style='width:7rem'></colgroup>")
|
| 65 |
+
|
| 66 |
+
# Same self-sizing contract as results.board_document(). Two things it must get
|
| 67 |
+
# right, both learned there: measure the CONTENT wrapper for height, never
|
| 68 |
+
# documentElement.scrollHeight, which is bounded below by the iframe viewport and
|
| 69 |
+
# so feeds back into an infinite grow loop; and set width from the parent's
|
| 70 |
+
# #lb-tab, because an iframe is a replaced element with a 300px intrinsic width
|
| 71 |
+
# that Gradio's flex wrappers shrink-wrap regardless of CSS width:100%.
|
| 72 |
+
FIT_JS = (
|
| 73 |
+
"<script>"
|
| 74 |
+
"function fitW(){try{var fe=window.frameElement;if(!fe||!window.parent)return;"
|
| 75 |
+
"var host=window.parent.document.getElementById('lb-tab');if(!host)return;"
|
| 76 |
+
"var cs=window.parent.getComputedStyle(host);"
|
| 77 |
+
"var w=host.clientWidth-parseFloat(cs.paddingLeft||0)"
|
| 78 |
+
"-parseFloat(cs.paddingRight||0)-2;"
|
| 79 |
+
"if(w>40)fe.style.width=w+'px';}catch(e){}}"
|
| 80 |
+
"function fitH(){try{var r=document.getElementById('invroot');"
|
| 81 |
+
"if(!r||!window.frameElement)return;"
|
| 82 |
+
"var h=Math.ceil(r.getBoundingClientRect().height)+16;"
|
| 83 |
+
"var cur=parseInt(window.frameElement.style.height||'0',10)||0;"
|
| 84 |
+
"if(h>40&&Math.abs(h-cur)>2)window.frameElement.style.height=h+'px';}catch(e){}}"
|
| 85 |
+
"function fit(){fitW();fitH();}"
|
| 86 |
+
"window.addEventListener('load',fit);"
|
| 87 |
+
"window.addEventListener('resize',fit);"
|
| 88 |
+
"if(window.parent)window.parent.addEventListener('resize',fit);"
|
| 89 |
+
"if(window.ResizeObserver){new ResizeObserver(fit).observe(document.body);}"
|
| 90 |
+
"setTimeout(fit,60);setTimeout(fit,400);setTimeout(fit,1200);"
|
| 91 |
+
"</script>"
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
|
| 95 |
def load_shortlist_report(token: str | None) -> dict | None:
|
| 96 |
"""The raw shortlist file, or None if it is not there yet."""
|
|
|
|
| 115 |
return sorted(rows, key=lambda t: t.get("rank") or 0)
|
| 116 |
|
| 117 |
|
| 118 |
+
def _head() -> str:
|
| 119 |
+
"""Document head, with the same webfont the two boards load."""
|
| 120 |
+
return (
|
| 121 |
+
"<!DOCTYPE html><html lang='en'><head><meta charset='utf-8'>"
|
| 122 |
+
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
|
| 123 |
+
"<link rel='preconnect' href='https://fonts.googleapis.com'>"
|
| 124 |
+
"<link rel='preconnect' href='https://fonts.gstatic.com' crossorigin>"
|
| 125 |
+
"<link href='https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@500;600"
|
| 126 |
+
"&family=IBM+Plex+Sans:wght@400;600;700&display=swap' rel='stylesheet'>"
|
| 127 |
+
f"<style>{CSS}</style></head><body>"
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
|
| 131 |
def document(token: str | None) -> str:
|
| 132 |
+
"""The whole page, as a standalone HTML document for an iframe srcdoc.
|
| 133 |
+
|
| 134 |
+
Everything visible sits inside #invroot, which is what the sizing script
|
| 135 |
+
measures. Without a content wrapper the iframe stays at its default height
|
| 136 |
+
and the page is simply cut off partway down the second table.
|
| 137 |
+
"""
|
| 138 |
rep = load_shortlist_report(token)
|
| 139 |
esc = html.escape
|
| 140 |
+
out = [_head(), "<div id='invroot'>"]
|
| 141 |
|
| 142 |
if not rep or not rep.get("subtracks"):
|
| 143 |
out.append("<p class=empty>The invited teams have not been published "
|
| 144 |
"yet. They appear here once the shortlist is final.</p>")
|
| 145 |
+
out.append(f"</div>{FIT_JS}</body></html>")
|
| 146 |
return "".join(out)
|
| 147 |
|
| 148 |
by_sub = {(st["track"], st["division"]): st for st in rep["subtracks"]}
|
|
|
|
| 173 |
if not rows:
|
| 174 |
out.append("<p class=empty>No teams invited in this sub-track.</p>")
|
| 175 |
continue
|
| 176 |
+
out.append(f"<table>{COLS}<thead><tr><th class=num>#</th><th>Team</th>"
|
| 177 |
+
"<th>HF account</th>"
|
| 178 |
+
f"<th>Model</th><th class=num>{metric}</th>"
|
| 179 |
+
"<th class=num>Total params</th></tr></thead><tbody>")
|
| 180 |
for i, t in enumerate(rows, 1):
|
| 181 |
score = t.get("primary_score")
|
| 182 |
try:
|
|
|
|
| 187 |
params = (f"{params/1e9:g}B"
|
| 188 |
if isinstance(params, (int, float)) and params else "—")
|
| 189 |
out.append(
|
| 190 |
+
f"<tr><td class=rank>{i}</td>"
|
| 191 |
f"<td class=team>{esc(str(t.get('team_name') or ''))}</td>"
|
| 192 |
+
f"<td class=acct>{esc(str(t.get('hf_user') or ''))}</td>"
|
| 193 |
+
f"<td class=model>{esc(str(t.get('model_name') or ''))}</td>"
|
| 194 |
f"<td class=num>{score}</td>"
|
| 195 |
f"<td class=num>{esc(params)}</td></tr>")
|
| 196 |
+
out.append("</tbody></table>")
|
| 197 |
|
| 198 |
out.append("<p class=note>Ranked by each sub-track's own validation metric, "
|
| 199 |
"open-weight entries only, deduplicated by team. Scores are the "
|
| 200 |
"validation results already shown on the Validation board.</p>")
|
| 201 |
+
out.append(f"</div>{FIT_JS}</body></html>")
|
| 202 |
return "".join(out)
|
tests/test_invited.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests for the Invited to Test Phase tab.
|
| 2 |
+
|
| 3 |
+
Written 2026-08-08 after the first version shipped with no tests and three
|
| 4 |
+
visible defects: the iframe never grew past its default height so the page was
|
| 5 |
+
cut off partway down the second of six tables, the intro line ran off the right
|
| 6 |
+
edge, and each table sized to its own content so the columns stepped left and
|
| 7 |
+
right between sub-tracks. Each of those is pinned below.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import re
|
| 12 |
+
import sys
|
| 13 |
+
import unittest
|
| 14 |
+
from unittest import mock
|
| 15 |
+
|
| 16 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 17 |
+
|
| 18 |
+
import invited # noqa: E402
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _report(n_subtracks=2, rows_per=3, note_rows=0):
|
| 22 |
+
subs = []
|
| 23 |
+
for i in range(n_subtracks):
|
| 24 |
+
shortlist = [
|
| 25 |
+
{
|
| 26 |
+
"rank": r + 1,
|
| 27 |
+
"team_name": f"Team {i}{r}",
|
| 28 |
+
"hf_user": f"user{i}{r}",
|
| 29 |
+
"model_name": "a-very-long-model-name-" + "x" * 60,
|
| 30 |
+
"primary_score": 0.5 - r / 100,
|
| 31 |
+
"total_params": 7_000_000_000,
|
| 32 |
+
}
|
| 33 |
+
for r in range(rows_per)
|
| 34 |
+
]
|
| 35 |
+
for k in range(note_rows):
|
| 36 |
+
shortlist.append({
|
| 37 |
+
"rank": rows_per + k + 1, "team_name": "htranx",
|
| 38 |
+
"hf_user": "htranx", "model_name": "smoke",
|
| 39 |
+
"primary_score": 1.0, "total_params": 1,
|
| 40 |
+
"note": "organizer smoke test",
|
| 41 |
+
})
|
| 42 |
+
subs.append({
|
| 43 |
+
"track": ["convqa", "longqa"][i % 2],
|
| 44 |
+
"division": ["small", "large"][i % 2],
|
| 45 |
+
"track_label": f"Track{i}",
|
| 46 |
+
"primary_metric_label": "Accuracy",
|
| 47 |
+
"primary_is_self_reported": False,
|
| 48 |
+
"shortlist": shortlist,
|
| 49 |
+
})
|
| 50 |
+
return {"distinct_teams": 5, "total_seats": n_subtracks * rows_per,
|
| 51 |
+
"subtracks": subs}
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _doc(rep):
|
| 55 |
+
with mock.patch.object(invited, "load_shortlist_report", lambda t: rep):
|
| 56 |
+
return invited.document(None)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class DocumentIsWellFormedTest(unittest.TestCase):
|
| 60 |
+
def test_it_is_a_complete_html_document(self):
|
| 61 |
+
d = _doc(_report())
|
| 62 |
+
self.assertTrue(d.startswith("<!DOCTYPE html>"), d[:40])
|
| 63 |
+
self.assertTrue(d.rstrip().endswith("</html>"), d[-40:])
|
| 64 |
+
|
| 65 |
+
def test_the_empty_state_is_also_a_complete_document(self):
|
| 66 |
+
"""The no-shortlist branch returned early and closed nothing."""
|
| 67 |
+
d = _doc(None)
|
| 68 |
+
self.assertTrue(d.startswith("<!DOCTYPE html>"))
|
| 69 |
+
self.assertTrue(d.rstrip().endswith("</html>"))
|
| 70 |
+
self.assertIn("have not been published", d)
|
| 71 |
+
|
| 72 |
+
def test_every_container_is_balanced(self):
|
| 73 |
+
d = _doc(_report(n_subtracks=2))
|
| 74 |
+
for tag in ("div", "table", "thead", "tbody", "tr", "th", "td"):
|
| 75 |
+
# `<th` also prefixes `<thead`, so match the tag name followed by a
|
| 76 |
+
# delimiter rather than by str.count on the opening bracket.
|
| 77 |
+
opens = len(re.findall(rf"<{tag}[\s>]", d))
|
| 78 |
+
self.assertEqual(opens, d.count(f"</{tag}>"), tag)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
class SelfSizingTest(unittest.TestCase):
|
| 82 |
+
"""The page was cut off partway down. This is why."""
|
| 83 |
+
|
| 84 |
+
def test_the_content_is_wrapped_in_the_element_the_script_measures(self):
|
| 85 |
+
d = _doc(_report())
|
| 86 |
+
self.assertEqual(1, d.count("id='invroot'"))
|
| 87 |
+
# the wrapper must close before the script, or the script is inside it
|
| 88 |
+
self.assertIn("</div><script>", d)
|
| 89 |
+
|
| 90 |
+
def test_it_measures_the_wrapper_not_the_document(self):
|
| 91 |
+
"""documentElement.scrollHeight is bounded below by the iframe's own
|
| 92 |
+
viewport, so feeding it back into the iframe height grows without
|
| 93 |
+
bound. results.board_document() hit this first; do not reintroduce it.
|
| 94 |
+
"""
|
| 95 |
+
d = _doc(_report())
|
| 96 |
+
self.assertIn("getElementById('invroot')", d)
|
| 97 |
+
self.assertNotIn("documentElement.scrollHeight", d)
|
| 98 |
+
|
| 99 |
+
def test_it_sets_width_from_the_host_tab(self):
|
| 100 |
+
"""An iframe has a 300px intrinsic width and Gradio shrink-wraps it."""
|
| 101 |
+
d = _doc(_report())
|
| 102 |
+
self.assertIn("getElementById('lb-tab')", d)
|
| 103 |
+
|
| 104 |
+
def test_it_refits_on_load_and_resize(self):
|
| 105 |
+
d = _doc(_report())
|
| 106 |
+
for hook in ("load", "resize"):
|
| 107 |
+
self.assertIn(f"addEventListener('{hook}',fit)", d)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
class ColumnsLineUpTest(unittest.TestCase):
|
| 111 |
+
def test_every_table_carries_the_same_colgroup(self):
|
| 112 |
+
d = _doc(_report(n_subtracks=2))
|
| 113 |
+
self.assertEqual(2, d.count("<table>"))
|
| 114 |
+
self.assertEqual(d.count("<table>"), d.count(invited.COLS))
|
| 115 |
+
|
| 116 |
+
def test_the_layout_is_fixed_so_widths_cannot_be_content_driven(self):
|
| 117 |
+
d = _doc(_report())
|
| 118 |
+
self.assertIn("table-layout:fixed", d)
|
| 119 |
+
|
| 120 |
+
def test_long_free_text_wraps_rather_than_widening_the_table(self):
|
| 121 |
+
"""model_name is participant free text and reaches 80+ chars."""
|
| 122 |
+
d = _doc(_report())
|
| 123 |
+
self.assertIn("td.team,td.acct,td.model{white-space:normal", d)
|
| 124 |
+
self.assertIn("overflow-wrap:anywhere", d)
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
class ContentTest(unittest.TestCase):
|
| 128 |
+
def test_organizer_rows_are_filtered_by_their_note_field(self):
|
| 129 |
+
d = _doc(_report(n_subtracks=1, rows_per=2, note_rows=1))
|
| 130 |
+
self.assertNotIn("htranx", d)
|
| 131 |
+
self.assertIn("<b>2 seats</b>", d)
|
| 132 |
+
|
| 133 |
+
def test_the_team_count_is_read_from_the_file_not_recounted(self):
|
| 134 |
+
"""Neither team names nor accounts reproduce the transitive merge."""
|
| 135 |
+
d = _doc(_report(n_subtracks=1, rows_per=2))
|
| 136 |
+
self.assertIn("5 teams", d)
|
| 137 |
+
|
| 138 |
+
def test_rows_are_ordered_by_rank(self):
|
| 139 |
+
rep = _report(n_subtracks=1, rows_per=3)
|
| 140 |
+
rep["subtracks"][0]["shortlist"].reverse()
|
| 141 |
+
d = _doc(rep)
|
| 142 |
+
pos = [d.index(f"Team 0{r}") for r in range(3)]
|
| 143 |
+
self.assertEqual(pos, sorted(pos))
|
| 144 |
+
|
| 145 |
+
def test_a_missing_score_or_param_count_renders_a_dash(self):
|
| 146 |
+
rep = _report(n_subtracks=1, rows_per=1)
|
| 147 |
+
rep["subtracks"][0]["shortlist"][0]["primary_score"] = None
|
| 148 |
+
rep["subtracks"][0]["shortlist"][0]["total_params"] = None
|
| 149 |
+
d = _doc(rep)
|
| 150 |
+
self.assertEqual(2, d.count("—"))
|
| 151 |
+
|
| 152 |
+
def test_a_sub_track_with_no_invited_teams_says_so(self):
|
| 153 |
+
rep = _report(n_subtracks=1, rows_per=0)
|
| 154 |
+
self.assertIn("No teams invited", _doc(rep))
|
| 155 |
+
|
| 156 |
+
def test_team_names_are_escaped(self):
|
| 157 |
+
rep = _report(n_subtracks=1, rows_per=1)
|
| 158 |
+
rep["subtracks"][0]["shortlist"][0]["team_name"] = "<script>x</script>"
|
| 159 |
+
d = _doc(rep)
|
| 160 |
+
self.assertNotIn("<script>x</script>", d)
|
| 161 |
+
self.assertIn("<script>", d)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
if __name__ == "__main__":
|
| 165 |
+
unittest.main()
|