Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@ <h3>Tensor Shape</h3>
<div id="validation-errors" class="validation-errors"></div>
<div id="validation-warnings" class="validation-warnings"></div>

<button type="submit">Update Visualization</button>
<button type="button" id="show-linear-layout">Show in Linear Layout</button>
</form>

Expand Down
12 changes: 9 additions & 3 deletions src/main.tabs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ const mockParams: BlockLayoutParams = {

const getParamsMock = vi.fn()
const validateMock = vi.fn()
const onSubmitMock = vi.fn()
const onParamsChangeMock = vi.fn()
const onValidationChangeMock = vi.fn()
vi.mock('./ui/ParameterForm', () => ({
ParameterForm: vi.fn().mockImplementation(() => ({
getParams: getParamsMock,
validate: validateMock,
onSubmit: onSubmitMock,
onParamsChange: onParamsChangeMock,
onValidationChange: onValidationChangeMock,
})),
}))

Expand Down Expand Up @@ -426,7 +428,11 @@ describe('main tab switching', () => {
validateMock.mockReset()
validateMock.mockReturnValue(true)

onSubmitMock.mockReset()
onParamsChangeMock.mockReset()
onValidationChangeMock.mockReset()
onValidationChangeMock.mockImplementation((callback: (isValid: boolean) => void) => {
callback(true)
})
projectionListeners.clear()
tabActivationHandler = null
subscribeMock.mockClear()
Expand Down
10 changes: 10 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,16 @@ button:active {
background-color: #21618c;
}

button:disabled {
background-color: #95a5a6;
cursor: not-allowed;
opacity: 0.65;
}

button:disabled:hover {
background-color: #95a5a6;
}

.dimension-add {
width: 100%;
padding: 0.5rem;
Expand Down
73 changes: 70 additions & 3 deletions src/tabs/BlockLayoutTab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import { layoutProjectionBus, LINEAR_LAYOUT_TAB_ID } from '../integration/Layout
type ParameterFormStub = {
getParams: ReturnType<typeof vi.fn>
validate: ReturnType<typeof vi.fn>
onSubmit: ReturnType<typeof vi.fn>
onParamsChange: ReturnType<typeof vi.fn>
onValidationChange: ReturnType<typeof vi.fn>
triggerParamsChange?: (params: BlockLayoutParams) => void
triggerValidationChange?: (isValid: boolean) => void
}

const defaultParams: BlockLayoutParams = {
Expand All @@ -33,11 +36,35 @@ const parameterFormInstances: ParameterFormStub[] = []

vi.mock('../ui/ParameterForm', () => ({
ParameterForm: vi.fn().mockImplementation(() => {
let lastValidationNotification: boolean | null = null
const instance: ParameterFormStub = {
getParams: vi.fn(() => currentParams),
validate: vi.fn(() => validateResult),
onSubmit: vi.fn(),
validate: vi.fn(),
onParamsChange: vi.fn(),
onValidationChange: vi.fn(),
triggerParamsChange: undefined,
triggerValidationChange: undefined,
}
instance.validate.mockImplementation(() => {
if (
typeof instance.triggerValidationChange === 'function' &&
lastValidationNotification !== validateResult
) {
instance.triggerValidationChange(validateResult)
}
lastValidationNotification = validateResult
return validateResult
})
instance.onParamsChange.mockImplementation((callback: (params: BlockLayoutParams) => void) => {
instance.triggerParamsChange = callback
})
instance.onValidationChange.mockImplementation((callback: (isValid: boolean) => void) => {
instance.triggerValidationChange = (state: boolean) => {
lastValidationNotification = state
callback(state)
}
instance.triggerValidationChange(validateResult)
})
parameterFormInstances.push(instance)
return instance
}),
Expand Down Expand Up @@ -135,6 +162,46 @@ describe('BlockLayoutTab', () => {
document.body.innerHTML = ''
})

it('re-renders automatically when parameters change', () => {
instantiateTab()
expect(canvasRendererInstances.length).toBe(1)

const formInstance = parameterFormInstances[0]
expect(formInstance?.triggerParamsChange).toBeDefined()

currentParams = {
sizePerThread: [4, 1],
threadsPerWarp: [2, 16],
warpsPerCTA: [1, 1],
order: [1, 0],
tensorShape: [32, 8],
}

formInstance?.triggerParamsChange?.(currentParams)

expect(canvasRendererInstances.length).toBe(2)
})

it('toggles the Show in Linear Layout button based on validation state changes', () => {
instantiateTab()
const formInstance = parameterFormInstances[0]
const button = document.getElementById('show-linear-layout') as HTMLButtonElement
expect(button.disabled).toBe(false)

formInstance?.triggerValidationChange?.(false)
expect(button.disabled).toBe(true)

formInstance?.triggerValidationChange?.(true)
expect(button.disabled).toBe(false)
})

it('initializes with the button disabled when the form is invalid', () => {
validateResult = false
instantiateTab()
const button = document.getElementById('show-linear-layout') as HTMLButtonElement
expect(button.disabled).toBe(true)
})

it('publishes snapshots that normalize lane naming and filter size-1 dimensions', () => {
instantiateTab()
currentParams = {
Expand Down
17 changes: 14 additions & 3 deletions src/tabs/BlockLayoutTab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,18 @@ export class BlockLayoutTab extends CanvasTab {

this.tabId = tabId
this.form = new ParameterForm('paramForm')
const linearLayoutButton = this.setupShowInLinearLayoutButton()
if (linearLayoutButton) {
this.monitorShowInLinearLayoutButton(linearLayoutButton)
}
this.setupFormHandlers()
this.setupShowInLinearLayoutButton()
}

/**
* Initialize form listeners and kick off the first render.
*/
private setupFormHandlers(): void {
this.form.onSubmit((params) => {
this.form.onParamsChange((params) => {
this.updateVisualization(params)
})

Expand All @@ -66,7 +69,7 @@ export class BlockLayoutTab extends CanvasTab {
}
}

private setupShowInLinearLayoutButton(): void {
private setupShowInLinearLayoutButton(): HTMLButtonElement | null {
this.setupLinearLayoutProjection({
buttonSelector: '#show-linear-layout',
missingButtonWarning: 'Show in Linear Layout button not found in BlockLayoutTab.',
Expand All @@ -80,6 +83,14 @@ export class BlockLayoutTab extends CanvasTab {
errorLogContext: 'Block Layout',
errorAlertMessage: 'Failed to convert Block Layout into Linear Layout. See console for details.',
})

return this.root.querySelector<HTMLButtonElement>('#show-linear-layout')
}

private monitorShowInLinearLayoutButton(button: HTMLButtonElement): void {
this.form.onValidationChange((isValid) => {
button.disabled = !isValid
})
}

/**
Expand Down
76 changes: 76 additions & 0 deletions src/ui/ParameterForm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, it, beforeEach, expect, vi } from 'vitest'
import { ParameterForm } from './ParameterForm'

const buildFormDom = (): void => {
document.body.innerHTML = `
<div>
<form id="paramForm">
<input id="sizePerThread0" type="number" value="2" />
<input id="sizePerThread1" type="number" value="2" />

<input id="threadsPerWarp0" type="number" value="8" />
<input id="threadsPerWarp1" type="number" value="4" />

<input id="warpsPerCTA0" type="number" value="1" />
<input id="warpsPerCTA1" type="number" value="2" />

<select id="order">
<option value="0,1" selected>0,1</option>
<option value="1,0">1,0</option>
</select>

<input id="tensorShape0" type="number" value="16" />
<input id="tensorShape1" type="number" value="16" />
</form>
<div id="validation-errors"></div>
<div id="validation-warnings"></div>
</div>
`
}

describe('ParameterForm', () => {
beforeEach(() => {
buildFormDom()
})

it('shows inline errors for decimal input instead of throwing', () => {
const form = new ParameterForm('paramForm')
const sizeInput = document.getElementById('sizePerThread0') as HTMLInputElement
sizeInput.value = '2.5'

const isValid = form.validate()
expect(isValid).toBe(false)

const errorsDiv = document.getElementById('validation-errors') as HTMLElement
expect(errorsDiv.classList.contains('visible')).toBe(true)
expect(errorsDiv.textContent).toContain('Size per thread')
})

it('suppresses callbacks and flips validation state when parse errors occur', () => {
const form = new ParameterForm('paramForm')
const changeSpy = vi.fn()
const validationSpy = vi.fn()
form.onParamsChange(changeSpy)
form.onValidationChange(validationSpy)
validationSpy.mockClear()

const tpwInput = document.getElementById('threadsPerWarp0') as HTMLInputElement
tpwInput.value = 'abc'

const invalidEvent = new Event('input', { bubbles: true })
tpwInput.dispatchEvent(invalidEvent)

expect(changeSpy).not.toHaveBeenCalled()
expect(validationSpy).toHaveBeenCalledWith(false)

const params = form.getParams()
expect(Number.isNaN(params.threadsPerWarp[0])).toBe(true)

validationSpy.mockClear()
tpwInput.value = '8'
tpwInput.dispatchEvent(new Event('input', { bubbles: true }))

expect(changeSpy).toHaveBeenCalledTimes(1)
expect(validationSpy).toHaveBeenCalledWith(true)
})
})
50 changes: 41 additions & 9 deletions src/ui/ParameterForm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export class ParameterForm {
private errorsDiv: HTMLElement
private warningsDiv: HTMLElement
private validator: InputValidator
private validationListeners = new Set<(isValid: boolean) => void>()
private lastValidationResult: boolean | null = null

constructor(formId: string) {
const form = document.getElementById(formId)
Expand Down Expand Up @@ -74,6 +76,7 @@ export class ParameterForm {
this.hideWarnings()
}

this.updateValidationState(result.valid)
return result.valid
}

Expand Down Expand Up @@ -115,17 +118,45 @@ export class ParameterForm {
this.warningsDiv.innerHTML = ''
}

private updateValidationState(nextState: boolean): void {
if (this.lastValidationResult === nextState) {
return
}
this.lastValidationResult = nextState
this.validationListeners.forEach((listener) => listener(nextState))
}

/**
* Add event listener for form submission
* Invoke the callback whenever any form field changes and the inputs validate.
*/
onSubmit(callback: (params: BlockLayoutParams) => void): void {
this.form.addEventListener('submit', (event) => {
event.preventDefault()

onParamsChange(callback: (params: BlockLayoutParams) => void): void {
const handleChange = (): void => {
if (this.validate()) {
callback(this.getParams())
}
}

const fields = this.form.querySelectorAll<HTMLInputElement | HTMLSelectElement>('input, select')
fields.forEach((field) => {
const eventName = field instanceof HTMLSelectElement ? 'change' : 'input'
field.addEventListener(eventName, handleChange)
})

this.form.addEventListener('submit', (event) => event.preventDefault())
}

/**
* Notify listeners whenever the validation state changes.
*/
onValidationChange(callback: (isValid: boolean) => void): void {
this.validationListeners.add(callback)

if (this.lastValidationResult === null) {
this.validate()
return
}

callback(this.lastValidationResult)
}

/**
Expand All @@ -136,11 +167,12 @@ export class ParameterForm {
if (!input) {
throw new Error(`Input not found: ${id}`)
}
const value = Number(input.value)
if (!Number.isFinite(value) || !Number.isInteger(value)) {
throw new Error(`Invalid number value for ${id}: ${input.value}`)
const rawValue = input.value.trim()
if (rawValue === '') {
return Number.NaN
}
return value
const numericValue = Number(rawValue)
return Number.isFinite(numericValue) ? numericValue : Number.NaN
}

/**
Expand Down
Loading