Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/@d-zero/dealer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 を参照。
151 changes: 151 additions & 0 deletions packages/@d-zero/dealer/src/display.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.spyOn>;

beforeEach(() => {
Expand Down Expand Up @@ -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();
}
});
});
57 changes: 51 additions & 6 deletions packages/@d-zero/dealer/src/display.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ export class Display {
}

this.#stack = [...this.#debugMessages, ...logs];
this.#dropStaleCountDowns();

if (this.#timer) {
return;
}
Expand Down Expand Up @@ -160,23 +162,66 @@ 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;

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<string>();
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;
Expand Down
67 changes: 67 additions & 0 deletions packages/@d-zero/dealer/src/lanes.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.spyOn>;

beforeEach(() => {
Expand Down Expand Up @@ -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();
}
});
});
12 changes: 12 additions & 0 deletions packages/@d-zero/dealer/src/lanes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading