B2 phase 4, and the last of it. Measured on the device: at 424 CSS px the four configured columns fit the row *exactly* -- `--grid-cols` came out `24px 102px 101px 101px 80px` -- and not one of them fit its content, with "Duration" too narrow for its own header. The columns were never too wide; there were too many of them. So a phone draws `titleArtist` (the title with the artist under it, across the row's whole width) plus the duration, and drops the column headers and the resize handles, which are a click-to-sort and a drag with no touch equivalent. It is a **column set, not a second row template**: the row, its delegated events, the selection semantics, the playing marker and the virtualizer never learn anything changed, because from their side only the number of columns did. Three rules come with it. The row height is in two places (`PHONE_ROW_HEIGHT` and the CSS rule) and must agree, since the virtualizer positions rows from that number and a taller row overlaps its neighbour. What is drawn and what can be sorted are different questions, so the sort list is built from `configuredColumns` -- a phone has no headers either, and building it from the drawn columns would leave it able to sort by title and duration alone. And a phone's column widths are neither loaded nor saved. That third rule is the bug the device found with the arrangement already passing five component tests and five e2e specs at the phone's own viewport. `loadColumnWidths` is keyed by column *id* and fills a gap with `MIN_COLUMN_WIDTH`, so the stacked column -- which nothing can ever have saved a width for -- came out at 148px beside a duration column of 236. The mirror image was worse and unreachable from a phone at all: saving would have written those widths back under the same ids, replacing the width the user dragged on a desktop. The specs asserted shape, and the fault depended on what `localStorage` held for a different column set; the unit test now carries that map as a fixture. Verified: 809 component tests, 112 e2e specs, and on the phone at 424x439 -- `24px 304px 80px`, 52px rows, no truncation, no overflow. One full e2e run of three saw an unrelated autotag keypress spec flake and pass on retry.
138 lines
4.8 KiB
TypeScript
138 lines
4.8 KiB
TypeScript
import { test, expect } from '../support/fixtures.js';
|
|
|
|
/**
|
|
* The track list on a phone (plan 016 B2 phase 4).
|
|
*
|
|
* The component tier pins the arrangement; this pins it in the real
|
|
* shell, at the viewport of the device the work was measured on — 424 x
|
|
* 439, a Light Phone III — because the fault it fixes was invisible to
|
|
* every assertion the app had. The columns *fit*: `--grid-cols` summed
|
|
* to exactly the host width, nothing overflowed, and every column was
|
|
* still unreadable. Only a measurement of what a cell can hold, or a
|
|
* screenshot, shows that.
|
|
*/
|
|
type Page = import('@playwright/test').Page;
|
|
|
|
/** The phone this was built against, in CSS pixels. */
|
|
const DEVICE = { width: 424, height: 439 };
|
|
|
|
/** A common small phone, as the shell specs use. */
|
|
const PHONE = { width: 390, height: 844 };
|
|
|
|
const list = (page: Page) => page.locator('track-list');
|
|
|
|
/** The row's grid tracks and the widest text a cell can show. */
|
|
const rowGeometry = (page: Page) =>
|
|
page.evaluate(() => {
|
|
const sr = document.querySelector('track-list')?.shadowRoot;
|
|
const row = sr?.querySelector('.track-row');
|
|
|
|
if (!row) return null;
|
|
|
|
const title = row.querySelector('.stacked-title');
|
|
const sub = row.querySelector('.stacked-sub');
|
|
|
|
return {
|
|
tracks: getComputedStyle(row)
|
|
.gridTemplateColumns.split(/\s+/)
|
|
.filter(Boolean).length,
|
|
rowHeight: Math.round(row.getBoundingClientRect().height),
|
|
headerRow: !!sr?.querySelector('.header-row'),
|
|
handles: sr?.querySelectorAll('.col-resize-handle').length ?? 0,
|
|
titleWidth: title ? Math.round(title.getBoundingClientRect().width) : 0,
|
|
// A truncated cell is the fault; a cell wider than its text is fine.
|
|
titleTruncated: title ? title.scrollWidth > title.clientWidth + 1 : null,
|
|
subText: sub?.textContent?.trim() ?? null,
|
|
};
|
|
});
|
|
|
|
test.describe('the track list on a phone', () => {
|
|
test.beforeEach(async ({ app }) => {
|
|
await app.setViewportSize(DEVICE);
|
|
await app.getByTestId('tab-tracks').click();
|
|
await expect(app.getByTestId('main-content')).toHaveAttribute(
|
|
'data-active-view',
|
|
'tracks',
|
|
);
|
|
await expect(list(app).first()).toBeVisible();
|
|
});
|
|
|
|
test.afterEach(async ({ app }) => {
|
|
await app.setViewportSize({ width: 1440, height: 900 });
|
|
});
|
|
|
|
test('stacks the title over the artist and drops the pointer affordances', async ({
|
|
app,
|
|
}) => {
|
|
const geo = await rowGeometry(app);
|
|
|
|
expect(geo).not.toBeNull();
|
|
// Favourite + one stacked column + duration.
|
|
expect(geo?.tracks).toBe(3);
|
|
expect(geo?.headerRow).toBe(false);
|
|
expect(geo?.handles).toBe(0);
|
|
expect(geo?.subText).toBeTruthy();
|
|
|
|
// The row height has to match the virtualizer's item size, or rows
|
|
// overlap; 52 is that number.
|
|
expect(geo?.rowHeight).toBe(52);
|
|
});
|
|
|
|
test('gives the title most of the row instead of a quarter of it', async ({
|
|
app,
|
|
}) => {
|
|
const geo = await rowGeometry(app);
|
|
|
|
// Four columns at this width gave a title ~102px. The measurement
|
|
// that matters is the share of the row, not the pixel count.
|
|
expect(geo?.titleWidth ?? 0).toBeGreaterThan(DEVICE.width * 0.55);
|
|
});
|
|
|
|
test('needs no sideways scrolling, and neither does the shell', async ({
|
|
app,
|
|
}) => {
|
|
const overflow = await app.evaluate(() => ({
|
|
body: [document.body.scrollWidth, document.body.clientWidth],
|
|
list: (() => {
|
|
const sr = document.querySelector('track-list')?.shadowRoot;
|
|
const row = sr?.querySelector('.track-row');
|
|
|
|
return row ? [row.scrollWidth, row.clientWidth] : null;
|
|
})(),
|
|
}));
|
|
|
|
expect(overflow.body[0]).toBe(overflow.body[1]);
|
|
expect(overflow.list?.[0]).toBe(overflow.list?.[1]);
|
|
});
|
|
|
|
test('keeps the sorts a phone has no headers to reach', async ({ app }) => {
|
|
// With no column headers, the page header's sort control is the only
|
|
// route to sort-by-artist — so it must still offer the columns the
|
|
// phone does not draw.
|
|
const ids = await app.evaluate(() => {
|
|
const header = document
|
|
.querySelector('track-list')
|
|
?.shadowRoot?.querySelector('page-header') as
|
|
| (Element & { sortOptions?: { id: string }[] })
|
|
| null;
|
|
|
|
return (header?.sortOptions ?? []).map((o) => o.id);
|
|
});
|
|
|
|
expect(ids).toContain('artistName');
|
|
expect(ids).toContain('album');
|
|
});
|
|
|
|
test('is the desktop list again above the breakpoint', async ({ app }) => {
|
|
await app.setViewportSize(PHONE);
|
|
await expect.poll(async () => (await rowGeometry(app))?.tracks).toBe(3);
|
|
|
|
await app.setViewportSize({ width: 1024, height: 800 });
|
|
|
|
// The same element, re-laid-out: this is one component with two
|
|
// column sets, not two components.
|
|
await expect.poll(async () => (await rowGeometry(app))?.headerRow).toBe(true);
|
|
await expect.poll(async () => (await rowGeometry(app))?.tracks).toBe(5);
|
|
});
|
|
});
|