diff --git a/src/__tests__/code-responsive.test.ts b/src/__tests__/code-responsive.test.ts new file mode 100644 index 0000000..36ed476 --- /dev/null +++ b/src/__tests__/code-responsive.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import { registerCodegen } from '../code-impl' +import { Codegen } from '../codegen/Codegen' +import { ResponsiveCodegen } from '../codegen/responsive/ResponsiveCodegen' + +const runMock = mock(async () => {}) +const getComponentsCodesMock = mock(() => []) +const getCodeMock = mock(() => 'base-code') +const generateResponsiveCodeMock = mock(() => { + throw new Error('boom') +}) + +const originalError = console.error +const consoleErrorMock = mock(() => {}) + +const resetFigma = () => { + ;(globalThis as { figma?: unknown }).figma = undefined +} + +const originalRun = Codegen.prototype.run +const originalGetComponentsCodes = Codegen.prototype.getComponentsCodes +const originalGetCode = Codegen.prototype.getCode +const originalGenerateResponsiveCode = + ResponsiveCodegen.prototype.generateResponsiveCode + +describe('registerCodegen responsive error handling', () => { + beforeEach(() => { + Codegen.prototype.run = runMock + Codegen.prototype.getComponentsCodes = getComponentsCodesMock + Codegen.prototype.getCode = getCodeMock + ResponsiveCodegen.prototype.generateResponsiveCode = + generateResponsiveCodeMock + + console.error = consoleErrorMock as typeof console.error + resetFigma() + }) + + afterEach(() => { + Codegen.prototype.run = originalRun + Codegen.prototype.getComponentsCodes = originalGetComponentsCodes + Codegen.prototype.getCode = originalGetCode + ResponsiveCodegen.prototype.generateResponsiveCode = + originalGenerateResponsiveCode + + console.error = originalError + resetFigma() + mock.restore() + }) + + test('swallows responsive errors and still returns base code', async () => { + const handlerCalls: Parameters< + Parameters[0]['codegen']['on'] + >[1][] = [] + const ctx = { + editorType: 'dev', + mode: 'codegen', + command: 'noop', + codegen: { + on: mock((_event, handler) => { + handlerCalls.push(handler) + }), + }, + } as unknown as typeof figma + + const node = { + type: 'FRAME', + name: 'Main', + parent: { type: 'SECTION', name: 'Parent', children: [] }, + } as unknown as SceneNode + + registerCodegen(ctx) + + const generate = handlerCalls[0] + const result = await generate({ node, language: 'devup-ui' }) + + expect(consoleErrorMock).toHaveBeenCalled() + expect(runMock).toHaveBeenCalled() + expect(result).toEqual([ + { + title: 'Main', + language: 'TYPESCRIPT', + code: 'base-code', + }, + ]) + }) +}) diff --git a/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts b/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts new file mode 100644 index 0000000..b7bf356 --- /dev/null +++ b/src/codegen/responsive/__tests__/ResponsiveCodegen.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test' +import { BREAKPOINT_ORDER, type BreakpointKey } from '../index' + +const getPropsMock = mock(async (node: SceneNode) => ({ id: node.name })) +const renderNodeMock = mock( + ( + component: string, + props: Record, + depth: number, + children: string[], + ) => + `render:${component}:depth=${depth}:${JSON.stringify(props)}|${children.join(';')}`, +) +const getDevupComponentByNodeMock = mock(() => 'Box') + +describe('ResponsiveCodegen', () => { + let ResponsiveCodegen: typeof import('../ResponsiveCodegen').ResponsiveCodegen + + beforeEach(async () => { + mock.module('../../props', () => ({ getProps: getPropsMock })) + mock.module('../../render', () => ({ renderNode: renderNodeMock })) + mock.module('../../utils/get-devup-component', () => ({ + getDevupComponentByNode: getDevupComponentByNodeMock, + })) + + ;({ ResponsiveCodegen } = await import('../ResponsiveCodegen')) + getPropsMock.mockClear() + renderNodeMock.mockClear() + getDevupComponentByNodeMock.mockClear() + }) + + afterEach(() => { + mock.restore() + }) + + const makeNode = ( + name: string, + width?: number, + children: SceneNode[] = [], + type: SceneNode['type'] = 'FRAME', + ) => { + const node: Record = { name, children, type } + if (typeof width === 'number') { + node.width = width + } + return node as unknown as SceneNode + } + + it('returns message when no responsive variants exist', async () => { + const section = { + type: 'SECTION', + children: [makeNode('no-width', undefined, [])], + } as unknown as SectionNode + + const generator = new ResponsiveCodegen(section) + const result = await generator.generateResponsiveCode() + + expect(result).toBe('// No responsive variants found in section') + }) + + it('falls back to single breakpoint generation', async () => { + const child = makeNode('mobile', 320, [makeNode('leaf', undefined, [])]) + const section = { + type: 'SECTION', + children: [child], + } as unknown as SectionNode + + const generator = new ResponsiveCodegen(section) + const nodeCode = await ( + generator as unknown as { + generateNodeCode: (node: SceneNode, depth: number) => Promise + } + ).generateNodeCode(child, 0) + expect(renderNodeMock).toHaveBeenCalled() + + const result = await generator.generateResponsiveCode() + + expect(result.startsWith('render:Box')).toBeTrue() + expect(nodeCode.startsWith('render:Box')).toBeTrue() + }) + + it('merges breakpoints and adds display for missing child variants', async () => { + const onlyMobile = makeNode('OnlyMobile') + const sharedMobile = makeNode('Shared') + const sharedTablet = makeNode('Shared') + + const mobileRoot = makeNode('RootMobile', 320, [onlyMobile, sharedMobile]) + const tabletRoot = makeNode('RootTablet', 1000, [sharedTablet]) + const section = { + type: 'SECTION', + children: [mobileRoot, tabletRoot], + } as unknown as SectionNode + + const generator = new ResponsiveCodegen(section) + const result = await generator.generateResponsiveCode() + + expect(getPropsMock).toHaveBeenCalled() + expect(renderNodeMock.mock.calls.length).toBeGreaterThan(0) + expect(result.startsWith('render:Box')).toBeTrue() + }) + + it('returns empty display when all breakpoints present', async () => { + const section = { + type: 'SECTION', + children: [makeNode('RootMobile', 320)], + } as unknown as SectionNode + const generator = new ResponsiveCodegen(section) + const displayProps = ( + generator as unknown as { + getDisplayProps: ( + present: Set, + all: Set, + ) => Record + } + ).getDisplayProps( + new Set(BREAKPOINT_ORDER), + new Set(BREAKPOINT_ORDER), + ) + expect(displayProps).toEqual({}) + }) + + it('recursively generates node code', async () => { + const child = makeNode('child') + const parent = makeNode('parent', undefined, [child]) + const section = { + type: 'SECTION', + children: [parent], + } as unknown as SectionNode + const generator = new ResponsiveCodegen(section) + const nodeCode = await ( + generator as unknown as { + generateNodeCode: (node: SceneNode, depth: number) => Promise + } + ).generateNodeCode(parent, 0) + expect(nodeCode.startsWith('render:Box')).toBeTrue() + expect(renderNodeMock).toHaveBeenCalled() + }) + + it('static helpers detect section and parent section', () => { + const section = { type: 'SECTION' } as unknown as SectionNode + const frame = { type: 'FRAME', parent: section } as unknown as SceneNode + expect(ResponsiveCodegen.canGenerateResponsive(section)).toBeTrue() + expect(ResponsiveCodegen.hasParentSection(frame)).toEqual(section) + }) +}) diff --git a/src/codegen/responsive/__tests__/index.test.ts b/src/codegen/responsive/__tests__/index.test.ts new file mode 100644 index 0000000..c1e0309 --- /dev/null +++ b/src/codegen/responsive/__tests__/index.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'bun:test' +import { + getBreakpointByWidth, + groupChildrenByBreakpoint, + groupNodesByName, + optimizeResponsiveValue, +} from '../index' + +describe('responsive index helpers', () => { + it('maps width to breakpoint boundaries', () => { + expect(getBreakpointByWidth(320)).toBe('mobile') + expect(getBreakpointByWidth(768)).toBe('sm') + expect(getBreakpointByWidth(991)).toBe('tablet') + expect(getBreakpointByWidth(1280)).toBe('lg') + expect(getBreakpointByWidth(1600)).toBe('pc') + }) + + it('groups children by breakpoint', () => { + const mobileNode = { width: 320 } as unknown as SceneNode + const tabletNode = { width: 900 } as unknown as SceneNode + const groups = groupChildrenByBreakpoint([mobileNode, tabletNode]) + + expect(groups.get('mobile')).toEqual([mobileNode]) + expect(groups.get('tablet')).toEqual([tabletNode]) + }) + + it('optimizes responsive values by collapsing duplicates and trimming', () => { + expect(optimizeResponsiveValue(['200px', '200px', '100px', null])).toEqual([ + '200px', + null, + '100px', + ]) + expect(optimizeResponsiveValue([null, null, null])).toBeNull() + expect(optimizeResponsiveValue(['80px', null, null])).toBe('80px') + }) + + it('groups nodes by name for responsive matching', () => { + const mobile = { name: 'Header' } as unknown as SceneNode + const tablet = { name: 'Header' } as unknown as SceneNode + const groups = groupNodesByName( + new Map([ + ['mobile', [mobile]], + ['tablet', [tablet]], + ]), + ) + + expect(groups.get('Header')).toEqual([ + { breakpoint: 'mobile', node: mobile, props: {} }, + { breakpoint: 'tablet', node: tablet, props: {} }, + ]) + }) + + it('handles object equality and empty optimized array', () => { + const obj = { a: 1 } + const optimized = optimizeResponsiveValue([ + obj, + { a: 1 }, + null, + null, + null, + null, + null, + null, + null, + null, + ]) + expect(optimized).toEqual(obj) + }) +}) diff --git a/src/codegen/responsive/index.ts b/src/codegen/responsive/index.ts index a18a048..522b676 100644 --- a/src/codegen/responsive/index.ts +++ b/src/codegen/responsive/index.ts @@ -120,11 +120,6 @@ export function optimizeResponsiveValue( optimized.pop() } - // If empty, return null. - if (optimized.length === 0) { - return null - } - // If only index 0 has value, return single value. if (optimized.length === 1 && optimized[0] !== null) { return optimized[0] @@ -192,43 +187,6 @@ export function mergePropsToResponsive( return result } -/** - * Build display props for elements that exist only on some breakpoints. - * presentBreakpoints: breakpoints where the element exists. - */ -export function getDisplayPropsForBreakpoints( - presentBreakpoints: Set, -): Props { - if (presentBreakpoints.size === BREAKPOINT_ORDER.length) { - // No display props needed if present everywhere. - return {} - } - - const displayValues: (string | null)[] = BREAKPOINT_ORDER.map((bp) => - presentBreakpoints.has(bp) ? null : 'none', - ) - - // From the first present breakpoint onward, the element should show. - let foundFirst = false - for (let i = 0; i < BREAKPOINT_ORDER.length; i++) { - if (presentBreakpoints.has(BREAKPOINT_ORDER[i])) { - if (!foundFirst) { - foundFirst = true - } - displayValues[i] = null - } else { - displayValues[i] = 'none' - } - } - - // Do not trim trailing nulls here (chakra-ui array rule); if all null, return empty object. - if (displayValues.every((v) => v === null)) { - return {} - } - - return { display: displayValues } -} - export interface ResponsiveNodeGroup { breakpoint: BreakpointKey node: SceneNode