feedback7 R22-LOADOUT: 干员调配's roster is one list — a row per operator with the quick skill / module choices and the 潜能 / 练度 selects in aligned columns — and the detail opens with 潜能与练度; golden: unchanged

The owner's decisions of 2026-10-08: 「D12 [PR #301] 可以做,具体布局怎么优化你来决定,他那样确实方便,但是三个竖排有点乱影响
观感」 and 「D7D8调配干员里自己设置吧,默认满潜满加成」. The quick choices are PR #301's idea (by @farridge); the layout is new:
one list instead of three columns of cards.

- screens/loadout.js: RosterHead + RosterRow — every row the same four columns under a sticky head (干员 | 技能 |
  模组 精锐 | 潜能 · 练度): portrait / name / bonds (selects, opens the detail), three skill slots (an empty one for a
  two-skill chess; the label says slot, name, SP, duration — quickSkillTags), the elite's modules (不装备 first), the
  潜能 / 练度 selects; a quick choice goes through setChoice / setEntries like the detail and keeps a phone on the list.
  The detail's 普通 / 精锐 skill toggle is one preview for the skills, the 特质 and the rows. The detail opens with the
  潜能与练度 section; 恢复默认 / 全部恢复默认 reset the settings too (全部: the roster's operators — a 自选 pick's stay
  with the 自选编队 tab); 导入 reports a settings-only import; ←/→ stay inside a focused row's choices.
- screens/cultivation.js (new): CultivationSelects (two compact selects; quiet at the defaults, mint when changed) and
  CultivationSection (潜能 1–6 and the four official tiers — effects.json names and effect texts — the rule in one line,
  the stand-in note for a chess marked not owned).
- screens/diy.js: an owned 自选 pick's slot card carries its operator's selects; a prototype says it has neither.
- css: the head and the rows share one grid (`--cols`), 32 px quick targets (40 px on a coarse pointer); the detail
  slides over the list below 1000 px (was 760 px); below 700 px the row's bonds give way to the name. Headless check at
  1920 × 1080, 1366 × 768, 844 × 390 and 667 × 375: no sideways overflow, the head's columns equal every row's.
- The 局内数值 caption says 含潜能与练度 when the numbers carry both (effects.json loaded); the overlay's note says the
  skills, modules, 潜能 and 练度 can be set (the operator's in-match level cannot).
- i18n: 21 strings in the four packs (the tier names, short tier labels, the notes, the reset confirm, the import toast).
- Docs: design/match.md (the list, the 潜能与练度 section, 导出's `ops`), ARCHITECTURE.md (screens/cultivation.js).
- Tests: test/ui/loadout-rows.test.js (new, 7): a row's pick / skill slots / modules / aria-pressed / callbacks and the
  shared 精锐 preview, its selects (values, options, a change, the changed mark, a stand-in), the head, the detail's
  section (tiers from effects.json, the effect text, the stand-in note), a 自选 owned pick vs a prototype, 局内数值 at
  潜能1 / 未精英化 and at the defaults with its caption, the css grid and breakpoints. e2e (not run here; their steps
  replayed with a headless page): the row pick selector, the phone test (one list, 40 px targets, the detail slides
  over, back to the list), the 局内数值 expectations ×1.1, a new 潜能 / 练度 flow (row select → stored, 部署费用 17 / 攻击
  454; the detail's 未精英化 → 413; the row follows; 恢复默认).
This commit is contained in:
sganggs committed 2026-10-08 21:38:26 +08:00
1 parent 519f04e2d5
commit dc336f24a1
14 files changed
+673 -149

No files matched your search

+72 -28
View File
@@ -101,7 +101,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
// search → one card
await page.type('.lo-search input', '隐现');
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
await page.click('.lo-card');
await page.click('.lo-card__pick');
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
await page.click('.lo-detail .lo-skill[data-skill="0"]');
await page.click('.lo-detail .lo-mod[data-module="none"]');
@@ -141,7 +141,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
// one edit, so there is something to export
await page.type('.lo-search input', '隐现');
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
await page.click('.lo-card');
await page.click('.lo-card__pick');
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
await page.click('.lo-detail .lo-skill[data-skill="0"]');
const stored = await page.evaluate(() => JSON.parse(localStorage.getItem('sp.pref.loadout')));
@@ -210,7 +210,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
await page.type('.lo-search input', '隐现');
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
await page.click('.lo-card');
await page.click('.lo-card__pick');
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
// choose S1, close and confirm at once — well inside the sync's debounce
await page.click('.lo-detail .lo-skill[data-skill="0"]');
@@ -273,7 +273,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
await page.setViewport({ width: 1920, height: 1080 });
await page.type('.lo-search input', '隐现');
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
await page.click('.lo-card');
await page.click('.lo-card__pick');
await page.waitForSelector('.lo-detail .lo-skill[data-skill="0"]', { visible: true });
// edit, and the briefing ends before the debounced send (every human ready: the requests stand in for the timer)
await page.click('.lo-detail .lo-skill[data-skill="0"]');
@@ -297,7 +297,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
await page.type('.lo-search input', '烛煌');
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
await page.click('.lo-card');
await page.click('.lo-card__pick');
await page.waitForSelector('.lo-detail .lo-mod[data-module="none"]', { visible: true });
// wait for every module tile to settle (image loaded or lettered fallback)
await page.waitForFunction(() => [...document.querySelectorAll('.lo-mod:not(.lo-mod--none) .lo-mglyph')]
@@ -320,36 +320,50 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
await ctx.close();
});
test('phone 844×390 (touch): roster + detail side by side, tier / class filters, tap to choose', async () => {
test('phone 844×390 (touch): one list with the quick choices (0.2.2), the detail slides over it, tier / class filters, tap to choose', async () => {
const { ctx, page, problems } = await open({ w: 844, h: 390, touch: true });
await page.tap('.lobby-screen [data-testid="loadout-open"]');
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
await assertBgLayer(page);
await page.screenshot({ path: path.join(OUT, 'loadout-phone.png') });
// review fix: the shared Button / TextField shrink to 6–7 px text on phones — the overlay's own controls stay readable
// and tappable (返回, 全部恢复默认, the detail's 恢复默认, the search input)
const ctl = await page.evaluate(() => Object.fromEntries(['.lo-back', '.lo-top__right .btn', '.lo-dhead__reset', '.lo-search .field__input'].map((sel) => {
// and tappable (返回, 全部恢复默认, the search input; the detail's 恢复默认 below)
const measure = (sels) => page.evaluate((list) => Object.fromEntries(list.map((sel) => {
const el = document.querySelector(sel);
const b = el.getBoundingClientRect();
return [sel, { h: Math.round(b.height), fs: parseFloat(getComputedStyle(el).fontSize) }];
})));
for (const [sel, v] of Object.entries(ctl)) {
assert.ok(v.fs >= 11, `${sel} text ≥ 11 px (${JSON.stringify(v)})`);
if (sel !== '.lo-search .field__input') assert.ok(v.h >= 28, `${sel} ≥ 28 px tall (${JSON.stringify(v)})`);
}
})), sels);
const check = (ctl) => {
for (const [sel, v] of Object.entries(ctl)) {
assert.ok(v.fs >= 11, `${sel} text ≥ 11 px (${JSON.stringify(v)})`);
if (sel !== '.lo-search .field__input') assert.ok(v.h >= 28, `${sel} ≥ 28 px tall (${JSON.stringify(v)})`);
}
};
check(await measure(['.lo-back', '.lo-top__right .btn', '.lo-search .field__input']));
// the list takes the whole width (no detail beside it below 1000 px); the quick choices are 40 px touch targets
assert.equal(await page.$eval('.lo-detail-wrap', (el) => getComputedStyle(el).display), 'none');
const q = await page.$eval('.lo-card .lo-q--skill', (el) => { const b = el.getBoundingClientRect(); return [Math.round(b.width), Math.round(b.height)]; });
assert.ok(q[0] >= 40 && q[1] >= 40, `quick targets ${q}`);
assert.equal(await page.evaluate(() => document.querySelector('.lo').scrollWidth > innerWidth + 1), false, 'no horizontal overflow');
await page.tap('.lo-chip--t6');
await page.waitForFunction(() => [...document.querySelectorAll('.lo-card')].every((c) => c.classList.contains('lo-card--t6')));
await page.tap('.lo-chip--prof[title="狙击"]');
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length >= 1);
await page.tap('.lo-card');
// a quick choice keeps the list
await page.tap('.lo-card .lo-q--skill[data-skill="0"]');
assert.notEqual(await page.$eval('.lo-roster', (el) => getComputedStyle(el).display), 'none', 'a quick choice keeps the list');
await page.tap('.lo-card__pick');
await page.waitForSelector('.lo-detail .lo-skill', { visible: true });
assert.notEqual(await page.$eval('.lo-roster', (el) => getComputedStyle(el).display), 'none', 'side by side');
assert.equal(await page.$eval('.lo-roster', (el) => getComputedStyle(el).display), 'none', 'the detail covers the list');
check(await measure(['.lo-dhead__reset']));
// every skill option is reachable inside the viewport (the detail body scrolls)
const r = await page.$eval('.lo-detail .lo-skill:last-child', (el) => { el.scrollIntoView({ block: 'nearest' }); const b = el.getBoundingClientRect(); return { top: b.top, bottom: b.bottom }; });
assert.ok(r.top >= 0 && r.bottom <= 390, JSON.stringify(r));
await page.tap('.lo-detail .lo-skill:last-child');
await page.waitForFunction(() => document.querySelector('.lo-detail .lo-skill:last-child').classList.contains('is-on'));
await page.screenshot({ path: path.join(OUT, 'loadout-phone-detail.png') });
await page.tap('.lo-detail-back');
await page.waitForSelector('.lo-roster', { visible: true });
assert.deepEqual(problems, []);
await ctx.close();
});
@@ -359,7 +373,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
await page.tap('.lobby-screen [data-testid="loadout-open"]');
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
assert.equal(await page.$eval('.lo-detail-wrap', (el) => getComputedStyle(el).display), 'none');
await page.tap('.lo-card');
await page.tap('.lo-card__pick');
await page.waitForSelector('.lo-detail .lo-skill', { visible: true });
assert.equal(await page.$eval('.lo-roster', (el) => getComputedStyle(el).display), 'none', 'detail covers the roster');
await page.screenshot({ path: path.join(OUT, 'loadout-small-phone.png') });
@@ -372,6 +386,8 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
// ---- 局内数值 (GitHub issue #64) -------------------------------------------------------------------------------------
const CHESS = () => JSON.parse(readFileSync(path.join(ROOT, 'data/chess.json'), 'utf8'));
const fmt = (n) => Math.round(n).toLocaleString('en-US');
// 0.2.2: the numbers carry the 练度 — none set: 精英2 Lv.60, effects.json aceffect_char_4 (×1.1 ATK / DEF / max HP)
const T4 = JSON.parse(readFileSync(path.join(ROOT, 'data/effects.json'), 'utf8')).aceffect_char_4.buffs.find((b) => b.key === 'char_attribute_mul').bb;
/** The section's eight stats: label → shown value. */
const shownStats = (page) => page.$$eval('.lo-sec--stats .dstat', (els) => Object.fromEntries(els.map((e) => [e.querySelector('.dstat__k').textContent, e.querySelector('.dstat__v').textContent])));
const rangeTiles = (page) => page.$$eval('.lo-sec--stats .rgrid i.on', (els) => els.length);
@@ -380,7 +396,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
await page.keyboard.press('Backspace');
await page.type('.lo-search input', name);
await page.waitForFunction((n) => document.querySelectorAll('.lo-card').length === 1 && document.querySelector('.lo-card .lo-card__name')?.textContent === n, { timeout: 5000 }, name);
await page.click('.lo-card');
await page.click('.lo-card__pick');
await page.waitForSelector('.lo-sec--stats .dstat', { visible: true });
}
@@ -394,25 +410,25 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
await pickChess(page, '隐现');
// between the skills and the modules, 精锐 shown first: the default module's numbers, the 3 × 4 range
assert.deepEqual(await page.$$eval('.lo-detail__body > .lo-sec > header h3', (els) => els.map((e) => e.firstChild.textContent)), ['技能', '局内数值', '模组']);
assert.deepEqual(await page.$$eval('.lo-detail__body > .lo-sec > header h3', (els) => els.map((e) => e.firstChild.textContent)), ['潜能与练度', '技能', '局内数值', '模组']);
const stats = await shownStats(page);
assert.deepEqual(Object.keys(stats), ['生命上限', '攻击', '防御', '法术抗性', '攻击间隔', '阻挡数', '部署费用', '再部署']);
assert.equal(stats['生命上限'], fmt(golden.statsBase.maxHp + mod.attr.maxHp));
assert.equal(stats['攻击'], fmt(golden.statsBase.atk + mod.attr.atk));
assert.equal(stats['生命上限'], fmt((golden.statsBase.maxHp + mod.attr.maxHp) * T4.max_hp));
assert.equal(stats['攻击'], fmt((golden.statsBase.atk + mod.attr.atk) * T4.atk));
assert.equal(await rangeTiles(page), base.rangeGrid.length);
assert.equal(await page.$eval('.lo-sec--stats', (el) => el.dataset.variant), 'elite');
await page.screenshot({ path: path.join(OUT, 'loadout-stats-desktop.png') });
// 不装备 → the base numbers; the skill choice changes nothing; 普通 → the normal chess; both toggles are independent
await page.click('.lo-detail .lo-mod[data-module="none"]');
await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(golden.statsBase.maxHp));
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk));
await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(golden.statsBase.maxHp * T4.max_hp));
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk * T4.atk));
await page.click('.lo-detail .lo-skill[data-skill="0"]');
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk), 'a skill is not a stat');
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk * T4.atk), 'a skill is not a stat');
await page.click('.lo-sec--stats .lo-seg button[data-variant="normal"]');
await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(base.stats.maxHp));
await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(base.stats.maxHp * T4.max_hp));
assert.equal(await page.$eval('.lo-sec--stats', (el) => el.dataset.variant), 'normal');
assert.equal(await page.$eval('.lo-skill.is-on', (el) => el.dataset.skill), '0');
assert.equal(await page.$eval('.lo-seg button.is-on', (el) => el.textContent.startsWith('普通')), true, 'the skill level toggle is its own');
assert.equal(await page.$eval('.lo-sec:not(.lo-sec--cult) .lo-seg button.is-on', (el) => el.textContent.startsWith('普通')), true, 'the skill level toggle is its own');
// the layout: no sideways overflow, the range box beside the numbers
const box = await page.evaluate(() => {
const body = document.querySelector('.lo-detail__body');
@@ -443,7 +459,7 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
await page.type('.lo-search input', '隐现');
await page.waitForFunction(() => document.querySelectorAll('.lo-card').length === 1, { timeout: 5000 });
await page.tap('.lo-card');
await page.tap('.lo-card__pick');
await page.waitForSelector('.lo-sec--stats .dstat', { visible: true });
await page.$eval('.lo-sec--stats', (el) => el.scrollIntoView({ block: 'start' }));
const m = await page.evaluate(() => {
@@ -471,8 +487,36 @@ describe('干员调配 overlay (real server, headless Chrome)', { skip: !ENABLED
await page.screenshot({ path: path.join(OUT, 'loadout-stats-phone.png') });
// a choice made further down updates it (touch: the module card, 不装备)
await page.tap('.lo-detail .lo-mod[data-module="none"]');
await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(golden.statsBase.maxHp));
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk));
await page.waitForFunction((v) => document.querySelector('.lo-sec--stats .dstat__v')?.textContent === v, {}, fmt(golden.statsBase.maxHp * T4.max_hp));
assert.equal((await shownStats(page))['攻击'], fmt(golden.statsBase.atk * T4.atk));
assert.deepEqual(problems, []);
await ctx.close();
});
test('潜能 / 练度 (0.2.2): a row\'s selects and the detail\'s buttons set the operator\'s settings — stored, synced, the numbers follow', async () => {
const chess = CHESS();
const v = chess.chess_char_1_06_a; // 刺玫: 潜能1 ATK 413 / cost 17; elite statsBase 508 at 潜能1
const { ctx, page, problems } = await open();
await clickSel(page, '.lobby-screen [data-testid="loadout-open"]');
await page.waitForSelector('.lo .lo-card', { visible: true, timeout: 15000 });
await pickChess(page, '刺玫');
// a row's select: 潜能 1
await page.select('.lo-card[data-chess="chess_char_1_06_a"] select[data-cult="potential"]', '1');
await page.waitForFunction((id) => JSON.parse(localStorage.getItem('sp.pref.loadout') || '{}').ops?.[id]?.potential === 1, {}, v.charId);
await page.click('.lo-sec--stats .lo-seg button[data-variant="normal"]');
await page.waitForFunction((x) => [...document.querySelectorAll('.lo-sec--stats .dstat')].some((e) => e.querySelector('.dstat__k').textContent === '部署费用' && e.querySelector('.dstat__v').textContent === x), {}, '17');
assert.equal((await shownStats(page))['攻击'], fmt(413 * T4.atk), '潜能1 at 精英2 Lv.60');
// the detail's 练度 buttons: 未精英化 → ×1
await page.click('.lo-sec--cult [data-cultivate="0"]');
await page.waitForFunction(() => document.querySelector('.lo-sec--cult [data-cultivate="0"]').getAttribute('aria-checked') === 'true');
assert.equal((await shownStats(page))['攻击'], fmt(413));
assert.equal(await page.$eval('.lo-card[data-chess="chess_char_1_06_a"] select[data-cult="cultivate"]', (el) => el.value), '0', 'the row follows');
assert.ok(await page.$eval('.lo-card[data-chess="chess_char_1_06_a"]', (el) => el.classList.contains('is-changed')), 'a changed operator');
await page.waitForFunction(() => /已同步/.test(document.querySelector('.lo-sync')?.textContent || ''), { timeout: 8000 });
// 恢复默认 resets them with the skill / module
await page.click('.lo-dhead__reset');
await page.waitForFunction((id) => !JSON.parse(localStorage.getItem('sp.pref.loadout') || '{}').ops?.[id], {}, v.charId);
assert.equal((await shownStats(page))['攻击'], fmt(v.stats.atk * T4.atk));
assert.deepEqual(problems, []);
await ctx.close();
});