This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
input:
|
||||
layout_preset: grid-2x2
|
||||
zones_data:
|
||||
- position: top-left
|
||||
template_id: MOCK_top-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: top-right
|
||||
template_id: MOCK_top-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-left
|
||||
template_id: MOCK_bottom-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-right
|
||||
template_id: MOCK_bottom-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"top-left top-right" "bottom-left bottom-right"'
|
||||
cols: 583px 583px
|
||||
rows: 286px 285px
|
||||
heights_px:
|
||||
- 286
|
||||
- 285
|
||||
widths_px:
|
||||
- 583
|
||||
- 583
|
||||
ratios:
|
||||
- 0.489
|
||||
- 0.487
|
||||
width_ratios:
|
||||
- 0.494
|
||||
- 0.494
|
||||
computation: 2d_dynamic_aggregated
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,63 @@
|
||||
input:
|
||||
layout_preset: grid-2x2
|
||||
zones_data:
|
||||
- position: top-left
|
||||
template_id: MOCK_top-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: top-right
|
||||
template_id: MOCK_top-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-left
|
||||
template_id: MOCK_bottom-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-right
|
||||
template_id: MOCK_bottom-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
top-left:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 0.55
|
||||
h: 0.4
|
||||
top-right:
|
||||
x: 0.55
|
||||
y: 0
|
||||
w: 0.45
|
||||
h: 0.4
|
||||
bottom-left:
|
||||
x: 0
|
||||
y: 0.4
|
||||
w: 0.55
|
||||
h: 0.6
|
||||
bottom-right:
|
||||
x: 0.55
|
||||
y: 0.4
|
||||
w: 0.45
|
||||
h: 0.6
|
||||
expected_layout_css:
|
||||
areas: '"top-left top-right" "bottom-left bottom-right"'
|
||||
cols: 641px 525px
|
||||
rows: 228px 343px
|
||||
heights_px:
|
||||
- 228
|
||||
- 343
|
||||
widths_px:
|
||||
- 641
|
||||
- 525
|
||||
ratios:
|
||||
- 0.4
|
||||
- 0.6
|
||||
width_ratios:
|
||||
- 0.55
|
||||
- 0.45
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,31 @@
|
||||
input:
|
||||
layout_preset: horizontal-2
|
||||
zones_data:
|
||||
- position: top
|
||||
template_id: MOCK_top
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: bottom
|
||||
template_id: MOCK_bottom
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"top" "bottom"'
|
||||
cols: 1fr
|
||||
rows: 286px 285px
|
||||
heights_px:
|
||||
- 286
|
||||
- 285
|
||||
widths_px:
|
||||
- 1180
|
||||
ratios:
|
||||
- 0.489
|
||||
- 0.487
|
||||
width_ratios:
|
||||
- 1.0
|
||||
computation: min_height_first + content_weight_distribution
|
||||
dynamic_rows: true
|
||||
dynamic_cols: false
|
||||
@@ -0,0 +1,41 @@
|
||||
input:
|
||||
layout_preset: horizontal-2
|
||||
zones_data:
|
||||
- position: top
|
||||
template_id: MOCK_top
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: bottom
|
||||
template_id: MOCK_bottom
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
top:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 1.0
|
||||
h: 0.3
|
||||
bottom:
|
||||
x: 0
|
||||
y: 0.3
|
||||
w: 1.0
|
||||
h: 0.7
|
||||
expected_layout_css:
|
||||
areas: '"top" "bottom"'
|
||||
cols: 1fr
|
||||
rows: 176px 410px
|
||||
heights_px:
|
||||
- 176
|
||||
- 410
|
||||
widths_px:
|
||||
- 1180
|
||||
ratios:
|
||||
- 0.3
|
||||
- 0.7
|
||||
width_ratios:
|
||||
- 1.0
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: true
|
||||
dynamic_cols: false
|
||||
@@ -0,0 +1,31 @@
|
||||
input:
|
||||
layout_preset: horizontal-2
|
||||
zones_data:
|
||||
- position: top
|
||||
template_id: MOCK_top
|
||||
content_weight:
|
||||
score: 0.8
|
||||
min_height_px: 200
|
||||
- position: bottom
|
||||
template_id: MOCK_bottom
|
||||
content_weight:
|
||||
score: 0.2
|
||||
min_height_px: 150
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"top" "bottom"'
|
||||
cols: 1fr
|
||||
rows: 377px 194px
|
||||
heights_px:
|
||||
- 377
|
||||
- 194
|
||||
widths_px:
|
||||
- 1180
|
||||
ratios:
|
||||
- 0.644
|
||||
- 0.332
|
||||
width_ratios:
|
||||
- 1.0
|
||||
computation: min_height_first + content_weight_distribution
|
||||
dynamic_rows: true
|
||||
dynamic_cols: false
|
||||
@@ -0,0 +1,38 @@
|
||||
input:
|
||||
layout_preset: left-1-right-2
|
||||
zones_data:
|
||||
- position: left
|
||||
template_id: MOCK_left
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: right-top
|
||||
template_id: MOCK_right-top
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: right-bottom
|
||||
template_id: MOCK_right-bottom
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"left right-top" "left right-bottom"'
|
||||
cols: 777px 389px
|
||||
rows: 286px 285px
|
||||
heights_px:
|
||||
- 286
|
||||
- 285
|
||||
widths_px:
|
||||
- 777
|
||||
- 389
|
||||
ratios:
|
||||
- 0.489
|
||||
- 0.487
|
||||
width_ratios:
|
||||
- 0.658
|
||||
- 0.33
|
||||
computation: 2d_dynamic_aggregated
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,53 @@
|
||||
input:
|
||||
layout_preset: left-1-right-2
|
||||
zones_data:
|
||||
- position: left
|
||||
template_id: MOCK_left
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: right-top
|
||||
template_id: MOCK_right-top
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: right-bottom
|
||||
template_id: MOCK_right-bottom
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
left:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 0.4
|
||||
h: 1.0
|
||||
right-top:
|
||||
x: 0.4
|
||||
y: 0
|
||||
w: 0.6
|
||||
h: 0.5
|
||||
right-bottom:
|
||||
x: 0.4
|
||||
y: 0.5
|
||||
w: 0.6
|
||||
h: 0.5
|
||||
expected_layout_css:
|
||||
areas: '"left right-top" "left right-bottom"'
|
||||
cols: 466px 700px
|
||||
rows: 286px 285px
|
||||
heights_px:
|
||||
- 286
|
||||
- 285
|
||||
widths_px:
|
||||
- 466
|
||||
- 700
|
||||
ratios:
|
||||
- 0.5
|
||||
- 0.5
|
||||
width_ratios:
|
||||
- 0.4
|
||||
- 0.6
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,38 @@
|
||||
input:
|
||||
layout_preset: left-2-right-1
|
||||
zones_data:
|
||||
- position: left-top
|
||||
template_id: MOCK_left-top
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: left-bottom
|
||||
template_id: MOCK_left-bottom
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: right
|
||||
template_id: MOCK_right
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"left-top right" "left-bottom right"'
|
||||
cols: 389px 777px
|
||||
rows: 286px 285px
|
||||
heights_px:
|
||||
- 286
|
||||
- 285
|
||||
widths_px:
|
||||
- 389
|
||||
- 777
|
||||
ratios:
|
||||
- 0.489
|
||||
- 0.487
|
||||
width_ratios:
|
||||
- 0.33
|
||||
- 0.658
|
||||
computation: 2d_dynamic_aggregated
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,53 @@
|
||||
input:
|
||||
layout_preset: left-2-right-1
|
||||
zones_data:
|
||||
- position: left-top
|
||||
template_id: MOCK_left-top
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: left-bottom
|
||||
template_id: MOCK_left-bottom
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: right
|
||||
template_id: MOCK_right
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
left-top:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 0.35
|
||||
h: 0.6
|
||||
left-bottom:
|
||||
x: 0
|
||||
y: 0.6
|
||||
w: 0.35
|
||||
h: 0.4
|
||||
right:
|
||||
x: 0.35
|
||||
y: 0
|
||||
w: 0.65
|
||||
h: 1.0
|
||||
expected_layout_css:
|
||||
areas: '"left-top right" "left-bottom right"'
|
||||
cols: 408px 758px
|
||||
rows: 343px 228px
|
||||
heights_px:
|
||||
- 343
|
||||
- 228
|
||||
widths_px:
|
||||
- 408
|
||||
- 758
|
||||
ratios:
|
||||
- 0.6
|
||||
- 0.4
|
||||
width_ratios:
|
||||
- 0.35
|
||||
- 0.65
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,38 @@
|
||||
input:
|
||||
layout_preset: top-1-bottom-2
|
||||
zones_data:
|
||||
- position: top
|
||||
template_id: MOCK_top
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: bottom-left
|
||||
template_id: MOCK_bottom-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-right
|
||||
template_id: MOCK_bottom-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"top top" "bottom-left bottom-right"'
|
||||
cols: 583px 583px
|
||||
rows: 314px 257px
|
||||
heights_px:
|
||||
- 314
|
||||
- 257
|
||||
widths_px:
|
||||
- 583
|
||||
- 583
|
||||
ratios:
|
||||
- 0.537
|
||||
- 0.439
|
||||
width_ratios:
|
||||
- 0.494
|
||||
- 0.494
|
||||
computation: 2d_dynamic_aggregated
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,53 @@
|
||||
input:
|
||||
layout_preset: top-1-bottom-2
|
||||
zones_data:
|
||||
- position: top
|
||||
template_id: MOCK_top
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: bottom-left
|
||||
template_id: MOCK_bottom-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom-right
|
||||
template_id: MOCK_bottom-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
top:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 1.0
|
||||
h: 0.3
|
||||
bottom-left:
|
||||
x: 0
|
||||
y: 0.3
|
||||
w: 0.5
|
||||
h: 0.7
|
||||
bottom-right:
|
||||
x: 0.5
|
||||
y: 0.3
|
||||
w: 0.5
|
||||
h: 0.7
|
||||
expected_layout_css:
|
||||
areas: '"top top" "bottom-left bottom-right"'
|
||||
cols: 583px 583px
|
||||
rows: 171px 400px
|
||||
heights_px:
|
||||
- 171
|
||||
- 400
|
||||
widths_px:
|
||||
- 583
|
||||
- 583
|
||||
ratios:
|
||||
- 0.3
|
||||
- 0.7
|
||||
width_ratios:
|
||||
- 0.5
|
||||
- 0.5
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,38 @@
|
||||
input:
|
||||
layout_preset: top-2-bottom-1
|
||||
zones_data:
|
||||
- position: top-left
|
||||
template_id: MOCK_top-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: top-right
|
||||
template_id: MOCK_top-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom
|
||||
template_id: MOCK_bottom
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"top-left top-right" "bottom bottom"'
|
||||
cols: 583px 583px
|
||||
rows: 257px 314px
|
||||
heights_px:
|
||||
- 257
|
||||
- 314
|
||||
widths_px:
|
||||
- 583
|
||||
- 583
|
||||
ratios:
|
||||
- 0.439
|
||||
- 0.537
|
||||
width_ratios:
|
||||
- 0.494
|
||||
- 0.494
|
||||
computation: 2d_dynamic_aggregated
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,53 @@
|
||||
input:
|
||||
layout_preset: top-2-bottom-1
|
||||
zones_data:
|
||||
- position: top-left
|
||||
template_id: MOCK_top-left
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: top-right
|
||||
template_id: MOCK_top-right
|
||||
content_weight:
|
||||
score: 0.25
|
||||
min_height_px: 200
|
||||
- position: bottom
|
||||
template_id: MOCK_bottom
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
top-left:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 0.6
|
||||
h: 0.4
|
||||
top-right:
|
||||
x: 0.6
|
||||
y: 0
|
||||
w: 0.4
|
||||
h: 0.4
|
||||
bottom:
|
||||
x: 0
|
||||
y: 0.4
|
||||
w: 1.0
|
||||
h: 0.6
|
||||
expected_layout_css:
|
||||
areas: '"top-left top-right" "bottom bottom"'
|
||||
cols: 700px 466px
|
||||
rows: 228px 343px
|
||||
heights_px:
|
||||
- 228
|
||||
- 343
|
||||
widths_px:
|
||||
- 700
|
||||
- 466
|
||||
ratios:
|
||||
- 0.4
|
||||
- 0.6
|
||||
width_ratios:
|
||||
- 0.6
|
||||
- 0.4
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,31 @@
|
||||
input:
|
||||
layout_preset: vertical-2
|
||||
zones_data:
|
||||
- position: left
|
||||
template_id: MOCK_left
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: right
|
||||
template_id: MOCK_right
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"left right"'
|
||||
cols: 583px 583px
|
||||
rows: 1fr
|
||||
heights_px:
|
||||
- 585
|
||||
widths_px:
|
||||
- 583
|
||||
- 583
|
||||
ratios:
|
||||
- 1.0
|
||||
width_ratios:
|
||||
- 0.494
|
||||
- 0.494
|
||||
computation: content_weight_distribution_cols
|
||||
dynamic_rows: false
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,41 @@
|
||||
input:
|
||||
layout_preset: vertical-2
|
||||
zones_data:
|
||||
- position: left
|
||||
template_id: MOCK_left
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
- position: right
|
||||
template_id: MOCK_right
|
||||
content_weight:
|
||||
score: 0.5
|
||||
min_height_px: 200
|
||||
override_zone_geometries:
|
||||
left:
|
||||
x: 0
|
||||
y: 0
|
||||
w: 0.4
|
||||
h: 1.0
|
||||
right:
|
||||
x: 0.4
|
||||
y: 0
|
||||
w: 0.6
|
||||
h: 1.0
|
||||
expected_layout_css:
|
||||
areas: '"left right"'
|
||||
cols: 40.0fr 60.0fr
|
||||
rows: 1fr
|
||||
heights_px:
|
||||
- 585
|
||||
widths_px:
|
||||
- 466
|
||||
- 700
|
||||
ratios:
|
||||
- 1.0
|
||||
width_ratios:
|
||||
- 0.4
|
||||
- 0.6
|
||||
computation: user_override_geometry
|
||||
dynamic_rows: false
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,31 @@
|
||||
input:
|
||||
layout_preset: vertical-2
|
||||
zones_data:
|
||||
- position: left
|
||||
template_id: MOCK_left
|
||||
content_weight:
|
||||
score: 0.7
|
||||
min_height_px: 200
|
||||
- position: right
|
||||
template_id: MOCK_right
|
||||
content_weight:
|
||||
score: 0.3
|
||||
min_height_px: 200
|
||||
override_zone_geometries: null
|
||||
expected_layout_css:
|
||||
areas: '"left right"'
|
||||
cols: 816px 350px
|
||||
rows: 1fr
|
||||
heights_px:
|
||||
- 585
|
||||
widths_px:
|
||||
- 816
|
||||
- 350
|
||||
ratios:
|
||||
- 1.0
|
||||
width_ratios:
|
||||
- 0.692
|
||||
- 0.297
|
||||
computation: content_weight_distribution_cols
|
||||
dynamic_rows: false
|
||||
dynamic_cols: true
|
||||
@@ -0,0 +1,24 @@
|
||||
case_id: grid-2x2_dynamic_2d
|
||||
description: |
|
||||
grid-2x2 (2x2 topology) is promoted to 2-D dynamic in IMP-09 PR 2.
|
||||
Row-axis retry MUST be skipped by the gate with the
|
||||
"dynamic_cols (2-D topology)" reason.
|
||||
input_layout_css:
|
||||
areas: '"top-left top-right" "bottom-left bottom-right"'
|
||||
cols: 583px 583px
|
||||
rows: 286px 285px
|
||||
heights_px: [286, 285]
|
||||
widths_px: [583, 583]
|
||||
ratios: [0.489, 0.487]
|
||||
width_ratios: [0.494, 0.494]
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "dynamic_cols"
|
||||
- "2-D"
|
||||
- "IMP-09"
|
||||
@@ -0,0 +1,24 @@
|
||||
case_id: horizontal2_dynamic_rows
|
||||
description: |
|
||||
horizontal-2 layout with dynamic_rows=True must pass the IMP-09 retry
|
||||
gate. The base trace should record retry_attempted=True (legacy
|
||||
plan/rerender path continues). retry_skipped_reason MUST NOT contain
|
||||
either of the IMP-09 gate skip strings.
|
||||
input_layout_css:
|
||||
areas: '"top" "bottom"'
|
||||
cols: 1fr
|
||||
rows: 333px 238px
|
||||
heights_px: [333, 238]
|
||||
widths_px: [1180]
|
||||
ratios: [0.569, 0.407]
|
||||
width_ratios: [1.0]
|
||||
dynamic_rows: true
|
||||
dynamic_cols: false
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: true
|
||||
retry_skipped_reason_excludes:
|
||||
- "dynamic_cols"
|
||||
- "fr_default_from_preset"
|
||||
@@ -0,0 +1,24 @@
|
||||
case_id: left-1-right-2_dynamic_2d
|
||||
description: |
|
||||
left-1-right-2 (side-T-left topology) is promoted to 2-D dynamic in
|
||||
IMP-09 PR 2. Row-axis retry MUST be skipped by the gate with the
|
||||
"dynamic_cols (2-D topology)" reason.
|
||||
input_layout_css:
|
||||
areas: '"left right-top" "left right-bottom"'
|
||||
cols: 777px 389px
|
||||
rows: 286px 285px
|
||||
heights_px: [286, 285]
|
||||
widths_px: [777, 389]
|
||||
ratios: [0.489, 0.487]
|
||||
width_ratios: [0.658, 0.33]
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "dynamic_cols"
|
||||
- "2-D"
|
||||
- "IMP-09"
|
||||
@@ -0,0 +1,24 @@
|
||||
case_id: left-2-right-1_dynamic_2d
|
||||
description: |
|
||||
left-2-right-1 (side-T-right topology) is promoted to 2-D dynamic in
|
||||
IMP-09 PR 2. Row-axis retry MUST be skipped by the gate with the
|
||||
"dynamic_cols (2-D topology)" reason.
|
||||
input_layout_css:
|
||||
areas: '"left-top right" "left-bottom right"'
|
||||
cols: 389px 777px
|
||||
rows: 286px 285px
|
||||
heights_px: [286, 285]
|
||||
widths_px: [389, 777]
|
||||
ratios: [0.489, 0.487]
|
||||
width_ratios: [0.33, 0.658]
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "dynamic_cols"
|
||||
- "2-D"
|
||||
- "IMP-09"
|
||||
@@ -0,0 +1,23 @@
|
||||
case_id: single_fr_default
|
||||
description: |
|
||||
Any layout that fell through to fr_default_from_preset (single,
|
||||
T-shape, 2x2 in PR 1) has neither dynamic_rows nor dynamic_cols.
|
||||
Row-axis retry is a no-op and must be skipped by the IMP-09 gate
|
||||
with a fr_default_from_preset skip reason.
|
||||
input_layout_css:
|
||||
areas: '"top top" "bottom-left bottom-right"'
|
||||
cols: 1fr 1fr
|
||||
rows: 1fr 1fr
|
||||
heights_px: [285, 286]
|
||||
widths_px: [583, 583]
|
||||
ratios: [0.487, 0.489]
|
||||
width_ratios: [0.494, 0.494]
|
||||
dynamic_rows: false
|
||||
dynamic_cols: false
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "fr_default_from_preset"
|
||||
@@ -0,0 +1,26 @@
|
||||
case_id: top-1-bottom-2_dynamic_2d
|
||||
description: |
|
||||
top-1-bottom-2 (T topology) is promoted to 2-D dynamic in IMP-09
|
||||
PR 2 (dynamic_rows=True, dynamic_cols=True). Row-axis retry MUST be
|
||||
skipped by the IMP-09 gate with the "dynamic_cols (2-D topology)"
|
||||
skip reason, because row-only redistribution cannot reconcile both
|
||||
axes simultaneously.
|
||||
input_layout_css:
|
||||
areas: '"top top" "bottom-left bottom-right"'
|
||||
cols: 583px 583px
|
||||
rows: 314px 257px
|
||||
heights_px: [314, 257]
|
||||
widths_px: [583, 583]
|
||||
ratios: [0.537, 0.439]
|
||||
width_ratios: [0.494, 0.494]
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "dynamic_cols"
|
||||
- "2-D"
|
||||
- "IMP-09"
|
||||
@@ -0,0 +1,24 @@
|
||||
case_id: top-2-bottom-1_dynamic_2d
|
||||
description: |
|
||||
top-2-bottom-1 (inverted-T topology) is promoted to 2-D dynamic in
|
||||
IMP-09 PR 2. Row-axis retry MUST be skipped by the gate with the
|
||||
"dynamic_cols (2-D topology)" reason.
|
||||
input_layout_css:
|
||||
areas: '"top-left top-right" "bottom bottom"'
|
||||
cols: 583px 583px
|
||||
rows: 257px 314px
|
||||
heights_px: [257, 314]
|
||||
widths_px: [583, 583]
|
||||
ratios: [0.439, 0.537]
|
||||
width_ratios: [0.494, 0.494]
|
||||
dynamic_rows: true
|
||||
dynamic_cols: true
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "dynamic_cols"
|
||||
- "2-D"
|
||||
- "IMP-09"
|
||||
@@ -0,0 +1,24 @@
|
||||
case_id: vertical2_dynamic_cols
|
||||
description: |
|
||||
vertical-2 layout with dynamic_cols=True must be skipped by the
|
||||
IMP-09 retry gate before plan/rerender, because the existing
|
||||
apply_retry_to_layout_css mutates only row-axis fields and would
|
||||
produce a misleading trace if it ran on a column-dynamic layout.
|
||||
input_layout_css:
|
||||
areas: '"left right"'
|
||||
cols: 583px 583px
|
||||
rows: 1fr
|
||||
heights_px: [585]
|
||||
widths_px: [583, 583]
|
||||
ratios: [1.0]
|
||||
width_ratios: [0.494, 0.494]
|
||||
dynamic_rows: false
|
||||
dynamic_cols: true
|
||||
router_decision:
|
||||
router_active: true
|
||||
proposed_actions_summary: [zone_ratio_retry]
|
||||
expected_gate:
|
||||
retry_attempted: false
|
||||
retry_skipped_reason_contains:
|
||||
- "dynamic_cols"
|
||||
- "IMP-09"
|
||||
@@ -0,0 +1,158 @@
|
||||
"""IMP-09 PR 1 — build_layout_css contract tests.
|
||||
|
||||
Verifies horizontal-2 byte-identity for the legacy grid strings
|
||||
(areas / cols / rows) and that every return path now carries the new
|
||||
length-locked col-axis keys (widths_px / width_ratios / dynamic_cols).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import (
|
||||
GRID_GAP,
|
||||
SLIDE_BODY_HEIGHT,
|
||||
SLIDE_BODY_WIDTH,
|
||||
build_layout_css,
|
||||
)
|
||||
|
||||
|
||||
def _zone(position: str, score: float, min_h: int = 100) -> dict:
|
||||
return {
|
||||
"position": position,
|
||||
"template_id": f"MOCK_{position}",
|
||||
"content_weight": {"score": score},
|
||||
"min_height_px": min_h,
|
||||
}
|
||||
|
||||
|
||||
# ────────────────────── new-key contract ──────────────────────
|
||||
|
||||
|
||||
NEW_KEYS = {"widths_px", "width_ratios", "dynamic_cols"}
|
||||
|
||||
|
||||
def test_all_presets_carry_new_col_axis_keys():
|
||||
"""Every PR 1 return path must include widths_px / width_ratios /
|
||||
dynamic_cols, and heights_px / widths_px must be length-locked to
|
||||
the catalog grid (R rows, C cols)."""
|
||||
cases = [
|
||||
("single", [_zone("primary", 1.0)]),
|
||||
("horizontal-2", [_zone("top", 0.6), _zone("bottom", 0.4)]),
|
||||
("vertical-2", [_zone("left", 0.5), _zone("right", 0.5)]),
|
||||
("top-1-bottom-2", [
|
||||
_zone("top", 0.5),
|
||||
_zone("bottom-left", 0.25),
|
||||
_zone("bottom-right", 0.25),
|
||||
]),
|
||||
("grid-2x2", [
|
||||
_zone("top-left", 0.25),
|
||||
_zone("top-right", 0.25),
|
||||
_zone("bottom-left", 0.25),
|
||||
_zone("bottom-right", 0.25),
|
||||
]),
|
||||
]
|
||||
for preset, zones in cases:
|
||||
result = build_layout_css(preset, zones)
|
||||
missing = NEW_KEYS - set(result)
|
||||
assert not missing, f"{preset} missing new keys: {missing}"
|
||||
# heights_px / widths_px never empty in PR 1 (length-locked).
|
||||
assert len(result["heights_px"]) > 0, f"{preset} empty heights_px"
|
||||
assert len(result["widths_px"]) > 0, f"{preset} empty widths_px"
|
||||
|
||||
|
||||
# ────────────────────── horizontal-2 byte-identity ──────────────────────
|
||||
|
||||
|
||||
def test_horizontal_2_grid_strings_match_legacy():
|
||||
zones = [_zone("top", 0.6), _zone("bottom", 0.4)]
|
||||
result = build_layout_css("horizontal-2", zones)
|
||||
|
||||
# Legacy contract: areas / cols / rows strings preserved.
|
||||
assert result["areas"] == '"top" "bottom"'
|
||||
assert result["cols"] == "1fr"
|
||||
assert result["rows"].count("px") == 2
|
||||
|
||||
# heights_px sum to body height; ratios consistent.
|
||||
assert sum(result["heights_px"]) == SLIDE_BODY_HEIGHT - GRID_GAP
|
||||
assert result["dynamic_rows"] is True
|
||||
assert result["dynamic_cols"] is False
|
||||
|
||||
# New col-axis defaults: full body width, ratio 1.0.
|
||||
assert result["widths_px"] == [SLIDE_BODY_WIDTH]
|
||||
assert result["width_ratios"] == [1.0]
|
||||
|
||||
|
||||
def test_horizontal_2_override_preserves_rows():
|
||||
zones = [_zone("top", 0.6), _zone("bottom", 0.4)]
|
||||
override = {
|
||||
"top": {"x": 0, "y": 0, "w": 1.0, "h": 0.3},
|
||||
"bottom": {"x": 0, "y": 0.3, "w": 1.0, "h": 0.7},
|
||||
}
|
||||
result = build_layout_css(
|
||||
"horizontal-2", zones, override_zone_geometries=override
|
||||
)
|
||||
assert result["computation"] == "user_override_geometry"
|
||||
assert result["dynamic_rows"] is True
|
||||
assert result["dynamic_cols"] is False
|
||||
assert result["heights_px"][0] < result["heights_px"][1]
|
||||
assert result["widths_px"] == [SLIDE_BODY_WIDTH]
|
||||
# Override ratio target.
|
||||
assert result["ratios"] == [0.3, 0.7]
|
||||
|
||||
|
||||
# ────────────────────── vertical-2 new dynamic ──────────────────────
|
||||
|
||||
|
||||
def test_vertical_2_normal_produces_dynamic_cols():
|
||||
zones = [_zone("left", 0.7), _zone("right", 0.3)]
|
||||
result = build_layout_css("vertical-2", zones)
|
||||
assert result["dynamic_rows"] is False
|
||||
assert result["dynamic_cols"] is True
|
||||
# cols string is px-based (no fr).
|
||||
assert "fr" not in result["cols"]
|
||||
assert result["cols"].count("px") == 2
|
||||
# Heights span full body in a single row.
|
||||
assert result["heights_px"] == [SLIDE_BODY_HEIGHT]
|
||||
# Widths reflect 70/30 weight split.
|
||||
assert result["widths_px"][0] > result["widths_px"][1]
|
||||
|
||||
|
||||
def test_vertical_2_override_keeps_fr_cols_legacy():
|
||||
"""PR 1 v-2 override path keeps legacy fr-string cols but now
|
||||
populates widths_px in pixels for downstream consumers."""
|
||||
zones = [_zone("left", 0.5), _zone("right", 0.5)]
|
||||
override = {
|
||||
"left": {"x": 0, "y": 0, "w": 0.4, "h": 1.0},
|
||||
"right": {"x": 0.4, "y": 0, "w": 0.6, "h": 1.0},
|
||||
}
|
||||
result = build_layout_css(
|
||||
"vertical-2", zones, override_zone_geometries=override
|
||||
)
|
||||
assert result["computation"] == "user_override_geometry"
|
||||
assert "fr" in result["cols"]
|
||||
assert result["dynamic_cols"] is True
|
||||
assert result["dynamic_rows"] is False
|
||||
# widths_px now populated.
|
||||
assert len(result["widths_px"]) == 2
|
||||
assert sum(result["widths_px"]) == SLIDE_BODY_WIDTH - GRID_GAP
|
||||
assert result["width_ratios"] == [0.4, 0.6]
|
||||
|
||||
|
||||
# ────────────────────── 2-D dynamic dispatch (PR 2) ──────────────────────
|
||||
|
||||
|
||||
def test_top_1_bottom_2_dynamic_2d_populates_geometry():
|
||||
"""T-shape (top-1-bottom-2) is dispatched through the 2-D dynamic
|
||||
builder in PR 2: heights_px / widths_px length-locked to grid
|
||||
R=2, C=2 with both dynamic flags True."""
|
||||
zones = [
|
||||
_zone("top", 0.5),
|
||||
_zone("bottom-left", 0.25),
|
||||
_zone("bottom-right", 0.25),
|
||||
]
|
||||
result = build_layout_css("top-1-bottom-2", zones)
|
||||
assert result["computation"] == "2d_dynamic_aggregated"
|
||||
assert result["dynamic_rows"] is True
|
||||
assert result["dynamic_cols"] is True
|
||||
assert len(result["heights_px"]) == 2 # R rows
|
||||
assert len(result["widths_px"]) == 2 # C cols
|
||||
@@ -0,0 +1,101 @@
|
||||
"""IMP-09 PR 1 — _compute_per_zone_geometry tests (1-D paths).
|
||||
|
||||
Verifies the unified per-zone geometry aggregator on horizontal-2 and
|
||||
vertical-2 (the two 1-D presets active in PR 1). 2-D spanning zone
|
||||
cases (T / 2x2) are exercised in PR 2.
|
||||
|
||||
The helper aggregates grid-track sizes into per-zone dimensions and
|
||||
must produce length-locked outputs:
|
||||
- layout_css["heights_px"] length == R (parsed css_areas rows)
|
||||
- layout_css["widths_px"] length == C (parsed css_areas cols)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import (
|
||||
GRID_GAP,
|
||||
SLIDE_BODY_HEIGHT,
|
||||
SLIDE_BODY_WIDTH,
|
||||
_compute_per_zone_geometry,
|
||||
build_layout_css,
|
||||
)
|
||||
|
||||
|
||||
def _zone(position: str, score: float) -> dict:
|
||||
return {
|
||||
"position": position,
|
||||
"template_id": f"MOCK_{position}",
|
||||
"content_weight": {"score": score},
|
||||
"min_height_px": 100,
|
||||
}
|
||||
|
||||
|
||||
def test_horizontal_2_per_zone_widths_match_slide_body():
|
||||
zones = [_zone("top", 0.6), _zone("bottom", 0.4)]
|
||||
layout_css = build_layout_css("horizontal-2", zones)
|
||||
debug_zones = [{"position": "top"}, {"position": "bottom"}]
|
||||
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
|
||||
|
||||
# Both zones share the single column => width == SLIDE_BODY_WIDTH.
|
||||
assert per_zone[0]["zone_width_px"] == SLIDE_BODY_WIDTH
|
||||
assert per_zone[1]["zone_width_px"] == SLIDE_BODY_WIDTH
|
||||
# Heights mirror layout_css.heights_px.
|
||||
assert per_zone[0]["zone_height_px"] == layout_css["heights_px"][0]
|
||||
assert per_zone[1]["zone_height_px"] == layout_css["heights_px"][1]
|
||||
|
||||
|
||||
def test_vertical_2_per_zone_heights_match_slide_body():
|
||||
zones = [_zone("left", 0.5), _zone("right", 0.5)]
|
||||
layout_css = build_layout_css("vertical-2", zones)
|
||||
debug_zones = [{"position": "left"}, {"position": "right"}]
|
||||
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
|
||||
|
||||
# Both zones share the single row => height == SLIDE_BODY_HEIGHT.
|
||||
assert per_zone[0]["zone_height_px"] == SLIDE_BODY_HEIGHT
|
||||
assert per_zone[1]["zone_height_px"] == SLIDE_BODY_HEIGHT
|
||||
# Widths mirror layout_css.widths_px.
|
||||
assert per_zone[0]["zone_width_px"] == layout_css["widths_px"][0]
|
||||
assert per_zone[1]["zone_width_px"] == layout_css["widths_px"][1]
|
||||
|
||||
|
||||
def test_heights_px_length_mismatch_raises():
|
||||
layout_css = {
|
||||
"areas": '"top" "bottom"',
|
||||
"heights_px": [300], # wrong length, expected 2
|
||||
"widths_px": [SLIDE_BODY_WIDTH],
|
||||
}
|
||||
with pytest.raises(ValueError, match="heights_px length"):
|
||||
_compute_per_zone_geometry(
|
||||
layout_css, [{"position": "top"}], GRID_GAP
|
||||
)
|
||||
|
||||
|
||||
def test_widths_px_length_mismatch_raises():
|
||||
layout_css = {
|
||||
"areas": '"left right"',
|
||||
"heights_px": [SLIDE_BODY_HEIGHT],
|
||||
"widths_px": [600], # wrong length, expected 2
|
||||
}
|
||||
with pytest.raises(ValueError, match="widths_px length"):
|
||||
_compute_per_zone_geometry(
|
||||
layout_css, [{"position": "left"}], GRID_GAP
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_position_raises():
|
||||
zones = [_zone("top", 0.5), _zone("bottom", 0.5)]
|
||||
layout_css = build_layout_css("horizontal-2", zones)
|
||||
debug_zones = [{"position": "ghost"}]
|
||||
with pytest.raises(ValueError, match="not present in css_areas"):
|
||||
_compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
|
||||
|
||||
|
||||
def test_fr_default_single_returns_full_body():
|
||||
# 'single' is the fr_default sink in PR 1; widths_px / heights_px
|
||||
# must still be populated (length 1 each).
|
||||
layout_css = build_layout_css("single", [_zone("primary", 1.0)])
|
||||
debug_zones = [{"position": "primary"}]
|
||||
per_zone = _compute_per_zone_geometry(layout_css, debug_zones, GRID_GAP)
|
||||
assert per_zone[0]["zone_height_px"] == SLIDE_BODY_HEIGHT
|
||||
assert per_zone[0]["zone_width_px"] == SLIDE_BODY_WIDTH
|
||||
@@ -0,0 +1,76 @@
|
||||
"""IMP-09 PR 1 — compute_zone_layout_cols tests.
|
||||
|
||||
Column-axis weight-only solver. Mirrors compute_zone_layout for rows.
|
||||
No min_width_px contract exists in frame_contracts.yaml (verified
|
||||
during Stage 2), so column distribution is purely content_weight.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_pipeline import (
|
||||
GRID_GAP,
|
||||
SLIDE_BODY_WIDTH,
|
||||
compute_zone_layout_cols,
|
||||
)
|
||||
|
||||
|
||||
def _zone(position: str, score: float) -> dict:
|
||||
return {
|
||||
"position": position,
|
||||
"template_id": f"MOCK_{position}",
|
||||
"content_weight": {"score": score},
|
||||
}
|
||||
|
||||
|
||||
def test_empty_zones_returns_empty_result():
|
||||
result = compute_zone_layout_cols([])
|
||||
assert result["widths_px"] == []
|
||||
assert result["width_ratios"] == []
|
||||
|
||||
|
||||
def test_two_equal_zones_split_evenly():
|
||||
zones = [_zone("left", 0.5), _zone("right", 0.5)]
|
||||
result = compute_zone_layout_cols(zones)
|
||||
available = SLIDE_BODY_WIDTH - GRID_GAP # one gap between two zones
|
||||
assert sum(result["widths_px"]) == available
|
||||
assert result["widths_px"][0] == result["widths_px"][1]
|
||||
assert result["computation"] == "content_weight_distribution_cols"
|
||||
|
||||
|
||||
def test_asymmetric_weights_distribute_by_ratio():
|
||||
zones = [_zone("left", 0.8), _zone("right", 0.2)]
|
||||
result = compute_zone_layout_cols(zones)
|
||||
available = SLIDE_BODY_WIDTH - GRID_GAP
|
||||
assert sum(result["widths_px"]) == available
|
||||
# left should be ~4x right
|
||||
assert result["widths_px"][0] > result["widths_px"][1] * 3
|
||||
|
||||
|
||||
def test_zero_weight_guard_equal_split():
|
||||
zones = [_zone("left", 0.0), _zone("right", 0.0)]
|
||||
result = compute_zone_layout_cols(zones)
|
||||
available = SLIDE_BODY_WIDTH - GRID_GAP
|
||||
assert sum(result["widths_px"]) == available
|
||||
assert result["widths_px"][0] == result["widths_px"][1]
|
||||
# weight_shares fallback to equal share.
|
||||
assert result["weight_shares"] == [0.5, 0.5]
|
||||
|
||||
|
||||
def test_integer_rounding_absorbed_by_last_zone():
|
||||
# Three zones with weights that don't divide evenly.
|
||||
zones = [
|
||||
_zone("a", 0.333333),
|
||||
_zone("b", 0.333333),
|
||||
_zone("c", 0.333334),
|
||||
]
|
||||
result = compute_zone_layout_cols(zones)
|
||||
available = SLIDE_BODY_WIDTH - 2 * GRID_GAP
|
||||
assert sum(result["widths_px"]) == available
|
||||
|
||||
|
||||
def test_width_ratios_match_total_width():
|
||||
zones = [_zone("left", 0.6), _zone("right", 0.4)]
|
||||
result = compute_zone_layout_cols(zones)
|
||||
# width_ratios should be widths_px / SLIDE_BODY_WIDTH (not / available)
|
||||
assert abs(
|
||||
result["width_ratios"][0] - result["widths_px"][0] / SLIDE_BODY_WIDTH
|
||||
) < 1e-3
|
||||
@@ -0,0 +1,81 @@
|
||||
"""IMP-15 실행-4 (Gitea issue #48) — debug.json top-level event surfacing.
|
||||
|
||||
Verifies ``write_debug_json`` lifts ``image_events`` + ``table_events`` out of
|
||||
``visual_runtime_check`` and exposes them as top-level keys, mirroring the
|
||||
existing ``zone_geometries_px`` precedent (src/phase_z2_pipeline.py:2739).
|
||||
|
||||
Two scenarios:
|
||||
|
||||
* Populated — ``visual_runtime_check`` carries non-empty event lists; the
|
||||
written debug dict surfaces both at the top level with identical payloads.
|
||||
* None — ``visual_runtime_check is None``; both top-level keys default to ``[]``
|
||||
(no KeyError, no propagated None).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from src.phase_z2_pipeline import write_debug_json
|
||||
|
||||
|
||||
def _read_debug(run_dir: Path) -> dict:
|
||||
return json.loads((run_dir / "debug.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_write_debug_json_surfaces_image_and_table_events(tmp_path: Path) -> None:
|
||||
image_events = [
|
||||
{
|
||||
"src": "img/a.png",
|
||||
"zone_position": "primary",
|
||||
"zone_template_id": "tid-1",
|
||||
"natural_w": 200,
|
||||
"natural_h": 100,
|
||||
"rendered_w": 200,
|
||||
"rendered_h": 200,
|
||||
"delta": 1.0,
|
||||
}
|
||||
]
|
||||
table_events = [
|
||||
{
|
||||
"zone_position": "secondary",
|
||||
"zone_template_id": "tid-2",
|
||||
"clientWidth": 300,
|
||||
"scrollWidth": 360,
|
||||
"excess_x": 60,
|
||||
"wrapper_clipped_index": 0,
|
||||
}
|
||||
]
|
||||
visual_runtime_check = {
|
||||
"image_events": image_events,
|
||||
"table_events": table_events,
|
||||
"zone_geometries_px": [],
|
||||
}
|
||||
|
||||
write_debug_json(
|
||||
run_dir=tmp_path,
|
||||
layout_preset="single",
|
||||
debug_zones=[],
|
||||
layout_css={},
|
||||
visual_runtime_check=visual_runtime_check,
|
||||
)
|
||||
|
||||
debug = _read_debug(tmp_path)
|
||||
assert "image_events" in debug, "image_events must be a top-level key"
|
||||
assert "table_events" in debug, "table_events must be a top-level key"
|
||||
assert debug["image_events"] == image_events
|
||||
assert debug["table_events"] == table_events
|
||||
|
||||
|
||||
def test_write_debug_json_defaults_when_visual_runtime_check_none(tmp_path: Path) -> None:
|
||||
write_debug_json(
|
||||
run_dir=tmp_path,
|
||||
layout_preset="single",
|
||||
debug_zones=[],
|
||||
layout_css={},
|
||||
visual_runtime_check=None,
|
||||
)
|
||||
|
||||
debug = _read_debug(tmp_path)
|
||||
assert debug["image_events"] == []
|
||||
assert debug["table_events"] == []
|
||||
@@ -0,0 +1,100 @@
|
||||
"""IMP-09 PR 1 — fixture-driven regression checks.
|
||||
|
||||
Loads the YAML snapshots under tests/phase_z2/fixtures/ and exercises
|
||||
build_layout_css + _attempt_zone_ratio_retry against them. Any drift
|
||||
in IMP-09 output forces a fixture refresh, which is the lock surface
|
||||
called out in Stage 3 round 4 §5.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from src.phase_z2_pipeline import _attempt_zone_ratio_retry, build_layout_css
|
||||
|
||||
|
||||
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
def _load_yaml(path: Path) -> dict:
|
||||
with path.open(encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
# ──────────────────────── build_layout_css fixtures ────────────────────────
|
||||
|
||||
|
||||
_BUILD_DIR = FIXTURES_DIR / "build_layout_css"
|
||||
_BUILD_FIXTURES = sorted(_BUILD_DIR.glob("*.yaml")) if _BUILD_DIR.exists() else []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fixture_path",
|
||||
_BUILD_FIXTURES,
|
||||
ids=[p.stem for p in _BUILD_FIXTURES],
|
||||
)
|
||||
def test_build_layout_css_matches_fixture(fixture_path: Path):
|
||||
payload = _load_yaml(fixture_path)
|
||||
inp = payload["input"]
|
||||
expected = payload["expected_layout_css"]
|
||||
|
||||
result = build_layout_css(
|
||||
inp["layout_preset"],
|
||||
inp["zones_data"],
|
||||
override_zone_geometries=inp.get("override_zone_geometries"),
|
||||
)
|
||||
# raw_zone_layout is intentionally not snapshotted (contains
|
||||
# solver internals); compare the rest.
|
||||
actual = {k: v for k, v in result.items() if k != "raw_zone_layout"}
|
||||
assert actual == expected, (
|
||||
f"layout_css drift in fixture {fixture_path.name}:\n"
|
||||
f" expected={expected}\n actual={actual}"
|
||||
)
|
||||
|
||||
|
||||
# ────────────────────────── retry_gate fixtures ──────────────────────────
|
||||
|
||||
|
||||
_RETRY_DIR = FIXTURES_DIR / "retry_gate"
|
||||
_RETRY_FIXTURES = sorted(_RETRY_DIR.glob("*.yaml")) if _RETRY_DIR.exists() else []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"fixture_path",
|
||||
_RETRY_FIXTURES,
|
||||
ids=[p.stem for p in _RETRY_FIXTURES],
|
||||
)
|
||||
def test_retry_gate_matches_fixture(fixture_path: Path, tmp_path: Path):
|
||||
payload = _load_yaml(fixture_path)
|
||||
layout_css = payload["input_layout_css"]
|
||||
router_decision = payload["router_decision"]
|
||||
expected = payload["expected_gate"]
|
||||
|
||||
trace = _attempt_zone_ratio_retry(
|
||||
run_dir=tmp_path,
|
||||
out_path=tmp_path / "final.html",
|
||||
slide_title="fixture",
|
||||
slide_footer=None,
|
||||
zones_data=[],
|
||||
debug_zones=[],
|
||||
layout_preset="fixture",
|
||||
layout_css=layout_css,
|
||||
overflow={},
|
||||
fit_classification={},
|
||||
router_decision=router_decision,
|
||||
gap_px=14,
|
||||
)
|
||||
|
||||
assert trace["retry_attempted"] == expected["retry_attempted"]
|
||||
skip_reason = trace.get("retry_skipped_reason")
|
||||
for needle in expected.get("retry_skipped_reason_contains", []):
|
||||
assert skip_reason is not None and needle in skip_reason, (
|
||||
f"expected {needle!r} in retry_skipped_reason, got {skip_reason!r}"
|
||||
)
|
||||
for forbidden in expected.get("retry_skipped_reason_excludes", []):
|
||||
if skip_reason is not None:
|
||||
assert forbidden not in skip_reason, (
|
||||
f"forbidden {forbidden!r} found in retry_skipped_reason {skip_reason!r}"
|
||||
)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""IMP-09 PR 1 — _parse_css_areas strict validation tests.
|
||||
|
||||
Covers the four ValueError cases declared in the Stage 3 round 4 lock
|
||||
(plan §2-D): empty input, no quoted rows, empty row tokens, and
|
||||
non-rectangular grids. Also exercises positive parsing on all 8
|
||||
catalog presets so any future catalog drift in row/col counts is
|
||||
caught here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import _parse_css_areas
|
||||
|
||||
|
||||
def test_parse_empty_string_raises():
|
||||
with pytest.raises(ValueError, match="no quoted row strings"):
|
||||
_parse_css_areas("")
|
||||
|
||||
|
||||
def test_parse_no_quotes_raises():
|
||||
with pytest.raises(ValueError, match="no quoted row strings"):
|
||||
_parse_css_areas("top top bottom-left bottom-right")
|
||||
|
||||
|
||||
def test_parse_empty_row_raises():
|
||||
# Whitespace-only quoted row -> tokens list is empty.
|
||||
with pytest.raises(ValueError, match="empty row"):
|
||||
_parse_css_areas('" "')
|
||||
|
||||
|
||||
def test_parse_non_rectangular_raises():
|
||||
# First row has 1 token, second row has 2 tokens.
|
||||
with pytest.raises(ValueError, match="non-rectangular"):
|
||||
_parse_css_areas('"top" "bottom-left bottom-right"')
|
||||
|
||||
|
||||
def test_parse_single_zone():
|
||||
rows, seen = _parse_css_areas('"primary"')
|
||||
assert rows == [["primary"]]
|
||||
assert seen == ["primary"]
|
||||
|
||||
|
||||
def test_parse_horizontal_2():
|
||||
rows, seen = _parse_css_areas('"top" "bottom"')
|
||||
assert rows == [["top"], ["bottom"]]
|
||||
assert seen == ["top", "bottom"]
|
||||
|
||||
|
||||
def test_parse_vertical_2():
|
||||
rows, seen = _parse_css_areas('"left right"')
|
||||
assert rows == [["left", "right"]]
|
||||
assert seen == ["left", "right"]
|
||||
|
||||
|
||||
def test_parse_top_1_bottom_2_span():
|
||||
rows, seen = _parse_css_areas('"top top" "bottom-left bottom-right"')
|
||||
assert rows == [["top", "top"], ["bottom-left", "bottom-right"]]
|
||||
# 'top' should appear once in seen even though it occupies two cells.
|
||||
assert seen == ["top", "bottom-left", "bottom-right"]
|
||||
|
||||
|
||||
def test_parse_grid_2x2_four_zones():
|
||||
rows, seen = _parse_css_areas(
|
||||
'"top-left top-right" "bottom-left bottom-right"'
|
||||
)
|
||||
assert rows == [
|
||||
["top-left", "top-right"],
|
||||
["bottom-left", "bottom-right"],
|
||||
]
|
||||
assert seen == ["top-left", "top-right", "bottom-left", "bottom-right"]
|
||||
@@ -0,0 +1,49 @@
|
||||
"""IMP-09 PR 1 — _parse_fr_string tests.
|
||||
|
||||
Catalog presets only use `1fr` / `1fr 1fr` specs (verified
|
||||
templates/phase_z2/layouts/layouts.yaml). The helper must reject
|
||||
non-fr tokens and round to integer pixel sizes summing to `total`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import _parse_fr_string
|
||||
|
||||
|
||||
def test_single_fr_returns_full_total():
|
||||
assert _parse_fr_string("1fr", 585) == [585]
|
||||
|
||||
|
||||
def test_two_equal_fr_splits_evenly():
|
||||
result = _parse_fr_string("1fr 1fr", 1180)
|
||||
assert result == [590, 590]
|
||||
assert sum(result) == 1180
|
||||
|
||||
|
||||
def test_unequal_fr_distributes_by_ratio():
|
||||
result = _parse_fr_string("2fr 1fr", 300)
|
||||
assert sum(result) == 300
|
||||
assert result[0] > result[1]
|
||||
|
||||
|
||||
def test_rounding_absorbed_by_last_track():
|
||||
# 1fr 1fr 1fr / total=100 -> 33,33,33 + diff 1 absorbed by last.
|
||||
result = _parse_fr_string("1fr 1fr 1fr", 100)
|
||||
assert sum(result) == 100
|
||||
assert result == [33, 33, 34]
|
||||
|
||||
|
||||
def test_non_fr_token_raises():
|
||||
with pytest.raises(ValueError, match="non-fr token"):
|
||||
_parse_fr_string("200px 1fr", 1000)
|
||||
|
||||
|
||||
def test_empty_spec_raises():
|
||||
with pytest.raises(ValueError, match="empty spec"):
|
||||
_parse_fr_string("", 1000)
|
||||
|
||||
|
||||
def test_zero_fr_raises():
|
||||
with pytest.raises(ValueError, match="total fr"):
|
||||
_parse_fr_string("0fr 0fr", 1000)
|
||||
@@ -0,0 +1,89 @@
|
||||
"""IMP-12 u11 — plan_cross_zone_redistribute tests.
|
||||
|
||||
Stage 2 contract (unit u11):
|
||||
- multi-role zone feasible (deficit role + surplus role in the same zone)
|
||||
- single-role zone infeasible reason (no peer to donate surplus)
|
||||
|
||||
u4 wraps fit_verifier.redistribute() in the Step-17 plan signature; feasibility
|
||||
depends on whether deficit roles can be covered by surplus roles within the
|
||||
same container.zone group (see src/fit_verifier.py:496-590). The plan exposes
|
||||
role_heights_before / role_heights_after and surfaces the
|
||||
'can_redistribute=False — single-role zone(s)' substring when redistribution
|
||||
is impossible. The apply helper must scope output to [data-role=...] only
|
||||
(feedback_phase_z_spacing_direction — no :root / body / .slide / .zone).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.fit_verifier import FitAnalysis, RoleFit
|
||||
from src.phase_z2_retry import (
|
||||
apply_cross_zone_redistribute_css,
|
||||
plan_cross_zone_redistribute,
|
||||
)
|
||||
|
||||
|
||||
def _fit(roles: dict[str, tuple[float, float]]) -> FitAnalysis:
|
||||
"""roles dict = {role: (allocated_px, shortfall_px)}.
|
||||
|
||||
Sign convention matches fit_verifier.redistribute: shortfall_px > 0 = deficit,
|
||||
shortfall_px < 0 = surplus (usable = abs(shortfall) - min_margin_px).
|
||||
"""
|
||||
return FitAnalysis(
|
||||
roles={
|
||||
name: RoleFit(role=name, allocated_px=alloc, shortfall_px=short)
|
||||
for name, (alloc, short) in roles.items()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_multi_role_zone_feasible():
|
||||
"""Two roles in the same zone — deficit covered by surplus → feasible."""
|
||||
fit = _fit({"top": (200.0, 30.0), "bottom_l": (300.0, -50.0)})
|
||||
containers = {
|
||||
"top": {"zone": "slide_body", "height_px": 200},
|
||||
"bottom_l": {"zone": "slide_body", "height_px": 300},
|
||||
}
|
||||
plan = plan_cross_zone_redistribute(
|
||||
fit_analysis=fit, containers=containers, min_margin_px=10.0,
|
||||
)
|
||||
assert plan["action"] == "cross_zone_redistribute"
|
||||
assert plan["feasible"] is True
|
||||
assert plan["can_redistribute"] is True
|
||||
assert plan["role_heights_before"] == {"top": 200.0, "bottom_l": 300.0}
|
||||
after = plan["role_heights_after"]
|
||||
# deficit (30) shifts top up, surplus (50-margin=40) shifts bottom_l down by 30.
|
||||
assert after["top"] > 200.0
|
||||
assert after["bottom_l"] < 300.0
|
||||
assert abs((after["top"] - 200.0) - (300.0 - after["bottom_l"])) < 1.0
|
||||
css = apply_cross_zone_redistribute_css(plan)
|
||||
assert '[data-role="top"]' in css
|
||||
assert '[data-role="bottom_l"]' in css
|
||||
# Scope lock — no global rules emitted.
|
||||
for forbidden in (":root", "body", ".slide", ".zone"):
|
||||
assert forbidden not in css
|
||||
|
||||
|
||||
def test_single_role_zone_infeasible_reason():
|
||||
"""Lone role in a zone has no peer to donate surplus → infeasible."""
|
||||
fit = _fit({"top": (200.0, 30.0)})
|
||||
containers = {"top": {"zone": "slide_body", "height_px": 200}}
|
||||
plan = plan_cross_zone_redistribute(
|
||||
fit_analysis=fit, containers=containers, min_margin_px=10.0,
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["can_redistribute"] is False
|
||||
reason = plan["failure_reason"]
|
||||
assert "single-role zone" in reason
|
||||
assert "can_redistribute=False" in reason
|
||||
# apply emits nothing when infeasible.
|
||||
assert apply_cross_zone_redistribute_css(plan) == ""
|
||||
|
||||
|
||||
def test_empty_fit_analysis_infeasible():
|
||||
"""No roles at all → defensive infeasible (cannot redistribute nothing)."""
|
||||
plan = plan_cross_zone_redistribute(
|
||||
fit_analysis=FitAnalysis(roles={}), containers={}, min_margin_px=10.0,
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["role_heights_before"] == {}
|
||||
assert "no roles" in plan["failure_reason"]
|
||||
assert apply_cross_zone_redistribute_css(plan) == ""
|
||||
@@ -0,0 +1,119 @@
|
||||
"""IMP-12 u14 — failure_router cascade tests.
|
||||
|
||||
Stage 2 contract (unit u14):
|
||||
- donor_slack_insufficient → cross_zone_redistribute (impl=IMPLEMENTED)
|
||||
- 3 new failure types (cross_zone_redistribute_insufficient,
|
||||
glue_absorption_insufficient, font_step_insufficient) all route to
|
||||
expected next actions per the locked NEXT_ACTION_BY_FAILURE table
|
||||
- rerender_still_fails preserved → frame_reselect
|
||||
|
||||
u2 (classifier) inspects retry_trace["salvage_steps"][-1] for the 3 new
|
||||
salvage failure types via SALVAGE_FAILURE_TYPE_BY_ACTION; u3 wires those
|
||||
failure types onto the deterministic cascade in NEXT_ACTION_BY_FAILURE.
|
||||
u7 records the cascade actions as IMPLEMENTED in NEXT_ACTION_IMPLEMENTATION_STATUS.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_failure_router import (
|
||||
NEXT_ACTION_BY_FAILURE,
|
||||
NEXT_ACTION_IMPLEMENTATION_STATUS,
|
||||
classify_retry_failure,
|
||||
enrich_retry_trace_with_failure_classification,
|
||||
route_retry_failure,
|
||||
)
|
||||
|
||||
|
||||
def test_donor_slack_insufficient_routes_to_cross_zone_redistribute_implemented():
|
||||
"""Stage 1 root cause — primary donor slack insufficient classifies as
|
||||
donor_slack_insufficient and routes onto the deterministic salvage cascade
|
||||
starting with cross_zone_redistribute (IMPLEMENTED per u7)."""
|
||||
trace = {
|
||||
"retry_attempted": True,
|
||||
"retry_passed": False,
|
||||
"plan": {
|
||||
"feasible": False,
|
||||
"failure_reason": (
|
||||
"primary donor 'bottom' slack 15px (aggregate 25px from 2 donor(s)) "
|
||||
"< target_added_px 70px"
|
||||
),
|
||||
},
|
||||
}
|
||||
fc = classify_retry_failure(trace)
|
||||
assert fc is not None
|
||||
assert fc["failure_type"] == "donor_slack_insufficient"
|
||||
|
||||
nr = route_retry_failure("donor_slack_insufficient")
|
||||
assert nr["next_proposed_action"] == "cross_zone_redistribute"
|
||||
assert nr["next_action_implementation_status"] == "IMPLEMENTED"
|
||||
|
||||
# enrichment composes both fields onto the trace
|
||||
enrich_retry_trace_with_failure_classification(trace)
|
||||
assert trace["failure_classification"]["failure_type"] == "donor_slack_insufficient"
|
||||
assert trace["next_action_proposal"]["next_proposed_action"] == "cross_zone_redistribute"
|
||||
|
||||
|
||||
def test_no_donor_candidates_routes_to_cross_zone_redistribute_implemented():
|
||||
"""no_donor_candidates is the second cascade entry — also onto
|
||||
cross_zone_redistribute per the locked mapping."""
|
||||
trace = {
|
||||
"retry_attempted": True,
|
||||
"retry_passed": False,
|
||||
"plan": {"feasible": False, "failure_reason": "no donor candidates"},
|
||||
}
|
||||
fc = classify_retry_failure(trace)
|
||||
assert fc["failure_type"] == "no_donor_candidates"
|
||||
nr = route_retry_failure("no_donor_candidates")
|
||||
assert nr["next_proposed_action"] == "cross_zone_redistribute"
|
||||
assert nr["next_action_implementation_status"] == "IMPLEMENTED"
|
||||
|
||||
|
||||
def test_three_new_salvage_failure_types_route_to_expected_cascade_actions():
|
||||
"""u2 classifier inspects salvage_steps[-1]. u3 routes the 3 new failure
|
||||
types through the deterministic cascade: cross_zone → glue → font_step →
|
||||
layout_adjust. Verifies the locked NEXT_ACTION_BY_FAILURE table directly
|
||||
and via the classifier path."""
|
||||
# Direct mapping (u3 lock)
|
||||
assert NEXT_ACTION_BY_FAILURE["cross_zone_redistribute_insufficient"] == "glue_compression"
|
||||
assert NEXT_ACTION_BY_FAILURE["glue_absorption_insufficient"] == "font_step_compression"
|
||||
assert NEXT_ACTION_BY_FAILURE["font_step_insufficient"] == "layout_adjust"
|
||||
|
||||
# Implementation status (u7): 2 cascade entries IMPLEMENTED, layout_adjust MISSING
|
||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["glue_compression"] == "IMPLEMENTED"
|
||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["font_step_compression"] == "IMPLEMENTED"
|
||||
assert NEXT_ACTION_IMPLEMENTATION_STATUS["layout_adjust"] == "MISSING"
|
||||
|
||||
# Classifier path via salvage_steps[-1].action → failure_type → next action
|
||||
cases = [
|
||||
("cross_zone_redistribute", "cross_zone_redistribute_insufficient", "glue_compression"),
|
||||
("glue_compression", "glue_absorption_insufficient", "font_step_compression"),
|
||||
("font_step_compression", "font_step_insufficient", "layout_adjust"),
|
||||
]
|
||||
for action, expected_ftype, expected_next in cases:
|
||||
trace = {
|
||||
"retry_attempted": True,
|
||||
"retry_passed": False,
|
||||
"salvage_passed": False,
|
||||
"salvage_steps": [
|
||||
{"action": action, "passed": False, "failure_reason": "salvage failed"}
|
||||
],
|
||||
}
|
||||
fc = classify_retry_failure(trace)
|
||||
assert fc is not None, f"classifier returned None for action={action}"
|
||||
assert fc["failure_type"] == expected_ftype
|
||||
nr = route_retry_failure(fc["failure_type"])
|
||||
assert nr["next_proposed_action"] == expected_next
|
||||
|
||||
|
||||
def test_rerender_still_fails_preserved_routes_to_frame_reselect():
|
||||
"""Pre-cascade behavior preserved: when plan was feasible and rerender ran
|
||||
but visual still failed, classifier emits rerender_still_fails → frame_reselect."""
|
||||
trace = {
|
||||
"retry_attempted": True,
|
||||
"retry_passed": False,
|
||||
"plan": {"feasible": True},
|
||||
"rerender_attempted": True,
|
||||
}
|
||||
fc = classify_retry_failure(trace)
|
||||
assert fc["failure_type"] == "rerender_still_fails"
|
||||
nr = route_retry_failure("rerender_still_fails")
|
||||
assert nr["next_proposed_action"] == "frame_reselect"
|
||||
@@ -0,0 +1,77 @@
|
||||
"""IMP-12 u13 — plan_font_step_compression tests.
|
||||
|
||||
Stage 2 contract (unit u13):
|
||||
- feasible case (15.2 → 13 closes excess)
|
||||
- infeasible (8px floor — FONT_SIZE_STEPS exhausted)
|
||||
- text_metrics missing → defensive infeasible reason
|
||||
|
||||
u6 wraps space_allocator.find_fitting_font_size in the Step-17 plan signature.
|
||||
Height savings per candidate font_size (Korean 1.6 line-height):
|
||||
height_saved = (current_font_px * 1.6 - font_size * 1.6) * available_lines
|
||||
|
||||
Scope lock per feedback_phase_z_spacing_direction:
|
||||
- apply_font_step_compression_css emits ONLY [data-zone-position="<pos>"] rule.
|
||||
- No :root / body / .slide / .zone selectors permitted.
|
||||
"""
|
||||
|
||||
from src.phase_z2_retry import (
|
||||
apply_font_step_compression_css,
|
||||
plan_font_step_compression,
|
||||
)
|
||||
|
||||
|
||||
def test_feasible_15_2_to_13_closes_excess() -> None:
|
||||
"""current=15.2, excess=20, lines=10 → 14.0 saves 19.2 (insufficient);
|
||||
13.0 saves 35.2 (>=20) → target_font_px=13.0. Emitted CSS scope-locked."""
|
||||
plan = plan_font_step_compression(
|
||||
current_font_px=15.2, excess_after_glue_px=20.0,
|
||||
available_lines=10, chars_per_line=40, zone_position="bottom_l",
|
||||
)
|
||||
assert plan["action"] == "font_step_compression"
|
||||
assert plan["zone_position"] == "bottom_l"
|
||||
assert plan["current_font_px"] == 15.2
|
||||
assert plan["excess_after_glue_px"] == 20.0
|
||||
assert plan["available_lines"] == 10
|
||||
assert plan["chars_per_line"] == 40
|
||||
assert plan["font_floor_px"] == 8.0
|
||||
assert plan["feasible"] is True
|
||||
assert plan["target_font_px"] == 13.0
|
||||
assert "failure_reason" not in plan
|
||||
|
||||
css = apply_font_step_compression_css(plan)
|
||||
assert '[data-zone-position="bottom_l"]' in css
|
||||
assert "font-size: 13.0px" in css
|
||||
for forbidden in (":root", "body ", ".slide", ".zone"):
|
||||
assert forbidden not in css, f"scope-lock violation: {forbidden!r} in css"
|
||||
|
||||
|
||||
def test_infeasible_font_floor_exhausted() -> None:
|
||||
"""current=15.2, excess=200, lines=10 — even 8.0px floor saves only 115.2,
|
||||
so FONT_SIZE_STEPS is exhausted → feasible=False, classifier-matching reason."""
|
||||
plan = plan_font_step_compression(
|
||||
current_font_px=15.2, excess_after_glue_px=200.0,
|
||||
available_lines=10, chars_per_line=40, zone_position="top",
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["target_font_px"] is None
|
||||
assert plan["font_floor_px"] == 8.0
|
||||
reason = plan["failure_reason"]
|
||||
assert "font_step floor" in reason
|
||||
assert "8.0px" in reason
|
||||
assert "200.0px" in reason
|
||||
assert "FONT_SIZE_STEPS exhausted" in reason
|
||||
assert apply_font_step_compression_css(plan) == ""
|
||||
|
||||
|
||||
def test_text_metrics_missing_defensive_infeasible() -> None:
|
||||
"""available_lines=0 → guard fires before find_fitting_font_size;
|
||||
failure_reason carries the text_metrics missing substring (classifier-friendly)."""
|
||||
plan = plan_font_step_compression(
|
||||
current_font_px=15.2, excess_after_glue_px=40.0,
|
||||
available_lines=0, chars_per_line=40, zone_position="bottom_r",
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["target_font_px"] is None
|
||||
assert "text_metrics missing" in plan["failure_reason"]
|
||||
assert "available_lines/chars_per_line required" in plan["failure_reason"]
|
||||
assert apply_font_step_compression_css(plan) == ""
|
||||
@@ -0,0 +1,87 @@
|
||||
"""IMP-12 u12 — plan_glue_compression tests.
|
||||
|
||||
Stage 2 contract (unit u12):
|
||||
- feasible case asserts emitted CSS contains [data-zone-position=...]
|
||||
selector and NO global :root / body / .slide rule (scope lock)
|
||||
- insufficient case feasible=False with envelope reason
|
||||
|
||||
u5 wraps space_allocator.calculate_glue_absorption + compute_glue_css_overrides
|
||||
in the Step-17 plan signature. Glue envelope per block_count (SPACING_GLUE):
|
||||
absorption_max = block_gap.shrink * (block_count-1) # 12 * (n-1)
|
||||
+ inner_gap.shrink * block_count # 8 * n
|
||||
+ title_gap.shrink * block_count # 4 * n
|
||||
+ container_padding.shrink * 2 # 8 * 2
|
||||
|
||||
block_count=3 → 12*2 + 8*3 + 4*3 + 8*2 = 24+24+12+16 = 76 px
|
||||
block_count=1 → 12*0 + 8*1 + 4*1 + 8*2 = 0+8+4+16 = 28 px
|
||||
|
||||
CSS must be wrapped under [data-zone-position="<pos>"] only
|
||||
(feedback_phase_z_spacing_direction — no :root/body/.slide/.zone mutation).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_retry import (
|
||||
apply_glue_compression_css,
|
||||
plan_glue_compression,
|
||||
)
|
||||
|
||||
|
||||
def test_feasible_case_emits_zone_scoped_css():
|
||||
"""excess (40px) <= absorption_max (76px @ block_count=3) → feasible.
|
||||
|
||||
Emitted CSS must wrap overrides in [data-zone-position=...] selector and
|
||||
contain none of the global selectors banned by feedback_phase_z_spacing_direction.
|
||||
"""
|
||||
plan = plan_glue_compression(
|
||||
excess_px=40.0, block_count=3, zone_position="bottom_l",
|
||||
)
|
||||
assert plan["action"] == "glue_compression"
|
||||
assert plan["zone_position"] == "bottom_l"
|
||||
assert plan["feasible"] is True
|
||||
assert plan["excess_px"] == 40.0
|
||||
assert plan["block_count"] == 3
|
||||
assert plan["absorption_max_px"] == 76.0
|
||||
overrides = plan["overrides"]
|
||||
assert overrides, "feasible plan must return non-empty overrides"
|
||||
for key in ("--spacing-block", "--spacing-inner", "--container-padding"):
|
||||
assert key in overrides, f"missing override key {key}"
|
||||
|
||||
css = apply_glue_compression_css(plan)
|
||||
assert '[data-zone-position="bottom_l"]' in css
|
||||
assert "--spacing-block:" in css
|
||||
assert "--spacing-inner:" in css
|
||||
assert "--container-padding:" in css
|
||||
# Scope lock — no global rules permitted.
|
||||
for forbidden in (":root", "body ", ".slide", ".zone"):
|
||||
assert forbidden not in css, f"forbidden selector {forbidden!r} leaked into glue CSS"
|
||||
|
||||
|
||||
def test_insufficient_envelope_feasible_false_with_reason():
|
||||
"""excess (80px) > absorption_max (28px @ block_count=1) → infeasible.
|
||||
|
||||
failure_reason must surface the envelope shortage so the cascade router
|
||||
(NEXT_ACTION_BY_FAILURE) can route onward to font_step_compression.
|
||||
"""
|
||||
plan = plan_glue_compression(
|
||||
excess_px=80.0, block_count=1, zone_position="top",
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["absorption_max_px"] == 28.0
|
||||
reason = plan["failure_reason"]
|
||||
assert "glue envelope insufficient" in reason
|
||||
assert "excess_px 80" in reason
|
||||
assert "max absorption 28" in reason
|
||||
# apply emits nothing when infeasible — no accidental CSS mutation on revert path.
|
||||
assert apply_glue_compression_css(plan) == ""
|
||||
|
||||
|
||||
def test_excess_non_positive_no_compression_needed():
|
||||
"""excess_px <= 0 → defensive infeasible (no compression required)."""
|
||||
plan = plan_glue_compression(
|
||||
excess_px=0.0, block_count=3, zone_position="bottom_r",
|
||||
)
|
||||
assert plan["feasible"] is False
|
||||
assert plan["overrides"] == {}
|
||||
assert plan["absorption_max_px"] == 0.0
|
||||
assert "no compression needed" in plan["failure_reason"]
|
||||
assert apply_glue_compression_css(plan) == ""
|
||||
@@ -0,0 +1,147 @@
|
||||
"""IMP-12 u10 — plan_zone_ratio_retry multi-donor aggregation tests.
|
||||
|
||||
Stage 2 contract (unit u10):
|
||||
- single-donor sufficient (regression — backward compat preserved)
|
||||
- single insufficient + 2nd sufficient (multi-donor PASS path)
|
||||
- aggregate insufficient (multi-donor FAIL path)
|
||||
|
||||
u1 extended plan_zone_ratio_retry from a single primary donor to greedy
|
||||
slack-desc aggregation across all eligible sibling zones. The plan dict
|
||||
now carries donors_used / aggregate_slack_used / aggregate_slack_available
|
||||
while preserving donor_zone_position + donor_reduced_px for the failure
|
||||
classifier substrings (router still keys off primary donor name).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_retry import plan_zone_ratio_retry
|
||||
|
||||
|
||||
_ROUTER_ACTIVE = {"router_active": True}
|
||||
|
||||
|
||||
def _classification(target_pos: str, excess_y: float) -> dict:
|
||||
return {
|
||||
"classifications": [
|
||||
{
|
||||
"proposed_action": "zone_ratio_retry",
|
||||
"zone_position": target_pos,
|
||||
"inputs": {"excess_y": excess_y},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _zone(position: str, height_px: int, min_height_px: int,
|
||||
fit_status: str | None = "ok") -> dict:
|
||||
return {
|
||||
"position": position,
|
||||
"height_px": height_px,
|
||||
"min_height_px": min_height_px,
|
||||
"composition_rationale": {
|
||||
"capacity_fit": {"fit_status": fit_status},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _overflow_clean(donor_positions: list[str]) -> dict:
|
||||
return {
|
||||
"zones": [
|
||||
{"position": p, "overflowed": False, "clipped_inner": False}
|
||||
for p in donor_positions
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_single_donor_sufficient_regression():
|
||||
"""One donor with abundant slack. Plan must remain feasible and the
|
||||
legacy donor_zone_position / donor_reduced_px fields must reflect the
|
||||
primary donor (router classifier substring stability)."""
|
||||
debug_zones = [
|
||||
_zone("top", height_px=200, min_height_px=180),
|
||||
_zone("bottom", height_px=400, min_height_px=200), # slack=200
|
||||
]
|
||||
plan = plan_zone_ratio_retry(
|
||||
debug_zones=debug_zones,
|
||||
overflow=_overflow_clean(["bottom"]),
|
||||
fit_classification=_classification("top", excess_y=20.0),
|
||||
router_decision=_ROUTER_ACTIVE,
|
||||
)
|
||||
assert plan is not None
|
||||
assert plan["feasible"] is True
|
||||
# target_added_px = ceil(20) + DEFAULT_SAFETY_MARGIN_PX(4) = 24
|
||||
assert plan["target_added_px"] == 24
|
||||
assert plan["donor_zone_position"] == "bottom"
|
||||
assert plan["donor_reduced_px"] == 24
|
||||
assert plan["donors_used"] == [
|
||||
{"position": "bottom", "reduced_px": 24,
|
||||
"slack_before": 200, "slack_after": 176}
|
||||
]
|
||||
assert plan["aggregate_slack_used"] == 24
|
||||
assert plan["aggregate_slack_available"] == 200
|
||||
assert plan["zones_after"]["top"] == 224
|
||||
assert plan["zones_after"]["bottom"] == 376
|
||||
|
||||
|
||||
def test_multi_donor_pass_primary_insufficient_secondary_covers():
|
||||
"""Primary donor alone has insufficient slack but primary + secondary
|
||||
aggregate covers target_added_px. Multi-donor greedy aggregation must
|
||||
split the deficit across both donors in slack-desc order."""
|
||||
debug_zones = [
|
||||
_zone("top", height_px=300, min_height_px=200),
|
||||
_zone("middle", height_px=250, min_height_px=200), # slack=50
|
||||
_zone("bottom", height_px=240, min_height_px=200), # slack=40
|
||||
]
|
||||
plan = plan_zone_ratio_retry(
|
||||
debug_zones=debug_zones,
|
||||
overflow=_overflow_clean(["middle", "bottom"]),
|
||||
fit_classification=_classification("top", excess_y=66.0),
|
||||
router_decision=_ROUTER_ACTIVE,
|
||||
)
|
||||
# target_added_px = ceil(66)+4 = 70. Primary (middle, slack=50) alone
|
||||
# cannot cover, but middle(50)+bottom(40)=90 >= 70.
|
||||
assert plan["feasible"] is True
|
||||
assert plan["target_added_px"] == 70
|
||||
assert plan["aggregate_slack_available"] == 90
|
||||
assert plan["aggregate_slack_used"] == 70
|
||||
assert plan["donor_zone_position"] == "middle" # primary
|
||||
assert plan["donor_reduced_px"] == 50 # primary takes its full slack
|
||||
assert [d["position"] for d in plan["donors_used"]] == ["middle", "bottom"]
|
||||
assert plan["donors_used"][0]["reduced_px"] == 50
|
||||
assert plan["donors_used"][1]["reduced_px"] == 20 # remainder
|
||||
assert plan["zones_after"]["top"] == 370
|
||||
assert plan["zones_after"]["middle"] == 200
|
||||
assert plan["zones_after"]["bottom"] == 220
|
||||
|
||||
|
||||
def test_multi_donor_fail_aggregate_insufficient():
|
||||
"""All donors combined still cannot cover target_added_px. Plan must
|
||||
be feasible=False with primary-donor substring preserved so the
|
||||
failure_router classifier still routes through donor_slack_insufficient."""
|
||||
debug_zones = [
|
||||
_zone("top", height_px=300, min_height_px=200),
|
||||
_zone("middle", height_px=210, min_height_px=200), # slack=10
|
||||
_zone("bottom", height_px=215, min_height_px=200), # slack=15
|
||||
]
|
||||
plan = plan_zone_ratio_retry(
|
||||
debug_zones=debug_zones,
|
||||
overflow=_overflow_clean(["middle", "bottom"]),
|
||||
fit_classification=_classification("top", excess_y=66.0),
|
||||
router_decision=_ROUTER_ACTIVE,
|
||||
)
|
||||
# target_added_px=70, aggregate=25 → fail
|
||||
assert plan["feasible"] is False
|
||||
assert plan["aggregate_slack_available"] == 25
|
||||
assert plan["aggregate_slack_used"] == 0
|
||||
assert plan["donors_used"] == []
|
||||
# Primary = highest-slack donor = bottom (15)
|
||||
assert plan["donor_zone_position"] == "bottom"
|
||||
assert plan["donor_max_slack"] == 15
|
||||
# Classifier substring stability: "donor", "slack", and "<" still present
|
||||
reason = plan["failure_reason"]
|
||||
assert "donor" in reason
|
||||
assert "slack" in reason
|
||||
assert "<" in reason
|
||||
# zones unchanged on fail (revert-friendly)
|
||||
assert plan["zones_after"]["top"] == 300
|
||||
assert plan["zones_after"]["middle"] == 210
|
||||
assert plan["zones_after"]["bottom"] == 215
|
||||
@@ -0,0 +1,196 @@
|
||||
"""IMP-15 실행-1 (Gitea issue #45) — Step 14 image_aspect_mismatch detection.
|
||||
|
||||
Tests Selenium-driven `<img>` aspect measurement added to ``run_overflow_check``:
|
||||
|
||||
* Fixture A — 200×100 image rendered at 200×100 → ``abs(delta) < tol``, no fail
|
||||
reason, ``passed=True``.
|
||||
* Fixture B — 200×100 image forced to render 200×200 → ``abs(delta) > 0.30``,
|
||||
fail reason includes ``image aspect mismatch in zone--primary:``,
|
||||
``passed=False``.
|
||||
* Fixture C — ``<img>`` with no ``.zone`` ancestor (attached directly under
|
||||
``.slide``) → event reports ``zone_position == "unknown"``.
|
||||
|
||||
Chromedriver resolution mirrors the pipeline's order
|
||||
(``PROJECT_ROOT/chromedriver{,.exe}`` → PATH fallback). When no driver is
|
||||
resolvable the suite skips by default; under ``PHASE_Z_REQUIRE_SELENIUM=1`` the
|
||||
tests are marked ``xfail(strict=True)`` so CI cannot silently lose coverage.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import (
|
||||
IMAGE_ASPECT_DELTA_TOL,
|
||||
PROJECT_ROOT,
|
||||
run_overflow_check,
|
||||
)
|
||||
|
||||
PIL_Image = pytest.importorskip("PIL.Image", reason="Pillow required for fixture PNGs")
|
||||
|
||||
|
||||
# ─── chromedriver skip / xfail guard ─────────────────────────────────
|
||||
|
||||
def _selenium_manager_resolvable() -> bool:
|
||||
"""Probe ``webdriver.Chrome(options=...)`` — pipeline's third tier.
|
||||
|
||||
``src/phase_z2_pipeline.py`` (run_overflow_check) tries
|
||||
``PROJECT_ROOT/chromedriver{,.exe}`` first, then falls back to
|
||||
``webdriver.Chrome(options=options)`` which delegates to Selenium Manager
|
||||
for driver auto-resolution. The test resolver must mirror that fallback
|
||||
or PHASE_Z_REQUIRE_SELENIUM=1 produces spurious strict-XPASS failures on
|
||||
machines where Selenium Manager can satisfy the pipeline at runtime.
|
||||
"""
|
||||
try:
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options as _Opts
|
||||
except Exception:
|
||||
return False
|
||||
opts = _Opts()
|
||||
opts.add_argument("--headless=new")
|
||||
opts.add_argument("--no-sandbox")
|
||||
opts.add_argument("--disable-dev-shm-usage")
|
||||
try:
|
||||
drv = webdriver.Chrome(options=opts)
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
drv.quit()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def _chromedriver_resolvable() -> bool:
|
||||
"""Mirror pipeline order: PROJECT_ROOT/chromedriver{,.exe} → PATH → Selenium Manager."""
|
||||
for candidate in (PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe"):
|
||||
if candidate.is_file():
|
||||
return True
|
||||
if shutil.which("chromedriver") or shutil.which("chromedriver.exe"):
|
||||
return True
|
||||
return _selenium_manager_resolvable()
|
||||
|
||||
|
||||
_REQUIRE_SELENIUM = os.environ.get("PHASE_Z_REQUIRE_SELENIUM") == "1"
|
||||
_DRIVER_AVAILABLE = _chromedriver_resolvable()
|
||||
|
||||
if not _DRIVER_AVAILABLE:
|
||||
if _REQUIRE_SELENIUM:
|
||||
pytestmark = pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="PHASE_Z_REQUIRE_SELENIUM=1 but chromedriver is unresolvable",
|
||||
)
|
||||
else:
|
||||
pytestmark = pytest.mark.skip(
|
||||
reason=(
|
||||
"chromedriver unresolvable (PROJECT_ROOT/chromedriver{,.exe} + PATH + Selenium Manager); "
|
||||
"set PHASE_Z_REQUIRE_SELENIUM=1 to make this a hard failure"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ─── HTML / PNG fixture helpers ──────────────────────────────────────
|
||||
|
||||
_SLIDE_CSS = """
|
||||
html, body { margin: 0; padding: 0; }
|
||||
.slide { width: 1280px; height: 720px; position: relative; box-sizing: border-box; }
|
||||
.zone { display: block; }
|
||||
"""
|
||||
|
||||
|
||||
def _write_png(path: Path, width: int, height: int, colour=(120, 160, 200)) -> Path:
|
||||
img = PIL_Image.new("RGB", (width, height), colour)
|
||||
img.save(path, format="PNG")
|
||||
return path
|
||||
|
||||
|
||||
def _write_slide_html(tmp_path: Path, body_inner: str, name: str = "slide.html") -> Path:
|
||||
html = (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
f"<style>{_SLIDE_CSS}</style></head><body>"
|
||||
'<div class="slide" data-page="1">'
|
||||
f"{body_inner}"
|
||||
"</div></body></html>"
|
||||
)
|
||||
path = tmp_path / name
|
||||
path.write_text(html, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def _find_event(events, src_basename: str) -> dict:
|
||||
for ev in events:
|
||||
if Path(ev.get("src", "")).name == src_basename:
|
||||
return ev
|
||||
raise AssertionError(f"image_events missing entry for {src_basename}; got {events}")
|
||||
|
||||
|
||||
# ─── tests ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_image_no_distortion(tmp_path: Path) -> None:
|
||||
"""Fixture A — 200×100 image rendered at native 200×100. delta ≈ 0."""
|
||||
png = _write_png(tmp_path / "ok.png", 200, 100)
|
||||
body = (
|
||||
'<div class="zone" data-zone-position="primary" data-template-id="t_ok">'
|
||||
f'<img src="{png.name}" style="width:200px;height:100px;display:block">'
|
||||
"</div>"
|
||||
)
|
||||
html_path = _write_slide_html(tmp_path, body, name="ok.html")
|
||||
result = run_overflow_check(html_path)
|
||||
|
||||
assert "error" not in result, result
|
||||
assert result.get("image_events"), "image_events must be populated"
|
||||
|
||||
ev = _find_event(result["image_events"], png.name)
|
||||
assert ev["zone_position"] == "primary"
|
||||
assert ev["natural_w"] == 200 and ev["natural_h"] == 100
|
||||
assert ev["rendered_w"] == 200 and ev["rendered_h"] == 100
|
||||
assert ev["delta"] is not None and abs(ev["delta"]) < IMAGE_ASPECT_DELTA_TOL
|
||||
|
||||
image_fails = [r for r in result.get("fail_reasons", []) if r.startswith("image aspect mismatch")]
|
||||
assert image_fails == [], f"unexpected image fail_reasons: {image_fails}"
|
||||
assert result["passed"] is True, result.get("fail_reasons")
|
||||
|
||||
|
||||
def test_image_forced_distortion(tmp_path: Path) -> None:
|
||||
"""Fixture B — 200×100 image forced to 200×200. delta > 0.30, fail emitted."""
|
||||
png = _write_png(tmp_path / "bad.png", 200, 100, colour=(200, 80, 80))
|
||||
body = (
|
||||
'<div class="zone" data-zone-position="primary" data-template-id="t_bad">'
|
||||
f'<img src="{png.name}" style="width:200px;height:200px;display:block">'
|
||||
"</div>"
|
||||
)
|
||||
html_path = _write_slide_html(tmp_path, body, name="bad.html")
|
||||
result = run_overflow_check(html_path)
|
||||
|
||||
assert "error" not in result, result
|
||||
ev = _find_event(result["image_events"], png.name)
|
||||
assert ev["natural_w"] == 200 and ev["natural_h"] == 100
|
||||
assert ev["rendered_w"] == 200 and ev["rendered_h"] == 200
|
||||
assert ev["delta"] is not None and abs(ev["delta"]) > 0.30
|
||||
|
||||
image_fails = [r for r in result.get("fail_reasons", []) if r.startswith("image aspect mismatch")]
|
||||
assert len(image_fails) == 1, f"expected one image fail_reason, got: {image_fails}"
|
||||
msg = image_fails[0]
|
||||
assert msg.startswith("image aspect mismatch in zone--primary:"), msg
|
||||
assert "natural=2.000" in msg and "rendered=1.000" in msg
|
||||
assert f"src={png.name}" in msg or png.name in msg
|
||||
assert result["passed"] is False
|
||||
|
||||
|
||||
def test_image_no_zone_ancestor(tmp_path: Path) -> None:
|
||||
"""Fixture C — <img> attached directly under .slide → zone_position == 'unknown'."""
|
||||
png = _write_png(tmp_path / "loose.png", 200, 100, colour=(80, 200, 120))
|
||||
body = f'<img src="{png.name}" style="width:200px;height:100px;display:block">'
|
||||
html_path = _write_slide_html(tmp_path, body, name="loose.html")
|
||||
result = run_overflow_check(html_path)
|
||||
|
||||
assert "error" not in result, result
|
||||
ev = _find_event(result["image_events"], png.name)
|
||||
assert ev["zone_position"] == "unknown"
|
||||
assert ev["natural_w"] == 200 and ev["natural_h"] == 100
|
||||
assert ev["delta"] is not None and abs(ev["delta"]) < IMAGE_ASPECT_DELTA_TOL
|
||||
image_fails = [r for r in result.get("fail_reasons", []) if r.startswith("image aspect mismatch")]
|
||||
assert image_fails == []
|
||||
@@ -0,0 +1,334 @@
|
||||
"""IMP-15 실행-2 (Gitea issue #46) — Step 14 table_self_overflow detection.
|
||||
|
||||
Tests Selenium-driven ``<table>`` self-overflow measurement and element-identity
|
||||
wrapper dedup added to ``run_overflow_check``:
|
||||
|
||||
* Fixture D — standalone ``<table>`` self-overflow, no clipped wrapper ancestor →
|
||||
``table_events`` entry reports ``wrapper_clipped_index = None`` and an
|
||||
``excess_*`` exceeding ``TABLE_SCROLL_TOL_PX``; Python aggregation then emits
|
||||
a ``table self-overflow`` fail_reason and flips ``result["passed"] = False``.
|
||||
* Fixture E — ``<table>`` inside a clipped ``f13b`` wrapper. The wrapper itself
|
||||
self-overflows (registers in ``clippedWrapperMap``) and the inner table also
|
||||
self-overflows. Asserts dedup is honored: the table's ``wrapper_clipped_index``
|
||||
resolves to the wrapper's map index (non-null) so the Python aggregation MUST
|
||||
NOT emit a ``table self-overflow`` fail_reason — only the wrapper's pre-existing
|
||||
``inner clipped`` fail line remains.
|
||||
|
||||
* Fixture F — two wrappers W1 / W2 share identical className ``f13b-cell``. W1
|
||||
contains an overflowing inline-block child (no ``<table>``) → W1 self-overflows
|
||||
and registers in ``clippedWrapperMap`` (emits ``inner clipped``). W2 contains
|
||||
only a self-overflowing ``<table>``; W2's own scrollWidth equals its clientWidth
|
||||
(the table's ``overflow:hidden`` keeps W2 itself uncliped). The element-identity
|
||||
ancestor walk MUST resolve the W2 table's ``wrapper_clipped_index`` to ``None``
|
||||
(W2 ≠ W1 by DOM reference, despite identical class string). A class-string
|
||||
lookup would have falsely resolved the W2 table → W1 and suppressed the fail —
|
||||
the test thereby proves ``Map<Element, int>`` distinguishes by node identity.
|
||||
|
||||
Chromedriver resolution mirrors the pipeline order
|
||||
(``PROJECT_ROOT/chromedriver{,.exe}`` → PATH → Selenium Manager). When no driver
|
||||
is resolvable the suite skips by default; under ``PHASE_Z_REQUIRE_SELENIUM=1``
|
||||
the tests are marked ``xfail(strict=True)`` so CI cannot silently lose coverage.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import (
|
||||
PROJECT_ROOT,
|
||||
TABLE_SCROLL_TOL_PX,
|
||||
run_overflow_check,
|
||||
)
|
||||
|
||||
|
||||
# ─── chromedriver skip / xfail guard ─────────────────────────────────
|
||||
|
||||
def _selenium_manager_resolvable() -> bool:
|
||||
"""Probe ``webdriver.Chrome(options=...)`` — pipeline's third tier.
|
||||
|
||||
``src/phase_z2_pipeline.py`` (run_overflow_check) tries
|
||||
``PROJECT_ROOT/chromedriver{,.exe}`` first, then falls back to
|
||||
``webdriver.Chrome(options=options)`` which delegates to Selenium Manager
|
||||
for driver auto-resolution. The test resolver must mirror that fallback
|
||||
or ``PHASE_Z_REQUIRE_SELENIUM=1`` produces spurious strict-XPASS failures
|
||||
on machines where Selenium Manager can satisfy the pipeline at runtime.
|
||||
"""
|
||||
try:
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.options import Options as _Opts
|
||||
except Exception:
|
||||
return False
|
||||
opts = _Opts()
|
||||
opts.add_argument("--headless=new")
|
||||
opts.add_argument("--no-sandbox")
|
||||
opts.add_argument("--disable-dev-shm-usage")
|
||||
try:
|
||||
drv = webdriver.Chrome(options=opts)
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
drv.quit()
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
|
||||
def _chromedriver_resolvable() -> bool:
|
||||
"""Mirror pipeline order: PROJECT_ROOT/chromedriver{,.exe} → PATH → Selenium Manager."""
|
||||
for candidate in (PROJECT_ROOT / "chromedriver", PROJECT_ROOT / "chromedriver.exe"):
|
||||
if candidate.is_file():
|
||||
return True
|
||||
if shutil.which("chromedriver") or shutil.which("chromedriver.exe"):
|
||||
return True
|
||||
return _selenium_manager_resolvable()
|
||||
|
||||
|
||||
_REQUIRE_SELENIUM = os.environ.get("PHASE_Z_REQUIRE_SELENIUM") == "1"
|
||||
_DRIVER_AVAILABLE = _chromedriver_resolvable()
|
||||
|
||||
if not _DRIVER_AVAILABLE:
|
||||
if _REQUIRE_SELENIUM:
|
||||
pytestmark = pytest.mark.xfail(
|
||||
strict=True,
|
||||
reason="PHASE_Z_REQUIRE_SELENIUM=1 but chromedriver is unresolvable",
|
||||
)
|
||||
else:
|
||||
pytestmark = pytest.mark.skip(
|
||||
reason=(
|
||||
"chromedriver unresolvable (PROJECT_ROOT/chromedriver{,.exe} + PATH + Selenium Manager); "
|
||||
"set PHASE_Z_REQUIRE_SELENIUM=1 to make this a hard failure"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ─── HTML fixture helpers ────────────────────────────────────────────
|
||||
|
||||
_SLIDE_CSS = """
|
||||
html, body { margin: 0; padding: 0; }
|
||||
.slide { width: 1280px; height: 720px; position: relative; box-sizing: border-box; }
|
||||
.zone { display: block; }
|
||||
"""
|
||||
|
||||
|
||||
def _write_slide_html(tmp_path: Path, body_inner: str, name: str = "slide.html") -> Path:
|
||||
html = (
|
||||
"<!doctype html><html><head><meta charset='utf-8'>"
|
||||
f"<style>{_SLIDE_CSS}</style></head><body>"
|
||||
'<div class="slide" data-page="1">'
|
||||
f"{body_inner}"
|
||||
"</div></body></html>"
|
||||
)
|
||||
path = tmp_path / name
|
||||
path.write_text(html, encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
# ─── tests ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_fixture_d_standalone_table_overflow(tmp_path: Path) -> None:
|
||||
"""Fixture D — standalone ``<table>`` self-overflow, no clipped wrapper.
|
||||
|
||||
The table is forced into block layout with a fixed clientWidth (100px) and
|
||||
``overflow: hidden``; the inner cell is 600px wide with ``white-space:nowrap``,
|
||||
so the table's scrollWidth exceeds clientWidth by well over ``TABLE_SCROLL_TOL_PX``.
|
||||
No ancestor carries an ``f13b/f29b/f16b`` class, so the element-identity walk
|
||||
must report ``wrapper_clipped_index = None``. Python aggregation then emits a
|
||||
``table self-overflow`` fail_reason and flips ``result["passed"]`` to ``False``.
|
||||
"""
|
||||
body = (
|
||||
'<div class="zone" data-zone-position="primary" data-template-id="t_table">'
|
||||
'<table style="display:block; width:100px; height:30px; overflow:hidden; '
|
||||
'box-sizing:border-box; table-layout:fixed;">'
|
||||
'<tr><td style="width:600px; white-space:nowrap;">'
|
||||
'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
|
||||
'</td></tr>'
|
||||
'</table>'
|
||||
'</div>'
|
||||
)
|
||||
html_path = _write_slide_html(tmp_path, body, name="fixture_d.html")
|
||||
result = run_overflow_check(html_path)
|
||||
|
||||
assert "error" not in result, result
|
||||
assert "table_events" in result, "run_overflow_check must expose table_events"
|
||||
table_events = result["table_events"]
|
||||
assert len(table_events) == 1, f"expected one table_events entry, got: {table_events}"
|
||||
|
||||
ev = table_events[0]
|
||||
assert ev["zone_position"] == "primary", ev
|
||||
assert ev["zone_template_id"] == "t_table", ev
|
||||
assert ev["wrapper_clipped_index"] is None, (
|
||||
f"standalone table must have null wrapper_clipped_index; got {ev['wrapper_clipped_index']}"
|
||||
)
|
||||
assert ev["excess_x"] > TABLE_SCROLL_TOL_PX, (
|
||||
f"expected excess_x > {TABLE_SCROLL_TOL_PX}; got {ev['excess_x']} "
|
||||
f"(clientWidth={ev['clientWidth']}, scrollWidth={ev['scrollWidth']})"
|
||||
)
|
||||
|
||||
# Python aggregation: emitted fail_reason + passed flipped to False.
|
||||
fail_reasons = result.get("fail_reasons", [])
|
||||
table_fails = [r for r in fail_reasons if "table self-overflow" in r]
|
||||
assert len(table_fails) == 1, (
|
||||
f"expected exactly one 'table self-overflow' fail_reason; got fail_reasons={fail_reasons}"
|
||||
)
|
||||
assert "zone--primary" in table_fails[0], table_fails[0]
|
||||
assert f"tol={TABLE_SCROLL_TOL_PX}" in table_fails[0], table_fails[0]
|
||||
assert result["passed"] is False, (
|
||||
f"table self-overflow must flip passed=False; got result={result}"
|
||||
)
|
||||
|
||||
|
||||
def test_fixture_e_table_in_clipped_wrapper_dedup(tmp_path: Path) -> None:
|
||||
"""Fixture E — ``<table>`` inside a clipped ``f13b`` wrapper (dedup honored).
|
||||
|
||||
The wrapper (clientWidth=300, ``overflow:hidden``) contains a ``display:block``
|
||||
table forced to width=500px → wrapper.scrollWidth (≈500) − clientWidth (300) > 5px,
|
||||
so the wrapper is registered in ``clippedWrapperMap`` (emits ``inner clipped`` fail).
|
||||
The inner table is itself self-overflowing (clientWidth=500, content nowrap-cell
|
||||
width=900 → scrollWidth ≈ 900). The element-identity ancestor walk MUST resolve
|
||||
the table's ``wrapper_clipped_index`` to the wrapper's integer map index, and the
|
||||
Python aggregation MUST then SKIP emitting a ``table self-overflow`` fail_reason
|
||||
(the clipped wrapper already accounts for this).
|
||||
"""
|
||||
body = (
|
||||
'<div class="zone" data-zone-position="primary" data-template-id="t_table_wrap">'
|
||||
'<div class="f13b-cell" style="width:300px; height:60px; overflow:hidden; '
|
||||
'box-sizing:border-box; position:relative;">'
|
||||
'<table style="display:block; width:500px; height:40px; overflow:hidden; '
|
||||
'box-sizing:border-box; table-layout:fixed;">'
|
||||
'<tr><td style="width:900px; white-space:nowrap;">'
|
||||
'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'
|
||||
'</td></tr>'
|
||||
'</table>'
|
||||
'</div>'
|
||||
'</div>'
|
||||
)
|
||||
html_path = _write_slide_html(tmp_path, body, name="fixture_e.html")
|
||||
result = run_overflow_check(html_path)
|
||||
|
||||
assert "error" not in result, result
|
||||
table_events = result.get("table_events", [])
|
||||
assert len(table_events) == 1, f"expected one table_events entry, got: {table_events}"
|
||||
|
||||
ev = table_events[0]
|
||||
# Dedup signal: ancestor walk must hit the f13b wrapper via Map.has(node).
|
||||
assert ev["wrapper_clipped_index"] is not None, (
|
||||
f"table inside clipped wrapper must inherit wrapper index; got ev={ev}"
|
||||
)
|
||||
assert isinstance(ev["wrapper_clipped_index"], int), ev
|
||||
# The inner table is itself overflowing — proves the dedup is the only thing
|
||||
# suppressing the table_self_overflow fail (not absence of overflow).
|
||||
assert ev["excess_x"] > TABLE_SCROLL_TOL_PX, (
|
||||
f"inner table must be self-overflowing for this test to be meaningful; ev={ev}"
|
||||
)
|
||||
|
||||
fail_reasons = result.get("fail_reasons", [])
|
||||
table_fails = [r for r in fail_reasons if "table self-overflow" in r]
|
||||
assert table_fails == [], (
|
||||
f"dedup must suppress table self-overflow fail when wrapper is clipped; "
|
||||
f"got table_fails={table_fails} fail_reasons={fail_reasons}"
|
||||
)
|
||||
# Wrapper's clipped_inner fail line must still be present.
|
||||
clipped_fails = [r for r in fail_reasons if "inner clipped" in r and "f13b" in r]
|
||||
assert len(clipped_fails) >= 1, (
|
||||
f"wrapper clipped_inner fail must remain; got fail_reasons={fail_reasons}"
|
||||
)
|
||||
assert result["passed"] is False, result
|
||||
|
||||
|
||||
def test_fixture_f_two_same_class_wrappers_element_identity(tmp_path: Path) -> None:
|
||||
"""Fixture F (F1 acceptance) — two same-class wrappers, element-identity dedup.
|
||||
|
||||
W1 and W2 share the identical className ``f13b-cell``. W1 (clientWidth=300,
|
||||
``overflow:hidden``) contains an inline-block ``<div>`` of width 600px →
|
||||
W1.scrollWidth − clientWidth ≈ 300 > 5; W1 is registered in
|
||||
``clippedWrapperMap`` and emits an ``inner clipped`` fail line. W2
|
||||
(clientWidth=600, ``overflow:hidden``) contains a 500px-wide block-display
|
||||
``<table>`` (matching the Fixture E table shape so the table is itself
|
||||
self-overflowing with excess_x > 5). W2's clientWidth (600) is larger than
|
||||
the table's outer width (500), so W2's own scrollWidth ≈ 500 < clientWidth
|
||||
and W2 is NOT registered in ``clippedWrapperMap``.
|
||||
|
||||
The element-identity ancestor walk in the pipeline (L2298–L2304) walks from
|
||||
the W2 table upward via ``parentElement`` and queries
|
||||
``clippedWrapperMap.has(node)`` — keyed by DOM node, NOT className. W2 is
|
||||
a different ``Element`` reference from W1 despite identical class string,
|
||||
so the lookup returns false at W2 and the walk terminates at ``.slide`` with
|
||||
``wrapper_clipped_index = null``. A class-substring keyed map (the F1
|
||||
regression scenario described in issue #46) would have resolved any
|
||||
``[class*="f13b"]`` ancestor of the W2 table → W1's index and falsely
|
||||
suppressed the W2 table_self_overflow fail.
|
||||
|
||||
Asserts:
|
||||
* Exactly ONE ``inner clipped`` fail line (for W1) — proves W1 is in the map.
|
||||
* Exactly ONE ``table self-overflow`` fail line (for W2's table) — proves
|
||||
the W2 table is NOT suppressed by W1's identical class string.
|
||||
* W2 table's ``table_events`` entry reports ``wrapper_clipped_index = None``
|
||||
(element-identity contract) and ``excess_x > TABLE_SCROLL_TOL_PX``.
|
||||
"""
|
||||
body = (
|
||||
'<div class="zone" data-zone-position="primary" '
|
||||
'data-template-id="t_table_same_class">'
|
||||
# W1 — same className, overflowing non-table child.
|
||||
'<div class="f13b-cell" id="w1" style="width:300px; height:60px; '
|
||||
'overflow:hidden; box-sizing:border-box; position:relative; '
|
||||
'margin-bottom:8px;">'
|
||||
'<div style="display:inline-block; width:600px; white-space:nowrap;">'
|
||||
'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX'
|
||||
'</div>'
|
||||
'</div>'
|
||||
# W2 — same className, NOT clipped (W2.clientWidth=600 > table.outer=500),
|
||||
# but the inner table itself self-overflows (table width=500, td width=900).
|
||||
'<div class="f13b-cell" id="w2" style="width:600px; height:60px; '
|
||||
'overflow:hidden; box-sizing:border-box; position:relative;">'
|
||||
'<table style="display:block; width:500px; height:40px; '
|
||||
'overflow:hidden; box-sizing:border-box; table-layout:fixed;">'
|
||||
'<tr><td style="width:900px; white-space:nowrap;">'
|
||||
'YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY'
|
||||
'</td></tr>'
|
||||
'</table>'
|
||||
'</div>'
|
||||
'</div>'
|
||||
)
|
||||
html_path = _write_slide_html(tmp_path, body, name="fixture_f.html")
|
||||
result = run_overflow_check(html_path)
|
||||
|
||||
assert "error" not in result, result
|
||||
|
||||
# Exactly one table_events entry (the W2 table — W1 has no <table>).
|
||||
table_events = result.get("table_events", [])
|
||||
assert len(table_events) == 1, f"expected one table_events entry, got: {table_events}"
|
||||
|
||||
ev = table_events[0]
|
||||
# Element-identity contract: W2 ≠ W1, so the ancestor walk MUST NOT inherit
|
||||
# W1's wrapper index merely because W2 shares W1's class string.
|
||||
assert ev["wrapper_clipped_index"] is None, (
|
||||
f"W2 (not itself clipped) must NOT inherit W1's index via class string; "
|
||||
f"got wrapper_clipped_index={ev['wrapper_clipped_index']}. "
|
||||
"This is the F1 regression — a class-substring map would have failed here."
|
||||
)
|
||||
assert ev["excess_x"] > TABLE_SCROLL_TOL_PX, (
|
||||
f"W2's inner table must self-overflow for this test to be meaningful; ev={ev}"
|
||||
)
|
||||
|
||||
fail_reasons = result.get("fail_reasons", [])
|
||||
|
||||
# W1: inner clipped fail emitted (W1 is in clippedWrapperMap, has overflowing inner div).
|
||||
w1_clipped_fails = [r for r in fail_reasons if "inner clipped" in r and "f13b" in r]
|
||||
assert len(w1_clipped_fails) == 1, (
|
||||
f"expected exactly one W1 'inner clipped' fail; got fail_reasons={fail_reasons}"
|
||||
)
|
||||
|
||||
# W2: table self-overflow fail emitted because element-identity dedup correctly
|
||||
# reports wrapper_clipped_index=None for the W2 table (W2 ≠ W1 by DOM ref).
|
||||
table_fails = [r for r in fail_reasons if "table self-overflow" in r]
|
||||
assert len(table_fails) == 1, (
|
||||
f"expected exactly one W2 'table self-overflow' fail (element-identity dedup); "
|
||||
f"got fail_reasons={fail_reasons}"
|
||||
)
|
||||
assert "zone--primary" in table_fails[0], table_fails[0]
|
||||
assert f"tol={TABLE_SCROLL_TOL_PX}" in table_fails[0], table_fails[0]
|
||||
|
||||
assert result["passed"] is False, result
|
||||
@@ -0,0 +1,248 @@
|
||||
"""IMP-12 u15 — End-to-end test of `_attempt_salvage_chain` (Step 17 deterministic salvage cascade).
|
||||
|
||||
Three Stage 2 cases against `src.phase_z2_pipeline._attempt_salvage_chain`:
|
||||
(a) zone_ratio fail + cross_zone pass → final.html promoted, salvage_passed=True
|
||||
(b) cross_zone fail + glue pass → 2nd cascade step promoted, salvage_passed=True
|
||||
(c) all 3 fail → (b)-revert preserved, original final.html intact, salvage_passed=False
|
||||
|
||||
`render_slide` and `run_overflow_check` are monkey-patched so the test stays deterministic
|
||||
(no Selenium / Jinja2 template files). The patches only stand in for the rendering / overflow
|
||||
oracles — the planners (`plan_cross_zone_redistribute`, `plan_glue_compression`,
|
||||
`plan_font_step_compression`) and the cascade router (`route_retry_failure` /
|
||||
`SALVAGE_FAIL_BY_ACTION`) all run unmocked.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import src.phase_z2_pipeline as _pz_pipeline
|
||||
from src.fit_verifier import FitAnalysis, RoleFit
|
||||
from src.phase_z2_pipeline import _attempt_salvage_chain
|
||||
|
||||
|
||||
_PROJECT_ROOT = _pz_pipeline.PROJECT_ROOT
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_tmp(tmp_path_factory):
|
||||
"""Temp dir under PROJECT_ROOT so _attempt_salvage_chain can call
|
||||
candidate_path.relative_to(PROJECT_ROOT) without ValueError on a
|
||||
cross-drive system tmp path (pytest's default tmp_path is under
|
||||
%LOCALAPPDATA% on Windows, which lives on a different drive from
|
||||
the project root in this repo)."""
|
||||
base = _PROJECT_ROOT / ".orchestrator" / "tmp"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
d = Path(tempfile.mkdtemp(prefix="u15_salvage_", dir=str(base)))
|
||||
try:
|
||||
yield d
|
||||
finally:
|
||||
shutil.rmtree(d, ignore_errors=True)
|
||||
|
||||
|
||||
_LAYOUT_CSS_GATE_PASS = {
|
||||
"areas": '"top" "bottom"',
|
||||
"cols": "1fr",
|
||||
"rows": "1fr 1fr",
|
||||
"heights_px": [300, 290],
|
||||
"widths_px": [1180],
|
||||
"ratios": [0.508, 0.491],
|
||||
"width_ratios": [1.0],
|
||||
"dynamic_rows": True,
|
||||
"dynamic_cols": False,
|
||||
}
|
||||
|
||||
|
||||
def _patch_render(monkeypatch):
|
||||
"""Stub render_slide → deterministic HTML envelope so the cascade does not
|
||||
need real Jinja2 templates. Returns a counter so tests can assert how many
|
||||
times it was invoked (one per CSS-feasible cascade step)."""
|
||||
counter = {"n": 0}
|
||||
|
||||
def _stub(slide_title, slide_footer, zones_data, layout_preset, layout_css, gap_px=14):
|
||||
counter["n"] += 1
|
||||
return (
|
||||
f"<html><head><meta charset='utf-8'></head>"
|
||||
f"<body><div data-slide-title='{slide_title}'></div></body></html>"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(_pz_pipeline, "render_slide", _stub)
|
||||
return counter
|
||||
|
||||
|
||||
def _kwargs(*, run_dir: Path, out_path: Path, cascade_inputs: dict,
|
||||
initial_failure_type: str = "donor_slack_insufficient") -> dict:
|
||||
return {
|
||||
"run_dir": run_dir,
|
||||
"out_path": out_path,
|
||||
"slide_title": "u15-test",
|
||||
"slide_footer": None,
|
||||
"zones_data": [],
|
||||
"layout_preset": "horizontal-2",
|
||||
"layout_css": _LAYOUT_CSS_GATE_PASS,
|
||||
"cascade_inputs": cascade_inputs,
|
||||
"initial_failure_type": initial_failure_type,
|
||||
"gap_px": 14,
|
||||
}
|
||||
|
||||
|
||||
def test_case_a_cross_zone_passes_final_html_promoted(project_tmp, monkeypatch):
|
||||
"""(a) cross_zone_redistribute is feasible + run_overflow_check returns
|
||||
passed=True → out_path overwritten with the cross_zone candidate HTML and
|
||||
salvage_passed=True after the very first cascade iteration."""
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||
|
||||
# Multi-role same-zone FitAnalysis: top +30 deficit, bottom_l -50 surplus.
|
||||
fit_analysis = FitAnalysis(roles={
|
||||
"top": RoleFit(role="top", allocated_px=200, shortfall_px=30.0),
|
||||
"bottom_l": RoleFit(role="bottom_l", allocated_px=300, shortfall_px=-50.0),
|
||||
})
|
||||
containers = {
|
||||
"top": {"zone": "slide_body", "height_px": 200},
|
||||
"bottom_l": {"zone": "slide_body", "height_px": 300},
|
||||
}
|
||||
cascade_inputs = {
|
||||
"fit_analysis": fit_analysis,
|
||||
"containers": containers,
|
||||
"min_margin_px": 10,
|
||||
"excess_px": 30.0, "excess_after_glue_px": 30.0,
|
||||
"block_count": 3, "zone_position": "top",
|
||||
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
|
||||
}
|
||||
|
||||
_patch_render(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check",
|
||||
lambda p: {"passed": True, "fail_reasons": []},
|
||||
)
|
||||
|
||||
trace = _attempt_salvage_chain(
|
||||
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
|
||||
)
|
||||
|
||||
assert trace["salvage_attempted"] is True
|
||||
assert trace["salvage_passed"] is True
|
||||
assert len(trace["salvage_steps"]) == 1
|
||||
step0 = trace["salvage_steps"][0]
|
||||
assert step0["action"] == "cross_zone_redistribute"
|
||||
assert step0["passed"] is True
|
||||
assert step0["plan"]["feasible"] is True
|
||||
assert step0["css_override"] and '[data-role=' in step0["css_override"]
|
||||
# out_path was overwritten with the salvage candidate.
|
||||
promoted = out_path.read_text(encoding="utf-8")
|
||||
assert "ORIGINAL_BEFORE_SALVAGE" not in promoted
|
||||
assert "u15-test" in promoted
|
||||
|
||||
|
||||
def test_case_b_cross_zone_fails_glue_passes_second_promoted(project_tmp, monkeypatch):
|
||||
"""(b) cross_zone is infeasible (single-role zone) → glue_compression CSS
|
||||
emitted + run_overflow_check passes → out_path overwritten with the glue
|
||||
candidate (2nd cascade step). salvage_passed=True; salvage_steps[0]
|
||||
records the infeasible cross_zone attempt."""
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||
|
||||
# Single-role zone → fit_verifier.redistribute returns can_redistribute=False
|
||||
# (peer required, none present).
|
||||
fit_analysis = FitAnalysis(roles={
|
||||
"top": RoleFit(role="top", allocated_px=200, shortfall_px=30.0),
|
||||
})
|
||||
containers = {"top": {"zone": "slide_body", "height_px": 200}}
|
||||
# Glue envelope at block_count=3 = 12*(3-1)+8*3+4*3+8*2 = 76 px → 40 px is feasible.
|
||||
cascade_inputs = {
|
||||
"fit_analysis": fit_analysis,
|
||||
"containers": containers,
|
||||
"min_margin_px": 10,
|
||||
"excess_px": 40.0, "excess_after_glue_px": 40.0,
|
||||
"block_count": 3, "zone_position": "bottom_l",
|
||||
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
|
||||
}
|
||||
|
||||
render_counter = _patch_render(monkeypatch)
|
||||
# cross_zone is infeasible → no CSS → no rerender / no overflow call. Glue is
|
||||
# feasible → exactly one rerender + overflow call → return passed=True.
|
||||
monkeypatch.setattr(
|
||||
_pz_pipeline, "run_overflow_check",
|
||||
lambda p: {"passed": True, "fail_reasons": []},
|
||||
)
|
||||
|
||||
trace = _attempt_salvage_chain(
|
||||
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
|
||||
)
|
||||
|
||||
assert trace["salvage_attempted"] is True
|
||||
assert trace["salvage_passed"] is True
|
||||
assert len(trace["salvage_steps"]) == 2
|
||||
|
||||
s0 = trace["salvage_steps"][0]
|
||||
assert s0["action"] == "cross_zone_redistribute"
|
||||
assert s0["passed"] is False
|
||||
assert s0["plan"]["feasible"] is False
|
||||
assert s0["css_override"] is None
|
||||
assert "single-role zone" in (s0["plan"].get("failure_reason") or "")
|
||||
|
||||
s1 = trace["salvage_steps"][1]
|
||||
assert s1["action"] == "glue_compression"
|
||||
assert s1["passed"] is True
|
||||
assert s1["plan"]["feasible"] is True
|
||||
assert s1["css_override"] and '[data-zone-position="bottom_l"]' in s1["css_override"]
|
||||
# render_slide was invoked exactly once (only the glue branch emitted CSS).
|
||||
assert render_counter["n"] == 1
|
||||
# out_path was overwritten with the glue candidate.
|
||||
promoted = out_path.read_text(encoding="utf-8")
|
||||
assert "ORIGINAL_BEFORE_SALVAGE" not in promoted
|
||||
|
||||
|
||||
def test_case_c_all_three_fail_revert_preserved(project_tmp, monkeypatch):
|
||||
"""(c) All three cascade actions are infeasible (no CSS emitted by any
|
||||
planner) → run_overflow_check is never invoked, salvage_passed=False,
|
||||
salvage_steps has three failed entries, and out_path is unchanged
|
||||
(original final.html intact — (b)-revert preserved)."""
|
||||
out_path = project_tmp / "final.html"
|
||||
out_path.write_text("ORIGINAL_BEFORE_SALVAGE", encoding="utf-8")
|
||||
|
||||
cascade_inputs = {
|
||||
# cross_zone: fit_analysis missing → plan returns feasible=False with reason
|
||||
# `cascade_inputs.fit_analysis missing` (see _attempt_salvage_chain branch).
|
||||
"fit_analysis": None,
|
||||
"containers": {},
|
||||
"min_margin_px": 10,
|
||||
# glue: excess_px (200) > envelope max at block_count=1 (28) → infeasible.
|
||||
"excess_px": 200.0, "excess_after_glue_px": 200.0,
|
||||
"block_count": 1, "zone_position": "top",
|
||||
# font_step: current_font_px=15.2 cannot absorb 200px even at 8px floor
|
||||
# → find_fitting_font_size returns None → feasible=False.
|
||||
"current_font_px": 15.2, "available_lines": 10, "chars_per_line": 40,
|
||||
}
|
||||
|
||||
render_counter = _patch_render(monkeypatch)
|
||||
# Guard: if run_overflow_check is ever called, the test fails loudly.
|
||||
def _must_not_call(_p): # pragma: no cover — intentional sentinel
|
||||
raise AssertionError("run_overflow_check must not run when no CSS is emitted")
|
||||
monkeypatch.setattr(_pz_pipeline, "run_overflow_check", _must_not_call)
|
||||
|
||||
trace = _attempt_salvage_chain(
|
||||
**_kwargs(run_dir=project_tmp, out_path=out_path, cascade_inputs=cascade_inputs),
|
||||
)
|
||||
|
||||
assert trace["salvage_attempted"] is True
|
||||
assert trace["salvage_passed"] is False
|
||||
assert len(trace["salvage_steps"]) == 3
|
||||
actions = [s["action"] for s in trace["salvage_steps"]]
|
||||
assert actions == [
|
||||
"cross_zone_redistribute",
|
||||
"glue_compression",
|
||||
"font_step_compression",
|
||||
]
|
||||
for step in trace["salvage_steps"]:
|
||||
assert step["passed"] is False
|
||||
assert step["css_override"] is None
|
||||
assert step["failure_reason"]
|
||||
# No CSS emitted anywhere → no render_slide calls either.
|
||||
assert render_counter["n"] == 0
|
||||
# (b) revert: out_path is untouched.
|
||||
assert out_path.read_text(encoding="utf-8") == "ORIGINAL_BEFORE_SALVAGE"
|
||||
@@ -0,0 +1,116 @@
|
||||
"""IMP-15 실행-3 (Gitea issue #47) — classifier consumer pure-dict tests.
|
||||
|
||||
`classify_visual_runtime_check` was widened to consume the new
|
||||
``image_events[]`` / ``table_events[]`` arrays produced by ``run_overflow_check``
|
||||
(IMP-15 실행-1/2). The consumer must:
|
||||
|
||||
* emit ``image_aspect_mismatch`` when ``|delta| > IMAGE_ASPECT_DELTA_TOL`` and
|
||||
skip when ``delta is None`` or ``|delta| <= IMAGE_ASPECT_DELTA_TOL``;
|
||||
* emit ``tabular_overflow`` when a table self-overflows beyond
|
||||
``TABLE_SCROLL_TOL_PX`` and ``wrapper_clipped_index is None`` — and dedupe
|
||||
when the table sits under a wrapper already on the clipped-wrapper map
|
||||
(``wrapper_clipped_index`` non-null);
|
||||
* flip ``visual_check_passed`` to False whenever any classification fires, even
|
||||
if zone-level overflow was clean (``overflow["passed"]=True``).
|
||||
|
||||
All four cases are pure-dict — no Selenium / chromedriver dependency.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_classifier import classify_visual_runtime_check
|
||||
from src.phase_z2_pipeline import IMAGE_ASPECT_DELTA_TOL, TABLE_SCROLL_TOL_PX
|
||||
|
||||
|
||||
def _base_overflow(**overrides) -> dict:
|
||||
"""Minimal clean overflow result; tests overlay image/table events."""
|
||||
base = {
|
||||
"passed": True,
|
||||
"slide": {"overflowed": False},
|
||||
"slide_body": {"overflowed": False},
|
||||
"zones": [],
|
||||
"image_events": [],
|
||||
"table_events": [],
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
# ─── image_events scan ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_image_aspect_mismatch_emits_classification():
|
||||
"""|delta| > IMAGE_ASPECT_DELTA_TOL ⇒ emit + flip visual_check_passed."""
|
||||
delta = IMAGE_ASPECT_DELTA_TOL + 0.05
|
||||
overflow = _base_overflow(image_events=[{
|
||||
"zone_position": "top",
|
||||
"zone_template_id": "f1b",
|
||||
"src": "img/sample.png",
|
||||
"natural_ratio": 2.0,
|
||||
"rendered_ratio": 2.0 * (1.0 + delta),
|
||||
"delta": delta,
|
||||
}])
|
||||
result = classify_visual_runtime_check(overflow, debug_zones=[])
|
||||
assert result["visual_check_passed"] is False
|
||||
assert result["categories_seen"] == ["image_aspect_mismatch"]
|
||||
assert len(result["classifications"]) == 1
|
||||
cls = result["classifications"][0]
|
||||
assert cls["category"] == "image_aspect_mismatch"
|
||||
assert cls["source"] == "image_event"
|
||||
assert cls["zone_position"] == "top"
|
||||
assert cls["delta"] == delta
|
||||
|
||||
|
||||
def test_image_aspect_delta_below_tol_no_classification():
|
||||
"""|delta| <= IMAGE_ASPECT_DELTA_TOL ⇒ skip (no false positive)."""
|
||||
delta = IMAGE_ASPECT_DELTA_TOL / 2.0
|
||||
overflow = _base_overflow(image_events=[{
|
||||
"zone_position": "top",
|
||||
"zone_template_id": "f1b",
|
||||
"src": "img/sample.png",
|
||||
"natural_ratio": 2.0,
|
||||
"rendered_ratio": 2.0 * (1.0 + delta),
|
||||
"delta": delta,
|
||||
}])
|
||||
result = classify_visual_runtime_check(overflow, debug_zones=[])
|
||||
assert result["visual_check_passed"] is True
|
||||
assert result["categories_seen"] == []
|
||||
assert result["classifications"] == []
|
||||
|
||||
|
||||
# ─── table_events scan ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_standalone_table_overflow_emits_classification():
|
||||
"""wrapper_clipped_index=None AND excess > TOL ⇒ emit tabular_overflow."""
|
||||
excess = TABLE_SCROLL_TOL_PX + 10
|
||||
overflow = _base_overflow(table_events=[{
|
||||
"zone_position": "bottom_l",
|
||||
"zone_template_id": "f13b",
|
||||
"wrapper_clipped_index": None,
|
||||
"excess_x": 0,
|
||||
"excess_y": excess,
|
||||
}])
|
||||
result = classify_visual_runtime_check(overflow, debug_zones=[])
|
||||
assert result["visual_check_passed"] is False
|
||||
assert result["categories_seen"] == ["tabular_overflow"]
|
||||
assert len(result["classifications"]) == 1
|
||||
cls = result["classifications"][0]
|
||||
assert cls["category"] == "tabular_overflow"
|
||||
assert cls["source"] == "table_event"
|
||||
assert cls["zone_position"] == "bottom_l"
|
||||
assert cls["excess_y"] == excess
|
||||
|
||||
|
||||
def test_table_dedup_when_wrapper_clipped():
|
||||
"""wrapper_clipped_index non-null ⇒ skip (dedupe with clipped_inner cascade)."""
|
||||
overflow = _base_overflow(table_events=[{
|
||||
"zone_position": "bottom_l",
|
||||
"zone_template_id": "f13b",
|
||||
"wrapper_clipped_index": 0,
|
||||
"excess_x": 0,
|
||||
"excess_y": TABLE_SCROLL_TOL_PX + 50,
|
||||
}])
|
||||
result = classify_visual_runtime_check(overflow, debug_zones=[])
|
||||
assert result["visual_check_passed"] is True
|
||||
assert result["categories_seen"] == []
|
||||
assert result["classifications"] == []
|
||||
@@ -0,0 +1,59 @@
|
||||
"""u1 — VerificationResult dataclass surface (IMP-16-U1).
|
||||
|
||||
Locks the Phase Z verification utility module anchor and the
|
||||
VerificationResult shape so downstream units (u2~u10) can rely on it
|
||||
without importing src.content_verifier.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_module_importable_without_content_verifier():
|
||||
mod = importlib.import_module("src.phase_z2_verification_utils")
|
||||
tree = ast.parse(open(mod.__file__, encoding="utf-8").read())
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
assert "content_verifier" not in alias.name, (
|
||||
"Phase Z verification utility must not import "
|
||||
"src.content_verifier"
|
||||
)
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
assert node.module is None or "content_verifier" not in node.module, (
|
||||
"Phase Z verification utility must not import "
|
||||
"src.content_verifier"
|
||||
)
|
||||
|
||||
|
||||
def test_verification_result_defaults():
|
||||
from src.phase_z2_verification_utils import VerificationResult
|
||||
|
||||
r = VerificationResult(passed=True, area_name="zone_test")
|
||||
assert r.passed is True
|
||||
assert r.area_name == "zone_test"
|
||||
assert r.checks == {}
|
||||
assert r.score == 0.0
|
||||
assert r.errors == []
|
||||
assert r.warnings == []
|
||||
|
||||
|
||||
def test_verification_result_independent_default_collections():
|
||||
from src.phase_z2_verification_utils import VerificationResult
|
||||
|
||||
a = VerificationResult(passed=False, area_name="a")
|
||||
b = VerificationResult(passed=False, area_name="b")
|
||||
a.checks["x"] = True
|
||||
a.errors.append("e")
|
||||
a.warnings.append("w")
|
||||
assert b.checks == {} and b.errors == [] and b.warnings == []
|
||||
|
||||
|
||||
def test_verification_result_required_fields():
|
||||
from src.phase_z2_verification_utils import VerificationResult
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
VerificationResult() # type: ignore[call-arg]
|
||||
@@ -0,0 +1,54 @@
|
||||
"""u2 — pure HTML text extraction surface (IMP-16-U1).
|
||||
|
||||
Locks the deterministic visible-text extraction contract:
|
||||
- <style> / <script> contents are excluded.
|
||||
- Whitespace-only chunks are dropped; surviving chunks are stripped.
|
||||
- Order of visible-text fragments is preserved.
|
||||
- No import of src.content_verifier.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_extract_plain_text_fragments_in_order():
|
||||
from src.phase_z2_verification_utils import extract_text_from_html
|
||||
|
||||
html = "<p>first</p><p>second</p><p>third</p>"
|
||||
assert extract_text_from_html(html) == ["first", "second", "third"]
|
||||
|
||||
|
||||
def test_extract_skips_style_and_script_bodies():
|
||||
from src.phase_z2_verification_utils import extract_text_from_html
|
||||
|
||||
html = (
|
||||
"<html><head>"
|
||||
"<style>body { color: red; } .x { font-size: 12px; }</style>"
|
||||
"<script>var keep_out = 1;</script>"
|
||||
"</head><body><p>visible</p></body></html>"
|
||||
)
|
||||
out = extract_text_from_html(html)
|
||||
assert "visible" in out
|
||||
joined = " ".join(out)
|
||||
assert "color: red" not in joined
|
||||
assert "keep_out" not in joined
|
||||
|
||||
|
||||
def test_extract_drops_whitespace_only_chunks_and_strips_survivors():
|
||||
from src.phase_z2_verification_utils import extract_text_from_html
|
||||
|
||||
html = "<div> \n\n </div><div> hello </div><span> world\t</span>"
|
||||
out = extract_text_from_html(html)
|
||||
assert out == ["hello", "world"]
|
||||
|
||||
|
||||
def test_extract_preserves_korean_and_inline_markup_text():
|
||||
from src.phase_z2_verification_utils import extract_text_from_html
|
||||
|
||||
html = "<p>설계 <strong>방식</strong>의 왜곡</p>"
|
||||
out = extract_text_from_html(html)
|
||||
assert out == ["설계", "방식", "의 왜곡"]
|
||||
|
||||
|
||||
def test_extract_empty_input_returns_empty_list():
|
||||
from src.phase_z2_verification_utils import extract_text_from_html
|
||||
|
||||
assert extract_text_from_html("") == []
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Tests for IMP-16-U1 unit u10: sample-backed smoke without pipeline import.
|
||||
|
||||
End-to-end smoke of the deterministic chain (extract_text_from_html ∘
|
||||
normalize_for_comparison ∘ split_into_sentences ∘ _sentence_matches_html
|
||||
→ verify_text_preservation / detect_invented_text) on a real
|
||||
``samples/mdx_batch`` MDX file. Per Stage 2 rationale: smoke coverage
|
||||
uses the sample but does NOT hardcode a sample-specific pass.
|
||||
|
||||
Also locks the AI-isolation contract for the verification axis: this
|
||||
test and the production module MUST NOT import orchestrator /
|
||||
phase_z2_pipeline / Phase Q content_verifier / Kei client.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
from src.phase_z2_verification_utils import (
|
||||
VerificationResult,
|
||||
detect_invented_text,
|
||||
verify_text_preservation,
|
||||
)
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_SAMPLE_MDX_PATH = _REPO_ROOT / "samples" / "mdx_batch" / "02.mdx"
|
||||
_FORBIDDEN_IMPORT_ROOTS = (
|
||||
"orchestrator",
|
||||
"src.phase_z2_pipeline",
|
||||
"src.content_verifier",
|
||||
"src.kei_client",
|
||||
)
|
||||
|
||||
|
||||
def _module_imports(path: Path) -> set[str]:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
names: set[str] = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
names.add(alias.name)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
names.add(node.module)
|
||||
return names
|
||||
|
||||
|
||||
def test_integration_sample_mdx_exists():
|
||||
# Smoke fixture availability gate; explicit so a missing sample
|
||||
# surfaces as a fixture problem, not a downstream assertion failure.
|
||||
assert _SAMPLE_MDX_PATH.exists(), f"sample missing: {_SAMPLE_MDX_PATH}"
|
||||
|
||||
|
||||
def test_integration_full_chain_runs_on_real_sample():
|
||||
# Locks API contract over the full chain on a real MDX: returns a
|
||||
# VerificationResult, area_name passthrough works, score within
|
||||
# [0.0, 1.0], and detect_invented_text returns a list. No assertion
|
||||
# is made about a specific score so the sample is not hardcoded as
|
||||
# the pipeline's pass rule (Stage 2 u10 rationale).
|
||||
mdx = _SAMPLE_MDX_PATH.read_text(encoding="utf-8")
|
||||
html = f"<div>{mdx}</div>"
|
||||
result = verify_text_preservation(mdx, html, "smoke")
|
||||
assert isinstance(result, VerificationResult)
|
||||
assert result.area_name == "smoke"
|
||||
assert 0.0 <= result.score <= 1.0
|
||||
assert isinstance(detect_invented_text(mdx, html), list)
|
||||
|
||||
|
||||
def test_integration_mirrored_html_passes_default_threshold():
|
||||
# When the HTML side mirrors the MDX text verbatim, the deterministic
|
||||
# preservation check must pass the Phase Q-default threshold (0.70).
|
||||
# This is the integration-level guarantee for the B-2 reverse path:
|
||||
# round-tripped HTML that preserves the MDX text must verify.
|
||||
mdx = _SAMPLE_MDX_PATH.read_text(encoding="utf-8")
|
||||
html = f"<div>{mdx}</div>"
|
||||
result = verify_text_preservation(mdx, html, "smoke")
|
||||
assert result.passed is True
|
||||
|
||||
|
||||
def test_integration_fabricated_html_flags_invented_text():
|
||||
# Locks the hallucination-guard end-to-end: HTML text that has no
|
||||
# keyword anchor in the source MDX must be flagged. Synthetic
|
||||
# sentence chosen so its keywords (완전히, 만들어낸, 원본, 등장 …)
|
||||
# do not appear in samples/mdx_batch/02.mdx.
|
||||
mdx = _SAMPLE_MDX_PATH.read_text(encoding="utf-8")
|
||||
fabricated_html = (
|
||||
"<p>완전히 새로 만들어낸 문장으로 원본에는 전혀 등장하지 않는 내용입니다.</p>"
|
||||
)
|
||||
invented = detect_invented_text(mdx, fabricated_html)
|
||||
assert isinstance(invented, list)
|
||||
assert len(invented) >= 1
|
||||
|
||||
|
||||
def test_integration_no_forbidden_imports():
|
||||
# AI-isolation + Phase Z scope-lock guard. Production module and
|
||||
# this test file must not import orchestrator / phase_z2_pipeline /
|
||||
# Phase Q content_verifier / Kei client. AST scan of the on-disk
|
||||
# source (not the imported module) so re-exports cannot mask a leak.
|
||||
for path in (
|
||||
_REPO_ROOT / "src" / "phase_z2_verification_utils.py",
|
||||
Path(__file__).resolve(),
|
||||
):
|
||||
modules = _module_imports(path)
|
||||
for module in modules:
|
||||
for forbidden in _FORBIDDEN_IMPORT_ROOTS:
|
||||
assert not (module == forbidden or module.startswith(forbidden + ".")), (
|
||||
f"{path.name} imports forbidden module: {module}"
|
||||
)
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Tests for IMP-16-U1 unit u9: ``detect_invented_text``.
|
||||
|
||||
Locks the Phase Z port of the deterministic hallucination guard
|
||||
(Phase Q reference: ``src/content_verifier.py:276-315``). The function
|
||||
is pure and composes u2 (extract_text_from_html), u3
|
||||
(normalize_for_comparison), and u4 (extract_keywords). No Phase Q
|
||||
import is exercised.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_verification_utils import (
|
||||
_INVENTED_TEXT_ALLOWED_LABELS,
|
||||
_INVENTED_TEXT_CSS_NUMBER_PATTERN,
|
||||
_INVENTED_TEXT_KEYWORD_THRESHOLD,
|
||||
_INVENTED_TEXT_MIN_LENGTH,
|
||||
_INVENTED_TEXT_TRUNCATE_LEN,
|
||||
detect_invented_text,
|
||||
)
|
||||
|
||||
|
||||
def test_detect_invented_text_constants_locked() -> None:
|
||||
"""Lock the five named module constants ported from Phase Q literals."""
|
||||
assert _INVENTED_TEXT_MIN_LENGTH == 15
|
||||
assert _INVENTED_TEXT_ALLOWED_LABELS == frozenset(
|
||||
{"용어 정의", "핵심 메시지", "상세 비교"}
|
||||
)
|
||||
assert _INVENTED_TEXT_CSS_NUMBER_PATTERN.pattern == r"^[\d\s.,%px#rgb()]+$"
|
||||
assert _INVENTED_TEXT_KEYWORD_THRESHOLD == 0.4
|
||||
assert _INVENTED_TEXT_TRUNCATE_LEN == 80
|
||||
|
||||
|
||||
def test_detect_invented_text_returns_empty_when_html_is_in_mdx() -> None:
|
||||
"""Text whose keywords fully appear in MDX is NOT flagged."""
|
||||
mdx = "원본 콘텐츠는 분석에 관한 것입니다."
|
||||
html = "<p>원본 콘텐츠는 분석에 관한 것입니다.</p>"
|
||||
assert detect_invented_text(mdx, html) == []
|
||||
|
||||
|
||||
def test_detect_invented_text_flags_text_with_low_keyword_overlap() -> None:
|
||||
"""Text whose keywords do not appear in MDX is flagged as invented."""
|
||||
mdx = "원본 콘텐츠는 분석에 관한 것입니다."
|
||||
html = "<p>완전히 다른 발명된 텍스트가 여기 있습니다 일반적이지 않은</p>"
|
||||
result = detect_invented_text(mdx, html)
|
||||
assert len(result) == 1
|
||||
assert "발명된" in result[0]
|
||||
|
||||
|
||||
def test_detect_invented_text_skips_short_text() -> None:
|
||||
"""Text shorter than ``min_length`` is not even considered."""
|
||||
mdx = "원본 콘텐츠"
|
||||
html = "<p>짧은 텍스트</p>"
|
||||
assert detect_invented_text(mdx, html) == []
|
||||
|
||||
|
||||
def test_detect_invented_text_skips_allowed_structural_labels() -> None:
|
||||
"""Allowed labels are skipped even when keyword overlap is zero.
|
||||
|
||||
Phase Q default ``min_length=15`` makes the allowed-label gate
|
||||
unreachable for the bundled labels (all < 15 chars). The Phase Z
|
||||
port preserves the gate verbatim — exercised here with
|
||||
``min_length=0`` so the structural-label short-circuit is
|
||||
actually observable.
|
||||
"""
|
||||
mdx = "원본 콘텐츠"
|
||||
html = "<h2>용어 정의</h2><h2>핵심 메시지</h2><h2>상세 비교</h2>"
|
||||
assert detect_invented_text(mdx, html, min_length=0) == []
|
||||
|
||||
|
||||
def test_detect_invented_text_skips_css_number_pattern_fragments() -> None:
|
||||
"""CSS/numeric fragments (e.g. ``100px 200px 300px``) are skipped."""
|
||||
mdx = "원본 콘텐츠"
|
||||
html = "<style>.x { padding: 100px; }</style><div>100px 200px 300px</div>"
|
||||
assert detect_invented_text(mdx, html) == []
|
||||
|
||||
|
||||
def test_detect_invented_text_truncates_flagged_value_to_80_chars() -> None:
|
||||
"""A flagged fragment longer than 80 chars is truncated for reporting."""
|
||||
mdx = "원본 콘텐츠"
|
||||
invented = "발명" * 50
|
||||
html = f"<p>{invented}</p>"
|
||||
result = detect_invented_text(mdx, html)
|
||||
assert len(result) == 1
|
||||
assert len(result[0]) == 80
|
||||
assert result[0] == invented[:80]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Tests for Phase Z2 IMP-16-U1 unit u4: extract_keywords.
|
||||
|
||||
Locks the deterministic surface: 3+ character tokens on the Phase Z H3
|
||||
character class, longest-match trailing particle strip with a length>=2
|
||||
stem guard, and no Phase Q content_verifier import.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_verification_utils import _PARTICLES, extract_keywords
|
||||
|
||||
|
||||
def test_extract_keywords_drops_short_tokens() -> None:
|
||||
# "AI" (2 chars) and "X" (1 char) are dropped; "기술" (2 chars) is dropped too.
|
||||
# "데이터" (3 chars) survives; "분석함" (3 chars) survives.
|
||||
assert extract_keywords("AI 기술 X 데이터 분석함") == ["데이터", "분석함"]
|
||||
|
||||
|
||||
def test_extract_keywords_strips_trailing_particle_when_stem_ge_2() -> None:
|
||||
# "설계의" (3 chars) → particle "의" stripped, stem "설계" (2 chars) kept.
|
||||
# "방식은" → particle "은" stripped → "방식".
|
||||
assert extract_keywords("설계의 방식은") == ["설계", "방식"]
|
||||
|
||||
|
||||
def test_extract_keywords_keeps_token_when_stem_would_be_too_short() -> None:
|
||||
# "에서" guard: a 3-char token whose 2-char suffix is a particle
|
||||
# but whose stem (1 char) is < 2 must keep the original token.
|
||||
# "안에서" → suffix "에서" len 2, stem "안" len 1 → guard fires,
|
||||
# falls through, then next particle "서" is NOT in _PARTICLES,
|
||||
# so the whole token "안에서" remains.
|
||||
assert extract_keywords("안에서") == ["안에서"]
|
||||
|
||||
|
||||
def test_extract_keywords_longest_match_particle_wins() -> None:
|
||||
# "_PARTICLES" is sorted longest-first, so "에서" wins over "서"/"에".
|
||||
# "현장에서" → "에서" stripped → "현장".
|
||||
assert "에서" in _PARTICLES
|
||||
assert extract_keywords("현장에서") == ["현장"]
|
||||
|
||||
|
||||
def test_extract_keywords_tokenises_korean_alnum_and_parens() -> None:
|
||||
# The Phase Z H3 character class is [가-힣a-zA-Z0-9()]+.
|
||||
# "프로젝트(2024)" is one token; "Hello!" splits into "Hello" only.
|
||||
# Punctuation outside the class acts as a delimiter.
|
||||
result = extract_keywords("프로젝트(2024) Hello! World123")
|
||||
assert "프로젝트(2024)" in result
|
||||
assert "Hello" in result
|
||||
assert "World123" in result
|
||||
assert "!" not in "".join(result)
|
||||
|
||||
|
||||
def test_extract_keywords_empty_returns_empty() -> None:
|
||||
assert extract_keywords("") == []
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Tests for IMP-16-U1 unit u7: ``_sentence_matches_html``.
|
||||
|
||||
Locks the Phase Z port of the deterministic per-sentence match
|
||||
helper (Phase Q reference: inline body of ``verify_text_preservation``
|
||||
at src/content_verifier.py:232-251). The helper is pure; no Phase Q
|
||||
import is exercised. Thresholds are locked as named constants so the
|
||||
0.6 / 0.65 surface cannot drift silently.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_verification_utils import (
|
||||
_SENTENCE_KEYWORD_MATCH_THRESHOLD,
|
||||
_SENTENCE_SEQUENCE_MATCH_THRESHOLD,
|
||||
_sentence_matches_html,
|
||||
)
|
||||
|
||||
|
||||
def test_match_helper_thresholds_locked():
|
||||
assert _SENTENCE_KEYWORD_MATCH_THRESHOLD == 0.6
|
||||
assert _SENTENCE_SEQUENCE_MATCH_THRESHOLD == 0.65
|
||||
|
||||
|
||||
def test_match_helper_returns_true_when_no_keywords():
|
||||
# "AI" tokenises to a single 2-char token which extract_keywords drops
|
||||
# (len < 3 gate). Empty keyword list -> helper returns True regardless
|
||||
# of HTML side. Phase Q parity: matched += 1; continue on empty keywords.
|
||||
assert _sentence_matches_html("AI", "", []) is True
|
||||
|
||||
|
||||
def test_match_helper_keyword_ratio_meets_threshold():
|
||||
# Sentence "데이터 분석의 핵심" -> keywords = ["데이터", "분석"]:
|
||||
# "데이터" (len 3, no particle ending) kept;
|
||||
# "분석의" (len 3, ends with "의", stem "분석" len 2) -> "분석" kept;
|
||||
# "핵심" (len 2 < 3) dropped.
|
||||
# Both keywords are substrings of the html_combined string, so
|
||||
# kw_ratio = 2 / 2 = 1.0 >= 0.6 -> True via keyword axis.
|
||||
assert _sentence_matches_html(
|
||||
"데이터 분석의 핵심",
|
||||
"데이터 분석을 수행합니다",
|
||||
["데이터 분석을 수행합니다"],
|
||||
) is True
|
||||
|
||||
|
||||
def test_match_helper_sequence_ratio_fallback():
|
||||
# Sentence "데이터 분석" -> keywords = ["데이터"] (the 2-char "분석"
|
||||
# is dropped by the len<3 gate). "데이터" is NOT in html_combined,
|
||||
# so kw_ratio = 0. The SequenceMatcher fallback compares the
|
||||
# normalized sentence against each normalized html_text; the second
|
||||
# fragment matches verbatim, yielding ratio 1.0 >= 0.65 -> True.
|
||||
assert _sentence_matches_html(
|
||||
"데이터 분석",
|
||||
"abc xyz",
|
||||
["abc xyz", "데이터 분석"],
|
||||
) is True
|
||||
|
||||
|
||||
def test_match_helper_below_both_thresholds_returns_false():
|
||||
# No keyword overlap and no high-similarity html fragment:
|
||||
# kw_ratio = 0, best SequenceMatcher ratio is far below 0.65.
|
||||
# Helper must return False so verify_text_preservation (u8)
|
||||
# records the sentence as missing.
|
||||
assert _sentence_matches_html(
|
||||
"데이터 분석",
|
||||
"abc xyz",
|
||||
["abc xyz"],
|
||||
) is False
|
||||
@@ -0,0 +1,73 @@
|
||||
"""u5 — meta-line stripping surface (IMP-16-U1).
|
||||
|
||||
Locks the deterministic meta-line filter contract:
|
||||
- lines whose stripped form starts with any ``_META_PREFIXES`` entry
|
||||
are dropped (8 prefix surface);
|
||||
- lines containing any ``_META_INLINE_FRAGMENTS`` entry are dropped
|
||||
(3 inline fragment surface);
|
||||
- other lines pass through with original whitespace preserved;
|
||||
- empty input returns the empty string;
|
||||
- no import of src.content_verifier.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_strip_meta_lines_drops_prefix_lines():
|
||||
from src.phase_z2_verification_utils import _META_PREFIXES, strip_meta_lines
|
||||
|
||||
# Exactly the 8-prefix Phase Z surface — locks both content and size.
|
||||
assert _META_PREFIXES == [
|
||||
"제목 라벨:",
|
||||
"표현 의도:",
|
||||
"슬라이드 주인공",
|
||||
"가장 큰 시각적 비중",
|
||||
"시각적으로",
|
||||
"간결하게 제기",
|
||||
"개별 증거로 제시",
|
||||
"계층적으로 시각화",
|
||||
]
|
||||
text = "제목 라벨: 어떤 제목\n본문 한 줄\n표현 의도: 강조"
|
||||
assert strip_meta_lines(text) == "본문 한 줄"
|
||||
|
||||
|
||||
def test_strip_meta_lines_matches_prefix_on_stripped_line():
|
||||
from src.phase_z2_verification_utils import strip_meta_lines
|
||||
|
||||
# Leading whitespace must not protect a meta-prefix line.
|
||||
text = " 제목 라벨: indented meta\n실제 본문"
|
||||
assert strip_meta_lines(text) == "실제 본문"
|
||||
|
||||
|
||||
def test_strip_meta_lines_drops_inline_fragment_lines():
|
||||
from src.phase_z2_verification_utils import (
|
||||
_META_INLINE_FRAGMENTS,
|
||||
strip_meta_lines,
|
||||
)
|
||||
|
||||
# Phase Z inline-fragment surface is exactly these three.
|
||||
assert _META_INLINE_FRAGMENTS == (
|
||||
"현상-문제 인과관계",
|
||||
"상위-하위 포함 관계",
|
||||
"독립적 나열",
|
||||
)
|
||||
text = (
|
||||
"구조: 현상-문제 인과관계 로 설계\n"
|
||||
"유형: 상위-하위 포함 관계\n"
|
||||
"패턴: 독립적 나열 형태\n"
|
||||
"그래서 결론은 한 줄"
|
||||
)
|
||||
assert strip_meta_lines(text) == "그래서 결론은 한 줄"
|
||||
|
||||
|
||||
def test_strip_meta_lines_keeps_unrelated_lines_verbatim():
|
||||
from src.phase_z2_verification_utils import strip_meta_lines
|
||||
|
||||
# Non-meta lines must pass through with original whitespace preserved.
|
||||
text = " 본문 한 줄\n\n다른 줄"
|
||||
assert strip_meta_lines(text) == " 본문 한 줄\n\n다른 줄"
|
||||
|
||||
|
||||
def test_strip_meta_lines_empty_input_returns_empty_string():
|
||||
from src.phase_z2_verification_utils import strip_meta_lines
|
||||
|
||||
assert strip_meta_lines("") == ""
|
||||
@@ -0,0 +1,64 @@
|
||||
"""u3 — Korean text normalization surface (IMP-16-U1).
|
||||
|
||||
Locks the deterministic text-normalization contract:
|
||||
- whitespace runs collapse + strip;
|
||||
- bullet markers from the Phase Q surface set are removed;
|
||||
- the small HTML-entity set used by the reverse path is decoded;
|
||||
- a single trailing 개조식 ending is folded to its 서술형 form;
|
||||
- particle list is sorted longest-first (matching the Phase Q surface
|
||||
so downstream keyword stripping is greedy);
|
||||
- no import of src.content_verifier.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_normalize_collapses_whitespace_and_strips():
|
||||
from src.phase_z2_verification_utils import normalize_for_comparison
|
||||
|
||||
assert normalize_for_comparison(" hello\n\n world\t") == "hello world"
|
||||
|
||||
|
||||
def test_normalize_removes_bullet_markers():
|
||||
from src.phase_z2_verification_utils import normalize_for_comparison
|
||||
|
||||
# Each marker from the Phase Q surface set must be stripped.
|
||||
for marker in ["•", "◦", "·", "-", "▪", "▸", "►"]:
|
||||
assert normalize_for_comparison(f"{marker} 항목") == "항목"
|
||||
|
||||
|
||||
def test_normalize_decodes_html_entities():
|
||||
from src.phase_z2_verification_utils import normalize_for_comparison
|
||||
|
||||
text = "A & B <tag> 'q' "d""
|
||||
assert normalize_for_comparison(text) == "A & B <tag> 'q' \"d\""
|
||||
|
||||
|
||||
def test_normalize_folds_trailing_gaejo_endings():
|
||||
from src.phase_z2_verification_utils import normalize_for_comparison
|
||||
|
||||
assert normalize_for_comparison("적용함") == "적용한다"
|
||||
assert normalize_for_comparison("필요됨") == "필요된다"
|
||||
assert normalize_for_comparison("값이 있음") == "값이 있다"
|
||||
assert normalize_for_comparison("자료 없음") == "자료 없다"
|
||||
assert normalize_for_comparison("결과임") == "결과이다"
|
||||
assert normalize_for_comparison("적용되었음") == "적용되었다"
|
||||
assert normalize_for_comparison("적용되었음.") == "적용되었음." # trailing punct blocks fold
|
||||
|
||||
|
||||
def test_normalize_only_folds_one_ending_and_only_at_end():
|
||||
from src.phase_z2_verification_utils import normalize_for_comparison
|
||||
|
||||
# 'break' after first match: only the suffix is folded, mid-string '함' is left alone.
|
||||
assert normalize_for_comparison("함수를 적용함") == "함수를 적용한다"
|
||||
# No fold when the ending is not the last token.
|
||||
assert normalize_for_comparison("적용함 그리고 종료") == "적용함 그리고 종료"
|
||||
|
||||
|
||||
def test_particles_sorted_longest_first():
|
||||
from src.phase_z2_verification_utils import _PARTICLES
|
||||
|
||||
lengths = [len(p) for p in _PARTICLES]
|
||||
assert lengths == sorted(lengths, reverse=True)
|
||||
# Phase Q surface size guard (no values reused from REQUIRED_PATTERNS;
|
||||
# this is the Korean-locale particle inventory).
|
||||
assert "에서" in _PARTICLES and "는" in _PARTICLES
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Tests for IMP-16-U1 unit u8: ``verify_text_preservation``.
|
||||
|
||||
Locks the Phase Z port of the deterministic text-preservation check
|
||||
(Phase Q reference: ``src/content_verifier.py:206-273``). The function
|
||||
is pure and composes u2 (extract_text_from_html), u3
|
||||
(normalize_for_comparison), u6 (split_into_sentences), and u7
|
||||
(_sentence_matches_html). No Phase Q import is exercised.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_verification_utils import (
|
||||
VerificationResult,
|
||||
_MISSING_SENTENCE_REPORT_LIMIT,
|
||||
_MISSING_SENTENCE_TRUNCATE_LEN,
|
||||
_TEXT_PRESERVATION_DEFAULT_THRESHOLD,
|
||||
verify_text_preservation,
|
||||
)
|
||||
|
||||
|
||||
def test_verify_text_preservation_defaults_locked():
|
||||
# Locks the Phase Q caller convention: threshold default = 0.70,
|
||||
# missing-list report cap = 5, per-item truncate length = 60.
|
||||
assert _TEXT_PRESERVATION_DEFAULT_THRESHOLD == 0.70
|
||||
assert _MISSING_SENTENCE_REPORT_LIMIT == 5
|
||||
assert _MISSING_SENTENCE_TRUNCATE_LEN == 60
|
||||
|
||||
|
||||
def test_verify_text_preservation_empty_sentences_returns_passed():
|
||||
# MDX that reduces to zero sentences after split_into_sentences
|
||||
# (e.g. headers only) must return passed=True with score 1.0 and
|
||||
# an empty errors/warnings surface. Phase Q parity: early return
|
||||
# before any HTML extraction.
|
||||
result = verify_text_preservation("# header only", "<p>anything</p>", "core")
|
||||
assert isinstance(result, VerificationResult)
|
||||
assert result.passed is True
|
||||
assert result.area_name == "core"
|
||||
assert result.checks == {"text_preservation": True}
|
||||
assert result.score == 1.0
|
||||
assert result.errors == []
|
||||
assert result.warnings == []
|
||||
|
||||
|
||||
def test_verify_text_preservation_full_match_passes():
|
||||
# All MDX sentences preserved in HTML -> score 1.0, passed True,
|
||||
# no warnings (warnings only attached when score < 1.0), no errors.
|
||||
mdx = "데이터 분석은 핵심 과정입니다. 시각화로 의사 결정을 지원합니다."
|
||||
html = (
|
||||
"<p>데이터 분석은 핵심 과정입니다.</p>"
|
||||
"<p>시각화로 의사 결정을 지원합니다.</p>"
|
||||
)
|
||||
result = verify_text_preservation(mdx, html, "body")
|
||||
assert result.passed is True
|
||||
assert result.score == 1.0
|
||||
assert result.warnings == []
|
||||
assert result.errors == []
|
||||
|
||||
|
||||
def test_verify_text_preservation_below_threshold_reports_errors():
|
||||
# Only one of two MDX sentences appears in the HTML -> score 0.5,
|
||||
# below default threshold 0.70 -> passed False, errors list opens
|
||||
# with the "누락 문장 (1/2):" header followed by quoted missing
|
||||
# sentences (truncation gate not crossed).
|
||||
mdx = (
|
||||
"데이터 분석은 핵심 과정입니다.\n"
|
||||
"전혀 다른 문맥의 두 번째 문장입니다."
|
||||
)
|
||||
html = "<p>데이터 분석은 핵심 과정입니다.</p>"
|
||||
result = verify_text_preservation(mdx, html, "core")
|
||||
assert result.passed is False
|
||||
assert result.score == 0.5
|
||||
assert result.checks == {"text_preservation": False}
|
||||
assert result.errors[0] == "누락 문장 (1/2):"
|
||||
assert any("두 번째 문장" in line for line in result.errors[1:])
|
||||
assert result.warnings == ["보존율: 50% (1/2 문장)"]
|
||||
|
||||
|
||||
def test_verify_text_preservation_truncates_long_missing_sentence():
|
||||
# A missing sentence longer than 60 chars must be rendered with
|
||||
# the "...\"" tail. Phase Z surface lifts the 60 constant to a
|
||||
# named module value (_MISSING_SENTENCE_TRUNCATE_LEN) so the gate
|
||||
# is auditable.
|
||||
long_sentence = "엄청나게 긴 문장이 들어가서 절단 동작을 검증합니다." + ("끝" * 60)
|
||||
mdx = long_sentence + "."
|
||||
html = "<p>관련 없는 문구</p>"
|
||||
result = verify_text_preservation(mdx, html, "footer", threshold=0.99)
|
||||
assert result.passed is False
|
||||
# Header + at least one missing-line entry; the entry must end with `..."`.
|
||||
assert len(result.errors) >= 2
|
||||
assert result.errors[-1].endswith("...\"")
|
||||
truncated_body = result.errors[-1].split('"', 2)[1].rstrip(".")
|
||||
assert len(truncated_body) == _MISSING_SENTENCE_TRUNCATE_LEN
|
||||
|
||||
|
||||
def test_verify_text_preservation_caps_missing_report_at_limit():
|
||||
# Generate seven MDX-only sentences with no HTML coverage.
|
||||
# passed=False, errors list = 1 header + at most 5 missing entries
|
||||
# (_MISSING_SENTENCE_REPORT_LIMIT). The header reports the true
|
||||
# missing/total counts even though only 5 are surfaced.
|
||||
mdx_lines = [f"전혀 다른 문맥의 문장 번호 {i} 입니다." for i in range(7)]
|
||||
mdx = "\n".join(mdx_lines)
|
||||
html = "<p>관련 없는 문구</p>"
|
||||
result = verify_text_preservation(mdx, html, "core")
|
||||
assert result.passed is False
|
||||
assert result.errors[0] == "누락 문장 (7/7):"
|
||||
assert len(result.errors) == 1 + _MISSING_SENTENCE_REPORT_LIMIT
|
||||
|
||||
|
||||
def test_verify_text_preservation_custom_threshold_passes_at_50_percent():
|
||||
# Lowering the threshold to 0.50 makes a 50% preservation pass.
|
||||
mdx = (
|
||||
"데이터 분석은 핵심 과정입니다.\n"
|
||||
"전혀 다른 문맥의 두 번째 문장입니다."
|
||||
)
|
||||
html = "<p>데이터 분석은 핵심 과정입니다.</p>"
|
||||
result = verify_text_preservation(mdx, html, "core", threshold=0.50)
|
||||
assert result.passed is True
|
||||
assert result.score == 0.5
|
||||
# Score < 1.0 so the 보존율 warning is still attached for trace surface.
|
||||
assert result.warnings == ["보존율: 50% (1/2 문장)"]
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Tests for IMP-16-U1 unit u6: split_into_sentences.
|
||||
|
||||
Locks the Phase Z port of the H3 deterministic sentence-splitter
|
||||
surface (Phase Q reference: src/content_verifier.py:174-199). The
|
||||
function is deterministic, pure, and composes ``strip_meta_lines``;
|
||||
no Phase Q import is exercised.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from src.phase_z2_verification_utils import (
|
||||
_BULLET_MARKER_PATTERN,
|
||||
_MIN_SENTENCE_LEN,
|
||||
_SENTENCE_SPLIT_PATTERN,
|
||||
split_into_sentences,
|
||||
)
|
||||
|
||||
|
||||
def test_split_into_sentences_applies_strip_meta_lines_first():
|
||||
text = (
|
||||
"제목 라벨: 설계 방식의 왜곡\n"
|
||||
"본문 첫 문장입니다.\n"
|
||||
"본문 둘째 문장입니다."
|
||||
)
|
||||
result = split_into_sentences(text)
|
||||
assert result == ["본문 첫 문장입니다.", "본문 둘째 문장입니다."]
|
||||
|
||||
|
||||
def test_split_into_sentences_skips_empty_and_header_lines():
|
||||
text = "\n# 대목차\n## 소목차\n실제 본문 문장입니다.\n"
|
||||
assert split_into_sentences(text) == ["실제 본문 문장입니다."]
|
||||
|
||||
|
||||
def test_split_into_sentences_strips_numeric_and_punctuated_markers():
|
||||
assert _BULLET_MARKER_PATTERN.match("1. 첫 단계입니다.")
|
||||
assert _BULLET_MARKER_PATTERN.match("2) 둘째 단계입니다.")
|
||||
assert _BULLET_MARKER_PATTERN.match("-. 첫 항목입니다.")
|
||||
assert _BULLET_MARKER_PATTERN.match("•. 둘째 항목입니다.")
|
||||
text = (
|
||||
"1. 첫 단계입니다.\n"
|
||||
"2) 둘째 단계입니다.\n"
|
||||
"-. 셋째 항목입니다."
|
||||
)
|
||||
assert split_into_sentences(text) == [
|
||||
"첫 단계입니다.",
|
||||
"둘째 단계입니다.",
|
||||
"셋째 항목입니다.",
|
||||
]
|
||||
|
||||
|
||||
def test_split_into_sentences_keeps_bare_dash_bullet_unstripped():
|
||||
assert _BULLET_MARKER_PATTERN.match("- 항목 하나입니다.") is None
|
||||
text = "- 항목 하나입니다."
|
||||
assert split_into_sentences(text) == ["- 항목 하나입니다."]
|
||||
|
||||
|
||||
def test_split_into_sentences_splits_on_period_boundary():
|
||||
assert _SENTENCE_SPLIT_PATTERN.pattern == r"(?<=\.)\s+"
|
||||
text = "첫 문장입니다. 둘째 문장입니다. 셋째 문장입니다."
|
||||
assert split_into_sentences(text) == [
|
||||
"첫 문장입니다.",
|
||||
"둘째 문장입니다.",
|
||||
"셋째 문장입니다.",
|
||||
]
|
||||
|
||||
|
||||
def test_split_into_sentences_drops_parts_shorter_than_min_len():
|
||||
assert _MIN_SENTENCE_LEN == 5
|
||||
text = "OK. 충분히 긴 문장입니다."
|
||||
assert split_into_sentences(text) == ["충분히 긴 문장입니다."]
|
||||
@@ -0,0 +1,129 @@
|
||||
"""IMP-09 PR 1 — retry gate tests (_attempt_zone_ratio_retry early exit).
|
||||
|
||||
Stage 3 round 4 lock §2-A: row-axis retry must skip when layout has
|
||||
dynamic_cols=True (2-D topology) OR dynamic_rows=False (fr_default
|
||||
sink). The horizontal-2 path (dynamic_rows=True, dynamic_cols=False)
|
||||
must still proceed through the gate.
|
||||
|
||||
These tests exercise the gate by routing the request through
|
||||
_attempt_zone_ratio_retry with router_active=True + proposed
|
||||
zone_ratio_retry — but with layout_css fields that should trip the
|
||||
gate. We confirm the early skip by asserting retry_attempted==False
|
||||
and retry_skipped_reason content.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import _attempt_zone_ratio_retry
|
||||
|
||||
|
||||
_ROUTER_ACTIVE = {
|
||||
"router_active": True,
|
||||
"proposed_actions_summary": ["zone_ratio_retry"],
|
||||
}
|
||||
|
||||
|
||||
def _dummy_kwargs(layout_css: dict, tmp_path: Path) -> dict:
|
||||
"""All params required by _attempt_zone_ratio_retry. Only
|
||||
`layout_css` and `router_decision` matter pre-gate."""
|
||||
return {
|
||||
"run_dir": tmp_path,
|
||||
"out_path": tmp_path / "final.html",
|
||||
"slide_title": "test",
|
||||
"slide_footer": None,
|
||||
"zones_data": [],
|
||||
"debug_zones": [],
|
||||
"layout_preset": "horizontal-2",
|
||||
"layout_css": layout_css,
|
||||
"overflow": {},
|
||||
"fit_classification": {},
|
||||
"router_decision": _ROUTER_ACTIVE,
|
||||
"gap_px": 14,
|
||||
}
|
||||
|
||||
|
||||
def test_vertical_2_dynamic_cols_skips_retry(tmp_path):
|
||||
layout_css = {
|
||||
"areas": '"left right"',
|
||||
"cols": "583px 583px",
|
||||
"rows": "1fr",
|
||||
"heights_px": [585],
|
||||
"widths_px": [583, 583],
|
||||
"ratios": [1.0],
|
||||
"width_ratios": [0.494, 0.494],
|
||||
"dynamic_rows": False,
|
||||
"dynamic_cols": True,
|
||||
}
|
||||
trace = _attempt_zone_ratio_retry(**_dummy_kwargs(layout_css, tmp_path))
|
||||
assert trace["retry_attempted"] is False
|
||||
assert "dynamic_cols" in trace["retry_skipped_reason"]
|
||||
assert "IMP-09" in trace["retry_skipped_reason"]
|
||||
|
||||
|
||||
def test_fr_default_sink_skips_retry(tmp_path):
|
||||
# PR 1 single / T-shape / 2x2 fall through to fr_default and must
|
||||
# not enter row-only retry plan.
|
||||
layout_css = {
|
||||
"areas": '"top top" "bottom-left bottom-right"',
|
||||
"cols": "1fr 1fr",
|
||||
"rows": "1fr 1fr",
|
||||
"heights_px": [285, 286],
|
||||
"widths_px": [583, 583],
|
||||
"ratios": [0.487, 0.489],
|
||||
"width_ratios": [0.494, 0.494],
|
||||
"dynamic_rows": False,
|
||||
"dynamic_cols": False,
|
||||
}
|
||||
trace = _attempt_zone_ratio_retry(**_dummy_kwargs(layout_css, tmp_path))
|
||||
assert trace["retry_attempted"] is False
|
||||
assert "fr_default_from_preset" in trace["retry_skipped_reason"]
|
||||
|
||||
|
||||
def test_horizontal_2_dynamic_rows_passes_gate(tmp_path):
|
||||
"""horizontal-2 with dynamic_rows=True must pass the gate. The
|
||||
test does not need plan_zone_ratio_retry to succeed; it only
|
||||
asserts the gate did not early-skip with one of the new
|
||||
skip reasons."""
|
||||
layout_css = {
|
||||
"areas": '"top" "bottom"',
|
||||
"cols": "1fr",
|
||||
"rows": "333px 238px",
|
||||
"heights_px": [333, 238],
|
||||
"widths_px": [1180],
|
||||
"ratios": [0.569, 0.407],
|
||||
"width_ratios": [1.0],
|
||||
"dynamic_rows": True,
|
||||
"dynamic_cols": False,
|
||||
}
|
||||
# plan_zone_ratio_retry will return None because debug_zones is
|
||||
# empty, so retry_attempted=True but plan==None.
|
||||
trace = _attempt_zone_ratio_retry(**_dummy_kwargs(layout_css, tmp_path))
|
||||
assert trace["retry_attempted"] is True
|
||||
# The gate was passed; skip reason (if any) is the legacy
|
||||
# plan-failure reason, not the new gate reasons.
|
||||
skip_reason = trace.get("retry_skipped_reason")
|
||||
if skip_reason is not None:
|
||||
assert "dynamic_cols" not in skip_reason
|
||||
assert "fr_default_from_preset" not in skip_reason
|
||||
|
||||
|
||||
def test_router_inactive_skips_before_gate(tmp_path):
|
||||
"""When router_active=False, the early skip happens before the
|
||||
new IMP-09 gate. Verify the existing behavior is unchanged."""
|
||||
layout_css = {
|
||||
"areas": '"left right"',
|
||||
"dynamic_rows": False,
|
||||
"dynamic_cols": True,
|
||||
"heights_px": [585],
|
||||
"widths_px": [583, 583],
|
||||
"ratios": [1.0],
|
||||
"width_ratios": [0.5, 0.5],
|
||||
}
|
||||
kwargs = _dummy_kwargs(layout_css, tmp_path)
|
||||
kwargs["router_decision"] = {"router_active": False}
|
||||
trace = _attempt_zone_ratio_retry(**kwargs)
|
||||
assert trace["retry_attempted"] is False
|
||||
assert "router_active=False" in trace["retry_skipped_reason"]
|
||||
@@ -0,0 +1,69 @@
|
||||
"""IMP-14 A-4 — slide_base.html embedded_mode contract tests.
|
||||
|
||||
Asserts the three-valued enum (auto / embedded / standalone) round-trips
|
||||
through render_slide -> slide_base.html, that the additive html.embedded
|
||||
CSS reset and the auto-mode detection <script> are emitted under the
|
||||
correct modes, that the invalid-mode guard raises ValueError, and that
|
||||
Jinja2 rendering is byte-deterministic across calls.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from src.phase_z2_pipeline import render_slide
|
||||
|
||||
|
||||
def _zone() -> dict:
|
||||
return {"position": "primary", "template_id": "__empty__", "slot_payload": {}}
|
||||
|
||||
|
||||
def _layout_css() -> dict:
|
||||
return {"areas": '"primary"', "cols": "1fr", "rows": "1fr"}
|
||||
|
||||
|
||||
def _render(embedded_mode: str = "auto") -> str:
|
||||
return render_slide(
|
||||
slide_title="t",
|
||||
slide_footer=None,
|
||||
zones_data=[_zone()],
|
||||
layout_preset="single",
|
||||
layout_css=_layout_css(),
|
||||
gap_px=14,
|
||||
embedded_mode=embedded_mode,
|
||||
)
|
||||
|
||||
|
||||
def test_auto_script_present():
|
||||
html = _render("auto")
|
||||
assert "params.get('embedded')" in html
|
||||
assert "window.self !== window.top" in html
|
||||
assert "classList.add('embedded')" in html
|
||||
|
||||
|
||||
def test_css_rules_present():
|
||||
html = _render("auto")
|
||||
assert "html.embedded body" in html
|
||||
assert "html.embedded .slide" in html
|
||||
|
||||
|
||||
def test_embedded_mode_explicit():
|
||||
html = _render("embedded")
|
||||
assert '<html lang="ko" class="embedded">' in html
|
||||
assert "params.get('embedded')" not in html
|
||||
|
||||
|
||||
def test_standalone_mode_explicit():
|
||||
html = _render("standalone")
|
||||
assert '<html lang="ko">' in html
|
||||
assert 'class="embedded"' not in html.split("</head>")[0]
|
||||
assert "params.get('embedded')" not in html
|
||||
|
||||
|
||||
def test_deterministic():
|
||||
assert _render("embedded") == _render("embedded")
|
||||
assert _render("auto") == _render("auto")
|
||||
|
||||
|
||||
def test_invalid_mode_raises():
|
||||
with pytest.raises(ValueError, match="invalid embedded_mode"):
|
||||
_render("bogus")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Spec lint: PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md §3.1 taxonomy must declare
|
||||
the `image_aspect_mismatch` row (IMP-15 실행-4, issue #48 u2).
|
||||
|
||||
The row encodes a post-render `fail_reasons` signal surfaced by Step 14
|
||||
visual_runtime_check, not a router-routed fit_classifier output. It is
|
||||
intentionally placed inside §3.1 to keep the taxonomy vocabulary aligned
|
||||
with the event streams now exposed at debug.json top level (u1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
SPEC_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "docs"
|
||||
/ "architecture"
|
||||
/ "PHASE-Z-FIT-CLASSIFIER-ROUTER-SPEC.md"
|
||||
)
|
||||
|
||||
|
||||
def _read_spec_text() -> str:
|
||||
return SPEC_PATH.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _extract_section_3_1(text: str) -> str:
|
||||
start_match = re.search(r"^###\s+3\.1\b", text, flags=re.MULTILINE)
|
||||
assert start_match, "§3.1 heading missing from spec"
|
||||
after_3_1 = text[start_match.end():]
|
||||
end_match = re.search(r"^###\s+3\.2\b", after_3_1, flags=re.MULTILINE)
|
||||
assert end_match, "§3.2 heading missing from spec"
|
||||
return after_3_1[: end_match.start()]
|
||||
|
||||
|
||||
def test_spec_section_3_1_contains_image_aspect_mismatch_row():
|
||||
section = _extract_section_3_1(_read_spec_text())
|
||||
row_pattern = re.compile(r"^\|\s*`image_aspect_mismatch`\s*\|", re.MULTILINE)
|
||||
matches = row_pattern.findall(section)
|
||||
assert len(matches) == 1, (
|
||||
"Expected exactly 1 `image_aspect_mismatch` row inside §3.1 taxonomy, "
|
||||
f"found {len(matches)}"
|
||||
)
|
||||
|
||||
|
||||
def test_image_aspect_mismatch_row_reflects_post_render_semantic():
|
||||
section = _extract_section_3_1(_read_spec_text())
|
||||
row_line = next(
|
||||
(
|
||||
line
|
||||
for line in section.splitlines()
|
||||
if line.lstrip().startswith("| `image_aspect_mismatch`")
|
||||
),
|
||||
None,
|
||||
)
|
||||
assert row_line is not None, "image_aspect_mismatch row not found"
|
||||
assert "Post-render" in row_line or "post-render" in row_line, (
|
||||
"Row must mark the signal as post-render (Stage 1 guardrail)"
|
||||
)
|
||||
assert "fail_reasons" in row_line, (
|
||||
"Row must reference `fail_reasons` so the vocabulary mirrors the "
|
||||
"visual_runtime_check output"
|
||||
)
|
||||
Reference in New Issue
Block a user