- tools/build-data.mjs bountyDraftHalf: exact faction-series counts (12 of 18 two-battle cards bring first-half enemies, 5 outside the wave tables, enemyeffect_12_4 a second-half attachment), base-HP gradient, the user's two-battle recall; counter-evidence (假想敌 named enemyInitial_*, the unread NGA R3 guide); the split marked [ASSUMED] with its flip; data unchanged - server/match/choices.js, docs/META.md, docs/DATA.md: the split marked [ASSUMED] - m.private shop.rewardOffer.queued: offers waiting behind the shown one; the header says 之后还有 N 项 and the reminder pill +N (a 定向投放 behind a promotion reward put off) - tests: corrected headers, the stale bountyTiers fixture dropped, queued checks (unit + opt-in e2e on desktop and 756x366)
203 lines
13 KiB
JavaScript
203 lines
13 KiB
JavaScript
// Real-server browser E2E of player report #6 after 0.1.0: "凯瑟琳策略升级刷新的三个装备显示异常". A solo 险境 prep with the
|
||
// strategy 凯瑟琳 (【定向投放】"每次升级调度中心时,在调度中心刷新随机3件装备,可以选择并获得其中1件"; test/e2e/fastServer.mjs
|
||
// SP_START_ROUND 2: a starter kit + 20 funds) is driven with real input: the LEVEL card is tapped twice (确认升级) and the
|
||
// three free items replace the bar's operator cards — each drawn as an item card (icon, name, tier, description, FREE),
|
||
// under a 定向投放 header (not 晋升奖励 / PROMOTION), with the item's detail on the first tap; the second tap takes it into
|
||
// the hand and the bar shows the shop again. Desktop 1920×1080 and the phones 844×390 / 756×366 (touch). Zero console
|
||
// errors. Screenshots: test/e2e/out/fb1-offer-*.png. A second case puts the offer off (稍后) and levels up again: the
|
||
// pill reads 定向投放待选择 +1, the header 之后还有 1 项, and the second offer follows the first pick
|
||
// (test/e2e/out/fb1-queue-*.png).
|
||
//
|
||
// SP_E2E=1 node --test test/ui/feedback1-offers.e2e.test.js
|
||
|
||
import { test, describe } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { existsSync } from 'node:fs';
|
||
import path from 'node:path';
|
||
import { Client, ROOT, sleep, hasChrome, startRealServer, problemsOf } from '../e2e/client.mjs';
|
||
|
||
const ENABLED = process.env.SP_E2E === '1' && hasChrome() && existsSync(path.join(ROOT, 'public/assets'));
|
||
|
||
async function soloToPrep(c, bandName) {
|
||
await c.open();
|
||
await c.enter('煌');
|
||
await c.click('.mode-card', '独立模拟');
|
||
await c.click('.diff-card', '险境');
|
||
await c.click('.create-box button', '开始独立模拟');
|
||
await c.waitFor((s) => !!s.room, 'solo room');
|
||
if (!(await c.st()).phase) await c.click('.room-bar__right button', '开始模拟', { timeout: 20000 });
|
||
await c.waitFor((s) => s.phase === 'INFO_CHECK', 'briefing', 30000);
|
||
await c.click('.brief__foot .btn--primary', '准备就绪');
|
||
await c.waitFor((s) => s.phase === 'BAND_DRAFT', 'band draft', 30000);
|
||
await c.click('.dband', bandName);
|
||
await c.click('.draft-detail__btns .btn--primary', '确认选择');
|
||
await c.waitFor((x) => x.phase === 'PREP' && !x.ready, 'prep', 60000);
|
||
await sleep(1800);
|
||
}
|
||
|
||
/** The offer as drawn: header texts, the cards (kind, name, description, art, free tag) and their boxes. */
|
||
function offerDom(c) {
|
||
return c.page.evaluate(() => {
|
||
const box = (el) => { if (!el) return null; const r = el.getBoundingClientRect(); return { l: r.left, t: r.top, r: r.right, b: r.bottom, w: r.width, h: r.height }; };
|
||
const root = document.querySelector('.shopbar__reward');
|
||
if (!root) return null;
|
||
const cards = [...root.querySelectorAll('.scard')].map((el) => ({
|
||
item: el.classList.contains('scard--item'),
|
||
name: el.querySelector('.scard__name')?.textContent.trim() || '',
|
||
desc: el.querySelector('.scard__idesc')?.textContent.trim() || '',
|
||
icon: !!el.querySelector('.scard__itemart img'),
|
||
free: !!el.querySelector('.scard__free'),
|
||
tier: el.querySelector('.tierchip, [class*="tier"]')?.textContent.trim() || '',
|
||
box: box(el),
|
||
clipped: el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1,
|
||
}));
|
||
return {
|
||
title: root.querySelector('.rwtag__title')?.textContent.trim() || '',
|
||
micro: root.querySelector('.rwtag__micro')?.textContent.trim() || '',
|
||
sub: root.querySelector('.rwtag__sub')?.textContent.trim() || '',
|
||
cards, root: box(root), vw: innerWidth, vh: innerHeight,
|
||
};
|
||
});
|
||
}
|
||
|
||
const privOf = (c) => c.page.evaluate(() => globalThis.__SP__.store.get().match.private);
|
||
|
||
describe('player report #6: 凯瑟琳\'s three free items after a level-up (real server, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome + assets) to run' }, () => {
|
||
test('the level-up offer shows three item cards (desktop and phones); a pick puts the item into the hand', { timeout: 240000 }, async () => {
|
||
const puppeteer = (await import('puppeteer-core')).default;
|
||
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 4, startRound: 2 } });
|
||
const c = new Client(puppeteer, srv.base, 'cathy', { prefix: 'fb1-offer' });
|
||
try {
|
||
await soloToPrep(c, '凯瑟琳');
|
||
assert.equal((await privOf(c)).bandId, 'band_cathy', 'strategy 凯瑟琳');
|
||
// LEVEL: first tap arms 确认升级, the second upgrades
|
||
await c.click('.lvcard');
|
||
await sleep(250);
|
||
await c.click('.lvcard');
|
||
await c.page.waitForFunction(() => !!globalThis.__SP__.store.get().match.private?.shop?.rewardOffer, { timeout: 8000 });
|
||
await sleep(600);
|
||
const priv = await privOf(c);
|
||
const offer = priv.shop.rewardOffer;
|
||
assert.equal(offer.slots.length, 3, 'three items');
|
||
assert.ok(offer.slots.every((s) => s.kind === 'item' && s.price === 0), JSON.stringify(offer));
|
||
const names = await c.page.evaluate((ids) => ids.map((id) => globalThis.__SP__.data.lookup('items', id)?.name), offer.slots.map((s) => s.id));
|
||
await c.shot('desktop');
|
||
const dom = await offerDom(c);
|
||
assert.ok(dom, 'the offer is in the bar');
|
||
assert.equal(dom.title, '定向投放', `the header names the strategy (${dom.title} / ${dom.micro})`);
|
||
assert.match(dom.sub, /免费选择 1 件/);
|
||
assert.deepEqual(dom.cards.map((x) => x.name), names, 'the item names');
|
||
for (const card of dom.cards) {
|
||
assert.ok(card.item, `${card.name}: drawn as an item card`);
|
||
assert.ok(card.icon, `${card.name}: the item icon`);
|
||
assert.ok(card.desc.length > 2, `${card.name}: the description`);
|
||
assert.ok(card.free, `${card.name}: FREE`);
|
||
}
|
||
// phones (touch, landscape): the same cards inside the viewport, none overlapping another
|
||
const browserUa = await c.browser.userAgent();
|
||
for (const [w, h] of [[844, 390], [756, 366]]) {
|
||
await c.page.emulate({ viewport: { width: w, height: h, deviceScaleFactor: 2, isMobile: true, hasTouch: true, isLandscape: true }, userAgent: browserUa });
|
||
await c.page.waitForFunction(() => !!document.querySelector('.shopbar__reward .scard'), { timeout: 30000 });
|
||
await sleep(900);
|
||
await c.page.screenshot({ path: path.join(ROOT, 'test/e2e/out', `fb1-offer-${w}x${h}.png`) });
|
||
const d = await offerDom(c);
|
||
assert.ok(d && d.cards.length === 3, `${w}×${h}: three cards`);
|
||
for (const card of d.cards) {
|
||
assert.ok(card.item && card.icon, `${w}×${h} ${card.name}: an item card with its icon`);
|
||
assert.ok(card.box.l >= 0 && card.box.r <= w + 0.5 && card.box.t >= 0 && card.box.b <= h + 0.5, `${w}×${h} ${card.name}: inside the viewport ${JSON.stringify(card.box)}`);
|
||
}
|
||
for (let i = 1; i < d.cards.length; i++) assert.ok(d.cards[i].box.l >= d.cards[i - 1].box.r - 0.5, `${w}×${h}: cards ${i - 1} / ${i} do not overlap`);
|
||
}
|
||
await c.page.emulate({ viewport: { width: 1920, height: 1080, deviceScaleFactor: 1, isMobile: false, hasTouch: false }, userAgent: browserUa });
|
||
// switching touch emulation reloads the page: the session resumes with the offer still queued (re-hook the intents)
|
||
await c.page.waitForFunction(() => !!globalThis.__SP__?.store.get().match.private?.shop?.rewardOffer && !!document.querySelector('.shopbar__reward'), { timeout: 30000 });
|
||
await sleep(900);
|
||
await c.hookRequests();
|
||
// first tap: armed + the item's detail; second tap: taken
|
||
const want = names[1];
|
||
await c.click('.shopbar__reward .scard', want);
|
||
await sleep(400);
|
||
const armed = await c.page.evaluate(() => ({
|
||
armed: document.querySelector('.shopbar__reward .scard.is-armed .scard__name')?.textContent.trim() || null,
|
||
strip: document.querySelector('.shopbar__reward .scard.is-armed .scard__confirm')?.textContent.trim() || null,
|
||
detail: document.querySelector('.dpanel')?.textContent || '',
|
||
}));
|
||
assert.equal(armed.armed, want, 'the tapped card is armed');
|
||
assert.match(armed.strip || '', /确认选择/);
|
||
assert.ok(armed.detail.includes(want), 'the detail card shows the item');
|
||
await c.shot('armed');
|
||
await c.click('.shopbar__reward .scard.is-armed');
|
||
await c.page.waitForFunction(() => !globalThis.__SP__.store.get().match.private?.shop?.rewardOffer, { timeout: 8000 });
|
||
const after = await privOf(c);
|
||
const got = await c.page.evaluate((hand) => hand.filter((p) => p && p.kind === 'item').map((p) => globalThis.__SP__.data.lookup('items', p.id)?.name), after.hand);
|
||
assert.ok(got.includes(want), `the item is in the hand (${got.join(', ')})`);
|
||
assert.equal((await c.requests('g.reward')).length, 1, 'one g.reward');
|
||
await sleep(300);
|
||
assert.ok(await c.exists('.shopbar__cards'), 'the shop is back');
|
||
await c.shot('after');
|
||
assert.deepEqual(problemsOf([c]), []);
|
||
} finally {
|
||
await c.close();
|
||
await srv.stop();
|
||
}
|
||
});
|
||
|
||
test('an offer put off (稍后) and a second level-up: the pill says "+1", the header "之后还有 1 项", then the second offer', { timeout: 180000 }, async () => {
|
||
const puppeteer = (await import('puppeteer-core')).default;
|
||
const srv = await startRealServer({ fast: { timerScale: 0.5, combatSpeed: 4, startRound: 2 } });
|
||
const c = new Client(puppeteer, srv.base, 'cathy2', { prefix: 'fb1-queue' });
|
||
const levelUp = async () => { await c.click('.lvcard'); await sleep(250); await c.click('.lvcard'); };
|
||
const offerOf = () => c.page.evaluate(() => globalThis.__SP__.store.get().match.private?.shop?.rewardOffer || null);
|
||
try {
|
||
await soloToPrep(c, '凯瑟琳');
|
||
await levelUp();
|
||
await c.page.waitForFunction(() => !!document.querySelector('.shopbar__reward .scard'), { timeout: 8000 });
|
||
await c.click('.rwtag__later');
|
||
await c.page.waitForFunction(() => !!document.querySelector('.rewardpill') && !!document.querySelector('.shopbar__cards'), { timeout: 8000 });
|
||
await levelUp();
|
||
await c.page.waitForFunction(() => globalThis.__SP__.store.get().match.private?.shop?.rewardOffer?.queued === 1, { timeout: 8000 });
|
||
await sleep(400);
|
||
const pill = await c.page.evaluate(() => document.querySelector('.rewardpill')?.textContent.replace(/\s+/g, '') || '');
|
||
assert.match(pill, /定向投放待选择/);
|
||
assert.match(pill, /\+1/, `the pill counts the offer behind (${pill})`);
|
||
await c.shot('pill');
|
||
await c.click('.rewardpill');
|
||
await c.page.waitForFunction(() => !!document.querySelector('.shopbar__reward .scard'), { timeout: 8000 });
|
||
await sleep(300);
|
||
assert.equal(await c.page.evaluate(() => document.querySelector('.rwtag__more')?.textContent.trim() || null), '之后还有 1 项');
|
||
await c.shot('offer');
|
||
// the narrowest phone: the header strip still holds every line and the 稍后 button inside the screen
|
||
const browserUa = await c.browser.userAgent();
|
||
await c.page.emulate({ viewport: { width: 756, height: 366, deviceScaleFactor: 2, isMobile: true, hasTouch: true, isLandscape: true }, userAgent: browserUa });
|
||
await c.page.waitForFunction(() => !!document.querySelector('.rwtag__more'), { timeout: 30000 });
|
||
await sleep(900);
|
||
await c.page.screenshot({ path: path.join(ROOT, 'test/e2e/out', 'fb1-queue-756x366.png') });
|
||
const strip = await c.page.evaluate(() => {
|
||
const box = (el) => { const r = el.getBoundingClientRect(); return { l: r.left, t: r.top, r: r.right, b: r.bottom }; };
|
||
const tag = document.querySelector('.rwtag');
|
||
return { tag: box(tag), parts: [...tag.children].map((el) => ({ cls: el.className, ...box(el) })), vw: innerWidth, vh: innerHeight };
|
||
});
|
||
for (const part of strip.parts) {
|
||
assert.ok(part.t >= strip.tag.t - 0.5 && part.b <= strip.tag.b + 0.5 && part.b <= strip.vh + 0.5, `756×366 ${part.cls}: inside the strip and the screen ${JSON.stringify(strip)}`);
|
||
}
|
||
await c.page.emulate({ viewport: { width: 1920, height: 1080, deviceScaleFactor: 1, isMobile: false, hasTouch: false }, userAgent: browserUa });
|
||
await c.page.waitForFunction(() => !!document.querySelector('.shopbar__reward .scard'), { timeout: 30000 });
|
||
await sleep(900);
|
||
await c.hookRequests();
|
||
const first = await offerOf();
|
||
await c.click('.shopbar__reward .scard');
|
||
await sleep(300);
|
||
await c.click('.shopbar__reward .scard.is-armed');
|
||
await c.page.waitForFunction((ids) => { const o = globalThis.__SP__.store.get().match.private?.shop?.rewardOffer; return !!o && o.queued === 0 && o.slots.map((x) => x.id).join() !== ids; }, { timeout: 8000 }, first.slots.map((x) => x.id).join());
|
||
await sleep(300);
|
||
assert.equal(await c.exists('.rwtag__more'), false, 'nothing behind the second offer');
|
||
assert.equal(await c.page.evaluate(() => document.querySelector('.rwtag__title')?.textContent.trim()), '定向投放');
|
||
await c.shot('second');
|
||
assert.deepEqual(problemsOf([c]), []);
|
||
} finally {
|
||
await c.close();
|
||
await srv.stop();
|
||
}
|
||
});
|
||
});
|