Files
Genarrative/apps/ai-game-creator-shell/tests/uiEditorTextRendering.test.tsx
T
k88936 0797410eef
Project CI / Repository checks (push) Successful in 3m18s
Project CI / Frontend tests (push) Successful in 3m32s
Project CI / Backend tests (push) Successful in 4m24s
Project CI / Native shell tests (push) Successful in 15m34s
Feat/UI自动识别和布局编辑器简化版 (#170)
* 支持从项目, 本地电脑, 主站导入/引用素材.
* 页面复杂, 需要足够空间, 作为资源打开时会占满右侧
* 编辑结果作为一种新的美术资源.
* 新增素材按钮现在是空的实现, 临时在旁边加了一个新增UI设计的按钮

临时入口:
![shotmd-1787128060.jpg](/attachments/349a4fa8-7abe-4bbb-80e0-73b1717b6488)

简化:
* 组件实现了最基本的图片和文本
* 容器式布局未接入llm编辑
* 不同阶段统一维护一个状态State, 任何阶段都可以人工调整, 不设单独的步骤
* 多图的UI合并功能暂时不要求, 隐藏入口
* 因为生成工具尚未从主站迁移, 主站地图子画布未实现, 以tab栏的形式容纳素材的子画布需求搁置
* 未实现llm绑定字体功能
* 识别会替换现有UI树, 暂时不是增量的

Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/170
Co-authored-by: 王德宇 <kvtodev@outlook.com>
Co-committed-by: 王德宇 <kvtodev@outlook.com>
2026-08-20 19:07:25 +08:00

179 lines
5.3 KiB
TypeScript

// @vitest-environment jsdom
import { act, render } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { TextComponent } from '../src/features/ui-editor/types/TextComponent';
import {
findLargestFittingFontSize,
fontFamilyToCss,
fontStyleToCss,
SYSTEM_UI_FONT_STACK,
} from '../src/features/ui-editor/utils/textStyleToCss';
import { TextComponentView } from '../src/view/ui-editor/components/preview/components/TextComponentView';
function text(overrides: Partial<TextComponent> = {}): TextComponent {
return {
content: '测试文本',
font: 'SystemFont',
font_style: 'Normal',
font_sizing: { Fixed: 14 },
color: [0, 0, 0, 255],
alignment: 'UpperLeft',
horizontal_overflow: 'Wrap',
vertical_overflow: 'Truncate',
line_spacing: 1,
...overrides,
};
}
const resources = {
previewUrls: {},
sprites: {},
fontFaces: {},
};
afterEach(() => {
vi.unstubAllGlobals();
});
describe('UI 编辑器文本样式算法', () => {
it.each([
['Normal', { fontStyle: 'normal', fontWeight: 400 }],
['Bold', { fontStyle: 'normal', fontWeight: 700 }],
['Italic', { fontStyle: 'italic', fontWeight: 400 }],
['BoldItalic', { fontStyle: 'italic', fontWeight: 700 }],
] as const)('maps %s to browser font properties', (style, expected) => {
expect(fontStyleToCss(style)).toEqual(expected);
});
it('uses a loaded bound font and falls back for all other states', () => {
expect(
fontFamilyToCss(
{ Bound: 'body' },
{
body: { cssFamily: 'ui-editor-font-body', status: 'loaded' },
},
),
).toBe('ui-editor-font-body');
for (const status of ['loading', 'error'] as const) {
expect(
fontFamilyToCss(
{ Bound: 'body' },
{
body: { cssFamily: 'ui-editor-font-body', status },
},
),
).toBe(SYSTEM_UI_FONT_STACK);
}
expect(fontFamilyToCss({ Bound: 'missing' }, {})).toBe(
SYSTEM_UI_FONT_STACK,
);
});
it('finds the greatest fitting integer and retains min if none fits', () => {
expect(findLargestFittingFontSize(8, 20, (size) => size <= 15)).toBe(15);
expect(findLargestFittingFontSize(8, 20, () => false)).toBe(8);
});
});
describe('TextComponentView', () => {
it('applies a loaded bound font and font style to the preview', () => {
render(
<TextComponentView
component={text({
font: { Bound: 'body' },
font_style: 'BoldItalic',
})}
resources={{
...resources,
fontFaces: {
body: { cssFamily: 'ui-editor-font-body', status: 'loaded' },
},
}}
/>,
);
const view = document.querySelector(
'[data-component-kind="Text"]',
) as HTMLDivElement;
expect(view.style.fontFamily).toBe('ui-editor-font-body');
expect(view.style.fontStyle).toBe('italic');
expect(view.style.fontWeight).toBe('700');
});
it('recalculates BestFit after its container is measured and resized', () => {
let observerCallback: ResizeObserverCallback | undefined;
class TestResizeObserver {
constructor(callback: ResizeObserverCallback) {
observerCallback = callback;
}
observe = vi.fn();
disconnect = vi.fn();
}
vi.stubGlobal('ResizeObserver', TestResizeObserver);
render(
<TextComponentView
component={text({ font_sizing: { BestFit: { min: 8, max: 20 } } })}
resources={resources}
/>,
);
const view = document.querySelector(
'[data-component-kind="Text"]',
) as HTMLDivElement;
const measurement = document.querySelector(
'[data-ui-editor-best-fit-measurement="true"]',
) as HTMLDivElement;
let width = 120;
Object.defineProperty(view, 'clientWidth', {
configurable: true,
get: () => width,
});
Object.defineProperty(view, 'clientHeight', {
configurable: true,
get: () => 24,
});
Object.defineProperty(measurement, 'scrollWidth', {
configurable: true,
get: () => Number.parseInt(measurement.style.fontSize, 10) * 8,
});
Object.defineProperty(measurement, 'scrollHeight', {
configurable: true,
get: () => 20,
});
act(() => observerCallback?.([], {} as ResizeObserver));
expect(view.style.fontSize).toBe('15px');
width = 72;
act(() => observerCallback?.([], {} as ResizeObserver));
expect(view.style.fontSize).toBe('9px');
});
it('does not restart BestFit measurement when an equivalent component is cloned', () => {
const observers: Array<{ disconnect: ReturnType<typeof vi.fn> }> = [];
class TestResizeObserver {
readonly disconnect = vi.fn();
constructor(_callback: ResizeObserverCallback) {
observers.push(this);
}
observe = vi.fn();
}
vi.stubGlobal('ResizeObserver', TestResizeObserver);
const original = text({ font_sizing: { BestFit: { min: 8, max: 20 } } });
const view = render(
<TextComponentView component={original} resources={resources} />,
);
view.rerender(
<TextComponentView
component={structuredClone(original)}
resources={resources}
/>,
);
expect(observers).toHaveLength(1);
expect(observers[0]!.disconnect).not.toHaveBeenCalled();
});
});