// FILIUS' „Exportieren“ (GUIContainer.createNetworkImage), against Java's own // print panel (tools/java-oracle's ExportRun on four projects and an empty // one, networkImage.java.json): the part of the surface the image shows and // where its footer lies, from the bounds of Java's own elements and footer // (their sizes depend on the font of the machine Java runs on); Java's // cable bounds; the colours Java paints (a transparent background, the // footer light grey, a rectangle as the design shows it); and the painting // itself on a recording context. import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import type { DocumentationView, TopologyView } from '@mdg-filius/doc'; import { rectangleBorder, rectangleFill } from '../src/canvas/documentationPaint.js'; import { cableControl, cableFrame, deviceBox, JAVA_LABEL_FONT, JAVA_LABEL_FONT_HEIGHT, type IconName } from '../src/canvas/layout.js'; import { CABLE_COLOR, exportLayout, exportNames, FOOTER_COLOR, LABEL_COLOR, measureNetworkImage, networkParts, paintNetworkImage, WIRELESS_COLOR, wrapLines, type Painter, } from '../src/canvas/networkImage.js'; type Box = [number, number, number, number]; interface JavaProject { readonly nodes: readonly { readonly name: string; readonly bounds: Box }[]; readonly cables: readonly Box[]; readonly docs: readonly { readonly type: number; readonly bounds: Box; readonly color: string }[]; readonly footer: { readonly text: string; readonly bounds: Box; readonly stringWidth: number; readonly fontHeight: number; readonly foreground: string }; readonly clip: Box; readonly imageSize: [number, number]; readonly cornerPixel: string; readonly footerColours: readonly [string, number][]; readonly rectangleCentre?: readonly [string, number][]; readonly rectangleCentreColor?: string; } const java = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'networkImage.java.json'), 'utf8')) as { readonly projects: Readonly>; readonly empty: JavaProject; }; const rect = ([x, y, width, height]: Box) => ({ x, y, width, height }); const rgb = (text: string) => { const [red, green, blue, alpha] = text.split(',').map(Number); return `rgb(${red}, ${green}, ${blue})${alpha === 255 ? '' : ` alpha ${alpha}`}`; }; describe("the exported image's layout, as Java's print panel", () => { it.each(Object.keys(java.projects))('cuts %s as Java does: 10 around, the footer 20 below the lowest element, 20 below the footer', (name) => { const project = java.projects[name]!; // Java's footer: its words and 20 more wide, its font high. expect(project.footer.bounds[2]).toBe(project.footer.stringWidth + 20); expect(project.footer.bounds[3]).toBe(project.footer.fontHeight); expect(project.footer.text).toBe(''); const parts = [...project.nodes.map((node) => rect(node.bounds)), ...project.cables.map(rect), ...project.docs.map((doc) => rect(doc.bounds))]; const layout = exportLayout(parts, { width: project.footer.bounds[2], height: project.footer.bounds[3] }); // Java's panel holds the elements 10 to the right and down (EMPTY_BORDER); its clip is in the panel's terms. const [x, y, width, height] = project.clip; expect(layout.image).toEqual({ x: x - 10, y: y - 10, width, height }); expect(layout.footer).toEqual({ x: project.footer.bounds[0] - 10, y: project.footer.bounds[1] - 10, width: project.footer.bounds[2], height: project.footer.bounds[3] }); expect(project.imageSize).toEqual([width, height]); }); it('is as wide as the footer and 20 more where the footer is wider than the elements', () => { const layout = exportLayout([{ x: 50, y: 40, width: 100, height: 60 }], { width: 300, height: 17 }); expect(layout.footer).toEqual({ x: 50, y: 120, width: 300, height: 17 }); expect(layout.image).toEqual({ x: 40, y: 30, width: 330, height: 127 }); }); it("shows an empty project's footer alone (Java's image of 20 × 35 shows nothing: its footer overflows)", () => { expect(java.empty.imageSize).toEqual([20, 35]); expect(java.empty.footerColours).toEqual([]); expect(java.empty.footer.text).toBe('Filius'); const layout = exportLayout([], { width: 57, height: 17 }); expect(layout.footer).toEqual({ x: 0, y: 0, width: 57, height: 17 }); expect(layout.image).toEqual({ x: -10, y: -10, width: 87, height: 47 }); }); it("does not cut at the surface's edges, which the editor does not have", () => { expect(exportLayout([{ x: -40, y: -30, width: 20, height: 10 }], { width: 10, height: 17 }).image).toEqual({ x: -50, y: -40, width: 40, height: 77 }); }); it("takes Java's cable bounds: the two middles with 2 to spare", () => { const project = java.projects['nat_with_mapped_port.fls']!; const middles = project.nodes.map(({ bounds: [x, y, width, height] }) => ({ x: Math.trunc(x + 0.5 * width), y: Math.trunc(y + 0.5 * height) })); for (const cable of project.cables) { const matches = middles.flatMap((first, index) => middles.slice(index + 1).filter((second) => JSON.stringify(cableFrame(first, second)) === JSON.stringify(rect(cable)))); expect(matches.length, JSON.stringify(cable)).toBeGreaterThan(0); } expect(cableFrame({ x: 50, y: 5 }, { x: 10, y: 20 })).toEqual({ x: 8, y: 3, width: 44, height: 19 }); }); it("draws cables in JCablePanel's colours: dark grey (64), a wireless link light grey (192)", () => { expect([CABLE_COLOR, WIRELESS_COLOR]).toEqual(['rgb(64, 64, 64)', 'rgb(192, 192, 192)']); }); it("paints as Java's image does: transparent around, the footer light grey, a rectangle as the design shows it", () => { for (const project of [...Object.values(java.projects), java.empty]) expect(project.cornerPixel).toBe('0,0,0,0'); for (const project of Object.values(java.projects)) { expect(rgb(project.footer.foreground)).toBe(FOOTER_COLOR); expect(project.footerColours.map(([colour]) => colour)).toContain(project.footer.foreground); if (project.rectangleCentre === undefined) continue; const [red, green, blue] = project.rectangleCentreColor!.split(',').map((part) => Number(part) / 255); expect(rgb(project.rectangleCentre[0]![0])).toBe(rectangleFill({ red: red!, green: green!, blue: blue!, alpha: 1 }, false)); } }); }); describe('the words of a text in the image', () => { const measure = (text: string) => text.length * 10; it('keeps lines that fit, and its own lines, empty ones too', () => { expect(wrapLines('Netz A', 100, measure)).toEqual(['Netz A']); expect(wrapLines('eins\n\nzwei', 100, measure)).toEqual(['eins', '', 'zwei']); expect(wrapLines('', 100, measure)).toEqual(['']); }); it('breaks after a space, the space staying at the end of its line', () => { expect(wrapLines('Netz A und B', 80, measure)).toEqual(['Netz A ', 'und B']); // A trailing space does not count: "abcd efgh " fits in 90. expect(wrapLines('abcd efgh ijk', 90, measure)).toEqual(['abcd efgh ', 'ijk']); expect(wrapLines(' eingerückt', 200, measure)).toEqual([' eingerückt']); }); it('breaks after a tab as after a space', () => { expect(wrapLines('abc\tdefghij', 70, measure)).toEqual(['abc\t', 'defghij']); }); it('holds a character a line, where one is wider than the line', () => { expect(wrapLines('ab', 5, measure)).toEqual(['a', 'b']); }); it('breaks a word wider than the line where it reaches the edge', () => { expect(wrapLines('Vermittlungsrechner', 50, measure)).toEqual(['Vermi', 'ttlun', 'gsrec', 'hner']); expect(wrapLines('ab Vermittlung', 50, measure)).toEqual(['ab ', 'Vermi', 'ttlun', 'g']); }); }); describe("the image's names", () => { it("writes the project's name in the footer and names the image after it, as Java names it after its file", () => { expect(exportNames('nat_with_mapped_port.fls')).toEqual({ footer: 'nat_with_mapped_port.fls', file: 'nat_with_mapped_port.png' }); expect(exportNames('netz.v2.fls')).toEqual({ footer: 'netz.v2.fls', file: 'netz.v2.png' }); expect(exportNames('Netz')).toEqual({ footer: 'Netz', file: 'Netz.png' }); expect(exportNames('.fls')).toEqual({ footer: '.fls', file: '.fls.png' }); }); it('writes „Filius“ without a name, as Java does without a file', () => { expect(exportNames(undefined)).toEqual({ footer: 'Filius', file: 'Filius.png' }); expect(exportNames(' ')).toEqual({ footer: 'Filius', file: 'Filius.png' }); }); }); /** A 2D context that writes down what is painted, its text 7 wide a character, a font's ascent 0.9 of its size, its descent 0.25. */ function recorder() { const calls: unknown[][] = []; const state = { font: '10px sans-serif', fillStyle: '' as unknown, strokeStyle: '' as unknown, lineWidth: 1, textAlign: 'start', textBaseline: 'alphabetic' }; const size = () => Number(/(\d+)px/.exec(state.font)![1]); const record = (name: string) => (...args: unknown[]) => { calls.push([name, ...args]); }; const context = { save: record('save'), restore: record('restore'), translate: record('translate'), beginPath: record('beginPath'), moveTo: record('moveTo'), quadraticCurveTo: record('quadraticCurveTo'), roundRect: record('roundRect'), rect: record('rect'), clip: record('clip'), fill: () => calls.push(['fill', state.fillStyle]), stroke: () => calls.push(['stroke', state.strokeStyle, state.lineWidth]), fillText: (text: string, x: number, y: number) => calls.push(['fillText', text, x, y, state.font, state.fillStyle, state.textAlign]), measureText: (text: string) => ({ width: text.length * 7, fontBoundingBoxAscent: size() * 0.9, fontBoundingBoxDescent: size() * 0.25 }), drawImage: (image: unknown, x: number, y: number, width: number, height: number) => calls.push(['drawImage', image, x, y, width, height]), setLineDash: record('setLineDash'), }; for (const key of Object.keys(state) as (keyof typeof state)[]) { Object.defineProperty(context, key, { get: () => state[key], set: (value: never) => { state[key] = value; }, }); } return { context: context as unknown as Painter, calls }; } const grey = { red: 204 / 255, green: 204 / 255, blue: 204 / 255, alpha: 1 }; const doc = (fields: Partial & Pick): DocumentationView => ({ x: 0, y: 0, width: 100, height: 50, text: '', color: grey, font: 'Dialog', size: 12, bold: false, italic: false, order: 0, ...fields, }); const VIEW: TopologyView = { nodes: [ { id: 'a', kind: 'pc', name: 'A', label: 'Rechner A', x: 100, y: 80, cloud: false, ports: [] }, { id: 'b', kind: 'networkSwitch', name: 'S', label: 'Switch', x: 300, y: 200, cloud: false, ports: [] }, { id: 'c', kind: 'notebook', name: 'N', label: 'N', x: 500, y: 60, cloud: false, ports: [] }, ], links: [ { id: 'l1', sourceNode: 'a', sourcePort: 'p', targetNode: 'b', targetPort: 'q', wireless: false }, { id: 'l2', sourceNode: 'c', sourcePort: 'p', targetNode: 'b', targetPort: 'r', wireless: true }, // Its device gone (before the janitor): left out, as the surface leaves it. { id: 'l3', sourceNode: 'a', sourcePort: 'p', targetNode: 'gone', targetPort: 'r', wireless: false }, ], docs: [ doc({ id: 'r', kind: 'rectangle', x: 60, y: 40, width: 200, height: 120, color: { red: 1, green: 201 / 255, blue: 201 / 255, alpha: 1 } }), doc({ id: 't', kind: 'text', x: 70, y: 300, width: 64, height: 40, text: 'Netz A123 B', size: 18, italic: true, color: { red: 0, green: 0, blue: 0, alpha: 1 } }), ], }; const ICONS = Object.fromEntries(['server', 'laptop', 'switch', 'cloud', 'router', 'gateway', 'modem'].map((name) => [name, `icon:${name}`])) as unknown as Record; describe('painting the image', () => { const labelWidth = (text: string) => text.length * 7; it("counts the devices, the cables between them and the documentation, as Java's panels do", () => { const { boxes, parts } = networkParts(VIEW, labelWidth); const [a, b, c] = ['a', 'b', 'c'].map((id) => boxes.get(id)!); expect(a).toEqual(deviceBox(VIEW.nodes[0]!, labelWidth('Rechner A'))); expect(parts).toEqual([a, b, c, cableFrame(a!.center, b!.center), cableFrame(c!.center, b!.center), { x: 60, y: 40, width: 200, height: 120 }, { x: 70, y: 300, width: 64, height: 40 }]); }); it('measures the labels and the footer in Java’s font', () => { const { context } = recorder(); const { layout } = measureNetworkImage(context, VIEW, 'netz.fls'); expect(context.font).toBe(JAVA_LABEL_FONT); const { parts } = networkParts(VIEW, labelWidth); expect(layout).toEqual(exportLayout(parts, { width: 8 * 7 + 20, height: JAVA_LABEL_FONT_HEIGHT })); }); it('paints the documentation, then the cables, then the devices, then the footer, on the cut-out', () => { const { context, calls } = recorder(); const layout = paintNetworkImage(context, VIEW, ICONS, 'netz.fls'); const { boxes } = networkParts(VIEW, labelWidth); const [a, b, c] = ['a', 'b', 'c'].map((id) => boxes.get(id)!); const named = (name: string) => calls.filter((call) => call[0] === name); expect(calls[1]).toEqual(['translate', -layout.image.x, -layout.image.y]); // The rectangle as the design shows it: inset by 1, corners of 5, opaque and lighter, its border darker. const pink = VIEW.docs[0]!.color; expect(named('roundRect')).toEqual([['roundRect', 61, 41, 198, 118, 5]]); expect(named('fill')).toEqual([['fill', rectangleFill(pink, false)]]); expect(named('stroke')[0]).toEqual(['stroke', rectangleBorder(pink), 1]); // The text inside its frame, in its font, broken to its width less the padding (62: "Netz A123" is 63 wide). expect(named('rect')).toEqual([['rect', 70, 300, 64, 40]]); const words = named('fillText').filter((call) => String(call[4]).startsWith('italic')); expect(words).toEqual([ // A line the font's ascent and descent high (16.2 + 4.5). ['fillText', 'Netz ', 71, 301 + 16.2, 'italic 18px sans-serif', 'rgba(0, 0, 0, 1)', 'left'], ['fillText', 'A123 B', 71, 301 + 16.2 + 20.7, 'italic 18px sans-serif', 'rgba(0, 0, 0, 1)', 'left'], ]); // The cables: Java's curve, a wireless one grey and dashed; the one without its device left out. expect(named('moveTo')).toEqual([ ['moveTo', a!.center.x, a!.center.y], ['moveTo', c!.center.x, c!.center.y], ]); const [first, second] = [cableControl(a!.center, b!.center), cableControl(c!.center, b!.center)]; expect(named('quadraticCurveTo')).toEqual([ ['quadraticCurveTo', first.x, first.y, b!.center.x, b!.center.y], ['quadraticCurveTo', second.x, second.y, b!.center.x, b!.center.y], ]); expect(named('stroke').slice(1)).toEqual([ ['stroke', CABLE_COLOR, 2], ['stroke', WIRELESS_COLOR, 2], ]); expect(named('setLineDash')).toEqual([ ['setLineDash', []], ['setLineDash', [10]], ['setLineDash', []], ]); // The devices: Java's icon, the label in the middle below it, black. expect(named('drawImage')).toEqual([ ['drawImage', 'icon:server', a!.iconRect.x, a!.iconRect.y, 64, 94], ['drawImage', 'icon:switch', b!.iconRect.x, b!.iconRect.y, 70, 52], ['drawImage', 'icon:laptop', c!.iconRect.x, c!.iconRect.y, 84, 80], ]); const labels = named('fillText').filter((call) => call[4] === JAVA_LABEL_FONT); expect(labels).toEqual([ ['fillText', 'Rechner A', a!.label.x, a!.label.y, JAVA_LABEL_FONT, LABEL_COLOR, 'center'], ['fillText', 'Switch', b!.label.x, b!.label.y, JAVA_LABEL_FONT, LABEL_COLOR, 'center'], ['fillText', 'N', c!.label.x, c!.label.y, JAVA_LABEL_FONT, LABEL_COLOR, 'center'], // The footer: at its left, its baseline the font's ascent down, light grey. ['fillText', 'netz.fls', layout.footer.x, layout.footer.y + 14, JAVA_LABEL_FONT, FOOTER_COLOR, 'left'], ]); // In that order, and the context left as it was found. const order = calls.map((call) => call[0]); expect(order.indexOf('roundRect')).toBeLessThan(order.indexOf('rect')); expect(order.lastIndexOf('fillText', order.indexOf('moveTo'))).toBeGreaterThan(order.indexOf('rect')); expect(order.lastIndexOf('quadraticCurveTo')).toBeLessThan(order.indexOf('drawImage')); expect(order.filter((name) => name === 'save').length).toBe(order.filter((name) => name === 'restore').length); expect(order.at(-1)).toBe('restore'); expect(layout.footer.y).toBe(340 + 20); }); });