@hoardodile/sdk-server 0.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +18 -0
- package/README.md +67 -0
- package/dist/helpers.d.ts +121 -0
- package/dist/helpers.js +183 -0
- package/dist/helpers.js.map +1 -0
- package/dist/index.d.ts +60 -0
- package/dist/index.js +148 -0
- package/dist/index.js.map +1 -0
- package/package.json +54 -0
- package/src/detectors.test.ts +115 -0
- package/src/detectors.ts +177 -0
- package/src/helpers.test.ts +250 -0
- package/src/helpers.ts +369 -0
- package/src/index.ts +70 -0
- package/src/result-reexports.test.ts +52 -0
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
FileType,
|
|
3
|
+
ImageHash,
|
|
4
|
+
ImageHashesResult,
|
|
5
|
+
ImageHashKind,
|
|
6
|
+
ResourceAPI,
|
|
7
|
+
} from "@hoardodile/sdk-types"
|
|
8
|
+
import {
|
|
9
|
+
PLUGIN_IMAGE_PROBE_CONCURRENCY,
|
|
10
|
+
PLUGIN_VIDEO_PROBE_CONCURRENCY,
|
|
11
|
+
} from "@hoardodile/sdk-types/plugin"
|
|
12
|
+
import {
|
|
13
|
+
RESOURCE_PREVIEW_MAX_AREA,
|
|
14
|
+
RESOURCE_PREVIEW_SIZE_THRESHOLD,
|
|
15
|
+
} from "@hoardodile/sdk-types/resource"
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* True when an image exceeds the preview thresholds — the pixel-area
|
|
19
|
+
* cap **or** the byte-size threshold — and should be served through the
|
|
20
|
+
* preview pipeline instead of the original. Format-driven transcode
|
|
21
|
+
* needs (formats browsers cannot render natively) are decided by the
|
|
22
|
+
* consuming plugin separately.
|
|
23
|
+
*/
|
|
24
|
+
function exceedsPreviewThresholds(check: {
|
|
25
|
+
readonly width: number | undefined
|
|
26
|
+
readonly height: number | undefined
|
|
27
|
+
readonly sizeBytes: number | undefined
|
|
28
|
+
}): boolean {
|
|
29
|
+
const { width, height, sizeBytes } = check
|
|
30
|
+
const exceedsArea =
|
|
31
|
+
width !== undefined &&
|
|
32
|
+
height !== undefined &&
|
|
33
|
+
width * height > RESOURCE_PREVIEW_MAX_AREA
|
|
34
|
+
const exceedsSize =
|
|
35
|
+
sizeBytes !== undefined && sizeBytes > RESOURCE_PREVIEW_SIZE_THRESHOLD
|
|
36
|
+
return exceedsArea || exceedsSize
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Return the lower-cased extension from the last dot, or `""` when there is none. */
|
|
40
|
+
export function extname(filename: string): string {
|
|
41
|
+
const dot = filename.lastIndexOf(".")
|
|
42
|
+
if (dot === -1) return ""
|
|
43
|
+
return filename.slice(dot).toLowerCase()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Natural-sort filenames (case-insensitive, numeric). Mutates and returns. */
|
|
47
|
+
export function naturalSort(files: readonly string[]): string[] {
|
|
48
|
+
return [...files].sort((a, b) =>
|
|
49
|
+
a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }),
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Map items with at most `limit` promises in flight. Results keep input
|
|
55
|
+
* order; the first rejection aborts the map (in-flight calls settle).
|
|
56
|
+
* Probe loops use this to fan out across the host's concurrent API
|
|
57
|
+
* dispatch instead of trickling one RPC at a time.
|
|
58
|
+
*/
|
|
59
|
+
export async function mapConcurrent<T, R>(
|
|
60
|
+
items: readonly T[],
|
|
61
|
+
limit: number,
|
|
62
|
+
fn: (item: T, index: number) => Promise<R>,
|
|
63
|
+
): Promise<R[]> {
|
|
64
|
+
const results: R[] = new Array(items.length)
|
|
65
|
+
let next = 0
|
|
66
|
+
async function lane(): Promise<void> {
|
|
67
|
+
for (;;) {
|
|
68
|
+
const index = next++
|
|
69
|
+
if (index >= items.length) return
|
|
70
|
+
const item = items[index]
|
|
71
|
+
if (item === undefined) continue
|
|
72
|
+
results[index] = await fn(item, index)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const lanes = Math.max(1, Math.min(limit, items.length))
|
|
76
|
+
const runners: Promise<void>[] = []
|
|
77
|
+
for (let i = 0; i < lanes; i++) runners.push(lane())
|
|
78
|
+
await Promise.all(runners)
|
|
79
|
+
return results
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** File-list item shapes produced by the probe helpers. */
|
|
83
|
+
type ProbedImageFile = {
|
|
84
|
+
readonly type: "image"
|
|
85
|
+
readonly width?: number
|
|
86
|
+
readonly height?: number
|
|
87
|
+
readonly preview: boolean
|
|
88
|
+
}
|
|
89
|
+
type ProbedVideoFile = {
|
|
90
|
+
readonly type: "video"
|
|
91
|
+
readonly width?: number
|
|
92
|
+
readonly height?: number
|
|
93
|
+
readonly durationMs?: number
|
|
94
|
+
}
|
|
95
|
+
type ProbedAudioFile = {
|
|
96
|
+
readonly type: "audio"
|
|
97
|
+
readonly durationMs?: number
|
|
98
|
+
readonly hasCoverArt?: boolean
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Probe a file and return the file-item shaped object matching what the
|
|
103
|
+
* content actually is, or `undefined` for non-media files. This is the
|
|
104
|
+
* primitive: plugins that accept mixed media route on the result
|
|
105
|
+
* instead of pre-sorting by extension, and the three per-kind helpers
|
|
106
|
+
* below are thin narrowings of it.
|
|
107
|
+
*
|
|
108
|
+
* When identification succeeds but decoding does not (no ffprobe on the
|
|
109
|
+
* host, a damaged container), the file keeps its media type and simply
|
|
110
|
+
* carries no dimensions — losing the entry entirely would be worse than
|
|
111
|
+
* showing it undecorated.
|
|
112
|
+
*/
|
|
113
|
+
export async function probeMediaFile(
|
|
114
|
+
api: ResourceAPI,
|
|
115
|
+
filename: string,
|
|
116
|
+
): Promise<ProbedImageFile | ProbedVideoFile | ProbedAudioFile | undefined> {
|
|
117
|
+
const probed = await api.probe(filename)
|
|
118
|
+
switch (probed.kind) {
|
|
119
|
+
case "image": {
|
|
120
|
+
const { width, height } = probed
|
|
121
|
+
const sizeBytes = (await api.statFile(filename))?.sizeBytes
|
|
122
|
+
return {
|
|
123
|
+
type: "image",
|
|
124
|
+
width,
|
|
125
|
+
height,
|
|
126
|
+
preview: exceedsPreviewThresholds({ width, height, sizeBytes }),
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
case "video":
|
|
130
|
+
return {
|
|
131
|
+
type: "video",
|
|
132
|
+
width: probed.width,
|
|
133
|
+
height: probed.height,
|
|
134
|
+
durationMs: probed.durationMs,
|
|
135
|
+
}
|
|
136
|
+
case "audio":
|
|
137
|
+
return {
|
|
138
|
+
type: "audio",
|
|
139
|
+
durationMs: probed.durationMs,
|
|
140
|
+
hasCoverArt: probed.coverArt === undefined ? undefined : true,
|
|
141
|
+
}
|
|
142
|
+
case "other":
|
|
143
|
+
return undefined
|
|
144
|
+
default:
|
|
145
|
+
return undecodedItem(await api.sniff(filename))
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Bare file item for media the host identified but could not decode. */
|
|
150
|
+
function undecodedItem(
|
|
151
|
+
type: FileType | undefined,
|
|
152
|
+
): ProbedImageFile | ProbedVideoFile | ProbedAudioFile | undefined {
|
|
153
|
+
switch (type?.kind) {
|
|
154
|
+
case "image":
|
|
155
|
+
return { type: "image", preview: false }
|
|
156
|
+
case "video":
|
|
157
|
+
return { type: "video" }
|
|
158
|
+
case "audio":
|
|
159
|
+
return { type: "audio" }
|
|
160
|
+
default:
|
|
161
|
+
return undefined
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Probe a file expected to be an image. A file that turns out not to be
|
|
167
|
+
* one still yields an image-shaped item (with no dimensions), so a file
|
|
168
|
+
* list keeps its declared type.
|
|
169
|
+
*/
|
|
170
|
+
export async function probeImageFile(
|
|
171
|
+
api: ResourceAPI,
|
|
172
|
+
filename: string,
|
|
173
|
+
): Promise<ProbedImageFile> {
|
|
174
|
+
const item = await probeMediaFile(api, filename)
|
|
175
|
+
return item?.type === "image" ? item : { type: "image", preview: false }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Probe a file expected to be a video. See {@link probeImageFile}. */
|
|
179
|
+
export async function probeVideoFile(
|
|
180
|
+
api: ResourceAPI,
|
|
181
|
+
filename: string,
|
|
182
|
+
): Promise<ProbedVideoFile> {
|
|
183
|
+
const item = await probeMediaFile(api, filename)
|
|
184
|
+
return item?.type === "video" ? item : { type: "video" }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Probe a file expected to be audio. See {@link probeImageFile}. */
|
|
188
|
+
export async function probeAudioFile(
|
|
189
|
+
api: ResourceAPI,
|
|
190
|
+
filename: string,
|
|
191
|
+
): Promise<ProbedAudioFile> {
|
|
192
|
+
const item = await probeMediaFile(api, filename)
|
|
193
|
+
return item?.type === "audio" ? item : { type: "audio" }
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* One entry of {@link mediaFileList}: the filename plus everything the
|
|
198
|
+
* probe pass learned about the file (`sniffed` is the sniffed type that
|
|
199
|
+
* decided the probe lane — the same data the entry's dimensions were
|
|
200
|
+
* routed on).
|
|
201
|
+
*/
|
|
202
|
+
export type MediaFileListEntry = {
|
|
203
|
+
readonly filename: string
|
|
204
|
+
readonly sniffed?: FileType
|
|
205
|
+
} & (ProbedImageFile | ProbedVideoFile | ProbedAudioFile)
|
|
206
|
+
|
|
207
|
+
export type MediaFileListOptions = {
|
|
208
|
+
/**
|
|
209
|
+
* Candidate names to probe. When absent the helper lists the
|
|
210
|
+
* resource itself (natural-sorted). Pass a pre-filtered list (e.g.
|
|
211
|
+
* top-level files only) to skip names the plugin never wants.
|
|
212
|
+
*/
|
|
213
|
+
readonly names?: readonly string[]
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Build a typed media file list in one pass: sniff every candidate,
|
|
218
|
+
* then probe images and timed media (video/audio) in separate bounded
|
|
219
|
+
* lanes — sharp header reads fan out wider than ffprobe spawns. The
|
|
220
|
+
* result keeps input order and drops files that are not decodable
|
|
221
|
+
* media. The one-call implementation for a `listFiles` hook over a
|
|
222
|
+
* flat media resource.
|
|
223
|
+
*/
|
|
224
|
+
export async function mediaFileList(
|
|
225
|
+
api: ResourceAPI,
|
|
226
|
+
opts: MediaFileListOptions = {},
|
|
227
|
+
): Promise<readonly MediaFileListEntry[]> {
|
|
228
|
+
// `listFileNames` already returns the host's canonical order (the
|
|
229
|
+
// `.order` upload order, natural name sort otherwise) — re-sorting
|
|
230
|
+
// here would scramble it. Plugins wanting a different order pass
|
|
231
|
+
// explicit `names`.
|
|
232
|
+
const files = opts.names ?? (await api.listFileNames())
|
|
233
|
+
const types = await mapConcurrent(
|
|
234
|
+
files,
|
|
235
|
+
PLUGIN_IMAGE_PROBE_CONCURRENCY,
|
|
236
|
+
(name) => api.sniff(name),
|
|
237
|
+
)
|
|
238
|
+
const entries = new Map<string, MediaFileListEntry>()
|
|
239
|
+
const imageIndexes: number[] = []
|
|
240
|
+
const timedIndexes: number[] = []
|
|
241
|
+
for (const index of files.keys()) {
|
|
242
|
+
const kind = types[index]?.kind
|
|
243
|
+
if (kind === "image") imageIndexes.push(index)
|
|
244
|
+
else if (kind === "video" || kind === "audio") timedIndexes.push(index)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function probeInto(index: number): Promise<void> {
|
|
248
|
+
const filename = files[index]
|
|
249
|
+
if (filename === undefined) return
|
|
250
|
+
const probed = await probeMediaFile(api, filename)
|
|
251
|
+
if (probed === undefined) return
|
|
252
|
+
entries.set(filename, {
|
|
253
|
+
filename,
|
|
254
|
+
sniffed: types[index],
|
|
255
|
+
...probed,
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
await Promise.all([
|
|
260
|
+
mapConcurrent(imageIndexes, PLUGIN_IMAGE_PROBE_CONCURRENCY, probeInto),
|
|
261
|
+
mapConcurrent(timedIndexes, PLUGIN_VIDEO_PROBE_CONCURRENCY, probeInto),
|
|
262
|
+
])
|
|
263
|
+
|
|
264
|
+
const result: MediaFileListEntry[] = []
|
|
265
|
+
for (const filename of files) {
|
|
266
|
+
const entry = entries.get(filename)
|
|
267
|
+
if (entry !== undefined) result.push(entry)
|
|
268
|
+
}
|
|
269
|
+
return result
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The `sourceMeta` for a bare file count — the one-liner for plugins
|
|
274
|
+
* whose card needs only `fileCount`. Resolves to `undefined` for empty
|
|
275
|
+
* resources, matching the host's "nothing to report" contract.
|
|
276
|
+
*/
|
|
277
|
+
export async function countSourceMeta(
|
|
278
|
+
api: ResourceAPI,
|
|
279
|
+
): Promise<{ readonly fileCount: number } | undefined> {
|
|
280
|
+
const files = await api.listFileNames()
|
|
281
|
+
return files.length === 0 ? undefined : { fileCount: files.length }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export type ReadFileChunksOptions = {
|
|
285
|
+
/** Chunk size in bytes. Defaults to 1 MiB. */
|
|
286
|
+
readonly chunkSize?: number
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Stream a file as a sequence of chunks via ranged `readFile` calls.
|
|
291
|
+
* Memory stays bounded by the chunk size on both sides of the plugin
|
|
292
|
+
* boundary — the host never buffers the whole file.
|
|
293
|
+
*/
|
|
294
|
+
export async function* readFileChunks(
|
|
295
|
+
api: ResourceAPI,
|
|
296
|
+
path: string,
|
|
297
|
+
opts: ReadFileChunksOptions = {},
|
|
298
|
+
): AsyncGenerator<Uint8Array, void, undefined> {
|
|
299
|
+
const chunkSize = opts.chunkSize ?? 1024 * 1024
|
|
300
|
+
let offset = 0
|
|
301
|
+
for (;;) {
|
|
302
|
+
const chunk = await api.readFile(path, {
|
|
303
|
+
start: offset,
|
|
304
|
+
end: offset + chunkSize,
|
|
305
|
+
})
|
|
306
|
+
if (chunk.byteLength === 0) return
|
|
307
|
+
yield chunk
|
|
308
|
+
if (chunk.byteLength < chunkSize) return
|
|
309
|
+
offset += chunk.byteLength
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Per-file default hash kinds for {@link imageHashesFor}. */
|
|
314
|
+
export const DEFAULT_IMAGE_HASH_KINDS: readonly ImageHashKind[] = [
|
|
315
|
+
"sha256",
|
|
316
|
+
"dhash",
|
|
317
|
+
]
|
|
318
|
+
|
|
319
|
+
export type ImageHashesForOptions = {
|
|
320
|
+
/** Hash kinds per image. Defaults to sha256 + dhash. */
|
|
321
|
+
readonly kinds?: readonly ImageHashKind[]
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Compute the requested hash kinds of one image file as `ImageHash`
|
|
326
|
+
* entries (`scope` = the file path). Resolves to `[]` for non-image or
|
|
327
|
+
* undecodable files.
|
|
328
|
+
*/
|
|
329
|
+
export async function imageHashesForFile(
|
|
330
|
+
api: ResourceAPI,
|
|
331
|
+
scope: string,
|
|
332
|
+
kinds: readonly ImageHashKind[] = DEFAULT_IMAGE_HASH_KINDS,
|
|
333
|
+
): Promise<readonly ImageHash[]> {
|
|
334
|
+
const computed = await api.computeImageHashes(scope, kinds)
|
|
335
|
+
if (computed === undefined) return []
|
|
336
|
+
const entries: ImageHash[] = []
|
|
337
|
+
for (const kind of kinds) {
|
|
338
|
+
const value = computed[kind]
|
|
339
|
+
if (value !== undefined) entries.push({ scope, type: kind, value })
|
|
340
|
+
}
|
|
341
|
+
return entries
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* One-line `imageHashes` hook implementation for image plugins: hash
|
|
346
|
+
* every image file of the resource (animated sources hash their first
|
|
347
|
+
* frame). Image files are selected by content, so a mislabelled photo
|
|
348
|
+
* is still deduplicated. Plugins facing image-less resources omit the
|
|
349
|
+
* hook entirely.
|
|
350
|
+
*/
|
|
351
|
+
export async function imageHashesFor(
|
|
352
|
+
api: ResourceAPI,
|
|
353
|
+
opts: ImageHashesForOptions = {},
|
|
354
|
+
): Promise<ImageHashesResult> {
|
|
355
|
+
const kinds = opts.kinds ?? DEFAULT_IMAGE_HASH_KINDS
|
|
356
|
+
const names = await api.listFileNames()
|
|
357
|
+
const types = await mapConcurrent(
|
|
358
|
+
names,
|
|
359
|
+
PLUGIN_IMAGE_PROBE_CONCURRENCY,
|
|
360
|
+
(name) => api.sniff(name),
|
|
361
|
+
)
|
|
362
|
+
const images = names.filter((_, i) => types[i]?.kind === "image")
|
|
363
|
+
const hashes = (
|
|
364
|
+
await mapConcurrent(images, PLUGIN_IMAGE_PROBE_CONCURRENCY, (filename) =>
|
|
365
|
+
imageHashesForFile(api, filename, kinds),
|
|
366
|
+
)
|
|
367
|
+
).flat()
|
|
368
|
+
return { hashes }
|
|
369
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @hoardodile/sdk-server — the authoring surface for plugin `main.js`
|
|
3
|
+
* files, and the only package plugin authors need on the server side.
|
|
4
|
+
* The plugin contract (`definePlugin`, `ResourceAPI`, fixtures, hook
|
|
5
|
+
* names) lives in @hoardodile/sdk-types and is re-exported here so
|
|
6
|
+
* authors have a single import root.
|
|
7
|
+
*
|
|
8
|
+
* This package is fully MIT and dependency-closed within the SDK — it
|
|
9
|
+
* never imports `@hoardodile/host` (the app-side runtime). Dev-time
|
|
10
|
+
* test tooling (`runPluginHook`, `createDirectoryResourceAPI`) lives in
|
|
11
|
+
* `@hoardodile/host` and is consumed from plugin tests as a
|
|
12
|
+
* devDependency.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type {
|
|
16
|
+
ArchiveExtraction,
|
|
17
|
+
ArchiveExtractionEntry,
|
|
18
|
+
AudioCoverArt,
|
|
19
|
+
AudioInfo,
|
|
20
|
+
AudioTags,
|
|
21
|
+
ContainerListing,
|
|
22
|
+
Detection,
|
|
23
|
+
FileType,
|
|
24
|
+
ImageHash,
|
|
25
|
+
ImageHashesResult,
|
|
26
|
+
ImageHashKind,
|
|
27
|
+
ImageInfo,
|
|
28
|
+
Logger,
|
|
29
|
+
MediaKind,
|
|
30
|
+
PluginAssetDeleteResult,
|
|
31
|
+
PluginAssetError,
|
|
32
|
+
PluginAssetErrorName,
|
|
33
|
+
PluginDefinition,
|
|
34
|
+
PluginDownloadRequest,
|
|
35
|
+
PluginDownloadResult,
|
|
36
|
+
ProbeResult,
|
|
37
|
+
ReadFileRange,
|
|
38
|
+
ResourceAPI,
|
|
39
|
+
ResourceAPIFixtureConfig,
|
|
40
|
+
VideoInfo,
|
|
41
|
+
} from "@hoardodile/sdk-types"
|
|
42
|
+
export {
|
|
43
|
+
assertPluginShape,
|
|
44
|
+
createFailingPlugin,
|
|
45
|
+
createResourceAPIFixture,
|
|
46
|
+
definePlugin,
|
|
47
|
+
err,
|
|
48
|
+
fileTypeFromName,
|
|
49
|
+
isDetected,
|
|
50
|
+
isErr,
|
|
51
|
+
isMissed,
|
|
52
|
+
isOk,
|
|
53
|
+
isPluginAssetError,
|
|
54
|
+
matchResult,
|
|
55
|
+
ok,
|
|
56
|
+
pluginAssetError,
|
|
57
|
+
stubLogger,
|
|
58
|
+
} from "@hoardodile/sdk-types"
|
|
59
|
+
export type { Detector } from "./detectors.ts"
|
|
60
|
+
export {
|
|
61
|
+
all,
|
|
62
|
+
any,
|
|
63
|
+
files,
|
|
64
|
+
hasExt,
|
|
65
|
+
hasKind,
|
|
66
|
+
hasMime,
|
|
67
|
+
hasName,
|
|
68
|
+
minFiles,
|
|
69
|
+
not,
|
|
70
|
+
} from "./detectors.ts"
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { Result } from "@hoardodile/sdk-types"
|
|
2
|
+
import { describe, expect, test } from "vitest"
|
|
3
|
+
import {
|
|
4
|
+
err,
|
|
5
|
+
isDetected,
|
|
6
|
+
isErr,
|
|
7
|
+
isMissed,
|
|
8
|
+
isOk,
|
|
9
|
+
matchResult,
|
|
10
|
+
ok,
|
|
11
|
+
} from "./index.ts"
|
|
12
|
+
|
|
13
|
+
describe("result helper re-exports", () => {
|
|
14
|
+
test("ok/err build the same literal shapes as the contract", () => {
|
|
15
|
+
expect(ok()).toEqual({ ok: true })
|
|
16
|
+
expect(ok({ files: [] })).toEqual({ ok: true, files: [] })
|
|
17
|
+
expect(err({ reasons: ["required-file"] })).toEqual({
|
|
18
|
+
ok: false,
|
|
19
|
+
reasons: ["required-file"],
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test("guards narrow both branches", () => {
|
|
24
|
+
const hit = ok({ page: 1 })
|
|
25
|
+
const miss = err({ reasons: ["page-image"] })
|
|
26
|
+
expect(isOk(hit)).toBe(true)
|
|
27
|
+
expect(isOk(miss)).toBe(false)
|
|
28
|
+
expect(isErr(miss)).toBe(true)
|
|
29
|
+
expect(isErr(hit)).toBe(false)
|
|
30
|
+
expect(isDetected(hit)).toBe(true)
|
|
31
|
+
expect(isMissed(miss)).toBe(true)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("matchResult dispatches to the branch handler", () => {
|
|
35
|
+
type Hit = { readonly count: number }
|
|
36
|
+
type Miss = { readonly reasons: readonly string[] }
|
|
37
|
+
const hit: Result<Hit, Miss> = ok({ count: 2 })
|
|
38
|
+
const miss: Result<Hit, Miss> = err({ reasons: ["required-file"] })
|
|
39
|
+
expect(
|
|
40
|
+
matchResult(hit, {
|
|
41
|
+
ok: (r) => r.count,
|
|
42
|
+
err: () => 0,
|
|
43
|
+
}),
|
|
44
|
+
).toBe(2)
|
|
45
|
+
expect(
|
|
46
|
+
matchResult(miss, {
|
|
47
|
+
ok: () => 0,
|
|
48
|
+
err: (r) => r.reasons.length,
|
|
49
|
+
}),
|
|
50
|
+
).toBe(1)
|
|
51
|
+
})
|
|
52
|
+
})
|