Files
ark-stronghold-protocol/test/skland/scan.test.js
T
herryyun 7c25c89992
CI / test (ubuntu-latest, node 22) (push) Failing after 33m10s
CI / test (ubuntu-latest, node 24) (push) Failing after 34m41s
CI / tooling (lint, imports, typecheck) (push) Failing after 36m11s
CI / test (windows-latest, node 22) (push) Canceled after 0s
CI / test (windows-latest, node 24) (push) Canceled after 0s
实现账号绑定、持久登录与跨设备对局恢复
2026-10-14 23:22:50 +08:00

121 lines
5.7 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createQrLogin } from '../../public/js/skland/scan.js';
const flush = async () => { for (let i = 0; i < 20; i++) await Promise.resolve(); };
function fakeClock() {
let time = 0, id = 0;
const tasks = new Map();
return {
now: () => time,
timers: {
setTimeout: (fn, ms) => { tasks.set(++id, { fn, at: time + ms }); return id; },
clearTimeout: (key) => tasks.delete(key),
},
count: () => tasks.size,
async advance(ms) {
const target = time + ms;
for (;;) {
const next = [...tasks].sort((a, b) => a[1].at - b[1].at)[0];
if (!next || next[1].at > target) break;
time = next[1].at; tasks.delete(next[0]); next[1].fn(); await flush();
}
time = target; await flush();
},
};
}
function fixture(poll = () => ({ ok: true, state: 'waiting' }), onConfirmed) {
const clock = fakeClock(), calls = [], images = [], pending = [], errors = [];
let confirmed = 0, starts = 0;
const scan = createQrLogin({ ...clock,
request: async (path) => {
calls.push({ path, at: clock.now() });
return path === 'scan/start' ? { ok: true, scanUrl: `QR${++starts}` } : poll();
},
encode: async (url) => `image:${url}`, onQr: (value) => images.push(value),
onPending: (value) => pending.push(value), onError: (value) => { if (value) errors.push(value); },
onBusy: () => {}, onConfirmed: async () => { confirmed++; await onConfirmed?.(); },
});
return { scan, clock, calls, images, pending, errors, starts: () => starts, confirmed: () => confirmed };
}
test('QR is fetched automatically, polled every 2 s and replaced every 10 s', async () => {
const f = fixture(); f.scan.setActive(true); await f.clock.advance(0);
assert.deepEqual(f.images, ['image:QR1']);
await f.clock.advance(9999); assert.equal(f.starts(), 1);
await f.clock.advance(1); assert.equal(f.starts(), 2);
assert.deepEqual(f.calls.filter((c) => c.path === 'scan/poll').map((c) => c.at), [2000, 4000, 6000, 8000, 10000]);
await f.clock.advance(10000); assert.equal(f.starts(), 3);
f.scan.stop(); assert.equal(f.clock.count(), 0);
});
test('a failed status fetch after confirmation retries completion without replacing the authenticated QR', async () => {
let attempts = 0;
const f = fixture(() => ({ ok: true, state: 'confirmed' }), async () => {
if (++attempts === 1) throw new Error('status offline');
});
f.scan.setActive(true); await f.clock.advance(2000);
assert.equal(f.confirmed(), 1); assert.equal(f.errors[0].message, 'status offline');
await f.clock.advance(10000);
assert.equal(f.confirmed(), 2); assert.equal(f.starts(), 1); assert.equal(f.clock.count(), 0);
f.scan.stop();
});
test('the first awaiting-confirmation event starts a 60 s window; repeated polls do not extend it', async () => {
const f = fixture(() => ({ ok: true, state: 'waiting', awaitingConfirmation: true }));
f.scan.setActive(true); await f.clock.advance(2000);
assert.equal(f.pending.at(-1), true);
await f.clock.advance(59999); assert.equal(f.starts(), 1);
await f.clock.advance(1); assert.equal(f.starts(), 2);
assert.equal(f.calls.filter((c) => c.path === 'scan/start')[1].at, 62000);
assert.equal(f.pending.at(-1), false, 'a new QR returns to the ordinary interval');
f.scan.refresh(); await f.clock.advance(0); assert.equal(f.starts(), 3, 'manual refresh is immediate too');
f.scan.stop();
});
test('a scan detected at the 10 s refresh boundary keeps the QR for confirmation', async () => {
let f;
f = fixture(() => ({ ok: true, state: 'waiting', awaitingConfirmation: f.clock.now() >= 10000 }));
f.scan.setActive(true); await f.clock.advance(10000);
assert.equal(f.starts(), 1); assert.equal(f.pending.at(-1), true);
await f.clock.advance(59999); assert.equal(f.starts(), 1);
await f.clock.advance(1); assert.equal(f.starts(), 2);
f.scan.stop();
});
test('pausing stops requests and resuming checks confirmation before replacing an overdue QR', async () => {
const f = fixture(() => ({ ok: true, state: 'waiting', awaitingConfirmation: true }));
f.scan.setActive(true); await f.clock.advance(0);
f.scan.setActive(false); assert.equal(f.clock.count(), 0);
await f.clock.advance(50000); assert.equal(f.calls.length, 1);
f.scan.setActive(true); await f.clock.advance(0);
assert.equal(f.starts(), 1); assert.equal(f.pending.at(-1), true);
f.scan.stop(); await f.clock.advance(120000); assert.equal(f.calls.length, 2);
});
test('confirmation wins over a manual refresh queued during an in-flight poll', async () => {
let resolvePoll;
const f = fixture(() => new Promise((resolve) => { resolvePoll = resolve; }));
f.scan.setActive(true); await f.clock.advance(2000);
f.scan.refresh(); assert.equal(f.starts(), 1, 'a refresh never overlaps its poll');
resolvePoll({ ok: true, state: 'confirmed' }); await flush();
assert.equal(f.confirmed(), 1); assert.equal(f.clock.count(), 0);
await f.clock.advance(120000); assert.equal(f.starts(), 1);
f.scan.stop();
});
test('stopping ignores late replies, and a failed poll can recover with a fresh QR', async () => {
let resolvePoll;
const stopped = fixture(() => new Promise((resolve) => { resolvePoll = resolve; }));
stopped.scan.setActive(true); await stopped.clock.advance(2000); stopped.scan.stop();
resolvePoll({ ok: true, state: 'confirmed' }); await flush();
assert.equal(stopped.confirmed(), 0); assert.equal(stopped.clock.count(), 0);
const retry = fixture(() => { throw new Error('offline'); });
retry.scan.setActive(true); await retry.clock.advance(2000);
assert.equal(retry.errors[0].message, 'offline');
await retry.clock.advance(9999); assert.equal(retry.starts(), 1);
await retry.clock.advance(1); assert.equal(retry.starts(), 2);
retry.scan.stop();
});