@shipload/sdk 1.0.0-next.70 → 1.0.0-next.72

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.
Files changed (42) hide show
  1. package/lib/shipload.d.ts +266 -101
  2. package/lib/shipload.js +898 -404
  3. package/lib/shipload.js.map +1 -1
  4. package/lib/shipload.m.js +880 -398
  5. package/lib/shipload.m.js.map +1 -1
  6. package/lib/testing.d.ts +51 -13
  7. package/lib/testing.js +209 -64
  8. package/lib/testing.js.map +1 -1
  9. package/lib/testing.m.js +209 -64
  10. package/lib/testing.m.js.map +1 -1
  11. package/package.json +1 -1
  12. package/src/contracts/server.ts +140 -34
  13. package/src/data/capability-formulas.ts +4 -4
  14. package/src/data/entities.json +84 -68
  15. package/src/data/item-ids.ts +5 -4
  16. package/src/data/items.json +30 -24
  17. package/src/data/kind-registry.json +75 -39
  18. package/src/data/kind-registry.ts +13 -0
  19. package/src/data/metadata.ts +35 -0
  20. package/src/data/recipes.json +194 -155
  21. package/src/derivation/build-methods.test.ts +4 -4
  22. package/src/derivation/capabilities.test.ts +14 -10
  23. package/src/derivation/capabilities.ts +8 -10
  24. package/src/derivation/capability-mappings.ts +4 -4
  25. package/src/derivation/capacity-fn-registry.test.ts +9 -0
  26. package/src/derivation/capacity-fn-registry.ts +18 -0
  27. package/src/derivation/recipe-usage.test.ts +7 -11
  28. package/src/index-module.ts +28 -8
  29. package/src/influence/influence.test.ts +1 -1
  30. package/src/managers/actions.ts +19 -4
  31. package/src/managers/context.ts +9 -0
  32. package/src/managers/fund.ts +49 -0
  33. package/src/managers/index.ts +3 -0
  34. package/src/managers/jobs.test.ts +25 -9
  35. package/src/managers/jobs.ts +8 -4
  36. package/src/nft/description.ts +5 -10
  37. package/src/receipts.ts +9 -9
  38. package/src/resolution/resolve-item.test.ts +15 -10
  39. package/src/scheduling/jobs.test.ts +417 -18
  40. package/src/scheduling/jobs.ts +222 -16
  41. package/src/scheduling/lanes.ts +53 -1
  42. package/src/shipload.ts +5 -0
@@ -1,12 +1,64 @@
1
1
  import {describe, expect, it} from 'bun:test'
2
- import {jobStatus, jobStatusLabel, splitJobCargo} from './jobs'
2
+ import type {OrderedTask} from './schedule'
3
+ import {
4
+ buildJobCancelRoute,
5
+ jobCancelRoute,
6
+ jobCancellationBlockReason,
7
+ jobCancellable,
8
+ jobDropoffTask,
9
+ jobStatus,
10
+ jobStatusLabel,
11
+ pickupsInFlight,
12
+ splitJobCargo,
13
+ workshopAvailability,
14
+ type JobWindow,
15
+ } from './jobs'
3
16
 
4
17
  const at = (s: string) => new Date(s)
5
18
 
19
+ const INPUTS = [{item_id: 101, stats: '413333752', quantity: 10}] as never
20
+ const FULL_INPUT = {
21
+ item_id: 101,
22
+ stats: '413333752',
23
+ modules: [{type: 1, installed: {item_id: 301, stats: '9'}}],
24
+ quantity: 10,
25
+ entity_id: '77',
26
+ }
27
+ const FULL_INPUTS = [FULL_INPUT] as never
28
+
29
+ function task(over: {
30
+ type: number
31
+ building: number
32
+ cargo?: unknown
33
+ startsAt: Date
34
+ completesAt: Date
35
+ }): OrderedTask {
36
+ return {
37
+ laneKey: 1,
38
+ taskIndex: 0,
39
+ startsAt: over.startsAt,
40
+ completesAt: over.completesAt,
41
+ task: {
42
+ type: over.type,
43
+ subject: {entity_id: over.building},
44
+ cargo: over.cargo ?? INPUTS,
45
+ },
46
+ } as unknown as OrderedTask
47
+ }
48
+
6
49
  describe('jobStatus', () => {
7
- const job = {startsAt: at('2026-07-26T10:00:00Z'), completesAt: at('2026-07-26T11:00:00Z')}
8
- it('is waiting before startsAt', () => {
9
- expect(jobStatus(job, at('2026-07-26T09:59:59Z'))).toBe('waiting')
50
+ const job = {
51
+ startsAt: at('2026-07-26T10:00:00Z'),
52
+ completesAt: at('2026-07-26T11:00:00Z'),
53
+ deposited: true,
54
+ quantity: 1,
55
+ building: 42,
56
+ inputs: INPUTS,
57
+ }
58
+ const inFlight = {...job, deposited: false}
59
+
60
+ it('is queued once deposited and before startsAt', () => {
61
+ expect(jobStatus(job, at('2026-07-26T09:59:59Z'))).toBe('queued')
10
62
  })
11
63
  it('is crafting between startsAt and completesAt', () => {
12
64
  expect(jobStatus(job, at('2026-07-26T10:30:00Z'))).toBe('crafting')
@@ -15,35 +67,382 @@ describe('jobStatus', () => {
15
67
  expect(jobStatus(job, at('2026-07-26T11:00:00Z'))).toBe('ready')
16
68
  expect(jobStatus(job, at('2026-07-26T12:00:00Z'))).toBe('ready')
17
69
  })
18
- it('is inline while the drop-off is still in the air', () => {
19
- const booked = {
20
- startsAt: at('1970-01-01T00:00:00Z'),
21
- completesAt: at('1970-01-01T00:00:00Z'),
70
+ it('is ready when the job was cancelled and holds its inputs', () => {
71
+ expect(jobStatus({...job, quantity: 0}, at('2026-07-26T09:00:00Z'))).toBe('ready')
72
+ })
73
+ it('is booked while the matching Drop-off task has not started', () => {
74
+ const tasks = [
75
+ task({
76
+ type: 21,
77
+ building: 42,
78
+ startsAt: at('2026-07-26T09:30:00Z'),
79
+ completesAt: at('2026-07-26T09:50:00Z'),
80
+ }),
81
+ ]
82
+ expect(jobStatus(inFlight, at('2026-07-26T09:00:00Z'), tasks)).toBe('booked')
83
+ })
84
+ it('is dropping off once the Drop-off task has started', () => {
85
+ const tasks = [
86
+ task({
87
+ type: 21,
88
+ building: 42,
89
+ startsAt: at('2026-07-26T09:30:00Z'),
90
+ completesAt: at('2026-07-26T09:50:00Z'),
91
+ }),
92
+ ]
93
+ expect(jobStatus(inFlight, at('2026-07-26T09:30:00Z'), tasks)).toBe('dropping')
94
+ })
95
+ it('matches the Drop-off by building and cargo, not by any deposit task', () => {
96
+ const tasks = [
97
+ task({
98
+ type: 21,
99
+ building: 42,
100
+ cargo: [{item_id: 999, stats: '1', quantity: 1}],
101
+ startsAt: at('2026-07-26T09:00:00Z'),
102
+ completesAt: at('2026-07-26T09:10:00Z'),
103
+ }),
104
+ task({
105
+ type: 21,
106
+ building: 42,
107
+ startsAt: at('2026-07-26T09:10:00Z'),
108
+ completesAt: at('2026-07-26T09:20:00Z'),
109
+ }),
110
+ ]
111
+ expect(jobStatus(inFlight, at('2026-07-26T09:05:00Z'), tasks)).toBe('booked')
112
+ })
113
+ it('finds the Drop-off task the card reads its landing time from', () => {
114
+ const dropoff = task({
115
+ type: 21,
116
+ building: 42,
117
+ startsAt: at('2026-07-26T09:30:00Z'),
118
+ completesAt: at('2026-07-26T09:50:00Z'),
119
+ })
120
+ const other = task({
121
+ type: 22,
122
+ building: 42,
123
+ startsAt: at('2026-07-26T09:00:00Z'),
124
+ completesAt: at('2026-07-26T09:10:00Z'),
125
+ })
126
+ expect(jobDropoffTask(inFlight, [other, dropoff])).toBe(dropoff)
127
+ expect(jobDropoffTask(inFlight, [other])).toBeUndefined()
128
+ expect(jobDropoffTask(inFlight, undefined)).toBeUndefined()
129
+ })
130
+ it('uses the authoritative arrival to select one of two identical Drop-offs', () => {
131
+ const first = task({
132
+ type: 21,
133
+ building: 42,
134
+ startsAt: at('2026-07-26T09:10:00Z'),
135
+ completesAt: at('2026-07-26T09:20:00Z'),
136
+ })
137
+ const second = {
138
+ ...task({
139
+ type: 21,
140
+ building: 42,
141
+ startsAt: at('2026-07-26T09:20:00Z'),
142
+ completesAt: at('2026-07-26T09:30:00Z'),
143
+ }),
144
+ taskIndex: 1,
22
145
  }
23
- expect(jobStatus({...booked, deposited: false}, at('2026-07-26T10:00:00Z'))).toBe('inline')
24
- expect(jobStatus({...job, deposited: true}, at('2026-07-26T10:30:00Z'))).toBe('crafting')
146
+ expect(jobDropoffTask({...inFlight, arrivesAt: second.completesAt}, [first, second])).toBe(
147
+ second
148
+ )
149
+ })
150
+ it('matches modules and entity identity and refuses an ambiguous match', () => {
151
+ const exact = task({
152
+ type: 21,
153
+ building: 42,
154
+ cargo: FULL_INPUTS,
155
+ startsAt: at('2026-07-26T09:10:00Z'),
156
+ completesAt: at('2026-07-26T09:20:00Z'),
157
+ })
158
+ const differentEntity = task({
159
+ type: 21,
160
+ building: 42,
161
+ cargo: [{...FULL_INPUT, entity_id: '78'}],
162
+ startsAt: exact.startsAt,
163
+ completesAt: exact.completesAt,
164
+ })
165
+ expect(jobDropoffTask({...inFlight, inputs: FULL_INPUTS}, [differentEntity, exact])).toBe(
166
+ exact
167
+ )
168
+ expect(
169
+ jobDropoffTask({...inFlight, inputs: FULL_INPUTS}, [exact, {...exact}])
170
+ ).toBeUndefined()
171
+ })
172
+ it('normalizes bare module layouts and absent entity identity like the contract', () => {
173
+ const bare = task({
174
+ type: 21,
175
+ building: 42,
176
+ cargo: [{item_id: 101, stats: '413333752', modules: [], quantity: 10}],
177
+ startsAt: at('2026-07-26T09:10:00Z'),
178
+ completesAt: at('2026-07-26T09:20:00Z'),
179
+ })
180
+ const padded = [
181
+ {
182
+ item_id: 101,
183
+ stats: '413333752',
184
+ modules: [{type: 6, installed: undefined}],
185
+ quantity: 10,
186
+ entity_id: 0,
187
+ },
188
+ ] as never
189
+ expect(jobDropoffTask({...inFlight, inputs: padded}, [bare])).toBe(bare)
25
190
  })
26
- it('labels every state', () => {
27
- expect(jobStatusLabel('inline')).toBe('In Line')
191
+ it('reads an undeposited row as dropping off when no schedule is given', () => {
192
+ expect(jobStatus(inFlight, at('2026-07-26T09:00:00Z'))).toBe('dropping')
193
+ })
194
+ it('labels every phase', () => {
195
+ expect(jobStatusLabel('booked')).toBe('Booked')
196
+ expect(jobStatusLabel('dropping')).toBe('Dropping off')
197
+ expect(jobStatusLabel('queued')).toBe('Queued')
198
+ expect(jobStatusLabel('crafting')).toBe('Crafting')
28
199
  expect(jobStatusLabel('ready')).toBe('Ready for Pickup')
200
+ expect(jobStatusLabel('pickingup')).toBe('Picking up')
201
+ })
202
+ })
203
+
204
+ describe('jobCancelRoute', () => {
205
+ const job = {
206
+ id: 7,
207
+ shipId: 5,
208
+ startsAt: at('2026-07-26T10:00:00Z'),
209
+ completesAt: at('2026-07-26T11:00:00Z'),
210
+ deposited: true,
211
+ quantity: 1,
212
+ building: 42,
213
+ inputs: INPUTS,
214
+ }
215
+ const inFlight = {...job, deposited: false}
216
+ const dropoff = task({
217
+ type: 21,
218
+ building: 42,
219
+ startsAt: at('2026-07-26T09:30:00Z'),
220
+ completesAt: at('2026-07-26T09:50:00Z'),
221
+ })
222
+
223
+ it('cancels a Booked job through the ship, from the Drop-off to the lane tail', () => {
224
+ const later = {
225
+ ...dropoff,
226
+ taskIndex: 1,
227
+ task: {...dropoff.task, type: 22},
228
+ } as unknown as OrderedTask
229
+ const route = jobCancelRoute(inFlight, at('2026-07-26T09:00:00Z'), [dropoff, later])
230
+ expect(route).toEqual({kind: 'dropoff', shipId: 5, laneKey: 1, count: 2})
231
+ })
232
+ it('cancels from the selected identical Drop-off and leaves the earlier booking intact', () => {
233
+ const first = {...dropoff, completesAt: at('2026-07-26T09:20:00Z')}
234
+ const second = {
235
+ ...dropoff,
236
+ taskIndex: 1,
237
+ startsAt: at('2026-07-26T09:20:00Z'),
238
+ completesAt: at('2026-07-26T09:30:00Z'),
239
+ }
240
+ expect(
241
+ jobCancelRoute(
242
+ {...inFlight, arrivesAt: second.completesAt},
243
+ at('2026-07-26T09:00:00Z'),
244
+ [first, second]
245
+ )
246
+ ).toEqual({kind: 'dropoff', shipId: 5, laneKey: 1, count: 1})
247
+ })
248
+ it('cancels a Dropping off job through the ship while the transfer is in flight', () => {
249
+ const route = jobCancelRoute(inFlight, at('2026-07-26T09:40:00Z'), [dropoff])
250
+ expect(route).toEqual({kind: 'dropoff', shipId: 5, laneKey: 1, count: 1})
251
+ })
252
+ it('counts only the Drop-off lane, not tasks on other lanes', () => {
253
+ const mobility = {
254
+ ...dropoff,
255
+ laneKey: 0,
256
+ taskIndex: 0,
257
+ task: {...dropoff.task, type: 1},
258
+ } as unknown as OrderedTask
259
+ const route = jobCancelRoute(inFlight, at('2026-07-26T09:00:00Z'), [mobility, dropoff])
260
+ expect(route).toEqual({kind: 'dropoff', shipId: 5, laneKey: 1, count: 1})
261
+ })
262
+ it('cancels a Queued job through cancelcraft', () => {
263
+ expect(jobCancelRoute(job, at('2026-07-26T09:59:59Z'))).toEqual({kind: 'craft', jobId: 7})
264
+ })
265
+ it('refuses only deposited positive-quantity output-only legacy jobs', () => {
266
+ const legacy = {...job, inputs: []}
267
+ expect(jobCancellationBlockReason(legacy)).toBe('legacy-inputs-unavailable')
268
+ expect(jobCancelRoute(legacy, at('2026-07-26T09:59:59Z'))).toBeNull()
269
+ expect(jobCancellationBlockReason({...legacy, deposited: false})).toBeNull()
270
+ expect(jobCancellationBlockReason({...legacy, quantity: 0})).toBeNull()
271
+ expect(jobCancellationBlockReason({...legacy, inputs: undefined})).toBeNull()
272
+ expect(jobCancellationBlockReason({...legacy, inputs: [FULL_INPUTS[0]]})).toBeNull()
273
+ })
274
+ it('refuses an existing exact-arrival tie rather than choosing either Drop-off', () => {
275
+ const tied = {...inFlight, arrivesAt: dropoff.completesAt}
276
+ expect(jobCancellationBlockReason(tied, [dropoff, {...dropoff}])).toBe('ambiguous-dropoff')
277
+ expect(jobCancelRoute(tied, at('2026-07-26T09:00:00Z'), [dropoff, {...dropoff}])).toBeNull()
278
+ })
279
+ it('has no route once crafting has started or the job is ready', () => {
280
+ expect(jobCancelRoute(job, at('2026-07-26T10:00:00Z'))).toBeNull()
281
+ expect(jobCancelRoute(job, at('2026-07-26T11:00:00Z'))).toBeNull()
282
+ expect(jobCancelRoute({...job, quantity: 0}, at('2026-07-26T09:00:00Z'))).toBeNull()
283
+ })
284
+ it('has no route for a landed Drop-off that has not resolved yet', () => {
285
+ expect(jobCancelRoute(inFlight, at('2026-07-26T09:50:00Z'), [dropoff])).toBeNull()
286
+ })
287
+ it('has no route for an undeposited job without the ship schedule', () => {
288
+ expect(jobCancelRoute(inFlight, at('2026-07-26T09:00:00Z'))).toBeNull()
289
+ expect(
290
+ jobCancelRoute({...inFlight, shipId: undefined}, at('2026-07-26T09:00:00Z'), [dropoff])
291
+ ).toBeNull()
292
+ })
293
+ it('names the three cancellable phases', () => {
294
+ expect(jobCancellable('booked')).toBe(true)
295
+ expect(jobCancellable('dropping')).toBe(true)
296
+ expect(jobCancellable('queued')).toBe(true)
297
+ expect(jobCancellable('crafting')).toBe(false)
298
+ expect(jobCancellable('ready')).toBe(false)
299
+ expect(jobCancellable('pickingup')).toBe(false)
300
+ })
301
+ })
302
+
303
+ describe('buildJobCancelRoute', () => {
304
+ const job = {
305
+ id: 3,
306
+ targetId: 9,
307
+ startsAt: at('2026-07-26T10:00:00Z'),
308
+ completesAt: at('2026-07-26T11:00:00Z'),
309
+ deposited: true,
310
+ building: 42,
311
+ inputs: INPUTS,
312
+ }
313
+ const inFlight = {...job, deposited: false}
314
+ const dropoff = task({
315
+ type: 21,
316
+ building: 42,
317
+ startsAt: at('2026-07-26T09:30:00Z'),
318
+ completesAt: at('2026-07-26T09:50:00Z'),
319
+ })
320
+
321
+ it('cancels a Booked or Dropping off build through the target, from the Drop-off to the lane tail', () => {
322
+ expect(buildJobCancelRoute(inFlight, at('2026-07-26T09:00:00Z'), [dropoff])).toEqual({
323
+ kind: 'dropoff',
324
+ shipId: 9,
325
+ laneKey: 1,
326
+ count: 1,
327
+ })
328
+ expect(buildJobCancelRoute(inFlight, at('2026-07-26T09:40:00Z'), [dropoff])).toEqual({
329
+ kind: 'dropoff',
330
+ shipId: 9,
331
+ laneKey: 1,
332
+ count: 1,
333
+ })
334
+ })
335
+ it('cancels a Queued build through cancelbuild', () => {
336
+ expect(buildJobCancelRoute(job, at('2026-07-26T09:59:59Z'))).toEqual({
337
+ kind: 'build',
338
+ jobId: 3,
339
+ })
340
+ })
341
+ it('has no route once the build has started or finished', () => {
342
+ expect(buildJobCancelRoute(job, at('2026-07-26T10:00:00Z'))).toBeNull()
343
+ expect(buildJobCancelRoute(job, at('2026-07-26T11:00:00Z'))).toBeNull()
344
+ })
345
+ it('has no route for an undeposited build without the target schedule', () => {
346
+ expect(buildJobCancelRoute(inFlight, at('2026-07-26T09:00:00Z'))).toBeNull()
347
+ expect(
348
+ buildJobCancelRoute({...inFlight, targetId: undefined}, at('2026-07-26T09:00:00Z'), [
349
+ dropoff,
350
+ ])
351
+ ).toBeNull()
352
+ })
353
+ })
354
+
355
+ describe('pickupsInFlight', () => {
356
+ it('lists the civic withdraw tasks bound for a building', () => {
357
+ const tasks = [
358
+ task({
359
+ type: 22,
360
+ building: 42,
361
+ cargo: [{item_id: 10001, stats: '5', quantity: 1}],
362
+ startsAt: at('2026-07-26T09:00:00Z'),
363
+ completesAt: at('2026-07-26T09:10:00Z'),
364
+ }),
365
+ task({
366
+ type: 21,
367
+ building: 42,
368
+ startsAt: at('2026-07-26T09:10:00Z'),
369
+ completesAt: at('2026-07-26T09:20:00Z'),
370
+ }),
371
+ task({
372
+ type: 22,
373
+ building: 7,
374
+ startsAt: at('2026-07-26T09:20:00Z'),
375
+ completesAt: at('2026-07-26T09:30:00Z'),
376
+ }),
377
+ ]
378
+ const pickups = pickupsInFlight(tasks, 42)
379
+ expect(pickups).toHaveLength(1)
380
+ expect(pickups[0].building).toBe(42)
381
+ expect(pickups[0].completesAt).toEqual(at('2026-07-26T09:10:00Z'))
382
+ expect(pickups[0].cargo).toEqual([{item_id: 10001, stats: '5', quantity: 1}] as never)
29
383
  })
30
384
  })
31
385
 
32
386
  describe('splitJobCargo', () => {
33
- it('reads an In Line row as inputs with no output', () => {
387
+ it('reads an undeposited row as inputs with no output', () => {
34
388
  const cargo = [{n: 'a'}, {n: 'b'}] as unknown as Parameters<typeof splitJobCargo>[0]
35
- const {output, inputs} = splitJobCargo(cargo, false)
389
+ const {output, inputs} = splitJobCargo(cargo, false, 1)
36
390
  expect(output).toBeNull()
37
391
  expect(inputs).toEqual([{n: 'a'}, {n: 'b'}] as never)
38
392
  })
39
- it('reads a landed row as the output with no inputs', () => {
393
+ it('reads a deposited row as inputs followed by the output tail', () => {
394
+ const cargo = [{n: 'a'}, {n: 'out'}] as unknown as Parameters<typeof splitJobCargo>[0]
395
+ const {output, inputs} = splitJobCargo(cargo, true, 1)
396
+ expect(output).toEqual({n: 'out'} as never)
397
+ expect(inputs).toEqual([{n: 'a'}] as never)
398
+ })
399
+ it('reads a legacy deposited row holding only its output', () => {
40
400
  const cargo = [{n: 'out'}] as unknown as Parameters<typeof splitJobCargo>[0]
41
- const {output, inputs} = splitJobCargo(cargo, true)
401
+ const {output, inputs} = splitJobCargo(cargo, true, 1)
42
402
  expect(output).toEqual({n: 'out'} as never)
43
403
  expect(inputs).toEqual([] as never)
44
404
  })
405
+ it('reads a cancelled row as inputs held for pickup', () => {
406
+ const cargo = [{n: 'a'}, {n: 'b'}] as unknown as Parameters<typeof splitJobCargo>[0]
407
+ const {output, inputs} = splitJobCargo(cargo, true, 0)
408
+ expect(output).toBeNull()
409
+ expect(inputs).toEqual([{n: 'a'}, {n: 'b'}] as never)
410
+ })
45
411
  it('returns null output for empty cargo', () => {
46
- expect(splitJobCargo([], true)).toEqual({output: null, inputs: []})
47
- expect(splitJobCargo([], false)).toEqual({output: null, inputs: []})
412
+ expect(splitJobCargo([], true, 1)).toEqual({output: null, inputs: []})
413
+ expect(splitJobCargo([], false, 1)).toEqual({output: null, inputs: []})
414
+ })
415
+ })
416
+
417
+ describe('workshopAvailability', () => {
418
+ const win = (socket: number, s: string, e: string): JobWindow => ({
419
+ id: 1,
420
+ socket,
421
+ owner: 'x',
422
+ startsAt: at(s),
423
+ completesAt: at(e),
424
+ recipeId: 1,
425
+ quantity: 1,
426
+ deposited: true,
427
+ })
428
+ const now = at('2026-07-26T10:00:00Z')
429
+
430
+ it('counts sockets with no live window as open now', () => {
431
+ const jobs = [win(0, '2026-07-26T09:00:00Z', '2026-07-26T11:00:00Z')]
432
+ expect(workshopAvailability(jobs, 3, now)).toEqual({open: 2, nextOpeningAt: null})
433
+ })
434
+ it('reports the earliest tail when every socket is booked', () => {
435
+ const jobs = [
436
+ win(0, '2026-07-26T09:00:00Z', '2026-07-26T11:00:00Z'),
437
+ win(1, '2026-07-26T09:00:00Z', '2026-07-26T10:40:00Z'),
438
+ ]
439
+ expect(workshopAvailability(jobs, 2, now)).toEqual({
440
+ open: 0,
441
+ nextOpeningAt: at('2026-07-26T10:40:00Z'),
442
+ })
443
+ })
444
+ it('ignores ended windows', () => {
445
+ const jobs = [win(0, '2026-07-26T08:00:00Z', '2026-07-26T09:00:00Z')]
446
+ expect(workshopAvailability(jobs, 1, now)).toEqual({open: 1, nextOpeningAt: null})
48
447
  })
49
448
  })