5656 "term" : "pottery" ,
5757 "filters" : {"source_only" : ["OPENCONTEXT" ]},
5858 },
59+ {
60+ # Pairs source restriction with a material-facet selection so the
61+ # benchmark exercises the facetFilterSQL() pid-IN-subquery path,
62+ # not just sourceFilterSQL(). The first material checkbox is used
63+ # to keep the test stable across data refreshes (don't hard-code a
64+ # URI that may disappear between snapshots).
65+ "label" : "composed-source-material" ,
66+ "term" : "pottery" ,
67+ "filters" : {
68+ "source_only" : ["OPENCONTEXT" ],
69+ "material_first_n" : 1 ,
70+ },
71+ },
5972]
6073
6174
@@ -72,17 +85,50 @@ def _wait_for_explorer_ready(page, timeout_ms: int = 90_000) -> None:
7285 )
7386
7487
88+ def _wait_for_facet_settle (page , timeout_ms : int = 30_000 ) -> None :
89+ """Block until no .facet-count is in the .recomputing transient state.
90+
91+ The cross-filter handler debounces 250ms before issuing the query
92+ (explorer.qmd:1610-1612), and the H3 cluster reload triggered by source
93+ filter changes is async and can exceed 1 s cold. Polling the DOM for
94+ "no recomputing class" is more reliable than a fixed wait.
95+ """
96+ page .wait_for_function (
97+ """() => {
98+ const recomputing = document.querySelectorAll('.facet-count.recomputing');
99+ return recomputing.length === 0;
100+ }""" ,
101+ timeout = timeout_ms ,
102+ )
103+
104+
75105def _apply_source_filter (page , sources_to_keep_checked : list [str ]) -> None :
76106 """Uncheck source checkboxes that aren't in the keep list."""
77107 all_sources = ["SESAR" , "OPENCONTEXT" , "GEOME" , "SMITHSONIAN" ]
108+ changed = False
78109 for src in all_sources :
79110 cb = page .locator (f"#sourceFilter input[type='checkbox'][value='{ src } ']" )
80111 is_checked = cb .is_checked ()
81112 should_be_checked = src in sources_to_keep_checked
82113 if is_checked != should_be_checked :
83114 cb .click ()
84- # Let the change handler debounce settle.
85- page .wait_for_timeout (800 )
115+ changed = True
116+ if changed :
117+ _wait_for_facet_settle (page )
118+
119+
120+ def _apply_material_first_n (page , n : int ) -> None :
121+ """Check the first n material-facet checkboxes (avoids hard-coding URIs)."""
122+ if n <= 0 :
123+ return
124+ boxes = page .locator ("#materialFilterBody input[type='checkbox']" )
125+ boxes .first .wait_for (state = "attached" , timeout = 15_000 )
126+ total = boxes .count ()
127+ for i in range (min (n , total )):
128+ cb = boxes .nth (i )
129+ if not cb .is_checked ():
130+ cb .click ()
131+ _wait_for_facet_settle (page )
86132
87133
88134def _run_search (page , term : str , * , captured : list , expected_id_after : int ) -> dict :
@@ -133,8 +179,11 @@ def _measure_one_query(browser, query: dict) -> dict:
133179 page .goto (EXPLORER_URL , wait_until = "domcontentloaded" , timeout = 60_000 )
134180 _wait_for_explorer_ready (page )
135181
136- if "source_only" in query ["filters" ]:
137- _apply_source_filter (page , query ["filters" ]["source_only" ])
182+ filters = query ["filters" ]
183+ if "source_only" in filters :
184+ _apply_source_filter (page , filters ["source_only" ])
185+ if "material_first_n" in filters :
186+ _apply_material_first_n (page , filters ["material_first_n" ])
138187
139188 cold = _run_search (page , query ["term" ], captured = captured , expected_id_after = 0 )
140189 warm = _run_search (
@@ -151,16 +200,27 @@ def _measure_one_query(browser, query: dict) -> dict:
151200 }
152201
153202
203+ def _utc_now () -> dt .datetime :
204+ """Aware UTC datetime; replaces the deprecated dt.datetime.utcnow()."""
205+ return dt .datetime .now (dt .timezone .utc )
206+
207+
154208@pytest .fixture (scope = "session" )
155- def baseline_output_path () -> pathlib .Path :
156- today = dt .datetime .utcnow ().strftime ("%Y-%m-%d" )
157- path = pathlib .Path (__file__ ).parent / f"search_baseline_{ today } .json"
209+ def benchmark_run_started_at () -> dt .datetime :
210+ return _utc_now ()
211+
212+
213+ @pytest .fixture (scope = "session" )
214+ def baseline_output_path (benchmark_run_started_at ) -> pathlib .Path :
215+ stamp = benchmark_run_started_at .strftime ("%Y-%m-%d" )
216+ path = pathlib .Path (__file__ ).parent / f"search_baseline_{ stamp } .json"
158217 return path
159218
160219
161- def test_record_search_baseline (browser , baseline_output_path ):
220+ def test_record_search_baseline (browser , benchmark_run_started_at , baseline_output_path ):
162221 """Run the canonical query set, dump JSON. Single test = one benchmark run."""
163222 results = []
223+ failures = []
164224 for query in CANONICAL_QUERIES :
165225 try :
166226 record = _measure_one_query (browser , query )
@@ -171,26 +231,33 @@ def test_record_search_baseline(browser, baseline_output_path):
171231 "filters" : query ["filters" ],
172232 "error" : f"{ type (exc ).__name__ } : { exc } " ,
173233 }
234+ failures .append (record )
174235 results .append (record )
175236 # Stream to stdout so partial runs are still useful.
176237 print (json .dumps (record , indent = 2 ))
177238
178239 payload = {
179240 "site_url" : SITE_URL ,
180- "captured_at_utc" : dt . datetime . utcnow (). isoformat () + "Z" ,
241+ "captured_at_utc" : benchmark_run_started_at . isoformat (),
181242 "schema_version" : 1 ,
182243 "field_subset" : "label+place_name (samples_map_lite.parquet)" ,
183244 "queries" : results ,
184245 }
185246 baseline_output_path .write_text (json .dumps (payload , indent = 2 ) + "\n " )
186247 print (f"\n Wrote baseline to { baseline_output_path } " )
187248
188- # Light sanity check: at least half the queries produced a usable cold record.
189- completed = sum (
190- 1 for r in results
191- if "cold" in r and r ["cold" ].get ("elapsed_ms" ) is not None
192- )
193- assert completed >= len (CANONICAL_QUERIES ) // 2 , (
194- f"Only { completed } /{ len (CANONICAL_QUERIES )} queries completed cleanly; "
195- f"see { baseline_output_path } for partial data"
249+ # A benchmark with silent failures is a poisoned baseline — refuse to
250+ # treat it as valid. Partial data is still on disk for diagnosis.
251+ incomplete = [
252+ r for r in results
253+ if "error" in r
254+ or "cold" not in r
255+ or r .get ("cold" , {}).get ("elapsed_ms" ) is None
256+ or "warm" not in r
257+ or r .get ("warm" , {}).get ("elapsed_ms" ) is None
258+ ]
259+ assert not incomplete , (
260+ f"{ len (incomplete )} /{ len (CANONICAL_QUERIES )} queries did not complete cleanly. "
261+ f"Failed labels: { [r ['label' ] for r in incomplete ]} . "
262+ f"Partial data at { baseline_output_path } ."
196263 )
0 commit comments