@cat-factory/app 0.259.1 → 0.260.1

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.
@@ -1,7 +1,8 @@
1
1
  import type { Ref } from 'vue'
2
2
  import { onMounted, onBeforeUnmount } from 'vue'
3
- import { useRafFn } from '@vueuse/core'
4
3
  import { lodAtLeast } from '~/composables/useSemanticZoom'
4
+ import { onBoardActivity, type BoardActivity } from '~/composables/useBoardActivity'
5
+ import { useSettlingRaf } from '~/composables/useSettlingRaf'
5
6
  import { headerDistanceSq, type Rect } from '~/utils/taskExpansionRanking'
6
7
 
7
8
  function intersects(a: Rect, b: Rect) {
@@ -33,8 +34,12 @@ function sameSet(a: Set<string>, b: Set<string>) {
33
34
  *
34
35
  * Only tasks with a running pipeline (steps to show) are candidates for either grant — a
35
36
  * task that wouldn't expand never blocks a neighbour and never lifts an empty card.
37
+ *
38
+ * Deciding costs a rect per candidate plus an `elementFromPoint`, so it runs only while the
39
+ * board is moving: the canvas activity pulse wakes it and `useSettlingRaf` parks it again once
40
+ * the two grants stop changing.
36
41
  */
37
- export function useTaskExpansion(container: Ref<HTMLElement | null>) {
42
+ export function useTaskExpansion(container: Ref<HTMLElement | null>, activity: BoardActivity) {
38
43
  const board = useBoardStore()
39
44
  const execution = useExecutionStore()
40
45
  const ui = useUiStore()
@@ -79,21 +84,29 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
79
84
  return id
80
85
  }
81
86
 
82
- function recompute() {
87
+ /** Re-decide both grants; reports whether either of them changed. */
88
+ function recompute(): boolean {
83
89
  // Hover expands a card at ANY zoom band, so the pointer hit is resolved BEFORE the
84
90
  // zoom gate below — resolving it after would collapse the hovered card the moment the
85
91
  // user zoomed back out past the `steps` band.
86
92
  const hovered = hoveredTaskId()
87
- if (store.hoveredId !== hovered) store.setHovered(hovered)
93
+ let changed = false
94
+ if (store.hoveredId !== hovered) {
95
+ store.setHovered(hovered)
96
+ changed = true
97
+ }
88
98
 
89
99
  // The zoom-driven expansion (every on-screen card, overlap-resolved) is deep-band
90
100
  // only; clear its grants otherwise. The hover grant above stands on its own.
91
101
  if (!lodAtLeast(ui.lod, 'steps')) {
92
- if (store.allowed.size) store.setAllowed(new Set())
93
- return
102
+ if (store.allowed.size) {
103
+ store.setAllowed(new Set())
104
+ changed = true
105
+ }
106
+ return changed
94
107
  }
95
108
  const view = container.value?.getBoundingClientRect()
96
- if (!view) return
109
+ if (!view) return changed
97
110
  const cx = view.left + view.width / 2
98
111
  const cy = view.top + view.height / 2
99
112
 
@@ -145,19 +158,24 @@ export function useTaskExpansion(container: Ref<HTMLElement | null>) {
145
158
  next.add(c.id)
146
159
  claimed.push(c.rect)
147
160
  }
148
- if (!sameSet(next, store.allowed)) store.setAllowed(next)
161
+ if (!sameSet(next, store.allowed)) {
162
+ store.setAllowed(next)
163
+ changed = true
164
+ }
165
+ return changed
149
166
  }
150
167
 
151
- const { pause, resume } = useRafFn(recompute, { immediate: false })
168
+ const { poke } = useSettlingRaf(recompute)
169
+ // The pointer listeners below only record where the pointer IS; the pulse (which watches the
170
+ // same gestures) is what schedules the frame that acts on it.
171
+ onBoardActivity(activity, poke)
152
172
  onMounted(() => {
153
173
  store.setDriverActive(true)
154
174
  const el = container.value
155
175
  el?.addEventListener('pointermove', onPointerMove)
156
176
  el?.addEventListener('pointerleave', onPointerLeave)
157
- resume()
158
177
  })
159
178
  onBeforeUnmount(() => {
160
- pause()
161
179
  const el = container.value
162
180
  el?.removeEventListener('pointermove', onPointerMove)
163
181
  el?.removeEventListener('pointerleave', onPointerLeave)
@@ -207,10 +207,17 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
207
207
  }
208
208
  }
209
209
 
210
- /** Patch the user-editable fields of a block (title, features, threshold…). */
211
- async function updateBlock(id: string, patch: UpdateBlockInput) {
210
+ /**
211
+ * Patch the user-editable fields of a block (title, features, threshold…).
212
+ *
213
+ * Returns whether the patch was PERSISTED. Both failure modes are already reported here (an
214
+ * unknown block is a no-op, a rejected write rolls back and toasts), so an inspector control
215
+ * firing and forgetting stays correct. A caller that goes on to ASSERT what the patch achieved
216
+ * must read it, or it announces links the rollback has just undone.
217
+ */
218
+ async function updateBlock(id: string, patch: UpdateBlockInput): Promise<boolean> {
212
219
  const b = getBlock(id)
213
- if (!b) return
220
+ if (!b) return false
214
221
  // Snapshot ONLY the fields this patch touches so a rejected write restores them exactly
215
222
  // (a patch may set several at once) rather than leaving a stale optimistic value stuck on
216
223
  // screen with no feedback — the same rollback contract the other mutations here follow.
@@ -224,6 +231,7 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
224
231
  Object.assign(b, patch) // optimistic
225
232
  try {
226
233
  upsert(await api.updateBlock(useWorkspaceStore().requireId(), id, patch))
234
+ return true
227
235
  } catch (e) {
228
236
  // Re-resolve the block: a live event may have replaced its object reference (`upsert`
229
237
  // swaps in a fresh one) while the write was in flight, so `b` can be stale. Only revert
@@ -242,6 +250,7 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
242
250
  icon: 'i-lucide-triangle-alert',
243
251
  color: 'error',
244
252
  })
253
+ return false
245
254
  }
246
255
  }
247
256
 
@@ -256,12 +256,22 @@ describe('board store read getters', () => {
256
256
  s.hydrate([frame('f1', { title: 'Original', description: 'orig' })])
257
257
  // With no active workspace, `requireId()` throws inside updateBlock's try — the same catch
258
258
  // that a rejected API write hits — so this exercises the optimistic-rollback + toast path.
259
- await s.updateBlock('f1', { title: 'Edited', description: 'changed' })
259
+ // The outcome is REPORTED to the caller, not only toasted: a caller that goes on to announce
260
+ // what the patch achieved (the monorepo import's frontend wiring) has to see the rollback.
261
+ await expect(s.updateBlock('f1', { title: 'Edited', description: 'changed' })).resolves.toBe(
262
+ false,
263
+ )
260
264
  expect(s.getBlock('f1')?.title).toBe('Original')
261
265
  expect(s.getBlock('f1')?.description).toBe('orig')
262
266
  expect(addSpy).toHaveBeenCalledWith(expect.objectContaining({ color: 'error' }))
263
267
  })
264
268
 
269
+ it('updateBlock reports a no-op for a block that is not on the board', async () => {
270
+ // Nothing is patched and nothing is toasted, so the return value is the ONLY signal that the
271
+ // write did not happen.
272
+ await expect(store.updateBlock('missing', { title: 'Edited' })).resolves.toBe(false)
273
+ })
274
+
265
275
  it('hydrate replaces and upsert inserts/updates cached blocks', () => {
266
276
  store.hydrate([frame('f1')])
267
277
  store.upsert(task('t1', 'f1', { title: 'first' }))
@@ -305,11 +315,21 @@ describe('board store optimistic rollback', () => {
305
315
  }))
306
316
  const store = useBoardStore()
307
317
  store.hydrate([frame('f1'), task('t1', 'f1', { title: 'orig', description: 'keep' })])
308
- await store.updateBlock('t1', { title: 'renamed' })
318
+ await expect(store.updateBlock('t1', { title: 'renamed' })).resolves.toBe(false)
309
319
  expect(store.getBlock('t1')?.title).toBe('orig')
310
320
  expect(store.getBlock('t1')?.description).toBe('keep')
311
321
  })
312
322
 
323
+ it('updateBlock reports the patch persisted when the API accepts it', async () => {
324
+ vi.stubGlobal('useApi', () => ({
325
+ updateBlock: async () => task('t1', 'f1', { title: 'renamed' }),
326
+ }))
327
+ const store = useBoardStore()
328
+ store.hydrate([frame('f1'), task('t1', 'f1', { title: 'orig' })])
329
+ await expect(store.updateBlock('t1', { title: 'renamed' })).resolves.toBe(true)
330
+ expect(store.getBlock('t1')?.title).toBe('renamed')
331
+ })
332
+
313
333
  it('previewResize translates the children when the drag moves the content origin', () => {
314
334
  // A child's position is relative to its container's content origin, so growing the frame
315
335
  // 40px west (origin -40) has to move every direct child +40 or the whole content slides with
@@ -521,6 +521,91 @@ describe('binaryOutputPickIssues, generative half', () => {
521
521
  expect(pick.conflictingSizeOptions).toEqual(['upscale'])
522
522
  })
523
523
 
524
+ // The refusal a value axis adds over the capability one: every selected endpoint takes an
525
+ // aspect ratio and none of them takes THIS ratio. Stated here because the builder is where the
526
+ // fix is (pick a listed ratio, or select an integration that renders this one), and because the
527
+ // set it names is already on the snapshot the picker is holding.
528
+ it('names a value nothing selected accepts, and what they do accept', () => {
529
+ const pick = binaryOutputPickIssues(
530
+ { storageServiceId: 'files', generatorIds: ['bucketed'], generation: { aspectRatio: '7:3' } },
531
+ catalog,
532
+ true,
533
+ [
534
+ {
535
+ id: 'bucketed',
536
+ modalities: ['image' as const],
537
+ capabilities: ['aspect-ratio' as const],
538
+ accepts: { aspectRatios: ['1:1', '16:9'] },
539
+ },
540
+ ],
541
+ )
542
+ expect(pick.issues).toContain('option_value_unaccepted')
543
+ expect(pick.unacceptedValues).toEqual([
544
+ { option: 'aspectRatio', requested: '7:3', accepted: ['1:1', '16:9'] },
545
+ ])
546
+ })
547
+
548
+ // ADVISORY, and the state that keeps the refusal above from firing on a working selection: one
549
+ // integration refuses the ratio and another has not said what it takes.
550
+ it('advises rather than refuses when a silent declarer might still serve the value', () => {
551
+ const pick = binaryOutputPickIssues(
552
+ {
553
+ storageServiceId: 'files',
554
+ generatorIds: ['bucketed', 'open'],
555
+ generation: { aspectRatio: '7:3' },
556
+ },
557
+ catalog,
558
+ true,
559
+ [
560
+ {
561
+ id: 'bucketed',
562
+ modalities: ['image' as const],
563
+ capabilities: ['aspect-ratio' as const],
564
+ accepts: { aspectRatios: ['1:1', '16:9'] },
565
+ },
566
+ { id: 'open', modalities: ['image' as const], capabilities: ['aspect-ratio' as const] },
567
+ ],
568
+ )
569
+ expect(pick.issues).toContain('option_value_unverifiable')
570
+ expect(pick.issues).not.toContain('option_value_unaccepted')
571
+ expect(pick.unverifiableValues).toEqual(['aspectRatio'])
572
+ })
573
+
574
+ // ADVISORY too, and the one the reader can act on precisely: one selected endpoint takes the
575
+ // ratio and another has written down that it does not. Naming the second is the whole remedy,
576
+ // and it is the finding a first-accepting-declarer short-circuit reported as nothing at all.
577
+ it('names the integrations that enumerated a value away when another accepts it', () => {
578
+ const pick = binaryOutputPickIssues(
579
+ {
580
+ storageServiceId: 'files',
581
+ generatorIds: ['wide', 'bucketed'],
582
+ generation: { aspectRatio: '7:3' },
583
+ },
584
+ catalog,
585
+ true,
586
+ [
587
+ {
588
+ id: 'wide',
589
+ modalities: ['image' as const],
590
+ capabilities: ['aspect-ratio' as const],
591
+ accepts: { aspectRatios: ['7:3', '1:1'] },
592
+ },
593
+ {
594
+ id: 'bucketed',
595
+ modalities: ['image' as const],
596
+ capabilities: ['aspect-ratio' as const],
597
+ accepts: { aspectRatios: ['1:1', '16:9'] },
598
+ },
599
+ ],
600
+ )
601
+ expect(pick.issues).toContain('option_value_partial')
602
+ expect(pick.issues).not.toContain('option_value_unaccepted')
603
+ expect(pick.issues).not.toContain('option_value_unverifiable')
604
+ expect(pick.partiallyAcceptedValues).toEqual([
605
+ { option: 'aspectRatio', requested: '7:3', refusedBy: ['bucketed'] },
606
+ ])
607
+ })
608
+
524
609
  it('reports BOTH faults when an unknown id was the one covering a requirement', () => {
525
610
  // One edit should clear the step. Naming only the missing id would leave the user to
526
611
  // discover the uncovered requirement on the next round trip.
@@ -3,6 +3,7 @@ import {
3
3
  binaryCapabilityCoverage,
4
4
  binaryFormatCoverage,
5
5
  binaryModalityOverlaps,
6
+ binaryValueCoverage,
6
7
  conflictingOutputSizeOptions,
7
8
  isBinaryModality,
8
9
  modalityCarriesPixelDimensions,
@@ -13,6 +14,9 @@ import type {
13
14
  BinaryGeneratorCapability,
14
15
  BinaryModality,
15
16
  BinaryModalityOverlap,
17
+ BinaryPartiallyAcceptedValue,
18
+ BinaryUnacceptedValue,
19
+ BinaryValueOption,
16
20
  ConflictingOutputSizeOption,
17
21
  RegisteredBinaryGenerator,
18
22
  } from '@cat-factory/contracts'
@@ -532,6 +536,28 @@ export type BinaryOutputPickIssue =
532
536
  * flag most working selections in the product.
533
537
  */
534
538
  | 'capability_unverifiable'
539
+ /**
540
+ * A generation option every selected integration can be ASKED for and none of them accepts the
541
+ * step's VALUE at: a `7:3` aspect ratio against endpoints whose picklists offer ten others
542
+ * (kernel's `option_value_unaccepted` spelling verbatim, like the members above it). A refusal.
543
+ */
544
+ | 'option_value_unaccepted'
545
+ /**
546
+ * A selected integration ACCEPTS the step's value and another has enumerated it away, so the
547
+ * step is servable by part of what it selected and the rest would quietly deliver something
548
+ * else. ADVISORY, and the reason is the same one that makes a capability covered when a single
549
+ * integration declares it: which endpoint renders which artifact is the agent's call. What is
550
+ * NOT optional is naming the ones that refuse it, since routing around them is the whole remedy.
551
+ */
552
+ | 'option_value_partial'
553
+ /**
554
+ * The step's value is on no stated set, and a selected integration that declares the capability
555
+ * states no set at all, so it may still be served. ADVISORY, for the reason
556
+ * `capability_unverifiable` is, and it is deliberately silent where NOBODY states a set: that is
557
+ * the state every registration is in until an endpoint is audited, and a line that fired there
558
+ * would ride nearly every step carrying an aspect ratio.
559
+ */
560
+ | 'option_value_unverifiable'
535
561
  /**
536
562
  * The step states an exact output size AND another option that restates the delivered
537
563
  * dimensions (`aspectRatio`, `upscale`). A refusal, mirroring `assertUnambiguousOutputSize` at
@@ -567,6 +593,14 @@ export interface BinaryOutputPickState {
567
593
  unsupportedCapabilities: readonly BinaryGeneratorCapability[]
568
594
  /** The ones that could not be judged, kept apart from the refusal above. */
569
595
  unverifiableCapabilities: readonly BinaryGeneratorCapability[]
596
+ /** The requested option values nothing selected accepts, each with what IS accepted, so the
597
+ * message names a value the reader can pick instead of only the one they cannot. */
598
+ unacceptedValues: readonly BinaryUnacceptedValue[]
599
+ /** The requested values a selected integration accepts and another enumerated away, naming the
600
+ * ones that refuse them, since the remedy is dropping or re-routing around those. */
601
+ partiallyAcceptedValues: readonly BinaryPartiallyAcceptedValue[]
602
+ /** The ones a silent declarer left open, kept apart from the refusal above. */
603
+ unverifiableValues: readonly BinaryValueOption[]
570
604
  /** The options restating the delivered dimensions beside an exact size, for the line that names
571
605
  * which field to delete. Computed through contracts' own rule, so this cannot come to a
572
606
  * different answer from the save that refuses it. */
@@ -598,7 +632,7 @@ function generatorPickIssues(
598
632
  config: BinaryOutputConfig | undefined,
599
633
  generators: readonly Pick<
600
634
  RegisteredBinaryGenerator,
601
- 'id' | 'modalities' | 'mediaTypes' | 'capabilities'
635
+ 'id' | 'modalities' | 'mediaTypes' | 'capabilities' | 'accepts'
602
636
  >[],
603
637
  unavailable: boolean,
604
638
  ): {
@@ -610,6 +644,9 @@ function generatorPickIssues(
610
644
  overlaps: BinaryModalityOverlap[]
611
645
  unsupportedCapabilities: BinaryGeneratorCapability[]
612
646
  unverifiableCapabilities: BinaryGeneratorCapability[]
647
+ unacceptedValues: BinaryUnacceptedValue[]
648
+ partiallyAcceptedValues: BinaryPartiallyAcceptedValue[]
649
+ unverifiableValues: BinaryValueOption[]
613
650
  } {
614
651
  const none = {
615
652
  unknownGeneratorIds: [],
@@ -619,6 +656,9 @@ function generatorPickIssues(
619
656
  overlaps: [],
620
657
  unsupportedCapabilities: [],
621
658
  unverifiableCapabilities: [],
659
+ unacceptedValues: [],
660
+ partiallyAcceptedValues: [],
661
+ unverifiableValues: [],
622
662
  }
623
663
  if (unavailable) return { issues: ['generators_unavailable'], ...none }
624
664
  const byId = new Map(generators.map((g) => [g.id, g]))
@@ -646,6 +686,10 @@ function generatorPickIssues(
646
686
  requiredBinaryCapabilities(config?.generation),
647
687
  selected,
648
688
  )
689
+ // One notch finer: the option is supported and the VALUE is not. Imported like every rule
690
+ // beside it, so the line this surface shows and the refusal the backend raises are one
691
+ // judgement rather than two that agree until somebody edits one of them.
692
+ const value = binaryValueCoverage(config?.generation, selected)
649
693
  const issues: BinaryOutputPickIssue[] = []
650
694
  if (unknownGeneratorIds.length) issues.push('unknown_generator')
651
695
  if (uncovered.length) issues.push('modality_uncovered')
@@ -654,6 +698,9 @@ function generatorPickIssues(
654
698
  if (overlaps.length) issues.push('generator_overlap')
655
699
  if (capability.uncovered.length) issues.push('capability_unsupported')
656
700
  if (capability.unverifiable.length) issues.push('capability_unverifiable')
701
+ if (value.unaccepted.length) issues.push('option_value_unaccepted')
702
+ if (value.partial.length) issues.push('option_value_partial')
703
+ if (value.unverifiable.length) issues.push('option_value_unverifiable')
657
704
  return {
658
705
  issues,
659
706
  unknownGeneratorIds,
@@ -663,6 +710,9 @@ function generatorPickIssues(
663
710
  overlaps,
664
711
  unsupportedCapabilities: capability.uncovered,
665
712
  unverifiableCapabilities: capability.unverifiable,
713
+ unacceptedValues: value.unaccepted,
714
+ partiallyAcceptedValues: value.partial,
715
+ unverifiableValues: value.unverifiable,
666
716
  }
667
717
  }
668
718
 
@@ -699,7 +749,7 @@ export function binaryOutputPickIssues(
699
749
  // stays a legitimate value rather than a hole.
700
750
  generators: readonly Pick<
701
751
  RegisteredBinaryGenerator,
702
- 'id' | 'modalities' | 'mediaTypes' | 'capabilities'
752
+ 'id' | 'modalities' | 'mediaTypes' | 'capabilities' | 'accepts'
703
753
  >[] = [],
704
754
  // Whether the deployment's integrations could not be READ. Defaulted to `false` — the honest
705
755
  // default, since every deployment but a mothership-mode node reads them in-process and cannot
@@ -737,6 +787,9 @@ export function binaryOutputPickIssues(
737
787
  generatorOverlaps: generative.overlaps,
738
788
  unsupportedCapabilities: generative.unsupportedCapabilities,
739
789
  unverifiableCapabilities: generative.unverifiableCapabilities,
790
+ unacceptedValues: generative.unacceptedValues,
791
+ partiallyAcceptedValues: generative.partiallyAcceptedValues,
792
+ unverifiableValues: generative.unverifiableValues,
740
793
  conflictingSizeOptions,
741
794
  }
742
795
  }
@@ -764,6 +817,9 @@ export function binaryOutputPickIssues(
764
817
  generatorOverlaps: generative.overlaps,
765
818
  unsupportedCapabilities: generative.unsupportedCapabilities,
766
819
  unverifiableCapabilities: generative.unverifiableCapabilities,
820
+ unacceptedValues: generative.unacceptedValues,
821
+ partiallyAcceptedValues: generative.partiallyAcceptedValues,
822
+ unverifiableValues: generative.unverifiableValues,
767
823
  conflictingSizeOptions,
768
824
  }
769
825
  }
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { shallowRef } from 'vue'
3
+ import { commitSegments, sameSegments, type EdgeSegment } from './edgeSegments'
4
+
5
+ const line = (over: Partial<EdgeSegment> = {}): EdgeSegment => ({
6
+ id: 'a__b',
7
+ x1: 0,
8
+ y1: 0,
9
+ x2: 10,
10
+ y2: 10,
11
+ ...over,
12
+ })
13
+
14
+ describe('sameSegments', () => {
15
+ it('accepts a freshly measured list that resolved to the same overlay', () => {
16
+ expect(sameSegments([line()], [line()])).toBe(true)
17
+ })
18
+
19
+ it('rejects a moved endpoint, however slightly', () => {
20
+ expect(sameSegments([line()], [line({ y2: 10.5 })])).toBe(false)
21
+ })
22
+
23
+ it('rejects a changed link set', () => {
24
+ expect(sameSegments([line()], [])).toBe(false)
25
+ expect(sameSegments([line()], [line({ id: 'a__c' })])).toBe(false)
26
+ })
27
+
28
+ it('rejects a dependency whose source finished, since it restyles the arrow', () => {
29
+ expect(sameSegments([line({ done: false })], [line({ done: true })])).toBe(false)
30
+ })
31
+ })
32
+
33
+ describe('commitSegments', () => {
34
+ it('publishes a changed list and reports it', () => {
35
+ const target = shallowRef<EdgeSegment[]>([line()])
36
+ const next = [line({ x2: 20 })]
37
+ expect(commitSegments(target, next)).toBe(true)
38
+ expect(target.value).toBe(next)
39
+ })
40
+
41
+ it('leaves the published array untouched when nothing moved', () => {
42
+ const published = [line()]
43
+ const target = shallowRef<EdgeSegment[]>(published)
44
+ // Identity has to survive, not just the values: reassigning an equal-but-new array is
45
+ // what re-rendered the whole overlay on every frame of an idle board.
46
+ expect(commitSegments(target, [line()])).toBe(false)
47
+ expect(target.value).toBe(published)
48
+ })
49
+ })
@@ -0,0 +1,49 @@
1
+ import type { Ref } from 'vue'
2
+
3
+ /**
4
+ * One drawable link on the board's screen-space overlay: a border-to-border line between two
5
+ * block cards. `done` rides only on dependency edges, where it picks the stroke and arrowhead.
6
+ */
7
+ export type EdgeSegment = {
8
+ id: string
9
+ x1: number
10
+ y1: number
11
+ x2: number
12
+ y2: number
13
+ done?: boolean
14
+ }
15
+
16
+ /** Whether two resolved segment lists would draw exactly the same overlay. */
17
+ export function sameSegments(a: readonly EdgeSegment[], b: readonly EdgeSegment[]): boolean {
18
+ if (a.length !== b.length) return false
19
+ for (let i = 0; i < a.length; i++) {
20
+ const left = a[i]!
21
+ const right = b[i]!
22
+ if (
23
+ left.id !== right.id ||
24
+ left.x1 !== right.x1 ||
25
+ left.y1 !== right.y1 ||
26
+ left.x2 !== right.x2 ||
27
+ left.y2 !== right.y2 ||
28
+ left.done !== right.done
29
+ ) {
30
+ return false
31
+ }
32
+ }
33
+ return true
34
+ }
35
+
36
+ /**
37
+ * Publish a freshly measured list, and report whether it moved anything. Writing an
38
+ * equal-but-new array every frame is what re-rendered the whole overlay 60 times a second on
39
+ * a board where nothing was moving, and it is also the signal the settling frame loop reads
40
+ * to decide it can park.
41
+ *
42
+ * The target is a `shallowRef`: the lists are replaced wholesale, so deep-proxying every
43
+ * segment object would be pure overhead.
44
+ */
45
+ export function commitSegments(target: Ref<EdgeSegment[]>, next: EdgeSegment[]): boolean {
46
+ if (sameSegments(target.value, next)) return false
47
+ target.value = next
48
+ return true
49
+ }
@@ -0,0 +1,120 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import type { CreatedMonorepoFrame } from './monorepoImport'
3
+ import {
4
+ canDesignateFrontend,
5
+ planFrontendConfigPatches,
6
+ planMonorepoImport,
7
+ } from './monorepoImport'
8
+
9
+ /** Pair a plan with block ids, the way the modal does as each create call returns. */
10
+ function created(entries: ReturnType<typeof planMonorepoImport>): CreatedMonorepoFrame[] {
11
+ return entries.map((entry, i) => ({ blockId: `blk_${i}`, entry }))
12
+ }
13
+
14
+ describe('canDesignateFrontend', () => {
15
+ it('offers the mark for two or more backend services', () => {
16
+ expect(canDesignateFrontend('service', 2)).toBe(true)
17
+ expect(canDesignateFrontend('service', 5)).toBe(true)
18
+ })
19
+
20
+ it('withholds it below two directories: there is no rest to bind to', () => {
21
+ expect(canDesignateFrontend('service', 1)).toBe(false)
22
+ expect(canDesignateFrontend('service', 0)).toBe(false)
23
+ })
24
+
25
+ it('withholds it for roles a backend binding cannot point at', () => {
26
+ // A binding's `service` source names a `service` frame; a library/document frame is not one,
27
+ // and when everything is already a frontend the mark divides nothing.
28
+ expect(canDesignateFrontend('library', 3)).toBe(false)
29
+ expect(canDesignateFrontend('document', 3)).toBe(false)
30
+ expect(canDesignateFrontend('frontend', 3)).toBe(false)
31
+ })
32
+ })
33
+
34
+ describe('planMonorepoImport', () => {
35
+ it('creates the marked directory as a frontend and the rest with the picked role', () => {
36
+ expect(
37
+ planMonorepoImport(['apps/web', 'services/api', 'services/auth'], 'service', 'apps/web'),
38
+ ).toEqual([
39
+ { directory: 'apps/web', type: 'frontend', designatedFrontend: true },
40
+ { directory: 'services/api', type: 'service', designatedFrontend: false },
41
+ { directory: 'services/auth', type: 'service', designatedFrontend: false },
42
+ ])
43
+ })
44
+
45
+ it('keeps the picked order, wherever the marked directory sits in it', () => {
46
+ const plan = planMonorepoImport(['services/api', 'apps/web'], 'service', 'apps/web')
47
+ expect(plan.map((e) => e.directory)).toEqual(['services/api', 'apps/web'])
48
+ expect(plan[1]?.type).toBe('frontend')
49
+ })
50
+
51
+ it('gives every directory the picked role when nothing is marked', () => {
52
+ expect(planMonorepoImport(['a', 'b'], 'service', undefined)).toEqual([
53
+ { directory: 'a', type: 'service', designatedFrontend: false },
54
+ { directory: 'b', type: 'service', designatedFrontend: false },
55
+ ])
56
+ })
57
+
58
+ it('ignores a mark on a directory that is not being created', () => {
59
+ // The cart entry was removed (or was already on the board and got filtered out) after being
60
+ // marked. Designating nothing is right; promoting some other frame to frontend would not be.
61
+ expect(planMonorepoImport(['a', 'b'], 'service', 'apps/web')).toEqual([
62
+ { directory: 'a', type: 'service', designatedFrontend: false },
63
+ { directory: 'b', type: 'service', designatedFrontend: false },
64
+ ])
65
+ })
66
+
67
+ it('designates nobody when the whole cart is imported under the frontend role', () => {
68
+ // Every entry is `type: 'frontend'` here, so the flag is the ONLY thing that separates
69
+ // "the app the others talk to" from "a cart of frontends". A mark is never on offer for this
70
+ // role (`canDesignateFrontend`), and one carried over from a role change must not act.
71
+ const plan = planMonorepoImport(['apps/web', 'apps/admin'], 'frontend', 'apps/web')
72
+ expect(plan.every((e) => e.type === 'frontend')).toBe(true)
73
+ expect(plan.some((e) => e.designatedFrontend)).toBe(false)
74
+ })
75
+ })
76
+
77
+ describe('planFrontendConfigPatches', () => {
78
+ it('binds the designated frontend to every other frame created beside it', () => {
79
+ const plan = planMonorepoImport(
80
+ ['apps/web', 'services/api', 'services/auth'],
81
+ 'service',
82
+ 'apps/web',
83
+ )
84
+ expect(planFrontendConfigPatches(created(plan))).toEqual([
85
+ {
86
+ blockId: 'blk_0',
87
+ config: {
88
+ directory: 'apps/web',
89
+ backendBindings: [
90
+ { envVar: '', source: { kind: 'service', serviceBlockId: 'blk_1' } },
91
+ { envVar: '', source: { kind: 'service', serviceBlockId: 'blk_2' } },
92
+ ],
93
+ },
94
+ },
95
+ ])
96
+ })
97
+
98
+ it('leaves every env var name empty rather than inventing one', () => {
99
+ const plan = planMonorepoImport(['apps/web', 'services/api'], 'service', 'apps/web')
100
+ const [patch] = planFrontendConfigPatches(created(plan))
101
+ expect(patch?.config.backendBindings.every((b) => b.envVar === '')).toBe(true)
102
+ })
103
+
104
+ it('carries the subdirectory to every frontend frame, designated or not', () => {
105
+ // `frontendConfig.directory` is what the harness's install/build/serve reads; the service-level
106
+ // directory that scopes an agent's checkout is a different field and does not stand in. A cart
107
+ // imported under the `frontend` role has no designated frame, and every frame in it would
108
+ // otherwise build the repo root.
109
+ const plan = planMonorepoImport(['apps/web', 'apps/admin'], 'frontend', undefined)
110
+ expect(planFrontendConfigPatches(created(plan))).toEqual([
111
+ { blockId: 'blk_0', config: { directory: 'apps/web', backendBindings: [] } },
112
+ { blockId: 'blk_1', config: { directory: 'apps/admin', backendBindings: [] } },
113
+ ])
114
+ })
115
+
116
+ it('patches nothing when the import creates no frontend frame', () => {
117
+ const plan = planMonorepoImport(['services/api', 'services/auth'], 'service', undefined)
118
+ expect(planFrontendConfigPatches(created(plan))).toEqual([])
119
+ })
120
+ })