fix: 高延迟下连按「刷新」只发送一次请求(GitHub #501)
This commit is contained in:
1 parent
bc50cac01f
commit
289a454fa1
10 files changed
+186
-8
No files matched your search
@@ -59,6 +59,7 @@
|
||||
## 3. 招募、整备区与晋升
|
||||
|
||||
- **购买点两次**:第一次点商店卡片只选中(卡片抬起并打开详情,底部出现「确认购买 · 再次点击」),再点这张卡片的**任意位置**(包括金色的确认条)才购买;点别处或按 Esc 取消。升级调度中心、晋升奖励同样两次;策略或装备带来的免费挑选(如凯瑟琳【定向投放】升级后刷新的 3 件装备、娜仁图亚【见者有份】的 2 件装备、寻呼模块的 3 名干员)也一样出现在商店卡片的位置,左侧标题写着它的来源,装备显示为装备卡(图标、名称、说明、FREE),选 1 个免费获得;同时有多个免费挑选时依次出现,标题下和「稍后」后的提示按钮会写还有几项(如「+1」)。不能购买时(例如已准备就绪)点卡片只打开详情。
|
||||
- **刷新在途不重复**:按下「刷新」后按钮会变暗,直到本次结果返回前,再点按钮或按快捷键都不会再次发送——网络延迟高时防止连点扣掉多次资金(GitHub #501)。
|
||||
- **公共卡池**(同盟):每名干员的副本数有限,I–VI 阶分别 12 / 14 / 18 / 16 / 8 / 5 份,所有玩家共用;持有的干员(棋盘 + 整备区)占用副本,商店里展示的不占用。一名干员被大家拿光后就刷不出来了;淘汰玩家的干员会还回卡池。
|
||||
- **位置上限**:棋盘最多部署 **8** 名;整备区 **10** 格。整备区满时**不能购买或领取**任何干员、装备(包括能凑齐 3 名合成精锐的那一张,和官方一样),先出售、部署或配发腾出格子;新获得的单位(合成的精锐、奖励、退回的装备等)进入**临时整备区**(整备区前面的 5 格),整备区一空出格子,临时整备区的单位就**自动补进去**(从右往左)。临时整备区有单位时**不能准备就绪**,棋盘上会用红色虚线框标出这一排并说明规则:把它们放进整备区或战场、配发或使用,或者出售 / 销毁。休整期中进入临时整备区的单位,若到**本休整期结束**时仍留在那里就会被自动处理(干员返还卡池、装备销毁);作战、结算期间或准备就绪之后才进入的(例如战斗奖励、结算时合成的精锐),会保留到**下一个休整期**结束,你一定有机会处理它们。
|
||||
- **晋升精锐**:3 名同名干员(无论是否部署)自动合成**精锐**(等级、技能、模组大幅提升;风丸只需 2 名),精锐不会再合成。如果被合成的干员里有**已经部署在战场上**的,精锐会直接出现在**那名干员的位置**(保持原来的朝向,不多占部署位;有两三名都在场上时,取最先部署的那名——最左一列优先,同一列从上到下);否则精锐进入整备区(满了进入临时整备区)。原先配发的装备都退回整备区,需要重新配发;被合成干员的召唤物随之移除,场上的精锐会把自己的召唤物送进整备区。点选一张能合成的商店卡片时,精锐将出现的那一格会亮起金色边框,同时打开的详情卡里也会写明去向(「可晋升:精锐干员将出现在作战区原位置 / 将进入整备区」;详情卡会自动停靠在不遮挡这一格的一侧)。在回合开始或结算时因策略、奖励等完成的合成,精锐出现时同样会播放金色的晋升特效。
|
||||
|
||||
@@ -707,6 +707,7 @@
|
||||
"冻结商店(下回合保留) · {key}": "Freeze Shop (kept next round) · {key}",
|
||||
"解冻": "Unfreeze",
|
||||
"刷新商店 · {key}": "Refresh Shop · {key}",
|
||||
"正在刷新…": "Refreshing…",
|
||||
"免费 ×{free}": "Free ×{free}",
|
||||
"查看 {name} 的战场": "View {name}'s Battlefield",
|
||||
"与你在同一战场": "On your field",
|
||||
|
||||
@@ -708,6 +708,7 @@
|
||||
"冻结商店(下回合保留) · {key}": "管理センターを凍結(次のラウンドに持ち越し) · {key}",
|
||||
"解冻": "解除",
|
||||
"刷新商店 · {key}": "管理センターを更新 · {key}",
|
||||
"正在刷新…": "更新中…",
|
||||
"免费 ×{free}": "無料 ×{free}",
|
||||
"查看 {name} 的战场": "{name}の戦場を見る",
|
||||
"与你在同一战场": "あなたと同じ戦場",
|
||||
|
||||
@@ -704,6 +704,7 @@
|
||||
"冻结商店(下回合保留) · {key}": "상점 동결 (다음 라운드까지 유지) · {key}",
|
||||
"解冻": "동결 해제",
|
||||
"刷新商店 · {key}": "상점 갱신 · {key}",
|
||||
"正在刷新…": "갱신 중…",
|
||||
"免费 ×{free}": "무료 ×{free}",
|
||||
"查看 {name} 的战场": "{name}의 전장 보기",
|
||||
"与你在同一战场": "같은 전장",
|
||||
|
||||
@@ -706,6 +706,7 @@
|
||||
"冻结商店(下回合保留) · {key}": "凍結商店(下回合保留) · {key}",
|
||||
"解冻": "解凍",
|
||||
"刷新商店 · {key}": "更新商店 · {key}",
|
||||
"正在刷新…": "正在更新…",
|
||||
"免费 ×{free}": "免費 ×{free}",
|
||||
"查看 {name} 的战场": "查看 {name} 的戰場",
|
||||
"与你在同一战场": "與你在同一戰場",
|
||||
|
||||
@@ -84,7 +84,7 @@ import { CombatHud } from '../ui/combatHud.js';
|
||||
import { SettingsModal, settingsStore } from '../ui/settings.js';
|
||||
import { ExitModal, AwayOverlay, awayStore } from '../ui/matchChrome.js';
|
||||
import { openGuide } from '../ui/guide.js';
|
||||
import { actions } from '../ui/gameActions.js';
|
||||
import { actions, onRefreshBusy } from '../ui/gameActions.js';
|
||||
import { FacingWheel, holdPiece, setPieceDir, syncPieceDirs, showRange, useTileScreen } from '../ui/facingWheel.js';
|
||||
import { Underframe, underframeRect, TempRowNotice } from '../ui/underframe.js';
|
||||
import { StandInTags } from './game/standInTags.js';
|
||||
@@ -183,6 +183,10 @@ function MatchScreen() {
|
||||
// deals duplicates) carry the same chess id and no piece, and each must say its 选中干员 (detailPanel selectVoiceKey)
|
||||
const cardTap = useRef(0);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
// GitHub #501: a g.refresh is still waiting for its reply — the shop's 刷新 button dims while it is (gameActions.js
|
||||
// single-flights the intent; the R hotkey is dropped by the same guard)
|
||||
const [refreshBusy, setRefreshBusy] = useState(false);
|
||||
useEffect(() => onRefreshBusy(setRefreshBusy), []);
|
||||
const [rewardMin, setRewardMin] = useState(false);
|
||||
const [emoteOpen, setEmoteOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
@@ -1468,7 +1472,7 @@ function MatchScreen() {
|
||||
${spectator ? null : html`<${Button} size="sm" variant="primary" icon="back" onClick=${() => watchPlayer({ playerId: myId })}>${t('返回自己')}<//>`}
|
||||
</div>` : null}
|
||||
|
||||
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} onCollapse=${setCollapsed}
|
||||
${showShop ? html`<${ShopBar} priv=${priv} editable=${editable} collapsed=${collapsed} refreshBusy=${refreshBusy} onCollapse=${setCollapsed}
|
||||
barRef=${barRef} offBonds=${offBonds}
|
||||
onBuy=${buy} onLevel=${() => actions.levelUp()} onRefresh=${() => actions.refresh()} onFreeze=${() => actions.freeze()}
|
||||
onDetail=${(id, kind, hint) => setDetail({ kind: kind === 'item' ? 'item' : 'chess', id, hint: hint || null, tap: ++cardTap.current })}
|
||||
|
||||
@@ -20,6 +20,21 @@ const emitBusy = () => { for (const fn of [...busyListeners]) { try { fn(infligh
|
||||
/** Subscribe to the number of in-flight intents (for subtle busy indicators). */
|
||||
export function onBusy(fn) { busyListeners.add(fn); return () => busyListeners.delete(fn); }
|
||||
|
||||
// Single-flight shop refresh (GitHub #501, single-ptilopsis): under a high RTT the shop has not answered yet while the
|
||||
// player taps 刷新 again — every extra g.refresh the client sends is a paid refresh (server/match intents.js → ps.refresh).
|
||||
// A tap while the previous request is still on the wire is dropped; the button dims and the R hotkey stays inert (both
|
||||
// call actions.refresh) until the reply lands. ui/shopBar.js subscribes to onRefreshBusy for the dim.
|
||||
let refreshPending = false;
|
||||
const refreshBusyListeners = new Set();
|
||||
const emitRefreshBusy = () => { for (const fn of [...refreshBusyListeners]) { try { fn(refreshPending); } catch { /* ignore */ } } };
|
||||
|
||||
/**
|
||||
* Subscribe to the shop refresh's in-flight state — fires once with the current state.
|
||||
* @param {(busy: boolean) => void} fn
|
||||
* @returns {() => void} unsubscribe
|
||||
*/
|
||||
export function onRefreshBusy(fn) { refreshBusyListeners.add(fn); try { fn(refreshPending); } catch { /* ignore */ } return () => refreshBusyListeners.delete(fn); }
|
||||
|
||||
/**
|
||||
* Send an intent. Resolves true on `ok`, false on error (already toasted).
|
||||
* @param {string} t
|
||||
@@ -55,7 +70,13 @@ export const actions = {
|
||||
band: (bandId) => act('g.band', { bandId }),
|
||||
bandSkip: () => act('g.bandSkip'),
|
||||
buy: (slot) => act('g.buy', { slot }),
|
||||
refresh: () => act('g.refresh'),
|
||||
// a tap while the previous g.refresh is still on the wire is dropped (GitHub #501) — see onRefreshBusy above
|
||||
refresh: () => {
|
||||
if (refreshPending) return Promise.resolve(false);
|
||||
refreshPending = true;
|
||||
emitRefreshBusy();
|
||||
return act('g.refresh').finally(() => { refreshPending = false; emitRefreshBusy(); });
|
||||
},
|
||||
freeze: () => act('g.freeze'),
|
||||
levelUp: () => act('g.levelUp'),
|
||||
sell: (uid) => act('g.sell', { uid }),
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
// pointer): there is no ⓘ corner (the official cards have none; user playtest #6 item 10 — on phones its invisible
|
||||
// 44 px touch area covered the card's bottom-right quarter, half the strip, and a second tap there only re-opened the
|
||||
// detail); a card that cannot be bought right now (the bar is not editable: ready, round start) opens its detail. The
|
||||
// D / R / F keys stay one-press shortcuts. There is no drag-to-sell zone: selling is the 出售 +N button of a tapped
|
||||
// D / R / F keys stay one-press shortcuts — a 刷新 whose request is still on the wire dims the button and swallows
|
||||
// further taps and the R key until the reply lands (GitHub #501; the single-flight lives in ui/gameActions.js
|
||||
// onRefreshBusy, ui/shopBar.js only draws it). There is no drag-to-sell zone: selling is the 出售 +N button of a tapped
|
||||
// unit's underframe (ui/underframe.js). After a promotion the operator cards are replaced by
|
||||
// the 晋升奖励 cards (3 free operators, pick 1 — also two taps) until picked or put off (稍后 → rewardOverlay.js pill);
|
||||
// a special refresh (凯瑟琳 定向投放's 3 items, 娜仁图亚, 寻呼模块, 信标, 松果) takes the same place under its own name, an
|
||||
@@ -286,13 +288,13 @@ export function RewardCards({ offer, priv, editable, onPick, onDetail, onLater,
|
||||
|
||||
/**
|
||||
* The bar.
|
||||
* @param {{ priv:any, editable:boolean, collapsed:boolean, onCollapse:(c:boolean)=>void,
|
||||
* @param {{ priv:any, editable:boolean, collapsed:boolean, refreshBusy?:boolean, onCollapse:(c:boolean)=>void,
|
||||
* onBuy:(i:number)=>void, onLevel:Function, onRefresh:Function, onFreeze:Function, onDetail:(id:string, kind?:string, hint?:string|null)=>void,
|
||||
* onDetailClose?: () => void, onRefuse?: (reason: string) => void, barRef:any,
|
||||
* reward?: any, onReward?: (idx:number)=>void, onRewardLater?: Function, offBonds?: Set<string>|null }} props — offBonds:
|
||||
* the bonds this mode never activates (gameLogic modeOffBonds), struck through on the operator cards
|
||||
*/
|
||||
export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef,
|
||||
export function ShopBar({ priv, editable, collapsed, refreshBusy = false, onCollapse, onBuy, onLevel, onRefresh, onFreeze, onDetail, onDetailClose, onRefuse, barRef,
|
||||
reward = null, onReward, onRewardLater, onArm = null, offBonds = null }) {
|
||||
const shop = priv?.shop || {};
|
||||
const slots = Array.isArray(shop.slots) ? shop.slots : [];
|
||||
@@ -354,10 +356,10 @@ export function ShopBar({ priv, editable, collapsed, onCollapse, onBuy, onLevel,
|
||||
<${Img} src=${uiUrl(data.get('assets'), frozen ? 'shopPanel/frozen_icon2' : 'shopPanel/frozen_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="snow" />`} />
|
||||
<span>${frozen ? t('解冻') : t('冻结')}</span><kbd>${hk.freeze}</kbd>
|
||||
</button>
|
||||
<button type="button" class="toolbtn toolbtn--amber" disabled=${!!refReason} onClick=${onRefresh} title=${refReason || t('刷新商店 · {key}', { key: hk.refresh })}>
|
||||
<button type="button" class="toolbtn toolbtn--amber" disabled=${!!refReason || refreshBusy} onClick=${onRefresh} title=${refReason || (refreshBusy ? t('正在刷新…') : t('刷新商店 · {key}', { key: hk.refresh }))}>
|
||||
<${Img} src=${uiUrl(data.get('assets'), 'shopPanel/refresh_icon')} class="toolbtn__img" fallback=${html`<${Icon} name="refresh" />`} />
|
||||
<span>${t('刷新')}</span>
|
||||
${free > 0 ? html`<span class="toolbtn__free">${t('免费 ×{free}', { free })}</span>` : html`<${HexBadge} value=${shop.refreshPrice ?? 1} tone=${refReason ? 'dark' : 'gold'} size="sm" />`}
|
||||
${free > 0 ? html`<span class="toolbtn__free">${t('免费 ×{free}', { free })}</span>` : html`<${HexBadge} value=${shop.refreshPrice ?? 1} tone=${refReason || refreshBusy ? 'dark' : 'gold'} size="sm" />`}
|
||||
<kbd>${hk.refresh}</kbd>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// test/ui/refresh-tap.e2e.test.js — GitHub #501 (single-ptilopsis): 高延迟下连按「刷新」只发送一次请求. Through the mock
|
||||
// harness (public/dev/game-mock.html) in headless Chrome, with the mock's reply to g.refresh delayed: the first tap
|
||||
// sends one request and dims the button, a spam tap and the R key while it is on the wire send nothing, the shop pays
|
||||
// once, and the next tap after the reply refreshes again. Opt-in: SP_E2E=1 (needs Chrome; CHROME_PATH overrides).
|
||||
//
|
||||
// SP_E2E=1 node --test test/ui/refresh-tap.e2e.test.js
|
||||
|
||||
import { test, describe, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync } from 'node:fs';
|
||||
|
||||
const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
|
||||
const ENABLED = process.env.SP_E2E === '1' && existsSync(CHROME);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const BTN = '.shopbar__tools .toolbtn--amber';
|
||||
|
||||
describe('the shop refresh single-flights (GitHub #501, mock harness, headless Chrome)', { skip: !ENABLED && 'set SP_E2E=1 (and have Chrome) to run' }, () => {
|
||||
let srv;
|
||||
let browser;
|
||||
let base;
|
||||
|
||||
before(async () => {
|
||||
const { startServer } = await import('../../server/index.js');
|
||||
const puppeteer = (await import('puppeteer-core')).default;
|
||||
srv = await startServer({ port: 0, host: '127.0.0.1', quiet: true });
|
||||
base = `http://127.0.0.1:${srv.port}`;
|
||||
browser = await puppeteer.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await browser?.close();
|
||||
await srv?.close();
|
||||
});
|
||||
|
||||
test('a tap under a lagging shop pays once: the button dims, the extra tap / R key are dropped, the next tap works', async () => {
|
||||
const page = await browser.newPage();
|
||||
await page.setViewport({ width: 1920, height: 1080, deviceScaleFactor: 1 });
|
||||
await page.goto(`${base}/dev/game-mock.html?shot=1&phase=PREP&render=fallback`, { waitUntil: 'networkidle0' });
|
||||
await page.waitForFunction(() => !!document.querySelector('.screen:not(.gload)') && !!globalThis.__SP_VIEW__, { timeout: 20000 });
|
||||
// the mock answers instantly; give g.refresh a high RTT, and count what the client actually sends (gameActions.js
|
||||
// looks net.request up at call time; the mock's own request record only sees the request after the delay)
|
||||
await page.evaluate(async () => {
|
||||
const { net } = await import('/js/net.js');
|
||||
const orig = net.request.bind(net);
|
||||
globalThis.__REFRESHES__ = 0;
|
||||
net.request = (t, ...rest) => {
|
||||
if (t === 'g.refresh') globalThis.__REFRESHES__ += 1;
|
||||
return new Promise((res, rej) => {
|
||||
setTimeout(() => orig(t, ...rest).then(res, rej), t === 'g.refresh' ? 700 : 0);
|
||||
});
|
||||
};
|
||||
});
|
||||
const btnState = () => page.evaluate(() => {
|
||||
const b = document.querySelector('.shopbar__tools .toolbtn--amber');
|
||||
return { disabled: !!b?.disabled, title: b?.title || null };
|
||||
});
|
||||
const refreshes = () => page.evaluate(() => globalThis.__REFRESHES__);
|
||||
const funds = () => page.evaluate(() => globalThis.__MOCK__.S().priv.funds);
|
||||
|
||||
assert.equal((await btnState()).disabled, false, 'the 刷新 button starts enabled');
|
||||
const n0 = await refreshes();
|
||||
const f0 = await funds();
|
||||
await page.click(BTN);
|
||||
await sleep(150);
|
||||
assert.equal(await refreshes(), n0 + 1, 'the tap sent one g.refresh');
|
||||
assert.deepEqual(await btnState(), { disabled: true, title: '正在刷新…' }, 'the button dims and says it is waiting');
|
||||
// the spam tap (the disabled button swallows clicks) and the R hotkey while pending: neither reaches the socket
|
||||
await page.evaluate(() => document.querySelector('.shopbar__tools .toolbtn--amber')?.click());
|
||||
await page.keyboard.press('r');
|
||||
await sleep(200);
|
||||
assert.equal(await refreshes(), n0 + 1, 'nothing else was sent');
|
||||
await page.waitForFunction(() => !document.querySelector('.shopbar__tools .toolbtn--amber')?.disabled, { timeout: 5000 });
|
||||
const back = await btnState();
|
||||
assert.equal(back.disabled, false, 'the button is enabled again after the reply');
|
||||
assert.match(back.title, /刷新商店/, 'the tooltip is back to the hotkey hint');
|
||||
assert.equal(await funds(), f0 - 1, 'the shop paid exactly one refresh');
|
||||
await page.click(BTN);
|
||||
await sleep(150);
|
||||
assert.equal(await refreshes(), n0 + 2, 'a refresh after the reply is sent again');
|
||||
await page.waitForFunction(() => !document.querySelector('.shopbar__tools .toolbtn--amber')?.disabled, { timeout: 5000 });
|
||||
assert.equal(await funds(), f0 - 2, 'and paid once more');
|
||||
await page.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
// test/ui/refresh-tap.test.js — GitHub #501 (single-ptilopsis): 高延迟下连按「刷新」会重复扣费. The intent layer now
|
||||
// single-flights it (ui/gameActions.js): a tap while the previous g.refresh is still on the wire is dropped — the
|
||||
// button dims through onRefreshBusy (game.js → ui/shopBar.js) and the R hotkey (the same actions.refresh) stays inert
|
||||
// until the reply lands. The browser run of the same case is test/ui/refresh-tap.e2e.test.js (SP_E2E=1).
|
||||
|
||||
import { test, describe } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
|
||||
const read = (p) => readFileSync(path.join(ROOT, p), 'utf8');
|
||||
|
||||
const { net } = await import('../../public/js/net.js');
|
||||
const { actions, onRefreshBusy } = await import('../../public/js/ui/gameActions.js');
|
||||
|
||||
describe('the shop refresh single-flights (GitHub #501)', () => {
|
||||
test('a refresh while the previous one is on the wire sends nothing; the busy signal brackets the request; a later one sends again', async () => {
|
||||
const sent = [];
|
||||
let release;
|
||||
const prev = net.request;
|
||||
net.request = (t) => { sent.push(t); return new Promise((r) => { release = () => r({ ok: true }); }); };
|
||||
const seen = [];
|
||||
const off = onRefreshBusy((b) => seen.push(b));
|
||||
try {
|
||||
assert.deepEqual(seen, [false], 'subscribing fires once with the current (idle) state');
|
||||
const first = actions.refresh();
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(seen.at(-1), true, 'busy from the moment of the send');
|
||||
assert.equal(await actions.refresh(), false, 'the duplicate resolves false without waiting');
|
||||
assert.equal(sent.length, 1, 'and never reaches the socket');
|
||||
release();
|
||||
assert.equal(await first, true);
|
||||
assert.deepEqual(seen, [false, true, false], 'the reply clears the busy state');
|
||||
const again = actions.refresh();
|
||||
assert.equal(sent.length, 2, 'a refresh after the reply is sent again');
|
||||
release();
|
||||
assert.equal(await again, true);
|
||||
assert.deepEqual(seen, [false, true, false, true, false]);
|
||||
} finally {
|
||||
off();
|
||||
net.request = prev;
|
||||
}
|
||||
});
|
||||
|
||||
test('an unsubscribe stops the updates', () => {
|
||||
const seen = [];
|
||||
const off = onRefreshBusy((b) => seen.push(b));
|
||||
off();
|
||||
assert.deepEqual(seen, [false]);
|
||||
});
|
||||
|
||||
test('the wiring: game.js subscribes and hands the state to the bar; the button dims and says what it waits for', () => {
|
||||
const game = read('public/js/screens/game.js');
|
||||
assert.match(game, /onRefreshBusy\(setRefreshBusy\)/, 'the game screen subscribes');
|
||||
assert.match(game, /<\$\{ShopBar\}[\s\S]*?refreshBusy=\$\{refreshBusy\}/, 'and the bar receives it');
|
||||
const bar = read('public/js/ui/shopBar.js');
|
||||
assert.match(bar, /disabled=\$\{!!refReason \|\| refreshBusy\}/, 'busy disables the 刷新 button');
|
||||
assert.match(bar, /t\('正在刷新…'\)/, 'and its tooltip says what it is waiting for');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user