0797410eef
* 支持从项目, 本地电脑, 主站导入/引用素材. * 页面复杂, 需要足够空间, 作为资源打开时会占满右侧 * 编辑结果作为一种新的美术资源. * 新增素材按钮现在是空的实现, 临时在旁边加了一个新增UI设计的按钮 临时入口:  简化: * 组件实现了最基本的图片和文本 * 容器式布局未接入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>
179 lines
5.3 KiB
TypeScript
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();
|
|
});
|
|
});
|