OverviewFilesCommitsIssuesDocsReleasesPeers

codetree / test / relations.test.js

123 lines · javascript ref:
import { test } from 'brittle'
import { relationRings, buildRelationView } from '../lib/floor/cards.js'

// The relation view: G replaces the board with the focus and what it is
// connected to, laid out so that the COLUMNS mean hop distance and direction —
// what feeds it on the left, what it feeds on the right. A smaller board would
// not answer "how is this connected"; a ringed one does.
//
// Links read supplier -> consumer everywhere in this codebase, so `from` is the
// dependency and `to` is the dependent.

function board () {
  //   up2 -> up1 -> F -> down1 -> down2
  //   sib  <- up1            (a sibling: shares a supplier with F)
  //   lone (nothing)
  return {
    cards: [
      { id: 'F', container: 'cat:a', sub: 'the focus' },
      { id: 'up1', container: 'cat:a', sub: '' },
      { id: 'up2', container: 'cat:b', sub: '' },
      { id: 'down1', container: 'cat:b', sub: '' },
      { id: 'down2', container: 'cat:c', sub: '' },
      { id: 'sib', container: 'cat:c', sub: '' },
      { id: 'lone', container: 'cat:c', sub: '' }
    ],
    links: [
      { from: 'up2', to: 'up1' }, { from: 'up1', to: 'F' },
      { from: 'F', to: 'down1' }, { from: 'down1', to: 'down2' },
      { from: 'up1', to: 'sib' }
    ],
    containers: [
      { id: 'cat:a', parent: null, label: 'a' },
      { id: 'cat:b', parent: null, label: 'b' },
      { id: 'cat:c', parent: null, label: 'c' }
    ]
  }
}

test('relations: hop and SIDE, upstream left and downstream right', (t) => {
  const r = relationRings(board().links, 'F', 1)
  t.alike([...r.keys()].sort(), ['F', 'down1', 'up1'], 'one hop each way')
  t.alike(r.get('F'), { hop: 0, side: 'focus' }, 'the focus')
  t.alike(r.get('up1'), { hop: 1, side: 'up' }, 'what F depends on is upstream')
  t.alike(r.get('down1'), { hop: 1, side: 'down' }, 'what depends on F is downstream')
})

test('relations: a sibling keeps the side of its FIRST step', (t) => {
  // sib is reached F <- up1 -> sib: two hops, and the first step went upstream.
  // Calling that "2 hops upstream" is truthful about how it is related, which is
  // the whole point of the view.
  const r = relationRings(board().links, 'F', 2)
  t.alike(r.get('sib'), { hop: 2, side: 'up' }, 'sibling via a shared supplier')
  t.alike(r.get('up2'), { hop: 2, side: 'up' }, 'and the real 2-hop dependency')
  t.alike(r.get('down2'), { hop: 2, side: 'down' })
  t.absent(r.get('lone'), 'an unconnected node is not in the view')
})

test('relations: depth bounds it, and a cycle terminates', (t) => {
  const links = [{ from: 'a', to: 'b' }, { from: 'b', to: 'c' }, { from: 'c', to: 'a' }]
  t.is(relationRings(links, 'a', 1).size, 3, 'a cycle reaches everything in one hop each way')
  t.is(relationRings(links, 'a', 9).size, 3, 'and does not spin')
  t.is(relationRings(board().links, 'F', 0).size, 1, 'depth 0 is the focus alone')
})

test('relations: the view keeps ONLY the connected set', (t) => {
  const b = board()
  const v = buildRelationView(b, 'F', 1)

  t.alike(v.cards.map(c => c.id).sort(), ['F', 'down1', 'up1'], 'three cards')
  t.alike(v.links.map(l => l.from + '->' + l.to).sort(), ['F->down1', 'up1->F'],
    'and only the links between them')

  // What is NOT shown has to be counted, or the view lies about being the graph.
  t.is(v.hidden.nodes, 4, 'lone, sib, up2 and down2 are out of view')
  t.is(v.hidden.links, 3, 'and three links with an endpoint outside')
})

test('relations: one container per ring, ranked so columns read left to right', (t) => {
  const v = buildRelationView(board(), 'F', 2)
  const byId = new Map(v.containers.map(c => [c.id, c]))

  t.ok(byId.has('ring:up:2'), 'a ring for 2 hops upstream')
  t.ok(byId.has('ring:up:1'))
  t.ok(byId.has('ring:focus'))
  t.ok(byId.has('ring:down:1'))
  t.ok(byId.has('ring:down:2'))
  t.is(v.containers.length, 5, 'and nothing else — the categories are gone')

  // Ranks are what put them in order; the layout no longer has to infer it.
  const rank = (id) => byId.get(id).rank
  t.is(rank('ring:up:2'), 0, 'furthest upstream is the leftmost column')
  t.is(rank('ring:up:1'), 1)
  t.is(rank('ring:focus'), 2, 'the focus sits between the two sides')
  t.is(rank('ring:down:1'), 3)
  t.is(rank('ring:down:2'), 4)

  const card = new Map(v.cards.map(c => [c.id, c]))
  t.is(card.get('sib').container, 'ring:up:2', 'the sibling is in the 2-hop upstream ring')
  t.is(card.get('F').container, 'ring:focus')
})

test('relations: with no upstream at all the focus is still the leftmost', (t) => {
  const v = buildRelationView(board(), 'up2', 1)
  const byId = new Map(v.containers.map(c => [c.id, c]))
  t.is(byId.get('ring:focus').rank, 0, 'nothing upstream, so the focus starts the row')
  t.is(byId.get('ring:down:1').rank, 1)
})

test('relations: a card says how many of its links are off-view', (t) => {
  const v = buildRelationView(board(), 'F', 1)
  const card = new Map(v.cards.map(c => [c.id, c]))
  // up1 has TWO links out of a depth-1 view: up2 supplies it, and it supplies
  // sib. Both directions count — an off-view link is an off-view link.
  t.ok(/2 off-view/.test(card.get('up1').sub), 'up1: ' + card.get('up1').sub)
  t.absent(/off-view/.test(card.get('F').sub), 'F has none: ' + card.get('F').sub)
})

test('relations: a focus that is not on the board yields nothing, not a throw', (t) => {
  const v = buildRelationView(board(), 'nope', 2)
  t.is(v.cards.length, 0, 'no cards')
  t.is(v.containers.length, 0, 'and no empty ring boxes either')
})