OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / test / layout.test.js

643 lines · javascript ref:
import { test } from 'brittle'
import { layout } from '../lib/layout.js'

// Zones are CONTAINERS in the layout, not boxes drawn afterwards around whatever
// positions fell out. The old node view computed a frame's rectangle from the
// bounding box of its members AFTER layering, and since each column ordered its
// rows independently, two frames' members interleaved and their boxes crossed.
// This is the fix, and these tests are the guarantee.

function node (id, container = null, w = 200, h = 60) {
  return { id, container, w, h }
}
function boxesOverlap (a, b) {
  return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h
}
function contains (outer, inner) {
  return outer.x <= inner.x && outer.y <= inner.y &&
    outer.x + outer.w >= inner.x + inner.w &&
    outer.y + outer.h >= inner.y + inner.h
}
function byId (list) {
  const m = {}
  for (const item of list) m[item.id] = item
  return m
}

test('layout: a flat graph lays out in dependency columns', (t) => {
  const out = layout({
    nodes: [node('a'), node('b'), node('c')],
    links: [{ from: 'a', to: 'b' }, { from: 'b', to: 'c' }],
    containers: []
  })

  const n = byId(out.nodes)
  t.ok(n.a.x < n.b.x, 'a is left of b')
  t.ok(n.b.x < n.c.x, 'b is left of c')
  t.is(n.a.y, n.b.y, 'a single chain keeps one row')
  t.ok(out.width > 0 && out.height > 0, 'reports its extent')
})

test('layout: two independent nodes stack, they do not collide', (t) => {
  const out = layout({ nodes: [node('a'), node('b')], links: [], containers: [] })
  const n = byId(out.nodes)
  t.is(n.a.x, n.b.x, 'same column')
  t.ok(!boxesOverlap(n.a, n.b), 'and they do not overlap')
})

test('layout: a container encloses its members with padding', (t) => {
  const out = layout({
    nodes: [node('x', 'z1'), node('y', 'z1')],
    links: [{ from: 'x', to: 'y' }],
    containers: [{ id: 'z1', parent: null, label: 'loop' }]
  })

  const n = byId(out.nodes)
  const z = byId(out.containers).z1
  t.ok(z, 'the container has a box')
  t.ok(contains(z, n.x), 'it contains x')
  t.ok(contains(z, n.y), 'it contains y')
  t.ok(z.x < n.x.x, 'with padding on the left')
  t.ok(z.y < n.x.y, 'and room for its label above')
})

test('layout: SIBLING CONTAINERS CANNOT OVERLAP — the bug this exists to kill', (t) => {
  const out = layout({
    nodes: [
      node('a1', 'zA'), node('a2', 'zA'), node('a3', 'zA'),
      node('b1', 'zB'), node('b2', 'zB'),
      node('loose')
    ],
    // Links that would, under the old scheme, spread each zone's members across
    // several columns and interleave them with the other zone's.
    links: [
      { from: 'a1', to: 'a2' }, { from: 'a2', to: 'a3' },
      { from: 'b1', to: 'b2' },
      { from: 'loose', to: 'a1' }
    ],
    containers: [
      { id: 'zA', parent: null, label: 'zone A' },
      { id: 'zB', parent: null, label: 'zone B' }
    ]
  })

  const c = byId(out.containers)
  t.absent(boxesOverlap(c.zA, c.zB), 'zone A and zone B are disjoint')

  // And every member really is inside its own container, not the other one.
  const n = byId(out.nodes)
  for (const id of ['a1', 'a2', 'a3']) t.ok(contains(c.zA, n[id]), id + ' inside zone A')
  for (const id of ['b1', 'b2']) t.ok(contains(c.zB, n[id]), id + ' inside zone B')
  t.absent(contains(c.zA, n.loose), 'a node outside every zone is not captured by one')
})

test('layout: sibling containers get their OWN breathing room, not the card gap', (t) => {
  // Reported twice as "the groups overlap" / "boxes stacked on top of each
  // other". They never intersected — they were separated by the same 26px used
  // between cards INSIDE a box, so two groups butted together read as one stack.
  // A group is not a card and does not get a card's gap.
  const out = layout({
    nodes: [node('a', 'zA'), node('b', 'zB'), node('c', 'zC')],
    links: [],
    containers: [
      { id: 'zA', parent: null, label: 'A' },
      { id: 'zB', parent: null, label: 'B' },
      { id: 'zC', parent: null, label: 'C' }
    ]
  }, { groupGap: 90, rowGap: 26 })

  // Groups may be arranged in a row or a column depending on how they pack, so
  // the claim is about SEPARATION, not about one axis: any two must be clear of
  // each other by the group gap in whichever direction separates them.
  const boxes = out.containers
  for (let i = 0; i < boxes.length; i++) {
    for (let j = i + 1; j < boxes.length; j++) {
      const a = boxes[i]
      const b = boxes[j]
      const gapX = Math.max(a.x, b.x) - Math.min(a.x + a.w, b.x + b.w)
      const gapY = Math.max(a.y, b.y) - Math.min(a.y + a.h, b.y + b.h)
      t.ok(Math.max(gapX, gapY) >= 90,
        a.id + '/' + b.id + ' separated by ' + Math.round(Math.max(gapX, gapY)))
    }
  }
})

test('layout: cards inside a container keep the tight card gap', (t) => {
  // The separation is for GROUPS. Cards within one group stay close together,
  // or every box becomes mostly empty space.
  const out = layout({
    nodes: [node('a', 'z'), node('b', 'z')],
    links: [],
    containers: [{ id: 'z', parent: null, label: 'z' }]
  }, { groupGap: 90, rowGap: 26 })

  const ns = out.nodes.slice().sort((p, q) => p.y - q.y)
  const gap = ns[1].y - (ns[0].y + ns[0].h)
  t.is(gap, 26, 'cards keep the card gap')
})

test('layout: nesting — a parent container strictly contains its child', (t) => {
  const out = layout({
    nodes: [node('outer1', 'zOuter'), node('inner1', 'zInner'), node('inner2', 'zInner')],
    links: [{ from: 'inner1', to: 'inner2' }, { from: 'outer1', to: 'inner1' }],
    containers: [
      { id: 'zOuter', parent: null, label: 'outer' },
      { id: 'zInner', parent: 'zOuter', label: 'inner' }
    ]
  })

  const c = byId(out.containers)
  const n = byId(out.nodes)
  t.ok(contains(c.zOuter, c.zInner), 'outer contains inner')
  t.ok(contains(c.zInner, n.inner1), 'inner contains its own members')
  t.ok(contains(c.zOuter, n.outer1), 'outer contains its direct member')
  t.absent(contains(c.zInner, n.outer1), 'but inner does not claim the outer member')
  t.is(c.zInner.depth, 2, 'depth is reported')
  t.is(c.zOuter.depth, 1, 'from the root down')
})

test('layout: a link crossing a boundary orders the containers themselves', (t) => {
  const out = layout({
    nodes: [node('a', 'zA'), node('b', 'zB')],
    links: [{ from: 'a', to: 'b' }],
    containers: [
      { id: 'zA', parent: null, label: 'A' },
      { id: 'zB', parent: null, label: 'B' }
    ]
  })

  const c = byId(out.containers)
  t.ok(c.zA.x < c.zB.x, 'A is placed left of B because a feeds b')
  t.absent(boxesOverlap(c.zA, c.zB), 'and they still do not overlap')
})

test('layout: expanding a node reflows without breaking enclosure (L4)', (t) => {
  const input = () => ({
    nodes: [node('x', 'z1'), node('y', 'z1'), node('outside')],
    links: [{ from: 'x', to: 'y' }],
    containers: [{ id: 'z1', parent: null, label: 'loop' }]
  })

  const before = layout(input())
  const grown = input()
  grown.nodes.find(n => n.id === 'x').h = 400   // the user expanded x's code
  const after = layout(grown)

  const zBefore = byId(before.containers).z1
  const zAfter = byId(after.containers).z1
  t.ok(zAfter.h > zBefore.h, 'the container grew with its member')

  const n = byId(after.nodes)
  t.ok(contains(zAfter, n.x), 'still contains the expanded node')
  t.ok(contains(zAfter, n.y), 'and its sibling')
  t.absent(boxesOverlap(zAfter, n.outside), 'and does not swallow the node outside it')
})

test('layout: a dependency cycle terminates', (t) => {
  const out = layout({
    nodes: [node('a'), node('b'), node('c')],
    links: [{ from: 'a', to: 'b' }, { from: 'b', to: 'c' }, { from: 'c', to: 'a' }],
    containers: []
  })
  t.is(out.nodes.length, 3, 'all three placed')
  for (const a of out.nodes) {
    for (const b of out.nodes) {
      if (a.id !== b.id) t.absent(boxesOverlap(a, b), a.id + ' and ' + b.id + ' disjoint')
    }
  }
})

test('layout: a link-less group WRAPS instead of forming one endless column', (t) => {
  // Found by running this on a real scan: keet-cli has 207 files, 117
  // directories and exactly ONE import edge, so every item layered to depth 0
  // and stacked into a single column 26,012px tall. Layering gives x; within one
  // layer, height has to wrap.
  const nodes = []
  for (let i = 0; i < 60; i++) nodes.push(node('n' + String(i).padStart(2, '0'), null, 200, 60))
  const out = layout({ nodes, links: [], containers: [] }, { maxColumnHeight: 600 })

  const xs = new Set(out.nodes.map(n => n.x))
  t.ok(xs.size > 1, 'it used more than one column: ' + xs.size)
  t.ok(out.height <= 700, 'and stayed inside the height budget: ' + out.height)

  for (const a of out.nodes) {
    for (const b of out.nodes) {
      if (a.id !== b.id) t.absent(boxesOverlap(a, b), a.id + '/' + b.id + ' disjoint')
    }
  }
})

test('layout: wrapping keeps dependency order in READING order', (t) => {
  // Columns wrap into rows to keep the board packed, so "supplier is always to
  // the left" cannot hold — a later layer may begin the next row. What holds is
  // reading order: a supplier is never on a LATER row, and on the same row it
  // is to the left. That is the honest claim once the board wraps.
  const nodes = []
  const links = []
  for (let i = 0; i < 12; i++) {
    nodes.push(node('a' + i, null, 200, 100))
    nodes.push(node('b' + i, null, 200, 100))
    links.push({ from: 'a' + i, to: 'b' + i })
  }
  const out = layout({ nodes, links, containers: [] }, { maxColumnHeight: 400 })
  const byId = {}
  for (const n of out.nodes) byId[n.id] = n

  for (let i = 0; i < 12; i++) {
    const a = byId['a' + i]
    const b = byId['b' + i]
    t.ok(Number.isInteger(a.row), 'the layout says which row a' + i + ' is in')
    if (a.row === b.row) t.ok(a.x + a.w <= b.x, 'a' + i + ' is left of b' + i + ' on their row')
    else t.ok(a.row < b.row, 'a' + i + ' is on an earlier row than b' + i)
  }
})

test('layout: a cycle does not inflate depth into nonsense columns', (t) => {
  // Measured on the real system level: 15 groups came out at depths 0, 1, 47,
  // 48, 49, 50, because longest-path layering keeps relaxing around a cycle
  // until the iteration cap. Depth 50 on a 15-item graph is not a layer, it is
  // a runaway.
  const nodes = []
  const links = []
  for (let i = 0; i < 8; i++) nodes.push(node('n' + i, null, 200, 60))
  for (let i = 0; i < 8; i++) links.push({ from: 'n' + i, to: 'n' + ((i + 1) % 8) })  // a ring

  const out = layout({ nodes, links, containers: [] })
  const xs = [...new Set(out.nodes.map(n => Math.round(n.x)))].sort((a, b) => a - b)
  t.ok(xs.length <= nodes.length, 'no more columns than items: ' + xs.length)

  // A ring, once one back edge is dropped, IS a chain of eight — so eight
  // columns is right. What must not happen is the runaway: fifty columns for
  // eight items.
  t.ok(out.width <= nodes.length * (200 + 130) + 200,
    'width matches the chain, not a runaway: ' + Math.round(out.width))
})

test('layout: a big group packs squarely, not as a slab', (t) => {
  // "b4a users" held 26 modules and came out 1892x950 — a wide slab, because
  // every group wrapped at the same fixed height whatever it contained.
  const nodes = []
  for (let i = 0; i < 26; i++) nodes.push(node('m' + i, 'big', 260, 48))
  const out = layout({
    nodes, links: [], containers: [{ id: 'big', parent: null, label: 'big' }]
  })

  const box = out.containers[0]
  const ratio = box.w / box.h
  t.ok(ratio > 0.4 && ratio < 2.6, 'the group is squarish: ' +
    Math.round(box.w) + 'x' + Math.round(box.h) + ' = ' + ratio.toFixed(2) + ':1')
})

test('layout: packs toward a readable aspect instead of a long strip', (t) => {
  // A fixed wrap height makes the whole board a strip: the real system level
  // came out 5594x1460, nearly 4:1, so most of it is off-screen in one
  // direction while the other is empty.
  const nodes = []
  const containers = []
  for (let i = 0; i < 24; i++) {
    const id = 'g' + String(i).padStart(2, '0')
    containers.push({ id, parent: null, label: id })
    nodes.push(node('n' + i, id, 240, 90))
  }
  const out = layout({ nodes, links: [], containers })
  const ratio = out.width / out.height

  t.ok(ratio > 0.6 && ratio < 3, 'aspect is in a usable band: ' +
    Math.round(out.width) + 'x' + Math.round(out.height) + ' = ' + ratio.toFixed(2) + ':1')

  // Still no overlaps — packing must not trade correctness for shape.
  for (const a of out.containers) {
    for (const b of out.containers) {
      if (a.id < b.id) t.absent(boxesOverlap(a, b), a.id + '/' + b.id + ' disjoint')
    }
  }
})

test('layout: an explicit wrap height still wins', (t) => {
  const nodes = []
  for (let i = 0; i < 30; i++) nodes.push(node('n' + i, null, 200, 60))
  const out = layout({ nodes, links: [], containers: [] }, { maxColumnHeight: 500 })
  t.ok(out.height <= 600, 'honoured: ' + out.height)
})

// Count how many pairs of edges cross, judging by the vertical order of their
// endpoints within each column.
function countCrossings (out, links) {
  const pos = {}
  for (const n of out.nodes) pos[n.id] = n
  let crossings = 0
  for (let i = 0; i < links.length; i++) {
    for (let j = i + 1; j < links.length; j++) {
      const a = links[i]
      const b = links[j]
      const a1 = pos[a.from]; const a2 = pos[a.to]
      const b1 = pos[b.from]; const b2 = pos[b.to]
      if (!a1 || !a2 || !b1 || !b2) continue
      // only compare edges spanning the same pair of columns
      if (Math.abs(a1.x - b1.x) > 1 || Math.abs(a2.x - b2.x) > 1) continue
      const startOrder = Math.sign(a1.y - b1.y)
      const endOrder = Math.sign(a2.y - b2.y)
      if (startOrder !== 0 && endOrder !== 0 && startOrder !== endOrder) crossings++
    }
  }
  return crossings
}

test('layout: orders each column to UNCROSS the wires', (t) => {
  // Sorting a column by id is arbitrary with respect to the edges, so a graph
  // whose natural order is reversed crosses every wire with every other. The
  // fix belongs in layout, not in routing: wires cross because the nodes they
  // join are in the wrong order.
  const nodes = []
  const links = []
  for (let i = 0; i < 6; i++) {
    nodes.push(node('a' + i, null, 200, 60))
    nodes.push(node('b' + i, null, 200, 60))
    links.push({ from: 'a' + i, to: 'b' + (5 - i) })   // fully reversed
  }
  const out = layout({ nodes, links, containers: [] })
  t.is(countCrossings(out, links), 0, 'a reversed pairing lays out with no crossings')
})

test('layout: a fan-out does not manufacture crossings', (t) => {
  const nodes = [node('src', null, 200, 60)]
  const links = []
  for (let i = 0; i < 5; i++) {
    nodes.push(node('t' + i, null, 200, 60))
    links.push({ from: 'src', to: 't' + i })
  }
  const out = layout({ nodes, links, containers: [] })
  t.is(countCrossings(out, links), 0, 'a fan from one node never crosses itself')
})

test('layout: ordering keeps every other guarantee', (t) => {
  const nodes = []
  const links = []
  const containers = [{ id: 'z', parent: null, label: 'z' }]
  for (let i = 0; i < 8; i++) {
    nodes.push(node('n' + i, i < 4 ? 'z' : null, 200, 60))
    if (i > 0) links.push({ from: 'n' + (i - 1), to: 'n' + i })
  }
  const out = layout({ nodes, links, containers })
  for (const a of out.nodes) {
    for (const b of out.nodes) {
      if (a.id !== b.id) t.absent(boxesOverlap(a, b), a.id + '/' + b.id + ' disjoint')
    }
  }
  const box = out.containers[0]
  for (const n of out.nodes.filter(n => ['n0', 'n1', 'n2', 'n3'].includes(n.id))) {
    t.ok(box.x <= n.x && box.x + box.w >= n.x + n.w, n.id + ' still inside its container')
  }
})

function totalWireLength (out, links) {
  const pos = {}
  for (const n of out.nodes) pos[n.id] = n
  let total = 0
  for (const l of links) {
    const a = pos[l.from]
    const b = pos[l.to]
    if (!a || !b) continue
    total += Math.abs(a.x - b.x) + Math.abs(a.y - b.y)
  }
  return total
}

test('layout: densely connected nodes are placed near each other', (t) => {
  // Element placement, as on a board: parts that talk to each other belong
  // close together, so the traces between them are short. Ids here interleave
  // the two clusters deliberately, so an id-ordered layout spreads each cluster
  // across the whole column.
  const nodes = []
  const links = []
  for (let i = 0; i < 4; i++) {
    nodes.push(node('a' + (i * 2), null, 200, 60))       // cluster A: a0 a2 a4 a6
    nodes.push(node('b' + (i * 2 + 1), null, 200, 60))   // cluster B: b1 b3 b5 b7
  }
  // Each cluster is a chain; the two never touch.
  for (let i = 0; i < 3; i++) {
    links.push({ from: 'a' + (i * 2), to: 'a' + ((i + 1) * 2) })
    links.push({ from: 'b' + (i * 2 + 1), to: 'b' + ((i + 1) * 2 + 1) })
  }

  const out = layout({ nodes, links, containers: [] })
  const pos = {}
  for (const n of out.nodes) pos[n.id] = n

  // Every wire should be short: a chain link should not jump across the board.
  for (const l of links) {
    const dy = Math.abs(pos[l.from].y - pos[l.to].y)
    t.ok(dy <= 200, l.from + ' -> ' + l.to + ' spans ' + Math.round(dy) + 'px vertically')
  }
})

test('layout: placement beats an arbitrary order on total wire length', (t) => {
  const nodes = []
  const links = []
  for (let i = 0; i < 8; i++) nodes.push(node('n' + i, null, 200, 60))
  // A pairing that id-order gets wrong: 0-7, 1-6, 2-5, 3-4 in the next column.
  for (let i = 0; i < 8; i++) nodes.push(node('m' + i, null, 200, 60))
  for (let i = 0; i < 8; i++) links.push({ from: 'n' + i, to: 'm' + (7 - i) })

  const out = layout({ nodes, links, containers: [] })
  const length = totalWireLength(out, links)

  // With the columns ordered to match, every wire is horizontal: the only
  // distance left is the one column gap each.
  const oneGap = totalWireLength({ nodes: out.nodes.map(n => ({ ...n, y: 0 })) }, links)
  t.ok(length <= oneGap * 1.15, 'wire length is near the horizontal minimum: ' +
    Math.round(length) + ' vs ' + Math.round(oneGap))
})

test('layout: wraps at a WEAK seam rather than through a dense one', (t) => {
  // Two blocks that are densely wired internally, joined by a single link. When
  // the board must wrap, the break belongs at the thin join — breaking through
  // a dense block is what scatters a cluster across two rows.
  //
  // Three nodes per column, three parallel edges between consecutive columns
  // inside a block, and exactly one edge between the blocks.
  const nodes = []
  const links = []
  const block = (prefix, cols) => {
    for (let c = 0; c < cols; c++) {
      for (let r = 0; r < 3; r++) nodes.push(node(prefix + c + '_' + r, null, 260, 60))
    }
    for (let c = 1; c < cols; c++) {
      for (let r = 0; r < 3; r++) {
        links.push({ from: prefix + (c - 1) + '_' + r, to: prefix + c + '_' + r })
      }
    }
  }
  block('a', 3)
  block('b', 3)
  links.push({ from: 'a2_0', to: 'b0_0' })   // the one thin join

  const out = layout({ nodes, links, containers: [] })
  const pos = {}
  for (const n of out.nodes) pos[n.id] = n

  // Find where rows actually change, in column order.
  const columnOf = new Map()
  for (const n of out.nodes) {
    const col = Math.round(n.x)
    if (!columnOf.has(col)) columnOf.set(col, n.row)
  }
  const rows = [...new Set(out.nodes.map(n => n.row))]
  if (rows.length === 1) {
    t.pass('it all fits on one row, so there is no seam to choose')
    return
  }

  // Every dense pair must stay on the same row: the split can only be the join.
  for (const prefix of ['a', 'b']) {
    for (let c = 1; c < 3; c++) {
      const before = pos[prefix + (c - 1) + '_0'].row
      const after = pos[prefix + c + '_0'].row
      t.is(before, after, prefix + ' stays intact across its dense seam ' + c)
    }
  }
})

test('layout: purity and stability', (t) => {
  const input = {
    nodes: [node('b', 'z'), node('a', 'z'), node('c')],
    links: [{ from: 'a', to: 'b' }],
    containers: [{ id: 'z', parent: null, label: 'z' }]
  }
  const a = layout(input)
  const b = layout(input)
  t.alike(a, b, 'same input, same output')
})

test('layout: degenerate input does not throw', (t) => {
  t.ok(layout({ nodes: [], links: [], containers: [] }), 'empty')
  t.ok(layout({ nodes: [node('a', 'missing')], links: [], containers: [] }),
    'a node naming a container that does not exist falls back to the root')
  t.ok(layout({ nodes: [node('a')], links: [{ from: 'a', to: 'nope' }], containers: [] }),
    'a link to a node that does not exist is ignored')
})

// --- the second pass: ordering a group against the rest of the board ---------
//
// A group is sized before its parent places it, so while its members are being
// ordered, nothing outside it has a position yet. For most groups that is all
// the information there is — a directory whose every import crosses a directory
// boundary has no sibling edges at all — so pass one cannot order it. Pass two
// re-lays everything out using pass one's absolute positions as the hint.

function crossingBoard () {
  // Three grouped nodes wired to three ungrouped ones in the OPPOSITE order, so
  // pass one's id ordering puts every wire across every other.
  return {
    nodes: [
      { id: 'a1', container: 'G1', w: 200, h: 40 },
      { id: 'a2', container: 'G1', w: 200, h: 40 },
      { id: 'a3', container: 'G1', w: 200, h: 40 },
      { id: 'x1', container: null, w: 200, h: 40 },
      { id: 'x2', container: null, w: 200, h: 40 },
      { id: 'x3', container: null, w: 200, h: 40 }
    ],
    containers: [{ id: 'G1', parent: null, label: 'G1' }],
    links: [{ from: 'a1', to: 'x3' }, { from: 'a2', to: 'x2' }, { from: 'a3', to: 'x1' }]
  }
}

function totalRise (input, out) {
  const y = new Map()
  for (const n of out.nodes) y.set(n.id, n.y + n.h / 2)
  let sum = 0
  for (const l of input.links) sum += Math.abs(y.get(l.from) - y.get(l.to))
  return sum
}

test('layout: a second pass orders a group by where its neighbours landed', (t) => {
  const input = crossingBoard()

  const one = layout(input, { passes: 1 })
  const two = layout(input, { passes: 2 })

  const y1 = new Map(one.nodes.map(n => [n.id, n.y]))
  const y2 = new Map(two.nodes.map(n => [n.id, n.y]))

  // The targets do not move: their own order is settled by edges inside the root
  // group, which pass one already had.
  for (const id of ['x1', 'x2', 'x3']) {
    t.is(y2.get(id), y1.get(id), id + ' keeps its place')
  }

  // a1 wires to the LOWEST target, so after the hint it must sit lowest itself.
  t.ok(y1.get('a1') < y1.get('a3'), 'pass one has a1 above a3, by id')
  t.ok(y2.get('a1') > y2.get('a3'), 'pass two has flipped them to follow the targets')

  // And the thing that actually matters: the wires got shorter.
  const before = totalRise(input, one)
  const after = totalRise(input, two)
  t.ok(after < before, 'total vertical run fell from ' + before + ' to ' + after)
})

test('layout: two passes reach a fixed point', (t) => {
  // If a third pass moved anything, the hint would be oscillating rather than
  // converging and the default of 2 would be arbitrary. Measured on four real
  // modules it changes nothing; this pins that for a board built to be hostile.
  const input = crossingBoard()
  t.alike(layout(input, { passes: 3 }), layout(input, { passes: 2 }),
    'a third pass moves nothing')
})

test('layout: the hint needs a neighbour OUTSIDE the group to work with', (t) => {
  // The pass can only help where a group has external edges. With everything
  // wired inside one group there is no hint to build, and the result must be
  // identical rather than merely similar — otherwise the pass is moving things
  // for reasons it cannot justify.
  const input = {
    nodes: [
      { id: 'a1', container: 'G1', w: 200, h: 40 },
      { id: 'a2', container: 'G1', w: 200, h: 40 },
      { id: 'a3', container: 'G1', w: 200, h: 40 }
    ],
    containers: [{ id: 'G1', parent: null, label: 'G1' }],
    links: [{ from: 'a1', to: 'a2' }, { from: 'a2', to: 'a3' }]
  }
  t.alike(layout(input, { passes: 2 }), layout(input, { passes: 1 }),
    'no external edges, no change')
})

test('layout: affinity sequencing puts sharers of a neighbour side by side', (t) => {
  // sequence: 'affinity' is off by default because the second pass beats it, but
  // it is the only tool for a board where positions cannot help, so it has to
  // keep working. Two of the five members share a target; they must end up
  // adjacent in the column once it wraps.
  const nodes = []
  for (let i = 1; i <= 8; i++) nodes.push({ id: 'm' + i, container: 'G1', w: 200, h: 90 })
  nodes.push({ id: 'hub', container: null, w: 200, h: 40 })
  nodes.push({ id: 'other', container: null, w: 200, h: 40 })

  const input = {
    nodes,
    containers: [{ id: 'G1', parent: null, label: 'G1' }],
    // m1 and m8 are the pair: nothing else touches `hub`.
    links: [
      { from: 'm1', to: 'hub' }, { from: 'm8', to: 'hub' },
      { from: 'm3', to: 'other' }, { from: 'm4', to: 'other' }, { from: 'm5', to: 'other' }
    ]
  }

  // passes: 1 so the only thing at work is the sequencing.
  const off = layout(input, { passes: 1, sequence: 'none' })
  const on = layout(input, { passes: 1, sequence: 'affinity' })

  const slot = (out) => {
    const placed = out.nodes.filter(n => n.id.startsWith('m'))
      .sort((a, b) => (a.x - b.x) || (a.y - b.y))
    const index = new Map()
    placed.forEach((n, i) => index.set(n.id, i))
    return index
  }
  const a = slot(off)
  const b = slot(on)

  t.ok(Math.abs(a.get('m1') - a.get('m8')) > 1,
    'by id the pair is ' + Math.abs(a.get('m1') - a.get('m8')) + ' slots apart')
  t.is(Math.abs(b.get('m1') - b.get('m8')), 1, 'affinity puts them next to each other')
})