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):