禁止 UI Transform 倒置锚点

持久化 State 校验拒绝 anchor_min 大于 anchor_max

tf2css 在转换边界拒绝倒置 anchor 并更新回归测试
This commit is contained in:
2026-08-18 16:31:59 +08:00
parent d49143fc7b
commit d00fbd29cc
3 changed files with 90 additions and 5 deletions
@@ -536,6 +536,16 @@ fn validate_node(
{
return Err("UI 节点 Transform 必须是有限数值".to_string());
}
if node
.layout
.transform
.anchor_min
.iter()
.zip(node.layout.transform.anchor_max.iter())
.any(|(min, max)| min > max)
{
return Err("UI 节点 Transform 的 anchor 必须按 min 到 max 排序".to_string());
}
if !node
.layout
.custom_minimum_size
@@ -882,6 +892,64 @@ mod tests {
assert_eq!(loaded.state, state);
}
#[test]
fn rejects_inverted_transform_anchors() {
let state: State = serde_json::from_value(serde_json::json!({
"ui_trees": [{
"src_ui_design": "page",
"root": {
"id": "root",
"layout": {
"transform": {
"anchor_min": [0.8, 0.0],
"anchor_max": [0.2, 1.0],
"offset_min": [0.0, 0.0],
"offset_max": [0.0, 0.0]
},
"custom_minimum_size": [0.0, 0.0],
"size_flags_horizontal": 1,
"size_flags_vertical": 1,
"size_flags_stretch_ratio": 1.0,
"container": "None"
},
"metadata": {
"name": "根节点",
"description": "",
"layout_status": "Pending",
"components_status": "Pending",
"allow_llm_edit_layout": true,
"allow_llm_edit_component": true,
"source": "System"
},
"components": [],
"children_display_mode": "Stack",
"children": []
}
}],
"ui_design_images": {
"page": {
"metadata": {
"name": "主界面",
"description": "",
"role": "Page",
"slave_to": null
},
"path": "missing.png",
"pixel_size": [1280.0, 720.0],
"pixels_per_unit": 1.0
}
},
"sprite_assets": {},
"font_assets": {}
}))
.expect("deserialize state with inverted anchors");
assert_eq!(
validate_state(&state),
Err("UI 节点 Transform 的 anchor 必须按 min 到 max 排序".to_string())
);
}
#[test]
fn recovers_last_valid_state_when_primary_is_corrupt() {
let (directory, asset_id) = fixture();
@@ -105,20 +105,20 @@ describe('tf2css', () => {
});
});
it('preserves out-of-range anchors and normalizes negative zero', () => {
it('preserves out-of-range ordered anchors and normalizes negative zero', () => {
expect(
tf2css({
anchor_min: [-0.25, 1.25],
anchor_max: [1.5, -0.5],
anchor_min: [-0.25, -0.5],
anchor_max: [1.5, 1.25],
offset_min: [-0, -0],
offset_max: [-0, -0],
}),
).toEqual({
position: 'absolute',
left: '-25%',
top: '125%',
top: '-50%',
right: '-50%',
bottom: '150%',
bottom: '-25%',
});
expect(tf2css(STRETCH_TRANSFORM)).toEqual({
@@ -157,4 +157,15 @@ describe('tf2css', () => {
),
);
});
it('rejects inverted anchors', () => {
expect(() =>
tf2css({
anchor_min: [0.8, 0],
anchor_max: [0.2, 1],
offset_min: [0, 0],
offset_max: [0, 0],
}),
).toThrowError('Transform anchors must be ordered');
});
});
@@ -69,6 +69,12 @@ function toCssLength(percentage: number, pixelOffset: number): string {
*/
export function tf2css(transform: Transform): CSSProperties {
assertFiniteTransform(transform);
if (
transform.anchor_min[0] > transform.anchor_max[0] ||
transform.anchor_min[1] > transform.anchor_max[1]
) {
throw new RangeError('Transform anchors must be ordered');
}
return {
position: 'absolute',