diff --git a/requirements/environment.yml b/requirements/environment.yml
index e48072999..b9d3a43b1 100644
--- a/requirements/environment.yml
+++ b/requirements/environment.yml
@@ -29,6 +29,7 @@ dependencies:
- pytest >= 7.0
- pytest-cov
- pytest-xdist
+ - pytest-playwright
# Documentation dependencies
- sphinx >= 6.0
diff --git a/requirements/locks/py312-lock-linux-64.txt b/requirements/locks/py312-lock-linux-64.txt
index e5ce709b8..105bcb891 100644
--- a/requirements/locks/py312-lock-linux-64.txt
+++ b/requirements/locks/py312-lock-linux-64.txt
@@ -1,6 +1,6 @@
# Generated by conda-lock.
# platform: linux-64
-# input_hash: 92892f231a070bfd8cfad35f87fdcc6778c70f030940011db094ac7837327e11
+# input_hash: d01a703af80bb0050595f5389e6af6ba5aee6028f21e4693954cc27d5a02c3e8
@EXPLICIT
https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda#a9f577daf3de00bca7c3c76c0ecbd1de
https://conda.anaconda.org/conda-forge/linux-64/aom-3.9.1-hac33072_0.conda#346722a0be40f6edc53f12640d301338
@@ -40,6 +40,7 @@ https://conda.anaconda.org/conda-forge/linux-64/giflib-5.2.2-hd590300_0.conda#3b
https://conda.anaconda.org/conda-forge/linux-64/glib-tools-2.86.4-hf516916_0.conda#70a09b6817c7ad694ef4543204c59c25
https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.14-hecca717_2.conda#2cd94587f3a401ae05e03a6caf09539d
https://conda.anaconda.org/conda-forge/linux-64/graphviz-14.1.2-h8b86629_0.conda#341fc61cfe8efa5c72d24db56c776f44
+https://conda.anaconda.org/conda-forge/linux-64/greenlet-3.3.2-py312h8285ef7_0.conda#db6bba1610e5c4256d2892ec2997c425
https://conda.anaconda.org/conda-forge/linux-64/gtk3-3.24.43-ha5ea40c_7.conda#f605332e1e4d9ff5c599933ae81db57d
https://conda.anaconda.org/conda-forge/linux-64/gts-0.7.6-h977cf35_4.conda#4d8df0b0db060d33c9a702ada998a8fe
https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-12.3.2-h6083320_0.conda#d170a70fc1d5c605fcebdf16851bd54a
@@ -110,6 +111,7 @@ https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_18.
https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda#cd5a90476766d53e901500df9215e927
https://conda.anaconda.org/conda-forge/linux-64/libudunits2-2.2.28-h40f5838_3.conda#4bdace082e911a3e1f1f0b721bed5b56
https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda#db409b7c1720428638e7c0d509d3e1b5
+https://conda.anaconda.org/conda-forge/linux-64/libuv-1.51.0-hb03c661_1.conda#0f03292cc56bf91a077a134ea8747118
https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda#aea31d2e5b1091feca96fcfe945c3cf9
https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda#92ed62436b625154323d40d5f2f11dd7
https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.36-hd590300_1.conda#5aa797f8787fe7a17d1b0821485b5adc
@@ -125,6 +127,7 @@ https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.10.8-py312he3d
https://conda.anaconda.org/conda-forge/linux-64/mo_pack-0.3.1-py312h4f23490_2.conda#cec5bc5f7d374f8f8095f8e28e31f6cb
https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda#47e340acb35de30501a76c7c799c41d7
https://conda.anaconda.org/conda-forge/linux-64/netcdf4-1.7.4-nompi_py312h25f8dc5_102.conda#99217b58c029977345b72bb36a1f6596
+https://conda.anaconda.org/conda-forge/linux-64/nodejs-24.13.1-h3d65ac4_0.conda#45fe531d027ce218b895565110a79a1f
https://conda.anaconda.org/conda-forge/linux-64/numpy-2.4.2-py312h33ff503_1.conda#3569a8fca2dd3202e4ab08f42499f6d3
https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda#11b3379b191f63139e29c0d19dee24cd
https://conda.anaconda.org/conda-forge/linux-64/openjph-0.26.3-h8d634f6_0.conda#792d5b6e99677177f5527a758a02bc07
@@ -133,6 +136,7 @@ https://conda.anaconda.org/conda-forge/linux-64/pango-1.56.4-hadf4263_0.conda#79
https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda#7a3bff861a6583f1889021facefc08b1
https://conda.anaconda.org/conda-forge/linux-64/pillow-12.1.1-py312h50c33e8_0.conda#c5eff3ada1a829f0bdb780dc4b62bbae
https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda#c01af13bdc553d1a8fbfff6e8db075f0
+https://conda.anaconda.org/conda-forge/linux-64/playwright-1.58.2-h5585027_0.conda#1650804f26dd992fd7418fe603f1c653
https://conda.anaconda.org/conda-forge/linux-64/proj-9.7.1-he0df7b0_3.conda#031e33ae075b336c0ce92b14efa886c5
https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda#b3c17d95b5a10c6e64a21fa17573e70e
https://conda.anaconda.org/conda-forge/linux-64/pygraphviz-1.14-py312hcdbcef4_3.conda#1f7333772f14e05d3156e891535b43c3
@@ -234,17 +238,22 @@ https://conda.anaconda.org/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda#
https://conda.anaconda.org/conda-forge/noarch/partd-1.4.2-pyhd8ed1ab_0.conda#0badf9c54e24cecfb0ad2f99d680c163
https://conda.anaconda.org/conda-forge/noarch/pip-26.0.1-pyh8b19718_0.conda#67bdec43082fd8a9cffb9484420b39a2
https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.9.2-pyhcf101f3_0.conda#4fefefb892ce9cc1539405bec2f1a6cd
+https://conda.anaconda.org/conda-forge/noarch/playwright-python-1.58.0-pyhcf101f3_0.conda#3b886c49cf44aa133d0eb07e4d0cac89
https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda#d7585b6550ad04c8c5e21097ada2888e
https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.5.1-pyha770c72_0.conda#7f3ac694319c7eaf81a0325d6405e974
https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda#12c566707c80111f9799308d9e265aef
+https://conda.anaconda.org/conda-forge/noarch/pyee-13.0.1-pyhd8ed1ab_0.conda#eadf0f76d9121a6297be754e9d7cc099
https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda#6b6ece66ebcae2d5f326c77ef2c5a066
https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda#3687cc0b82a8b4c17e1f0eb7e47163d5
https://conda.anaconda.org/conda-forge/noarch/pyshp-3.0.3-pyhd8ed1ab_0.conda#c138c7aaa6a10b5762dcd92247864aff
https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda#461219d1a5bd61342293efa2c0c90eac
https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.2-pyhcf101f3_0.conda#2b694bad8a50dc2f712f5368de866480
+https://conda.anaconda.org/conda-forge/noarch/pytest-base-url-2.1.0-pyhd8ed1ab_1.conda#057f32e4c376ce0c4c4a32a9f06bf34e
https://conda.anaconda.org/conda-forge/noarch/pytest-cov-7.0.0-pyhcf101f3_1.conda#6891acad5e136cb62a8c2ed2679d6528
+https://conda.anaconda.org/conda-forge/noarch/pytest-playwright-0.7.2-pyhd8ed1ab_1.conda#34d1d3c36ffccb8dc02c3f8da7ae1e5c
https://conda.anaconda.org/conda-forge/noarch/pytest-xdist-3.8.0-pyhd8ed1ab_0.conda#8375cfbda7c57fbceeda18229be10417
https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8
+https://conda.anaconda.org/conda-forge/noarch/python-slugify-8.0.4-pyhd8ed1ab_1.conda#a4059bc12930bddeb41aef71537ffaed
https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-8_cp312.conda#c3efd25ac4d74b1584d2f7a57195ddf1
https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda#bc8e3267d44011051f2eb14d22fb0960
https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhcf101f3_1.conda#c65df89a0b2e321045a9e01d1337b182
@@ -264,6 +273,7 @@ https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-htmlhelp-2.1.0-pyhd8
https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jsmath-1.0.1-pyhd8ed1ab_1.conda#fa839b5ff59e192f411ccc7dae6588bb
https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-qthelp-2.0.0-pyhd8ed1ab_1.conda#00534ebcc0375929b45c3039b5ba7636
https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-serializinghtml-1.1.10-pyhd8ed1ab_1.conda#3bc61f7161d28137797e038263c04c54
+https://conda.anaconda.org/conda-forge/noarch/text-unidecode-1.3-pyhd8ed1ab_2.conda#23b4ba5619c4752976eb7ba1f5acb7e8
https://conda.anaconda.org/conda-forge/noarch/tifffile-2026.2.20-pyhd8ed1ab_0.conda#9ee854e39faa623a8e79ae20ac374f1f
https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.0-pyhcf101f3_0.conda#72e780e9aa2d0a3295f59b1874e3768b
https://conda.anaconda.org/conda-forge/noarch/toolz-1.1.0-pyhd8ed1ab_1.conda#c07a6153f8306e45794774cf9b13bd32
diff --git a/requirements/locks/py313-lock-linux-64.txt b/requirements/locks/py313-lock-linux-64.txt
index d5e33d0c1..bf0b953e0 100644
--- a/requirements/locks/py313-lock-linux-64.txt
+++ b/requirements/locks/py313-lock-linux-64.txt
@@ -1,6 +1,6 @@
# Generated by conda-lock.
# platform: linux-64
-# input_hash: bc240bbc99db021df2695fa77c1e125fa0718f7c81444e4cd19c1f9b8635913b
+# input_hash: 9bf168a2c0e693f7bbd6851b4bd8384a082881ece159f91a6b1c47bd423a7152
@EXPLICIT
https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda#a9f577daf3de00bca7c3c76c0ecbd1de
https://conda.anaconda.org/conda-forge/linux-64/aom-3.9.1-hac33072_0.conda#346722a0be40f6edc53f12640d301338
@@ -40,6 +40,7 @@ https://conda.anaconda.org/conda-forge/linux-64/giflib-5.2.2-hd590300_0.conda#3b
https://conda.anaconda.org/conda-forge/linux-64/glib-tools-2.86.4-hf516916_0.conda#70a09b6817c7ad694ef4543204c59c25
https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.14-hecca717_2.conda#2cd94587f3a401ae05e03a6caf09539d
https://conda.anaconda.org/conda-forge/linux-64/graphviz-14.1.2-h8b86629_0.conda#341fc61cfe8efa5c72d24db56c776f44
+https://conda.anaconda.org/conda-forge/linux-64/greenlet-3.3.2-py313h5d5ffb9_0.conda#0199b03b39892320265af92b5e3e9093
https://conda.anaconda.org/conda-forge/linux-64/gtk3-3.24.43-ha5ea40c_7.conda#f605332e1e4d9ff5c599933ae81db57d
https://conda.anaconda.org/conda-forge/linux-64/gts-0.7.6-h977cf35_4.conda#4d8df0b0db060d33c9a702ada998a8fe
https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-12.3.2-h6083320_0.conda#d170a70fc1d5c605fcebdf16851bd54a
@@ -110,6 +111,7 @@ https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_18.
https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda#cd5a90476766d53e901500df9215e927
https://conda.anaconda.org/conda-forge/linux-64/libudunits2-2.2.28-h40f5838_3.conda#4bdace082e911a3e1f1f0b721bed5b56
https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda#db409b7c1720428638e7c0d509d3e1b5
+https://conda.anaconda.org/conda-forge/linux-64/libuv-1.51.0-hb03c661_1.conda#0f03292cc56bf91a077a134ea8747118
https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda#aea31d2e5b1091feca96fcfe945c3cf9
https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda#92ed62436b625154323d40d5f2f11dd7
https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.1-hca5e8e5_0.conda#2bca1fbb221d9c3c8e3a155784bbc2e9
@@ -124,6 +126,7 @@ https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.10.8-py313h683
https://conda.anaconda.org/conda-forge/linux-64/mo_pack-0.3.1-py313h29aa505_2.conda#ad53894d278895bf15c8fc324727d224
https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda#47e340acb35de30501a76c7c799c41d7
https://conda.anaconda.org/conda-forge/linux-64/netcdf4-1.7.4-nompi_py313h16051e2_102.conda#20ae46c5e9c7106bdb2cac6b44b7d845
+https://conda.anaconda.org/conda-forge/linux-64/nodejs-24.13.1-h3d65ac4_0.conda#45fe531d027ce218b895565110a79a1f
https://conda.anaconda.org/conda-forge/linux-64/numpy-2.4.2-py313hf6604e3_1.conda#ca9c6ba4beac38cb3d0a85afde27f94c
https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda#11b3379b191f63139e29c0d19dee24cd
https://conda.anaconda.org/conda-forge/linux-64/openjph-0.26.3-h8d634f6_0.conda#792d5b6e99677177f5527a758a02bc07
@@ -132,6 +135,7 @@ https://conda.anaconda.org/conda-forge/linux-64/pango-1.56.4-hadf4263_0.conda#79
https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda#7a3bff861a6583f1889021facefc08b1
https://conda.anaconda.org/conda-forge/linux-64/pillow-12.1.1-py313h80991f8_0.conda#2d5ee4938cdde91a8967f3eea686c546
https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda#c01af13bdc553d1a8fbfff6e8db075f0
+https://conda.anaconda.org/conda-forge/linux-64/playwright-1.58.2-h5585027_0.conda#1650804f26dd992fd7418fe603f1c653
https://conda.anaconda.org/conda-forge/linux-64/proj-9.7.1-he0df7b0_3.conda#031e33ae075b336c0ce92b14efa886c5
https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda#b3c17d95b5a10c6e64a21fa17573e70e
https://conda.anaconda.org/conda-forge/linux-64/pygraphviz-1.14-py313h8a0a71b_3.conda#39cc661e23cbcfcc8f3c965b2fda6590
@@ -232,17 +236,22 @@ https://conda.anaconda.org/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda#
https://conda.anaconda.org/conda-forge/noarch/partd-1.4.2-pyhd8ed1ab_0.conda#0badf9c54e24cecfb0ad2f99d680c163
https://conda.anaconda.org/conda-forge/noarch/pip-26.0.1-pyh145f28c_0.conda#09a970fbf75e8ed1aa633827ded6aa4f
https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.9.2-pyhcf101f3_0.conda#4fefefb892ce9cc1539405bec2f1a6cd
+https://conda.anaconda.org/conda-forge/noarch/playwright-python-1.58.0-pyhcf101f3_0.conda#3b886c49cf44aa133d0eb07e4d0cac89
https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda#d7585b6550ad04c8c5e21097ada2888e
https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.5.1-pyha770c72_0.conda#7f3ac694319c7eaf81a0325d6405e974
https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda#12c566707c80111f9799308d9e265aef
+https://conda.anaconda.org/conda-forge/noarch/pyee-13.0.1-pyhd8ed1ab_0.conda#eadf0f76d9121a6297be754e9d7cc099
https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda#6b6ece66ebcae2d5f326c77ef2c5a066
https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda#3687cc0b82a8b4c17e1f0eb7e47163d5
https://conda.anaconda.org/conda-forge/noarch/pyshp-3.0.3-pyhd8ed1ab_0.conda#c138c7aaa6a10b5762dcd92247864aff
https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda#461219d1a5bd61342293efa2c0c90eac
https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.2-pyhcf101f3_0.conda#2b694bad8a50dc2f712f5368de866480
+https://conda.anaconda.org/conda-forge/noarch/pytest-base-url-2.1.0-pyhd8ed1ab_1.conda#057f32e4c376ce0c4c4a32a9f06bf34e
https://conda.anaconda.org/conda-forge/noarch/pytest-cov-7.0.0-pyhcf101f3_1.conda#6891acad5e136cb62a8c2ed2679d6528
+https://conda.anaconda.org/conda-forge/noarch/pytest-playwright-0.7.2-pyhd8ed1ab_1.conda#34d1d3c36ffccb8dc02c3f8da7ae1e5c
https://conda.anaconda.org/conda-forge/noarch/pytest-xdist-3.8.0-pyhd8ed1ab_0.conda#8375cfbda7c57fbceeda18229be10417
https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8
+https://conda.anaconda.org/conda-forge/noarch/python-slugify-8.0.4-pyhd8ed1ab_1.conda#a4059bc12930bddeb41aef71537ffaed
https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-8_cp313.conda#94305520c52a4aa3f6c2b1ff6008d9f8
https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda#bc8e3267d44011051f2eb14d22fb0960
https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhcf101f3_1.conda#c65df89a0b2e321045a9e01d1337b182
@@ -262,6 +271,7 @@ https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-htmlhelp-2.1.0-pyhd8
https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jsmath-1.0.1-pyhd8ed1ab_1.conda#fa839b5ff59e192f411ccc7dae6588bb
https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-qthelp-2.0.0-pyhd8ed1ab_1.conda#00534ebcc0375929b45c3039b5ba7636
https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-serializinghtml-1.1.10-pyhd8ed1ab_1.conda#3bc61f7161d28137797e038263c04c54
+https://conda.anaconda.org/conda-forge/noarch/text-unidecode-1.3-pyhd8ed1ab_2.conda#23b4ba5619c4752976eb7ba1f5acb7e8
https://conda.anaconda.org/conda-forge/noarch/tifffile-2026.2.20-pyhd8ed1ab_0.conda#9ee854e39faa623a8e79ae20ac374f1f
https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.0-pyhcf101f3_0.conda#72e780e9aa2d0a3295f59b1874e3768b
https://conda.anaconda.org/conda-forge/noarch/toolz-1.1.0-pyhd8ed1ab_1.conda#c07a6153f8306e45794774cf9b13bd32
diff --git a/requirements/locks/py314-lock-linux-64.txt b/requirements/locks/py314-lock-linux-64.txt
index fdd1c3b08..a8cf13865 100644
--- a/requirements/locks/py314-lock-linux-64.txt
+++ b/requirements/locks/py314-lock-linux-64.txt
@@ -1,6 +1,6 @@
# Generated by conda-lock.
# platform: linux-64
-# input_hash: 609f99f90ce4b4dd01606c02840707d9b3e41f94b21f062c2347944019814e3d
+# input_hash: d8380565eeedc4f19ab94e56693baff09becf0ca3bbd96eaf3b4c5e0e8819707
@EXPLICIT
https://conda.anaconda.org/conda-forge/linux-64/_openmp_mutex-4.5-20_gnu.conda#a9f577daf3de00bca7c3c76c0ecbd1de
https://conda.anaconda.org/conda-forge/linux-64/aom-3.9.1-hac33072_0.conda#346722a0be40f6edc53f12640d301338
@@ -38,6 +38,7 @@ https://conda.anaconda.org/conda-forge/linux-64/giflib-5.2.2-hd590300_0.conda#3b
https://conda.anaconda.org/conda-forge/linux-64/glib-tools-2.86.4-hf516916_0.conda#70a09b6817c7ad694ef4543204c59c25
https://conda.anaconda.org/conda-forge/linux-64/graphite2-1.3.14-hecca717_2.conda#2cd94587f3a401ae05e03a6caf09539d
https://conda.anaconda.org/conda-forge/linux-64/graphviz-14.1.2-h8b86629_0.conda#341fc61cfe8efa5c72d24db56c776f44
+https://conda.anaconda.org/conda-forge/linux-64/greenlet-3.3.2-py314h42812f9_0.conda#511748f9debe034ff88eef99bc215fd3
https://conda.anaconda.org/conda-forge/linux-64/gtk3-3.24.43-ha5ea40c_7.conda#f605332e1e4d9ff5c599933ae81db57d
https://conda.anaconda.org/conda-forge/linux-64/gts-0.7.6-h977cf35_4.conda#4d8df0b0db060d33c9a702ada998a8fe
https://conda.anaconda.org/conda-forge/linux-64/harfbuzz-12.3.2-h6083320_0.conda#d170a70fc1d5c605fcebdf16851bd54a
@@ -108,6 +109,7 @@ https://conda.anaconda.org/conda-forge/linux-64/libstdcxx-ng-15.2.0-hdf11a46_18.
https://conda.anaconda.org/conda-forge/linux-64/libtiff-4.7.1-h9d88235_1.conda#cd5a90476766d53e901500df9215e927
https://conda.anaconda.org/conda-forge/linux-64/libudunits2-2.2.28-h40f5838_3.conda#4bdace082e911a3e1f1f0b721bed5b56
https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.41.3-h5347b49_0.conda#db409b7c1720428638e7c0d509d3e1b5
+https://conda.anaconda.org/conda-forge/linux-64/libuv-1.51.0-hb03c661_1.conda#0f03292cc56bf91a077a134ea8747118
https://conda.anaconda.org/conda-forge/linux-64/libwebp-base-1.6.0-hd42ef1d_0.conda#aea31d2e5b1091feca96fcfe945c3cf9
https://conda.anaconda.org/conda-forge/linux-64/libxcb-1.17.0-h8a09558_0.conda#92ed62436b625154323d40d5f2f11dd7
https://conda.anaconda.org/conda-forge/linux-64/libxkbcommon-1.13.1-hca5e8e5_0.conda#2bca1fbb221d9c3c8e3a155784bbc2e9
@@ -121,6 +123,7 @@ https://conda.anaconda.org/conda-forge/linux-64/matplotlib-base-3.10.8-py314h119
https://conda.anaconda.org/conda-forge/linux-64/mo_pack-0.3.1-py314hc02f841_2.conda#55ac6d85f5dd8ec5e9919e7762fcb31a
https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.5-h2d0b736_3.conda#47e340acb35de30501a76c7c799c41d7
https://conda.anaconda.org/conda-forge/linux-64/netcdf4-1.7.4-nompi_py314h4ae7121_102.conda#cf495d9fc5e01a2ee10e0867ce957a44
+https://conda.anaconda.org/conda-forge/linux-64/nodejs-24.13.1-h3d65ac4_0.conda#45fe531d027ce218b895565110a79a1f
https://conda.anaconda.org/conda-forge/linux-64/numpy-2.4.2-py314h2b28147_1.conda#4ea6b620fdf24a1a0bc4f1c7134dfafb
https://conda.anaconda.org/conda-forge/linux-64/openjpeg-2.5.4-h55fea9a_0.conda#11b3379b191f63139e29c0d19dee24cd
https://conda.anaconda.org/conda-forge/linux-64/openjph-0.26.3-h8d634f6_0.conda#792d5b6e99677177f5527a758a02bc07
@@ -129,6 +132,7 @@ https://conda.anaconda.org/conda-forge/linux-64/pango-1.56.4-hadf4263_0.conda#79
https://conda.anaconda.org/conda-forge/linux-64/pcre2-10.47-haa7fec5_0.conda#7a3bff861a6583f1889021facefc08b1
https://conda.anaconda.org/conda-forge/linux-64/pillow-12.1.1-py314h8ec4b1a_0.conda#79678378ae235e24b3aa83cee1b38207
https://conda.anaconda.org/conda-forge/linux-64/pixman-0.46.4-h54a6638_1.conda#c01af13bdc553d1a8fbfff6e8db075f0
+https://conda.anaconda.org/conda-forge/linux-64/playwright-1.58.2-h5585027_0.conda#1650804f26dd992fd7418fe603f1c653
https://conda.anaconda.org/conda-forge/linux-64/proj-9.7.1-he0df7b0_3.conda#031e33ae075b336c0ce92b14efa886c5
https://conda.anaconda.org/conda-forge/linux-64/pthread-stubs-0.4-hb9d3cd8_1002.conda#b3c17d95b5a10c6e64a21fa17573e70e
https://conda.anaconda.org/conda-forge/linux-64/pygraphviz-1.14-py314h5b92a88_3.conda#df9e41bd59730b88d0900142f3888458
@@ -233,17 +237,22 @@ https://conda.anaconda.org/conda-forge/noarch/packaging-26.0-pyhcf101f3_0.conda#
https://conda.anaconda.org/conda-forge/noarch/partd-1.4.2-pyhd8ed1ab_0.conda#0badf9c54e24cecfb0ad2f99d680c163
https://conda.anaconda.org/conda-forge/noarch/pip-26.0.1-pyh145f28c_0.conda#09a970fbf75e8ed1aa633827ded6aa4f
https://conda.anaconda.org/conda-forge/noarch/platformdirs-4.9.2-pyhcf101f3_0.conda#4fefefb892ce9cc1539405bec2f1a6cd
+https://conda.anaconda.org/conda-forge/noarch/playwright-python-1.58.0-pyhcf101f3_0.conda#3b886c49cf44aa133d0eb07e4d0cac89
https://conda.anaconda.org/conda-forge/noarch/pluggy-1.6.0-pyhf9edf01_1.conda#d7585b6550ad04c8c5e21097ada2888e
https://conda.anaconda.org/conda-forge/noarch/pre-commit-4.5.1-pyha770c72_0.conda#7f3ac694319c7eaf81a0325d6405e974
https://conda.anaconda.org/conda-forge/noarch/pycparser-2.22-pyh29332c3_1.conda#12c566707c80111f9799308d9e265aef
+https://conda.anaconda.org/conda-forge/noarch/pyee-13.0.1-pyhd8ed1ab_0.conda#eadf0f76d9121a6297be754e9d7cc099
https://conda.anaconda.org/conda-forge/noarch/pygments-2.19.2-pyhd8ed1ab_0.conda#6b6ece66ebcae2d5f326c77ef2c5a066
https://conda.anaconda.org/conda-forge/noarch/pyparsing-3.3.2-pyhcf101f3_0.conda#3687cc0b82a8b4c17e1f0eb7e47163d5
https://conda.anaconda.org/conda-forge/noarch/pyshp-3.0.3-pyhd8ed1ab_0.conda#c138c7aaa6a10b5762dcd92247864aff
https://conda.anaconda.org/conda-forge/noarch/pysocks-1.7.1-pyha55dd90_7.conda#461219d1a5bd61342293efa2c0c90eac
https://conda.anaconda.org/conda-forge/noarch/pytest-9.0.2-pyhcf101f3_0.conda#2b694bad8a50dc2f712f5368de866480
+https://conda.anaconda.org/conda-forge/noarch/pytest-base-url-2.1.0-pyhd8ed1ab_1.conda#057f32e4c376ce0c4c4a32a9f06bf34e
https://conda.anaconda.org/conda-forge/noarch/pytest-cov-7.0.0-pyhcf101f3_1.conda#6891acad5e136cb62a8c2ed2679d6528
+https://conda.anaconda.org/conda-forge/noarch/pytest-playwright-0.7.2-pyhd8ed1ab_1.conda#34d1d3c36ffccb8dc02c3f8da7ae1e5c
https://conda.anaconda.org/conda-forge/noarch/pytest-xdist-3.8.0-pyhd8ed1ab_0.conda#8375cfbda7c57fbceeda18229be10417
https://conda.anaconda.org/conda-forge/noarch/python-dateutil-2.9.0.post0-pyhe01879c_2.conda#5b8d21249ff20967101ffa321cab24e8
+https://conda.anaconda.org/conda-forge/noarch/python-slugify-8.0.4-pyhd8ed1ab_1.conda#a4059bc12930bddeb41aef71537ffaed
https://conda.anaconda.org/conda-forge/noarch/python_abi-3.14-8_cp314.conda#0539938c55b6b1a59b560e843ad864a4
https://conda.anaconda.org/conda-forge/noarch/pytz-2025.2-pyhd8ed1ab_0.conda#bc8e3267d44011051f2eb14d22fb0960
https://conda.anaconda.org/conda-forge/noarch/requests-2.32.5-pyhcf101f3_1.conda#c65df89a0b2e321045a9e01d1337b182
@@ -263,6 +272,7 @@ https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-htmlhelp-2.1.0-pyhd8
https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-jsmath-1.0.1-pyhd8ed1ab_1.conda#fa839b5ff59e192f411ccc7dae6588bb
https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-qthelp-2.0.0-pyhd8ed1ab_1.conda#00534ebcc0375929b45c3039b5ba7636
https://conda.anaconda.org/conda-forge/noarch/sphinxcontrib-serializinghtml-1.1.10-pyhd8ed1ab_1.conda#3bc61f7161d28137797e038263c04c54
+https://conda.anaconda.org/conda-forge/noarch/text-unidecode-1.3-pyhd8ed1ab_2.conda#23b4ba5619c4752976eb7ba1f5acb7e8
https://conda.anaconda.org/conda-forge/noarch/tifffile-2026.2.20-pyhd8ed1ab_0.conda#9ee854e39faa623a8e79ae20ac374f1f
https://conda.anaconda.org/conda-forge/noarch/tomli-2.4.0-pyhcf101f3_0.conda#72e780e9aa2d0a3295f59b1874e3768b
https://conda.anaconda.org/conda-forge/noarch/toolz-1.1.0-pyhd8ed1ab_1.conda#c07a6153f8306e45794774cf9b13bd32
diff --git a/src/CSET/cset_workflow/app/finish_website/bin/finish_website.py b/src/CSET/cset_workflow/app/finish_website/bin/finish_website.py
index 859ee82ea..0f98fa332 100755
--- a/src/CSET/cset_workflow/app/finish_website/bin/finish_website.py
+++ b/src/CSET/cset_workflow/app/finish_website/bin/finish_website.py
@@ -30,7 +30,7 @@
from importlib.metadata import version
from pathlib import Path
-from CSET._common import combine_dicts, sort_dict
+from CSET._common import sort_dict
logging.basicConfig(
level=os.getenv("LOGLEVEL", "INFO"),
@@ -67,35 +67,33 @@ def install_website_skeleton(www_root_link: Path, www_content: Path):
def construct_index(www_content: Path):
"""Construct the plot index."""
plots_dir = www_content / "plots"
- index = {}
- # Loop over all diagnostics and append to index.
- for metadata_file in plots_dir.glob("**/*/meta.json"):
- try:
- with open(metadata_file, "rt", encoding="UTF-8") as fp:
- plot_metadata = json.load(fp)
-
- category = plot_metadata["category"]
- case_date = plot_metadata.get("case_date", "")
- relative_url = str(metadata_file.parent.relative_to(plots_dir))
-
- record = {
- category: {
- case_date if case_date else "Aggregation": {
- relative_url: plot_metadata["title"].strip()
- }
- }
- }
- except (json.JSONDecodeError, KeyError, TypeError) as err:
- logging.error("%s is invalid, skipping.\n%s", metadata_file, err)
- continue
- index = combine_dicts(index, record)
-
- # Sort index of diagnostics.
- index = sort_dict(index)
-
- # Write out website index.
- with open(plots_dir / "index.json", "wt", encoding="UTF-8") as fp:
- json.dump(index, fp, indent=2)
+ with open(plots_dir / "index.jsonl", "wt", encoding="UTF-8") as index_fp:
+ # Loop over all diagnostics and append to index. The glob is sorted to
+ # ensure a consistent ordering.
+ for metadata_file in sorted(plots_dir.glob("**/*/meta.json")):
+ try:
+ with open(metadata_file, "rt", encoding="UTF-8") as plot_fp:
+ plot_metadata = json.load(plot_fp)
+ plot_metadata["path"] = str(metadata_file.parent.relative_to(plots_dir))
+ # Remove keys that are not useful for the index.
+ removed_index_keys = [
+ "description",
+ "plot_resolution",
+ "plots",
+ "skip_write",
+ "SUBAREA_EXTENT",
+ "SUBAREA_TYPE",
+ ]
+ for key in removed_index_keys:
+ plot_metadata.pop(key, None)
+ # Sort plot metadata.
+ plot_metadata = sort_dict(plot_metadata)
+ # Write metadata into website index.
+ json.dump(plot_metadata, index_fp, separators=(",", ":"))
+ index_fp.write("\n")
+ except (json.JSONDecodeError, KeyError, TypeError) as err:
+ logging.error("%s is invalid, skipping.\n%s", metadata_file, err)
+ continue
def bust_cache(www_content: Path):
diff --git a/src/CSET/cset_workflow/app/finish_website/file/html/index.html b/src/CSET/cset_workflow/app/finish_website/file/html/index.html
index b1b0a5de7..616fe0d9b 100644
--- a/src/CSET/cset_workflow/app/finish_website/file/html/index.html
+++ b/src/CSET/cset_workflow/app/finish_website/file/html/index.html
@@ -12,12 +12,69 @@
diff --git a/src/CSET/cset_workflow/app/finish_website/file/html/script.js b/src/CSET/cset_workflow/app/finish_website/file/html/script.js
index 265bb2289..c27bdc970 100644
--- a/src/CSET/cset_workflow/app/finish_website/file/html/script.js
+++ b/src/CSET/cset_workflow/app/finish_website/file/html/script.js
@@ -1,6 +1,387 @@
// JavaScript code that is used by the pages. Plots should not rely on this
// file, as it will not be stable.
+/** Search query lexer and parser.
+ *
+ * EBNF for filter.
+ *
+ * query = expression ;
+ *
+ * expression = condition
+ * | expression , combiner ? , expression
+ * | "NOT" , expression
+ * | "(" , expression , ")" ;
+ *
+ * combiner = "AND"
+ * | "OR" ;
+ *
+ * condition = facet ? , operator ? , value ;
+ *
+ * facet = LITERAL ;
+ *
+ * value = LITERAL ;
+ *
+ * operator = IN
+ * | EQUALS ;
+ * | LESS_THAN
+ * | GREATER_THAN
+ * | LESS_THAN_OR_EQUALS
+ * | GREATER_THAN_OR_EQUALS
+ */
+
+class Literal {
+ constructor(value) {
+ this.value = value;
+ }
+}
+
+const Parenthesis = {
+ BEGIN: "BEGIN",
+ END: "END",
+};
+
+const Operator = {
+ IN: "IN",
+ EQUALS: "EQUALS",
+ LESS_THAN: "LESS_THAN",
+ GREATER_THAN: "GREATER_THAN",
+ LESS_THAN_OR_EQUALS: "LESS_THAN_OR_EQUALS",
+ GREATER_THAN_OR_EQUALS: "GREATER_THAN_OR_EQUALS",
+};
+
+const Combiner = {
+ NOT: "NOT",
+ AND: "AND",
+ OR: "OR",
+};
+
+const TokenTypes = {
+ Literal: Literal,
+ Parenthesis: Parenthesis,
+ Operator: Operator,
+ Combiner: Combiner,
+};
+
+const TOKEN_SPEC = {
+ Parenthesis_BEGIN: "\\(",
+ Parenthesis_END: "\\)",
+ Operator_GREATER_THAN_OR_EQUALS: "<=",
+ Operator_GREATER_THAN: "<",
+ Operator_LESS_THAN_OR_EQUALS: ">=",
+ Operator_LESS_THAN: ">",
+ Operator_EQUALS: "=",
+ Operator_IN: ":",
+ Combiner_NOT: "\\bnot\\b",
+ Combiner_AND: "\\band\\b",
+ Combiner_OR: "\\bor\\b",
+ LexOnly_WHITESPACE: "\\s+",
+ LexOnly_LITERAL: `'[^']*'|"[^"]*"|[^\\s\\(\\):=<>]+`,
+};
+
+const TOKEN_REGEX = RegExp(
+ Array.from(
+ Object.entries(TOKEN_SPEC).map((pair) => {
+ return `(?<${pair[0]}>${pair[1]})`;
+ }),
+ ).join("|"),
+ "ig",
+);
+
+// Lex input string into tokens.
+function lexer(query) {
+ const tokens = [];
+ for (const match of query.matchAll(TOKEN_REGEX)) {
+ // Split into tokens by matching the capture group names from TOKEN_SPEC.
+ if (!match.groups) {
+ throw new SyntaxError("Query did not consist of valid tokens.");
+ }
+ let [kind, value] = Object.entries(match.groups).filter(
+ (pair) => pair[1] !== undefined,
+ )[0];
+ switch (kind) {
+ case "LexOnly_WHITESPACE":
+ // Skip whitespace.
+ continue;
+ case "LexOnly_LITERAL":
+ // Unquote and store value for literals.
+ if (/^".*"$|^'.+'$/.test(value)) {
+ value = value.slice(1, -1);
+ }
+ tokens.push(new Literal(value));
+ break;
+ default:
+ // Tokens for Operators and Combiners.
+ const kind_split_position = kind.indexOf("_");
+ const kind_type = kind.slice(0, kind_split_position);
+ const kind_value = kind.slice(kind_split_position + 1);
+ tokens.push(TokenTypes[kind_type][kind_value]);
+ break;
+ }
+ }
+ return tokens;
+}
+
+class Condition {
+ constructor(value, facet, operator) {
+ // Allow constructing a Condition from a Condition, e.g: (((Condition)))
+ if (typeof value === "function") {
+ this.func = value;
+ return;
+ }
+ const v = value.value.toLowerCase();
+ const f = facet.value;
+ let cond_func;
+ switch (operator) {
+ case Operator.IN:
+ cond_func = function cond_in(diagnostic) {
+ return f in diagnostic && diagnostic[f].toLowerCase().includes(v);
+ };
+ break;
+ case Operator.EQUALS:
+ cond_func = function cond_eq(diagnostic) {
+ return f in diagnostic && v === diagnostic[f].toLowerCase();
+ };
+ break;
+ case Operator.GREATER_THAN:
+ cond_func = function cond_gt(diagnostic) {
+ return f in diagnostic && v > diagnostic[f].toLowerCase();
+ };
+ break;
+ case Operator.GREATER_THAN_OR_EQUALS:
+ cond_func = function cond_gte(diagnostic) {
+ return f in diagnostic && v >= diagnostic[f].toLowerCase();
+ };
+ break;
+ case Operator.LESS_THAN:
+ cond_func = function cond_lt(diagnostic) {
+ return f in diagnostic && v < diagnostic[f].toLowerCase();
+ };
+ break;
+ case Operator.LESS_THAN_OR_EQUALS:
+ cond_func = function cond_lte(diagnostic) {
+ return f in diagnostic && v <= diagnostic[f].toLowerCase();
+ };
+ break;
+ default:
+ throw new Error(`Invalid operator: ${operator}`);
+ }
+ this.func = cond_func;
+ }
+
+ test(diagnostic) {
+ return this.func(diagnostic);
+ }
+
+ and(other) {
+ return new Condition(
+ (diagnostic) => this.test(diagnostic) && other.test(diagnostic),
+ );
+ }
+
+ or(other) {
+ return new Condition(
+ (diagnostic) => this.test(diagnostic) || other.test(diagnostic),
+ );
+ }
+
+ invert() {
+ return new Condition((diagnostic) => !this.test(diagnostic));
+ }
+}
+
+// Parse a grouped expression from a stream of tokens.
+function parse_grouped_expression(tokens) {
+ if (tokens.length < 2 || tokens[0] !== Parenthesis.BEGIN) {
+ return [0, null];
+ }
+ let offset = 1;
+ let depth = 1;
+ while (depth > 0 && offset < tokens.length) {
+ switch (tokens[offset]) {
+ case Parenthesis.BEGIN:
+ depth += 1;
+ break;
+ case Parenthesis.END:
+ depth -= 1;
+ break;
+ }
+ offset += 1;
+ }
+ if (depth !== 0) {
+ throw new Error("Unmatched parenthesis.");
+ }
+ // Recursively parse the grouped expression.
+ inner_expression = parse_expression(tokens.slice(1, offset - 1));
+ return [offset, inner_expression];
+}
+
+// Parse a condition from a stream of tokens.
+function parse_condition(tokens) {
+ if (
+ tokens[0] instanceof Literal &&
+ tokens[1] in Operator &&
+ tokens[2] instanceof Literal
+ ) {
+ // Value to search for in facet with operator.
+ return [3, new Condition(tokens[2], (facet = tokens[0]), (operator = tokens[1]))];
+ } else if (tokens[0] instanceof Literal) {
+ // Just a value to search for.
+ return [
+ 1,
+ new Condition(
+ tokens[0],
+ (facet = new Literal("title")),
+ (operator = Operator.IN),
+ ),
+ ];
+ } else {
+ // Not matched as a condition.
+ return [0, null];
+ }
+}
+
+// Collapse all NOTs in a list of conditions.
+function evaluate_not(conditions) {
+ const negated_conditions = [];
+ let index = 0;
+ while (index < conditions.length) {
+ if (conditions[index] === Combiner.NOT && conditions[index + 1] === Combiner.NOT) {
+ // Skip double NOTs, as they negate each other.
+ index += 2;
+ } else if (
+ conditions[index] === Combiner.NOT &&
+ conditions[index + 1] instanceof Condition
+ ) {
+ const right = conditions[index + 1];
+ negated_conditions.push(right.invert());
+ index += 2;
+ } else if (conditions[index] !== Combiner.NOT) {
+ negated_conditions.push(conditions[index]);
+ index += 1;
+ } else {
+ throw new Error("Unprocessable NOT.");
+ }
+ }
+ return negated_conditions;
+}
+
+// Collapse all explicit and implicit ANDs in a list of conditions.
+function evaluate_and(conditions) {
+ const anded_conditions = [];
+ let index = 0;
+ while (index < conditions.length) {
+ let left = null;
+ if (anded_conditions.length) {
+ left = anded_conditions.pop();
+ }
+ if (
+ left instanceof Condition &&
+ conditions[index] === Combiner.AND &&
+ conditions[index + 1] instanceof Condition
+ ) {
+ const right = conditions[index + 1];
+ anded_conditions.push(left.and(right));
+ index += 2;
+ } else if (left instanceof Condition && conditions[index] instanceof Condition) {
+ const right = conditions[index];
+ anded_conditions.push(left.and(right));
+ index += 2;
+ } else if (conditions[index] !== Combiner.AND) {
+ if (left !== null) {
+ anded_conditions.push(left);
+ }
+ const right = conditions[index];
+ anded_conditions.push(right);
+ index += 1;
+ } else {
+ throw new Error("Unprocessable AND.");
+ }
+ }
+ return anded_conditions;
+}
+
+// Collapse all ORs in a list of conditions.
+function evaluate_or(conditions) {
+ const ored_conditions = [];
+ let index = 0;
+ while (index < conditions.length) {
+ if (
+ conditions[index] instanceof Condition &&
+ conditions[index + 1] === Combiner.OR &&
+ conditions[index + 2]
+ ) {
+ const left = conditions[index];
+ const right = conditions[index + 2];
+ ored_conditions.push(left.or(right));
+ index += 3;
+ } else if (conditions[index] !== Combiner.OR) {
+ ored_conditions.push(conditions[index]);
+ index += 1;
+ } else {
+ throw new Error("Unprocessable OR.");
+ }
+ }
+ return ored_conditions;
+}
+
+// Parse an expression into a single Condition function.
+function parse_expression(tokens) {
+ let conditions = [];
+ let index = 0;
+ while (index < tokens.length) {
+ console.log("Conditions:", conditions);
+ console.log("Token index:", index);
+ // Accounts for AND/OR/NOT.
+ if (tokens[index] in Combiner) {
+ conditions.push(tokens[index]);
+ index += 1;
+ continue;
+ }
+ // Accounts for parentheses.
+ let [offset, condition] = parse_grouped_expression(tokens.slice(index));
+ if (offset > 0 && condition !== null) {
+ conditions.push(condition);
+ index += offset;
+ continue;
+ }
+ // Accounts for Facets, Operators, and Literals.
+ [offset, condition] = parse_condition(tokens.slice(index));
+ if (offset > 0 && condition !== null) {
+ conditions.push(condition);
+ index += offset;
+ continue;
+ }
+ console.error(tokens[index]);
+ throw new Error(`Unexpected token in expression: ${tokens[index]}`);
+ }
+ // Evaluate NOTs first, left to right.
+ conditions = evaluate_not(conditions);
+ // Evaluate ANDs second, left to right.
+ conditions = evaluate_and(conditions);
+ // Evaluate ORs third, left to right.
+ conditions = evaluate_or(conditions);
+ // Verify we have collapsed down to a single condition at this point.
+ if (conditions.length !== 1 || !(conditions[0] instanceof Condition)) {
+ throw new Error("Collapse should produce a single Condition.");
+ }
+ return conditions[0];
+}
+
+// Parse the query, returning a comparison function.
+function query2condition(query) {
+ const tokens = lexer(query);
+ console.log("Tokens:", tokens);
+ if (tokens.length === 0) {
+ // If query is empty show everything.
+ return new Condition((_) => true);
+ }
+ return parse_expression(tokens);
+}
+
+/**
+ * End of query parser.
+ */
+
// Toggle display of the extended description for plots. Global variable so it
// can be referenced at plot insertion time.
let description_shown = true;
@@ -14,7 +395,7 @@ function enforce_description_toggle() {
}
label: for (plot_frame of document.querySelectorAll("iframe")) {
const description_container = plot_frame.contentDocument.getElementById(
- "description-container"
+ "description-container",
);
// Skip doing anything if plot not loaded.
if (!description_container) {
@@ -44,7 +425,7 @@ function setup_description_toggle_button() {
});
// Ensure the description toggle persists across changing the frame content.
for (const plot_frame of document.querySelectorAll("iframe")) {
- plot_frame.addEventListener("load", () => {
+ plot_frame.addEventListener("DOMContentLoaded", () => {
enforce_description_toggle();
});
}
@@ -66,87 +447,142 @@ function ensure_dual_frame() {
dual_frame.classList.remove("hidden");
}
-function construct_sidebar_from_data(data) {
- const sidebar = document.getElementById("plot-selector");
+// Create a list entry element for a single diagnostic.
+// Unseen facet values are recorded in facet_values.
+function create_diagnostic_element(record, facet_values) {
+ // Add entry's display name.
+ const entry_title = document.createElement("h2");
+ entry_title.textContent = record["title"];
+
+ // Create card for diagnostic.
+ const facets = document.createElement("dl");
+ for (const facet in record) {
+ if (facet !== "title" && facet !== "path") {
+ const facet_node = document.createElement("div");
+ const name = document.createElement("dt");
+ const value = document.createElement("dd");
+ name.textContent = facet;
+ value.textContent = record[facet];
+ facet_node.appendChild(name);
+ facet_node.appendChild(value);
+ facets.appendChild(facet_node);
+ // Record facet values.
+ if (!(facet in facet_values)) {
+ facet_values[facet] = new Set();
+ }
+ facet_values[facet].add(record[facet]);
+ }
+ }
+
+ // Container element for plot position chooser buttons.
+ const position_chooser = document.createElement("div");
+ position_chooser.classList.add("plot-position-chooser");
+
+ // Bind path to name in this scope to ensure it sticks around for callbacks.
+ const path = record["path"];
// Button icons.
- const icons = { left: "◧", full: "▣", right: "◨" };
-
- for (const category in data) {
- // Details element for category.
- const category_details = document.createElement("details");
-
- // Title for category (summary element).
- const category_summary = document.createElement("summary");
- category_summary.textContent = category;
- category_details.append(category_summary);
-
- // Add each case date into category.
- for (const case_date in data[category]) {
- // Details element for case_date.
- const case_details = document.createElement("details");
-
- // Title for case_date.
- const case_summary = document.createElement("summary");
- case_summary.textContent = case_date;
- case_details.append(case_summary);
-
- // Menu of plots for this category and case_date.
- const case_menu = document.createElement("menu");
-
- // Add each plot.
- for (const plot in data[category][case_date]) {
- // Menu entry for plot.
- const list_item = document.createElement("li");
- list_item.textContent = data[category][case_date][plot];
-
- // Container element for plot position chooser buttons.
- const position_chooser = document.createElement("div");
- position_chooser.classList.add("plot-position-chooser");
-
- // Add buttons for each position.
- for (const position of ["left", "full", "right"]) {
- // Create button.
- const button = document.createElement("button");
- button.classList.add(position);
- button.textContent = icons[position];
-
- // Add a callback updating the iframe when the link is clicked.
- button.addEventListener("click", (event) => {
- event.preventDefault();
- // Set the appropriate frame layout.
- position == "full" ? ensure_single_frame() : ensure_dual_frame();
- document.getElementById(`plot-frame-${position}`).src = `${PLOTS_PATH}/${plot}`;
- });
-
- // Add button to chooser.
- position_chooser.append(button);
- }
+ const icons = { left: "◧", full: "▣", right: "◨", popup: "↗" };
- // Add position chooser to entry.
- list_item.append(position_chooser);
+ // Add buttons for each position.
+ for (const position of ["left", "full", "right", "popup"]) {
+ // Create button.
+ const button = document.createElement("button");
+ button.classList.add(position);
+ button.textContent = icons[position];
- // Add entry to the menu.
- case_menu.append(list_item);
+ // Add a callback updating the iframe when the link is clicked.
+ button.addEventListener("click", (event) => {
+ event.preventDefault();
+ // Open new window for popup.
+ if (position === "popup") {
+ window.open(`${PLOTS_PATH}/${path}`, "_blank", "popup,width=800,height=600");
+ return;
}
+ // Set the appropriate frame layout.
+ position === "full" ? ensure_single_frame() : ensure_dual_frame();
+ document.getElementById(`plot-frame-${position}`).src = `${PLOTS_PATH}/${path}`;
+ });
+
+ // Add button to chooser.
+ position_chooser.appendChild(button);
+ }
+
+ // Create list entry for diagnostic.
+ const diagnostic_entry = document.createElement("li");
+
+ // Add name, facets, and position chooser to entry.
+ diagnostic_entry.appendChild(entry_title);
+ diagnostic_entry.appendChild(facets);
+ diagnostic_entry.appendChild(position_chooser);
+
+ return diagnostic_entry;
+}
- // Finish constructing this case and add to its category.
- case_details.append(case_menu);
- category_details.append(case_details);
+function add_facet_dropdowns(facet_values) {
+ const facets_dropdowns = document.createDocumentFragment();
+ for (const facet in facet_values) {
+ const label = document.createElement("label");
+ label.setAttribute("for", `facet-${facet}`);
+ label.textContent = facet;
+ const select = document.createElement("select");
+ select.id = `facet-${facet}`;
+ select.name = facet;
+ const null_option = document.createElement("option");
+ null_option.value = "";
+ null_option.defaultSelected = true;
+ null_option.textContent = "--- Any ---";
+ select.appendChild(null_option);
+ // Sort facet values.
+ const values = Array.from(facet_values[facet]);
+ values.sort();
+ for (const value of values) {
+ const option = document.createElement("option");
+ option.textContent = value;
+ select.appendChild(option);
}
+ select.addEventListener("change", updateFacetQuery);
+ // Put label and select in row.
+ const facet_row = document.createElement("div");
+ facet_row.appendChild(label);
+ facet_row.appendChild(select);
+ facets_dropdowns.appendChild(facet_row);
+ }
+ // Add to DOM.
+ const facets_container = document.getElementById("filter-facets");
+ facets_container.appendChild(facets_dropdowns);
+}
- // Join category to the DOM.
- sidebar.append(category_details);
+// Update query based on facet dropdown value.
+function updateFacetQuery(event) {
+ const facet = event.target.name;
+ const value = event.target.value;
+ const queryElem = document.getElementById("filter-query");
+ const query = queryElem.value;
+ let new_query;
+ // Construct regular expression matching facet condition.
+ const pattern = RegExp(`${facet}=\\s*('[^']*'|"[^"]*"|[^ \\t\\(\\)]+)`, "i");
+ if (value === "") {
+ // Facet unselected, remove from query.
+ new_query = query.replace(pattern, "");
+ } else if (pattern.test(query)) {
+ // Facet value selected, update the query.
+ new_query = query.replace(pattern, `${facet}="${value}"`);
+ } else {
+ // Facet value selected, add the query.
+ new_query = query + ` ${facet}="${value}"`;
}
+ queryElem.value = new_query.trim();
+ doSearch();
}
-// Plot selection sidebar
+// Plot selection sidebar.
function setup_plots_sidebar() {
// Skip if there is no sidebar on page.
if (!document.getElementById("plot-selector")) {
return;
}
// Loading of plot index file, and adding them to the sidebar.
- fetch(`${PLOTS_PATH}/index.json`)
+ fetch(`${PLOTS_PATH}/index.jsonl`)
.then((response) => {
// Display a message and stop if the fetch fails.
if (!response.ok) {
@@ -155,7 +591,25 @@ function setup_plots_sidebar() {
window.alert(message);
return;
}
- response.json().then(construct_sidebar_from_data);
+ response.text().then((data) => {
+ const diagnostics = document.createDocumentFragment();
+ const facet_values = {};
+ for (let line of data.split("\n")) {
+ line = line.trim();
+ // Skip blank lines.
+ if (line.length) {
+ const diagnostic = create_diagnostic_element(
+ JSON.parse(line),
+ facet_values,
+ );
+ diagnostics.appendChild(diagnostic);
+ }
+ }
+ // Replace the throbber with the diagnostics.
+ const diagnostics_list = document.getElementById("diagnostics");
+ diagnostics_list.replaceChildren(diagnostics);
+ add_facet_dropdowns(facet_values);
+ });
})
.catch((err) => {
// Catch non-HTTP fetch errors.
@@ -176,7 +630,95 @@ function setup_clear_view_button() {
clear_view_button.addEventListener("click", clear_frames);
}
+function setup_clear_search_button() {
+ const clear_search_button = document.getElementById("clear-query");
+ clear_search_button.addEventListener("click", () => {
+ document.getElementById("filter-query").value = "";
+ for (const select of document.querySelectorAll("#filter-facets select")) {
+ select.value = "";
+ }
+ doSearch();
+ });
+}
+
+// Track the current timeout for search debouncing.
+let searchTimeoutID = undefined;
+
+// Filter the displayed diagnostics by the query.
+function doSearch() {
+ // Clear timeout to prevent searching on both input and change events.
+ clearTimeout(searchTimeoutID);
+ const queryElem = document.getElementById("filter-query");
+ const query = queryElem.value;
+ // Update URL in address bar to match current query, deleting if blank.
+ const url = new URL(document.location.href);
+ query ? url.searchParams.set("q", query) : url.searchParams.delete("q");
+ // Updates the URL without reloading the page.
+ history.pushState(history.state, "", url.href);
+
+ console.log("Search query:", query);
+ let condition;
+ try {
+ condition = query2condition(query);
+ // Set to an empty string to mark as valid.
+ queryElem.setCustomValidity("");
+ } catch (error) {
+ console.error("Query failed to parse.", error);
+ // Add :invalid pseudoclass to input, so user gets feedback.
+ queryElem.setCustomValidity("Query failed to parse.");
+ return;
+ }
+
+ // Filter all entries.
+ for (const entryElem of document.querySelectorAll("#diagnostics > li")) {
+ const entry = {};
+ entry["title"] = entryElem.querySelector("h2").textContent;
+ for (const facet_node of entryElem.querySelector("dl").children) {
+ const facet = facet_node.firstChild.textContent;
+ const value = facet_node.lastChild.textContent;
+ entry[facet] = value;
+ }
+
+ // Show entries matching filter and hide entries that don't.
+ if (condition.test(entry)) {
+ entryElem.classList.remove("hidden");
+ } else {
+ entryElem.classList.add("hidden");
+ }
+ }
+}
+
+// For performance don't search on every keystroke immediately. Instead wait
+// until quarter of a second of no typing has elapsed. To maximised perceived
+// responsiveness immediately perform the search if a space is typed, as that
+// indicates a completed search term.
+function debounce(event) {
+ clearTimeout(searchTimeoutID);
+ if (event.data === " ") {
+ doSearch();
+ } else {
+ searchTimeoutID = setTimeout(doSearch, 250);
+ }
+}
+
+// Diagnostic filtering searchbar.
+function setup_search() {
+ const search = document.getElementById("filter-query");
+ search.addEventListener("input", debounce);
+ // Trigger search immediately when input is unfocused.
+ search.addEventListener("change", doSearch);
+ // Do initial search if we already have a query specified in the URL.
+ const params = new URLSearchParams(document.location.search);
+ const initial_query = params.get("q");
+ if (initial_query) {
+ search.value = initial_query;
+ doSearch();
+ }
+}
+
// Run everything.
setup_description_toggle_button();
setup_clear_view_button();
+setup_clear_search_button();
setup_plots_sidebar();
+setup_search();
diff --git a/src/CSET/cset_workflow/app/finish_website/file/html/style.css b/src/CSET/cset_workflow/app/finish_website/file/html/style.css
index f8e929844..f1195700c 100644
--- a/src/CSET/cset_workflow/app/finish_website/file/html/style.css
+++ b/src/CSET/cset_workflow/app/finish_website/file/html/style.css
@@ -1,3 +1,16 @@
+/* Colour definitions. */
+:root {
+ --primary-fg-colour: black;
+ --primary-bg-colour: white;
+ --secondary-fg-colour: oklab(42% 0 0);
+ --secondary-bg-colour: oklab(97% 0 0);
+ --alternate-bg-colour: oklab(90% 0 0);
+ --invalid-colour: red;
+ --hover-fg-colour: white;
+ --hover-bg-colour: oklab(22% 0 0);
+ --shadow-colour: darkgrey;
+}
+
/* Inherit fonts for inputs and buttons */
input,
button,
@@ -20,83 +33,147 @@ a:hover {
text-decoration-thickness: max(3px, 0.12em);
}
-nav > header {
- margin: 8px;
-}
-
-nav > header > h1 {
- font-size: xx-large;
- margin: 0;
-}
-
-nav > header > button {
- margin: 4px 0;
-}
-
nav {
- display: inline;
float: left;
- width: 15em;
+ width: 35em;
height: 100%;
- overflow-x: hidden;
overflow-y: scroll;
- background-color: whitesmoke;
- border-right: 1px solid black;
-}
+ background-color: var(--secondary-bg-colour);
+ border-right: 1px solid var(--secondary-fg-colour);
-nav summary {
- font-weight: bold;
-}
+ > header {
+ position: sticky;
+ top: 0;
+ background-color: var(--secondary-bg-colour);
-nav menu {
- list-style: none;
- padding: 0;
- margin: 0;
-}
+ > div {
+ padding: 0 8px;
+ display: flex;
+ justify-content: space-between;
-nav details {
- margin: 8px;
-}
+ > h1 {
+ font-size: xx-large;
+ margin: 0;
+ }
+ }
-nav menu > li {
- background-color: lightgrey;
- margin: 8px 0;
- padding: 0 4px;
- border: 1px solid black;
- font-size: small;
- overflow-wrap: break-word;
-}
+ > search {
+ padding: 8px;
+ box-shadow: 0 4px 4px var(--shadow-colour);
-.plot-position-chooser {
- display: flex;
- margin-bottom: 0.25em;
- font-size: medium;
-}
+ > div {
+ max-height: 66vh;
+ overflow-y: auto;
+ }
-.plot-position-chooser button {
- margin: 0 4px;
- cursor: pointer;
- width: 33%;
-}
+ dt {
+ font-family: monospace;
+ }
-.plot-position-chooser button.left {
- background-color: #b8dcfd;
- border-top-left-radius: 25% 50%;
- border-bottom-left-radius: 25% 50%;
-}
-.plot-position-chooser button.full {
- background-color: #c5e836;
-}
-.plot-position-chooser button.right {
- background-color: #fdcabb;
- border-top-right-radius: 25% 50%;
- border-bottom-right-radius: 25% 50%;
-}
+ #filter-query {
+ width: 100%;
+ padding: 0.5em 1em;
+ border-radius: 3em;
+
+ &:invalid {
+ box-shadow: 0px 0px 4px 4px var(--invalid-colour);
+ }
+ }
+
+ #filter-facets div {
+ padding: 4px;
+ &:nth-child(even) {
+ background-color: var(--alternate-bg-colour);
+ }
+ label {
+ display: inline-block;
+ width: 50%;
+ }
+ select {
+ width: 50%;
+ }
+ }
+ }
+ }
+
+ > ul {
+ list-style: none;
+ padding: 0;
+ margin: 0;
-.plot-position-chooser button:focus,
-.plot-position-chooser button:hover {
- background-color: #1a1a1a;
- color: white;
+ > li {
+ padding: 5px 4px 3px;
+ overflow-wrap: break-word;
+ box-shadow: inset 0 4px 4px var(--shadow-colour);
+
+ &:nth-child(odd) {
+ background-color: var(--alternate-bg-colour);
+ }
+
+ > h2 {
+ margin: 0;
+ font-size: medium;
+ }
+
+ > dl {
+ font-size: small;
+ margin: 4px 0;
+ columns: 16em auto;
+
+ dt {
+ display: inline;
+ }
+
+ dt:after {
+ content: ": ";
+ }
+
+ dd {
+ display: inline;
+ margin-left: 0;
+ }
+ }
+
+ .plot-position-chooser {
+ display: flex;
+ margin: 4px auto;
+ font-size: medium;
+
+ button {
+ margin: 0 4px;
+ cursor: pointer;
+ width: 33%;
+
+ &.left {
+ background-color: #b8dcfd;
+ border-top-left-radius: 3em;
+ border-bottom-left-radius: 3em;
+ }
+
+ &.full {
+ background-color: #cae387;
+ }
+
+ &.right {
+ background-color: #fdcabb;
+ border-top-right-radius: 3em;
+ border-bottom-right-radius: 3em;
+ }
+
+ &.popup {
+ background-color: #e2dd92;
+ border-radius: 3em;
+ }
+
+ &:focus,
+ &:hover {
+ background-color: var(--hover-bg-colour);
+ color: var(--hover-fg-colour);
+ }
+ }
+ }
+ }
+ }
}
.vsplit {
@@ -118,7 +195,7 @@ nav menu > li {
.websplit-container {
display: flex;
flex-direction: column;
- outline: solid 1px black;
+ outline: solid 1px var(--primary-fg-colour);
}
main {
@@ -139,3 +216,51 @@ main article > iframe {
.hidden {
display: none;
}
+
+/* Loading throbber from https://cssloaders.github.io/
+
+MIT License
+
+Copyright (c) 2020 Vineeth.TR
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+*/
+loading-throbber {
+ display: block;
+ margin: 48px auto;
+ width: 48px;
+ height: 48px;
+ border: 5px solid var(--secondary-fg-colour);
+ border-bottom-color: transparent;
+ border-radius: 50%;
+ box-sizing: border-box;
+ animation: rotation 1s linear infinite;
+}
+
+@keyframes rotation {
+ 0% {
+ transform: rotate(0deg);
+ }
+
+ 100% {
+ transform: rotate(360deg);
+ }
+}
+
+/* End loading throbber. */
diff --git a/src/CSET/recipes/__init__.py b/src/CSET/recipes/__init__.py
index 3c92d83fb..b459aa020 100644
--- a/src/CSET/recipes/__init__.py
+++ b/src/CSET/recipes/__init__.py
@@ -214,9 +214,15 @@ def parbake(self, ROSE_DATAC: Path, SHARE_DIR: Path) -> None:
# Add input paths to recipe variables.
self.variables["INPUT_PATHS"] = data_dirs
- # Parbake this recipe, saving into recipe_dir.
+ # Parbake this recipe.
recipe = parse_recipe(Path(self.recipe), self.variables)
+ # Add variables as extra metadata to filter on.
+ for key, value in self.variables.items():
+ # Don't overwrite existing keys.
+ if key != "INPUT_PATHS" and key not in recipe:
+ recipe[key] = value
+
# Serialise into memory, as we use the serialised value twice.
with StringIO() as s:
with YAML(pure=True, output=s) as yaml:
@@ -226,6 +232,7 @@ def parbake(self, ROSE_DATAC: Path, SHARE_DIR: Path) -> None:
# with the same title.
digest = hashlib.sha256(serialised_recipe).hexdigest()
output_filename = recipe_dir / f"{slugify(recipe['title'])}_{digest[:12]}.yaml"
+ # Save into recipe_dir.
with open(output_filename, "wb") as fp:
fp.write(serialised_recipe)
diff --git a/tests/test_data/index.jsonl b/tests/test_data/index.jsonl
new file mode 100644
index 000000000..754193dbc
--- /dev/null
+++ b/tests/test_data/index.jsonl
@@ -0,0 +1,4 @@
+{"MODEL_NAME":"Model A","VARNAME":"temperature_at_screen_level","case_date":"20230101T0000Z","category":"Surface Spatial Plot","path":"20230101T0000Z/model_a_temperature_at_screen_level_74cc6d9af34a","title":"Model A temperature_at_screen_level "}
+{"MODEL_NAME":"Model B","VARNAME":"temperature_at_screen_level","case_date":"20230101T0000Z","category":"Surface Spatial Plot","path":"20230101T0000Z/model_b_temperature_at_screen_level_f0a7d87a13fd","title":"Model B temperature_at_screen_level "}
+{"MODEL_NAME":"Model A","VARNAME":"temperature_at_screen_level","case_date":"20231231T0000Z","category":"Surface Spatial Plot","path":"20231231T0000Z/model_a_temperature_at_screen_level_74cc6d9af34a","title":"Model A temperature_at_screen_level "}
+{"MODEL_NAME":"Model B","VARNAME":"temperature_at_screen_level","case_date":"20231231T0000Z","category":"Surface Spatial Plot","path":"20231231T0000Z/model_b_temperature_at_screen_level_f0a7d87a13fd","title":"Model B temperature_at_screen_level ","unique":"foo"}
diff --git a/tests/test_recipes.py b/tests/test_recipes.py
index ab263a1cf..fa10ebc07 100644
--- a/tests/test_recipes.py
+++ b/tests/test_recipes.py
@@ -216,6 +216,7 @@ def test_RawRecipe_parbake(tmp_working_dir):
- operator: misc.noop
paths:
- {rose_datac / "data/1"}
+ VAR: value
"""
)
# Act.
@@ -257,6 +258,7 @@ def test_RawRecipe_parbake_aggregation(tmp_working_dir):
- {tmp_working_dir / "cycle/*/data/1"}
- {tmp_working_dir / "cycle/*/data/2"}
- {tmp_working_dir / "cycle/*/data/3"}
+ VAR: value
"""
)
# Act.
diff --git a/tests/test_web_interface.py b/tests/test_web_interface.py
new file mode 100644
index 000000000..f1a6e9d4c
--- /dev/null
+++ b/tests/test_web_interface.py
@@ -0,0 +1,106 @@
+# © Crown copyright, Met Office (2022-2026) and CSET contributors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Playwright browser tests for the CSET web interface."""
+
+import http.server
+import shutil
+import tempfile
+import threading
+from pathlib import Path
+
+import pytest
+from playwright.sync_api import Page, expect
+
+
+@pytest.fixture(scope="session")
+def webserver():
+ """Run a simple webserver serving static files. Its URL is returned."""
+ # Prepare the static files in a temporary directory.
+ tmp_path = Path(tempfile.mkdtemp())
+ shutil.copytree(
+ "src/CSET/cset_workflow/app/finish_website/file/html",
+ tmp_path,
+ dirs_exist_ok=True,
+ )
+ plot_dir = tmp_path / "plots-CACHEBUSTER"
+ plot_dir.mkdir(exist_ok=True)
+ shutil.copy("tests/test_data/index.jsonl", plot_dir)
+
+ class HTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
+ """Serve files from the temporary directory."""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs, directory=tmp_path)
+
+ # Try ports until we find one, up to 100 tries.
+ port = 8000
+ webserver = None
+ while not webserver and port < 8100:
+ try:
+ address = ("localhost", port)
+ webserver = http.server.ThreadingHTTPServer(address, HTTPRequestHandler)
+ break
+ except OSError:
+ port += 1
+ if not webserver:
+ raise OSError("No available ports in range 8000-8099.")
+
+ # Start server in a separate thread.
+ thread = threading.Thread(target=webserver.serve_forever, daemon=True)
+ thread.start()
+
+ # Return the localhost URL to the webserver.
+ yield f"http://localhost:{port}/"
+
+ # Shutdown the webserver once we return here.
+ webserver.shutdown()
+
+
+@pytest.mark.slow
+def test_filter_title(page: Page, webserver: str):
+ """Check that you can filter on title with the search box."""
+ page.goto(webserver)
+
+ # Check we can find the search box.
+ expect(page.get_by_role("searchbox")).to_be_visible()
+
+ # Filter diagnostics.
+ page.get_by_role("searchbox").click()
+ page.get_by_role("searchbox").fill('"Model A"')
+
+ # Test the diagnostics correctly filtered. Check for Model B first so we
+ # know it has finished doing the search.
+ expect(page.get_by_role("heading", name="Model B").first).not_to_be_visible()
+ expect(page.get_by_role("heading", name="Model A").first).to_be_visible()
+
+ # Test clearing the filter.
+ page.get_by_role("button", name="⌫ Clear search").click()
+ expect(page.get_by_role("heading", name="Model A").first).to_be_visible()
+ expect(page.get_by_role("heading", name="Model B").first).to_be_visible()
+
+
+@pytest.mark.slow
+def test_facet_dropdown(page: Page, webserver: str):
+ """Test filtering diagnostics via the facet dropdowns."""
+ page.goto(webserver)
+
+ # Open the facets panel, then select a value for a facet.
+ page.get_by_text("Search facet dropdowns").click()
+ page.get_by_label("unique").select_option("foo")
+
+ # Check we filtered correctly.
+ expect(page.get_by_role("listitem")).to_contain_text("uniquefoo")
+ # Line duplicated so we don't immediately close the browser during the demo.
+ expect(page.get_by_role("listitem")).to_contain_text("uniquefoo")
diff --git a/tests/workflow_utils/test_finish_website.py b/tests/workflow_utils/test_finish_website.py
index eb2e29b5d..6187b24ed 100644
--- a/tests/workflow_utils/test_finish_website.py
+++ b/tests/workflow_utils/test_finish_website.py
@@ -79,9 +79,8 @@ def test_write_workflow_status(tmp_path):
assert re.search(pattern, content)
-def test_construct_index(monkeypatch, tmp_path):
+def test_construct_index(tmp_path):
"""Test putting the index together."""
- monkeypatch.setenv("CYLC_WORKFLOW_SHARE_DIR", str(tmp_path))
plots_dir = tmp_path / "web/plots"
plots_dir.mkdir(parents=True)
@@ -102,17 +101,19 @@ def test_construct_index(monkeypatch, tmp_path):
finish_website.construct_index(plots_dir.parent)
# Check index.
- index_file = plots_dir / "index.json"
+ index_file = plots_dir / "index.jsonl"
assert index_file.is_file()
with open(index_file, "rt", encoding="UTF-8") as fp:
- index = json.load(fp)
- expected = {"Category": {"20250101": {"p1": "P1", "p2": "P2"}}}
+ index = fp.read()
+ expected = (
+ '{"case_date":"20250101","category":"Category","path":"p1","title":"P1"}\n'
+ '{"case_date":"20250101","category":"Category","path":"p2","title":"P2"}\n'
+ )
assert index == expected
-def test_construct_index_aggregation_case(monkeypatch, tmp_path):
+def test_construct_index_aggregation_case(tmp_path):
"""Construct the index from a diagnostics without a case date."""
- monkeypatch.setenv("CYLC_WORKFLOW_SHARE_DIR", str(tmp_path))
plots_dir = tmp_path / "web/plots"
plots_dir.mkdir(parents=True)
@@ -125,17 +126,40 @@ def test_construct_index_aggregation_case(monkeypatch, tmp_path):
finish_website.construct_index(plots_dir.parent)
# Check index.
- index_file = plots_dir / "index.json"
+ index_file = plots_dir / "index.jsonl"
assert index_file.is_file()
with open(index_file, "rt", encoding="UTF-8") as fp:
index = json.load(fp)
- expected = {"Category": {"Aggregation": {"p1": "P1"}}}
+ expected = {"category": "Category", "path": "p1", "title": "P1"}
assert index == expected
-def test_construct_index_invalid(monkeypatch, tmp_path, caplog):
+def test_construct_index_remove_keys(tmp_path):
+ """Unneeded keys are removed from the index."""
+ plots_dir = tmp_path / "web/plots"
+ plots_dir.mkdir(parents=True)
+
+ # Plot directories.
+ plot1 = plots_dir / "p1/meta.json"
+ plot1.parent.mkdir()
+ plot1.write_text(
+ '{"category": "Category", "title": "P1", "case_date": "20250101", "plots": ["a.png"], "description": "Foo"}'
+ )
+
+ # Construct index.
+ finish_website.construct_index(plots_dir.parent)
+
+ # Check index.
+ index_file = plots_dir / "index.jsonl"
+ assert index_file.is_file()
+ with open(index_file, "rt", encoding="UTF-8") as fp:
+ index = json.loads(fp.readline())
+ assert "plots" not in index
+ assert "description" not in index
+
+
+def test_construct_index_invalid(tmp_path, caplog):
"""Test constructing index when metadata is invalid."""
- monkeypatch.setenv("CYLC_WORKFLOW_SHARE_DIR", str(tmp_path))
plots_dir = tmp_path / "web/plots"
plots_dir.mkdir(parents=True)
@@ -152,12 +176,9 @@ def test_construct_index_invalid(monkeypatch, tmp_path, caplog):
assert level == logging.ERROR
assert "p1/meta.json is invalid, skipping." in message
- index_file = plots_dir / "index.json"
+ index_file = plots_dir / "index.jsonl"
assert index_file.is_file()
- with open(index_file, "rt", encoding="UTF-8") as fp:
- index = json.load(fp)
- expected = {}
- assert index == expected
+ assert index_file.stat().st_size == 0
def test_entrypoint(monkeypatch):