1import { describe, expect, it } from 'vitest'2import orderNine from '../../../examples/diagrams/order-nine.squaring?raw'3import antiWedge from '../../../examples/diagrams/anti-wedge.squaring?raw'4import compound from '../../../examples/diagrams/compound.squaring?raw'5import stoneSketch from '../../../examples/diagrams/stone-sketch.squaring?raw'6import cubeSearch from '../../../examples/diagrams/cube-search.squaring?raw'7import gap from '../../../examples/diagrams/gap.squaring?raw'8import { squaringAdapter } from '../src/adapters/squaring'9import { parseSquaring } from '../src/languages/squaring'10import { buildSquaring } from '../src/languages/squaring-model'11import { POLYHEDRA } from '../src/languages/squaring-polyhedra'12import type { RenderRequest } from '../src/runtime/types'1314function request(source: string, options: Record<string, string> = {}): RenderRequest {15 return {16 engine: 'squaring',17 source,18 format: 'svg',19 options,20 metadata: { title: '', description: '' },21 presentation: { background: '', padding: 0, frame: false },22 }23}2425const render = (source: string, options: Record<string, string> = {}) => squaringAdapter.render(request(source, options), new AbortController().signal)26const build = (source: string) => buildSquaring(parseSquaring(source))2728function expectTiling(model: ReturnType<typeof build>): void {29 expect(model.squares.reduce((sum, square) => sum + square.side * square.side, 0)).toBe(model.width * model.height)30 for (const first of model.squares) {31 for (const second of model.squares) {32 if (first === second) continue33 const overlap = first.x < second.x + second.side && second.x < first.x + first.side && first.y < second.y + second.side && second.y < first.y + first.side34 expect(overlap).toBe(false)35 }36 }37}3839describe('squaring language', () => {40 it('parses a Bouwkamp rectangle with grouped, bare, and commented square lists', () => {41 const document = parseSquaring(orderNine)4243 expect(document.view).toBe('both')44 expect(document.labels).toBe('sides')45 expect(document.title).toBe('Order 9 simple perfect squared rectangle')46 expect(document.rectangle).toMatchObject({ width: 33, height: 32, symbolic: false })47 expect(document.rectangle?.groups.map(group => group.sides.map(side => side.constant))).toEqual([[18, 15], [7, 8], [14, 4], [10, 1], [9]])48 expect(document.network).toBeNull()49 expect(parseSquaring('rectangle 3x2\n2, 1, 1 # trailing comment').rectangle?.groups[0]?.sides.map(side => side.text)).toEqual(['2', '1', '1'])50 })5152 it('parses sketches with expressions, unknown dimensions, and under clauses', () => {53 const document = parseSquaring('rectangle ? x 10\n(a 2a+b)\n(b) under a\n(3) under #2 b')54 expect(document.rectangle).toMatchObject({ width: null, height: 10, symbolic: true })55 const groups = document.rectangle?.groups ?? []56 expect(groups[0]?.sides[1]).toMatchObject({ text: '2a+b', terms: { a: 2, b: 1 }, constant: 0 })57 expect(groups[1]?.under).toEqual(['a'])58 expect(groups[2]?.under).toEqual(['#2', 'b'])59 expect(parseSquaring('rectangle 5 x 5\n(3 ?)').rectangle?.groups[0]?.sides[1]?.auto).toBe(true)60 })6162 it('parses a network with a battery, wire lines, chains, faces, and polyhedra', () => {63 const document = parseSquaring(antiWedge)64 expect(document.rectangle).toBeNull()65 expect(document.network?.positive).toBe('a')66 expect(document.network?.negative).toBe('e')67 expect(document.network?.nodes).toEqual(['a', 'e', 'b', 'c', 'd', 'f'])68 expect(document.network?.wires).toHaveLength(9)6970 const faces = parseSquaring('battery any\nface a b c\nface a c d\nface a d b\nface b d c')71 expect(faces.network?.searchBattery).toBe(true)72 expect(faces.network?.wires).toHaveLength(6)7374 const cube = parseSquaring(cubeSearch)75 expect(cube.network?.wires).toHaveLength(12)76 expect(cube.network?.nodes).toHaveLength(8)77 })7879 it('rejects mixed, incomplete, and malformed sources with line numbers', () => {80 expect(() => parseSquaring('squares 1 2')).toThrow('Line 1: Declare the rectangle')81 expect(() => parseSquaring('rectangle 3 x 2\nsquares 2 1 1\nbattery a b')).toThrow('Line 3: A squaring is either a rectangle')82 expect(() => parseSquaring('a - b')).toThrow('Line 1: A network needs a battery')83 expect(() => parseSquaring('battery a b\nc - d')).toThrow('Battery node "a" must be connected')84 expect(() => parseSquaring('.view sideways\nrectangle 3 x 2\n2 1 1')).toThrow('Line 1: Unknown view "sideways"')85 expect(() => parseSquaring('rectangle 3 x 2\n2 1 1\nbogus line')).toThrow('Line 3: Unrecognized line')86 expect(() => parseSquaring('rectangle ? x ?\n(a b)\n(c d)')).toThrow('Line 3: In a sketch with named sides, every group after the first needs "under"')87 expect(() => parseSquaring('battery a b\npolyhedron teapot')).toThrow('Unknown polyhedron "teapot"')88 expect(() => parseSquaring('rectangle 3 x 2\n(a wire)')).toThrow('is a keyword')89 })90})9192describe('polyhedron presets', () => {93 it('define connected graphs with the expected edge counts', () => {94 const expected: Record<string, [number, number]> = {95 tetrahedron: [4, 6], cube: [8, 12], octahedron: [6, 12], dodecahedron: [20, 30], icosahedron: [12, 30],96 'triangular-prism': [6, 9], 'square-pyramid': [5, 8], 'square-antiprism': [8, 16], 'tetragonal-antiwedge': [6, 10],97 }98 for (const [name, [vertices, edges]] of Object.entries(expected)) {99 const preset = POLYHEDRA[name]?.()100 expect(preset?.vertices, name).toHaveLength(vertices)101 expect(preset?.edges, name).toHaveLength(edges)102 }103 })104})105106describe('squaring model', () => {107 it('places Bouwkamp squares and derives the Smith diagram from horizontal segments', () => {108 const model = build(orderNine)109110 expect(model).toMatchObject({ form: 'rectangle', width: 33, height: 32, order: 9, complete: true, simple: true, perfect: true, repeatedSides: [], blocks: [] })111 expect(model.squares.map(square => [square.side, square.x, square.y])).toEqual([112 [18, 0, 0], [15, 18, 0], [7, 18, 15], [8, 25, 15], [14, 0, 18], [4, 14, 18], [10, 14, 22], [1, 24, 22], [9, 24, 23],113 ])114 expect(model.nodes.map(node => [node.id, node.voltage, node.x0, node.x1])).toEqual([115 ['top', 32, 0, 33], ['n1', 17, 18, 33], ['n2', 14, 0, 18], ['n3', 10, 14, 25], ['n4', 9, 24, 33], ['bottom', 0, 0, 33],116 ])117 for (const node of model.nodes.slice(1, -1)) {118 const inflow = model.squares.filter(square => square.bottom === node.index).reduce((sum, square) => sum + square.side, 0)119 const outflow = model.squares.filter(square => square.top === node.index).reduce((sum, square) => sum + square.side, 0)120 expect(inflow).toBe(outflow)121 }122 })123124 it('renders partial dissections instead of rejecting them', () => {125 const stopped = build('rectangle 33 x 32\n15 18 7 8 14 4 10 1 9')126 expect(stopped.complete).toBe(false)127 expect(stopped.problem).toContain('square 7 (side 10) does not fit at column 0, row 22')128 expect(stopped.squares).toHaveLength(6)129 expect(stopped.nodes).toEqual([])130131 const unfilled = build('rectangle 33 x 32\n18 15 7 8')132 expect(unfilled.complete).toBe(false)133 expect(unfilled.problem).toContain('do not fill the rectangle')134135 const auto = build(gap)136 expect(auto.squares.map(square => square.side)).toEqual([18, 15, 7, 8, 14, 4])137 expect(auto.complete).toBe(false)138139 const inferred = build('rectangle ? x ?\n(18 15) (7 8) (14 4) (10 1) (9)')140 expect(inferred).toMatchObject({ width: 33, height: 32, complete: true, simple: true, perfect: true })141 })142143 it('solves a Stone sketch with unknown sides into the order-9 squaring', () => {144 const model = build(stoneSketch)145 expect(model).toMatchObject({ width: 33, height: 32, order: 9, complete: true, simple: true, perfect: true })146 expect(model.squares.map(square => square.side)).toEqual([18, 15, 7, 8, 14, 4, 10, 1, 9])147 expect(model.squares[0]?.label).toBe('a=18')148 expectTiling(model)149150 const pinned = build('rectangle ? x ?\n(a 15)\n(c d) under 15\n(e f) under a\n(g h) under f c\n(i) under h d')151 expect(pinned.width).toBe(33)152153 // Four equal squares: the sketch closes as a 2 × 2 block.154 expect(build('rectangle ? x ?\n(a b)\n(c) under a\n(d) under b')).toMatchObject({ width: 2, height: 2, complete: true, perfect: false })155 expect(() => build('rectangle ? x ?\n(a 16)\n(c d) under 16\n(e f) under a\n(g h) under f c\n(i) under h d')).toThrow(/whole number|cannot close|closes only/)156 expect(() => build('rectangle ? x ?\n(a b)\n(c) under b\n(d) under c a')).toThrow(/every side is zero|closes only|cannot close/)157 expect(() => build('rectangle ? x ?\n(a 1 1)\n(c) under 1')).toThrow('ambiguous')158 expect(() => build('rectangle ? x ?\n(a b)\n(c) under a\n(d) under a')).toThrow('already has a group under it')159 })160161 it('solves a polyhedral network into an integer squared rectangle', () => {162 const model = build(antiWedge)163164 expect(model).toMatchObject({ form: 'network', width: 15, height: 11, order: 9, simple: true, perfect: false, repeatedSides: [1, 4, 5, 6] })165 expect(model.nodes.find(node => node.id === 'a')?.voltage).toBe(11)166 expect(model.nodes.find(node => node.id === 'e')?.voltage).toBe(0)167 for (const square of model.squares) {168 expect((model.nodes[square.top]?.voltage ?? 0) - (model.nodes[square.bottom]?.voltage ?? 0)).toBe(square.side)169 }170 expectTiling(model)171 })172173 it('tries every wire as the battery and keeps the best candidate', () => {174 const model = build(cubeSearch)175 expect(model.search).toHaveLength(12)176 expect(model.searchIndex).toBeGreaterThanOrEqual(0)177 expect(model.search?.every(candidate => candidate.error === null)).toBe(true)178 expect(model).toMatchObject({ order: 11, complete: true })179 expectTiling(model)180181 const wedge = build('polyhedron tetragonal-antiwedge\nbattery any')182 expect(wedge.search).toHaveLength(10)183 expect(wedge.simple).toBe(true)184 })185186 it('finds compound blocks and repeated sides', () => {187 const model = build(compound)188 expect(model).toMatchObject({ order: 10, simple: false, perfect: true })189 expect(model.blocks).toEqual([{ x: 32, y: 0, width: 33, height: 32, squares: 9 }])190191 const quilt = build('rectangle 3 x 2\n2 1 1')192 expect(quilt.simple).toBe(false)193 expect(quilt.blocks).toEqual([{ x: 2, y: 0, width: 1, height: 2, squares: 2 }])194 expect(quilt.repeatedSides).toEqual([1])195 })196197 it('explains why a network cannot become a squaring', () => {198 expect(() => build('battery a b\na - b\nc - d')).toThrow('not connected')199 expect(() => build('battery a c\na - b - c\na - d - c\nb - d')).toThrow('carries no current')200 expect(() => build('battery n1 n4\nn1 - n5\nn1 - n6\nn2 - n4\nn2 - n5\nn2 - n6\nn3 - n4\nn3 - n5\nn3 - n6\nn2 - n5')).toThrow('cannot be arranged')201 const cube = build('battery a b\na - b - c - d - a\ne - f - g - h - e\na - e\nb - f\nc - g\nd - h')202 expect(cube).toMatchObject({ width: 24, height: 14, order: 12, simple: false })203 expect(cube.squares[0]).toMatchObject({ side: 14, x: 0, y: 0 })204 })205})206207describe('squaring renderer', () => {208 it('renders the rectangle and the Smith diagram side by side', async () => {209 const result = await render(orderNine)210211 expect(result.engineVersion).toBe('diagramzip-squaring@3')212 expect(result.body).toContain('<title>Order 9 simple perfect squared rectangle</title>')213 expect(result.body).toContain('data-view="both" data-form="rectangle" data-order="9" data-width="33" data-height="32" data-complete="true" data-simple="true" data-perfect="true"')214 expect(result.body.match(/class="squaring-square"/g)).toHaveLength(9)215 expect(result.body.match(/class="squaring-wire"/g)).toHaveLength(9)216 expect(result.body.match(/class="squaring-node"/g)).toHaveLength(6)217 expect(result.body).toContain('class="squaring-wire" data-from="top" data-to="n2" data-current="18"')218 expect(result.body).toContain('class="squaring-node" data-node-id="n1" data-voltage="17"')219 expect(result.body).toContain('class="squaring-battery"')220 expect(result.body).toContain('Order 9 | 33 × 32 | simple | perfect')221 expect(result.body).not.toContain('class="squaring-block"')222 expect(result.body).not.toContain('squaring-hatch')223 })224225 it('draws incomplete dissections with hatched gaps and no circuit', async () => {226 const result = await render(gap)227 expect(result.body).toContain('data-view="rectangle"')228 expect(result.body).toContain('data-complete="false"')229 expect(result.body).toContain('class="squaring-gap"')230 expect(result.body).toContain('id="squaring-hatch"')231 expect(result.body).not.toContain('class="squaring-wire"')232 expect(result.body).toContain('incomplete: the squares do not fill the rectangle')233 })234235 it('lists every battery candidate under a searched network', async () => {236 const result = await render(cubeSearch)237 expect(result.body).toContain('class="squaring-search" data-candidates="12"')238 expect(result.body).toContain('best of 12 choices')239 expect(result.body).toContain('◀')240 })241242 it('outlines compound blocks in the overlay view and honours label and view directives', async () => {243 const overlay = await render(compound)244 expect(overlay.body).toContain('data-view="overlay"')245 expect(overlay.body).toContain('class="squaring-block" data-squares="9"')246 expect(overlay.body).toContain('compound (1 block outlined) | perfect')247 expect(overlay.body).toContain('data-overlay="true"')248249 const plain = await render('.view rectangle\n.labels none\nrectangle 3 x 2\n2 1 1')250 expect(plain.body).toContain('data-view="rectangle"')251 expect(plain.body).not.toContain('class="squaring-wire"')252 expect(plain.body).not.toContain('class="squaring-square-label"')253 expect(plain.body).toContain('<title>Squared rectangle 3 × 2</title>')254255 const circuit = await render(antiWedge.replace('.view both', '.view circuit'))256 expect(circuit.body).toContain('data-view="circuit"')257 expect(circuit.body).not.toContain('class="squaring-square"')258 expect(circuit.body).toContain('battery a → e')259 })260261 it('rejects renderer options', () => {262 expect(() => render(orderNine, { scale: '2' })).toThrow('Unsupported squaring option: scale.')263 })264})