Раздел 46 · Effect: Тихая Академия

По чьим часам: Clock и TestClock

middle~20 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В прошлом уроке башня научилась повторять по расписанию и слать сводку на рассвете. Проверить это честно, как Ринцвальд, значит прожить ночь. Вопрос Тикки звучит философски, а ответ инженерный: время в башне это зависимость, как провод и журнал из EF5. Кто её даёт, тот и решает, который час.

Часы как зависимость

Вот как башня ставила метку на строку раньше:

const stampOld = Effect.sync(() => new Date(Date.now()).toISOString());
const stampNew = Effect.map(DateTime.now, DateTime.formatIso);

Тест ставит тестовые часы на шесть утра 1 ноября и спрашивает обе метки:

[ '2026-11-01T04:19:46.667Z', '2026-11-01T06:00:00.000Z' ]

Первая метка показывает настоящее время машины, на которой шёл тест: Date.now() спрашивает операционную систему, и никто между ними не стоит. Вторая показывает шесть утра: DateTime.now спрашивает часы из контекста, а в тесте там тестовые часы.

Часы в Effect это сервис Clock. В обычном запуске он смотрит на настоящее время, а в тесте его подменяют, как Wire.layerTest подменял провод. Отсюда правило раздела: в башне нет Date.now(), new Date() и setTimeout. Время только из Clock, ожидание только через Effect.sleep или расписание. Тесты этапов 38 и 39 подменяют Date.now функцией, которая бросает: спросишь его, тест упадёт.

Clock в R квитанции не появляется. Это сервис по умолчанию, как ShiftName из урока 52: у рантайма всегда есть настоящие часы, а тест кладёт поверх свои.

export const now: Effect.Effect<number> = Clock.currentTimeMillis;

Clock.currentTimeMillis это миллисекунды эпохи. Есть ещё currentTimeNanos и Clock.clockWith, если нужен сам сервис. Но чаще время нужно не числом, а датой.

DateTime

DateTime это момент времени, неизменяемый и с часовым поясом или без. DateTime.now берёт его из Clock:

export const stampLine = (line: string): Effect.Effect<string> =>
  Effect.map(DateTime.now, (time) => `[${DateTime.formatIso(time)}] ${line}`);

formatIso пишет момент по UTC: 2026-11-01T06:00:01.500Z. С часовым поясом момент тот же, а показ другой:

const at = DateTime.makeUnsafe(Date.UTC(2026, 6, 1, 5, 0));
const london = DateTime.setZoneNamedUnsafe(at, 'Europe/London');
2026-07-01T05:00:00.000Z 2026-07-01T06:00:00.000+01:00[Europe/London] 06:00

Пять утра по UTC и шесть по Анкмору это одна и та же точка на оси времени. DateTime.add(at, { hours: 25 }) двигает момент, DateTime.distance меряет промежуток, DateTime.format показывает человеку. makeUnsafe и setZoneNamedUnsafe бросают на кривом входе, поэтому они для констант в коде; для чужих данных есть DateTime.make и zoneMakeNamed, которые отвечают Option. Разбирать время из чужих кадров будем схемами в EF9.

Duration

Промежуток времени это Duration. Ты уже писал их строками: '1 second', '100 millis'. Тип Duration.Input принимает строку, число миллисекунд или готовую Duration, а функции модуля Duration работают с готовой:

Duration.format(Duration.millis(1500));                        // 1s 500ms
Duration.format(Duration.sum(Duration.minutes(1), Duration.millis(1))); // 1m 1ms
Duration.isGreaterThan(Duration.seconds(31), Duration.seconds(30));    // true

Сколько шла квитанция, меряет Effect.timed: ответ это пара [Duration, результат]. Башня пишет в журнал, сколько шла строка:

export const timedSend = (line: string): Effect.Effect<void, SendFailed, Wire | Journal> =>
  Effect.gen(function* () {
    const [took] = yield* Effect.timed(Wire.use((wire) => wire.send(line)));
    yield* Journal.use((journal) => journal.write(`> ${line} за ${Duration.format(took)}`));
  });

Effect.timed меряет по тем же часам из контекста, поэтому в тесте полторы секунды сна это ровно 1s 500ms, а не 1s 502ms.

Перевод с Ук

Просрочен ли кадр: прошло ли с отправки больше предела. Предел удобно принимать как Duration.Input, чтобы писать '30 seconds':

export const isOverdue = (sentAt: number, limit: Duration.Input): Effect.Effect<boolean> =>
  Effect.map(Clock.currentTimeMillis, (time) => Duration.isGreaterThan(Duration.millis(time - sentAt), limit));
uk74.ts(4,104): error TS2345: Argument of type 'Input' is not assignable to parameter of type 'Duration'.
  Type 'number' is not assignable to type 'Duration'.

Input это объединение: строка, число, готовая длительность и ещё несколько форм. isGreaterThan хочет только готовую Duration. Вторая строка называет первого, кто не подошёл: число. Компилятор не знает, миллисекунды это или секунды, и не будет гадать. Перевести Input в Duration нужно явно: Duration.fromInputUnsafe(limit). Unsafe в имени потому, что кривая строка вроде 'полчаса' бросит; для своих констант это нормально.

TestClock

Тестовые часы лежат в effect/testing. В it.effect из @effect/vitest они уже стоят, отдельно подключать не надо. Пока тест не скажет, время не идёт:

const fiber = yield* Effect.forkChild(Effect.as(Effect.sleep('1 hour'), 'проснулся'));
yield* TestClock.adjust('59 minutes');
const early = fiber.pollUnsafe();
yield* TestClock.adjust('1 minute');
return [early, yield* Fiber.join(fiber), yield* Clock.currentTimeMillis];
[ undefined, 'проснулся', 3600000 ]

Через 59 минут файбер ещё спит, через час просыпается. Тестовые часы начинаются с нуля, то есть с 1 января 1970 года. Две ручки:

  • TestClock.adjust(длительность) двигает часы вперёд и будит всех, чей сон за это время кончился, по порядку;
  • TestClock.setTime(миллисекунды) ставит часы на момент, например на Date.UTC(2026, 6, 1, 3, 0), чтобы проверить рассвет летом.

Сутки сна в таком тесте проходят за одну миллисекунду настоящего времени. Сводку на рассвете тест этапа 37 проверяет так: setTime на три часа ночи, atDawn в фоне, часы шагами по часу вперёд, и в списке ровно два рассвета.

Тест часто двигает часы не одним прыжком, а шагами: for по TestClock.adjust('10 millis'). Когда между двумя снами работа проходит через очередь или ворота, как в EF7, следующий сон регистрируется уже после прыжка, и одного большого adjust не хватает.

Под капотом

TestClock держит текущее время числом и список снов, упорядоченный по моменту пробуждения. Effect.sleep под тестовыми часами не заводит таймер: он кладёт в список запись «разбудить в такой-то момент» с воротами Latch из урока 66 и ждёт у ворот. adjust берёт самый ранний сон, переводит время на его момент, открывает его ворота и ждёт, пока разбуженный файбер и все, кого он разбудил, снова уснут или кончатся. Потом берёт следующий. Поэтому файбер, проснувшийся в 100, видит на часах ровно 100, а не конец прыжка, и успевает заснуть снова до того, как часы пойдут дальше. А если тест чего-то ждёт, но часы не двигает, через секунду настоящего времени TestClock пишет в лог предупреждение: «A test is using time, but is not advancing the test clock».

Где тут дыра

it.effect('сводка на рассвете', () =>
  Effect.gen(function* () {
    vi.useFakeTimers();
    vi.setSystemTime(Date.UTC(2026, 6, 1, 3, 0));
    const fiber = yield* Effect.forkChild(atDawn(report, 1));
    vi.advanceTimersByTime(3 * 3_600_000);
    yield* Fiber.join(fiber);
  }),
);

Тест висит. Поддельные таймеры vitest подменяют глобальные setTimeout и Date, а сон внутри it.effect идёт по TestClock, о котором vitest ничего не знает. Часы, которые двигает Ринцвальд, и часы, по которым спит башня, это разные часы. А Date.now() в метке показывает то, что поставил vi.setSystemTime, а не время башни, и метки разъезжаются с журналом. Вне it.effect поддельные таймеры ещё и подменяют setTimeout и setImmediate, через которые Effect запускает файберы, и тест может просто встать.

Лечится одними часами: время только из Clock, двигает его только TestClock.

Башня, этап 38

Башня переходит на одни часы: метка на строке, срок кадра и время отправки в журнале берутся из Clock. После этого этапа тест может поставить на часы башни любое время.

Практика

Глубже в архиве

Поддельные таймеры и дублёры без Effect разобраны в уроке «Дубли».

Дальше

Рыдкулл заходит с картой линии и красным карандашом.

Башня, которая чинит себя сама, ждёт в следующем уроке.