LGTM
This commit is contained in:
parent
701c17a703
commit
f114ada255
44 changed files with 5264 additions and 1674 deletions
|
|
@ -1,326 +0,0 @@
|
|||
import type { Page } from 'playwright';
|
||||
import type { DashboardRecorder, HexagonClickTarget } from './dashboard.js';
|
||||
import {
|
||||
AI_ZOOM_SCALE,
|
||||
BRAND_NAME,
|
||||
BRAND_TAGLINE,
|
||||
BRAND_URL,
|
||||
PROMPT_TEXT,
|
||||
TT_CARD_SELECTOR,
|
||||
TT_DRAG_FROM_MIN,
|
||||
TT_DRAG_TO_MIN,
|
||||
TT_SLIDER_MAX,
|
||||
} from './config.js';
|
||||
import {
|
||||
clearVignette,
|
||||
flashRect,
|
||||
hideCaption,
|
||||
showCaption,
|
||||
showOutro,
|
||||
zoomReset,
|
||||
zoomTo,
|
||||
} from './dom.js';
|
||||
import { fakeType, sleep, smoothMove, smoothDragSliderThumb } from './motion.js';
|
||||
|
||||
export interface SceneCtx {
|
||||
page: Page;
|
||||
dashboard: DashboardRecorder;
|
||||
cursor: { x: number; y: number };
|
||||
}
|
||||
|
||||
const AI_CLOSEUP_ZOOM_MS = 1400;
|
||||
const RESULTS_ZOOM_OUT_MS = 1500;
|
||||
const EXPORT_ZOOM_OUT_MS = 1100;
|
||||
const PROMPT_TYPING_DELAY_MS = 64;
|
||||
const MAP_ZOOM_WHEEL_STEPS = 18;
|
||||
const MAP_ZOOM_WHEEL_DELTA = -120;
|
||||
const MAP_ZOOM_WHEEL_PAUSE_MS = 70;
|
||||
|
||||
/**
|
||||
* Scene 1: start wide, then zoom into the AI prompt. The AI response is
|
||||
* stubbed, while the map filters and right pane are loaded from the real app.
|
||||
*/
|
||||
export async function sceneAiCloseUp(ctx: SceneCtx): Promise<void> {
|
||||
const { page, dashboard } = ctx;
|
||||
|
||||
await clearVignette(page);
|
||||
await showCaption(page, 'Brief: flats or terraces under £450k near central Manchester.');
|
||||
await sleep(180);
|
||||
|
||||
await zoomToAiBox(page, AI_CLOSEUP_ZOOM_MS);
|
||||
await sleep(AI_CLOSEUP_ZOOM_MS + 160);
|
||||
|
||||
await fakeType(
|
||||
page,
|
||||
'[data-tutorial="ai-filters"] textarea',
|
||||
PROMPT_TEXT,
|
||||
PROMPT_TYPING_DELAY_MS
|
||||
);
|
||||
await sleep(160);
|
||||
const aiResponse = page
|
||||
.waitForResponse(
|
||||
(response) => response.url().includes('/api/ai-filters') && response.status() === 200,
|
||||
{ timeout: 1800 }
|
||||
)
|
||||
.catch(() => null);
|
||||
const mapVersion = dashboard.getMapDataVersion();
|
||||
await page.evaluate(() => {
|
||||
document.querySelector<HTMLFormElement>('[data-tutorial="ai-filters"] form')?.requestSubmit();
|
||||
});
|
||||
await aiResponse;
|
||||
await sleep(160);
|
||||
await dashboard.waitForMapSettled(mapVersion, 15000);
|
||||
await showCaption(page, 'The filters are already live on the map.');
|
||||
await sleep(560);
|
||||
await hideCaption(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scene 2: animate the wrapper back to scale 1 so the full dashboard is
|
||||
* revealed. The map has already pan-flown to Manchester (MapPage's
|
||||
* own flyTo fires when AI travel-time filters are applied), so the zoom-out
|
||||
* lands on a useful, filtered view.
|
||||
*/
|
||||
export async function sceneZoomOutResults(ctx: SceneCtx): Promise<void> {
|
||||
const { page } = ctx;
|
||||
await showCaption(page, 'Now the view lands on central Manchester with the filters already set.');
|
||||
await zoomReset(page, RESULTS_ZOOM_OUT_MS);
|
||||
await sleep(RESULTS_ZOOM_OUT_MS + 160);
|
||||
await hideCaption(page);
|
||||
await sleep(180);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scene 3: drag the right thumb of the AI-applied travel-time slider from
|
||||
* 35 to 20 minutes. The slider has step=1 over 0–120, so the drag is paced
|
||||
* with real pointer updates instead of jumping the value directly.
|
||||
*
|
||||
* The card we drag (`tt_0`) only exists because the AI filter step inserted
|
||||
* exactly one travel-time entry; if you change the AI stub's count, update
|
||||
* the selector or this scene will time out.
|
||||
*/
|
||||
export async function sceneTravelTimeSlider(ctx: SceneCtx): Promise<void> {
|
||||
const { page, dashboard } = ctx;
|
||||
await showCaption(
|
||||
page,
|
||||
`Then tighten the commute: ${TT_DRAG_FROM_MIN} minutes down to ${TT_DRAG_TO_MIN}.`
|
||||
);
|
||||
|
||||
const card = page.locator(TT_CARD_SELECTOR);
|
||||
await card.waitFor({ state: 'visible', timeout: 4000 });
|
||||
await card.scrollIntoViewIfNeeded();
|
||||
await sleep(60);
|
||||
|
||||
// Two thumbs in a Radix range slider; the second one is the max.
|
||||
const thumbSelector = `${TT_CARD_SELECTOR} [role="slider"] >> nth=1`;
|
||||
// Track is the first horizontal-orientation element inside the card.
|
||||
const trackSelector = `${TT_CARD_SELECTOR} [data-orientation="horizontal"] >> nth=0`;
|
||||
|
||||
// Slider goes 0..120, target = 20 → fraction 0.166...
|
||||
const toFraction = TT_DRAG_TO_MIN / TT_SLIDER_MAX;
|
||||
const mapVersion = dashboard.getMapDataVersion();
|
||||
|
||||
ctx.cursor = await smoothDragSliderThumb(
|
||||
page,
|
||||
thumbSelector,
|
||||
trackSelector,
|
||||
ctx.cursor,
|
||||
toFraction,
|
||||
1180
|
||||
);
|
||||
|
||||
await sleep(220);
|
||||
await dashboard.waitForMapSettled(mapVersion, 16000);
|
||||
await showCaption(page, 'The map redraws around the areas that still work.');
|
||||
await sleep(720);
|
||||
await hideCaption(page);
|
||||
await sleep(180);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scene 4: after the filtered result map is visible, zoom into Manchester,
|
||||
* click a hexagon, then let the right pane open from that selection.
|
||||
*/
|
||||
export async function sceneClusterClick(ctx: SceneCtx): Promise<void> {
|
||||
const { page } = ctx;
|
||||
const viewport = page.viewportSize() ?? { width: 1920, height: 1080 };
|
||||
|
||||
await showCaption(page, 'Zoom into the result clusters, then click a promising hexagon.');
|
||||
|
||||
const defaultCluster = {
|
||||
x: 360 + (viewport.width - 360) * 0.35,
|
||||
y: viewport.height * 0.52,
|
||||
};
|
||||
const cluster = await pickMapZoomTarget(ctx, defaultCluster);
|
||||
|
||||
await smoothMove(page, ctx.cursor, cluster, { durationMs: 520 });
|
||||
ctx.cursor = cluster;
|
||||
await sleep(220);
|
||||
|
||||
await zoomMapWithWheel(ctx, cluster);
|
||||
ctx.cursor = await clickVisibleHexagon(ctx, cluster);
|
||||
await sleep(360);
|
||||
await showCaption(
|
||||
page,
|
||||
'This is the useful pause: local stats, matching homes, and street context together.'
|
||||
);
|
||||
await sleep(1000);
|
||||
await hideCaption(page);
|
||||
}
|
||||
|
||||
async function pickMapZoomTarget(
|
||||
ctx: SceneCtx,
|
||||
fallback: { x: number; y: number }
|
||||
): Promise<{ x: number; y: number }> {
|
||||
const [target] = await ctx.dashboard.visibleHexagonTargets(1).catch(() => []);
|
||||
return target ? { x: target.x, y: target.y } : fallback;
|
||||
}
|
||||
|
||||
async function zoomMapWithWheel(ctx: SceneCtx, target: { x: number; y: number }): Promise<void> {
|
||||
const { page, dashboard } = ctx;
|
||||
const mapVersion = dashboard.getMapDataVersion();
|
||||
await page.mouse.move(target.x, target.y);
|
||||
for (let i = 0; i < MAP_ZOOM_WHEEL_STEPS; i++) {
|
||||
await page.mouse.wheel(0, MAP_ZOOM_WHEEL_DELTA);
|
||||
await sleep(MAP_ZOOM_WHEEL_PAUSE_MS);
|
||||
}
|
||||
await dashboard.waitForMapSettled(mapVersion, 16000);
|
||||
await sleep(260);
|
||||
}
|
||||
|
||||
async function clickVisibleHexagon(
|
||||
ctx: SceneCtx,
|
||||
fallbackTarget: { x: number; y: number }
|
||||
): Promise<{ x: number; y: number }> {
|
||||
const candidates = await ctx.dashboard.visibleHexagonTargets(8).catch((error) => {
|
||||
console.log(
|
||||
`[scene] Falling back to direct map click targets: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
);
|
||||
return [];
|
||||
});
|
||||
const clickTargets = await addFallbackClickTargets(ctx, candidates, fallbackTarget);
|
||||
const startedAt = ctx.dashboard.getSelectionStatsVersion();
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const target of clickTargets) {
|
||||
await moveAndClickHexagon(ctx, target);
|
||||
try {
|
||||
await ctx.dashboard.waitForSelectionReady(startedAt, 7000);
|
||||
return { x: target.x, y: target.y };
|
||||
} catch (error) {
|
||||
if (ctx.dashboard.getSelectionStatsVersion() > startedAt) {
|
||||
return { x: target.x, y: target.y };
|
||||
}
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Could not open a map selection from the visible hexagons${
|
||||
lastError ? `: ${lastError.message}` : ''
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
async function addFallbackClickTargets(
|
||||
ctx: SceneCtx,
|
||||
candidates: HexagonClickTarget[],
|
||||
fallbackTarget: { x: number; y: number }
|
||||
): Promise<HexagonClickTarget[]> {
|
||||
const mapBox = await ctx.page.locator('[data-tutorial="map"]').boundingBox();
|
||||
const fallbacks: HexagonClickTarget[] = [
|
||||
{
|
||||
h3: 'direct-target',
|
||||
x: fallbackTarget.x,
|
||||
y: fallbackTarget.y,
|
||||
count: 1,
|
||||
},
|
||||
];
|
||||
|
||||
if (mapBox) {
|
||||
fallbacks.push({
|
||||
h3: 'map-center',
|
||||
x: mapBox.x + mapBox.width / 2,
|
||||
y: mapBox.y + mapBox.height / 2,
|
||||
count: 1,
|
||||
});
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
return [...candidates, ...fallbacks].filter((target) => {
|
||||
const key = `${Math.round(target.x / 12)},${Math.round(target.y / 12)}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function moveAndClickHexagon(ctx: SceneCtx, target: HexagonClickTarget): Promise<void> {
|
||||
await smoothMove(ctx.page, ctx.cursor, { x: target.x, y: target.y }, { durationMs: 420 });
|
||||
ctx.cursor = { x: target.x, y: target.y };
|
||||
await ctx.page.mouse.click(target.x, target.y);
|
||||
await sleep(140);
|
||||
}
|
||||
|
||||
/** Export the current shortlist, then reveal the URL. */
|
||||
export async function sceneExportAndOutro(ctx: SceneCtx): Promise<void> {
|
||||
const { page } = ctx;
|
||||
|
||||
await showCaption(page, 'When the shortlist feels right, export the exact filtered view.');
|
||||
await zoomReset(page, EXPORT_ZOOM_OUT_MS);
|
||||
await sleep(EXPORT_ZOOM_OUT_MS + 120);
|
||||
|
||||
const exportButton = page.locator('button[title="Export to Excel"]').first();
|
||||
await exportButton.waitFor({ state: 'visible', timeout: 4000 });
|
||||
const box = await exportButton.boundingBox();
|
||||
if (!box) throw new Error('Export button has no bounding box');
|
||||
const target = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
||||
|
||||
await smoothMove(page, ctx.cursor, target, { durationMs: 620 });
|
||||
ctx.cursor = target;
|
||||
await sleep(160);
|
||||
|
||||
const download = page.waitForEvent('download', { timeout: 4000 }).catch(() => null);
|
||||
await page.mouse.click(target.x, target.y);
|
||||
await flashRect(page, box);
|
||||
|
||||
await sleep(680);
|
||||
await hideCaption(page);
|
||||
await showOutro(ctx.page, BRAND_NAME, BRAND_TAGLINE, BRAND_URL);
|
||||
void download;
|
||||
await sleep(2200);
|
||||
}
|
||||
|
||||
/** Open the AI prompt before the timed scene starts. */
|
||||
export async function prepareAiBox(ctx: SceneCtx): Promise<void> {
|
||||
const { page } = ctx;
|
||||
|
||||
const aiRoot = page.locator('[data-tutorial="ai-filters"]').first();
|
||||
await aiRoot.waitFor({ state: 'visible', timeout: 15000 });
|
||||
|
||||
const textarea = page.locator('[data-tutorial="ai-filters"] textarea');
|
||||
if (!(await textarea.isVisible().catch(() => false))) {
|
||||
const aiButton = aiRoot.locator('button').first();
|
||||
await aiButton.waitFor({ state: 'visible', timeout: 8000 });
|
||||
const btnBox = await aiButton.boundingBox();
|
||||
if (btnBox) await page.mouse.click(btnBox.x + btnBox.width / 2, btnBox.y + btnBox.height / 2);
|
||||
}
|
||||
if (!(await textarea.isVisible().catch(() => false))) {
|
||||
await page.evaluate(() => {
|
||||
document.querySelector<HTMLElement>('[data-tutorial="ai-filters"] button')?.click();
|
||||
});
|
||||
}
|
||||
await textarea.waitFor({ state: 'visible', timeout: 15000 });
|
||||
await sleep(100);
|
||||
}
|
||||
|
||||
async function zoomToAiBox(page: Page, durationMs: number): Promise<void> {
|
||||
const aiCard = page.locator('[data-tutorial="ai-filters"]');
|
||||
const cardBox = await aiCard.boundingBox();
|
||||
if (!cardBox) throw new Error('AI card has no bounding box');
|
||||
const focusX = cardBox.x + cardBox.width / 2;
|
||||
const focusY = cardBox.y + cardBox.height / 2;
|
||||
await zoomTo(page, { scale: AI_ZOOM_SCALE, focusX, focusY, durationMs });
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue