feat(util-time): add a millisecond Duration span
Duration holds one finite millisecond count behind a frozen class: a class rather than a branded number because TypeScript erases a brand through arithmetic, so only an instance can stop seconds and milliseconds from being added. It adds, subtracts, orders, converts to larger units fractionally, and splits a non-negative span into whole days, hours, minutes, and seconds. There is no valueOf and no toString, so a reader always states its unit. dsh-time-context reads its elapsed line through it and keeps the wording: a backward clock reading still clamps to zero there. The rendered text is byte-identical for every finite sample, verified by a differential over more than eight thousand inputs. A non-finite sample now throws from the span constructor instead of rendering 'NaNs', which the package's own reading invariant already refused.
This commit is contained in:
1 parent
bbdac8febe
commit
0939392e71
14 files changed
+366
-47
No files matched your search
@@ -9,11 +9,11 @@
|
||||
en: abd0263d14861926
|
||||
zh: e1736cd08e87f795
|
||||
/agent-note-shared-time-vocabulary-for-wall-clock-selectors/decision:
|
||||
en: d97690dac210f970
|
||||
zh: b78439b9f402b278
|
||||
en: 34feee585cb33001
|
||||
zh: d882bbba56a679bd
|
||||
/agent-note-shared-time-vocabulary-for-wall-clock-selectors/alternatives-considered:
|
||||
en: 5bf3cc0d112531e7
|
||||
zh: 99ed3e958d9d8ccb
|
||||
en: 1737484c099fab52
|
||||
zh: 303b144c52d167bd
|
||||
/agent-note-shared-time-vocabulary-for-wall-clock-selectors/consequences:
|
||||
en: 22e0e257ba361a33
|
||||
zh: 599ad36dccf8bd64
|
||||
en: 7e3004318640cca7
|
||||
zh: 007d7fe761718d37
|
||||
@@ -18,6 +18,8 @@ Schedule consumes it and keeps every domain decision. `canonicalizeTimeZone`, `d
|
||||
|
||||
`dsh-time-context` consumes it too and keeps its stricter policy. `deriveBrowserTimeZoneContext` reads each user-rpc zone through `canonicalizeTimeZone` and refuses an alias whose canonical answer differs from the reported value, so the request boundary still requires an identity another process resolves back to the same name. Time-context owns its three refusals and their messages; the shared entry only answers the canonical name or a reason, and its `unsupported` rejection carries the platform error the library caught, so that refusal keeps its `cause` in both consumers.
|
||||
|
||||
`Duration` holds one span as a single millisecond count, and it is a class rather than a branded number because TypeScript's arithmetic erases a brand: adding two differently branded numbers answers an unbranded `number`, so a brand cannot stop seconds and milliseconds from being mixed, while an instance can. The unit is milliseconds because every producer and consumer in this repository already uses it (`Date.now()`, `SessionEvent.time`, the session log, the client), and because a double holds every millisecond of the whole `Date` range exactly, so no precision is spent on the unit. Instances are frozen; the count is readable only as `milliseconds` or through a fractional `toSeconds`/`toMinutes`/`toHours`/`toDays` conversion, and there is deliberately no `valueOf` and no `toString`, so no reader ever receives a bare count whose unit it has to remember. Addition, subtraction, ordering, and a whole-part `decompose()` are the whole operation set.
|
||||
|
||||
The maintained `@js-temporal/polyfill` 0.5.1 becomes a runtime dependency of `dsh-util-time` as well as of `dsh-schedule`. Local resolution continues to use it, so the Host's IANA data still decides gaps and overlaps and no caller owns offset sampling. `util/time`'s README and description lose the `zero-dependency` claim the added dependency contradicts.
|
||||
|
||||
Observable behavior does not change. Every accepted input resolves to the same epoch, every refused input raises the same `ScheduleInputError` or `ScheduleLogError` code and the same message, and the stored `scheduledAt` profile is unchanged, so no durable record and no recorded snapshot is re-recorded. Schedule's other suites assert error codes only, so the assertion this change adds to `packages/schedule/schedule/tests/domain.spec.ts` is the only place that pins the three local-calendar message strings. `packages/util/time/tests/instant.spec.ts` covers the new entries directly, per-file 100% included.
|
||||
@@ -30,6 +32,10 @@ Observable behavior does not change. Every accepted input resolves to the same e
|
||||
|
||||
**Have the library return `undefined` instead of reasons.** That is the shape `canonicalClientTimeZone` already has, and it was the smaller change. It was rejected because Schedule must distinguish a malformed instant from a non-existent calendar date from an out-of-range clock field to keep its existing diagnostics, and `canonicalizeTimeZone` must distinguish a bad name from a locally unresolvable one; collapsing those to one falsy value would have forced Schedule to re-derive the distinction with its own regular expressions, restoring the duplication.
|
||||
|
||||
**Brand the millisecond count instead of building a class.** `Branded<number>` already exists in this repository for opaque identities, and a branded count is smaller than a class. It was rejected because the brand does not survive the operation that matters: TypeScript accepts `brandedA + brandedB` and answers an unbranded `number`, so a caller who adds a span to an epoch silently loses the unit, which is the exact mistake the type exists to prevent. A class makes that addition a type error, and it also gives the whole-part split one home.
|
||||
|
||||
**Hold the span in nanoseconds.** `Temporal` carries nanoseconds internally as a `bigint`, so a nanosecond span is representable. It was rejected for the same reason the boundaries stay in milliseconds: the count would have to be a `bigint`, which no `number` arithmetic, no `Date`, and no `JSON.stringify` accepts, so every consumer and every durable record would need a string or binary hop to move it. A nanosecond span as a `number` would instead be quantized to roughly 256 ns at the current epoch, which is a different value wearing the same name.
|
||||
|
||||
**Add formatting and DST arithmetic for other consumers too.** No current consumer needs model-facing formatting or offset arithmetic from this library: `dsh-time-context` owns its own formatter and zone policy text. Adding them now would give the library a public operation set with no consumer evidence, against the package rule that ties each abstraction to a current owner and need.
|
||||
|
||||
## Consequences
|
||||
@@ -38,4 +44,6 @@ The time profile now has one home. A change to the accepted instant profile or t
|
||||
|
||||
The cost is a wider published surface and a heavier dependency graph: `dsh-util-time` gains a runtime dependency on `@js-temporal/polyfill`, so a consumer that imports only `canonicalClientTimeZone` now loads that module too. The library is also no longer a pure "validation and canonicalization only" package, which its README and package description had to state and which its Known Limitations section now records. Schedule keeps a thin mapping layer at each entry. The local-calendar and instant mappers switch over the library's closed reason unions with a `never` default, so a newly added reason fails the build there instead of silently taking a catch-all. The zone mapper instead branches only on `not_canonical_shape` — a reason the current IANA data never produces, so it is guarded by a `v8 ignore` rather than a `never` default — and folds `blank`, `padded`, `not_area_location`, and `unsupported` into one diagnostic. A reason added to `TimeZoneRejection` later would therefore compile there and silently take that generic message.
|
||||
|
||||
The elapsed reading keeps its split in the library and its wording in the consumer: `dsh-time-context` still decides that a backward clock reading clamps to zero and how the compact line reads, while `Duration.decompose()` answers the whole parts. That swap is byte-identical for every finite sample, verified by a differential over more than eight thousand inputs including negatives, fractions, and values past a year. One input does change: a non-finite elapsed sample now throws from the span constructor instead of rendering `NaNs`, which the package's own reading invariant already refused, so the failure moves to the sample that produced it.
|
||||
|
||||
Time-context keeps the two policies this change did not touch: its ISO-shaped timestamp rendering in `packages/context/time-context/src/timestamp.ts`, which is that package's durable reading format, and the elapsed-duration wording in its own plugin. The browser client keeps its own localized date rendering and its own UTC-offset reading, which serve a different purpose (a reader's locale) from a boundary value; unifying those means deciding whether the shared library should also own display formatting, and which package then owns the durable reading format its invariant validates. `packages/session/session-format-v0-to-v1/src/payload-validation.ts` also validates the canonical four-digit-year UTC instant with its own regular expression and round trip, which is a remaining copy of the instant profile this change did not touch.
|
||||
@@ -18,6 +18,8 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec
|
||||
|
||||
`dsh-time-context` 同样消费它,并保留自己更严格的策略。`deriveBrowserTimeZoneContext` 通过 `canonicalizeTimeZone` 读取每条 user-rpc 时区,并拒绝规范答案与所报值不同的别名,因此请求边界仍然要求另一个进程能解析回同一名称。三条拒绝与它们的文案归 time-context 所有;共享入口只回答规范名称或一个原因;被拒绝的结果中只有 `unsupported` 会携带本库捕获到的平台错误,使该拒绝在两个消费方都保留其 `cause`。
|
||||
|
||||
`Duration` 以单一毫秒计数保存一段时间;它做成类而不是 branded number,是因为 TypeScript 的算术会抹掉 brand:两个不同品牌的数字相加得到的是无品牌的 `number`,所以 brand 挡不住秒与毫秒的混用,而实例可以。单位选毫秒,是因为本仓库的每个生产者与消费者本来就用它(`Date.now()`、`SessionEvent.time`、Session 日志、客户端),也因为 double 能精确表示 `Date` 全范围内的每一个毫秒,这个单位不消耗任何精度。实例被冻结;计数只能通过 `milliseconds` 或按小数换算的 `toSeconds`/`toMinutes`/`toHours`/`toDays` 读取,且刻意不提供 `valueOf` 与 `toString`,因此读取方永远不会拿到一个需要自己记单位的裸数字。加、减、排序与整数量拆解 `decompose()` 就是全部操作集合。
|
||||
|
||||
受维护的 `@js-temporal/polyfill` 0.5.1 在成为 `dsh-schedule` 运行时依赖之外,也成为 `dsh-util-time` 的运行时依赖。本地解析继续使用它,因此缺口与重叠仍由宿主 IANA 数据决定,也没有调用方需要自己实现偏移采样。`util/time` 的 README 与包描述删去了新增依赖所否定的 `zero-dependency` 表述。
|
||||
|
||||
可观察行为不变。每个被接受的输入解析出相同的纪元值,每个被拒绝的输入抛出相同的 `ScheduleInputError` 或 `ScheduleLogError` 错误码与相同的消息,存储的 `scheduledAt` 剖面不变,因此没有持久记录或录制快照需要重录。Schedule 其余测试只断言错误码,因此本次在 `packages/schedule/schedule/tests/domain.spec.ts` 新增的断言是唯一固定这三条本地日历消息的地方。`packages/util/time/tests/instant.spec.ts` 直接覆盖新增入口,包含逐文件 100% 覆盖。
|
||||
@@ -30,6 +32,10 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec
|
||||
|
||||
**让本库回答 `undefined` 而不是原因。** 这正是 `canonicalClientTimeZone` 已有的形态,也是改动更小的做法。之所以否决,是因为 Schedule 必须区分格式错误的时刻、日历上不存在的日期与越界时钟字段,才能保留既有诊断;`canonicalizeTimeZone` 也必须区分名称本身不合法与本地无法解析。把二者压成一个假值,会迫使 Schedule 用自己的正则重新推导这层区分,从而恢复重复。
|
||||
|
||||
**给毫秒计数加 brand,而不是造类。** 本仓库已有用于不透明标识的 `Branded<number>`,branded 计数也比类更小。之所以否决,是因为 brand 撑不过真正要紧的那步运算:TypeScript 接受 `brandedA + brandedB` 并回答无品牌的 `number`,于是把时间段加到纪元上的调用方会静默丢掉单位,而这正是该类型要防的错误。类会把这种相加变成类型错误,也让整数量拆解只有一个归宿。
|
||||
|
||||
**用纳秒保存时间段。** `Temporal` 内部以 `bigint` 携带纳秒,所以纳秒时间段是可表示的。之所以否决,理由与边界保持毫秒相同:计数必须变成 `bigint`,而 `number` 算术、`Date` 与 `JSON.stringify` 都不接受它,于是每个消费方与每条持久记录都要多一次字符串或二进制转换。若改用 `number` 表示纳秒,在当前离线纪元量级上它会被量化到约 256 ns,那是同一个名字下的另一个值。
|
||||
|
||||
**同时为其他消费方加入格式化与 DST 运算。** 目前没有消费方需要本库提供面向模型的格式化或偏移运算:`dsh-time-context` 自己拥有格式化器与时区策略文本。现在加入只会给本库一个没有消费方证据的公开操作集合,违背"每项抽象都绑定当前所有者与需求"的包规则。
|
||||
|
||||
## Consequences
|
||||
@@ -38,4 +44,6 @@ Schedule 消费它,并保留全部域内决策。`canonicalizeTimeZone`、`dec
|
||||
|
||||
代价是更宽的已发布接口与更重的依赖图:`dsh-util-time` 增加了对 `@js-temporal/polyfill` 的运行时依赖,因此只导入 `canonicalClientTimeZone` 的消费方也会加载该模块。本库也不再是纯"只做校验与规范化"的包——其 README 与包描述必须说明这一点,其"已知限制"一节现在也记录了它。Schedule 在每个入口保留一层薄映射。本地日历与时刻映射函数以 `never` 默认分支穷尽 switch 本库的封闭原因联合,因此新增原因会让这两个调用点构建失败,而不是静默走兜底分支。时区映射函数则只对 `not_canonical_shape` 分支——当前 IANA 数据不会产生该原因,因此它由 `v8 ignore` 守护而不是 `never` 默认分支——并把 `blank`、`padded`、`not_area_location` 与 `unsupported` 合并成一条诊断。因此日后给 `TimeZoneRejection` 新增原因时会通过编译并静默套用那条通用文案。
|
||||
|
||||
耗时读数的拆分上收到库、措辞留在一侧:`dsh-time-context` 仍然决定回退的时钟读数钳到零、以及那行紧凑文本怎么写,而 `Duration.decompose()` 回答整数量。这次替换对每个有限样本都逐字不变,已用一份覆盖八千多个输入(含负数、小数与一年以上的值)的差分验证。有一个输入会变:非有限的耗时样本现在由时间段构造函数抛出,而不是渲染出 `NaNs`——而那本来就被该包自己的读数不变量拒绝,所以失败点前移到了产生它的那次采样。
|
||||
|
||||
本次未触及、仍归 time-context 的两项:`packages/context/time-context/src/timestamp.ts` 中的 ISO 形状时间戳渲染(那是该包的持久读数字格式),以及其插件内的耗时措辞。浏览器客户端也保留自己的本地化日期渲染与自己的 UTC 偏移读取——它们服务的目的(读者的区域设置)与边界值不同;统一它们意味着先决定共享库是否也要拥有展示格式化,以及届时由谁拥有其不变量所校验的持久读数字格式。`packages/session/session-format-v0-to-v1/src/payload-validation.ts` 也用自己的一段正则与往返比对校验规范的四位年份 UTC 时刻,这是本次未触及、仍然存在的时刻剖面副本。
|
||||
@@ -19,6 +19,7 @@ declare module '@deepseek-ai/dsh-llm' {
|
||||
|
||||
import type { UserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionSeq } from '@deepseek-ai/dsh-session'
|
||||
import { Duration } from '@deepseek-ai/dsh-util-time'
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
import {
|
||||
deriveBrowserTimeZoneContext,
|
||||
@@ -66,15 +67,17 @@ export const Config: z<Config> = z.object({
|
||||
refreshIntervalMs: z.number(),
|
||||
})
|
||||
|
||||
/** Format a non-negative elapsed millisecond count as compact whole-second units. */
|
||||
/**
|
||||
* Format a non-negative elapsed millisecond count as compact whole-second units.
|
||||
*
|
||||
* A backward wall-clock reading is a property of the clock rather than of the
|
||||
* span, so it clamps to zero here; the shared span type then supplies the whole
|
||||
* parts, and this package keeps the wording.
|
||||
*/
|
||||
function formatDuration(elapsedMs: number): string {
|
||||
let seconds = Math.floor(Math.max(0, elapsedMs) / 1000)
|
||||
const days = Math.floor(seconds / 86_400)
|
||||
seconds %= 86_400
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
seconds %= 3600
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
seconds %= 60
|
||||
const { days, hours, minutes, seconds } = Duration
|
||||
.fromMilliseconds(Math.max(0, elapsedMs))
|
||||
.decompose()
|
||||
const parts: string[] = []
|
||||
if (days > 0) parts.push(`${days}d`)
|
||||
if (hours > 0) parts.push(`${hours}h`)
|
||||
|
||||
@@ -9,14 +9,14 @@
|
||||
en: 7283f2afb343d150
|
||||
zh: 32c56bd67c0314c6
|
||||
/util-shared-utilities/summary:
|
||||
en: 2424d44f0d79be74
|
||||
en: f21c74532a9c929e
|
||||
zh: 5bc05166e8d83c1e
|
||||
/util-shared-utilities/table-of-contents:
|
||||
en: 1eb35b015a6c423f
|
||||
zh: a8a3dceee2c9e943
|
||||
/util-shared-utilities/packages:
|
||||
en: 662b2baeac105692
|
||||
zh: f076c6d39c2985b1
|
||||
en: 44eb08c6c7a5bbfb
|
||||
zh: 9857e5374e5b14de
|
||||
/util-shared-utilities/related-documentation:
|
||||
en: 432ae81110914571
|
||||
zh: c5bf4efc007311f3
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
The `util/` group gives capability packages shared mechanical primitives instead of duplicate implementations. It covers atomic writes, branded ids, deques, lossless JSON values, UUIDs, Harness-home paths, launch environments, outbound proxy policy, native commands, output retention, time-zone and instant vocabulary, and timeout handling. Every root entry here is a library: it registers no product service or event, and the consuming capability retains the business semantics.
|
||||
The `util/` group gives capability packages shared mechanical primitives instead of duplicate implementations. It covers atomic writes, branded ids, deques, lossless JSON values, UUIDs, Harness-home paths, launch environments, outbound proxy policy, native commands, output retention, time-zone, instant, and span vocabulary, and timeout handling. Every root entry here is a library: it registers no product service or event, and the consuming capability retains the business semantics.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -41,7 +41,7 @@ Each package provides one primitive; open a package page for how to use it.
|
||||
| [`workspace-path/`](workspace-path/README.md) | Provides browser-safe Workspace path and display helpers |
|
||||
| [`code-language/`](code-language/README.md) | Maps a filename suffix to the syntax-highlighting language that the Code preview, diff review, and the read card render |
|
||||
| [`output-retention/`](output-retention/README.md) | Bounds model-facing output and reports exact omission metadata |
|
||||
| [`time/`](time/README.md) | Parses strict instants, local calendars, and caller-reported IANA zones |
|
||||
| [`time/`](time/README.md) | Parses strict instants, local calendars, and caller-reported IANA zones, and holds a millisecond span |
|
||||
| [`timeout/`](timeout/README.md) | Deadline arithmetic, signal fusion, and timeout-versus-cancel classification |
|
||||
|
||||
-----
|
||||
|
||||
@@ -41,7 +41,7 @@ kind: "package-group"
|
||||
| [`workspace-path/`](workspace-path/README.zh.md) | 提供浏览器安全的 Workspace 路径与显示辅助函数 |
|
||||
| [`code-language/`](code-language/README.zh.md) | 把文件名后缀映射为 Code 预览、diff 审阅与 read 卡片所渲染的语法高亮语言 |
|
||||
| [`output-retention/`](output-retention/README.zh.md) | 限制面向模型的输出并报告精确的省略元数据 |
|
||||
| [`time/`](time/README.zh.md) | 解析严格的时刻、本地日历与调用方所报的 IANA 时区 |
|
||||
| [`time/`](time/README.zh.md) | 解析严格的时刻、本地日历与调用方所报的 IANA 时区,并持有毫秒精度的时间段 |
|
||||
| [`timeout/`](timeout/README.zh.md) | 截止时间运算、信号融合与超时和取消分类 |
|
||||
|
||||
-----
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/util/time/README.md
|
||||
/:
|
||||
en: 78b8a5dd36b637f5
|
||||
zh: 451e95d5bc7d5a66
|
||||
en: 6daf8db648646a31
|
||||
zh: 156a7d47413a6a55
|
||||
/dsh-util-time:
|
||||
en: bc299a9b8fe7717f
|
||||
zh: 896661ee0c4edf25
|
||||
/dsh-util-time/summary:
|
||||
en: 6d4d39093c2a2b5a
|
||||
zh: 1dc4af01352581f2
|
||||
en: cdc849b4f44094b1
|
||||
zh: b03edaa6a020d39d
|
||||
/dsh-util-time/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: f14f34fce35a8d90
|
||||
@@ -18,14 +18,14 @@
|
||||
en: bbff250eba0f1b89
|
||||
zh: 0a618f05cc88ee2b
|
||||
/dsh-util-time/use-this-package/when-to-use-it:
|
||||
en: f97e654746aa3712
|
||||
zh: 52ba385e9f3b96a9
|
||||
en: 7015b3ed33de4c53
|
||||
zh: d4fc9aee6f6910ca
|
||||
/dsh-util-time/use-this-package/entry-point:
|
||||
en: b5b79b2f1617b788
|
||||
zh: 72606179d6a7c33c
|
||||
en: 306f0f5d65af8223
|
||||
zh: a79b60c39be361c8
|
||||
/dsh-util-time/understand-the-implementation:
|
||||
en: c814c83b60f43e0c
|
||||
zh: 89021dc77356392b
|
||||
en: f42cb0fc332d7740
|
||||
zh: f980bcae2f72fdd8
|
||||
/dsh-util-time/further-exploration:
|
||||
en: 8aa95ebdfd1f323e
|
||||
zh: c6879eac9fa6e087
|
||||
@@ -36,8 +36,8 @@
|
||||
en: daf6e45a18c087b0
|
||||
zh: e6714f1e65ed55ce
|
||||
/dsh-util-time/known-limitations-and-deferred-work:
|
||||
en: 8b29ec4add8f93e6
|
||||
zh: ee9bfd6224a4204c
|
||||
en: b4108682b9b18245
|
||||
zh: 7dfb014c2dc1775f
|
||||
/dsh-util-time/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: e88aeb617ffb74ea
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "Strict RFC 3339 instant, local-calendar, and IANA zone parsing for maintainers accepting a caller-reported zone or a wall-clock selector at a boundary."
|
||||
description: "Strict RFC 3339 instant, local-calendar, and IANA zone parsing, plus a millisecond span type, for maintainers accepting a caller-reported zone, a wall-clock selector, or an elapsed time at a boundary."
|
||||
kind: "package-library"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Shared time vocabulary for the boundaries that accept a caller's zone or a wall-clock selector. `canonicalClientTimeZone` admits `UTC` or an IANA `Area/Location` name and answers the platform-canonical spelling, so an alias never reaches a durable record. A wall-clock consumer instead parses strict RFC 3339 instants, local calendar fields, and zone names through discriminated results, then resolves a local value with maintained `Temporal` DST and overlap behavior. The library owns no failure vocabulary or model-facing text: each caller maps a reason onto its own domain code.
|
||||
Shared time vocabulary for the boundaries that accept a caller's zone, a wall-clock selector, or an elapsed time. `canonicalClientTimeZone` answers the platform-canonical `UTC` or IANA `Area/Location` spelling, so an alias never reaches a durable record. The strict parsers read RFC 3339 instants, local calendar fields, and zone names as discriminated results, and `resolveLocalInstant` applies maintained `Temporal` DST and overlap behavior. `Duration` holds a span in milliseconds and splits it into whole parts. The library owns no failure vocabulary and no model-facing text: each caller maps a reason onto its own domain code.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -29,14 +29,14 @@ Shared time vocabulary for the boundaries that accept a caller's zone or a wall-
|
||||
|
||||
It is a **library, not a service or plugin**: no `ctx`, registers nothing, holds no state.
|
||||
|
||||
Call it at the boundary that receives the zone or the wall-clock selector, before the value reaches anything durable. `dsh-api-session-controller` and `dsh-subagent` admit a caller-reported zone through `canonicalClientTimeZone`; `dsh-time-context` admits the zone a request turn reports through `canonicalizeTimeZone` and refuses an alias rather than rewriting it; `dsh-schedule` reads every absolute and wall-clock selector through the strict parsers and resolves its local targets through `resolveLocalInstant`. A consumer that owns calendar or interval arithmetic of its own keeps that arithmetic and calls these entries only for the boundary value.
|
||||
Call it at the boundary that receives the zone or the wall-clock selector, before the value reaches anything durable. `dsh-api-session-controller` and `dsh-subagent` admit a caller-reported zone through `canonicalClientTimeZone`; `dsh-time-context` admits the zone a request turn reports through `canonicalizeTimeZone` and refuses an alias rather than rewriting it; `dsh-schedule` reads every absolute and wall-clock selector through the strict parsers and resolves its local targets through `resolveLocalInstant`. `dsh-time-context` reads its elapsed reading through `Duration` and keeps the wording of the line it injects. A consumer that owns calendar or interval arithmetic of its own keeps that arithmetic and calls these entries only for the boundary value.
|
||||
|
||||
No runtime invariant companion is published because this pure utility owns no event stream or mutable runtime data; unit tests verify every entry.
|
||||
|
||||
### Entry point
|
||||
|
||||
```ts
|
||||
import { canonicalClientTimeZone, fourDigitYearEpoch, parseLocalCalendar, resolveLocalInstant } from '@deepseek-ai/dsh-util-time'
|
||||
import { Duration, canonicalClientTimeZone, fourDigitYearEpoch, parseLocalCalendar, resolveLocalInstant } from '@deepseek-ai/dsh-util-time'
|
||||
|
||||
const zone = canonicalClientTimeZone('US/Eastern') // 'America/New_York' or undefined
|
||||
const stored = fourDigitYearEpoch('2026-03-08T02:30:00.000Z') // { kind: 'accepted', epoch } or a rejection reason
|
||||
@@ -45,9 +45,12 @@ const local = parseLocalCalendar('2026-03-08', '02:30:00') // { kind: 'accept
|
||||
if (local.kind === 'accepted') {
|
||||
resolveLocalInstant(local.time, zone ?? 'UTC') // epoch milliseconds, or undefined inside a DST gap
|
||||
}
|
||||
|
||||
Duration.fromSeconds(90).decompose() // { days: 0, hours: 0, minutes: 1, seconds: 30, milliseconds: 0 }
|
||||
Duration.fromSeconds(90).milliseconds // 90000 — the unit is in the name, and no coercion answers the count
|
||||
```
|
||||
|
||||
`canonicalClientTimeZone` answers `undefined` for a name it refuses, and the caller raises its own refusal — `session/invalid-time-zone` for the Session prompt, `subagent/invalid-time-zone` for a subagent continuation. `canonicalizeTimeZone`, the strict parsers, and `fourDigitYearEpoch` instead report why they refused, as a discriminated result whose `rejected` arm carries a stable reason, so each caller chooses its own code and diagnostic. `formatFourDigitYearInstant` and `resolveLocalInstant` take already-validated values and answer a string, or epoch milliseconds, with `undefined` for an unrepresentable epoch or a wall-clock value inside a DST gap.
|
||||
`canonicalClientTimeZone` answers `undefined` for a name it refuses, and the caller raises its own refusal — `session/invalid-time-zone` for the Session prompt, `subagent/invalid-time-zone` for a subagent continuation. `canonicalizeTimeZone`, the strict parsers, and `fourDigitYearEpoch` instead report why they refused, as a discriminated result whose `rejected` arm carries a stable reason, so each caller chooses its own code and diagnostic. `formatFourDigitYearInstant` and `resolveLocalInstant` take already-validated values and answer a string, or epoch milliseconds, with `undefined` for an unrepresentable epoch or a wall-clock value inside a DST gap. `Duration` is a frozen value type over one millisecond count: it validates that the count is finite, adds and orders without any unit conversion, converts to seconds, minutes, hours, and days fractionally, and splits a non-negative span into whole parts.
|
||||
|
||||
-----
|
||||
|
||||
@@ -71,6 +74,7 @@ The strict parsers match a fixed regular expression before constructing any date
|
||||
| `formatFourDigitYearInstant` | An integral epoch inside the four-digit-year range; answers `undefined` outside it or when non-integral. |
|
||||
| `isFourDigitYearInstant` | The same range, as a predicate, so a caller that only needs the bound does not re-derive it. |
|
||||
| `resolveLocalInstant` | Exact calendar fields of a real date, or an already-built `Temporal.PlainDateTime`, plus an already-canonical zone. |
|
||||
| `Duration` | One finite millisecond count, built from milliseconds or seconds, and never a bare count on read. |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -100,7 +104,9 @@ None of its own. The consumer that injects a zone- or schedule-derived line into
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **Alias resolution follows the runtime's ICU data** — which name an alias group canonicalizes to is the platform's answer, so two processes on different Node builds can disagree about it.
|
||||
- **No recurrence arithmetic** — instant validity, local-calendar parsing, zone resolution, and canonical formatting only; a caller that walks candidate dates or intervals owns that search.
|
||||
- **No recurrence arithmetic** — instant validity, local-calendar parsing, zone resolution, canonical formatting, and a millisecond span only; a caller that walks candidate dates or intervals owns that search.
|
||||
- **`Duration` has no calendar units** — a day is exactly 24 hours and a month has no fixed length, so a caller that needs local calendar days derives them from the zone.
|
||||
- **`Duration` does not convert units implicitly** — it has no `valueOf` and no `toString`, so a reader states its unit through `milliseconds` or a `to*` conversion.
|
||||
- **The `Temporal` polyfill is a runtime dependency** — a consumer that imports only `canonicalClientTimeZone` still resolves it at module load, because both entries share one module.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
description: "面向在协议边界接收调用方所报时区或墙钟选择器的维护者,说明严格的 RFC 3339 时刻、本地日历与时区解析。"
|
||||
description: "面向在协议边界接收调用方所报时区、墙钟选择器或耗时的维护者,说明严格的 RFC 3339 时刻、本地日历与时区解析,以及毫秒精度的时间段类型。"
|
||||
kind: "package-library"
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ kind: "package-library"
|
||||
|
||||
## 概述
|
||||
|
||||
共享时间词汇,供接收调用方时区或墙钟选择器的协议边界使用。`canonicalClientTimeZone` 只接受 `UTC` 或 IANA `Area/Location` 名称,并回答当前平台上的规范拼写,因此别名不会进入持久记录。需要墙钟语义的消费方则通过带判别值的结果解析严格的 RFC 3339 时刻、本地日历字段与时区名称,再借助受维护的 `Temporal` 解析本地值,获得 DST 与重叠时段的既定行为。本库不持有失败词汇,也不生成面向模型的文本:每个调用方把拒绝原因映射成自己的域错误码。
|
||||
共享时间词汇,供接收调用方时区、墙钟选择器或耗时的边界使用。`canonicalClientTimeZone` 回答当前平台上的规范 `UTC` 或 IANA `Area/Location` 拼写,因此别名不会进入持久记录。严格解析器以带判别值的结果读取 RFC 3339 时刻、本地日历字段与时区名称,`resolveLocalInstant` 则套用受维护的 `Temporal` 的 DST 与重叠行为。`Duration` 以毫秒保存一段时间并拆成整数量。本库不持有失败词汇,也不生成面向模型的文本:每个调用方把拒绝原因映射成自己的域错误码。
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -29,14 +29,14 @@ kind: "package-library"
|
||||
|
||||
它是**库,不是服务也不是插件**:无 `ctx`、不注册任何东西、不持有状态。
|
||||
|
||||
在接收时区或墙钟选择器的那个边界上调用它,让值在进入任何持久物之前先过一遍。`dsh-api-session-controller` 与 `dsh-subagent` 通过 `canonicalClientTimeZone` 接收调用方所报时区;`dsh-time-context` 通过 `canonicalizeTimeZone` 接收某一轮请求所报时区,并拒绝别名而不是把它改写;`dsh-schedule` 通过严格解析器读取每一个绝对选择器与墙钟选择器,并通过 `resolveLocalInstant` 解析其本地目标。自行拥有日历或间隔运算的消费方保留那部分运算,只在边界值上调用这些入口。
|
||||
在接收时区或墙钟选择器的那个边界上调用它,让值在进入任何持久物之前先过一遍。`dsh-api-session-controller` 与 `dsh-subagent` 通过 `canonicalClientTimeZone` 接收调用方所报时区;`dsh-time-context` 通过 `canonicalizeTimeZone` 接收某一轮请求所报时区,并拒绝别名而不是把它改写;`dsh-schedule` 通过严格解析器读取每一个绝对选择器与墙钟选择器,并通过 `resolveLocalInstant` 解析其本地目标。`dsh-time-context` 通过 `Duration` 读取其耗时读数,并保留它注入那一行的措辞。自行拥有日历或间隔运算的消费方保留那部分运算,只在边界值上调用这些入口。
|
||||
|
||||
不发布运行时不变量伴生入口;这个纯工具不拥有事件流或可变运行时数据,每个入口都由单元测试验证。
|
||||
|
||||
### 入口
|
||||
|
||||
```ts
|
||||
import { canonicalClientTimeZone, fourDigitYearEpoch, parseLocalCalendar, resolveLocalInstant } from '@deepseek-ai/dsh-util-time'
|
||||
import { Duration, canonicalClientTimeZone, fourDigitYearEpoch, parseLocalCalendar, resolveLocalInstant } from '@deepseek-ai/dsh-util-time'
|
||||
|
||||
const zone = canonicalClientTimeZone('US/Eastern') // 'America/New_York' or undefined
|
||||
const stored = fourDigitYearEpoch('2026-03-08T02:30:00.000Z') // { kind: 'accepted', epoch } or a rejection reason
|
||||
@@ -45,9 +45,12 @@ const local = parseLocalCalendar('2026-03-08', '02:30:00') // { kind: 'accept
|
||||
if (local.kind === 'accepted') {
|
||||
resolveLocalInstant(local.time, zone ?? 'UTC') // epoch milliseconds, or undefined inside a DST gap
|
||||
}
|
||||
|
||||
Duration.fromSeconds(90).decompose() // { days: 0, hours: 0, minutes: 1, seconds: 30, milliseconds: 0 }
|
||||
Duration.fromSeconds(90).milliseconds // 90000 — the unit is in the name, and no coercion answers the count
|
||||
```
|
||||
|
||||
不可用的时区名称由 `canonicalClientTimeZone` 回答 `undefined`,由调用方抛出自己的拒绝——Session 提示词用 `session/invalid-time-zone`,subagent 续话用 `subagent/invalid-time-zone`。`canonicalizeTimeZone`、各严格解析器与 `fourDigitYearEpoch` 则会说明拒绝原因,以带判别值的结果回答,其 `rejected` 分支携带稳定的拒绝原因,由各调用方选择自己的错误码与诊断文案。`formatFourDigitYearInstant` 与 `resolveLocalInstant` 接收已校验的值,分别回答字符串与纪元毫秒,无法表示的纪元或落在 DST 缺口内的墙钟值回答 `undefined`。
|
||||
不可用的时区名称由 `canonicalClientTimeZone` 回答 `undefined`,由调用方抛出自己的拒绝——Session 提示词用 `session/invalid-time-zone`,subagent 续话用 `subagent/invalid-time-zone`。`canonicalizeTimeZone`、各严格解析器与 `fourDigitYearEpoch` 则会说明拒绝原因,以带判别值的结果回答,其 `rejected` 分支携带稳定的拒绝原因,由各调用方选择自己的错误码与诊断文案。`formatFourDigitYearInstant` 与 `resolveLocalInstant` 接收已校验的值,分别回答字符串与纪元毫秒,无法表示的纪元或落在 DST 缺口内的墙钟值回答 `undefined`。`Duration` 是一个基于单一毫秒计数的冻结值类型:它校验计数有限,加减与排序都不做单位换算,按小数换算到秒、分、小时与天,并把非负的时间段拆成整数量。
|
||||
|
||||
-----
|
||||
|
||||
@@ -71,6 +74,7 @@ if (local.kind === 'accepted') {
|
||||
| `formatFourDigitYearInstant` | 四位年份范围内的整数纪元毫秒;超出范围或非整数时回答 `undefined`。 |
|
||||
| `isFourDigitYearInstant` | 同一范围的谓词形式,只判断范围边界的调用方无需自行推导。 |
|
||||
| `resolveLocalInstant` | 真实日期的精确日历字段,或已构造好的 `Temporal.PlainDateTime`,外加已规范化的时区。 |
|
||||
| `Duration` | 一个有限的毫秒计数,可由毫秒或秒构造,读取时从不交出裸计数。 |
|
||||
|
||||
</details>
|
||||
|
||||
@@ -100,7 +104,9 @@ if (local.kind === 'accepted') {
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **别名解析取决于运行时的 ICU 数据**——一个别名组规范化成哪个名称由平台回答,因此两个跑在不同 Node 构建上的进程可能给出不同答案。
|
||||
- **不含重复规则运算**——只做时刻有效性、本地日历解析、时区解析与规范格式化;遍历候选日期或间隔的搜索由调用方自己拥有。
|
||||
- **不含重复规则运算**——只做时刻有效性、本地日历解析、时区解析、规范格式化与毫秒精度的时间段;遍历候选日期或间隔的搜索由调用方自己拥有。
|
||||
- **`Duration` 没有日历单位**——一天恰好是 24 小时,月没有固定长度;需要本地日历天的调用方从时区推导。
|
||||
- **`Duration` 不做隐式单位换算**——它没有 `valueOf` 也没有 `toString`,读取方必须通过 `milliseconds` 或某个 `to*` 换算声明单位。
|
||||
- **`Temporal` polyfill 是运行时依赖**——只导入 `canonicalClientTimeZone` 的消费方在模块加载时同样会解析它,因为两个入口共用一个模块。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-util-time",
|
||||
"description": "Time vocabulary shared by wire boundaries: canonicalClientTimeZone (IANA zone validation and canonicalization), and strict RFC 3339 instant, local-calendar, and zone resolution for wall-clock selectors",
|
||||
"description": "Time vocabulary shared by wire boundaries: canonicalClientTimeZone (IANA zone validation and canonicalization), strict RFC 3339 instant, local-calendar, and zone resolution for wall-clock selectors, and a millisecond Duration span",
|
||||
"version": "0.1.7-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* One span of time, held as a count of milliseconds.
|
||||
*
|
||||
* A class rather than a branded number: arithmetic on two branded numbers
|
||||
* answers an unbranded `number`, so a brand cannot stop seconds and
|
||||
* milliseconds from being added, while an instance can. There is no `valueOf`
|
||||
* and no `toString`, so no reader ever receives a bare count whose unit it has
|
||||
* to remember; a caller states the unit by reading `milliseconds` or by
|
||||
* converting through one of the `to*` entries.
|
||||
* @module @deepseek-ai/dsh-util-time
|
||||
*/
|
||||
|
||||
const MS_PER_SECOND = 1_000
|
||||
const MS_PER_MINUTE = 60 * MS_PER_SECOND
|
||||
const MS_PER_HOUR = 60 * MS_PER_MINUTE
|
||||
const MS_PER_DAY = 24 * MS_PER_HOUR
|
||||
const SECONDS_PER_DAY = 86_400
|
||||
const SECONDS_PER_HOUR = 3_600
|
||||
const SECONDS_PER_MINUTE = 60
|
||||
|
||||
/** Whole parts of one non-negative span, in the units a reading is written in. */
|
||||
export interface DurationParts {
|
||||
/** Whole days. */
|
||||
readonly days: number
|
||||
/** Whole hours left after the days, from 0 through 23. */
|
||||
readonly hours: number
|
||||
/** Whole minutes left after the hours, from 0 through 59. */
|
||||
readonly minutes: number
|
||||
/** Whole seconds left after the minutes, from 0 through 59. */
|
||||
readonly seconds: number
|
||||
/** Fractional milliseconds left after the seconds, from 0 up to (but excluding) 1000. */
|
||||
readonly milliseconds: number
|
||||
}
|
||||
|
||||
/**
|
||||
* One span of time in milliseconds.
|
||||
*
|
||||
* Instances are frozen, so a span cannot drift after it is built, and they carry
|
||||
* no calendar meaning: a month is not a fixed count of milliseconds, so no entry
|
||||
* here converts to or from calendar units.
|
||||
*/
|
||||
export class Duration {
|
||||
/** The additive identity. */
|
||||
static readonly zero: Duration = new Duration(0)
|
||||
|
||||
private readonly span: number
|
||||
|
||||
private constructor(milliseconds: number) {
|
||||
this.span = milliseconds
|
||||
Object.freeze(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one span from a millisecond count.
|
||||
* @param milliseconds - Finite millisecond count; a fractional count is kept as given.
|
||||
* @returns The span.
|
||||
* @throws TypeError when the count is not a finite number.
|
||||
*/
|
||||
static fromMilliseconds(milliseconds: number): Duration {
|
||||
if (!Number.isFinite(milliseconds)) {
|
||||
throw new TypeError(`duration milliseconds must be finite, got ${String(milliseconds)}`)
|
||||
}
|
||||
return new Duration(milliseconds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one span from a second count, for a boundary whose unit is seconds.
|
||||
* @param seconds - Finite second count; a fractional count is kept as given.
|
||||
* @returns The span.
|
||||
* @throws TypeError when the count is not finite, or its millisecond equivalent is not.
|
||||
*/
|
||||
static fromSeconds(seconds: number): Duration {
|
||||
if (!Number.isFinite(seconds)) {
|
||||
throw new TypeError(`duration seconds must be finite, got ${String(seconds)}`)
|
||||
}
|
||||
return Duration.fromMilliseconds(seconds * MS_PER_SECOND)
|
||||
}
|
||||
|
||||
/** The span as a millisecond count; the unit is part of the name. */
|
||||
get milliseconds(): number {
|
||||
return this.span
|
||||
}
|
||||
|
||||
/**
|
||||
* Add one span to this span.
|
||||
* @param other - Span to add; both operands already share the millisecond unit.
|
||||
* @returns The sum.
|
||||
*/
|
||||
plus(other: Duration): Duration {
|
||||
return new Duration(this.span + other.span)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtract one span from this span.
|
||||
* @param other - Span to subtract.
|
||||
* @returns The difference, which is negative when `other` is longer.
|
||||
*/
|
||||
minus(other: Duration): Duration {
|
||||
return new Duration(this.span - other.span)
|
||||
}
|
||||
|
||||
/**
|
||||
* Order two spans.
|
||||
* @param other - Span to compare with.
|
||||
* @returns `-1` when this span is shorter, `0` when equal, `1` when longer.
|
||||
*/
|
||||
compare(other: Duration): -1 | 0 | 1 {
|
||||
if (this.span < other.span) return -1
|
||||
return this.span > other.span ? 1 : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the span is a negative interval, which every sign-sensitive reader
|
||||
* must decide how to handle.
|
||||
* @returns Whether the millisecond count is below zero.
|
||||
*/
|
||||
isNegative(): boolean {
|
||||
return this.span < 0
|
||||
}
|
||||
|
||||
/**
|
||||
* The span in seconds.
|
||||
* @returns The span as a fractional second count, lossless in both directions.
|
||||
*/
|
||||
toSeconds(): number {
|
||||
return this.span / MS_PER_SECOND
|
||||
}
|
||||
|
||||
/**
|
||||
* The span in minutes.
|
||||
* @returns The span as a fractional minute count, lossless in both directions.
|
||||
*/
|
||||
toMinutes(): number {
|
||||
return this.span / MS_PER_MINUTE
|
||||
}
|
||||
|
||||
/**
|
||||
* The span in hours.
|
||||
* @returns The span as a fractional hour count, lossless in both directions.
|
||||
*/
|
||||
toHours(): number {
|
||||
return this.span / MS_PER_HOUR
|
||||
}
|
||||
|
||||
/**
|
||||
* The span in days.
|
||||
*
|
||||
* A day here is exactly 24 hours. A wall-clock reading that crosses a
|
||||
* daylight-saving transition is not a whole number of days, so a caller that
|
||||
* needs local calendar days derives them from the zone instead.
|
||||
* @returns The span as a fractional day count, lossless in both directions.
|
||||
*/
|
||||
toDays(): number {
|
||||
return this.span / MS_PER_DAY
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the span into whole days, hours, minutes, seconds, and the remaining
|
||||
* fraction of a second. Each part is floored, so the parts never exceed the
|
||||
* span.
|
||||
* @returns The frozen whole parts.
|
||||
* @throws RangeError when the span is negative, because a negative interval
|
||||
* has no single whole-part decomposition: the caller owns that policy.
|
||||
*/
|
||||
decompose(): DurationParts {
|
||||
if (this.span < 0) {
|
||||
throw new RangeError('a negative duration has no whole-part decomposition')
|
||||
}
|
||||
let seconds = Math.floor(this.span / MS_PER_SECOND)
|
||||
const days = Math.floor(seconds / SECONDS_PER_DAY)
|
||||
seconds %= SECONDS_PER_DAY
|
||||
const hours = Math.floor(seconds / SECONDS_PER_HOUR)
|
||||
seconds %= SECONDS_PER_HOUR
|
||||
const minutes = Math.floor(seconds / SECONDS_PER_MINUTE)
|
||||
seconds %= SECONDS_PER_MINUTE
|
||||
return Object.freeze({
|
||||
days,
|
||||
hours,
|
||||
minutes,
|
||||
seconds,
|
||||
milliseconds: this.span - Math.floor(this.span / MS_PER_SECOND) * MS_PER_SECOND,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Time vocabulary shared by the boundaries that accept a caller's zone or a
|
||||
* wall-clock selector.
|
||||
* wall-clock selector, plus one span type for elapsed and interval arithmetic.
|
||||
*
|
||||
* Validation, canonicalization, and strict parsing only: this library formats
|
||||
* no model-facing text and owns no failure vocabulary. A refusal that carries a
|
||||
@@ -8,12 +8,14 @@
|
||||
* own domain code and diagnostic. `canonicalClientTimeZone` is the wire-boundary
|
||||
* form and answers a name or `undefined`; `formatFourDigitYearInstant` and
|
||||
* `resolveLocalInstant` take already-validated values and answer a string or
|
||||
* epoch milliseconds directly.
|
||||
* epoch milliseconds directly; `Duration` holds a span in milliseconds with no
|
||||
* implicit numeric conversion.
|
||||
* @module @deepseek-ai/dsh-util-time
|
||||
*/
|
||||
|
||||
import { canonicalizeTimeZone } from './instant.ts'
|
||||
|
||||
export * from './duration.ts'
|
||||
export * from './instant.ts'
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Duration } from '@deepseek-ai/dsh-util-time'
|
||||
|
||||
describe('Duration construction', () => {
|
||||
it('holds the millisecond count it was built from, fraction included', () => {
|
||||
expect(Duration.fromMilliseconds(1_500).milliseconds).toBe(1_500)
|
||||
expect(Duration.fromMilliseconds(0.5).milliseconds).toBe(0.5)
|
||||
expect(Duration.zero.milliseconds).toBe(0)
|
||||
})
|
||||
|
||||
it('converts a second count at the boundary that carries seconds', () => {
|
||||
expect(Duration.fromSeconds(300).milliseconds).toBe(300_000)
|
||||
expect(Duration.fromSeconds(0.25).milliseconds).toBe(250)
|
||||
expect(Duration.fromSeconds(-60).milliseconds).toBe(-60_000)
|
||||
})
|
||||
|
||||
it('refuses a count that is not a finite number', () => {
|
||||
for (const value of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) {
|
||||
expect(() => Duration.fromMilliseconds(value)).toThrow(TypeError)
|
||||
expect(() => Duration.fromSeconds(value)).toThrow(TypeError)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a finite second count whose millisecond equivalent leaves the finite range', () => {
|
||||
expect(() => Duration.fromSeconds(1e306)).toThrow(TypeError)
|
||||
})
|
||||
|
||||
it('is frozen, and carries no implicit numeric conversion', () => {
|
||||
const span = Duration.fromMilliseconds(1)
|
||||
expect(Object.isFrozen(span)).toBe(true)
|
||||
expect(Reflect.set(span, 'span', 2)).toBe(false)
|
||||
expect(span.milliseconds).toBe(1)
|
||||
// Without a `valueOf` or `toString` override, a coercion never answers the count.
|
||||
expect(Number(span)).toBe(Number.NaN)
|
||||
expect(String(span)).toBe('[object Object]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Duration arithmetic', () => {
|
||||
it('adds and subtracts in the one shared unit', () => {
|
||||
expect(Duration.fromSeconds(2).plus(Duration.fromMilliseconds(500)).milliseconds).toBe(2_500)
|
||||
expect(Duration.fromSeconds(2).minus(Duration.fromMilliseconds(500)).milliseconds).toBe(1_500)
|
||||
expect(Duration.fromMilliseconds(1).minus(Duration.fromSeconds(1)).isNegative()).toBe(true)
|
||||
expect(Duration.zero.plus(Duration.zero).milliseconds).toBe(0)
|
||||
})
|
||||
|
||||
it('orders spans without surrendering the unit', () => {
|
||||
const minute = Duration.fromSeconds(60)
|
||||
const hour = Duration.fromSeconds(3_600)
|
||||
expect(minute.compare(hour)).toBe(-1)
|
||||
expect(hour.compare(minute)).toBe(1)
|
||||
expect(minute.compare(Duration.fromMilliseconds(60_000))).toBe(0)
|
||||
expect(minute.isNegative()).toBe(false)
|
||||
expect(Duration.fromMilliseconds(-1).isNegative()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Duration conversion', () => {
|
||||
it('converts to larger units fractionally, so nothing is lost either way', () => {
|
||||
const span = Duration.fromMilliseconds(90_061_500)
|
||||
expect(span.toSeconds()).toBe(90_061.5)
|
||||
expect(span.toMinutes()).toBe(1_501.025)
|
||||
expect(span.toHours()).toBe(25.017_083_333_333_332)
|
||||
expect(span.toDays()).toBe(1.042_378_472_222_222_2)
|
||||
expect(Duration.fromSeconds(span.toSeconds()).milliseconds).toBe(span.milliseconds)
|
||||
})
|
||||
|
||||
it('covers a day as exactly twenty-four hours', () => {
|
||||
expect(Duration.fromMilliseconds(86_400_000).toDays()).toBe(1)
|
||||
expect(Duration.fromMilliseconds(-86_400_000).toDays()).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Duration decompose', () => {
|
||||
it('splits a span into floored whole parts and the fractional remainder', () => {
|
||||
expect(Duration.fromMilliseconds(0).decompose())
|
||||
.toEqual({ days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 0 })
|
||||
expect(Duration.fromMilliseconds(999).decompose())
|
||||
.toEqual({ days: 0, hours: 0, minutes: 0, seconds: 0, milliseconds: 999 })
|
||||
expect(Duration.fromMilliseconds(1_000).decompose())
|
||||
.toEqual({ days: 0, hours: 0, minutes: 0, seconds: 1, milliseconds: 0 })
|
||||
expect(Duration.fromMilliseconds(90_061_500).decompose())
|
||||
.toEqual({ days: 1, hours: 1, minutes: 1, seconds: 1, milliseconds: 500 })
|
||||
expect(Duration.fromMilliseconds(86_400_000).decompose())
|
||||
.toEqual({ days: 1, hours: 0, minutes: 0, seconds: 0, milliseconds: 0 })
|
||||
expect(Duration.fromMilliseconds(90_061_500.75).decompose())
|
||||
.toEqual({ days: 1, hours: 1, minutes: 1, seconds: 1, milliseconds: 500.75 })
|
||||
})
|
||||
|
||||
it('answers frozen parts whose sum never exceeds the span', () => {
|
||||
const parts = Duration.fromMilliseconds(3_661_999).decompose()
|
||||
expect(Object.isFrozen(parts)).toBe(true)
|
||||
const summed = parts.days * 86_400_000 + parts.hours * 3_600_000
|
||||
+ parts.minutes * 60_000 + parts.seconds * 1_000 + parts.milliseconds
|
||||
expect(summed).toBe(3_661_999)
|
||||
expect(summed).toBeLessThanOrEqual(3_661_999)
|
||||
})
|
||||
|
||||
it('refuses a negative span instead of inventing a sign policy', () => {
|
||||
expect(() => Duration.fromMilliseconds(-1).decompose()).toThrow(RangeError)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user