В прошлом уроке башня научилась повторять по расписанию и слать сводку на рассвете. Проверить это честно, как Ринцвальд, значит прожить ночь. Вопрос Тикки звучит философски, а ответ инженерный: время в башне это зависимость, как провод и журнал из 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 разобраны в уроке «Дубли».
Дальше
Рыдкулл заходит с картой линии и красным карандашом.
Башня, которая чинит себя сама, ждёт в следующем уроке.