1111from pathlib import Path
1212from typing import Iterator
1313
14+ from _testcode import get_test_code
15+
1416IMAGE_DIR = Path ("tests/result_images" ).resolve ()
1517EXPECTED_SUFFIX = "-expected"
1618FAILED_SUFFIX = "-failed-diff"
1719RESULT_PREFIX = "tests/result_images"
1820BASELINE_PREFIX = "tests/baseline_images"
21+ TESTS_DIR = IMAGE_DIR .parent
22+
23+ try :
24+ from pygments import highlight
25+ from pygments .formatters import HtmlFormatter
26+ from pygments .lexers import PythonLexer
27+
28+ def highlight_python (source : str ) -> str :
29+ """
30+ Python source as HTML spans with pygments token classes
31+ """
32+ return highlight (source , PythonLexer (), HtmlFormatter (nowrap = True ))
33+
34+ except ImportError :
35+ from html import escape
36+
37+ def highlight_python (source : str ) -> str :
38+ """
39+ Python source as plain escaped text (pygments unavailable)
40+ """
41+ return escape (source )
1942
2043
2144@dataclass (frozen = True )
@@ -98,6 +121,12 @@ def get_test_images() -> Iterator[TestImage]:
98121 --amber-soft: #fcf3d4;
99122 --link: #2c7be5;
100123 --img-bg: #ffffff;
124+ --code-kw: #cf222e;
125+ --code-str: #0a3069;
126+ --code-num: #0550ae;
127+ --code-com: #6e7781;
128+ --code-fn: #8250df;
129+ --code-builtin: #953800;
101130}
102131
103132@media (prefers-color-scheme: dark) {
@@ -119,6 +148,12 @@ def get_test_images() -> Iterator[TestImage]:
119148 --amber-soft: #3a2c10;
120149 --link: #6cb6ff;
121150 --img-bg: #ffffff;
151+ --code-kw: #f47067;
152+ --code-str: #96d0ff;
153+ --code-num: #6cb6ff;
154+ --code-com: #768390;
155+ --code-fn: #dcbdfb;
156+ --code-builtin: #f69d50;
122157 }
123158}
124159
@@ -140,6 +175,12 @@ def get_test_images() -> Iterator[TestImage]:
140175 --amber-soft: #3a2c10;
141176 --link: #6cb6ff;
142177 --img-bg: #ffffff;
178+ --code-kw: #f47067;
179+ --code-str: #96d0ff;
180+ --code-num: #6cb6ff;
181+ --code-com: #768390;
182+ --code-fn: #dcbdfb;
183+ --code-builtin: #f69d50;
143184}
144185
145186* { box-sizing: border-box; }
@@ -505,6 +546,55 @@ def get_test_images() -> Iterator[TestImage]:
505546 border-radius: 4px;
506547}
507548
549+ .code-toggle.active {
550+ background: var(--chip-active-bg);
551+ color: var(--chip-active-fg);
552+ border-color: var(--chip-active-bg);
553+ }
554+
555+ .code-panel {
556+ display: flex;
557+ flex-direction: column;
558+ gap: 4px;
559+ }
560+
561+ .code-panel.hidden { display: none; }
562+
563+ .code-path {
564+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco,
565+ Consolas, monospace;
566+ font-size: 12px;
567+ align-self: flex-start;
568+ }
569+
570+ pre.code {
571+ margin: 0;
572+ padding: 10px 12px;
573+ border: 1px solid var(--border);
574+ border-radius: 6px;
575+ background: var(--chip-bg);
576+ overflow-x: auto;
577+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco,
578+ Consolas, monospace;
579+ font-size: 12px;
580+ line-height: 1.5;
581+ }
582+
583+ pre.code .k, pre.code .kn, pre.code .ow, pre.code .o {
584+ color: var(--code-kw);
585+ }
586+ pre.code .s, pre.code .s1, pre.code .s2, pre.code .sd,
587+ pre.code .sa, pre.code .si {
588+ color: var(--code-str);
589+ }
590+ pre.code .mi, pre.code .mf { color: var(--code-num); }
591+ pre.code .c1, pre.code .cm {
592+ color: var(--code-com);
593+ font-style: italic;
594+ }
595+ pre.code .nf, pre.code .nc, pre.code .fm { color: var(--code-fn); }
596+ pre.code .nb, pre.code .bp { color: var(--code-builtin); }
597+
508598.empty {
509599 padding: 40px;
510600 text-align: center;
@@ -711,7 +801,9 @@ def get_test_images() -> Iterator[TestImage]:
711801 for (const row of rows) {
712802 if (row.dataset.status !== 'failed') continue;
713803
714- const buttons = row.querySelectorAll('.view-buttons button');
804+ const buttons = row.querySelectorAll(
805+ '.view-buttons button[data-view]'
806+ );
715807 for (const btn of buttons) {
716808 btn.addEventListener('click', () => {
717809 const view = btn.dataset.view;
@@ -799,6 +891,17 @@ def get_test_images() -> Iterator[TestImage]:
799891 });
800892 applyTheme();
801893
894+ // Per-row test-code panel toggles.
895+ for (const btn of document.querySelectorAll('button.code-toggle')) {
896+ btn.addEventListener('click', () => {
897+ const row = btn.closest('.test-row');
898+ const panel = row && row.querySelector('.code-panel');
899+ if (!panel) return;
900+ const open = !panel.classList.toggle('hidden');
901+ btn.classList.toggle('active', open);
902+ });
903+ }
904+
802905 // Lightbox: click an anchor-wrapped image to inspect it in-page.
803906 const lightbox = document.getElementById('lightbox');
804907 const lbImg = lightbox.querySelector('img');
@@ -1084,6 +1187,55 @@ def _passed_content(test: TestImage) -> str:
10841187 )
10851188
10861189
1190+ def _code_parts (test : TestImage ) -> tuple [str , str ]:
1191+ """
1192+ (toggle button, hidden panel) for a test's code, or empty strings
1193+ """
1194+ test_file = TESTS_DIR / f"{ test .subdir } .py"
1195+ snippet = get_test_code (test_file , test .name )
1196+ if snippet is None :
1197+ return "" , ""
1198+ button = '<button class="code-toggle" type="button">{ } Code</button>'
1199+ panel = (
1200+ '<div class="code-panel hidden">'
1201+ f'<a class="code-path" href="../{ test .subdir } .py">'
1202+ f"tests/{ test .subdir } .py:{ snippet .lineno } </a>"
1203+ f'<pre class="code">{ highlight_python (snippet .source )} </pre>'
1204+ "</div>"
1205+ )
1206+ return button , panel
1207+
1208+
1209+ def _with_code (content : str , button : str , panel : str ) -> str :
1210+ """
1211+ Content with the code button in its tab strip and the panel under it
1212+
1213+ The panel opens between the strip and the images. Rows without a
1214+ tab strip (passed/new, and failed without an expected image) get
1215+ one holding just the code button.
1216+ """
1217+ if not button :
1218+ return content
1219+ if '<div class="view-buttons">' in content :
1220+ # The strip's last button (Flip) closes the strip; the code
1221+ # button goes after it.
1222+ content = content .replace (
1223+ "</button></div>" ,
1224+ f"</button>{ button } </div>" ,
1225+ 1 ,
1226+ )
1227+ else :
1228+ content = content .replace (
1229+ '<div class="content">' ,
1230+ f'<div class="content"><div class="view-buttons">{ button } </div>' ,
1231+ 1 ,
1232+ )
1233+ # The strip holds only buttons, so its first </div> closes it.
1234+ strip_start = content .find ('<div class="view-buttons">' )
1235+ strip_end = content .find ("</div>" , strip_start ) + len ("</div>" )
1236+ return content [:strip_end ] + panel + content [strip_end :]
1237+
1238+
10871239def render_row (test : TestImage ) -> str :
10881240 """
10891241 Build the HTML for a single test row
@@ -1105,6 +1257,7 @@ def render_row(test: TestImage) -> str:
11051257 content = _new_content (test )
11061258 else :
11071259 content = _passed_content (test )
1260+ content = _with_code (content , * _code_parts (test ))
11081261
11091262 classes = ["test-row" ]
11101263 if test .status == "failed" :
0 commit comments