From 81bcd1f2bc2df6a15c0a9c013c028b8b060260bb Mon Sep 17 00:00:00 2001 From: leeliu103 Date: Fri, 5 Dec 2025 21:50:12 +0000 Subject: [PATCH] Add field-level validation for LinearLayout dimension inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements real-time validation with visual feedback for LinearLayout tab: - Name validation: must be non-empty after trimming - Size validation: must be power of 2 and >= 2 - Red border highlighting on invalid fields - Inline error messages below each field - Warning-style status message for overall validation state Creates LinearLayoutValidator following InputValidator pattern: - Extracted validation logic to src/validation/LinearLayoutValidator.ts - Added comprehensive unit tests (7 tests covering all validation rules) - Proper dependency flow: validator owns types, tab imports them UI improvements: - Added .input-error CSS class for invalid field styling - Added .layout-status CSS class matching validation-warnings style - Prevents matrix updates when validation fails Core library unchanged: - Validation enforced only at UI layer (LinearLayoutTab) - Core LinearLayout.ts supports all valid cases including size=1 - Added regression test for degenerate (size=1) dimension handling - BlockLayout, MFMALayout, WMMALayout unaffected 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/core/LinearLayout.test.ts | 11 ++ src/styles.css | 25 ++++ src/tabs/LinearLayoutTab.ts | 134 +++++++++++++++++-- src/validation/LinearLayoutValidator.test.ts | 82 ++++++++++++ src/validation/LinearLayoutValidator.ts | 35 +++++ 5 files changed, 274 insertions(+), 13 deletions(-) create mode 100644 src/validation/LinearLayoutValidator.test.ts create mode 100644 src/validation/LinearLayoutValidator.ts diff --git a/src/core/LinearLayout.test.ts b/src/core/LinearLayout.test.ts index b2788c0..8378f27 100644 --- a/src/core/LinearLayout.test.ts +++ b/src/core/LinearLayout.test.ts @@ -199,6 +199,17 @@ describe('LinearLayout', () => { expect(result.dim0).toBe(3) expect(result.dim1).toBe(3) }) + + it('should multiply when a layout contributes a degenerate output dimension', () => { + const regular = LinearLayout.identity1D(4, 'active', 'dim0') + const degenerate = LinearLayout.identity1D(1, 'stub', 'dim1') + + const combined = regular.multiply(degenerate) + + expect(combined.getOutDimSize('dim0')).toBe(4) + expect(combined.getOutDimSize('dim1')).toBe(1) + expect(combined.apply({ active: 3, stub: 0 })).toEqual({ dim0: 3, dim1: 0 }) + }) }) describe('invert', () => { diff --git a/src/styles.css b/src/styles.css index 942d807..247873e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -144,6 +144,16 @@ select:focus { box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1); } +.input-error { + border-color: #e74c3c !important; + box-shadow: 0 0 0 3px rgba(231, 76, 60, 0.15); +} + +.input-error:focus { + border-color: #e74c3c; + box-shadow: 0 0 0 3px rgba(231, 76, 60, 0.25); +} + button { width: 100%; padding: 0.75rem; @@ -447,6 +457,21 @@ button:active { margin: 0.5rem 0 0 0; } +.layout-status { + margin-top: 0.75rem; + padding: 0.75rem; + border-radius: 4px; + font-size: 0.85rem; + background-color: #fff3cd; + border: 1px solid #ffc107; + color: #856404; + display: none; +} + +.layout-status.visible { + display: block; +} + .layout-tooltip { background-color: rgba(33, 37, 41, 0.92); color: #f8f9fa; diff --git a/src/tabs/LinearLayoutTab.ts b/src/tabs/LinearLayoutTab.ts index de7689d..88514f3 100644 --- a/src/tabs/LinearLayoutTab.ts +++ b/src/tabs/LinearLayoutTab.ts @@ -1,5 +1,10 @@ import { LinearLayout } from '../core/LinearLayout' import type { BlockLayoutParams } from '../validation/InputValidator' +import { + LinearLayoutValidator, + type DimensionFieldErrors, + type LinearDimension, +} from '../validation/LinearLayoutValidator' import { CanvasRenderer, type CellInfo } from '../visualization/CanvasRenderer' import { LinearLayoutMatrixEditor, type MatrixEditorDimensions } from '../ui/LinearLayoutMatrixEditor' import { renderSharedControls } from '../ui/renderSharedControls' @@ -7,12 +12,6 @@ import { CanvasTab, type CanvasTabElements } from './CanvasTab' type DimensionType = 'input' | 'output' -interface LinearDimension { - id: string - name: string - size: number -} - interface BasisColumnDescriptor { dimName: string bitIndex: number @@ -40,8 +39,11 @@ export class LinearLayoutTab extends CanvasTab { private readonly matrixEditor: LinearLayoutMatrixEditor private readonly layoutStatus: HTMLElement private readonly colorDimensionSelect: HTMLSelectElement + private readonly validator: LinearLayoutValidator private dimensionState: Record private selectedColorDimensionId: string | null + private hasDimensionValidationErrors: boolean + private showingValidationStatus: boolean constructor(tabId: string) { const tabContent = document.getElementById(tabId) @@ -84,6 +86,7 @@ export class LinearLayoutTab extends CanvasTab { super(elements) + this.validator = new LinearLayoutValidator() this.sidebar = sidebar this.form = form this.dimensionState = { @@ -125,6 +128,8 @@ export class LinearLayoutTab extends CanvasTab { matrixButton.insertAdjacentElement('afterend', this.layoutStatus) this.colorDimensionSelect = colorSelect this.selectedColorDimensionId = null + this.hasDimensionValidationErrors = false + this.showingValidationStatus = false this.colorDimensionSelect.addEventListener('change', () => { this.handleColorDimensionSelectionChange() }) @@ -132,6 +137,10 @@ export class LinearLayoutTab extends CanvasTab { this.toggleSidebarInteractivity(isOpen) }) this.matrixEditor.onMatrixChange((matrix) => { + if (this.hasDimensionValidationErrors) { + this.updateValidationStatus(true) + return + } this.rebuildLayoutFromMatrix(matrix) }) @@ -229,26 +238,35 @@ export class LinearLayoutTab extends CanvasTab { nameLabel.textContent = 'Name:' const nameInput = document.createElement('input') nameInput.type = 'text' + nameInput.dataset.field = 'name' nameInput.value = dimension.name nameInput.addEventListener('input', (event) => { dimension.name = (event.target as HTMLInputElement).value - this.handleDimensionFieldChange() + this.handleDimensionFieldChange(type) }) nameLabel.appendChild(nameInput) + const nameError = this.createDimensionErrorElement('name') + const sizeLabel = document.createElement('label') sizeLabel.textContent = 'Size:' const sizeInput = document.createElement('input') sizeInput.type = 'number' sizeInput.min = '2' - sizeInput.value = dimension.size.toString() + sizeInput.step = '1' + sizeInput.dataset.field = 'size' + sizeInput.value = Number.isFinite(dimension.size) ? dimension.size.toString() : '' sizeInput.addEventListener('input', (event) => { - const value = Number((event.target as HTMLInputElement).value) - dimension.size = Number.isFinite(value) ? value : 0 - this.handleDimensionFieldChange() + const target = event.target as HTMLInputElement + const rawValue = target.value.trim() + const numericValue = rawValue === '' ? Number.NaN : Number(rawValue) + dimension.size = Number.isFinite(numericValue) ? numericValue : Number.NaN + this.handleDimensionFieldChange(type) }) sizeLabel.appendChild(sizeInput) + const sizeError = this.createDimensionErrorElement('size') + const removeButton = document.createElement('button') removeButton.type = 'button' removeButton.className = 'dimension-remove' @@ -261,7 +279,9 @@ export class LinearLayoutTab extends CanvasTab { }) row.appendChild(nameLabel) + row.appendChild(nameError) row.appendChild(sizeLabel) + row.appendChild(sizeError) row.appendChild(removeButton) list.appendChild(row) @@ -271,6 +291,7 @@ export class LinearLayoutTab extends CanvasTab { if (type === 'input') { this.updateColorDimensionOptions() } + this.applyDimensionValidationState() this.syncEditorAndLayout() } @@ -283,12 +304,18 @@ export class LinearLayoutTab extends CanvasTab { } } - private handleDimensionFieldChange(): void { - this.updateColorDimensionOptions() + private handleDimensionFieldChange(type: DimensionType): void { + if (type === 'input') { + this.updateColorDimensionOptions() + } + this.applyDimensionValidationState() this.syncEditorAndLayout() } private syncEditorAndLayout(): void { + if (this.hasDimensionValidationErrors) { + return + } const emittedMatrixChange = this.matrixEditor.updateDimensions(this.getMatrixDimensions()) if (!emittedMatrixChange) { this.rebuildLayoutFromMatrix() @@ -296,6 +323,10 @@ export class LinearLayoutTab extends CanvasTab { } private rebuildLayoutFromMatrix(matrixSnapshot?: number[][]): void { + if (this.hasDimensionValidationErrors) { + this.updateValidationStatus(true) + return + } if (this.dimensionState.input.length === 0 || this.dimensionState.output.length === 0) { this.setLayoutStatus('Add at least one input and one output dimension.') return @@ -730,6 +761,83 @@ export class LinearLayoutTab extends CanvasTab { return digits.padStart(width, '0') } + private createDimensionErrorElement(field: 'name' | 'size'): HTMLDivElement { + const element = document.createElement('div') + element.className = 'dimension-error' + element.dataset.errorField = field + return element + } + + private applyDimensionValidationState(): boolean { + const errorsByType: Record> = { + input: {}, + output: {}, + } + let hasErrors = false + + ;(['input', 'output'] as const).forEach((type) => { + this.dimensionState[type].forEach((dimension) => { + const fieldErrors = this.validator.validateDimension(dimension) + errorsByType[type][dimension.id] = fieldErrors + if (fieldErrors.name || fieldErrors.size) { + hasErrors = true + } + }) + }) + + ;(['input', 'output'] as const).forEach((type) => { + const list = this.dimensionLists[type] + if (!list) { + return + } + Object.entries(errorsByType[type]).forEach(([id, fieldErrors]) => { + const row = list.querySelector(`.dimension-row[data-dimension-id="${id}"]`) + if (!row) { + return + } + this.applyFieldValidation(row, 'name', fieldErrors.name) + this.applyFieldValidation(row, 'size', fieldErrors.size) + }) + }) + + this.hasDimensionValidationErrors = hasErrors + this.updateValidationStatus(hasErrors) + return hasErrors + } + + private applyFieldValidation(row: HTMLElement, field: 'name' | 'size', message?: string): void { + const input = row.querySelector(`input[data-field="${field}"]`) + const errorElement = row.querySelector(`[data-error-field="${field}"]`) + + if (input) { + if (message) { + input.classList.add('input-error') + input.setAttribute('aria-invalid', 'true') + } else { + input.classList.remove('input-error') + input.removeAttribute('aria-invalid') + } + } + + if (errorElement) { + errorElement.textContent = message ?? '' + errorElement.classList.toggle('visible', Boolean(message)) + } + } + + private updateValidationStatus(hasErrors: boolean): void { + if (hasErrors) { + this.showingValidationStatus = true + this.setLayoutStatus('Fix highlighted dimension fields') + return + } + + if (this.showingValidationStatus) { + this.showingValidationStatus = false + this.setLayoutStatus('') + } + } + private getBitWidthFromSize(size: number): number { if (!Number.isFinite(size) || size < 2) { return 1 diff --git a/src/validation/LinearLayoutValidator.test.ts b/src/validation/LinearLayoutValidator.test.ts new file mode 100644 index 0000000..5b89a70 --- /dev/null +++ b/src/validation/LinearLayoutValidator.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest' +import { LinearLayoutValidator } from './LinearLayoutValidator' + +describe('LinearLayoutValidator', () => { + const validator = new LinearLayoutValidator() + + describe('valid dimensions', () => { + it('accepts dimension with trimmed name and power-of-two size', () => { + const result = validator.validateDimension({ + id: 'dim-1', + name: ' thread ', + size: 32, + }) + + expect(result).toEqual({}) + }) + + it('accepts smallest valid size of 2', () => { + const result = validator.validateDimension({ + id: 'dim-2', + name: 'reg', + size: 2, + }) + + expect(result).toEqual({}) + }) + }) + + describe('invalid names', () => { + it('rejects empty name', () => { + const result = validator.validateDimension({ + id: 'dim-3', + name: '', + size: 8, + }) + + expect(result.name).toBe('Name is required') + }) + + it('rejects whitespace-only name', () => { + const result = validator.validateDimension({ + id: 'dim-4', + name: ' ', + size: 8, + }) + + expect(result.name).toBe('Name is required') + }) + }) + + describe('invalid sizes', () => { + it('rejects non-integer sizes', () => { + const result = validator.validateDimension({ + id: 'dim-5', + name: 'warp', + size: 4.5, + }) + + expect(result.size).toBe('Size must be an integer') + }) + + it('rejects sizes below 2', () => { + const result = validator.validateDimension({ + id: 'dim-6', + name: 'warp', + size: 1, + }) + + expect(result.size).toBe('Size must be at least 2') + }) + + it('rejects sizes that are not powers of two', () => { + const result = validator.validateDimension({ + id: 'dim-7', + name: 'warp', + size: 6, + }) + + expect(result.size).toBe('Size must be a power of 2') + }) + }) +}) diff --git a/src/validation/LinearLayoutValidator.ts b/src/validation/LinearLayoutValidator.ts new file mode 100644 index 0000000..b30f0bb --- /dev/null +++ b/src/validation/LinearLayoutValidator.ts @@ -0,0 +1,35 @@ +export interface LinearDimension { + id: string + name: string + size: number +} + +export interface DimensionFieldErrors { + name?: string + size?: string +} + +export class LinearLayoutValidator { + validateDimension(dimension: LinearDimension): DimensionFieldErrors { + const errors: DimensionFieldErrors = {} + + if (!dimension.name.trim()) { + errors.name = 'Name is required' + } + + const size = dimension.size + if (!Number.isInteger(size)) { + errors.size = 'Size must be an integer' + } else if (size < 2) { + errors.size = 'Size must be at least 2' + } else if (!this.isPowerOfTwo(size)) { + errors.size = 'Size must be a power of 2' + } + + return errors + } + + private isPowerOfTwo(value: number): boolean { + return Number.isInteger(value) && value >= 2 && (value & (value - 1)) === 0 + } +}