Plan 019 phase 2. A finger on a track row now drags a reveal out from under it and queues the track on release, with the affordance saying what it will do before it does it. Two things the device said that the plan did not predict, and both change the implementation rather than decorate it. The gesture runs on touch events, not pointer events. Chrome 113's WebView cancels the pointer stream ~16px into any drag whatever touch-action says -- measured at auto, pan-y and none alike -- while touchmove keeps firing. So touch-action: pan-y is half the fix and a non-passive touchmove calling preventDefault is the other half, and neither works alone: with the preventDefault in place and touch-action back at auto the gesture died after one move. Both are correct in Chromium either way, which is why the module's header carries the measurement and the component tier asserts the stylesheet. And a phase 1 defect the device found on the way past: the native contextmenu arrives in either order and only one was handled. Our 500ms timer firing first, a component claiming it, and Chrome delivering its own menu 50-70ms later was suppressed by nothing -- so the context menu opened over the selection bar, two holds in four, on the one surface this issue exists to have changed. Six holds clean after. draggable="true" is not a competitor: no dragstart fires from a touch drag on this WebView at all.
312 lines
10 KiB
TypeScript
312 lines
10 KiB
TypeScript
import { test, expect, callBinding } from '../support/fixtures.js';
|
|
|
|
/**
|
|
* The touch gestures against the real app (plan 019, #63; long-press
|
|
* from plan 016 B2).
|
|
*
|
|
* The component tier proves the gestures in isolation, against markup
|
|
* it built itself. What it cannot prove is the half that made this one
|
|
* document listener instead of six: that the announced gesture reaches
|
|
* the handler a *real* component bound — `track-list` delegates on the
|
|
* `lit-virtualizer` rather than binding per row — and that the real
|
|
* menu opens from it, a path with its own history of opening and then
|
|
* refusing to work (see `menu-keyboard.spec.ts`).
|
|
*
|
|
* **Both halves of the reassignment are here, and the second is the
|
|
* one that matters.** #63 makes a hold on a *track row* mean selection
|
|
* mode; every other surface in the app keeps the context menu it has
|
|
* had, because an unclaimed `yj-long-press` still becomes a
|
|
* `contextmenu`. A spec that only checked the row would pass on a
|
|
* build that had silently broken the other thirteen menus.
|
|
*
|
|
* The pointer events are dispatched rather than performed: this
|
|
* project runs Desktop Chrome and Desktop Safari, neither of which has
|
|
* touch. So this is honest about what it checks — the app's own
|
|
* listeners, on the app's own DOM, from the events a touch would
|
|
* produce — and not about a real finger. The finger is the Android
|
|
* tier, and it found something this cannot see: Chrome 113's WebView
|
|
* fires its own `contextmenu` on a long press, which is why the module
|
|
* announces the gesture from a native event rather than standing down.
|
|
*/
|
|
|
|
/** A common small phone, as in `phone-shell.spec.ts`. */
|
|
const PHONE = { width: 390, height: 844 };
|
|
|
|
/** Comfortably past the module's 500ms hold. */
|
|
const HELD = 900;
|
|
|
|
type Page = import('@playwright/test').Page;
|
|
|
|
/** A component's menu panel, or null while it is not rendered. */
|
|
const panel = (page: Page, host: string) =>
|
|
page.evaluate((tag) => {
|
|
const el = document
|
|
.querySelector(tag)
|
|
?.shadowRoot?.querySelector('.context-menu-panel');
|
|
|
|
if (!el) return null;
|
|
|
|
return {
|
|
role: el.getAttribute('role'),
|
|
label: el.getAttribute('aria-label'),
|
|
items: el.querySelectorAll('[role="menuitem"]').length,
|
|
};
|
|
}, host);
|
|
|
|
/** How many tracks the selection bar says are selected, or null. */
|
|
const selectionCount = (page: Page) =>
|
|
page.evaluate(() => {
|
|
const bar = document
|
|
.querySelector('track-list')
|
|
?.shadowRoot?.querySelector('selection-bar');
|
|
|
|
return bar ? (bar as unknown as { count: number }).count : null;
|
|
});
|
|
|
|
/**
|
|
* Press an element, optionally dragging partway through — the shape of
|
|
* a scroll that begins on a row, which must be neither gesture — and
|
|
* optionally lifting, which is what makes it a tap rather than a hold.
|
|
*/
|
|
async function press(
|
|
page: Page,
|
|
selector: { host: string; inner: string },
|
|
opts: { driftY?: number; lift?: boolean } = {},
|
|
): Promise<void> {
|
|
await page.evaluate(
|
|
({ host, inner, drift, lift }) => {
|
|
const el = document
|
|
.querySelector(host)
|
|
?.shadowRoot?.querySelector(inner);
|
|
|
|
if (!el) throw new Error(`no ${inner} in ${host} to press`);
|
|
|
|
const box = el.getBoundingClientRect();
|
|
const x = Math.round(box.left + box.width / 2);
|
|
const y = Math.round(box.top + box.height / 2);
|
|
const send = (type: string, dy = 0) =>
|
|
el.dispatchEvent(
|
|
new PointerEvent(type, {
|
|
bubbles: true,
|
|
composed: true,
|
|
cancelable: true,
|
|
pointerType: 'touch',
|
|
isPrimary: true,
|
|
clientX: x,
|
|
clientY: y + dy,
|
|
}),
|
|
);
|
|
|
|
send('pointerdown');
|
|
|
|
if (drift) send('pointermove', drift);
|
|
if (lift) send('pointerup');
|
|
},
|
|
{
|
|
host: selector.host,
|
|
inner: selector.inner,
|
|
drift: opts.driftY ?? 0,
|
|
lift: opts.lift ?? false,
|
|
},
|
|
);
|
|
}
|
|
|
|
// `.track-row`, not `[role="row"]`: the column header is a row too, and
|
|
// it is the *first* one — a press on it is correctly ignored, which
|
|
// reads exactly like the gesture not working.
|
|
const TRACK_ROW = { host: 'track-list', inner: '.track-row' };
|
|
|
|
test.describe('a hold on a track row selects it', () => {
|
|
test.beforeEach(async ({ app }) => {
|
|
await app.setViewportSize(PHONE);
|
|
await app.getByTestId('tab-tracks').click();
|
|
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
|
'data-active-view',
|
|
'tracks',
|
|
);
|
|
});
|
|
|
|
test.afterEach(async ({ app }) => {
|
|
// Every other spec file runs against a desktop, and the viewport
|
|
// belongs to the shared context rather than to this file.
|
|
await app.setViewportSize({ width: 1440, height: 900 });
|
|
});
|
|
|
|
test('raises the selection bar rather than the context menu', async ({
|
|
app,
|
|
}) => {
|
|
await expect.poll(() => selectionCount(app)).toBeNull();
|
|
|
|
await press(app, TRACK_ROW);
|
|
|
|
await expect
|
|
.poll(() => selectionCount(app), { timeout: HELD + 2000 })
|
|
.toBe(1);
|
|
|
|
// The gesture is claimed, so the menu this hold used to open must
|
|
// not also be up -- on a phone that would be a sheet over the bar.
|
|
expect(await panel(app, 'track-list')).toBeNull();
|
|
});
|
|
|
|
test('is neither gesture when the press turns into a scroll', async ({
|
|
app,
|
|
}) => {
|
|
await press(app, TRACK_ROW, { driftY: 40 });
|
|
await app.waitForTimeout(HELD);
|
|
|
|
expect(await selectionCount(app)).toBeNull();
|
|
expect(await panel(app, 'track-list')).toBeNull();
|
|
});
|
|
});
|
|
|
|
test.describe('a hold anywhere else still opens the menu', () => {
|
|
test.beforeEach(async ({ app }) => {
|
|
await app.setViewportSize(PHONE);
|
|
await app.getByTestId('tab-albums').click();
|
|
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
|
'data-active-view',
|
|
'albums',
|
|
);
|
|
});
|
|
|
|
test.afterEach(async ({ app }) => {
|
|
await app.setViewportSize({ width: 1440, height: 900 });
|
|
});
|
|
|
|
test('reaches the delegated handler and opens the real menu', async ({
|
|
app,
|
|
}) => {
|
|
// The property that let #63 reassign the hold without touching one
|
|
// of the fourteen context menus: unclaimed, it is what it was.
|
|
// Without this half, breaking all of them passes the suite.
|
|
await expect.poll(() => panel(app, 'cover-grid')).toBeNull();
|
|
|
|
await press(app, { host: 'cover-grid', inner: '[role="option"]' });
|
|
|
|
await expect
|
|
.poll(() => panel(app, 'cover-grid'), { timeout: HELD + 2000 })
|
|
.toMatchObject({ role: 'menu' });
|
|
|
|
// The same panel Shift+F10 opens, items and all -- not an empty
|
|
// popup that happened to become visible.
|
|
expect((await panel(app, 'cover-grid'))?.items).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Swipe right on a track row to queue it (plan 019 phase 2, #63).
|
|
*
|
|
* The component tier has the rule this obeys — one row is a position,
|
|
* several are a choice — against a queue that is a fake. What is only
|
|
* true here is that the gesture reaches the *real* queue: `AddTracks`
|
|
* is a Go method, the queue is persisted, and "the row was added"
|
|
* is a question only the backend can answer.
|
|
*
|
|
* **It is Chromium-only, and that is a property of the browser rather
|
|
* than a gap.** The gesture runs on touch events, because Chrome 113's
|
|
* WebView cancels the pointer stream ~16px into any drag whatever
|
|
* `touch-action` says. Desktop WebKit implements no `TouchEvent`
|
|
* constructor at all — touch events are a mobile-Safari surface — so
|
|
* the events this needs cannot be built there. Skipping loudly is
|
|
* better than a spec that quietly asserts nothing on half the matrix,
|
|
* which is what `layout-overflow.spec.ts` and `back-navigation.spec.ts`
|
|
* were each doing when they were green on a broken build.
|
|
*/
|
|
test.describe('a swipe right on a track row queues it', () => {
|
|
test.beforeEach(async ({ app, browserName }) => {
|
|
test.skip(
|
|
browserName !== 'chromium',
|
|
'desktop WebKit has no TouchEvent constructor to build the gesture from',
|
|
);
|
|
|
|
await app.setViewportSize(PHONE);
|
|
await app.getByTestId('tab-tracks').click();
|
|
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
|
'data-active-view',
|
|
'tracks',
|
|
);
|
|
});
|
|
|
|
test.afterEach(async ({ app }) => {
|
|
await app.setViewportSize({ width: 1440, height: 900 });
|
|
});
|
|
|
|
/**
|
|
* Drag the first row sideways by a fraction of its own width and
|
|
* lift. `fraction` is against the row, because the commit threshold
|
|
* is — a number of pixels here would be a second declaration of it,
|
|
* right on one viewport and wrong on the next.
|
|
*/
|
|
const swipeFirstRow = (page: Page, fraction: number) =>
|
|
page.evaluate((f) => {
|
|
const row = document
|
|
.querySelector('track-list')
|
|
?.shadowRoot?.querySelector('.track-row');
|
|
|
|
if (!row) throw new Error('no track row to swipe');
|
|
|
|
const box = row.getBoundingClientRect();
|
|
const y = box.top + box.height / 2;
|
|
const at = (x: number) =>
|
|
new Touch({
|
|
identifier: 1,
|
|
target: row,
|
|
clientX: box.left + x,
|
|
clientY: y,
|
|
});
|
|
const send = (type: string, points: Touch[]) =>
|
|
row.dispatchEvent(
|
|
new TouchEvent(type, {
|
|
bubbles: true,
|
|
composed: true,
|
|
cancelable: true,
|
|
touches: points,
|
|
changedTouches: points.length > 0 ? points : [at(0)],
|
|
}),
|
|
);
|
|
|
|
send('touchstart', [at(0)]);
|
|
|
|
for (const step of [0.25, 0.5, 0.75, 1]) {
|
|
send('touchmove', [at(box.width * f * step)]);
|
|
}
|
|
|
|
send('touchend', []);
|
|
}, fraction);
|
|
|
|
/** How many tracks the backend says are in the queue. */
|
|
const queueLength = async (page: Page) => {
|
|
const state = await callBinding<{ tracks: unknown[] }>(
|
|
page,
|
|
'queue.Queue.GetState',
|
|
);
|
|
|
|
return state.tracks?.length ?? 0;
|
|
};
|
|
|
|
test('adds exactly one track to the real queue', async ({ app }) => {
|
|
const before = await queueLength(app);
|
|
|
|
await swipeFirstRow(app, 0.6);
|
|
|
|
await expect.poll(() => queueLength(app)).toBe(before + 1);
|
|
|
|
// Queued, not played: a swipe is not a tap, and the difference is
|
|
// what is on screen afterwards.
|
|
expect(
|
|
await app.getByTestId('main-content').getAttribute('data-active-view'),
|
|
).toBe('tracks');
|
|
});
|
|
|
|
test('does nothing when the finger did not get far enough', async ({
|
|
app,
|
|
}) => {
|
|
const before = await queueLength(app);
|
|
|
|
await swipeFirstRow(app, 0.1);
|
|
await app.waitForTimeout(400);
|
|
|
|
expect(await queueLength(app)).toBe(before);
|
|
});
|
|
});
|