diff --git a/packages/@d-zero/dealer/README.md b/packages/@d-zero/dealer/README.md index 20f41aa0..4e44e626 100644 --- a/packages/@d-zero/dealer/README.md +++ b/packages/@d-zero/dealer/README.md @@ -64,6 +64,6 @@ const id = await TaskList.pipe('fetch', async () => fetchUser(userId)) - **`interval` 遅延はアイテム開始の「直後・最初の出力前」**に実行される(順序に注意) - **`unshift` は既存キューの先頭に割り込む**(優先度の高い動的追加用、push との順序を理解する必要あり) -- **`Lanes` / `Display` を直接使う場合は `using` 宣言(`Symbol.dispose`)で自動解放**する(leak 防止)。スコープと解放タイミングが一致しない場合のみ `close()` を直接呼ぶ(`close()` は deprecated) +- **`Lanes` を直接使う場合は `using` 宣言(`Symbol.dispose`)で自動解放**する(leak 防止)。スコープと解放タイミングが一致しない場合のみ `close()` を直接呼ぶ(`close()` は deprecated) これらの背景と実装は `src/deal.ts` / `src/dealer.ts` / `src/lanes.ts` の JSDoc を参照。 diff --git a/packages/@d-zero/dealer/src/display.spec.ts b/packages/@d-zero/dealer/src/display.spec.ts index e650615b..b282f4c5 100644 --- a/packages/@d-zero/dealer/src/display.spec.ts +++ b/packages/@d-zero/dealer/src/display.spec.ts @@ -24,6 +24,19 @@ function makeStreamCollector(): { return { stream, read: () => Buffer.concat(chunks).toString('utf8') }; } +/** + * Repaints the current stack on demand. + * + * `Display` registers its `#resize` handler on whatever stream it was given, + * and a plain `Writable` is an `EventEmitter`, so emitting `resize` forces one + * frame at the exact fake-clock instant the test has advanced to — far more + * precise than waiting for the fps timer, whose interval never divides evenly. + * @param stream - The stream the `Display` under test was constructed with. + */ +function repaint(stream: NodeJS.WritableStream): void { + stream.emit('resize'); +} + let stdoutWriteSpy: ReturnType; beforeEach(() => { @@ -294,3 +307,141 @@ describe('Display stream option', () => { expect(stdoutWriteSpy).toHaveBeenCalled(); }); }); + +describe('Display countdown placeholder', () => { + test('counts down while the placeholder stays on the stack', () => { + vi.useFakeTimers(); + try { + const collector = makeStreamCollector(); + using display = new Display({ stream: collector.stream }); + + display.write('waiting %countdown(1000,lane0)%ms'); + expect(collector.read()).toContain('waiting 1000ms'); + + vi.advanceTimersByTime(400); + const mark = collector.read().length; + repaint(collector.stream); + + expect(collector.read().slice(mark)).toContain('waiting 600ms'); + } finally { + vi.useRealTimers(); + } + }); + + test('clamps at zero once the duration has elapsed', () => { + vi.useFakeTimers(); + try { + const collector = makeStreamCollector(); + using display = new Display({ stream: collector.stream }); + + display.write('waiting %countdown(1000,lane0)%ms'); + vi.advanceTimersByTime(2500); + const mark = collector.read().length; + repaint(collector.stream); + + expect(collector.read().slice(mark)).toContain('waiting 0ms'); + } finally { + vi.useRealTimers(); + } + }); + + test('rounds to whole seconds for the s unit', () => { + vi.useFakeTimers(); + try { + const collector = makeStreamCollector(); + using display = new Display({ stream: collector.stream }); + + display.write('opening %countdown(30000,openPage_a,s)%s'); + vi.advanceTimersByTime(2400); + const mark = collector.read().length; + repaint(collector.stream); + + expect(collector.read().slice(mark)).toContain('opening 28s'); + } finally { + vi.useRealTimers(); + } + }); + + test('restarts from the full duration when the same id returns to the stack', () => { + vi.useFakeTimers(); + try { + const collector = makeStreamCollector(); + using display = new Display({ stream: collector.stream }); + + display.write('opening %countdown(1000,openPage_a)%ms'); + vi.advanceTimersByTime(1500); + display.write('retrying'); + vi.advanceTimersByTime(100); + + const mark = collector.read().length; + display.write('opening %countdown(1000,openPage_a)%ms'); + repaint(collector.stream); + + expect(collector.read().slice(mark)).toContain('opening 1000ms'); + } finally { + vi.useRealTimers(); + } + }); + + test('restarts even when the id leaves and returns without a frame in between', () => { + vi.useFakeTimers(); + try { + const collector = makeStreamCollector(); + using display = new Display({ stream: collector.stream }); + + display.write('opening %countdown(1000,openPage_a)%ms'); + vi.advanceTimersByTime(1500); + + const mark = collector.read().length; + display.write('retrying'); + display.write('opening %countdown(1000,openPage_a)%ms'); + repaint(collector.stream); + + expect(collector.read().slice(mark)).toContain('opening 1000ms'); + } finally { + vi.useRealTimers(); + } + }); + + test('drops only the ids that left the stack, not the ones still on it', () => { + vi.useFakeTimers(); + try { + const collector = makeStreamCollector(); + using display = new Display({ stream: collector.stream }); + + display.write('a %countdown(1000,laneA)%ms', 'b %countdown(1000,laneB)%ms'); + vi.advanceTimersByTime(400); + + display.write('a %countdown(1000,laneA)%ms', 'b done'); + + const mark = collector.read().length; + display.write('a %countdown(1000,laneA)%ms', 'b %countdown(1000,laneB)%ms'); + repaint(collector.stream); + const painted = collector.read().slice(mark); + + expect(painted).toContain('a 600ms'); + expect(painted).toContain('b 1000ms'); + } finally { + vi.useRealTimers(); + } + }); + + test('verbose mode renders the full duration every time the line is logged', () => { + vi.useFakeTimers(); + try { + const collector = makeStreamCollector(); + using display = new Display({ stream: collector.stream, verbose: true }); + + display.write('waiting %countdown(1000,lane0)%ms'); + vi.advanceTimersByTime(5000); + display.write('waiting %countdown(1000,lane0)%ms'); + + expect(collector.read().match(/waiting \d+ms/g)).toEqual([ + 'waiting 1000ms', + 'waiting 1000ms', + ]); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/@d-zero/dealer/src/display.ts b/packages/@d-zero/dealer/src/display.ts index cc0efc57..71fcd101 100644 --- a/packages/@d-zero/dealer/src/display.ts +++ b/packages/@d-zero/dealer/src/display.ts @@ -113,6 +113,8 @@ export class Display { } this.#stack = [...this.#debugMessages, ...logs]; + this.#dropStaleCountDowns(); + if (this.#timer) { return; } @@ -160,16 +162,24 @@ export class Display { const { id, time, placeholder, unit } = parsed; - const currentTime = this.#coundDownMap.get(id); - let displayTimeMS: number; - if (currentTime == null) { - this.#coundDownMap.set(id, Date.now()); + if (this.#verbose) { + // verbose モードには繰り返し描画されるフレームが存在せず、1行は + // 出力された瞬間に確定する。カウントダウン行が出るのは待機の開始 + // 時点なので満了時間をそのまま出す。開始時刻を残さないため、同じ + // ID の次の行が前回の経過時間を引き継ぐこともない。 displayTimeMS = time; } else { - const elapsedTime = Date.now() - currentTime; - displayTimeMS = Math.max(time - elapsedTime, 0); + const currentTime = this.#coundDownMap.get(id); + + if (currentTime == null) { + this.#coundDownMap.set(id, Date.now()); + displayTimeMS = time; + } else { + const elapsedTime = Date.now() - currentTime; + displayTimeMS = Math.max(time - elapsedTime, 0); + } } const displayTime = unit === 's' ? Math.round(displayTimeMS / 1000) : displayTimeMS; @@ -177,6 +187,41 @@ export class Display { return text.replace(placeholder, `${displayTime}`); } + /** + * 表示スタックから消えたカウントダウン ID の開始時刻を破棄する。 + * + * カウントダウンの開始時刻は「その placeholder が表示スタックに載っている + * 間」だけ有効な状態。破棄することで、同じ ID が再登場したとき——リトライで + * 同じページを開き直す、レーン番号だけを ID にした待機が次のアイテムで再び + * 出る、など——に満了時間から数え直せる。残したままにすると前回の開始時刻を + * 引き継ぎ、経過時間が満了時間を超えているため残り 0 に張り付く。Map が実行 + * 中ずっと ID を抱え続けるのも防ぐ。 + * + * フレーム描画時ではなくスタック更新時に判定するのは、フレーム間隔 (既定 + * 33ms) より短い間に「消えて再登場」した ID を取りこぼさないため。 + */ + #dropStaleCountDowns() { + if (this.#coundDownMap.size === 0) { + return; + } + + const liveIds = new Set(); + for (const line of this.#stack ?? []) { + // riffle が置換するのは `%earth%` のようなアニメーション名のみで + // `%countdown(...)%` には触れないため、描画前の生の行で判定できる。 + const parsed = countDownFunctionParser(line); + if (parsed) { + liveIds.add(parsed.id); + } + } + + for (const id of this.#coundDownMap.keys()) { + if (!liveIds.has(id)) { + this.#coundDownMap.delete(id); + } + } + } + #enterFrame() { if (this.#verbose) { return; diff --git a/packages/@d-zero/dealer/src/lanes.spec.ts b/packages/@d-zero/dealer/src/lanes.spec.ts index 9760f0d3..ff36fb97 100644 --- a/packages/@d-zero/dealer/src/lanes.spec.ts +++ b/packages/@d-zero/dealer/src/lanes.spec.ts @@ -1,7 +1,27 @@ +import { Writable } from 'node:stream'; + import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest'; import { Lanes } from './lanes.js'; +/** + * `Writable` stub that collects every painted frame so assertions can grep the + * rendered text. Passed as `stream` so the test never touches `process.stdout`. + */ +function makeStreamCollector(): { + readonly stream: NodeJS.WritableStream; + read(): string; +} { + const chunks: Buffer[] = []; + const stream = new Writable({ + write(chunk: Buffer | string, _encoding, cb) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + cb(); + }, + }); + return { stream, read: () => Buffer.concat(chunks).toString('utf8') }; +} + let stdoutWriteSpy: ReturnType; beforeEach(() => { @@ -79,3 +99,50 @@ describe('Lanes verbose update', () => { expect(lastCall).not.toContain('undefined'); }); }); + +describe('Lanes countdown lifetime', () => { + test('a lane-scoped countdown id restarts from its full duration on the next item', () => { + vi.useFakeTimers(); + try { + const collector = makeStreamCollector(); + using lanes = new Lanes({ stream: collector.stream }); + + // deal() が各アイテムの待機に出す、レーン番号だけを ID にした行 + lanes.update(0, 'Waiting interval: %countdown(1000,0_interval)%ms'); + vi.advanceTimersByTime(1500); + lanes.update(0, 'Scraping'); + lanes.delete(0); + + const mark = collector.read().length; + lanes.update(0, 'Waiting interval: %countdown(1000,0_interval)%ms'); + collector.stream.emit('resize'); + + expect(collector.read().slice(mark)).toContain('Waiting interval: 1000ms'); + } finally { + vi.useRealTimers(); + } + }); + + test('a countdown on another lane keeps counting while a vanished one is dropped', () => { + vi.useFakeTimers(); + try { + const collector = makeStreamCollector(); + using lanes = new Lanes({ stream: collector.stream }); + + lanes.update(0, 'lane0 %countdown(1000,0_interval)%ms'); + lanes.update(1, 'lane1 %countdown(1000,1_interval)%ms'); + vi.advanceTimersByTime(400); + lanes.delete(1); + + const mark = collector.read().length; + lanes.update(1, 'lane1 %countdown(1000,1_interval)%ms'); + collector.stream.emit('resize'); + const painted = collector.read().slice(mark); + + expect(painted).toContain('lane0 600ms'); + expect(painted).toContain('lane1 1000ms'); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/@d-zero/dealer/src/lanes.ts b/packages/@d-zero/dealer/src/lanes.ts index dba2cbea..1e55a9d4 100644 --- a/packages/@d-zero/dealer/src/lanes.ts +++ b/packages/@d-zero/dealer/src/lanes.ts @@ -117,8 +117,20 @@ export class Lanes { * 指定した ID のログを更新する。 * verbose モードではヘッダー設定済みならヘッダーとログを連結し、未設定なら * ログのみを即時出力する。 + * + * ログ中の `%countdown(満了ミリ秒, ID, 単位)%` は残り時間に置換される + * (単位は `ms` / `s`、省略時は `ms`)。残り時間はカウントダウン ID ごとに + * 追跡され、その placeholder が全レーンの表示から消えた時点で破棄される。 + * つまり同じカウントダウン ID を再び表示させれば満了時間から数え直しになる + * ため、リトライのように同じ待機が繰り返される箇所で ID を使い回してよい。 + * verbose モードは1行が出力時点で確定し再描画されないため、残り時間を追跡 + * せず常に満了時間を出力する。 * @param id - 更新するログの ID * @param log - ログメッセージ + * @example + * ```ts + * lanes.update(0, 'Waiting: %countdown(30000, openPage, s)%s'); + * ``` */ update(id: number, log: string) { if (this.#verbose) {