@shipload/item-renderer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +14 -0
- package/.gitignore +6 -0
- package/Makefile +50 -0
- package/biome.json +18 -0
- package/bun.lock +123 -0
- package/package.json +51 -0
- package/scripts/check-bundle-size.ts +37 -0
- package/scripts/copy-fonts.ts +41 -0
- package/scripts/preview.ts +43 -0
- package/src/errors.ts +22 -0
- package/src/fonts/index.ts +36 -0
- package/src/fonts/inter-400.woff2 +0 -0
- package/src/fonts/inter-600.woff2 +0 -0
- package/src/fonts/jetbrains-500.woff2 +0 -0
- package/src/fonts/load-bun.ts +16 -0
- package/src/fonts/orbitron-700.woff2 +0 -0
- package/src/index.ts +46 -0
- package/src/links.ts +19 -0
- package/src/meta.ts +42 -0
- package/src/payload/base64url.ts +27 -0
- package/src/payload/codec.ts +26 -0
- package/src/primitives/category-icon.ts +87 -0
- package/src/primitives/compact-row.ts +38 -0
- package/src/primitives/divider.ts +20 -0
- package/src/primitives/icon-hex.ts +39 -0
- package/src/primitives/module-slot.ts +147 -0
- package/src/primitives/panel.ts +24 -0
- package/src/primitives/quantity-badge.ts +37 -0
- package/src/primitives/span-paragraph.ts +72 -0
- package/src/primitives/stat-bar.ts +85 -0
- package/src/primitives/svg.ts +25 -0
- package/src/primitives/text.ts +42 -0
- package/src/primitives/wrap.ts +24 -0
- package/src/render.ts +33 -0
- package/src/templates/_shared.ts +15 -0
- package/src/templates/component.ts +139 -0
- package/src/templates/index.ts +30 -0
- package/src/templates/item-cell.ts +96 -0
- package/src/templates/module.ts +190 -0
- package/src/templates/packed-entity.ts +30 -0
- package/src/templates/resource.ts +151 -0
- package/src/templates/ship-panel.ts +145 -0
- package/src/tokens/colors.ts +45 -0
- package/src/tokens/index.ts +7 -0
- package/src/tokens/spacing.ts +10 -0
- package/src/tokens/typography.ts +19 -0
- package/test/__image_snapshots__/.gitkeep +0 -0
- package/test/__image_snapshots__/component-hull-plates.png +0 -0
- package/test/__image_snapshots__/module-engine-t1.png +0 -0
- package/test/__image_snapshots__/module-storage-t1.png +0 -0
- package/test/__image_snapshots__/packed-entity-ship-t1-only-engine.png +0 -0
- package/test/__image_snapshots__/packed-entity-ship-t1-two-modules.diff.png +0 -0
- package/test/__image_snapshots__/packed-entity-ship-t1-two-modules.png +0 -0
- package/test/__image_snapshots__/resource-iron.diff.png +0 -0
- package/test/__image_snapshots__/resource-iron.png +0 -0
- package/test/__snapshots__/templates-component.test.ts.snap +5 -0
- package/test/__snapshots__/templates-item-cell.test.ts.snap +9 -0
- package/test/__snapshots__/templates-module.test.ts.snap +17 -0
- package/test/__snapshots__/templates-packed-entity.test.ts.snap +5 -0
- package/test/__snapshots__/templates-resource.test.ts.snap +7 -0
- package/test/base64url.test.ts +33 -0
- package/test/codec.test.ts +43 -0
- package/test/errors.test.ts +24 -0
- package/test/fixtures/cargo-items.ts +122 -0
- package/test/fonts.test.ts +28 -0
- package/test/links-meta.test.ts +34 -0
- package/test/pixel.test.ts +66 -0
- package/test/primitives-category-icon.test.ts +79 -0
- package/test/primitives-compact-row.test.ts +44 -0
- package/test/primitives-domain.test.ts +72 -0
- package/test/primitives-layout.test.ts +56 -0
- package/test/primitives-module-slot.test.ts +88 -0
- package/test/render.test.ts +40 -0
- package/test/sanity.test.ts +6 -0
- package/test/sdk-link.test.ts +19 -0
- package/test/snapshots/.gitkeep +0 -0
- package/test/svg.test.ts +28 -0
- package/test/templates-component.test.ts +36 -0
- package/test/templates-dispatch.test.ts +35 -0
- package/test/templates-item-cell.test.ts +94 -0
- package/test/templates-module.test.ts +63 -0
- package/test/templates-packed-entity.test.ts +47 -0
- package/test/templates-resource.test.ts +71 -0
- package/test/templates-ship-panel.test.ts +87 -0
- package/test/tokens.test.ts +32 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { ServerContract } from '@shipload/sdk'
|
|
2
|
+
|
|
3
|
+
export const ITEM_IRON = 26
|
|
4
|
+
export const ITEM_HELIUM = 2
|
|
5
|
+
export const ITEM_ENGINE_T1 = 10100
|
|
6
|
+
export const ITEM_GENERATOR_T1 = 10101
|
|
7
|
+
export const ITEM_GATHERER_T1 = 10102
|
|
8
|
+
export const ITEM_LOADER_T1 = 10103
|
|
9
|
+
export const ITEM_MANUFACTURING_T1 = 10104
|
|
10
|
+
export const ITEM_STORAGE_T1 = 10105
|
|
11
|
+
export const ITEM_HAULER_T1 = 10106
|
|
12
|
+
export const ITEM_SHIP_T1_PACKED = 10201
|
|
13
|
+
|
|
14
|
+
export const MODULE_ENGINE = 1
|
|
15
|
+
export const MODULE_GENERATOR = 2
|
|
16
|
+
|
|
17
|
+
export function cargoIron(stats = '0x123456789ABCDEF', quantity = 1) {
|
|
18
|
+
return ServerContract.Types.cargo_item.from({
|
|
19
|
+
item_id: ITEM_IRON,
|
|
20
|
+
quantity,
|
|
21
|
+
stats,
|
|
22
|
+
modules: [],
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function cargoShipT1Packed(opts?: {
|
|
27
|
+
stats?: string
|
|
28
|
+
engineStats?: string
|
|
29
|
+
generatorStats?: string
|
|
30
|
+
onlyEngine?: boolean
|
|
31
|
+
}) {
|
|
32
|
+
const o = opts ?? {}
|
|
33
|
+
const modules: unknown[] = []
|
|
34
|
+
modules.push({
|
|
35
|
+
type: MODULE_ENGINE,
|
|
36
|
+
installed: { item_id: ITEM_ENGINE_T1, stats: o.engineStats ?? '0x2A4F6B8C' },
|
|
37
|
+
})
|
|
38
|
+
if (!o.onlyEngine) {
|
|
39
|
+
modules.push({
|
|
40
|
+
type: MODULE_GENERATOR,
|
|
41
|
+
installed: { item_id: ITEM_GENERATOR_T1, stats: o.generatorStats ?? '0x1B2D4F' },
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
return ServerContract.Types.cargo_item.from({
|
|
45
|
+
item_id: ITEM_SHIP_T1_PACKED,
|
|
46
|
+
quantity: 1,
|
|
47
|
+
stats: o.stats ?? '0',
|
|
48
|
+
modules,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const ITEM_HULL_PLATES = 10001
|
|
53
|
+
|
|
54
|
+
export const FIXTURES = {
|
|
55
|
+
iron: cargoIron('0x123456789ABCDEF'),
|
|
56
|
+
ironStackOf50: cargoIron('0x123456789ABCDEF', 50),
|
|
57
|
+
ironZeroStats: cargoIron('0'),
|
|
58
|
+
helium: ServerContract.Types.cargo_item.from({
|
|
59
|
+
item_id: ITEM_HELIUM,
|
|
60
|
+
quantity: 1,
|
|
61
|
+
stats: '0xDEADBEEF1234',
|
|
62
|
+
modules: [],
|
|
63
|
+
}),
|
|
64
|
+
hullPlates: ServerContract.Types.cargo_item.from({
|
|
65
|
+
item_id: ITEM_HULL_PLATES,
|
|
66
|
+
quantity: 1,
|
|
67
|
+
stats: '0x7FFF',
|
|
68
|
+
modules: [],
|
|
69
|
+
}),
|
|
70
|
+
engineT1: ServerContract.Types.cargo_item.from({
|
|
71
|
+
item_id: ITEM_ENGINE_T1,
|
|
72
|
+
quantity: 1,
|
|
73
|
+
stats: '358800',
|
|
74
|
+
modules: [],
|
|
75
|
+
}),
|
|
76
|
+
generatorT1: ServerContract.Types.cargo_item.from({
|
|
77
|
+
item_id: ITEM_GENERATOR_T1,
|
|
78
|
+
quantity: 1,
|
|
79
|
+
stats: '683908',
|
|
80
|
+
modules: [],
|
|
81
|
+
}),
|
|
82
|
+
gathererT1: ServerContract.Types.cargo_item.from({
|
|
83
|
+
item_id: ITEM_GATHERER_T1,
|
|
84
|
+
quantity: 1,
|
|
85
|
+
stats: '138255128433040',
|
|
86
|
+
modules: [],
|
|
87
|
+
}),
|
|
88
|
+
loaderT1: ServerContract.Types.cargo_item.from({
|
|
89
|
+
item_id: ITEM_LOADER_T1,
|
|
90
|
+
quantity: 1,
|
|
91
|
+
stats: '512750',
|
|
92
|
+
modules: [],
|
|
93
|
+
}),
|
|
94
|
+
crafterT1: ServerContract.Types.cargo_item.from({
|
|
95
|
+
item_id: ITEM_MANUFACTURING_T1,
|
|
96
|
+
quantity: 1,
|
|
97
|
+
stats: '512600',
|
|
98
|
+
modules: [],
|
|
99
|
+
}),
|
|
100
|
+
storageT1: ServerContract.Types.cargo_item.from({
|
|
101
|
+
item_id: ITEM_STORAGE_T1,
|
|
102
|
+
quantity: 1,
|
|
103
|
+
stats: '537605632700',
|
|
104
|
+
modules: [],
|
|
105
|
+
}),
|
|
106
|
+
haulerT1: ServerContract.Types.cargo_item.from({
|
|
107
|
+
item_id: ITEM_HAULER_T1,
|
|
108
|
+
quantity: 1,
|
|
109
|
+
stats: '0x3E8',
|
|
110
|
+
modules: [],
|
|
111
|
+
}),
|
|
112
|
+
shipT1NoModules: ServerContract.Types.cargo_item.from({
|
|
113
|
+
item_id: ITEM_SHIP_T1_PACKED,
|
|
114
|
+
quantity: 1,
|
|
115
|
+
stats: '0',
|
|
116
|
+
modules: [],
|
|
117
|
+
}),
|
|
118
|
+
shipT1TwoModules: cargoShipT1Packed(),
|
|
119
|
+
shipT1OnlyEngine: cargoShipT1Packed({ onlyEngine: true }),
|
|
120
|
+
} as const
|
|
121
|
+
|
|
122
|
+
export type FixtureName = keyof typeof FIXTURES
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { embedFontsInSvg } from '../src/fonts/index.ts'
|
|
3
|
+
import { loadFontData } from '../src/fonts/load-bun.ts'
|
|
4
|
+
|
|
5
|
+
test('loadFontData returns all four faces with non-zero bytes', async () => {
|
|
6
|
+
const data = await loadFontData()
|
|
7
|
+
expect(Object.keys(data).sort()).toEqual([
|
|
8
|
+
'inter-400',
|
|
9
|
+
'inter-600',
|
|
10
|
+
'jetbrains-500',
|
|
11
|
+
'orbitron-700',
|
|
12
|
+
])
|
|
13
|
+
for (const [key, bytes] of Object.entries(data)) {
|
|
14
|
+
expect(bytes.byteLength, `${key} bytes`).toBeGreaterThan(2000)
|
|
15
|
+
expect(bytes.byteLength, `${key} bytes`).toBeLessThan(120_000)
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('embedFontsInSvg inlines @font-face blocks', async () => {
|
|
20
|
+
const data = await loadFontData()
|
|
21
|
+
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"></svg>'
|
|
22
|
+
const out = embedFontsInSvg(svg, data)
|
|
23
|
+
expect(out).toContain('@font-face')
|
|
24
|
+
expect(out).toContain('font-family: "Orbitron"')
|
|
25
|
+
expect(out).toContain('font-family: "Inter"')
|
|
26
|
+
expect(out).toContain('font-family: "JetBrains Mono"')
|
|
27
|
+
expect(out).toContain('data:font/woff2;base64,')
|
|
28
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { resolveItem } from '@shipload/sdk'
|
|
3
|
+
import { linkToItemImage, linkToItemPage } from '../src/links.ts'
|
|
4
|
+
import { itemPageMeta } from '../src/meta.ts'
|
|
5
|
+
import { FIXTURES } from './fixtures/cargo-items.ts'
|
|
6
|
+
|
|
7
|
+
test('linkToItemPage defaults to shiploadgame.com', () => {
|
|
8
|
+
const url = linkToItemPage(FIXTURES.iron)
|
|
9
|
+
expect(url).toMatch(/^https:\/\/shiploadgame\.com\/guide\/item\/[A-Za-z0-9_-]+$/)
|
|
10
|
+
})
|
|
11
|
+
|
|
12
|
+
test('linkToItemPage accepts a custom base URL', () => {
|
|
13
|
+
const url = linkToItemPage(FIXTURES.iron, 'http://localhost:5173')
|
|
14
|
+
expect(url.startsWith('http://localhost:5173/guide/item/')).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('linkToItemImage builds a PNG URL', () => {
|
|
18
|
+
const url = linkToItemImage(FIXTURES.iron, 'png')
|
|
19
|
+
expect(url).toMatch(/^https:\/\/img\.shiploadgame\.com\/item\/[A-Za-z0-9_-]+\.png$/)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('linkToItemImage builds an SVG URL', () => {
|
|
23
|
+
const url = linkToItemImage(FIXTURES.iron, 'svg')
|
|
24
|
+
expect(url).toMatch(/\.svg$/)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('itemPageMeta produces title, description, and ogImage', () => {
|
|
28
|
+
const item = FIXTURES.iron
|
|
29
|
+
const resolved = resolveItem(item.item_id, item.stats, item.modules)
|
|
30
|
+
const meta = itemPageMeta(item, resolved)
|
|
31
|
+
expect(meta.title).toContain('Iron')
|
|
32
|
+
expect(meta.description.length).toBeGreaterThan(0)
|
|
33
|
+
expect(meta.ogImage).toMatch(/^https:\/\/img\.shiploadgame\.com\/item\/[A-Za-z0-9_-]+\.png$/)
|
|
34
|
+
})
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { expect, test } from 'bun:test'
|
|
5
|
+
import { Resvg } from '@resvg/resvg-js'
|
|
6
|
+
import pixelmatch from 'pixelmatch'
|
|
7
|
+
import { PNG } from 'pngjs'
|
|
8
|
+
import { resolveItem } from '@shipload/sdk'
|
|
9
|
+
import { renderItem } from '../src/render.ts'
|
|
10
|
+
import { embedFontsInSvg } from '../src/fonts/index.ts'
|
|
11
|
+
import { loadFontData } from '../src/fonts/load-bun.ts'
|
|
12
|
+
import { FIXTURES } from './fixtures/cargo-items.ts'
|
|
13
|
+
|
|
14
|
+
const SNAP_DIR = resolve(import.meta.dir, '__image_snapshots__')
|
|
15
|
+
const UPDATE = process.env.UPDATE_IMAGE_SNAPSHOTS === '1'
|
|
16
|
+
|
|
17
|
+
await mkdir(SNAP_DIR, { recursive: true })
|
|
18
|
+
const fontData = await loadFontData()
|
|
19
|
+
|
|
20
|
+
async function renderPng(svg: string): Promise<Buffer> {
|
|
21
|
+
const resvg = new Resvg(svg, {
|
|
22
|
+
font: { loadSystemFonts: false, fontBuffers: Object.values(fontData).map((b) => Buffer.from(b)) },
|
|
23
|
+
})
|
|
24
|
+
return resvg.render().asPng()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const CASES = [
|
|
28
|
+
{ name: 'resource-iron', fixture: FIXTURES.iron },
|
|
29
|
+
{ name: 'packed-entity-ship-t1-two-modules', fixture: FIXTURES.shipT1TwoModules },
|
|
30
|
+
{ name: 'packed-entity-ship-t1-only-engine', fixture: FIXTURES.shipT1OnlyEngine },
|
|
31
|
+
{ name: 'component-hull-plates', fixture: FIXTURES.hullPlates },
|
|
32
|
+
{ name: 'module-engine-t1', fixture: FIXTURES.engineT1 },
|
|
33
|
+
{ name: 'module-storage-t1', fixture: FIXTURES.storageT1 },
|
|
34
|
+
] as const
|
|
35
|
+
|
|
36
|
+
for (const c of CASES) {
|
|
37
|
+
test(`pixel golden — ${c.name}`, async () => {
|
|
38
|
+
const resolved = resolveItem(c.fixture.item_id, c.fixture.stats, c.fixture.modules)
|
|
39
|
+
const svg = embedFontsInSvg(renderItem(c.fixture, resolved), fontData)
|
|
40
|
+
const png = await renderPng(svg)
|
|
41
|
+
const goldPath = resolve(SNAP_DIR, `${c.name}.png`)
|
|
42
|
+
|
|
43
|
+
if (UPDATE || !existsSync(goldPath)) {
|
|
44
|
+
await writeFile(goldPath, png)
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const actual = PNG.sync.read(png)
|
|
49
|
+
const expected = PNG.sync.read(await readFile(goldPath))
|
|
50
|
+
expect(actual.width).toBe(expected.width)
|
|
51
|
+
expect(actual.height).toBe(expected.height)
|
|
52
|
+
const diff = new PNG({ width: actual.width, height: actual.height })
|
|
53
|
+
const diffCount = pixelmatch(
|
|
54
|
+
actual.data,
|
|
55
|
+
expected.data,
|
|
56
|
+
diff.data,
|
|
57
|
+
actual.width,
|
|
58
|
+
actual.height,
|
|
59
|
+
{ threshold: 0.1 },
|
|
60
|
+
)
|
|
61
|
+
if (diffCount > 10) {
|
|
62
|
+
await writeFile(resolve(SNAP_DIR, `${c.name}.diff.png`), PNG.sync.write(diff))
|
|
63
|
+
}
|
|
64
|
+
expect(diffCount).toBeLessThanOrEqual(10)
|
|
65
|
+
})
|
|
66
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { test, expect } from 'bun:test'
|
|
2
|
+
import { categoryIconSvg, categoryIconPath } from '../src/primitives/category-icon.ts'
|
|
3
|
+
|
|
4
|
+
test('categoryIconSvg returns self-contained <svg> for each shape', () => {
|
|
5
|
+
for (const shape of ['hex', 'diamond', 'star', 'circle', 'square'] as const) {
|
|
6
|
+
const svg = categoryIconSvg(shape)
|
|
7
|
+
expect(svg.startsWith('<svg ')).toBe(true)
|
|
8
|
+
expect(svg.endsWith('</svg>')).toBe(true)
|
|
9
|
+
expect(svg).toContain('viewBox=')
|
|
10
|
+
}
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('categoryIconSvg respects size option', () => {
|
|
14
|
+
const svg = categoryIconSvg('hex', { size: 24 })
|
|
15
|
+
expect(svg).toContain('width="24"')
|
|
16
|
+
expect(svg).toContain('height="24"')
|
|
17
|
+
expect(svg).toContain('viewBox="0 0 24 24"')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('categoryIconSvg respects color option', () => {
|
|
21
|
+
const svg = categoryIconSvg('hex', { color: '#ff0000' })
|
|
22
|
+
expect(svg).toContain('#ff0000')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
test('categoryIconPath returns inline element at given coordinates without svg wrapper', () => {
|
|
26
|
+
const path = categoryIconPath({ shape: 'diamond', cx: 50, cy: 50, size: 20, color: '#00ff00' })
|
|
27
|
+
expect(path.startsWith('<svg')).toBe(false)
|
|
28
|
+
expect(path).toMatch(/^<(polygon|rect|circle)\b/)
|
|
29
|
+
expect(path).toContain('#00ff00')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('hex shape produces a 6-point polygon', () => {
|
|
33
|
+
const path = categoryIconPath({ shape: 'hex', cx: 50, cy: 50, size: 40, color: '#fff' })
|
|
34
|
+
expect(path).toContain('<polygon')
|
|
35
|
+
const match = path.match(/points="([^"]+)"/)
|
|
36
|
+
expect(match).not.toBeNull()
|
|
37
|
+
const pointCount = match![1].trim().split(/\s+/).length
|
|
38
|
+
expect(pointCount).toBe(6)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('star shape produces a 10-vertex polygon', () => {
|
|
42
|
+
const path = categoryIconPath({ shape: 'star', cx: 50, cy: 50, size: 40, color: '#fff' })
|
|
43
|
+
const match = path.match(/points="([^"]+)"/)
|
|
44
|
+
expect(match).not.toBeNull()
|
|
45
|
+
const pointCount = match![1].trim().split(/\s+/).length
|
|
46
|
+
expect(pointCount).toBe(10)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('circle shape produces a <circle> element', () => {
|
|
50
|
+
const path = categoryIconPath({ shape: 'circle', cx: 50, cy: 50, size: 40, color: '#abc' })
|
|
51
|
+
expect(path).toMatch(/^<circle\b/)
|
|
52
|
+
expect(path).toContain('cx="50"')
|
|
53
|
+
expect(path).toContain('cy="50"')
|
|
54
|
+
expect(path).toContain('r="20"')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test('square shape produces a <rect> element', () => {
|
|
58
|
+
const path = categoryIconPath({ shape: 'square', cx: 50, cy: 50, size: 40, color: '#abc' })
|
|
59
|
+
expect(path).toMatch(/^<rect\b/)
|
|
60
|
+
expect(path).toContain('width="40"')
|
|
61
|
+
expect(path).toContain('height="40"')
|
|
62
|
+
expect(path).toContain('x="30"')
|
|
63
|
+
expect(path).toContain('y="30"')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('strokeWidth renders outline mode: fill=none, stroke=color', () => {
|
|
67
|
+
for (const shape of ['hex', 'diamond', 'star', 'circle', 'square'] as const) {
|
|
68
|
+
const path = categoryIconPath({ shape, cx: 50, cy: 50, size: 40, color: '#ff0000', strokeWidth: 2 })
|
|
69
|
+
expect(path).toContain('fill="none"')
|
|
70
|
+
expect(path).toContain('stroke="#ff0000"')
|
|
71
|
+
expect(path).toContain('stroke-width="2"')
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('fill mode (no strokeWidth) does not produce stroke attributes', () => {
|
|
76
|
+
const path = categoryIconPath({ shape: 'hex', cx: 50, cy: 50, size: 40, color: '#ff0000' })
|
|
77
|
+
expect(path).toContain('fill="#ff0000"')
|
|
78
|
+
expect(path).not.toContain('stroke=')
|
|
79
|
+
})
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { test, expect } from 'bun:test'
|
|
2
|
+
import { compactRow } from '../src/primitives/compact-row.ts'
|
|
3
|
+
|
|
4
|
+
test('compactRow emits left-anchored label and right-anchored value', () => {
|
|
5
|
+
const svg = compactRow({
|
|
6
|
+
x: 14,
|
|
7
|
+
y: 80,
|
|
8
|
+
width: 252,
|
|
9
|
+
label: 'Thrust',
|
|
10
|
+
value: '700',
|
|
11
|
+
})
|
|
12
|
+
expect(svg).toContain('<text')
|
|
13
|
+
expect(svg).toContain('Thrust')
|
|
14
|
+
expect(svg).toContain('700')
|
|
15
|
+
expect(svg).toContain('x="14"')
|
|
16
|
+
expect(svg).toContain('x="266"')
|
|
17
|
+
expect(svg).toContain('text-anchor="end"')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
test('compactRow applies supplied label and value colors', () => {
|
|
21
|
+
const svg = compactRow({
|
|
22
|
+
x: 14,
|
|
23
|
+
y: 80,
|
|
24
|
+
width: 252,
|
|
25
|
+
label: 'Drain',
|
|
26
|
+
value: '50',
|
|
27
|
+
labelColor: '#aabbcc',
|
|
28
|
+
valueColor: '#ddeeff',
|
|
29
|
+
})
|
|
30
|
+
expect(svg).toContain('fill="#aabbcc"')
|
|
31
|
+
expect(svg).toContain('fill="#ddeeff"')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('compactRow escapes HTML entities in label and value', () => {
|
|
35
|
+
const svg = compactRow({
|
|
36
|
+
x: 14,
|
|
37
|
+
y: 80,
|
|
38
|
+
width: 252,
|
|
39
|
+
label: 'A & B',
|
|
40
|
+
value: '<5',
|
|
41
|
+
})
|
|
42
|
+
expect(svg).toContain('A & B')
|
|
43
|
+
expect(svg).toContain('<5')
|
|
44
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { iconHex } from '../src/primitives/icon-hex.ts'
|
|
3
|
+
import { statBar } from '../src/primitives/stat-bar.ts'
|
|
4
|
+
import { moduleSlot } from '../src/primitives/module-slot.ts'
|
|
5
|
+
import { quantityBadge } from '../src/primitives/quantity-badge.ts'
|
|
6
|
+
|
|
7
|
+
test('iconHex draws a hexagon with the category color and 2-char code', () => {
|
|
8
|
+
const svg = iconHex({ x: 14, y: 14, color: '#58d08c', code: 'FE' })
|
|
9
|
+
expect(svg).toContain('<polygon')
|
|
10
|
+
expect(svg).toContain('stroke="#58d08c"')
|
|
11
|
+
expect(svg).toContain('>FE<')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('statBar emits a labeled bar with value ∈ [0, 1023]', () => {
|
|
15
|
+
const svg = statBar({
|
|
16
|
+
x: 14,
|
|
17
|
+
y: 100,
|
|
18
|
+
width: 252,
|
|
19
|
+
label: 'Strength',
|
|
20
|
+
abbreviation: 'STR',
|
|
21
|
+
value: 342,
|
|
22
|
+
color: '#58d08c',
|
|
23
|
+
})
|
|
24
|
+
expect(svg).toContain('STR')
|
|
25
|
+
expect(svg).toContain('Strength')
|
|
26
|
+
expect(svg).toContain('342')
|
|
27
|
+
// Fill width proportional to value/1023:
|
|
28
|
+
// expected filled width ≈ 252 * 342 / 1023 ≈ 84.2
|
|
29
|
+
expect(svg).toContain('width="84"')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('statBar inverts the visual fill when inverted is true', () => {
|
|
33
|
+
const hi = statBar({
|
|
34
|
+
x: 0, y: 0, width: 100, label: 'X', abbreviation: 'X',
|
|
35
|
+
value: 900, color: '#fff',
|
|
36
|
+
})
|
|
37
|
+
const lo = statBar({
|
|
38
|
+
x: 0, y: 0, width: 100, label: 'X', abbreviation: 'X',
|
|
39
|
+
value: 900, color: '#fff', inverted: true,
|
|
40
|
+
})
|
|
41
|
+
expect(hi).not.toBe(lo)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('moduleSlot renders an empty state with "Empty module" label', () => {
|
|
45
|
+
const svg = moduleSlot({ x: 14, y: 200, width: 252, installed: false })
|
|
46
|
+
expect(svg).toContain('Empty module')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('moduleSlot renders an installed state with capability description', () => {
|
|
50
|
+
const svg = moduleSlot({
|
|
51
|
+
x: 14,
|
|
52
|
+
y: 200,
|
|
53
|
+
width: 252,
|
|
54
|
+
installed: true,
|
|
55
|
+
capability: 'Engine',
|
|
56
|
+
description: 'generates 757 thrust for travel while draining 41 energy per distance travelled',
|
|
57
|
+
accentColor: '#58d08c',
|
|
58
|
+
})
|
|
59
|
+
expect(svg).toContain('Engine')
|
|
60
|
+
expect(svg).toContain('757')
|
|
61
|
+
expect(svg).toContain('thrust')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('quantityBadge is empty string when quantity <= 1', () => {
|
|
65
|
+
expect(quantityBadge({ x: 0, y: 0, quantity: 1 })).toBe('')
|
|
66
|
+
expect(quantityBadge({ x: 0, y: 0, quantity: 0 })).toBe('')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('quantityBadge renders ×N chip when quantity > 1', () => {
|
|
70
|
+
const svg = quantityBadge({ x: 250, y: 8, quantity: 50 })
|
|
71
|
+
expect(svg).toContain('×50')
|
|
72
|
+
})
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { panel } from '../src/primitives/panel.ts'
|
|
3
|
+
import { text } from '../src/primitives/text.ts'
|
|
4
|
+
import { divider } from '../src/primitives/divider.ts'
|
|
5
|
+
import { wrapText } from '../src/primitives/wrap.ts'
|
|
6
|
+
|
|
7
|
+
test('panel renders a rect inset by 0.5px so the 1px stroke stays inside the viewBox', () => {
|
|
8
|
+
const svg = panel({ width: 280, height: 120 })
|
|
9
|
+
expect(svg).toContain('<rect')
|
|
10
|
+
expect(svg).toContain('x="0.5"')
|
|
11
|
+
expect(svg).toContain('y="0.5"')
|
|
12
|
+
expect(svg).toContain('width="279"')
|
|
13
|
+
expect(svg).toContain('height="119"')
|
|
14
|
+
expect(svg).toContain('rx="10"')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
test('panel accepts an optional tier border color', () => {
|
|
18
|
+
const svg = panel({ width: 280, height: 120, borderColor: '#6cb9ff' })
|
|
19
|
+
expect(svg).toContain('stroke="#6cb9ff"')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('text emits a <text> element with escaped content', () => {
|
|
23
|
+
const svg = text({ x: 10, y: 20, value: `Ship "T1"`, size: 14, weight: 600 })
|
|
24
|
+
expect(svg).toContain('<text')
|
|
25
|
+
expect(svg).toContain('x="10"')
|
|
26
|
+
expect(svg).toContain('y="20"')
|
|
27
|
+
expect(svg).toContain('Ship "T1"')
|
|
28
|
+
expect(svg).toContain('font-size="14"')
|
|
29
|
+
expect(svg).toContain('font-weight="600"')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('divider is a horizontal line at y', () => {
|
|
33
|
+
const svg = divider({ x: 14, y: 40, width: 252 })
|
|
34
|
+
expect(svg).toContain('<line')
|
|
35
|
+
expect(svg).toContain('x1="14"')
|
|
36
|
+
expect(svg).toContain('x2="266"')
|
|
37
|
+
expect(svg).toContain('y1="40"')
|
|
38
|
+
expect(svg).toContain('y2="40"')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('wrapText splits a string into lines that fit within a char budget', () => {
|
|
42
|
+
const lines = wrapText({
|
|
43
|
+
value: 'generates 757 thrust for travel while draining 41 energy per distance travelled',
|
|
44
|
+
charsPerLine: 40,
|
|
45
|
+
})
|
|
46
|
+
for (const line of lines) expect(line.length).toBeLessThanOrEqual(40)
|
|
47
|
+
// No word is broken mid-word:
|
|
48
|
+
expect(lines.join(' ')).toBe(
|
|
49
|
+
'generates 757 thrust for travel while draining 41 energy per distance travelled',
|
|
50
|
+
)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
test('wrapText preserves a single unbreakable long token', () => {
|
|
54
|
+
const lines = wrapText({ value: 'supercalifragilisticexpialidocious', charsPerLine: 10 })
|
|
55
|
+
expect(lines).toEqual(['supercalifragilisticexpialidocious'])
|
|
56
|
+
})
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { test, expect } from 'bun:test'
|
|
2
|
+
import { moduleSlot } from '../src/primitives/module-slot.ts'
|
|
3
|
+
import type { TextSpan } from '@shipload/sdk'
|
|
4
|
+
|
|
5
|
+
test('string description flows inline with the bold capability label', () => {
|
|
6
|
+
const svg = moduleSlot({
|
|
7
|
+
x: 14,
|
|
8
|
+
y: 40,
|
|
9
|
+
width: 252,
|
|
10
|
+
installed: true,
|
|
11
|
+
capability: 'Engine',
|
|
12
|
+
description: 'generates 700 thrust for travel',
|
|
13
|
+
accentColor: '#2fd6d1',
|
|
14
|
+
})
|
|
15
|
+
expect(svg).toContain('<polygon')
|
|
16
|
+
expect(svg).toContain('Engine: ')
|
|
17
|
+
expect(svg).toContain('font-weight="600"')
|
|
18
|
+
expect(svg).toContain('generates 700 thrust for')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('TextSpan[] description renders each span; highlighted spans get accent color', () => {
|
|
22
|
+
const spans: TextSpan[] = [
|
|
23
|
+
{ text: 'generates ' },
|
|
24
|
+
{ text: '700', highlight: true },
|
|
25
|
+
{ text: ' thrust for travel while draining ' },
|
|
26
|
+
{ text: '45', highlight: true },
|
|
27
|
+
{ text: ' energy per distance travelled' },
|
|
28
|
+
]
|
|
29
|
+
const svg = moduleSlot({
|
|
30
|
+
x: 14,
|
|
31
|
+
y: 40,
|
|
32
|
+
width: 252,
|
|
33
|
+
installed: true,
|
|
34
|
+
capability: 'Engine',
|
|
35
|
+
description: spans,
|
|
36
|
+
accentColor: '#2fd6d1',
|
|
37
|
+
})
|
|
38
|
+
expect(svg).toContain('Engine:')
|
|
39
|
+
expect(svg).toContain('>generates <')
|
|
40
|
+
expect(svg).toContain('>700<')
|
|
41
|
+
expect(svg).toContain('>45<')
|
|
42
|
+
expect(svg).toMatch(/fill="#[0-9A-Fa-f]{6}"[^>]*>700</)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('TextSpan[] description wraps across lines preserving highlight boundaries', () => {
|
|
46
|
+
const spans: TextSpan[] = [
|
|
47
|
+
{ text: 'mines resources at ' },
|
|
48
|
+
{ text: '880', highlight: true },
|
|
49
|
+
{ text: ' speed to a max depth of ' },
|
|
50
|
+
{ text: '248', highlight: true },
|
|
51
|
+
{ text: ' with ' },
|
|
52
|
+
{ text: '100', highlight: true },
|
|
53
|
+
{ text: ' gather speed while draining ' },
|
|
54
|
+
{ text: '1,250', highlight: true },
|
|
55
|
+
{ text: ' energy per second' },
|
|
56
|
+
]
|
|
57
|
+
const svg = moduleSlot({
|
|
58
|
+
x: 14,
|
|
59
|
+
y: 40,
|
|
60
|
+
width: 252,
|
|
61
|
+
installed: true,
|
|
62
|
+
capability: 'Gatherer',
|
|
63
|
+
description: spans,
|
|
64
|
+
accentColor: '#f59e0b',
|
|
65
|
+
})
|
|
66
|
+
expect(svg).toContain('880')
|
|
67
|
+
expect(svg).toContain('248')
|
|
68
|
+
expect(svg).toContain('100')
|
|
69
|
+
expect(svg).toContain('1,250')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('Empty description array renders headline only', () => {
|
|
73
|
+
const svg = moduleSlot({
|
|
74
|
+
x: 14,
|
|
75
|
+
y: 40,
|
|
76
|
+
width: 252,
|
|
77
|
+
installed: true,
|
|
78
|
+
capability: 'Engine',
|
|
79
|
+
description: [],
|
|
80
|
+
accentColor: '#2fd6d1',
|
|
81
|
+
})
|
|
82
|
+
expect(svg).toContain('Engine:')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test('Empty slot renders unchanged', () => {
|
|
86
|
+
const svg = moduleSlot({ x: 14, y: 40, width: 252, installed: false })
|
|
87
|
+
expect(svg).toContain('Empty module')
|
|
88
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { resolveItem } from '@shipload/sdk'
|
|
3
|
+
import { renderItem, renderFromPayload } from '../src/render.ts'
|
|
4
|
+
import { RenderError } from '../src/errors.ts'
|
|
5
|
+
import { encodePayload } from '../src/payload/codec.ts'
|
|
6
|
+
import { FIXTURES } from './fixtures/cargo-items.ts'
|
|
7
|
+
|
|
8
|
+
test('renderItem dispatches to resource template for resources', () => {
|
|
9
|
+
const item = FIXTURES.iron
|
|
10
|
+
const resolved = resolveItem(item.item_id, item.stats, item.modules)
|
|
11
|
+
const svg = renderItem(item, resolved)
|
|
12
|
+
expect(svg).toContain('Iron')
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
test('renderItem dispatches to packed entity template for entities', () => {
|
|
16
|
+
const item = FIXTURES.shipT1TwoModules
|
|
17
|
+
const resolved = resolveItem(item.item_id, item.stats, item.modules)
|
|
18
|
+
const svg = renderItem(item, resolved)
|
|
19
|
+
expect(svg).toContain('HULL')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('renderItem dispatches to module template for modules', () => {
|
|
23
|
+
const item = FIXTURES.engineT1
|
|
24
|
+
const resolved = resolveItem(item.item_id, item.stats, item.modules)
|
|
25
|
+
const svg = renderItem(item, resolved)
|
|
26
|
+
expect(svg).toContain('MODULE')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test('renderItem throws RenderError for unknown types', () => {
|
|
30
|
+
const item = FIXTURES.iron
|
|
31
|
+
const fake = { ...resolveItem(item.item_id, item.stats, item.modules), itemType: 'unknown' as never }
|
|
32
|
+
expect(() => renderItem(item, fake)).toThrow(RenderError)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('renderFromPayload round-trips a payload into SVG', async () => {
|
|
36
|
+
const payload = encodePayload(FIXTURES.iron)
|
|
37
|
+
const { svg, item } = await renderFromPayload(payload)
|
|
38
|
+
expect(svg).toContain('Iron')
|
|
39
|
+
expect(item.itemType).toBe('resource')
|
|
40
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { resolveItem, ServerContract, type ResolvedItem } from '@shipload/sdk'
|
|
3
|
+
|
|
4
|
+
test('sdkv2 is linked and exposes resolveItem + ResolvedItem type', () => {
|
|
5
|
+
const resolved: ResolvedItem = resolveItem(26 /* Iron */, undefined, undefined)
|
|
6
|
+
expect(resolved.itemId).toBe(26)
|
|
7
|
+
expect(resolved.name).toBe('Iron')
|
|
8
|
+
expect(resolved.itemType).toBe('resource')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
test('ServerContract.Types.cargo_item can be constructed', () => {
|
|
12
|
+
const ci = ServerContract.Types.cargo_item.from({
|
|
13
|
+
item_id: 26,
|
|
14
|
+
quantity: 1,
|
|
15
|
+
stats: '0',
|
|
16
|
+
modules: [],
|
|
17
|
+
})
|
|
18
|
+
expect(ci.item_id.equals(26)).toBe(true)
|
|
19
|
+
})
|
|
File without changes
|