Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions pages/workspace/[id]/edit.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
// @test e2e: while the editor is loading, the page shows a loading spinner over the editor area; it goes away once the editor has started
// @test e2e: leaving this page and coming back while the editor is still starting shows the spinner again until it has started
// @test e2e: if the editor fails to start, the page says so in place of the spinner
// @test e2e: opening this page with no map location in the URL starts the editor at the extent of the workspace's data; a location in the URL is kept, and an empty workspace keeps the editor's default
// @test e2e: validate that all the API calls used on this page match the Swagger spec (https://api.workspaces-stage.sidewalks.washington.edu/openapi.json)

<template>
Expand Down Expand Up @@ -41,7 +42,8 @@
</template>

<script setup lang="ts">
import { pathwaysManager, rapidManager, rapid3Manager } from '~/services/index';
import { pathwaysManager, rapidManager, rapid3Manager, workspacesClient } from '~/services/index';
import { mapHash, viewForBbox } from '~/util/osm';

const route = useRoute();
const workspaceId = Number(route.params.id);
Expand Down Expand Up @@ -92,7 +94,28 @@ function onEditorLoaded() {
waitForStart(started);
}

onMounted(() => {
// The URL holds the editor's location (Rapid keeps it updated as the map moves).
// Without one, put the workspace's extent there for the editor to start from.
async function startAtDataExtent() {
if (window.location.hash.includes('map=')) {
return;
}

try {
const bbox = await workspacesClient.getWorkspaceBbox(workspaceId);
if (bbox && editorContainer.value) {
const { clientWidth: width, clientHeight: height } = editorContainer.value;
const others = window.location.hash.replace(/^#/, ''); // e.g. a background=
const hash = `${mapHash(viewForBbox(bbox, { width, height }))}${others ? `&${others}` : ''}`;
window.history.replaceState(window.history.state, '', `${window.location.pathname}${window.location.search}${hash}`);
}
}
catch (err) {
console.warn('Could not find the workspace extent to start the editor at:', err);
}
}

onMounted(async () => {
// If a different Rapid version is already loaded, hard-reload to swap.
// Only one version can occupy the global Rapid namespace at a time.
if (manager !== pathwaysManager) {
Expand All @@ -110,6 +133,11 @@ onMounted(() => {
return;
}

await startAtDataExtent();
if (!editorContainer.value) {
return; // left the page meanwhile
}

if (!manager.loaded.value) {
watch(manager.loaded, (val) => {
if (val) {
Expand Down
76 changes: 74 additions & 2 deletions test/e2e/edit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
// with exactly enough of a fake editor to make the manager's load+init path run
// to completion and paint a recognizable editor surface.
//
// The page itself makes NO workspaces API (api.test/workspaces/...) calls — it reads
// Beyond the bbox lookup below (when the URL has no map location), the page itself makes NO workspaces API (api.test/workspaces/...) calls — it reads
// the workspace id straight off the route — so the contract test should see an
// empty violation list.

Expand All @@ -37,7 +37,7 @@
body: `
window.Rapid = {
Context: class {
constructor() { window.__fakeRapidContext = this; }
constructor() { window.__fakeRapidContext = this; window.__hashAtStart = location.hash; }
embed() {}
async resetAsync() {}
async initAsync() {
Expand Down Expand Up @@ -158,6 +158,8 @@
await page.route('**/rapid3/js/rapid-dev.min.js', RAPID3_SCRIPT);
await page.route('**/pathways/iD.css', CSS);
await page.route('**/pathways/iD.js', PATHWAYS_SCRIPT);
// An empty workspace by default: the editor keeps its own starting location.
await page.route(/\/workspaces\/\d+\/bbox/, route => route.fulfill({ status: 204 }));
}

test.describe('workspace edit (editor host)', () => {
Expand Down Expand Up @@ -418,7 +420,7 @@

await page.evaluate(() => (window as any).__releaseEditorInit());

await expect(page.locator('.editorContainer .fake-editor')).toBeVisible();

Check failure on line 423 in test/e2e/edit.spec.ts

View workflow job for this annotation

GitHub Actions / ci

[chromium] › test/e2e/edit.spec.ts:399:3 › workspace edit (editor host) › shows the spinner again on coming back while the editor is still starting

1) [chromium] › test/e2e/edit.spec.ts:399:3 › workspace edit (editor host) › shows the spinner again on coming back while the editor is still starting Error: expect(locator).toBeVisible() failed Locator: locator('.editorContainer .fake-editor') Expected: visible Timeout: 10000ms Error: element(s) not found Call log: - Expect "toBeVisible" with timeout 10000ms - waiting for locator('.editorContainer .fake-editor') 421 | await page.evaluate(() => (window as any).__releaseEditorInit()); 422 | > 423 | await expect(page.locator('.editorContainer .fake-editor')).toBeVisible(); | ^ 424 | await expect(loading).toHaveCount(0); 425 | }); 426 | at /home/runner/work/workspaces-frontend/workspaces-frontend/test/e2e/edit.spec.ts:423:65
await expect(loading).toHaveCount(0);
});

Expand All @@ -437,6 +439,76 @@
await expect(loading.getByRole('status')).toHaveCount(0);
});

// @test e2e: opening this page with no map location in the URL starts the editor at the extent of the workspace's data; a location in the URL is kept, and an empty workspace keeps the editor's default
test.describe('starting location', () => {
const hashAtStart = (page: import('@playwright/test').Page) =>
page.evaluate(() => (window as any).__hashAtStart);

test('starts at the data extent when the URL has no location', async ({ page }) => {
await seedAuthenticatedSession(page);
await stubAllEditors(page);
await page.route(/\/workspaces\/1\/bbox/, route => route.fulfill({
json: { min_lat: 47.6, min_lon: -122.34, max_lat: 47.62, max_lon: -122.32 }
}));

await page.goto('/workspace/1/edit?datatype=osw');

await expect(page.locator('.editorContainer .fake-editor')).toBeVisible();
expect(await hashAtStart(page)).toMatch(/^#map=[\d.]+\/47\.61\d*\/-122\.33\d*$/);
await expect(page).toHaveURL(/\/workspace\/1\/edit\?datatype=osw#map=/);
});

test('keeps the rest of the URL\'s hash when adding the location', async ({ page }) => {
await seedAuthenticatedSession(page);
await stubAllEditors(page);
await page.route(/\/workspaces\/1\/bbox/, route => route.fulfill({
json: { min_lat: 47.6, min_lon: -122.34, max_lat: 47.62, max_lon: -122.32 }
}));

await page.goto('/workspace/1/edit?datatype=osw#background=Bing');

await expect(page.locator('.editorContainer .fake-editor')).toBeVisible();
expect(await hashAtStart(page)).toMatch(/^#map=.+&background=Bing$/);
});

test('keeps a location already in the URL', async ({ page }) => {
await seedAuthenticatedSession(page);
await stubAllEditors(page);
let lookedUp = false;
await page.route(/\/workspaces\/1\/bbox/, (route) => {
lookedUp = true;
return route.fulfill({ status: 204 });
});

await page.goto('/workspace/1/edit?datatype=osw#map=15/40/-70');

await expect(page.locator('.editorContainer .fake-editor')).toBeVisible();
expect(await hashAtStart(page)).toBe('#map=15/40/-70');
expect(lookedUp).toBe(false);
});

test('keeps the default location for an empty workspace', async ({ page }) => {
await seedAuthenticatedSession(page);
await stubAllEditors(page);

await page.goto('/workspace/1/edit?datatype=osw');

await expect(page.locator('.editorContainer .fake-editor')).toBeVisible();
expect(await hashAtStart(page)).toBe('');
});

test('still opens the editor when the extent lookup fails', async ({ page }) => {
await seedAuthenticatedSession(page);
await stubAllEditors(page);
await page.route(/\/workspaces\/1\/bbox/, route => route.fulfill({ status: 500 }));

await page.goto('/workspace/1/edit?datatype=osw');

await expect(page.locator('.editorContainer .fake-editor')).toBeVisible();
expect(await hashAtStart(page)).toBe('');
});
});

// @test e2e: validate that all the API calls used on this page match the Swagger spec
test('all API calls conform to the OpenAPI spec', async ({ page }) => {
await seedAuthenticatedSession(page);
Expand Down
5 changes: 5 additions & 0 deletions test/unit/services/rapid.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ import { RapidManager } from '~/services/rapid';

import type { TdeiAuthStore } from '~/services/tdei';

// Rapid chooses its background for the workspace's extent, which loads the real clients otherwise.
vi.mock('~/services/index', () => ({
workspacesClient: { getWorkspaceBbox: async () => undefined }
}));

describe('RapidManager subscriptions', () => {
it('stops notifying page callbacks after their cleanup functions run', async () => {
let stableChangeHandler: (() => void) | undefined;
Expand Down
31 changes: 31 additions & 0 deletions test/unit/util/osm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
elementGeoJson,
geometryEqual,
mapHash,
viewForBbox,
newerOsmElement,
OsmElementLookup,
wayToGeoJsonCoords
Expand Down Expand Up @@ -242,3 +243,33 @@ describe('elementGeoJson', () => {
expect(elementGeoJson('way', 404, nodes).features).toEqual([]);
});
});

describe('viewForBbox', () => {
const size = { width: 1400, height: 800 };

it('centers on the box and zooms in as it gets smaller', () => {
const wide = viewForBbox({ min_lat: 47, min_lon: -123, max_lat: 48, max_lon: -122 }, size);
const narrow = viewForBbox({ min_lat: 47.6, min_lon: -122.34, max_lat: 47.62, max_lon: -122.32 }, size);

expect(wide.lat).toBeCloseTo(47.5);
expect(wide.lon).toBeCloseTo(-122.5);
// A box 50 times smaller is about 5.6 zoom levels closer (log2 50).
expect(narrow.zoom - wide.zoom).toBeCloseTo(Math.log2(50), 1);
});

it('fits the box inside the map, beside the editor sidebar', () => {
const bbox = { min_lat: 47.6, min_lon: -122.34, max_lat: 47.62, max_lon: -122.32 };
const { zoom } = viewForBbox(bbox, size);

expect((bbox.max_lon - bbox.min_lon) / 360 * 256 * 2 ** zoom).toBeLessThanOrEqual(size.width - 400);
});

it('uses the closest zoom for a single point', () => {
expect(viewForBbox({ min_lat: 47.6, min_lon: -122.3, max_lat: 47.6, max_lon: -122.3 }, size))
.toEqual({ zoom: 20, lat: 47.6, lon: -122.3 });
});

it('goes no farther out than a whole-world view', () => {
expect(viewForBbox({ min_lat: -80, min_lon: -180, max_lat: 80, max_lon: 180 }, { width: 500, height: 300 }).zoom).toBe(2);
});
});
Loading
Loading