Skip to content

Commit

Permalink
fix: テストカバレッジの改善 (#156)
Browse files Browse the repository at this point in the history
* fix: Refactor ScheduleRunner

* fix: Make placeholder public

* fix: Add test case for clear typos db

* fix: Add reservation test

* fix: Add test case with not object

* refactor: Extract into method consume

* doc: Add doc comments
  • Loading branch information
MikuroXina authored May 12, 2022
1 parent ed7f93f commit 50367e2
Show file tree
Hide file tree
Showing 4 changed files with 204 additions and 42 deletions.
2 changes: 1 addition & 1 deletion src/adaptor/clock.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Clock } from '../runner';

export class MockClock implements Clock {
constructor(private readonly placeholder: Date) {}
constructor(public placeholder: Date) {}

now(): Date {
return this.placeholder;
Expand Down
91 changes: 91 additions & 0 deletions src/model/reservation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { Reservation, ReservationId, ReservationTime } from './reservation';
import type { Snowflake } from './id';

test('invalid time construction', () => {
expect(() => new ReservationTime(-1, 0)).toThrow(
'hours or minutes got out of range'
);
expect(() => new ReservationTime(24, 0)).toThrow(
'hours or minutes got out of range'
);
expect(() => new ReservationTime(0, -1)).toThrow(
'hours or minutes got out of range'
);
expect(() => new ReservationTime(0, 60)).toThrow(
'hours or minutes got out of range'
);
});

test('time from invalid hours minutes', () => {
expect(ReservationTime.fromHoursMinutes('0:')).toBeNull();
expect(ReservationTime.fromHoursMinutes(':0')).toBeNull();
expect(ReservationTime.fromHoursMinutes('24:00')).toBeNull();
expect(ReservationTime.fromHoursMinutes('10:60')).toBeNull();
});

test('time into Japanese', () => {
expect(new ReservationTime(11, 59).intoJapanese()).toStrictEqual(
'午前11時59分'
);
expect(new ReservationTime(12, 0).intoJapanese()).toStrictEqual('午後0時0分');
});

test('serialize reservation', () => {
const reservation = new Reservation(
'0000' as ReservationId,
new ReservationTime(6, 0),
'1234' as Snowflake,
'3456' as Snowflake
);
expect(reservation.serialize()).toStrictEqual(
'{"id":"0000","time":{"hours":6,"minutes":0},"guildId":"1234","voiceRoomId":"3456"}'
);
});

test('deserialize reservation', () => {
expect(Reservation.deserialize('0')).toBeNull();
expect(Reservation.deserialize('[]')).toBeNull();
expect(
Reservation.deserialize(
'{"time":{"hours":6,"minutes":0},"guildId":"1234","voiceRoomId":"3456"}'
)
).toBeNull();
expect(
Reservation.deserialize(
'{"id":"0000","guildId":"1234","voiceRoomId":"3456"}'
)
).toBeNull();
expect(
Reservation.deserialize(
'{"id":"0000","time":{"minutes":0},"guildId":"1234","voiceRoomId":"3456"}'
)
).toBeNull();
expect(
Reservation.deserialize(
'{"id":"0000","time":{"hours":6},"guildId":"1234","voiceRoomId":"3456"}'
)
).toBeNull();
expect(
Reservation.deserialize(
'{"id":"0000","time":{"hours":6,"minutes":0},"voiceRoomId":"3456"}'
)
).toBeNull();
expect(
Reservation.deserialize(
'{"id":"0000","time":{"hours":6,"minutes":0},"guildId":"1234"}'
)
).toBeNull();

expect(
Reservation.deserialize(
'{"id":"0000","time":{"hours":6,"minutes":0},"guildId":"1234","voiceRoomId":"3456"}'
)
).toStrictEqual(
new Reservation(
'0000' as ReservationId,
new ReservationTime(6, 0),
'1234' as Snowflake,
'3456' as Snowflake
)
);
});
102 changes: 61 additions & 41 deletions src/runner/schedule.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { addMilliseconds, differenceInMilliseconds } from 'date-fns';
import { addMilliseconds, isAfter } from 'date-fns';

/**
* `ScheduleRunner` に登録するイベントが実装するインターフェイス。戻り値は次に自身を再実行する時刻。`null` を返した場合は再実行されない。
Expand Down Expand Up @@ -27,6 +27,8 @@ export interface Clock {
now(): Date;
}

const CONSUMPTION_INTERVAL = 100;

/**
* 機能を指定ミリ秒後や特定時刻に実行する。特定間隔での再実行については `ScheduleTask` を参照。
*
Expand All @@ -35,59 +37,77 @@ export interface Clock {
* @template M
*/
export class ScheduleRunner {
constructor(private readonly clock: Clock) {}
constructor(private readonly clock: Clock) {
this.taskConsumerId = setInterval(() => {
this.consume();
}, CONSUMPTION_INTERVAL);
}

private runningTasks = new Map<unknown, ReturnType<typeof setTimeout>>();
private taskConsumerId: NodeJS.Timer;
private queue = new Map<unknown, [ScheduleTask, Date]>();

killAll(): void {
for (const task of this.runningTasks.values()) {
clearTimeout(task);
/**
* 登録したタスクのうち指定時刻になったものを実行する。時計の時刻を急に進めた場合などに用いる。
*/
consume() {
const neededExe = this.extractTaskNeededExe();
for (const [key, task] of neededExe) {
void task()
.catch((e) => {
console.error(e);
return null;
})
.then((nextTime) => nextTime && this.queue.set(key, [task, nextTime]));
this.queue.delete(key);
}
this.runningTasks.clear();
}

/**
* すべての実行を停止する。テスト終了時などに用いる。
*/
killAll(): void {
clearInterval(this.taskConsumerId);
this.queue.clear();
}

/**
* 現在からミリ秒指定で一定時間後にタスクを実行するように登録する。
*
* @param key あとで登録したタスクを停止させるときに用いるキーのオブジェクト
* @param task 実行したいタスク
* @param milliSeconds 現在から何ミリ秒経過した時に実行するのか
*/
runAfter(key: unknown, task: ScheduleTask, milliSeconds: number): void {
this.startInner(key, task, addMilliseconds(this.clock.now(), milliSeconds));
this.queue.set(key, [
task,
addMilliseconds(this.clock.now(), milliSeconds)
]);
}

/**
* 特定時刻にタスクを実行するように登録する。
*
* @param key あとで登録したタスクを停止させるときに用いるキーのオブジェクト
* @param task 実行したいタスク
* @param time いつ実行するのか
*/
runOnNextTime(key: unknown, task: ScheduleTask, time: Date): void {
this.startInner(key, task, time);
this.queue.set(key, [task, time]);
}

/**
* タスクの実行登録を解除する。このキーに登録されていない場合は何も起こらない。
*
* @param key 実行を登録したときのキーのオブジェクト
*/
stop(key: unknown): void {
const id = this.runningTasks.get(key);
if (id !== undefined) {
clearTimeout(id);
this.runningTasks.delete(key);
}
this.queue.delete(key);
}

private startInner(key: unknown, task: ScheduleTask, timeout: Date): void {
const old = this.runningTasks.get(key);
if (old) {
clearTimeout(old);
}
const id = setTimeout(() => {
void (async () => {
const newTimeout = await task().catch((e) => {
console.error(e);
return null;
});
this.onDidRun(key, task, newTimeout);
})();
}, differenceInMilliseconds(timeout, this.clock.now()));
this.runningTasks.set(key, id);
}

private onDidRun(
key: unknown,
task: ScheduleTask,
timeout: Date | null
): void {
if (timeout === null) {
this.runningTasks.delete(key);
} else {
this.startInner(key, task, timeout);
}
private extractTaskNeededExe(): [unknown, ScheduleTask][] {
const now = this.clock.now();
return [...this.queue.entries()]
.filter(([, [, start]]) => isAfter(now, start))
.map(([key, [task]]) => [key, task]);
}
}
51 changes: 51 additions & 0 deletions src/service/typo-record.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { InMemoryTypoRepository, MockClock } from '../adaptor';
import { TypoRecorder, TypoReporter, type TypoRepository } from './typo-record';
import { addDays, setHours, setMinutes } from 'date-fns';
import EventEmitter from 'events';
import { ScheduleRunner } from '../runner';
import type { Snowflake } from '../model/id';
Expand Down Expand Up @@ -123,3 +124,53 @@ test('must not reply', async () => {

runner.killAll();
});

test('clear typos on next day', async () => {
const clock = new MockClock(new Date(0));
const db = new InMemoryTypoRepository();
await db.addTypo('279614913129742338' as Snowflake, 'foo');
await db.addTypo('279614913129742338' as Snowflake, 'hoge');

const runner = new ScheduleRunner(clock);

const responder = new TypoReporter(db, clock, runner);
await responder.on(
'CREATE',
createMockMessage(
{
args: ['typo']
},
(message) => {
expect(message).toStrictEqual({
title: `† 今日のMikuroさいなのtypo †`,
description: '- foo\n- hoge'
});
return Promise.resolve();
}
)
);

const now = clock.now();
const nextDay = addDays(now, 1);
const nextDay6 = setHours(nextDay, 6);
clock.placeholder = setMinutes(nextDay6, 1);
runner.consume();

await responder.on(
'CREATE',
createMockMessage(
{
args: ['typo']
},
(message) => {
expect(message).toStrictEqual({
title: `† 今日のMikuroさいなのtypo †`,
description: ''
});
return Promise.resolve();
}
)
);

runner.killAll();
});

0 comments on commit 50367e2

Please sign in to comment.