Files
ark-stronghold-protocol/test/ui/feedback1-offers.e2e.test.js
T
sganggs 6e0c26af9e feedback1 WB: bounty-half evidence stated as a trend with its counter-evidence; queued free offers counted on the pill and header
- 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)
2026-10-03 02:38:42 +08:00

203 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
}
});
});