Expand website and game trackers
Some checks failed
Test and deploy staging / staging (push) Failing after 5m29s
Some checks failed
Test and deploy staging / staging (push) Failing after 5m29s
This commit is contained in:
841
tests/game-tracker.test.ts
Normal file
841
tests/game-tracker.test.ts
Normal file
@@ -0,0 +1,841 @@
|
||||
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: 'Update my rank' }).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.getByText('Level 42 · Prestige 3')).toBeVisible();
|
||||
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 expect(weaponCard).toContainText('Max level reached');
|
||||
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);
|
||||
const card = page.getByRole('article', { 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/);
|
||||
await card.getByRole('button', { name: `Prestige up ${weapon.name}` }).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 + '?/adjustWeaponRank', {
|
||||
itemId: String(weapon.id),
|
||||
field: 'level',
|
||||
delta: '1'
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(concurrent.every((r) => r.status === 200)).toBe(true);
|
||||
await page.reload();
|
||||
await expect(level).toHaveText(/LEVEL5/);
|
||||
await page.route('**/*adjustWeaponRank', (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('**/*adjustWeaponRank');
|
||||
await card.getByRole('button', { name: `Level up ${weapon.name}` }).click();
|
||||
await expect(level).toHaveText(/LEVEL6/);
|
||||
expect(
|
||||
(
|
||||
await action(page.request, cod + '?/adjustWeaponRank', {
|
||||
itemId: String(weapon.id),
|
||||
field: 'level',
|
||||
delta: '999'
|
||||
})
|
||||
).status
|
||||
).toBe(400);
|
||||
expect(
|
||||
(
|
||||
await action(page.request, '/projects/game-tracker/call-of-duty/wwii?/adjustWeaponRank', {
|
||||
itemId: String(weapon.id),
|
||||
field: 'level',
|
||||
delta: '1'
|
||||
})
|
||||
).status
|
||||
).toBe(404);
|
||||
const anonymous = await playwright.request.newContext({ baseURL: origin });
|
||||
expect(
|
||||
(
|
||||
await action(anonymous, cod + '?/adjustWeaponRank', {
|
||||
itemId: String(weapon.id),
|
||||
field: 'level',
|
||||
delta: '1'
|
||||
})
|
||||
).status
|
||||
).toBe(401);
|
||||
await anonymous.dispose();
|
||||
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: 'All progress ↗', 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');
|
||||
});
|
||||
Reference in New Issue
Block a user