-
-
Notifications
You must be signed in to change notification settings - Fork 640
feat(stac): browse a static catalog as a tree and search from what you pick #1945
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 8 commits
Commits
Show all changes
20 commits
Select commit
Hold shift + click to select a range
8cb030d
feat(stac): browse a static catalog as a tree and search from what yo…
clintonlunn 1592b27
test(stac): cover the panel wiring, the extent lookup and the entries…
clintonlunn 5f709b9
style: auto-format (ruff + oxfmt) [pre-commit.ci]
pre-commit-ci[bot] 015b3ee
fix(stac): refuse a bounding box with no middle, and flatten every on…
clintonlunn 22f8bae
fix(stac): name the group a tree row opens and how deep the row sits
clintonlunn 41c0cdd
test(stac): pin the extent a search flies to, modifier clicks, and fo…
clintonlunn 31eaf2c
fix(stac): drop a collection extent that lands after the user has ask…
clintonlunn d11f860
fix(stac): keep the arrows working while a modifier is held
clintonlunn c520115
fix(stac): let a collection that holds collections be closed and open…
clintonlunn f60cbbc
Merge branch 'main' into feat/stac-catalog-tree
clintonlunn 73b0e4c
fix(stac): search a catalog that carries its own items, and name a fi…
clintonlunn 6d2da70
fix(stac): open a folder with the arrows without changing what is chosen
clintonlunn 8117eb5
fix(stac): search a catalog that holds sub-catalogs and items of its own
clintonlunn dd2b3b1
fix(stac): read a collection when it is chosen, and let a node with n…
clintonlunn 8b2ff05
fix(stac): drop a request for items the user has since asked to leave
clintonlunn 2854f2d
fix(stac): collapse a chosen collection without letting go of it
clintonlunn 0236bb3
fix(stac): only take a search generation when a search actually runs
clintonlunn c7c14f9
fix(stac): keep a stale search from reporting its failure over the cu…
clintonlunn 1c459f3
refactor(stac): cancel the reading a catalog switch makes stale inste…
clintonlunn 89625f9
perf(stac): stop a search's walk when another search replaces it
clintonlunn File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,172 @@ | ||
| import { expect, test, type Page } from "@playwright/test"; | ||
| import { waitForMap } from "./helpers"; | ||
|
|
||
| // `maplibre-stac.ts` builds its panel by hand and exports nothing to call, so the wiring between | ||
| // the catalog tree, the collection list and the search only exists here. An API is the half that | ||
| // has no tree at all: it answers item search itself, and offering a tree of its hierarchy would | ||
| // promise a way in that its endpoint does not honour. | ||
| const API = "https://api.stac.test/v1"; | ||
|
|
||
| const COLLECTIONS = [ | ||
| { id: "sentinel-2", title: "Sentinel-2 L2A", extent: { spatial: { bbox: [[4, 50, 6, 52]] } } }, | ||
| { id: "landsat-9", title: "Landsat 9", extent: { spatial: { bbox: [[-114, 37, -109, 42]] } } }, | ||
| ]; | ||
|
|
||
| function item(id: string, collection: string): Record<string, unknown> { | ||
| return { | ||
| type: "Feature", | ||
| stac_version: "1.0.0", | ||
| id, | ||
| collection, | ||
| bbox: [4, 50, 6, 52], | ||
| geometry: { | ||
| type: "Polygon", | ||
| coordinates: [ | ||
| [ | ||
| [4, 50], | ||
| [6, 50], | ||
| [6, 52], | ||
| [4, 52], | ||
| [4, 50], | ||
| ], | ||
| ], | ||
| }, | ||
| properties: { datetime: "2024-05-01T00:00:00Z" }, | ||
| assets: {}, | ||
| links: [], | ||
| }; | ||
| } | ||
|
|
||
| /** A STAC API that answers item search, and a hierarchy underneath it that must not be offered. */ | ||
| async function serveApi(page: Page, searches: string[]): Promise<void> { | ||
| await page.route("https://api.stac.test/**", async (route) => { | ||
| const request = route.request(); | ||
| const url = request.url(); | ||
| const json = (body: unknown) => | ||
| route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) }); | ||
|
|
||
| if (url.endsWith("/collections")) return json({ collections: COLLECTIONS }); | ||
| if (url.includes("/search")) { | ||
| const asked = request.postDataJSON() ?? {}; | ||
| searches.push(JSON.stringify(asked.collections ?? [])); | ||
| const collection = asked.collections?.[0] ?? "sentinel-2"; | ||
| return json({ | ||
| type: "FeatureCollection", | ||
| features: [item(`${collection}-1`, collection), item(`${collection}-2`, collection)], | ||
| numberMatched: 2, | ||
| links: [], | ||
| }); | ||
| } | ||
| return json({ | ||
| type: "Catalog", | ||
| id: "api", | ||
| title: "E2E STAC API", | ||
| conformsTo: [ | ||
| "https://api.stacspec.org/v1.0.0/core", | ||
| "https://api.stacspec.org/v1.0.0/item-search", | ||
| ], | ||
| links: [ | ||
| { rel: "data", href: `${API}/collections` }, | ||
| { rel: "search", href: `${API}/search`, method: "POST" }, | ||
| // An API may also advertise a hierarchy. It is not a way in: only the endpoint is. | ||
| { rel: "child", href: `${API}/providers/ESA`, title: "ESA" }, | ||
| ], | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| async function connect(page: Page, url: string): Promise<void> { | ||
| await page.getByRole("button", { name: "Plugins", exact: true }).click(); | ||
| await page.getByRole("menuitem", { name: "Web Services" }).click(); | ||
| await page.getByRole("menuitem", { name: "STAC Catalogs" }).click(); | ||
| await page.getByPlaceholder("https://example.org/stac/").fill(url); | ||
| await page.getByRole("button", { name: "Connect", exact: true }).click(); | ||
| } | ||
|
|
||
| test("an API is offered as a collection list, never as a tree", async ({ page }) => { | ||
| const searches: string[] = []; | ||
| await serveApi(page, searches); | ||
| await waitForMap(page); | ||
| await connect(page, API); | ||
|
|
||
| await expect(page.getByText("E2E STAC API")).toBeVisible(); | ||
| const list = page.getByRole("listbox"); | ||
| await expect(list).toBeVisible(); | ||
| await expect(list.getByRole("option", { name: "Sentinel-2 L2A" })).toBeVisible(); | ||
|
|
||
| // The catalog advertises a child link; offering it would hand the user a branch this panel | ||
| // cannot search, because the branch answers on its own endpoint rather than this one. | ||
| await expect(page.getByRole("tree")).toBeHidden(); | ||
| await expect(page.getByRole("treeitem")).toHaveCount(0); | ||
| }); | ||
|
|
||
| test("double-clicking a collection in the list searches it and moves the map", async ({ page }) => { | ||
| const searches: string[] = []; | ||
| await serveApi(page, searches); | ||
| await waitForMap(page); | ||
| await connect(page, API); | ||
|
|
||
| await page.getByLabel("Limit search to the current map extent").uncheck(); | ||
| // The status bar's own reading of where the map is, so this asserts the view moved rather than | ||
| // that some text somewhere changed. | ||
| const view = async (): Promise<string> => { | ||
| const text = (await page.locator("footer, [class*=status]").first().textContent()) ?? ""; | ||
| return /BBox:[^A-Z]*/.exec(text)?.[0] ?? ""; | ||
| }; | ||
| const bounds = async (): Promise<number[]> => { | ||
| const text = (await page.locator("footer, [class*=status]").first().textContent()) ?? ""; | ||
| const found = /BBox: (-?[\d.]+), (-?[\d.]+), (-?[\d.]+), (-?[\d.]+)/.exec(text); | ||
| return found ? found.slice(1, 5).map(Number) : []; | ||
| }; | ||
| const before = await view(); | ||
| const landsat = page.getByRole("option", { name: "Landsat 9" }); | ||
| await landsat.click(); | ||
| await landsat.dblclick(); | ||
|
|
||
| // The same gesture as in the tree, and it must reach the search on its own rather than leaving | ||
| // the user to find the button. | ||
| await expect(page.getByText(/Showing \d+ of \d+ items\./)).toBeVisible(); | ||
| expect(searches.at(-1)).toBe(JSON.stringify(["landsat-9"])); | ||
|
|
||
| await expect.poll(async () => await view(), { timeout: 10_000 }).not.toBe(before); | ||
|
|
||
| // Landsat's extent, not the items': the fixture returns items over Belgium precisely so a fit | ||
| // to the results would fail this. | ||
| const [west, south, east, north] = await bounds(); | ||
| expect(west).toBeLessThanOrEqual(-114); | ||
| expect(east).toBeGreaterThanOrEqual(-109); | ||
| expect(south).toBeLessThanOrEqual(37); | ||
| expect(north).toBeGreaterThanOrEqual(42); | ||
| expect(east - west).toBeLessThan(60); | ||
| }); | ||
|
|
||
| test("connecting to an API after a static catalog clears the tree", async ({ page }) => { | ||
| const searches: string[] = []; | ||
| await serveApi(page, searches); | ||
| await page.route("https://static.stac.test/**", async (route) => | ||
| route.fulfill({ | ||
| status: 200, | ||
| contentType: "application/json", | ||
| body: JSON.stringify({ | ||
| type: "Catalog", | ||
| id: "static", | ||
| title: "E2E Static", | ||
| links: [{ rel: "child", href: "./hazards/collection.json", title: "Hazards" }], | ||
| }), | ||
| }), | ||
| ); | ||
| await waitForMap(page); | ||
| await connect(page, "https://static.stac.test/catalog.json"); | ||
|
|
||
| const hazards = page.getByRole("treeitem", { name: "Hazards" }); | ||
| await expect(hazards).toBeVisible(); | ||
| await hazards.click(); | ||
| await expect(hazards).toHaveAttribute("aria-selected", "true"); | ||
|
|
||
| // A catalog the user has left must not leave its rows, or its selection, behind. | ||
| await page.getByPlaceholder("https://example.org/stac/").fill(API); | ||
| await page.getByRole("button", { name: "Connect", exact: true }).click(); | ||
| await expect(page.getByText("E2E STAC API")).toBeVisible(); | ||
| await expect(page.getByRole("tree")).toBeHidden(); | ||
| await expect(page.getByRole("treeitem")).toHaveCount(0); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.