clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

main
diagramzip / renderers / edge / test / trn.test.ts
trn.test.ts 9.7K
1import { describe, expect, it } from 'vitest'2import zenith from '../../../examples/diagrams/zenith.trn?raw'3import marshmallows from '../../../examples/diagrams/marshmallows.trn?raw'4import { trnAdapter } from '../src/adapters/trn'5import { parseTrn } from '../src/languages/trn'6import type { RenderRequest } from '../src/runtime/types'78function request(source: string, options: Record<string, string> = {}): RenderRequest {9 return {10 engine: 'trn',11 source,12 format: 'svg',13 options,14 metadata: { title: '', description: '' },15 presentation: { background: '', padding: 0, frame: false },16 }17}1819describe('TRN language', () => {20 it('parses the full Zenith tree and infers its single root', () => {21 const document = parseTrn(zenith)2223 expect(document.ingredients.size).toBe(22)24 expect(document.outcomes.size).toBe(10)25 expect(document.layout).toBe('individual')26 expect(document.roots.map(root => root.id)).toEqual(['zenith'])27 expect(document.outcomes.get('zenith')?.stages[0]?.inputs).toHaveLength(10)28 expect(document.outcomes.get('true_nights_edge')?.label).toBe("True Night's Edge")29 })3031 it('renders ingredients as rows and operations as spanning table cells', async () => {32 const result = await trnAdapter.render(request(zenith.replace('.layout individual', '.layout combined')), new AbortController().signal)3334 expect(result.body).toContain('<title>Zenith TRN</title>')35 expect(result.body).toContain('data-outcome-id="terra_blade"')36 expect(result.body).toContain('class="trn-ingredient"')37 expect(result.body).toContain('class="trn-operation-zone"')38 expect(result.body).toContain('data-column="6"')39 expect(result.body).toContain('class="trn-zone-shape')40 expect(result.body).toContain('transform="rotate(90')41 expect(result.body).not.toContain('Moon Lord drop')42 })4344 it('flattens outcomes into dependency-ordered individual recipe tables', async () => {45 const result = await trnAdapter.render(request(zenith), new AbortController().signal)46 const recipes = [...result.body.matchAll(/class="trn-individual-recipe" data-recipe-id="([^"]+)"/g)]47 .map(match => match[1])4849 expect(result.body).toContain('data-layout="individual"')50 expect(recipes).toHaveLength(10)51 expect(recipes.slice(0, 2)).toEqual(['copper_shortsword', 'lights_bane'])52 expect(recipes.at(-1)).toBe('zenith')53 expect(result.body).not.toContain('data-recipe-id="chlorophyte_bars"')54 expect(result.body).toContain('>smelt 24 Chlorophyte Bars</text>')55 expect(result.body).toContain('data-recipe-id="zenith" data-depth="4"')56 expect(result.body.match(/class="trn-recipe-link"/g)).toHaveLength(9)57 expect(result.body.match(/data-route="hallway"/g)).toHaveLength(2)58 })5960 it('validates the source layout directive and rejects renderer options', () => {61 expect(() => parseTrn(zenith.replace('.layout individual', '.layout grid')))62 .toThrow(/Expected '.layout combined' or '.layout individual'/)63 expect(() => parseTrn('.layout combined\n.layout individual\ningredient flour\noutcome bread {\n + flour\n -> bake\n}'))64 .toThrow(/may only appear once/)65 expect(() => parseTrn('ingredient flour\n.layout individual\noutcome bread {\n + flour\n -> bake\n}'))66 .toThrow(/must appear before recipe declarations/)67 expect(() => trnAdapter.render(request(zenith, { color: 'blue' }), new AbortController().signal))68 .toThrow(/Unsupported TRN option: color/)69 })7071 it('renders the reference marshmallow recipe with its preparation row', async () => {72 const document = parseTrn(marshmallows)73 const result = await trnAdapter.render(request(marshmallows), new AbortController().signal)7475 expect(document.instructions.map(item => item.text)).toEqual(['Grease 9x13-in. pan and powder with powdered sugar'])76 expect(document.layout).toBe('combined')77 expect(document.roots[0]).toMatchObject({ id: 'marshmallows', portion: 'about 40 large marshmallows' })78 expect(result.body).toContain('Marshmallows (yields about 40 large marshmallows)')79 expect(result.body).toContain('class="trn-instruction-cell"')80 expect(result.body).toContain('mix until marshmallow has fluffed up')81 expect(result.body).toContain('data-outcome-id="marshmallows" data-column="7"')82 const colors = [...result.body.matchAll(/class="trn-operation-zone"[^>]*data-branch-color="(\d)"/g)].map(match => match[1])83 expect(colors).toHaveLength(8)84 const touchingPairs: Array<[number, number]> = [[0, 1], [0, 2], [1, 2], [1, 3], [2, 3], [3, 4], [4, 5], [5, 6], [6, 7]]85 for (const [left, right] of touchingPairs) {86 expect(colors[left]!).not.toBe(colors[right]!)87 }88 })8990 it('rejects unknown values and dependency cycles with source locations', () => {91 expect(() => parseTrn('outcome bread {\n + flour\n -> mix\n}')).toThrow(/Line 2: Unknown value 'flour'/)92 expect(() => parseTrn('outcome a {\n + b\n -> make\n}\noutcome b {\n + a\n -> make\n}')).toThrow(/Outcome cycle/)93 expect(() => parseTrn('ingredient flour\noutcome bread {\n + flour\n -> bake {portion}\n}')).toThrow(/Line 4: Action in 'bread' uses \{portion\}, but the outcome has no portion/)94 })9596 it('supports sequential steps and interpolates optional portions in actions', async () => {97 const source = 'ingredient flour\noutcome bread portion 1 loaf {\n + flour 500 g\n -> mix\n -> bake {portion}\n}'98 const document = parseTrn(source)99 const result = await trnAdapter.render(request(source), new AbortController().signal)100101 expect(document.outcomes.get('bread')).toMatchObject({ portion: '1 loaf' })102 expect(document.outcomes.get('bread')?.stages).toHaveLength(2)103 expect(result.body).toContain('>bake 1 loaf</text>')104 expect(result.body).not.toContain('{portion}')105 })106107 it('expands row height to fit a vertical action phrase without shrinking it', async () => {108 const source = `ingredient ore109outcome blade {110 + ore111 -> forge a finished copper shortsword112}`113 const result = await trnAdapter.render(request(source), new AbortController().signal)114 const ingredientCell = result.body.match(/class="trn-ingredient-cell"[^>]*height="(\d+)"/)115116 expect(Number(ingredientCell?.[1])).toBeGreaterThan(28)117 expect(result.body).toContain('font-size="10"')118 expect(result.body).toContain('>forge a finished copper shortsword</text>')119 })120121 it('wraps long instructions and expands the table before recipe rows begin', async () => {122 const source = `instruction "Position a rack in the center of the oven, preheat thoroughly, and grease the pan all the way into the corners before dusting it lightly with flour."123ingredient flour124outcome cake {125 + flour 2 cups126 -> bake127}`128 const result = await trnAdapter.render(request(source), new AbortController().signal)129 const instructionCell = result.body.match(/class="trn-instruction-cell"[^>]*y="([\d.]+)"[^>]*width="([\d.]+)"[^>]*height="([\d.]+)"/)130 const ingredientCell = result.body.match(/class="trn-ingredient-cell"[^>]*y="([\d.]+)"/)131 const lines = [...result.body.matchAll(/class="trn-instruction-line"/g)]132133 expect(lines.length).toBeGreaterThan(1)134 expect(Number(instructionCell?.[3])).toBeGreaterThan(28)135 expect(Number(ingredientCell?.[1])).toBe(Number(instructionCell?.[1]) + Number(instructionCell?.[3]))136 expect(result.body).toContain('<title>Position a rack in the center of the oven')137 expect(result.body).toContain('>the corners before dusting it</tspan>')138 expect(result.body).toContain('>lightly with flour.</tspan>')139 })140141 it('breaks a single instruction token that is wider than the table', async () => {142 const source = `instruction "supercalifragilisticexpialidocioussupercalifragilisticexpialidocious"143ingredient flour144outcome cake {145 + flour146 -> bake147}`148 const result = await trnAdapter.render(request(source), new AbortController().signal)149150 expect(result.body.match(/class="trn-instruction-line"/g)?.length).toBeGreaterThan(1)151 expect(result.body).not.toContain('…')152 })153154 it('merges waiting cells and grows operations across the inputs they consume', async () => {155 const source = `ingredient gelatin156ingredient water157ingredient sugar158ingredient syrup159ingredient salt160ingredient vanilla161ingredient coating162163outcome bloomed {164 + gelatin165 + water166 -> soak167}168169outcome cooked {170 + sugar171 + syrup172 -> boil173}174175outcome marshmallows {176 + bloomed177 + cooked178 -> drizzle179 + salt180 -> mix181 + vanilla182 -> cool183 -> cut184 + coating185 -> powder186}`187 const result = await trnAdapter.render(request(source), new AbortController().signal)188189 expect(result.body).not.toContain('class="trn-flow-cell"')190 expect(result.body).toContain('data-outcome-id="marshmallows" data-column="3" data-cell-count="7" data-arm-count="1"')191 expect(result.body).toContain('data-outcome-id="marshmallows" data-column="4" data-cell-count="9" data-arm-count="1"')192 expect(result.body).toContain('data-outcome-id="marshmallows" data-column="6" data-cell-count="12" data-arm-count="1"')193 expect(result.body).toContain('height="196"')194 })195196 it('places prerequisite outcomes before direct ingredients in FIFO order', async () => {197 const source = `ingredient remaining_water "remaining 2½ c lukewarm water"198ingredient starter_water "½ c lukewarm water"199ingredient yeast "2 pkt yeast"200201outcome activated_yeast {202 + starter_water203 + yeast204 -> sprinkle yeast over water205}206207outcome bagels portion 16 bagels {208 + remaining_water209 + activated_yeast210 -> combine211}`212 const result = await trnAdapter.render(request(source), new AbortController().signal)213 const rows = [...result.body.matchAll(/class="trn-ingredient" data-value-id="([^"]+)"/g)]214 .map(match => match[1])215216 expect(rows).toEqual(['starter_water', 'yeast', 'remaining_water'])217 expect(result.body).toContain('data-outcome-id="bagels" data-column="2" data-cell-count="4" data-arm-count="1"')218 })219})