From 4d5cd4e2897babec5d62836a464713397718460d Mon Sep 17 00:00:00 2001 From: waaraawa Date: Tue, 14 Oct 2025 11:19:33 +0900 Subject: [PATCH] feat(core): add legend position and footer control options - Add legendPosition option (right, left, bottom, none) to control legend placement - Add showFooter option to show/hide footer info - Support both config (YAML) and render options for flexibility - Fix legend overlapping issue when positioned at bottom - Maintain backward compatibility with showLegend option --- examples/README.md | 132 +++++--- examples/ipv4-bit-layout.yaml | 24 ++ examples/mixed-bit-byte.yaml | 24 ++ examples/nibble-test.yaml | 14 + examples/simple-bit-layout.yaml | 14 + packages/core/src/layoutEngine.ts | 52 +-- packages/core/src/parser.ts | 114 ++++++- packages/core/src/svgRenderer.ts | 178 ++++++++-- packages/core/src/types.ts | 22 +- packages/core/src/validator.ts | 69 +++- packages/core/tests/layoutEngine.test.ts | 137 ++++++++ packages/core/tests/parser.test.ts | 394 +++++++++++++++++++++++ packages/core/tests/svgRenderer.test.ts | 199 ++++++++++++ packages/core/tests/validator.test.ts | 133 ++++++++ 14 files changed, 1387 insertions(+), 119 deletions(-) create mode 100644 examples/ipv4-bit-layout.yaml create mode 100644 examples/mixed-bit-byte.yaml create mode 100644 examples/nibble-test.yaml create mode 100644 examples/simple-bit-layout.yaml diff --git a/examples/README.md b/examples/README.md index 8ceda5e..e36e8f7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,92 +1,122 @@ # ByteGrid Examples -이 폴더에는 ByteGrid 플러그인에서 사용할 수 있는 예시 파일들이 있습니다. +This folder contains example files for the ByteGrid plugin. -## 사용 방법 +## How to Use -1. 원하는 예시 파일을 엽니다 -2. 내용을 **모두 선택**하여 복사합니다 -3. Obsidian 노트에서 코드블록을 만듭니다: +1. Open the example file you want to use +2. **Select all** content and copy it +3. Create a code block in your Obsidian note: ``` ```bytegrid - [여기에 붙여넣기] + [paste here] ``` ``` -## 예시 파일 목록 +## Example Files -### 기본 예시 +### Basic Examples #### 1. simple-struct.yaml -- 간단한 16바이트 구조체 -- 4개 필드 (ID, Timestamp, Value, Checksum) -- 8바이트/행 레이아웃 +- Simple 16-byte structure +- 4 fields (ID, Timestamp, Value, Checksum) +- 8 bytes/row layout #### 2. wav-header.yaml -- WAV 오디오 파일 헤더 (44바이트) -- 14개 필드 (RIFF, WAVE, fmt, data 청크) -- 16바이트/행 레이아웃 -- 오디오 형식, 샘플레이트 등 포함 +- WAV audio file header (44 bytes) +- 14 fields (RIFF, WAVE, fmt, data chunks) +- 16 bytes/row layout +- Includes audio format, sample rate, etc. -### 네트워크 프로토콜 +### Network Protocols #### 3. tcp-header.yaml -- TCP 프로토콜 헤더 (20바이트) -- 10개 필드 (포트, 시퀀스 번호, 플래그 등) -- **비트필드 예시**: 8개 TCP 플래그 (CWR, ECE, URG, ACK, PSH, RST, SYN, FIN) -- 16바이트/행 레이아웃 +- TCP protocol header (20 bytes) +- 10 fields (ports, sequence number, flags, etc.) +- **Bitfield example**: 8 TCP flags (CWR, ECE, URG, ACK, PSH, RST, SYN, FIN) +- 16 bytes/row layout #### 4. ip-header.yaml -- IPv4 헤더 (20바이트) -- 10개 필드 (Version, DSCP, TTL, Protocol, IP 주소 등) -- **비트필드 예시**: Version+IHL, DSCP+ECN, Flags+FragmentOffset -- IP 단편화 플래그 포함 (DF, MF) +- IPv4 header (20 bytes) +- 10 fields (Version, DSCP, TTL, Protocol, IP addresses, etc.) +- **Bitfield example**: Version+IHL, DSCP+ECN, Flags+FragmentOffset +- Includes IP fragmentation flags (DF, MF) -### 파일 시스템 & 권한 +### File System & Permissions #### 5. file-attributes.yaml -- FAT32 파일 속성 (4바이트) -- **비트필드 예시**: 8개 파일 속성 플래그 -- READ_ONLY, HIDDEN, SYSTEM, DIRECTORY, ARCHIVE 등 +- FAT32 file attributes (4 bytes) +- **Bitfield example**: 8 file attribute flags +- READ_ONLY, HIDDEN, SYSTEM, DIRECTORY, ARCHIVE, etc. #### 6. unix-permissions.yaml -- Unix 파일 권한 (2바이트) -- **비트필드 예시**: rwxrwxrwx (9비트) -- Owner/Group/Others 권한 (읽기/쓰기/실행) +- Unix file permissions (2 bytes) +- **Bitfield example**: rwxrwxrwx (9 bits) +- Owner/Group/Others permissions (read/write/execute) -### 하드웨어 & CPU +### Hardware & CPU #### 7. cpu-flags.yaml -- x86 CPU FLAGS 레지스터 (4바이트) -- **비트필드 예시**: 16개 CPU 플래그 -- CF, ZF, SF, IF, DF, OF, IOPL 등 +- x86 CPU FLAGS register (4 bytes) +- **Bitfield example**: 16 CPU flags +- CF, ZF, SF, IF, DF, OF, IOPL, etc. -## 주의사항 +## Important Notes -⚠️ **복사 시 들여쓰기 주의** +⚠️ **Watch Out for Indentation When Copying** -Obsidian 코드블록에 붙여넣을 때 자동으로 들여쓰기가 추가될 수 있습니다. +Obsidian may automatically add indentation when pasting into code blocks. -**문제 발생 시:** -1. Obsidian **소스 모드**로 전환 (Ctrl/Cmd + E) -2. ` ```bytegrid` 다음 줄 확인 -3. `name:` 앞에 공백이 **없어야** 정상 -4. 공백이 있다면 제거 +**If you encounter issues:** +1. Switch to Obsidian **Source Mode** (Ctrl/Cmd + E) +2. Check the line after ` ```bytegrid` +3. There should be **no spaces** before `name:` +4. Remove any extra spaces if present -## 직접 만들기 +## Create Your Own -새로운 구조체를 시각화하려면: +To visualize a new structure: ```yaml -name: 구조체 이름 -size: 전체 바이트 크기 -layout: 16 # 한 행당 바이트 수 (선택, 기본값: 16) +name: Structure Name +size: total byte size +layout: 16 # bytes per row (optional, default: 16) +legendPosition: right # right, left, bottom, none (optional, default: right) +showFooter: true # show/hide footer (optional, default: true) fields: - offset: 0-3 - name: 필드명 + name: FieldName type: uint32_t color: blue # blue, cyan, yellow, green, orange, purple, mint, pink, gray - description: "설명" # 선택사항 + description: "Description" # optional ``` -더 자세한 내용은 [CLAUDE.md](../CLAUDE.md)를 참조하세요. +## Bit-Level Layout Examples + +### 8. simple-bit-layout.yaml +- Basic bit-level layout test (16 bits) +- **Suffix notation**: `size: 16b`, `layout: 16b`, `offset: 0-7b` +- `layoutUnit` can be omitted (auto-inferred from suffix) + +### 9. nibble-test.yaml +- 4-bit field (nibble) test +- Each nibble represented independently + +### 10. mixed-bit-byte.yaml +- Mixed bit and byte offsets +- `0-3b` (bit), `1B` (Byte), `2-3` (byte, default) + +### 11. ipv4-bit-layout.yaml +- IPv4 header in bit-level representation +- Sub-byte fields like Version(4bit) + IHL(4bit) + +**Suffix Notation:** +- `b` (lowercase) = bit: `32b`, `0-7b` +- `B` (uppercase) = Byte: `4B`, `0-3B` +- no suffix = byte (default) + +**layoutUnit field:** +- Can be omitted (auto-inferred from suffix) +- Can be explicit (use `layoutUnit: bit` or `layoutUnit: byte` if desired) + +For more details, see [CLAUDE.md](../CLAUDE.md). diff --git a/examples/ipv4-bit-layout.yaml b/examples/ipv4-bit-layout.yaml new file mode 100644 index 0000000..61950b3 --- /dev/null +++ b/examples/ipv4-bit-layout.yaml @@ -0,0 +1,24 @@ +name: IPv4 Header (Bit Layout) +size: 4B +layout: 32b +fields: + - offset: 0-3b + name: Version + type: bits + description: "IP version (4 for IPv4)" + color: blue + - offset: 4-7b + name: IHL + type: bits + description: "Header length in 32-bit words" + color: cyan + - offset: 1B + name: DSCP+ECN + type: uint8_t + description: "Byte 1 using B suffix" + color: yellow + - offset: 16-31b + name: TotalLength + type: uint16_t + description: "Total packet length (2 bytes = 16 bits)" + color: green diff --git a/examples/mixed-bit-byte.yaml b/examples/mixed-bit-byte.yaml new file mode 100644 index 0000000..78c0c4d --- /dev/null +++ b/examples/mixed-bit-byte.yaml @@ -0,0 +1,24 @@ +name: Mixed Bit and Byte Offsets +size: 32b +layout: 32b +fields: + - offset: 0-3b + name: Version + type: bits + description: "4 bits with b suffix" + color: blue + - offset: 4-7b + name: IHL + type: bits + description: "4 bits with b suffix" + color: cyan + - offset: 1B + name: SecondByte + type: uint8_t + description: "Byte 1 with B suffix (= bits 8-15)" + color: yellow + - offset: 2-3 + name: ThirdFourthByte + type: uint16_t + description: "Bytes 2-3 no suffix (= bits 16-31)" + color: green diff --git a/examples/nibble-test.yaml b/examples/nibble-test.yaml new file mode 100644 index 0000000..471e5c9 --- /dev/null +++ b/examples/nibble-test.yaml @@ -0,0 +1,14 @@ +name: 4-bit Fields (Nibbles) +size: 8b +layout: 8b +fields: + - offset: 0-3b + name: Nibble1 + type: bits + description: "First 4 bits" + color: blue + - offset: 4-7b + name: Nibble2 + type: bits + description: "Second 4 bits" + color: cyan diff --git a/examples/simple-bit-layout.yaml b/examples/simple-bit-layout.yaml new file mode 100644 index 0000000..7b0ab23 --- /dev/null +++ b/examples/simple-bit-layout.yaml @@ -0,0 +1,14 @@ +name: Simple Bit Layout Test +size: 16b +layout: 16b +fields: + - offset: 0-7b + name: FirstByte + type: uint8_t + description: "8 bits using b suffix" + color: blue + - offset: 8-15b + name: SecondByte + type: uint8_t + description: "Another 8 bits" + color: cyan diff --git a/packages/core/src/layoutEngine.ts b/packages/core/src/layoutEngine.ts index bb19993..5902790 100644 --- a/packages/core/src/layoutEngine.ts +++ b/packages/core/src/layoutEngine.ts @@ -14,21 +14,37 @@ import { parseOffset } from './validator'; */ export function createLayout(config: ByteGridConfig): LayoutBlock[] { const layout = config.layout || 16; + const layoutUnit = config.layoutUnit || 'byte'; const blocks: LayoutBlock[] = []; + // Convert offset to bits for unified processing + const toBits = (offset: ReturnType): { start: number; end: number; size: number } => { + if (layoutUnit === 'bit') { + // In bit layout mode, convert everything to bits + if (offset.unit === 'bit') { + return { start: offset.start, end: offset.end, size: offset.size }; + } + // Convert byte offset to bits + return { start: offset.start * 8, end: offset.end * 8 + 7, size: (offset.end - offset.start + 1) * 8 }; + } + // In byte layout mode, keep as bytes + return { start: offset.start, end: offset.end, size: offset.size }; + }; + for (const field of config.fields) { const offset = parseOffset(field.offset); + const offsetInUnits = toBits(offset); const color = (field.color || 'gray') as ColorName; const isPadding = field.type === 'reserved' || field.type === 'padding'; // Check if field spans multiple rows - const startRow = Math.floor(offset.start / layout); - const endRow = Math.floor(offset.end / layout); + const startRow = Math.floor(offsetInUnits.start / layout); + const endRow = Math.floor(offsetInUnits.end / layout); if (startRow === endRow) { // Field fits in a single row - const col = offset.start % layout; - const span = offset.size; + const col = offsetInUnits.start % layout; + const span = offsetInUnits.size; blocks.push({ row: startRow, @@ -39,42 +55,42 @@ export function createLayout(config: ByteGridConfig): LayoutBlock[] { color, value: field.value, description: field.description, - offsetStart: offset.start, - offsetEnd: offset.end, + offsetStart: offsetInUnits.start, + offsetEnd: offsetInUnits.end, isPadding, bitfields: field.bitfields, }); } else { // Field spans multiple rows - split it - let currentByte = offset.start; + let currentUnit = offsetInUnits.start; - while (currentByte <= offset.end) { - const row = Math.floor(currentByte / layout); - const col = currentByte % layout; + while (currentUnit <= offsetInUnits.end) { + const row = Math.floor(currentUnit / layout); + const col = currentUnit % layout; - // Calculate how many bytes fit in this row - const bytesLeftInRow = layout - col; - const bytesLeftInField = offset.end - currentByte + 1; - const bytesInThisBlock = Math.min(bytesLeftInRow, bytesLeftInField); + // Calculate how many units fit in this row + const unitsLeftInRow = layout - col; + const unitsLeftInField = offsetInUnits.end - currentUnit + 1; + const unitsInThisBlock = Math.min(unitsLeftInRow, unitsLeftInField); - const blockEnd = currentByte + bytesInThisBlock - 1; + const blockEnd = currentUnit + unitsInThisBlock - 1; blocks.push({ row, col, - span: bytesInThisBlock, + span: unitsInThisBlock, fieldName: field.name, fieldType: field.type, color, value: field.value, description: field.description, - offsetStart: currentByte, + offsetStart: currentUnit, offsetEnd: blockEnd, isPadding, bitfields: field.bitfields, }); - currentByte += bytesInThisBlock; + currentUnit += unitsInThisBlock; } } } diff --git a/packages/core/src/parser.ts b/packages/core/src/parser.ts index 4d92838..4632d5f 100644 --- a/packages/core/src/parser.ts +++ b/packages/core/src/parser.ts @@ -37,7 +37,7 @@ export function parse(source: string): ByteGridConfig { throw new ParseError('Missing or invalid required field: name'); } - if (!obj.size || typeof obj.size !== 'number') { + if (!obj.size) { throw new ParseError('Missing or invalid required field: size'); } @@ -49,8 +49,98 @@ export function parse(source: string): ByteGridConfig { throw new ParseError('Fields array must contain at least one field'); } - // Parse layout (optional, defaults to 16) - const layout = obj.layout !== undefined ? Number(obj.layout) : 16; + // Parse layoutUnit (optional) + let layoutUnit: 'byte' | 'bit' | undefined = obj.layoutUnit && typeof obj.layoutUnit === 'string' + ? (obj.layoutUnit as 'byte' | 'bit') + : undefined; + + // Parse layout (optional, defaults to 16) and infer layoutUnit if not specified + let layout: number; + if (obj.layout === undefined) { + layout = 16; + } else if (typeof obj.layout === 'number') { + layout = obj.layout; + } else if (typeof obj.layout === 'string') { + const layoutStr = obj.layout.trim(); + let layoutValue: number; + let layoutUnitSuffix: 'byte' | 'bit' | undefined; + + // Extract suffix + if (layoutStr.endsWith('b')) { + layoutValue = parseInt(layoutStr.slice(0, -1).trim()); + layoutUnitSuffix = 'bit'; + // Auto-infer layoutUnit from suffix if not explicitly set + if (!layoutUnit) layoutUnit = 'bit'; + } else if (layoutStr.endsWith('B')) { + layoutValue = parseInt(layoutStr.slice(0, -1).trim()); + layoutUnitSuffix = 'byte'; + } else { + layoutValue = parseInt(layoutStr); + layoutUnitSuffix = undefined; + } + + if (isNaN(layoutValue) || layoutValue <= 0) { + throw new ParseError('Invalid layout value'); + } + + // Default layoutUnit to byte if still not set + const effectiveLayoutUnit = layoutUnit || 'byte'; + const effectiveSuffix = layoutUnitSuffix || effectiveLayoutUnit; + + // Convert to the layoutUnit + if (effectiveLayoutUnit === 'bit') { + // In bit layout mode, layout must be in bits + layout = effectiveSuffix === 'byte' ? layoutValue * 8 : layoutValue; + } else { + // In byte layout mode, layout must be in bytes + layout = effectiveSuffix === 'bit' ? Math.ceil(layoutValue / 8) : layoutValue; + } + } else { + layout = 16; + } + + // Parse size (supports suffix: b = bit, B = Byte, no suffix = depends on layoutUnit) and infer layoutUnit if not specified + let size: number; + if (typeof obj.size === 'number') { + size = obj.size; + } else if (typeof obj.size === 'string') { + const sizeStr = obj.size.trim(); + let sizeValue: number; + let sizeUnitSuffix: 'byte' | 'bit' | undefined; + + // Extract suffix + if (sizeStr.endsWith('b')) { + sizeValue = parseInt(sizeStr.slice(0, -1).trim()); + sizeUnitSuffix = 'bit'; + // Auto-infer layoutUnit from suffix if not explicitly set + if (!layoutUnit) layoutUnit = 'bit'; + } else if (sizeStr.endsWith('B')) { + sizeValue = parseInt(sizeStr.slice(0, -1).trim()); + sizeUnitSuffix = 'byte'; + } else { + sizeValue = parseInt(sizeStr); + sizeUnitSuffix = undefined; + } + + if (isNaN(sizeValue) || sizeValue <= 0) { + throw new ParseError('Invalid size value'); + } + + // Default layoutUnit to byte if still not set + const effectiveLayoutUnit = layoutUnit || 'byte'; + const effectiveSizeUnit = sizeUnitSuffix || effectiveLayoutUnit; + + // Convert to the layoutUnit + if (effectiveLayoutUnit === 'bit') { + // In bit layout mode, size must be in bits + size = effectiveSizeUnit === 'byte' ? sizeValue * 8 : sizeValue; + } else { + // In byte layout mode, size must be in bytes + size = effectiveSizeUnit === 'bit' ? Math.ceil(sizeValue / 8) : sizeValue; + } + } else { + throw new ParseError('Size must be a number or string'); + } // Parse fields const fields: Field[] = obj.fields.map((fieldObj, index) => { @@ -152,11 +242,27 @@ export function parse(source: string): ByteGridConfig { ? (obj.colorScheme as 'default' | 'dark' | 'light') : undefined; + // Parse legendPosition (optional) + const legendPosition = obj.legendPosition && typeof obj.legendPosition === 'string' + ? (obj.legendPosition as 'right' | 'left' | 'bottom' | 'none') + : undefined; + + // Parse showFooter (optional) + const showFooter = obj.showFooter !== undefined && typeof obj.showFooter === 'boolean' + ? obj.showFooter + : undefined; + + // Set final layoutUnit (default to byte if not inferred) + const finalLayoutUnit = layoutUnit || 'byte'; + return { name: obj.name, - size: obj.size, + size, layout, + layoutUnit: finalLayoutUnit, colorScheme, + legendPosition, + showFooter, fields, }; } diff --git a/packages/core/src/svgRenderer.ts b/packages/core/src/svgRenderer.ts index 0e9e852..30e4a12 100644 --- a/packages/core/src/svgRenderer.ts +++ b/packages/core/src/svgRenderer.ts @@ -3,7 +3,7 @@ * Renders LayoutBlocks to SVG string */ -import { ByteGridConfig, LayoutBlock, RenderOptions, ColorName } from './types'; +import { ByteGridConfig, LayoutBlock, RenderOptions, ColorName, LegendPosition } from './types'; /** * Color palette mapping @@ -26,6 +26,8 @@ const COLORS: Record = { const DEFAULT_OPTIONS: Required = { showHexDump: false, showLegend: true, + legendPosition: 'right', + showFooter: true, showGrid: true, cellWidth: 40, // Increased from 30 to make bit numbers readable cellHeight: 30, @@ -47,8 +49,35 @@ export function renderSVG( options?: RenderOptions ): string { const opts = { ...DEFAULT_OPTIONS, ...options }; + const layoutUnit = config.layoutUnit || 'byte'; const layout = config.layout || 16; + // Adjust cellWidth for bit layout + if (layoutUnit === 'bit' && !options?.cellWidth) { + opts.cellWidth = 10; // Smaller cells for bit-level layout + } + + // Determine legend position + // Priority: options.legendPosition > config.legendPosition > options.showLegend (backward compat) > default + let effectiveLegendPosition: LegendPosition; + if (options?.legendPosition !== undefined) { + effectiveLegendPosition = options.legendPosition; + } else if (config.legendPosition !== undefined) { + effectiveLegendPosition = config.legendPosition; + } else if (options?.showLegend === false) { + effectiveLegendPosition = 'none'; + } else { + effectiveLegendPosition = 'right'; // default + } + + // Determine showFooter + // Priority: options.showFooter > config.showFooter > default (true) + const effectiveShowFooter = options?.showFooter !== undefined + ? options.showFooter + : config.showFooter !== undefined + ? config.showFooter + : opts.showFooter; + // Detect rows with bitfields const rowsWithBitfields = new Set(); for (const block of blocks) { @@ -65,13 +94,33 @@ export function renderSVG( // Calculate dimensions const rows = Math.ceil(config.size / layout); const gridWidth = layout * opts.cellWidth; - const legendWidth = opts.showLegend ? 200 : 0; + const legendWidth = effectiveLegendPosition !== 'none' && effectiveLegendPosition !== 'bottom' ? 200 : 0; const margin = 20; // Count unique fields for legend height calculation const uniqueFieldNames = new Set(blocks.map((b) => b.fieldName)); const fieldCount = uniqueFieldNames.size; - const legendHeight = opts.showLegend && fieldCount > 0 ? fieldCount * 50 + 40 : 0; + + // Calculate legend height (for 'bottom' position or side positions) + let legendHeight = 0; + if (effectiveLegendPosition !== 'none' && fieldCount > 0) { + // Calculate actual height needed based on bitfields + let totalLegendHeight = 40; // Header space + const uniqueFields = new Map(); + for (const block of blocks) { + if (!uniqueFields.has(block.fieldName)) { + uniqueFields.set(block.fieldName, block); + } + } + for (const [, block] of uniqueFields) { + let entryHeight = 50; + if (block.bitfields && block.bitfields.length > 0) { + entryHeight = 50 + block.bitfields.length * 12; + } + totalLegendHeight += entryHeight; + } + legendHeight = totalLegendHeight; + } // Calculate grid height based on row heights let gridHeight = 0; @@ -89,14 +138,33 @@ export function renderSVG( } } - // Use the larger of grid height or legend height - const contentHeight = Math.max(gridHeight, legendHeight); + // Calculate total width and height based on legend position + const footerHeight = effectiveShowFooter ? 60 : 20; // Footer space or just bottom margin + let totalWidth: number; + let totalHeight: number; + let gridStartX: number; + let gridStartY: number; - const totalWidth = margin + gridWidth + margin + legendWidth + margin; - const totalHeight = margin + 60 + contentHeight + margin + 80; - - const gridStartX = margin; - const gridStartY = margin + 60; + if (effectiveLegendPosition === 'left') { + // Legend on left, grid on right + totalWidth = margin + legendWidth + margin + gridWidth + margin; + totalHeight = margin + 60 + Math.max(gridHeight, legendHeight) + margin + footerHeight; + gridStartX = margin + legendWidth + margin; + gridStartY = margin + 60; + } else if (effectiveLegendPosition === 'bottom') { + // Legend at bottom (with extra 30px spacing for title) + totalWidth = margin + gridWidth + margin; + totalHeight = margin + 60 + gridHeight + margin + 30 + legendHeight + footerHeight; + gridStartX = margin; + gridStartY = margin + 60; + } else { + // Legend on right (default) or none + const contentHeight = Math.max(gridHeight, legendHeight); + totalWidth = margin + gridWidth + margin + legendWidth + margin; + totalHeight = margin + 60 + contentHeight + margin + footerHeight; + gridStartX = margin; + gridStartY = margin + 60; + } // Calculate cumulative Y position for each row const rowYPositions = new Map(); @@ -125,12 +193,27 @@ export function renderSVG( // Title svg += `${escapeXml(config.name)}`; - // Column headers (byte offsets) + // Column headers if (opts.showGrid) { for (let col = 0; col < layout; col++) { const x = gridStartX + col * opts.cellWidth + opts.cellWidth / 2; const y = gridStartY - 10; - svg += `${col}`; + // For bit layout, show index 0-7 pattern, for byte layout show absolute position + const headerLabel = layoutUnit === 'bit' ? col % 8 : col; + svg += `${headerLabel}`; + } + } + + // Row headers (left side) for bit layout + if (opts.showGrid && layoutUnit === 'bit') { + for (let row = 0; row < rows; row++) { + const y = rowYPositions.get(row) || gridStartY; + const cellHeight = rowsWithBitfields.has(row) + ? (opts.uniformRowHeight && hasBitfields ? bitfieldCellHeight : bitfieldCellHeight) + : (opts.uniformRowHeight && hasBitfields ? bitfieldCellHeight : normalCellHeight); + const startOffset = row * layout; + const textY = y + cellHeight / 2 + 4; + svg += `${startOffset}`; } } @@ -145,16 +228,18 @@ export function renderSVG( // Draw block rectangle svg += ``; - // Draw byte numbers - for (let i = 0; i < block.span; i++) { - const byteNum = block.offsetStart + i; - const cellX = x + i * opts.cellWidth + opts.cellWidth / 2; - const cellY = y + (cellHeight / 3); // Upper third for byte number - svg += `${byteNum}`; + // Draw numbers inside cells (only for byte layout) + if (layoutUnit !== 'bit') { + for (let i = 0; i < block.span; i++) { + const byteNum = block.offsetStart + i; + const cellX = x + i * opts.cellWidth + opts.cellWidth / 2; + const cellY = y + (cellHeight / 3); // Upper third for byte number + svg += `${byteNum}`; + } } - // Draw bit grid for bitfield cells - if (block.bitfields && block.bitfields.length > 0) { + // Draw bit grid for bitfield cells (only in byte layout mode) + if (block.bitfields && block.bitfields.length > 0 && layoutUnit !== 'bit') { for (let i = 0; i < block.span; i++) { const cellX = x + i * opts.cellWidth; const bitCellWidth = opts.cellWidth / 8; @@ -175,12 +260,43 @@ export function renderSVG( } } - // Legend - if (opts.showLegend && blocks.length > 0) { - const legendX = margin + gridWidth + margin * 2; - const legendStartY = gridStartY; + // Draw bit boundary lines for bit layout + if (layoutUnit === 'bit') { + for (let col = 1; col < layout; col++) { + const lineX = gridStartX + col * opts.cellWidth; + // Every 8 bits: darker dotted line (byte boundary) + // Other bits: lighter dotted line (bit boundary) + if (col % 8 === 0) { + svg += ``; + } else { + svg += ``; + } + } + } - svg += `Fields`; + // Legend + if (effectiveLegendPosition !== 'none' && blocks.length > 0) { + let legendX: number; + let legendStartY: number; + let legendTitleY: number; + + // Position legend based on effectiveLegendPosition + if (effectiveLegendPosition === 'left') { + legendX = margin; + legendStartY = gridStartY; + legendTitleY = legendStartY - 20; + } else if (effectiveLegendPosition === 'bottom') { + legendX = margin; + legendStartY = gridStartY + gridHeight + margin + 30; // Extra space for title + legendTitleY = gridStartY + gridHeight + margin + 10; + } else { + // 'right' (default) + legendX = margin + gridWidth + margin * 2; + legendStartY = gridStartY; + legendTitleY = legendStartY - 20; + } + + svg += `Fields`; // Group blocks by field name to avoid duplicates const uniqueFields = new Map(); @@ -212,7 +328,9 @@ export function renderSVG( svg += `${escapeXml(block.fieldType)}`; // Offset info - svg += `offset: ${block.offsetStart}-${block.offsetEnd} (${block.offsetEnd - block.offsetStart + 1} bytes)`; + const unit = layoutUnit === 'bit' ? 'bits' : 'bytes'; + const size = block.offsetEnd - block.offsetStart + 1; + svg += `offset: ${block.offsetStart}-${block.offsetEnd} (${size} ${unit})`; // Bitfields (if any) if (block.bitfields && block.bitfields.length > 0) { @@ -231,8 +349,12 @@ export function renderSVG( } // Footer - const footerY = totalHeight - 40; - svg += `Total size: ${config.size} bytes | Layout: ${layout} bytes/row`; + if (effectiveShowFooter) { + const footerY = totalHeight - 40; + const sizeUnit = layoutUnit === 'bit' ? 'bits' : 'bytes'; + const layoutUnitText = layoutUnit === 'bit' ? 'bits/row' : 'bytes/row'; + svg += `Total size: ${config.size} ${sizeUnit} | Layout: ${layout} ${layoutUnitText}`; + } svg += ``; return svg; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 4a9cbc6..fcd438b 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -72,14 +72,27 @@ export interface Field { bitfields?: Bitfield[]; } +/** + * Layout unit type + */ +export type LayoutUnit = 'byte' | 'bit'; + +/** + * Legend position options + */ +export type LegendPosition = 'right' | 'left' | 'bottom' | 'none'; + /** * Main configuration for ByteGrid visualization */ export interface ByteGridConfig { name: string; - size: number; // Total size in bytes - layout?: number; // Bytes per row (default: 16) + size: number; // Total size in bytes (for bit layouts, this is total bits) + layout?: number; // Units per row (default: 16), unit depends on layoutUnit + layoutUnit?: LayoutUnit; // 'byte' (default) or 'bit' colorScheme?: ColorScheme; + legendPosition?: LegendPosition; // 'right' (default), 'left', 'bottom', or 'none' + showFooter?: boolean; // Show footer with total size and layout info (default: true) fields: Field[]; } @@ -90,6 +103,7 @@ export interface OffsetRange { start: number; end: number; size: number; + unit: LayoutUnit; // 'byte' or 'bit' } /** @@ -115,7 +129,9 @@ export interface LayoutBlock { */ export interface RenderOptions { showHexDump?: boolean; - showLegend?: boolean; + showLegend?: boolean; // Deprecated: use legendPosition instead + legendPosition?: LegendPosition; // 'right' (default), 'left', 'bottom', or 'none' + showFooter?: boolean; // Show footer with total size and layout info (default: true) showGrid?: boolean; cellWidth?: number; cellHeight?: number; diff --git a/packages/core/src/validator.ts b/packages/core/src/validator.ts index 66cfdb0..1f9a29e 100644 --- a/packages/core/src/validator.ts +++ b/packages/core/src/validator.ts @@ -26,6 +26,7 @@ const SUPPORTED_TYPES: Set = new Set([ 'double', 'reserved', 'padding', + 'bits', // For bit-level fields ]); /** @@ -47,10 +48,14 @@ const TYPE_BITS: Record = { /** * Parse offset string to OffsetRange - * Supports formats: "0-3" (range) or "4" (single byte) + * Supports formats: + * "0-3" (byte range), "4" (single byte) + * "0-7b" (bit range), "5b" (single bit) + * "0-3B" (byte range), "0B" (single byte) + * No suffix defaults to byte * * @param offset Offset string - * @returns Parsed offset range + * @returns Parsed offset range with unit * @throws ValidationError if format is invalid */ export function parseOffset(offset: string): OffsetRange { @@ -60,9 +65,21 @@ export function parseOffset(offset: string): OffsetRange { throw new ValidationError('Offset cannot be empty'); } + // Extract suffix (b = bit, B = Byte, none = byte default) + let unit: 'byte' | 'bit' = 'byte'; + let offsetWithoutSuffix = trimmed; + + if (trimmed.endsWith('b')) { + unit = 'bit'; + offsetWithoutSuffix = trimmed.slice(0, -1).trim(); + } else if (trimmed.endsWith('B')) { + unit = 'byte'; + offsetWithoutSuffix = trimmed.slice(0, -1).trim(); + } + // Check for range format "0-3" - if (trimmed.includes('-')) { - const parts = trimmed.split('-'); + if (offsetWithoutSuffix.includes('-')) { + const parts = offsetWithoutSuffix.split('-'); if (parts.length !== 2) { throw new ValidationError(`Invalid offset format: "${offset}"`); @@ -89,11 +106,12 @@ export function parseOffset(offset: string): OffsetRange { start, end, size: end - start + 1, + unit, }; } - // Single byte format "4" - const value = parseInt(trimmed); + // Single value format "4" + const value = parseInt(offsetWithoutSuffix); if (isNaN(value)) { throw new ValidationError(`Invalid offset format: "${offset}"`); @@ -107,6 +125,7 @@ export function parseOffset(offset: string): OffsetRange { start: value, end: value, size: 1, + unit, }; } @@ -168,9 +187,24 @@ function parseBitfieldBits(bits: string): { start: number; end: number } { * @throws ValidationError if validation fails */ export function validate(config: ByteGridConfig): void { + const layoutUnit = config.layoutUnit || 'byte'; + + // Convert offset to bits for unified comparison + const toBits = (offset: OffsetRange): { start: number; end: number } => { + if (offset.unit === 'bit') { + return { start: offset.start, end: offset.end }; + } + // Convert bytes to bits + return { start: offset.start * 8, end: offset.end * 8 + 7 }; + }; + + // Total size in bits + const totalSizeInBits = layoutUnit === 'bit' ? config.size : config.size * 8; + const parsedFields: Array<{ index: number; offset: OffsetRange; + offsetInBits: { start: number; end: number }; name: string; type: DataType; }> = []; @@ -190,10 +224,12 @@ export function validate(config: ByteGridConfig): void { ); } - // Validate field doesn't exceed total size - if (offset.end >= config.size) { + const offsetInBits = toBits(offset); + + // Validate field doesn't exceed total size (in bits) + if (offsetInBits.end >= totalSizeInBits) { throw new ValidationError( - `Field "${field.name}" at index ${i} (offset ${offset.start}-${offset.end}) exceeds total size ${config.size}`, + `Field "${field.name}" at index ${i} (offset ${field.offset}) exceeds total size ${config.size} ${layoutUnit}${config.size !== 1 ? 's' : ''}`, i ); } @@ -246,26 +282,29 @@ export function validate(config: ByteGridConfig): void { parsedFields.push({ index: i, offset, + offsetInBits, name: field.name, type: field.type, }); } - // Check for overlapping fields + // Check for overlapping fields (in bit space) for (let i = 0; i < parsedFields.length; i++) { for (let j = i + 1; j < parsedFields.length; j++) { const field1 = parsedFields[i]; const field2 = parsedFields[j]; - // Check if ranges overlap + // Check if ranges overlap (in bits) const overlap = - (field1.offset.start <= field2.offset.end && field1.offset.end >= field2.offset.start) || - (field2.offset.start <= field1.offset.end && field2.offset.end >= field1.offset.start); + (field1.offsetInBits.start <= field2.offsetInBits.end && field1.offsetInBits.end >= field2.offsetInBits.start) || + (field2.offsetInBits.start <= field1.offsetInBits.end && field2.offsetInBits.end >= field1.offsetInBits.start); if (overlap) { + const field1Desc = `${config.fields[field1.index].offset}`; + const field2Desc = `${config.fields[field2.index].offset}`; throw new ValidationError( - `Field "${field2.name}" at index ${field2.index} (offset ${field2.offset.start}-${field2.offset.end}) ` + - `overlaps with field "${field1.name}" (offset ${field1.offset.start}-${field1.offset.end})`, + `Field "${field2.name}" at index ${field2.index} (offset ${field2Desc}) ` + + `overlaps with field "${field1.name}" (offset ${field1Desc})`, field2.index ); } diff --git a/packages/core/tests/layoutEngine.test.ts b/packages/core/tests/layoutEngine.test.ts index 871e1ed..b915ce8 100644 --- a/packages/core/tests/layoutEngine.test.ts +++ b/packages/core/tests/layoutEngine.test.ts @@ -247,4 +247,141 @@ describe('LayoutEngine', () => { expect(blocks[3].col).toBe(12); }); }); + + describe('createLayout() - bit-unit layout', () => { + it('should create layout blocks for bit-unit fields', () => { + const config: ByteGridConfig = { + name: 'Bit Layout', + size: 16, // 16 bits + layoutUnit: 'bit', + layout: 16, // 16 bits per row + fields: [ + { offset: '0-7b', name: 'FirstByte', type: 'uint8_t', color: 'blue' }, + { offset: '8-15b', name: 'SecondByte', type: 'uint8_t', color: 'cyan' }, + ], + }; + + const blocks = createLayout(config); + + expect(blocks).toHaveLength(2); + + // FirstByte: bits 0-7 + expect(blocks[0].row).toBe(0); + expect(blocks[0].col).toBe(0); + expect(blocks[0].span).toBe(8); + expect(blocks[0].fieldName).toBe('FirstByte'); + expect(blocks[0].offsetStart).toBe(0); + expect(blocks[0].offsetEnd).toBe(7); + + // SecondByte: bits 8-15 + expect(blocks[1].row).toBe(0); + expect(blocks[1].col).toBe(8); + expect(blocks[1].span).toBe(8); + expect(blocks[1].fieldName).toBe('SecondByte'); + }); + + it('should handle Byte offset in bit layout', () => { + const config: ByteGridConfig = { + name: 'Mixed', + size: 16, // 16 bits + layoutUnit: 'bit', + layout: 16, + fields: [ + { offset: '0B', name: 'ByteField', type: 'uint8_t', color: 'blue' }, + ], + }; + + const blocks = createLayout(config); + + expect(blocks).toHaveLength(1); + // 0B = byte 0 = bits 0-7 + expect(blocks[0].row).toBe(0); + expect(blocks[0].col).toBe(0); + expect(blocks[0].span).toBe(8); + expect(blocks[0].offsetStart).toBe(0); + expect(blocks[0].offsetEnd).toBe(7); + }); + + it('should split bit fields across multiple rows', () => { + const config: ByteGridConfig = { + name: 'Multi-row Bits', + size: 32, // 32 bits + layoutUnit: 'bit', + layout: 16, // 16 bits per row + fields: [ + { offset: '0-23b', name: 'LargeBitField', type: 'bits', color: 'green' }, + ], + }; + + const blocks = createLayout(config); + + // Should split into 2 blocks (16 bits each) + expect(blocks).toHaveLength(2); + + // First row: bits 0-15 + expect(blocks[0].row).toBe(0); + expect(blocks[0].col).toBe(0); + expect(blocks[0].span).toBe(16); + expect(blocks[0].offsetStart).toBe(0); + expect(blocks[0].offsetEnd).toBe(15); + + // Second row: bits 16-23 + expect(blocks[1].row).toBe(1); + expect(blocks[1].col).toBe(0); + expect(blocks[1].span).toBe(8); + expect(blocks[1].offsetStart).toBe(16); + expect(blocks[1].offsetEnd).toBe(23); + }); + + it('should handle mixed bit and Byte offsets', () => { + const config: ByteGridConfig = { + name: 'IPv4 Header', + size: 32, // 32 bits + layoutUnit: 'bit', + layout: 32, + fields: [ + { offset: '0-3b', name: 'Version', type: 'bits', color: 'blue' }, + { offset: '4-7b', name: 'IHL', type: 'bits', color: 'cyan' }, + { offset: '1B', name: 'DSCP', type: 'uint8_t', color: 'yellow' }, + ], + }; + + const blocks = createLayout(config); + + expect(blocks).toHaveLength(3); + + // Version: bits 0-3 + expect(blocks[0].col).toBe(0); + expect(blocks[0].span).toBe(4); + + // IHL: bits 4-7 + expect(blocks[1].col).toBe(4); + expect(blocks[1].span).toBe(4); + + // DSCP: byte 1 = bits 8-15 + expect(blocks[2].col).toBe(8); + expect(blocks[2].span).toBe(8); + expect(blocks[2].offsetStart).toBe(8); + expect(blocks[2].offsetEnd).toBe(15); + }); + + it('should handle 4-bit fields', () => { + const config: ByteGridConfig = { + name: '4-bit Fields', + size: 8, // 8 bits = 1 byte + layoutUnit: 'bit', + layout: 8, + fields: [ + { offset: '0-3b', name: 'Nibble1', type: 'bits' }, + { offset: '4-7b', name: 'Nibble2', type: 'bits' }, + ], + }; + + const blocks = createLayout(config); + + expect(blocks).toHaveLength(2); + expect(blocks[0].span).toBe(4); + expect(blocks[1].span).toBe(4); + }); + }); }); diff --git a/packages/core/tests/parser.test.ts b/packages/core/tests/parser.test.ts index 94ba710..1ac0580 100644 --- a/packages/core/tests/parser.test.ts +++ b/packages/core/tests/parser.test.ts @@ -230,5 +230,399 @@ fields: expect(() => parse(yaml)).toThrow(ParseError); expect(() => parse(yaml)).toThrow(/field.*type/i); }); + + // Bit-unit layout tests + describe('Bit-unit layout', () => { + it('should parse layoutUnit: bit', () => { + const yaml = ` +name: Bit Layout Test +size: 2 +layoutUnit: bit +layout: 16 +fields: + - offset: 0-7b + name: BitField + type: uint8_t +`; + + const result = parse(yaml); + + expect(result.layoutUnit).toBe('bit'); + expect(result.layout).toBe(16); + }); + + it('should default layoutUnit to byte', () => { + const yaml = ` +name: Test +size: 16 +fields: + - offset: 0-3 + name: Field1 + type: uint32_t +`; + + const result = parse(yaml); + + expect(result.layoutUnit).toBe('byte'); + }); + + it('should parse bit offset with b suffix: 0-7b', () => { + const yaml = ` +name: Test +size: 2 +layoutUnit: bit +fields: + - offset: 0-7b + name: BitField + type: uint8_t +`; + + const result = parse(yaml); + + expect(result.fields[0].offset).toBe('0-7b'); + }); + + it('should parse Byte offset with B suffix: 0B', () => { + const yaml = ` +name: Test +size: 2 +layoutUnit: bit +fields: + - offset: 0B + name: ByteField + type: uint8_t +`; + + const result = parse(yaml); + + expect(result.fields[0].offset).toBe('0B'); + }); + + it('should parse offset without suffix as Byte', () => { + const yaml = ` +name: Test +size: 2 +layoutUnit: bit +fields: + - offset: 0-1 + name: TwoBytes + type: uint16_t +`; + + const result = parse(yaml); + + expect(result.fields[0].offset).toBe('0-1'); + }); + + it('should parse mixed bit and Byte offsets', () => { + const yaml = ` +name: IPv4 Header +size: 4 +layoutUnit: bit +layout: 32 +fields: + - offset: 0-3b + name: Version + type: bits + - offset: 4-7b + name: IHL + type: bits + - offset: 1B + name: DSCP + type: uint8_t +`; + + const result = parse(yaml); + + expect(result.fields).toHaveLength(3); + expect(result.fields[0].offset).toBe('0-3b'); + expect(result.fields[1].offset).toBe('4-7b'); + expect(result.fields[2].offset).toBe('1B'); + }); + + it('should parse size with B suffix (Byte)', () => { + const yaml = ` +name: Test +size: 4B +layoutUnit: bit +fields: + - offset: 0-31b + name: Field1 + type: uint32_t +`; + + const result = parse(yaml); + + expect(result.size).toBe(32); // 4 bytes = 32 bits (in bit layout mode) + }); + + it('should parse size with b suffix (bit)', () => { + const yaml = ` +name: Test +size: 32b +layoutUnit: bit +fields: + - offset: 0-31b + name: Field1 + type: uint32_t +`; + + const result = parse(yaml); + + expect(result.size).toBe(32); // 32 bits + }); + + it('should parse size without suffix (uses layoutUnit)', () => { + const yaml = ` +name: Test +size: 4 +layoutUnit: byte +fields: + - offset: 0-3 + name: Field1 + type: uint32_t +`; + + const result = parse(yaml); + + expect(result.size).toBe(4); // 4 bytes (layoutUnit=byte) + }); + + it('should parse layout with B suffix (Byte)', () => { + const yaml = ` +name: Test +size: 16b +layoutUnit: bit +layout: 2B +fields: + - offset: 0-15b + name: Field1 + type: uint16_t +`; + + const result = parse(yaml); + + expect(result.layout).toBe(16); // 2 Bytes = 16 bits (in bit layout mode) + }); + + it('should parse layout with b suffix (bit)', () => { + const yaml = ` +name: Test +size: 32b +layoutUnit: bit +layout: 24b +fields: + - offset: 0-31b + name: Field1 + type: uint32_t +`; + + const result = parse(yaml); + + expect(result.layout).toBe(24); // 24 bits + }); + + it('should parse layout without suffix (uses layoutUnit)', () => { + const yaml = ` +name: Test +size: 32 +layoutUnit: byte +layout: 8 +fields: + - offset: 0-31 + name: Field1 + type: uint8_t[32] +`; + + const result = parse(yaml); + + expect(result.layout).toBe(8); // 8 bytes (layoutUnit=byte) + }); + + it('should auto-infer layoutUnit=bit from b suffix', () => { + const yaml = ` +name: Test +size: 32b +layout: 16b +fields: + - offset: 0-7b + name: Field1 + type: uint8_t +`; + + const result = parse(yaml); + + expect(result.layoutUnit).toBe('bit'); // Auto-inferred from b suffix + expect(result.size).toBe(32); + expect(result.layout).toBe(16); + }); + + it('should default to byte when no layoutUnit and no b suffix', () => { + const yaml = ` +name: Test +size: 4B +layout: 16 +fields: + - offset: 0-3 + name: Field1 + type: uint32_t +`; + + const result = parse(yaml); + + expect(result.layoutUnit).toBe('byte'); // Default + expect(result.size).toBe(4); + expect(result.layout).toBe(16); + }); + + it('should prefer explicit layoutUnit over suffix inference', () => { + const yaml = ` +name: Test +size: 4B +layoutUnit: byte +layout: 16 +fields: + - offset: 0-31b + name: Field1 + type: bits +`; + + const result = parse(yaml); + + expect(result.layoutUnit).toBe('byte'); // Explicit wins + }); + }); + + // Legend position tests + describe('Legend position', () => { + it('should parse legendPosition: right', () => { + const yaml = ` +name: Test +size: 16 +legendPosition: right +fields: + - offset: 0-3 + name: Field1 + type: uint32_t +`; + + const result = parse(yaml); + + expect(result.legendPosition).toBe('right'); + }); + + it('should parse legendPosition: left', () => { + const yaml = ` +name: Test +size: 16 +legendPosition: left +fields: + - offset: 0-3 + name: Field1 + type: uint32_t +`; + + const result = parse(yaml); + + expect(result.legendPosition).toBe('left'); + }); + + it('should parse legendPosition: bottom', () => { + const yaml = ` +name: Test +size: 16 +legendPosition: bottom +fields: + - offset: 0-3 + name: Field1 + type: uint32_t +`; + + const result = parse(yaml); + + expect(result.legendPosition).toBe('bottom'); + }); + + it('should parse legendPosition: none', () => { + const yaml = ` +name: Test +size: 16 +legendPosition: none +fields: + - offset: 0-3 + name: Field1 + type: uint32_t +`; + + const result = parse(yaml); + + expect(result.legendPosition).toBe('none'); + }); + + it('should return undefined when legendPosition is not specified', () => { + const yaml = ` +name: Test +size: 16 +fields: + - offset: 0-3 + name: Field1 + type: uint32_t +`; + + const result = parse(yaml); + + expect(result.legendPosition).toBeUndefined(); + }); + }); + + // showFooter tests + describe('showFooter', () => { + it('should parse showFooter: true', () => { + const yaml = ` +name: Test +size: 16 +showFooter: true +fields: + - offset: 0-3 + name: Field1 + type: uint32_t +`; + + const result = parse(yaml); + + expect(result.showFooter).toBe(true); + }); + + it('should parse showFooter: false', () => { + const yaml = ` +name: Test +size: 16 +showFooter: false +fields: + - offset: 0-3 + name: Field1 + type: uint32_t +`; + + const result = parse(yaml); + + expect(result.showFooter).toBe(false); + }); + + it('should return undefined when showFooter is not specified', () => { + const yaml = ` +name: Test +size: 16 +fields: + - offset: 0-3 + name: Field1 + type: uint32_t +`; + + const result = parse(yaml); + + expect(result.showFooter).toBeUndefined(); + }); + }); }); }); diff --git a/packages/core/tests/svgRenderer.test.ts b/packages/core/tests/svgRenderer.test.ts index 22f172e..c494f5e 100644 --- a/packages/core/tests/svgRenderer.test.ts +++ b/packages/core/tests/svgRenderer.test.ts @@ -284,5 +284,204 @@ describe('SVGRenderer', () => { expect(svg).toContain('height="30"'); }); }); + + // Legend position tests + describe('Legend position', () => { + const legendConfig: ByteGridConfig = { + name: 'Legend Position Test', + size: 16, + layout: 16, + fields: [ + { offset: '0-3', name: 'Field1', type: 'uint32_t', color: 'blue' }, + { offset: '4-7', name: 'Field2', type: 'uint32_t', color: 'cyan' }, + ], + }; + + const legendBlocks: LayoutBlock[] = [ + { + row: 0, + col: 0, + span: 4, + fieldName: 'Field1', + fieldType: 'uint32_t', + color: 'blue', + offsetStart: 0, + offsetEnd: 3, + isPadding: false, + }, + { + row: 0, + col: 4, + span: 4, + fieldName: 'Field2', + fieldType: 'uint32_t', + color: 'cyan', + offsetStart: 4, + offsetEnd: 7, + isPadding: false, + }, + ]; + + it('should render legend on right by default', () => { + const svg = renderSVG(legendConfig, legendBlocks); + + // Should contain legend title + expect(svg).toContain('Fields'); + expect(svg).toContain('Field1'); + expect(svg).toContain('Field2'); + }); + + it('should render legend on right when legendPosition is "right"', () => { + const svg = renderSVG(legendConfig, legendBlocks, { + legendPosition: 'right', + }); + + expect(svg).toContain('Fields'); + expect(svg).toContain('Field1'); + expect(svg).toContain('Field2'); + }); + + it('should render legend on left when legendPosition is "left"', () => { + const svg = renderSVG(legendConfig, legendBlocks, { + legendPosition: 'left', + }); + + expect(svg).toContain('Fields'); + expect(svg).toContain('Field1'); + expect(svg).toContain('Field2'); + }); + + it('should render legend at bottom when legendPosition is "bottom"', () => { + const svg = renderSVG(legendConfig, legendBlocks, { + legendPosition: 'bottom', + }); + + expect(svg).toContain('Fields'); + expect(svg).toContain('Field1'); + expect(svg).toContain('Field2'); + }); + + it('should not render legend when legendPosition is "none"', () => { + const svg = renderSVG(legendConfig, legendBlocks, { + legendPosition: 'none', + }); + + // Should NOT contain legend + expect(svg).not.toContain('Fields'); + }); + + it('should not render legend when showLegend is false (backward compatibility)', () => { + const svg = renderSVG(legendConfig, legendBlocks, { + showLegend: false, + }); + + // Should NOT contain legend + expect(svg).not.toContain('Fields'); + }); + + it('should prioritize legendPosition over showLegend', () => { + // legendPosition: 'none' should override showLegend: true + const svg1 = renderSVG(legendConfig, legendBlocks, { + showLegend: true, + legendPosition: 'none', + }); + + expect(svg1).not.toContain('Fields'); + + // legendPosition: 'right' should override showLegend: false + const svg2 = renderSVG(legendConfig, legendBlocks, { + showLegend: false, + legendPosition: 'right', + }); + + expect(svg2).toContain('Fields'); + }); + + it('should use config.legendPosition when options.legendPosition is not provided', () => { + const configWithLegend: ByteGridConfig = { + ...legendConfig, + legendPosition: 'left', + }; + + const svg = renderSVG(configWithLegend, legendBlocks); + + expect(svg).toContain('Fields'); + }); + + it('should prioritize options.legendPosition over config.legendPosition', () => { + const configWithLegend: ByteGridConfig = { + ...legendConfig, + legendPosition: 'left', + }; + + const svg = renderSVG(configWithLegend, legendBlocks, { + legendPosition: 'none', + }); + + expect(svg).not.toContain('Fields'); + }); + + it('should respect config.legendPosition: none', () => { + const configWithLegend: ByteGridConfig = { + ...legendConfig, + legendPosition: 'none', + }; + + const svg = renderSVG(configWithLegend, legendBlocks); + + expect(svg).not.toContain('Fields'); + }); + }); + + // showFooter tests + describe('showFooter option', () => { + it('should render footer by default', () => { + const svg = renderSVG(sampleConfig, sampleBlocks); + + expect(svg).toContain('Total size:'); + expect(svg).toContain('Layout:'); + }); + + it('should render footer when showFooter is true in options', () => { + const svg = renderSVG(sampleConfig, sampleBlocks, { + showFooter: true, + }); + + expect(svg).toContain('Total size:'); + }); + + it('should not render footer when showFooter is false in options', () => { + const svg = renderSVG(sampleConfig, sampleBlocks, { + showFooter: false, + }); + + expect(svg).not.toContain('Total size:'); + expect(svg).not.toContain('Layout:'); + }); + + it('should use config.showFooter when options.showFooter is not provided', () => { + const configWithFooter: ByteGridConfig = { + ...sampleConfig, + showFooter: false, + }; + + const svg = renderSVG(configWithFooter, sampleBlocks); + + expect(svg).not.toContain('Total size:'); + }); + + it('should prioritize options.showFooter over config.showFooter', () => { + const configWithFooter: ByteGridConfig = { + ...sampleConfig, + showFooter: true, + }; + + const svg = renderSVG(configWithFooter, sampleBlocks, { + showFooter: false, + }); + + expect(svg).not.toContain('Total size:'); + }); + }); }); }); diff --git a/packages/core/tests/validator.test.ts b/packages/core/tests/validator.test.ts index 8139aaa..4765b4f 100644 --- a/packages/core/tests/validator.test.ts +++ b/packages/core/tests/validator.test.ts @@ -246,4 +246,137 @@ describe('Validator', () => { expect(() => validate(config)).toThrow(/bit.*out of range/i); }); }); + + describe('parseOffset() - bit/byte suffix', () => { + it('should parse bit offset with b suffix: 0-7b', () => { + const result = parseOffset('0-7b'); + + expect(result.start).toBe(0); + expect(result.end).toBe(7); + expect(result.size).toBe(8); + expect(result.unit).toBe('bit'); + }); + + it('should parse single bit with b suffix: 5b', () => { + const result = parseOffset('5b'); + + expect(result.start).toBe(5); + expect(result.end).toBe(5); + expect(result.size).toBe(1); + expect(result.unit).toBe('bit'); + }); + + it('should parse Byte offset with B suffix: 0B', () => { + const result = parseOffset('0B'); + + expect(result.start).toBe(0); + expect(result.end).toBe(0); + expect(result.size).toBe(1); + expect(result.unit).toBe('byte'); + }); + + it('should parse Byte range with B suffix: 1-3B', () => { + const result = parseOffset('1-3B'); + + expect(result.start).toBe(1); + expect(result.end).toBe(3); + expect(result.size).toBe(3); + expect(result.unit).toBe('byte'); + }); + + it('should default to byte unit when no suffix: 0-3', () => { + const result = parseOffset('0-3'); + + expect(result.start).toBe(0); + expect(result.end).toBe(3); + expect(result.size).toBe(4); + expect(result.unit).toBe('byte'); + }); + + it('should parse with whitespace: 0 - 7b', () => { + const result = parseOffset('0 - 7b'); + + expect(result.start).toBe(0); + expect(result.end).toBe(7); + expect(result.size).toBe(8); + expect(result.unit).toBe('bit'); + }); + }); + + describe('validate() - bit-unit layout', () => { + it('should validate bit-unit layout with bit offsets', () => { + const config: ByteGridConfig = { + name: 'Bit Layout', + size: 16, // 16 bits = 2 bytes + layoutUnit: 'bit', + layout: 16, + fields: [ + { offset: '0-7b', name: 'FirstByte', type: 'uint8_t' }, + { offset: '8-15b', name: 'SecondByte', type: 'uint8_t' }, + ], + }; + + expect(() => validate(config)).not.toThrow(); + }); + + it('should validate mixed bit and Byte offsets', () => { + const config: ByteGridConfig = { + name: 'Mixed Layout', + size: 32, // 32 bits = 4 bytes + layoutUnit: 'bit', + layout: 32, + fields: [ + { offset: '0-3b', name: 'Version', type: 'bits' }, + { offset: '4-7b', name: 'IHL', type: 'bits' }, + { offset: '1B', name: 'DSCP', type: 'uint8_t' }, + ], + }; + + expect(() => validate(config)).not.toThrow(); + }); + + it('should detect overlap in bit-unit layout', () => { + const config: ByteGridConfig = { + name: 'Overlap Test', + size: 16, // 16 bits = 2 bytes + layoutUnit: 'bit', + fields: [ + { offset: '0-7b', name: 'Field1', type: 'uint8_t' }, + { offset: '4-11b', name: 'Field2', type: 'uint8_t' }, // overlaps bits 4-7 + ], + }; + + expect(() => validate(config)).toThrow(ValidationError); + expect(() => validate(config)).toThrow(/overlap/i); + }); + + it('should detect overlap between bit and Byte offsets', () => { + const config: ByteGridConfig = { + name: 'Mixed Overlap', + size: 16, // 16 bits = 2 bytes + layoutUnit: 'bit', + fields: [ + { offset: '0-3b', name: 'Version', type: 'bits' }, + { offset: '0B', name: 'FullByte', type: 'uint8_t' }, // overlaps bits 0-3 + ], + }; + + expect(() => validate(config)).toThrow(ValidationError); + expect(() => validate(config)).toThrow(/overlap/i); + }); + + it('should throw ValidationError if bit offset exceeds size', () => { + const config: ByteGridConfig = { + name: 'Size Test', + size: 16, // 16 bits + layoutUnit: 'bit', + fields: [ + { offset: '0-19b', name: 'TooBig', type: 'bits' }, // exceeds 16 bits + ], + }; + + expect(() => validate(config)).toThrow(ValidationError); + expect(() => validate(config)).toThrow(/exceeds total size/i); + }); + }); });