1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
//! Input-encoding handling: the same instant can be presented in different byte
//! encodings, and a packed format's ON-DISK byte order differs from a packed
//! integer. The hex path must decode packed formats (FAT) from their on-disk
//! layout so an analyst with raw bytes gets the right instant, not a silently
//! wrong one (see docs/concepts/input-conventions.md).
#![allow(clippy::unwrap_used, clippy::expect_used)]
use timeglyph::{format, interpret, Encoded, PosixNs};
/// The instant 2020-01-01T00:00:00Z, via the unix decoder (unix seconds is
/// itself anchored to the time-decode oracle in tests/oracle.rs).
fn instant_2020() -> PosixNs {
format("unix").unwrap().decode_int(1_577_836_800).unwrap()
}
#[test]
fn encode_float_matches_time_decode_vectors() {
// Tier-1: ground-truth values are `time-decode --timestamp "2020-01-01
// 00:00:00"` output (third-party authored), NOT a round-trip of our own
// encoder. See docs/validation.md.
let inst = instant_2020();
for (id, expected) in [
("ole", 43831.0_f64), // Windows OLE Automation Date
("sqlite_julian", 2_458_849.5), // Julian Date decimal
("excel1904", 42369.0), // Microsoft Excel 1904 Date
("cocoa_float", 599_529_600.0), // Apple NSDate - Mac Absolute
] {
let got = format(id).unwrap().encode_float(inst).unwrap();
assert!(
(got - expected).abs() < 1e-6,
"{id}: encoded {got}, oracle says {expected}"
);
}
}
#[test]
fn encode_dispatches_float_vs_int() {
let inst = instant_2020();
assert_eq!(
format("unix").unwrap().encode(inst).unwrap(),
Encoded::Int(1_577_836_800)
);
assert_eq!(
format("ole").unwrap().encode(inst).unwrap(),
Encoded::Float(43831.0)
);
// Display: int prints bare, float prints its decimal value.
assert_eq!(
format("unix").unwrap().encode(inst).unwrap().to_string(),
"1577836800"
);
assert_eq!(
format("ole").unwrap().encode(inst).unwrap().to_string(),
"43831"
);
}
#[test]
fn encode_float_rejects_non_float_formats() {
assert!(format("unix")
.unwrap()
.encode_float(instant_2020())
.is_err());
}
#[test]
fn encode_embedded_timestamp_bits_match_time_decode() {
// Tier-1: values are `time-decode --timestamp "2020-01-01 00:00:00"` output.
// Embedded IDs carry the timestamp in the high bits and worker/sequence in
// the low `shift` bits; time-decode fills some low bits with sample/invented
// data (e.g. LinkedIn's value is odd), so we compare the TIMESTAMP bits
// (value >> shift) — what an encoder is actually responsible for — between
// timeglyph's encoder and the third-party encoder. Not a self round-trip.
let inst = instant_2020();
// (id, shift_bits — the spec constant carried in registry.rs, oracle value)
let vectors: &[(&str, u32, i64)] = &[
("snowflake", 22, 1_212_161_512_043_446_272), // Twitter time
("discord", 22, 661_720_242_585_600_000), // Discord time
("mastodon", 16, 103_405_112_524_800_000), // Mastodon time
("linkedin", 22, 6_617_927_201_590_237_494), // LinkedIn Activity time
("tiktok", 32, 6_776_757_454_425_292_800), // TikTok time
];
for &(id, shift, oracle) in vectors {
let tg = format(id).unwrap().encode_int(inst).unwrap();
assert_eq!(
tg >> shift,
oracle >> shift,
"{id}: timeglyph timestamp bits {} vs oracle {}",
tg >> shift,
oracle >> shift
);
}
}
#[test]
fn encode_embedded_far_future_overflows_rather_than_wraps() {
// A snowflake ID is 63-bit with a 22-bit shift, so the ms timestamp caps
// near year 2080; a far-future instant must error, not silently wrap.
let far = format("unix").unwrap().decode_int(7_258_118_400).unwrap(); // ~year 2200
assert!(format("snowflake").unwrap().encode_int(far).is_err());
}
/// Parse time-decode's on-disk FAT/exFAT/MS-DOS string (a big-endian hex literal
/// standing in for the four on-disk little-endian bytes) into timeglyph's packed
/// integer `date << 16 | time`. Shared by the packed-encode oracle tests.
fn fat_ondisk_to_packed(be_hex: u32) -> i64 {
let b = be_hex.to_be_bytes();
let date = u16::from_le_bytes([b[0], b[1]]);
let time = u16::from_le_bytes([b[2], b[3]]);
(i64::from(date) << 16) | i64::from(time)
}
#[test]
fn encode_fat_matches_time_decode_vector() {
// Tier-1: `time-decode --timestamp "2020-01-01 00:00:00"` prints
// "FAT Date + Time: 21500000" — the on-disk little-endian wFatDate/wFatTime
// bytes. timeglyph's fat encoder must produce the packed integer those bytes
// represent (date << 16 | time). Value sourced from the third-party oracle.
let inst = instant_2020();
let want = fat_ondisk_to_packed(0x2150_0000);
assert_eq!(format("fat").unwrap().encode_int(inst).unwrap(), want);
}
#[test]
fn encode_fat_rejects_out_of_range() {
// Pre-1980 is below the FAT 7-bit year field (year - 1980 would be negative).
let y1970 = format("unix").unwrap().decode_int(0).unwrap();
assert!(format("fat").unwrap().encode_int(y1970).is_err());
// An instant outside jiff's civil range yields no civil fields at all.
assert!(format("fat")
.unwrap()
.encode_int(PosixNs(i128::MAX))
.is_err());
}
#[test]
fn packed_encoders_place_fields_correctly_at_a_nontrivial_instant() {
// Regression guard for the midnight blind spot: at 00:00:00 a mis-shifted
// hour/minute/second field is invisible (it caught nothing when encode_fat_dos
// shifted minute by 6 instead of 5). Encode a non-trivial instant and
// round-trip through the decoder — which is INDEPENDENTLY oracle-validated at
// non-trivial times (e.g. FAT `a45a597a` → 2025-05-04 15:18:50), so a
// field-placement bug surfaces as a wrong minute/hour, not a compensating pair.
// Supplements the per-format tier-1 time-decode tests; seconds vary by
// granularity so this asserts to the minute.
let inst = format("unix").unwrap().decode_int(1_592_228_842).unwrap(); // 2020-06-15T13:47:22Z
for id in [
"fat",
"exfat",
"dttm",
"bitdate",
"bitdec",
"bcd",
"logtime",
"semioctet",
"gsm",
"nokiale",
"sqlserver",
"moto",
"symantec",
"dvr",
"ns40",
"ns40le",
] {
let f = format(id).unwrap();
let v = f.encode_int(inst).unwrap();
let back = f.decode_int(v).unwrap().to_rfc3339().unwrap();
assert!(
back.starts_with("2020-06-15T13:47"),
"{id}: encode→decode gave {back}, expected 2020-06-15T13:47"
);
}
}
#[test]
fn encode_g2_packed_rejects_out_of_range_year() {
// A deeply negative instant (year ≈ -252) is below every G2 format's year
// field, exercising each encoder's range guard (no panic, Err).
let ancient = format("unix").unwrap().decode_int(-70_000_000_000).unwrap();
for id in ["moto", "symantec", "dvr", "ns40", "ns40le"] {
assert!(
format(id).unwrap().encode_int(ancient).is_err(),
"{id}: expected out-of-range year to error"
);
}
}
#[test]
fn fat_on_disk_hex_decodes_to_fat() {
// The FAT/DOS on-disk layout stores a date word then a time word, each
// little-endian. time-decode's example `a45a597a` => 2025-05-04 15:18:50.
let groups = interpret::interpret_hex("a45a597a").unwrap();
assert!(
groups
.iter()
.any(|(label, cands)| label.to_lowercase().contains("fat")
&& cands.iter().any(|c| c.format_id == "fat"
&& c.rendered
.as_deref()
.unwrap_or("")
.starts_with("2025-05-04T15:18:50"))),
"expected a FAT on-disk candidate from a45a597a: {groups:?}"
);
}
#[test]
fn fat_hex_offers_both_word_orders() {
// The same 4 bytes are ambiguous: the DOS packed convention is date-word then
// time-word, but a FAT DIRECTORY entry stores time-word then date-word. Feeding
// raw directory bytes under the wrong order silently swaps date and time, so
// BOTH orders must be surfaced and clearly labelled (let the analyst choose).
let groups = interpret::interpret_hex("a45a597a").unwrap();
let date_time = groups
.iter()
.any(|(l, c)| l.contains("date|time") && c.iter().any(|x| x.format_id == "fat"));
let time_date = groups
.iter()
.any(|(l, c)| l.contains("time|date") && c.iter().any(|x| x.format_id == "fat"));
assert!(date_time, "missing date|time order: {groups:?}");
assert!(time_date, "missing time|date (directory) order: {groups:?}");
}
#[test]
fn hex_notes_trailing_bytes() {
// 6 bytes: the width decoders use the first 4/8; trailing bytes must be
// surfaced, not silently dropped.
let groups = interpret::interpret_hex("a45a597affff").unwrap();
assert!(
groups.iter().any(|(label, _)| label.contains("of 6")),
"expected a 'first N of 6' note: {groups:?}"
);
}
#[test]
fn hex_all_ones_u64_is_flagged_sentinel() {
// 0xFFFFFFFFFFFFFFFF exceeds i64 so no linear candidate is produced; it must
// still surface as an all-ones sentinel rather than vanish silently.
let groups = interpret::interpret_hex("ffffffffffffffff").unwrap();
assert!(
groups
.iter()
.any(|(_, cands)| cands.iter().any(|c| c.sentinel)),
"expected an all-ones sentinel candidate: {groups:?}"
);
}