All checks were successful
Test and deploy staging / staging (push) Successful in 2m29s
- Scale the COD page to the screen: larger gun art, text and progress ribbon, sized from one viewport unit so it fills a MacBook Pro 16" and big monitors and still fits 1280x720 without scrolling. - Five columns on wide screens and four below, picked by CSS, so the server-rendered page already has the final layout. - Replace the tabs/filters/overview rows with one toolbar of matching controls. - Player and weapon ranks step with + and -: level 55 + 1 is the next prestige at level 1, up to max prestige. Presses show at once, batch while a save is running, and stepRank does the arithmetic in SQL. - Clicking a rank still opens exact entry for big jumps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1007 lines
38 KiB
TypeScript
1007 lines
38 KiB
TypeScript
import { test, expect, type APIRequestContext } from '@playwright/test';
|
||
import postgres from 'postgres';
|
||
|
||
test.skip(
|
||
!process.env.TRACKER_E2E,
|
||
'Opt-in: npm run test:tracker uses the migrated local development database.'
|
||
);
|
||
const origin = 'http://127.0.0.1:4174';
|
||
const users: string[] = [];
|
||
let sql: ReturnType<typeof postgres>;
|
||
test.beforeAll(() => {
|
||
if (!process.env.DB_URL) throw new Error('DB_URL required');
|
||
sql = postgres(process.env.DB_URL, { max: 1 });
|
||
});
|
||
test.afterAll(async () => {
|
||
if (!sql) return;
|
||
for (const id of users) await sql`delete from "user" where id=${id}`;
|
||
await sql.end();
|
||
});
|
||
async function register(request: APIRequestContext, attempt = 0): Promise<string> {
|
||
const response = await request.post('/api/auth/sign-up/email', {
|
||
headers: { Origin: origin },
|
||
data: {
|
||
name: 'Tracker Tester',
|
||
email: `tracker-${crypto.randomUUID()}@example.com`,
|
||
password: 'Tracker-test-password-2026!'
|
||
}
|
||
});
|
||
if (response.status() === 429 && attempt < 2) {
|
||
await new Promise((resolve) => setTimeout(resolve, 11000));
|
||
return register(request, attempt + 1);
|
||
}
|
||
expect(response.ok(), await response.text()).toBe(true);
|
||
const body = await response.json();
|
||
users.push(body.user.id);
|
||
return body.user.id as string;
|
||
}
|
||
async function action(
|
||
request: APIRequestContext,
|
||
path: string,
|
||
values: Record<string, string | string[]>
|
||
) {
|
||
const body = new URLSearchParams();
|
||
for (const [k, v] of Object.entries(values))
|
||
for (const value of Array.isArray(v) ? v : [v]) body.append(k, value);
|
||
const response = await request.post(path, {
|
||
headers: {
|
||
Origin: origin,
|
||
Accept: 'application/json',
|
||
'x-sveltekit-action': 'true',
|
||
'content-type': 'application/x-www-form-urlencoded'
|
||
},
|
||
data: body.toString()
|
||
});
|
||
const result = await response.json();
|
||
return { status: result.status ?? response.status(), type: result.type, result };
|
||
}
|
||
|
||
test('Platinum route, regional dex, catch journal, duplicate policy, team slots and ownership', async ({
|
||
page,
|
||
playwright
|
||
}) => {
|
||
const id = await register(page.request);
|
||
const [{ id: gameId }] = await sql`select id from games where slug='platinum'`;
|
||
const [{ id: pokemonId }] = await sql`select id from pokemon where number=387`;
|
||
await page.goto('/projects/game-tracker/pokemon?game=platinum');
|
||
await expect(page.getByRole('heading', { name: 'Pokémon journal' })).toBeVisible();
|
||
await expect(page.getByRole('button', { name: 'set rules', exact: true })).toBeEnabled({
|
||
timeout: 15000
|
||
});
|
||
await expect(page.getByRole('button', { name: /Turtwig Grass/ })).toBeVisible();
|
||
await expect(page.getByRole('button', { name: /Nidoran/ })).toHaveCount(0);
|
||
await page.getByRole('button', { name: 'set rules', exact: true }).click();
|
||
await expect(page.getByRole('dialog', { name: 'Set rules' })).toBeVisible();
|
||
await page.keyboard.press('Escape');
|
||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||
await page.getByRole('tab', { name: 'Pokédex', exact: true }).click();
|
||
await expect(page.getByLabel('Pokédex order')).toHaveValue('extended-sinnoh');
|
||
await expect(page.locator('article').first()).toContainText('Turtwig');
|
||
await page.getByLabel('Pokédex order').selectOption('national');
|
||
await expect(page.locator('article').first()).toContainText('Bulbasaur');
|
||
const catchValues = {
|
||
gameId: String(gameId),
|
||
pokemonId: String(pokemonId),
|
||
nickname: 'Sprout',
|
||
level: '5',
|
||
moves: ['Tackle', 'Withdraw'],
|
||
stage: 'story',
|
||
obtained: 'starter',
|
||
partySlot: '1',
|
||
caughtOn: '2026-09-25',
|
||
location: 'Route 201'
|
||
};
|
||
expect(
|
||
(await action(page.request, '/projects/game-tracker/pokemon?/saveCatch', catchValues)).status
|
||
).toBe(200);
|
||
const [first] = await sql`select * from pokemon_catches where user_id=${id}`;
|
||
expect(
|
||
(
|
||
await action(page.request, '/projects/game-tracker/pokemon?/saveCatch', {
|
||
...catchValues,
|
||
nickname: 'Second Sprout',
|
||
partySlot: '2'
|
||
})
|
||
).status
|
||
).toBe(200);
|
||
const [second] = await sql`select * from pokemon_catches where user_id=${id} and id<>${first.id}`;
|
||
await page.reload();
|
||
await page.getByRole('tab', { name: /My PC/ }).click();
|
||
await expect(page.getByRole('tabpanel').getByRole('button', { name: /#387/ })).toContainText(
|
||
'Sprout'
|
||
);
|
||
await expect(page.getByRole('tabpanel').getByRole('button', { name: /#387/ })).not.toContainText(
|
||
'Second Sprout'
|
||
);
|
||
await page.getByRole('tabpanel').getByRole('button', { name: /#387/ }).click();
|
||
await expect(page.getByRole('dialog')).toContainText('Meet Sprout');
|
||
await page.getByLabel('Level', { exact: true }).fill('12');
|
||
await page.getByRole('button', { name: 'Save Pokémon', exact: true }).click();
|
||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||
expect((await sql`select level from pokemon_catches where id=${first.id}`)[0].level).toBe(12);
|
||
expect(
|
||
(
|
||
await action(page.request, '/projects/game-tracker/pokemon?/saveCatch', {
|
||
...catchValues,
|
||
id: String(second.id),
|
||
nickname: 'Second Sprout',
|
||
partySlot: '1'
|
||
})
|
||
).status
|
||
).toBe(200);
|
||
expect(
|
||
(await sql`select party_slot from pokemon_catches where id=${first.id}`)[0].party_slot
|
||
).toBeNull();
|
||
expect(
|
||
(
|
||
await action(page.request, '/projects/game-tracker/pokemon?/saveCatch', {
|
||
...catchValues,
|
||
moves: ['a', 'b', 'c', 'd', 'e']
|
||
})
|
||
).status
|
||
).toBe(400);
|
||
const other = await playwright.request.newContext({ baseURL: origin });
|
||
await register(other);
|
||
expect(
|
||
(
|
||
await action(other, '/projects/game-tracker/pokemon?/saveCatch', {
|
||
...catchValues,
|
||
id: String(first.id)
|
||
})
|
||
).status
|
||
).toBe(404);
|
||
expect(
|
||
(await action(other, '/projects/game-tracker/pokemon?/deleteCatch', { id: String(first.id) }))
|
||
.status
|
||
).toBe(404);
|
||
await other.dispose();
|
||
expect(
|
||
(
|
||
await action(page.request, '/projects/game-tracker/pokemon?/saveJourney', {
|
||
gameId: String(gameId),
|
||
stage: 'postgame',
|
||
location: 'stark-mountain'
|
||
})
|
||
).status
|
||
).toBe(200);
|
||
expect(
|
||
(
|
||
await action(page.request, '/projects/game-tracker/pokemon?/saveMilestone', {
|
||
gameId: String(gameId),
|
||
stage: 'story',
|
||
key: 'league:champion-cynthia',
|
||
title: 'Champion · Cynthia',
|
||
category: 'league',
|
||
done: 'on'
|
||
})
|
||
).status
|
||
).toBe(200);
|
||
await page.reload();
|
||
await expect(page.getByLabel('My playthrough')).toHaveValue('postgame');
|
||
await expect(page.getByLabel('Encounter location')).toHaveValue('stark-mountain');
|
||
await page.screenshot({ path: '/tmp/tracker-pokemon-saved.png', fullPage: true });
|
||
const [entry] =
|
||
await sql`select id from pokemon_game_entries where game_id=${gameId} and pokemon_id=${pokemonId}`;
|
||
expect(
|
||
(
|
||
await action(page.request, '/projects/game-tracker/pokemon?/togglePokemonCaught', {
|
||
entryId: String(entry.id)
|
||
})
|
||
).status
|
||
).toBe(400);
|
||
for (const row of [first, second])
|
||
expect(
|
||
(
|
||
await action(page.request, '/projects/game-tracker/pokemon?/deleteCatch', {
|
||
id: String(row.id)
|
||
})
|
||
).status
|
||
).toBe(200);
|
||
expect(
|
||
(
|
||
await sql`select * from user_entries where user_id=${id} and pokemon_game_entry_id=${entry.id}`
|
||
).length
|
||
).toBe(1);
|
||
expect(
|
||
(
|
||
await action(page.request, '/projects/game-tracker/pokemon?/togglePokemonCaught', {
|
||
entryId: String(entry.id)
|
||
})
|
||
).status
|
||
).toBe(200);
|
||
});
|
||
|
||
test('weapon/player ranks, challenge progress and osu goals/scores/training persist independently', async ({
|
||
page,
|
||
playwright
|
||
}) => {
|
||
const id = await register(page.request);
|
||
const [weapon] =
|
||
await sql`select i.id, i.name from tracker_items i join tracker_games g on i.game_id=g.id where g.slug='black-ops-3' and i.kind='weapon' order by i.id limit 1`;
|
||
const [challenge] =
|
||
await sql`select c.id,c.target from tracker_challenges c join tracker_games g on c.game_id=g.id where g.slug='black-ops-3' and c.target>1 limit 1`;
|
||
const cod = '/projects/game-tracker/call-of-duty/black-ops-3';
|
||
await page.goto(cod);
|
||
await page.getByRole('button', { name: 'Player level and prestige' }).click();
|
||
await page.getByLabel('Current level', { exact: true }).fill('42');
|
||
await page.getByLabel('Prestige', { exact: true }).fill('3');
|
||
await page.getByRole('button', { name: 'Save rank' }).click();
|
||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||
await expect(page.getByLabel('Player level and prestige')).toContainText('LEVEL42');
|
||
await expect(page.getByLabel('Player level and prestige')).toContainText('PRESTIGE3');
|
||
expect(
|
||
(
|
||
await action(page.request, cod + '?/saveRank', {
|
||
itemId: String(weapon.id),
|
||
level: '17',
|
||
prestige: '2'
|
||
})
|
||
).status
|
||
).toBe(200);
|
||
expect(
|
||
(
|
||
await action(page.request, cod + '?/saveChallenge', {
|
||
challengeId: String(challenge.id),
|
||
current: String(challenge.target)
|
||
})
|
||
).status
|
||
).toBe(200);
|
||
await page.reload();
|
||
const weaponCard = page.getByRole('article', { name: weapon.name, exact: true });
|
||
await expect(
|
||
weaponCard.getByRole('button', { name: `Edit ${weapon.name} level and prestige` })
|
||
).toContainText('17');
|
||
await weaponCard.getByRole('button', { name: `${weapon.name} camos and milestones` }).click();
|
||
await expect(page.getByRole('dialog')).toContainText('Max level reached');
|
||
await page.keyboard.press('Escape');
|
||
await page.getByRole('tab', { name: 'Challenges', exact: true }).click();
|
||
await expect(page.locator('article')).toHaveCount(36);
|
||
const goal = {
|
||
kind: 'goal',
|
||
title: 'First 200 BPM FC',
|
||
mode: 'osu',
|
||
goal: 'fc',
|
||
skill: 'stamina',
|
||
playedOn: '2026-09-25',
|
||
rate: '1.00',
|
||
mods: 'NM',
|
||
beatmapUrl: 'https://osu.ppy.sh/beatmaps/123'
|
||
};
|
||
expect((await action(page.request, '/projects/game-tracker/osu?/save', goal)).status).toBe(200);
|
||
expect(
|
||
(
|
||
await action(page.request, '/projects/game-tracker/osu?/save', {
|
||
...goal,
|
||
kind: 'score',
|
||
title: 'My best play',
|
||
accuracy: '99.25',
|
||
combo: '1234',
|
||
misses: '0',
|
||
pp: '250.3',
|
||
scoreUrl: 'https://osu.ppy.sh/scores/123'
|
||
})
|
||
).status
|
||
).toBe(200);
|
||
expect(
|
||
(
|
||
await action(page.request, '/projects/game-tracker/osu?/save', {
|
||
...goal,
|
||
kind: 'training',
|
||
title: 'Stream practice',
|
||
bpm: '180',
|
||
duration: '60',
|
||
accuracy: '98.5'
|
||
})
|
||
).status
|
||
).toBe(200);
|
||
expect(
|
||
(
|
||
await action(page.request, '/projects/game-tracker/osu?/save', {
|
||
...goal,
|
||
kind: 'training',
|
||
title: 'Faster streams',
|
||
bpm: '140',
|
||
duration: '45',
|
||
rate: '1.50',
|
||
mods: 'DT',
|
||
accuracy: '97.5'
|
||
})
|
||
).status
|
||
).toBe(200);
|
||
expect(
|
||
(
|
||
await action(page.request, '/projects/game-tracker/osu?/save', {
|
||
...goal,
|
||
beatmapUrl: 'https://osu.ppy.sh.evil.test/beatmaps/123'
|
||
})
|
||
).status
|
||
).toBe(400);
|
||
expect(
|
||
(
|
||
await action(page.request, '/projects/game-tracker/osu?/save', {
|
||
...goal,
|
||
kind: 'training',
|
||
duration: '0',
|
||
bpm: '180'
|
||
})
|
||
).status
|
||
).toBe(400);
|
||
await page.goto('/projects/game-tracker/osu');
|
||
await expect(page.getByRole('heading', { name: 'First 200 BPM FC' })).toBeVisible();
|
||
await page.getByRole('button', { name: 'Achieved!', exact: true }).click();
|
||
await expect(page.getByText('✓ achieved')).toBeVisible();
|
||
await page.getByRole('tab', { name: 'Training log' }).click();
|
||
await expect(page.getByText('210 effective BPM')).toBeVisible();
|
||
await expect(page.getByRole('img', { name: /Last 2 qualifying/ })).toBeVisible();
|
||
await page.screenshot({ path: '/tmp/tracker-osu-saved.png', fullPage: true });
|
||
const [row] = await sql`select id from osu_entries where user_id=${id} limit 1`;
|
||
const other = await playwright.request.newContext({ baseURL: origin });
|
||
await register(other);
|
||
expect(
|
||
(await action(other, '/projects/game-tracker/osu?/save', { ...goal, id: String(row.id) }))
|
||
.status
|
||
).toBe(404);
|
||
expect(
|
||
(await action(other, '/projects/game-tracker/osu?/delete', { id: String(row.id) })).status
|
||
).toBe(404);
|
||
await other.dispose();
|
||
});
|
||
|
||
test('shared dialogs have focus, Escape dismissal and mobile layouts without overflow', async ({
|
||
page
|
||
}) => {
|
||
const id = await register(page.request);
|
||
await sql`update "user" set role='admin' where id=${id}`;
|
||
for (const [path, label, title] of [
|
||
['/blog', 'new article', 'new article'],
|
||
['/lists', 'new list', 'new list']
|
||
]) {
|
||
await page.goto(path);
|
||
await page.getByRole('button', { name: label, exact: true }).click();
|
||
const dialog = page.getByRole('dialog', { name: title });
|
||
await expect(dialog).toBeVisible();
|
||
await expect(dialog.getByRole('textbox').first()).toBeFocused();
|
||
await page.keyboard.press('Escape');
|
||
await expect(dialog).toHaveCount(0);
|
||
await expect(page.getByRole('button', { name: label, exact: true })).toBeFocused();
|
||
}
|
||
await page.goto('/projects/money-tracker');
|
||
await page.getByRole('heading', { name: 'Accounts', exact: true }).waitFor();
|
||
await page.locator('#accounts').getByRole('button', { name: 'Add', exact: true }).click();
|
||
const moneyDialog = page.getByRole('dialog');
|
||
await expect(moneyDialog).toBeVisible();
|
||
await moneyDialog.getByLabel('Account name', { exact: true }).fill('Tracker integration account');
|
||
await moneyDialog.getByLabel(/^Opening balance/).fill('123.45');
|
||
await moneyDialog.getByRole('button', { name: 'Save', exact: true }).click();
|
||
await expect(moneyDialog).toHaveCount(0);
|
||
expect(
|
||
(await sql`select opening_balance_cents from money_accounts where user_id=${id}`)[0]
|
||
.opening_balance_cents
|
||
).toBe(12345);
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
for (const path of [
|
||
'/projects/game-tracker',
|
||
'/projects/game-tracker/pokemon?game=platinum',
|
||
'/projects/game-tracker/osu',
|
||
'/projects/game-tracker/call-of-duty/black-ops-3'
|
||
]) {
|
||
await page.goto(path);
|
||
await expect(page.locator('h1')).not.toHaveText('500');
|
||
expect(
|
||
await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||
).toBe(true);
|
||
}
|
||
await page.goto('/projects/game-tracker/pokemon?game=platinum');
|
||
await expect(page.getByRole('button', { name: /Turtwig Grass/ })).toBeVisible();
|
||
await page.screenshot({ path: '/tmp/tracker-pokemon-mobile.png', fullPage: true });
|
||
});
|
||
|
||
test('illustrated library uses two Pokémon columns, follows selected games, and picture buttons select one game', async ({
|
||
page
|
||
}) => {
|
||
await register(page.request);
|
||
const games = await sql<{ id: number; slug: string }[]>`select id, slug from games`;
|
||
const selected = ['platinum', 'red', 'white', 'emerald', 'legends-arceus'];
|
||
expect(
|
||
(
|
||
await action(page.request, '/projects/game-tracker/pokemon?/setGames', {
|
||
gameIds: games.filter((g) => selected.includes(g.slug)).map((g) => String(g.id))
|
||
})
|
||
).status
|
||
).toBe(200);
|
||
await page.setViewportSize({ width: 1440, height: 900 });
|
||
await page.goto('/projects/game-tracker');
|
||
await expect(page.locator('.collection-grid .game-row')).toHaveCount(5);
|
||
await expect(page.locator('.collection-grid .game-name')).toHaveText([
|
||
'Red',
|
||
'Emerald',
|
||
'Platinum',
|
||
'White',
|
||
'Legends: Arceus'
|
||
]);
|
||
await expect(page.locator('.cod-rows .game-row')).toHaveCount(7);
|
||
await expect
|
||
.poll(() =>
|
||
page
|
||
.locator('main img')
|
||
.evaluateAll((images) =>
|
||
images.every(
|
||
(i) => (i as HTMLImageElement).complete && (i as HTMLImageElement).naturalWidth > 0
|
||
)
|
||
)
|
||
)
|
||
.toBe(true);
|
||
await page.screenshot({ path: '/tmp/tracker-library-pictures.png', fullPage: true });
|
||
await page.getByRole('link', { name: /Platinum artwork Platinum/ }).click();
|
||
await expect(page.getByRole('button', { name: 'Play Platinum', exact: true })).toHaveAttribute(
|
||
'aria-pressed',
|
||
'true'
|
||
);
|
||
await expect(page.getByRole('combobox', { name: 'Current game' })).toHaveCount(0);
|
||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||
const map = page.getByRole('img', { name: 'Sinnoh region map from the Pokémon games' });
|
||
await expect(map).toBeVisible();
|
||
await expect
|
||
.poll(() => map.evaluate((i) => (i as HTMLImageElement).naturalWidth))
|
||
.toBeGreaterThan(0);
|
||
await page.getByRole('button', { name: 'Explore Route 201', exact: true }).click();
|
||
await expect(page.getByRole('button', { name: /Turtwig Grass/ })).toBeVisible();
|
||
await map.scrollIntoViewIfNeeded();
|
||
await page.screenshot({ path: '/tmp/tracker-real-map.png' });
|
||
await page.getByRole('button', { name: 'Play White', exact: true }).click();
|
||
await expect(page).toHaveURL(/game=white/);
|
||
await expect(page.getByRole('button', { name: 'Play White', exact: true })).toHaveAttribute(
|
||
'aria-pressed',
|
||
'true'
|
||
);
|
||
await expect(page.getByRole('button', { name: 'Play Platinum', exact: true })).toHaveAttribute(
|
||
'aria-pressed',
|
||
'false'
|
||
);
|
||
// Large collections stay readable in exactly two columns; vertical page scrolling is allowed.
|
||
expect(
|
||
(
|
||
await action(page.request, '/projects/game-tracker/pokemon?/setGames', {
|
||
gameIds: games.map((g) => String(g.id))
|
||
})
|
||
).status
|
||
).toBe(200);
|
||
for (const [width, height] of [
|
||
[1440, 900],
|
||
[1280, 720]
|
||
]) {
|
||
await page.setViewportSize({ width, height });
|
||
await page.goto('/projects/game-tracker');
|
||
await expect(page.locator('.collection-grid .game-row')).toHaveCount(games.length);
|
||
const size = await page.evaluate(() => ({
|
||
height: document.documentElement.scrollHeight,
|
||
width: document.documentElement.scrollWidth,
|
||
viewportHeight: innerHeight,
|
||
viewportWidth: innerWidth
|
||
}));
|
||
expect(
|
||
await page
|
||
.locator('.collection-grid')
|
||
.evaluate((el) => getComputedStyle(el).gridTemplateColumns.split(' ').length)
|
||
).toBe(2);
|
||
expect(size.width).toBeLessThanOrEqual(size.viewportWidth);
|
||
const clipped = await page.locator('.game-row').evaluateAll(
|
||
(rows) =>
|
||
rows.filter((r) => {
|
||
const b = r.getBoundingClientRect();
|
||
return b.right > innerWidth || b.height < 56;
|
||
}).length
|
||
);
|
||
|
||
expect(clipped).toBe(0);
|
||
}
|
||
await page.screenshot({ path: '/tmp/tracker-library-all-games.png', fullPage: true });
|
||
await page.goto('/projects/game-tracker/pokemon?game=platinum');
|
||
await expect(
|
||
page.getByRole('group', { name: 'Choose Pokémon game' }).getByRole('button')
|
||
).toHaveCount(games.length);
|
||
await expect
|
||
.poll(() =>
|
||
page
|
||
.locator('.game-cover-button')
|
||
.evaluateAll(
|
||
(buttons) => new Set(buttons.map((b) => Math.round(b.getBoundingClientRect().top))).size
|
||
)
|
||
)
|
||
.toBe(2);
|
||
await expect(page.getByRole('button', { name: 'Switch game', exact: true })).toHaveCount(0);
|
||
const covers = await page.locator('.game-cover-button').evaluateAll((buttons) =>
|
||
buttons.every((b) => {
|
||
const r = b.getBoundingClientRect();
|
||
return r.left >= 0 && r.right <= innerWidth;
|
||
})
|
||
);
|
||
expect(covers).toBe(true);
|
||
});
|
||
|
||
test('live weapon controls save immediately, infer max level from prestige, and handle concurrent updates', async ({
|
||
page,
|
||
playwright
|
||
}) => {
|
||
const userId = await register(page.request);
|
||
const [weapon] =
|
||
await sql`select i.id,i.name from tracker_items i join tracker_games g on i.game_id=g.id where g.slug='black-ops-3' and i.kind='weapon' order by i.sort_order limit 1`;
|
||
const cod = '/projects/game-tracker/call-of-duty/black-ops-3';
|
||
await page.setViewportSize({ width: 1280, height: 800 });
|
||
await page.goto(cod);
|
||
await page.getByRole('button', { name: `${weapon.name} camos and milestones` }).click();
|
||
const card = page.getByRole('dialog', { name: weapon.name, exact: true });
|
||
const level = card.getByRole('button', { name: `Edit ${weapon.name} level and prestige` });
|
||
await expect(card.getByRole('button', { name: `Level down ${weapon.name}` })).toBeDisabled();
|
||
await card.getByRole('button', { name: `Level up ${weapon.name}` }).click();
|
||
await expect(level).toHaveText(/LEVEL2/);
|
||
await card.getByRole('button', { name: `Level down ${weapon.name}` }).click();
|
||
await expect(level).toHaveText(/LEVEL1/);
|
||
// Without known caps + / − only move the level; prestige is typed into the exact-rank dialog.
|
||
await level.click();
|
||
await page.getByLabel('Prestige', { exact: true }).fill('1');
|
||
await page.getByRole('button', { name: 'Save rank' }).click();
|
||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||
await page.getByRole('button', { name: `${weapon.name} camos and milestones` }).click();
|
||
await expect(card).toContainText('Max level reached');
|
||
await expect(card.getByRole('checkbox', { name: /max level/ })).toHaveCount(0);
|
||
expect(
|
||
(
|
||
await sql`select level,prestige from tracker_weapon_ranks where user_id=${userId} and item_id=${weapon.id}`
|
||
)[0]
|
||
).toMatchObject({ level: 1, prestige: 1 });
|
||
const concurrent = await Promise.all(
|
||
Array.from({ length: 4 }, () =>
|
||
action(page.request, cod + '?/stepRank', { itemId: String(weapon.id), delta: '1' })
|
||
)
|
||
);
|
||
expect(concurrent.every((r) => r.status === 200)).toBe(true);
|
||
await page.reload();
|
||
await page.getByRole('button', { name: `${weapon.name} camos and milestones` }).click();
|
||
await expect(level).toHaveText(/LEVEL5/);
|
||
await page.route('**/*stepRank', (route) => route.abort());
|
||
await card.getByRole('button', { name: `Level up ${weapon.name}` }).click();
|
||
await expect(card.getByRole('status')).toContainText(/fetch|save|network/i);
|
||
await expect(level).toHaveText(/LEVEL5/);
|
||
await page.unroute('**/*stepRank');
|
||
await card.getByRole('button', { name: `Level up ${weapon.name}` }).click();
|
||
await expect(level).toHaveText(/LEVEL6/);
|
||
for (const delta of ['999', '0', '1.5', ''])
|
||
expect(
|
||
(await action(page.request, cod + '?/stepRank', { itemId: String(weapon.id), delta })).status
|
||
).toBe(400);
|
||
expect(
|
||
(
|
||
await action(page.request, '/projects/game-tracker/call-of-duty/wwii?/stepRank', {
|
||
itemId: String(weapon.id),
|
||
delta: '1'
|
||
})
|
||
).status
|
||
).toBe(404);
|
||
const anonymous = await playwright.request.newContext({ baseURL: origin });
|
||
expect(
|
||
(
|
||
await action(anonymous, cod + '?/stepRank', {
|
||
itemId: String(weapon.id),
|
||
delta: '1'
|
||
})
|
||
).status
|
||
).toBe(401);
|
||
await anonymous.dispose();
|
||
await page.keyboard.press('Escape');
|
||
const cards = await page
|
||
.locator('.weapon-card')
|
||
.evaluateAll((cards) => cards.map((c) => c.getBoundingClientRect().bottom));
|
||
expect(Math.max(...cards)).toBeLessThanOrEqual(800);
|
||
await page.screenshot({ path: '/tmp/tracker-live-cod.png' });
|
||
});
|
||
|
||
test('crowded Pokémon areas show every species without a scrolling list and allow a catch from the map', async ({
|
||
page
|
||
}) => {
|
||
await register(page.request);
|
||
await page.setViewportSize({ width: 1280, height: 800 });
|
||
await page.goto('/projects/game-tracker/pokemon?game=platinum');
|
||
await expect(page.getByRole('button', { name: /Turtwig Grass/ })).toBeVisible();
|
||
const [{ id: locationId }] =
|
||
await sql`select id from pokemon_locations where slug='sinnoh-route-210'`;
|
||
const response = page.waitForResponse((r) => {
|
||
const url = new URL(r.url());
|
||
return (
|
||
url.pathname.endsWith('/api/encounters') &&
|
||
url.searchParams.get('location') === String(locationId)
|
||
);
|
||
});
|
||
await page.getByLabel('Encounter location', { exact: true }).selectOption('sinnoh-route-210');
|
||
const rows = await (await response).json();
|
||
const special = page.getByRole('checkbox', { name: /Include special conditions/ });
|
||
if (await special.count()) await special.check();
|
||
const expected = new Set(rows.map((r: { number: number }) => r.number)).size;
|
||
const overview = page.getByRole('group', { name: 'All Pokémon in this area' });
|
||
await expect(overview.getByRole('button')).toHaveCount(expected);
|
||
expect(expected).toBeGreaterThan(20);
|
||
const bounds = await overview.evaluate((el) => {
|
||
const parent = el.getBoundingClientRect();
|
||
return {
|
||
height: el.clientHeight,
|
||
scroll: el.scrollHeight,
|
||
fits: [...el.querySelectorAll('button')].every((b) => {
|
||
const r = b.getBoundingClientRect();
|
||
return (
|
||
r.bottom <= parent.bottom + 1 &&
|
||
r.right <= parent.right + 1 &&
|
||
r.width >= 20 &&
|
||
r.height >= 20
|
||
);
|
||
})
|
||
};
|
||
});
|
||
expect(bounds.scroll).toBeLessThanOrEqual(bounds.height + 1);
|
||
expect(bounds.fits).toBe(true);
|
||
// The overview itself always fits without a scrolling list. A large cover library
|
||
// can move the workspace below the fold, so bring that panel into view first.
|
||
await overview.scrollIntoViewIfNeeded();
|
||
await expect
|
||
.poll(() => overview.evaluate((el) => el.getBoundingClientRect().bottom <= innerHeight))
|
||
.toBe(true);
|
||
await page.screenshot({ path: '/tmp/tracker-compact-pokemon.png' });
|
||
await page.getByRole('button', { name: 'Expand encounters' }).click();
|
||
const expanded = page.getByRole('dialog');
|
||
await expect(expanded.getByRole('group').getByRole('button')).toHaveCount(expected);
|
||
await expanded.getByRole('group').getByRole('button').first().click();
|
||
const details = page.getByRole('dialog').last();
|
||
await expect(details).toContainText('Lv.');
|
||
await details.getByRole('button', { name: 'Mark as seen', exact: true }).click();
|
||
await expect(details.getByRole('button', { name: '✓ Seen · undo', exact: true })).toBeEnabled();
|
||
await details.getByRole('button', { name: 'Register a catch', exact: true }).click();
|
||
const editor = page.getByRole('dialog', { name: /^Register / });
|
||
await expect(editor).toContainText('Add to my collection');
|
||
await expect(page.locator('[role="dialog"][data-state="closed"]')).toHaveCount(0);
|
||
await editor.getByLabel('Nickname', { exact: true }).fill('Route companion');
|
||
await editor.getByRole('button', { name: 'Add to my collection', exact: true }).click();
|
||
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||
await expect(overview.getByRole('button').first()).toHaveAttribute('aria-label', /caught/);
|
||
for (const [width, height] of [
|
||
[1280, 720],
|
||
[768, 1024]
|
||
]) {
|
||
await page.setViewportSize({ width, height });
|
||
await expect(overview.getByRole('button')).toHaveCount(expected);
|
||
await overview.scrollIntoViewIfNeeded();
|
||
await expect
|
||
.poll(() => overview.evaluate((el) => el.getBoundingClientRect().bottom <= innerHeight))
|
||
.toBe(true);
|
||
}
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await expect(page.getByRole('tab', { name: 'Area Pokémon', exact: true })).toBeVisible();
|
||
await expect(overview.getByRole('button')).toHaveCount(expected);
|
||
await overview.scrollIntoViewIfNeeded();
|
||
await expect
|
||
.poll(() => overview.evaluate((el) => el.getBoundingClientRect().bottom <= innerHeight))
|
||
.toBe(true);
|
||
await page.screenshot({ path: '/tmp/tracker-crowded-mobile.png' });
|
||
await expect(
|
||
page.getByRole('img', { name: 'Sinnoh region map from the Pokémon games' })
|
||
).not.toBeVisible();
|
||
await page.getByRole('tab', { name: 'Region map', exact: true }).click();
|
||
await page.getByRole('button', { name: 'Explore Route 201', exact: true }).click();
|
||
await expect(page.getByRole('button', { name: /Turtwig Grass/ })).toBeVisible();
|
||
await page.screenshot({ path: '/tmp/tracker-live-mobile.png' });
|
||
});
|
||
|
||
test('visible progress reacts to sightings, catches and completed stories without double-counting species', async ({
|
||
page,
|
||
playwright
|
||
}) => {
|
||
const userId = await register(page.request);
|
||
const games = await sql<
|
||
{ id: number; slug: string }[]
|
||
>`select id,slug from games where slug in ('platinum','diamond')`;
|
||
const platinum = games.find((g) => g.slug === 'platinum')!;
|
||
const diamond = games.find((g) => g.slug === 'diamond')!;
|
||
const [species] = await sql`select id from pokemon where number=387`;
|
||
await action(page.request, '/projects/game-tracker/pokemon?/setGames', {
|
||
gameIds: games.map((g) => String(g.id))
|
||
});
|
||
await page.setViewportSize({ width: 1440, height: 900 });
|
||
await page.goto('/projects/game-tracker/pokemon?game=platinum');
|
||
const collection = page.getByRole('progressbar', { name: 'National collection', exact: true });
|
||
const sightings = page.getByRole('progressbar', {
|
||
name: 'Species seen at least once',
|
||
exact: true
|
||
});
|
||
const completed = page.getByRole('progressbar', { name: 'Games completed', exact: true });
|
||
const gameProgress = page.getByRole('progressbar', { name: 'Platinum completion', exact: true });
|
||
await expect(collection).toHaveAttribute('aria-valuenow', '0');
|
||
await page.getByRole('tab', { name: 'Pokédex', exact: true }).click();
|
||
await page.getByRole('checkbox', { name: 'Seen Turtwig in Platinum', exact: true }).check();
|
||
await expect
|
||
.poll(async () => Number(await sightings.getAttribute('aria-valuenow')))
|
||
.toBeGreaterThan(0);
|
||
await expect(collection).toHaveAttribute('aria-valuenow', '0');
|
||
await page.reload();
|
||
await expect
|
||
.poll(async () => Number(await sightings.getAttribute('aria-valuenow')))
|
||
.toBeGreaterThan(0);
|
||
await page.getByRole('tab', { name: 'Pokédex', exact: true }).click();
|
||
await page.getByRole('checkbox', { name: 'Caught Turtwig in Platinum', exact: true }).check();
|
||
await expect
|
||
.poll(async () => Number(await collection.getAttribute('aria-valuenow')))
|
||
.toBeGreaterThan(0);
|
||
const first = await collection.getAttribute('aria-valuenow');
|
||
const [entry] =
|
||
await sql`select id from pokemon_game_entries where game_id=${diamond.id} and pokemon_id=${species.id}`;
|
||
await action(page.request, '/projects/game-tracker/pokemon?/togglePokemonCaught', {
|
||
entryId: String(entry.id),
|
||
caught: 'on'
|
||
});
|
||
await page.reload();
|
||
await expect(collection).toHaveAttribute('aria-valuenow', first!);
|
||
const before = Number(await gameProgress.getAttribute('aria-valuenow'));
|
||
await page.getByRole('button', { name: 'Mark story complete', exact: true }).click();
|
||
await expect(completed).toHaveAttribute('aria-valuenow', '50');
|
||
await expect
|
||
.poll(async () => Number(await gameProgress.getAttribute('aria-valuenow')))
|
||
.toBeGreaterThan(before);
|
||
await page.getByRole('tab', { name: 'Badges & milestones', exact: true }).click();
|
||
await page.getByRole('button', { name: 'Complete Coal · Roark', exact: true }).click();
|
||
await expect(
|
||
page.getByRole('progressbar', { name: 'Badges & trials', exact: true })
|
||
).toHaveAttribute('aria-valuenow', '12.5');
|
||
await page.getByRole('button', { name: 'Full breakdown', exact: true }).click();
|
||
await expect(
|
||
page.getByRole('dialog', { name: 'Your Pokémon progress' }).getByRole('progressbar')
|
||
).toHaveCount(16);
|
||
await page.screenshot({ path: '/tmp/tracker-progress-pokemon.png' });
|
||
await page.keyboard.press('Escape');
|
||
await action(page.request, '/projects/game-tracker/pokemon?/saveJourney', {
|
||
gameId: String(platinum.id),
|
||
stage: 'postgame',
|
||
location: 'sinnoh-route-201'
|
||
});
|
||
expect(
|
||
(
|
||
await sql`select completed from pokemon_journeys where user_id=${userId} and game_id=${platinum.id}`
|
||
)[0].completed
|
||
).toBe(true);
|
||
const unseen = await action(page.request, '/projects/game-tracker/pokemon?/setSeen', {
|
||
gameId: String(platinum.id),
|
||
pokemonId: String(species.id),
|
||
seen: 'off'
|
||
});
|
||
expect(unseen.status).toBe(400);
|
||
const anonymous = await playwright.request.newContext({ baseURL: origin });
|
||
expect(
|
||
(
|
||
await action(anonymous, '/projects/game-tracker/pokemon?/setSeen', {
|
||
gameId: String(platinum.id),
|
||
pokemonId: String(species.id),
|
||
seen: 'on'
|
||
})
|
||
).status
|
||
).toBe(401);
|
||
await anonymous.dispose();
|
||
await page.goto('/projects/game-tracker');
|
||
await expect(
|
||
page.getByRole('progressbar', { name: 'National collection', exact: true })
|
||
).toHaveAttribute('aria-valuenow', first!);
|
||
await expect(
|
||
page.getByRole('progressbar', { name: 'Pokémon games completed', exact: true })
|
||
).toHaveAttribute('aria-valuenow', '50');
|
||
await page.screenshot({ path: '/tmp/tracker-progress-index.png', fullPage: true });
|
||
});
|
||
|
||
test('partial COD challenges and osu goals visibly advance completion meters', async ({ page }) => {
|
||
await register(page.request);
|
||
const cod = '/projects/game-tracker/call-of-duty/black-ops-3';
|
||
const [challenge] =
|
||
await sql`select c.* from tracker_challenges c join tracker_games g on g.id=c.game_id where g.slug='black-ops-3' and c.target>=10 order by c.id limit 1`;
|
||
await page.goto(cod);
|
||
const overall = page.getByRole('progressbar', { name: 'Game completion', exact: true });
|
||
await expect(overall).toHaveAttribute('aria-valuenow', '0');
|
||
await page.getByRole('tab', { name: 'Challenges', exact: true }).click();
|
||
await page.getByLabel('Search challenges').fill(challenge.name);
|
||
const card = page
|
||
.getByRole('article')
|
||
.filter({ has: page.getByRole('heading', { name: challenge.name, exact: true }) });
|
||
await card.getByRole('spinbutton').fill(String(Math.floor(challenge.target / 2)));
|
||
await card.getByRole('button', { name: 'Save', exact: true }).click();
|
||
await expect
|
||
.poll(async () => Number(await overall.getAttribute('aria-valuenow')))
|
||
.toBeGreaterThan(0);
|
||
const partial = Number(await overall.getAttribute('aria-valuenow'));
|
||
await card.getByRole('checkbox', { name: 'Unlocked' }).check();
|
||
await card.getByRole('button', { name: 'Save', exact: true }).click();
|
||
await expect
|
||
.poll(async () => Number(await overall.getAttribute('aria-valuenow')))
|
||
.toBeGreaterThan(partial);
|
||
await page.getByRole('tab', { name: 'Weapons', exact: true }).click();
|
||
await page.screenshot({ path: '/tmp/tracker-progress-cod.png' });
|
||
await action(page.request, '/projects/game-tracker/osu?/save', {
|
||
kind: 'goal',
|
||
title: 'Progress test FC',
|
||
mode: 'osu',
|
||
goal: 'fc',
|
||
skill: 'stamina',
|
||
playedOn: '2026-09-25',
|
||
rate: '1.00',
|
||
mods: 'NM'
|
||
});
|
||
await page.goto('/projects/game-tracker/osu');
|
||
await expect(
|
||
page.getByRole('progressbar', { name: 'All map goals', exact: true })
|
||
).toHaveAttribute('aria-valuenow', '0');
|
||
await page.getByRole('button', { name: 'Achieved!', exact: true }).click();
|
||
await expect(
|
||
page.getByRole('progressbar', { name: 'All map goals', exact: true })
|
||
).toHaveAttribute('aria-valuenow', '100');
|
||
await expect(
|
||
page.getByRole('progressbar', { name: 'Full combo goals', exact: true })
|
||
).toHaveAttribute('aria-valuenow', '100');
|
||
});
|
||
|
||
test('BO2 armoury fits every gun, enforces caps and advances all rank progress with each level', async ({
|
||
page
|
||
}) => {
|
||
const userId = await register(page.request);
|
||
const cod = '/projects/game-tracker/call-of-duty/black-ops-2';
|
||
const [m27] =
|
||
await sql`select i.id from tracker_items i join tracker_games g on g.id=i.game_id where g.slug='black-ops-2' and i.name='M27'`;
|
||
await page.setViewportSize({ width: 1280, height: 720 });
|
||
await page.goto(cod);
|
||
await expect(page.locator('.weapon-card')).toHaveCount(40);
|
||
for (const [width, height] of [
|
||
[1280, 720],
|
||
[1920, 1080]
|
||
]) {
|
||
await page.setViewportSize({ width, height });
|
||
const bounds = await page.locator('.weapon-card').evaluateAll((cards) =>
|
||
cards.map((c) => {
|
||
const b = c.getBoundingClientRect();
|
||
const section = c.closest('section')!.getBoundingClientRect();
|
||
return {
|
||
name: c.getAttribute('aria-label'),
|
||
fits:
|
||
b.bottom <= section.bottom + 1 &&
|
||
b.bottom <= innerHeight &&
|
||
b.right <= innerWidth &&
|
||
b.height >= 29
|
||
};
|
||
})
|
||
);
|
||
expect(bounds.filter((b) => !b.fits)).toEqual([]);
|
||
expect(await page.evaluate(() => document.documentElement.scrollHeight)).toBeLessThanOrEqual(
|
||
height
|
||
);
|
||
await expect
|
||
.poll(() =>
|
||
page
|
||
.locator('.weapon-card img')
|
||
.evaluateAll((images) =>
|
||
images.every(
|
||
(i) => (i as HTMLImageElement).complete && (i as HTMLImageElement).naturalWidth > 0
|
||
)
|
||
)
|
||
)
|
||
.toBe(true);
|
||
}
|
||
for (const name of [
|
||
'M27',
|
||
'AN-94',
|
||
'DSR 50',
|
||
'Peacekeeper',
|
||
'MP7',
|
||
'Vector K10',
|
||
'Skorpion EVO'
|
||
]) {
|
||
await expect(
|
||
page
|
||
.getByRole('article', { name, exact: true })
|
||
.getByRole('button', { name: `View ${name} artwork` })
|
||
).toBeVisible();
|
||
}
|
||
const card = page.getByRole('article', { name: 'M27', exact: true });
|
||
const levels = page.getByRole('progressbar', { name: 'Weapon levels', exact: true });
|
||
const overall = page.getByRole('progressbar', { name: 'Game completion', exact: true });
|
||
await expect(levels).toHaveAttribute('aria-valuenow', '0');
|
||
await card.getByRole('button', { name: 'Level up M27', exact: true }).click();
|
||
const m27Rank = card.getByRole('button', { name: 'Edit M27 level and prestige' });
|
||
await expect(m27Rank).toHaveText('P0 2/17');
|
||
const first = Number(await levels.getAttribute('aria-valuenow'));
|
||
expect(first).toBeGreaterThan(0);
|
||
const before = Number(await overall.getAttribute('aria-valuenow'));
|
||
await card.getByRole('button', { name: 'Level up M27', exact: true }).click();
|
||
await expect
|
||
.poll(async () => Number(await levels.getAttribute('aria-valuenow')))
|
||
.toBeGreaterThan(first);
|
||
await expect
|
||
.poll(async () => Number(await overall.getAttribute('aria-valuenow')))
|
||
.toBeGreaterThan(before);
|
||
expect(
|
||
(
|
||
await action(page.request, cod + '?/saveRank', {
|
||
itemId: String(m27.id),
|
||
level: '18',
|
||
prestige: '2'
|
||
})
|
||
).status
|
||
).toBe(400);
|
||
expect(
|
||
(
|
||
await action(page.request, cod + '?/saveRank', {
|
||
itemId: String(m27.id),
|
||
level: '17',
|
||
prestige: '3'
|
||
})
|
||
).status
|
||
).toBe(400);
|
||
await action(page.request, cod + '?/saveRank', {
|
||
itemId: String(m27.id),
|
||
level: '17',
|
||
prestige: '0'
|
||
});
|
||
// Presses from two devices at max level both count: level 17 → prestige 1 level 2.
|
||
const simultaneous = await Promise.all(
|
||
[1, 2].map(() =>
|
||
action(page.request, cod + '?/stepRank', { itemId: String(m27.id), delta: '1' })
|
||
)
|
||
);
|
||
expect(simultaneous.every((r) => r.status === 200)).toBe(true);
|
||
expect(
|
||
(
|
||
await sql`select level,prestige from tracker_weapon_ranks where user_id=${userId} and item_id=${m27.id}`
|
||
)[0]
|
||
).toMatchObject({ level: 2, prestige: 1 });
|
||
await page.reload();
|
||
await expect(m27Rank).toHaveText('P1 2/17');
|
||
await card.getByRole('button', { name: 'Level down M27', exact: true }).click();
|
||
await card.getByRole('button', { name: 'Level down M27', exact: true }).click();
|
||
await expect(m27Rank).toHaveText('P0 17/17');
|
||
await expect
|
||
.poll(
|
||
async () =>
|
||
(
|
||
await sql`select level,prestige from tracker_weapon_ranks where user_id=${userId} and item_id=${m27.id}`
|
||
)[0]
|
||
)
|
||
.toMatchObject({ level: 17, prestige: 0 });
|
||
await card.getByRole('button', { name: 'Level up M27', exact: true }).click();
|
||
await expect(m27Rank).toHaveText('P1 1/17');
|
||
await action(page.request, cod + '?/saveRank', { level: '55', prestige: '0' });
|
||
await page.reload();
|
||
const badge = page.getByLabel('Player level and prestige');
|
||
await page.getByRole('button', { name: 'Level up player rank', exact: true }).click();
|
||
await expect(badge).toContainText('PRESTIGE1/10');
|
||
await expect(badge).toContainText('LEVEL1/55');
|
||
await page.getByRole('button', { name: 'Level down player rank', exact: true }).click();
|
||
await expect(badge).toContainText('LEVEL55/55');
|
||
await expect
|
||
.poll(
|
||
async () => (await sql`select level,prestige from tracker_ranks where user_id=${userId}`)[0]
|
||
)
|
||
.toMatchObject({ level: 55, prestige: 0 });
|
||
await action(page.request, cod + '?/saveRank', {
|
||
itemId: String(m27.id),
|
||
level: '17',
|
||
prestige: '2'
|
||
});
|
||
await action(page.request, cod + '?/saveRank', { level: '42', prestige: '3' });
|
||
await page.reload();
|
||
await expect(m27Rank).toHaveText('P2 17/17');
|
||
await expect(card.getByRole('button', { name: 'Level up M27' })).toBeDisabled();
|
||
await expect(page.getByRole('img', { name: 'Prestige 3 emblem' })).toBeVisible();
|
||
const progress = await overall.getAttribute('aria-valuenow');
|
||
await page.goto('/projects/game-tracker');
|
||
await expect(
|
||
page.getByRole('progressbar', { name: 'Black Ops II progress', exact: true })
|
||
).toHaveAttribute('aria-valuenow', progress!);
|
||
await page.goto(cod);
|
||
await page.setViewportSize({ width: 1280, height: 720 });
|
||
await page.screenshot({ path: '/tmp/bo2-verified-desktop.png' });
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await page.screenshot({ path: '/tmp/bo2-verified-mobile.png', fullPage: true });
|
||
await expect
|
||
.poll(() => page.evaluate(() => document.documentElement.scrollWidth))
|
||
.toBeLessThanOrEqual(390);
|
||
});
|