|
| 1 | +import { expect, test, type Page } from "@playwright/test"; |
| 2 | +import { waitForMap } from "./helpers"; |
| 3 | + |
| 4 | +// A Zarr store is a directory read key by key, so only a run through the real panel shows that the |
| 5 | +// store URL, the chosen variable and the reader's own requests line up. |
| 6 | +const API = "https://api.stac.test/v1"; |
| 7 | +const STORE = "https://store.stac.test/mini.zarr"; |
| 8 | + |
| 9 | +/** |
| 10 | + * A Zarr v2 store, built here rather than committed: it is a directory of a dozen tiny files, and |
| 11 | + * every byte of it is derivable from the shape below. |
| 12 | + */ |
| 13 | +const N = 8; |
| 14 | +const zarray = (shape: number[]) => |
| 15 | + JSON.stringify({ |
| 16 | + chunks: shape, |
| 17 | + compressor: null, |
| 18 | + dtype: "<f4", |
| 19 | + fill_value: 0, |
| 20 | + filters: null, |
| 21 | + order: "C", |
| 22 | + shape, |
| 23 | + zarr_format: 2, |
| 24 | + }); |
| 25 | +const zattrs = (dimensions: string[], unit?: string) => |
| 26 | + JSON.stringify({ _ARRAY_DIMENSIONS: dimensions, ...(unit ? { units: unit } : {}) }); |
| 27 | + |
| 28 | +const STORE_METADATA: Record<string, string> = { |
| 29 | + ".zgroup": JSON.stringify({ zarr_format: 2 }), |
| 30 | + "lat/.zarray": zarray([N]), |
| 31 | + "lat/.zattrs": zattrs(["lat"]), |
| 32 | + "lon/.zarray": zarray([N]), |
| 33 | + "lon/.zattrs": zattrs(["lon"]), |
| 34 | + "temperature/.zarray": zarray([N, N]), |
| 35 | + "temperature/.zattrs": zattrs(["lat", "lon"], "degC"), |
| 36 | + "precipitation/.zarray": zarray([N, N]), |
| 37 | + "precipitation/.zattrs": zattrs(["lat", "lon"], "mm"), |
| 38 | +}; |
| 39 | +STORE_METADATA[".zmetadata"] = JSON.stringify({ |
| 40 | + metadata: Object.fromEntries( |
| 41 | + Object.entries(STORE_METADATA).map(([key, value]) => [key, JSON.parse(value)]), |
| 42 | + ), |
| 43 | + zarr_consolidated_format: 1, |
| 44 | +}); |
| 45 | + |
| 46 | +/** A chunk of `count` float32 values, ascending so the array is not uniformly the fill value. */ |
| 47 | +function chunk(count: number): Buffer { |
| 48 | + const values = Float32Array.from({ length: count }, (_, index) => index); |
| 49 | + return Buffer.from(values.buffer); |
| 50 | +} |
| 51 | + |
| 52 | +const STORE_CHUNKS: Record<string, Buffer> = { |
| 53 | + "lat/0": chunk(N), |
| 54 | + "lon/0": chunk(N), |
| 55 | + "temperature/0.0": chunk(N * N), |
| 56 | + "precipitation/0.0": chunk(N * N), |
| 57 | +}; |
| 58 | + |
| 59 | +const COLLECTIONS = [ |
| 60 | + { id: "cube", title: "Demo cubes", extent: { spatial: { bbox: [[-114, 37, -109, 42]] } } }, |
| 61 | +]; |
| 62 | + |
| 63 | +function item(): Record<string, unknown> { |
| 64 | + return { |
| 65 | + type: "Feature", |
| 66 | + stac_version: "1.0.0", |
| 67 | + id: "cube-1", |
| 68 | + collection: "cube", |
| 69 | + stac_extensions: ["https://stac-extensions.github.io/datacube/v2.2.0/schema.json"], |
| 70 | + bbox: [-114, 37, -109, 42], |
| 71 | + geometry: { |
| 72 | + type: "Polygon", |
| 73 | + coordinates: [ |
| 74 | + [ |
| 75 | + [-114, 37], |
| 76 | + [-109, 37], |
| 77 | + [-109, 42], |
| 78 | + [-114, 42], |
| 79 | + [-114, 37], |
| 80 | + ], |
| 81 | + ], |
| 82 | + }, |
| 83 | + properties: { |
| 84 | + datetime: "2024-05-01T00:00:00Z", |
| 85 | + "cube:dimensions": { |
| 86 | + lat: { type: "spatial", axis: "y" }, |
| 87 | + lon: { type: "spatial", axis: "x" }, |
| 88 | + }, |
| 89 | + "cube:variables": { |
| 90 | + // Spans no two spatial dimensions, so it must not be offered as something to draw. |
| 91 | + lat_bounds: { dimensions: ["lat"], type: "data" }, |
| 92 | + temperature: { dimensions: ["lat", "lon"], type: "data", unit: "degC" }, |
| 93 | + precipitation: { dimensions: ["lat", "lon"], type: "data", unit: "mm" }, |
| 94 | + }, |
| 95 | + }, |
| 96 | + assets: { data: { href: STORE, type: "application/vnd+zarr", title: "Demo cube" } }, |
| 97 | + links: [], |
| 98 | + }; |
| 99 | +} |
| 100 | + |
| 101 | +async function serveApi(page: Page): Promise<void> { |
| 102 | + await page.route("https://api.stac.test/**", async (route) => { |
| 103 | + const url = route.request().url(); |
| 104 | + const json = (body: unknown) => |
| 105 | + route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) }); |
| 106 | + |
| 107 | + if (url.endsWith("/collections")) return json({ collections: COLLECTIONS }); |
| 108 | + if (url.includes("/search")) { |
| 109 | + return json({ type: "FeatureCollection", features: [item()], numberMatched: 1, links: [] }); |
| 110 | + } |
| 111 | + return json({ |
| 112 | + type: "Catalog", |
| 113 | + id: "api", |
| 114 | + title: "E2E STAC API", |
| 115 | + conformsTo: [ |
| 116 | + "https://api.stacspec.org/v1.0.0/core", |
| 117 | + "https://api.stacspec.org/v1.0.0/item-search", |
| 118 | + ], |
| 119 | + links: [ |
| 120 | + { rel: "data", href: `${API}/collections` }, |
| 121 | + { rel: "search", href: `${API}/search`, method: "POST" }, |
| 122 | + ], |
| 123 | + }); |
| 124 | + }); |
| 125 | +} |
| 126 | + |
| 127 | +/** Serves the store key by key, so the reader's own requests decide whether the layer loads. */ |
| 128 | +async function serveStore(page: Page, reads: string[]): Promise<void> { |
| 129 | + await page.route("https://store.stac.test/**", async (route) => { |
| 130 | + const key = new URL(route.request().url()).pathname.replace(/^\/mini\.zarr\/?/, ""); |
| 131 | + reads.push(key); |
| 132 | + if (STORE_METADATA[key]) { |
| 133 | + return route.fulfill({ |
| 134 | + status: 200, |
| 135 | + contentType: "application/json", |
| 136 | + body: STORE_METADATA[key], |
| 137 | + }); |
| 138 | + } |
| 139 | + if (STORE_CHUNKS[key]) return route.fulfill({ status: 200, body: STORE_CHUNKS[key] }); |
| 140 | + // A store is probed for keys it need not have (`zarr.json` on a v2 store, say). |
| 141 | + return route.fulfill({ status: 404, body: "" }); |
| 142 | + }); |
| 143 | +} |
| 144 | + |
| 145 | +test("a Zarr asset from a STAC item reaches the map as the chosen variable", async ({ page }) => { |
| 146 | + const reads: string[] = []; |
| 147 | + await serveApi(page); |
| 148 | + await serveStore(page, reads); |
| 149 | + await waitForMap(page); |
| 150 | + |
| 151 | + await page.getByRole("button", { name: "Plugins", exact: true }).click(); |
| 152 | + await page.getByRole("menuitem", { name: "Web Services" }).click(); |
| 153 | + await page.getByRole("menuitem", { name: "STAC Catalogs" }).click(); |
| 154 | + await page.getByPlaceholder("https://example.org/stac/").fill(API); |
| 155 | + await page.getByRole("button", { name: "Connect", exact: true }).click(); |
| 156 | + |
| 157 | + await page.getByLabel("Limit search to the current map extent").uncheck(); |
| 158 | + const collection = page.getByRole("option", { name: "Demo cubes" }); |
| 159 | + await collection.click(); |
| 160 | + await collection.dblclick(); |
| 161 | + await expect(page.getByText(/Showing \d+ of \d+ items\./)).toBeVisible(); |
| 162 | + |
| 163 | + // The asset names its format, and the store's drawable arrays are offered — bounds excluded. |
| 164 | + await expect(page.getByRole("combobox").filter({ hasText: "Demo cube — Zarr" })).toBeVisible(); |
| 165 | + const targets = page.getByRole("combobox").filter({ hasText: "temperature (degC)" }); |
| 166 | + await expect(targets).toBeVisible(); |
| 167 | + await expect(targets.getByRole("option")).toHaveText([ |
| 168 | + "temperature (degC)", |
| 169 | + "precipitation (mm)", |
| 170 | + ]); |
| 171 | + |
| 172 | + await targets.selectOption({ label: "precipitation (mm)" }); |
| 173 | + const add = page.getByRole("button", { name: "Add", exact: true }).first(); |
| 174 | + await expect(add).toBeEnabled(); |
| 175 | + await add.click(); |
| 176 | + |
| 177 | + // Named for the item, its asset and the variable actually drawn. Scoped to the panel because |
| 178 | + // the name also renders in the on-map layer control. |
| 179 | + const layers = page.getByRole("complementary", { name: "Layers" }); |
| 180 | + await expect(layers.getByText("cube-1 — Demo cube — precipitation")).toBeVisible(); |
| 181 | + await expect(page.getByText("Added Demo cube to the map.")).toBeVisible(); |
| 182 | + |
| 183 | + // The reader was pointed at the store and read its metadata from there. Chunk reads are left |
| 184 | + // out on purpose: those only happen once deck.gl paints, which headless WebGL may never do. |
| 185 | + expect(reads).toContain(".zmetadata"); |
| 186 | +}); |
0 commit comments