feat(#14): design_adaptation_plan apply 계층 — AI 구조 plan 을 code 가 verbatim builder 재실행으로 적용
Gitea #98 Task 12 완결. 롤백된 _emergency_p4_ai_redistribute docstring 이 명시한 승인 아키텍처 5단계('AI: plan only → CODE: apply') 구현. - _apply_design_adaptation_plan: op 분류 + repeat-count op 만 builder 재실행 적용 - set/increase_repeat_count → _emergency_p4b_build_verbatim_slot_payload (override_slot_count, repeat 패턴 4종) — max(override, len(groups)) floor 로 원문 그룹 절대 비손실 (Task 11c) - compact_spacing → policy_skip (T28.5 no_font_shrink) / rebalance_zone_ratio → policy_skip (CSS code 소유) / split_group(frame_repeat) → unsupported (#26) / frame_contract 변이 → unsupported (catalog 소유) — 전 op 사유와 함께 design_plan_op_status 기록 (침묵 drop 금지, PZ-4) - count 필드 어휘 관용 추출 (count/repeat_count/to/target_count — 실측 변동) - prompts.py: task12 계약에 op 스키마 예시 명시 - 시그니처: units 옵션 kwarg (구 호출 backward compat — 미제공 시 명시 status) 검증: - 신규 테스트 13종 + 영향권/전체 게이트 1003 passed 0 failed - mdx04 live: 04-1 apply=applied:design_adaptation_plan_no_delta (AI plan 이 apply 계층 최초 통과 — count 5 = 현행 동일이라 no_delta 가 정답), coverage ok Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -91,6 +91,13 @@ def build_ai_fallback_prompt(
|
|||||||
"compact_spacing",
|
"compact_spacing",
|
||||||
"use_repeatable_frame",
|
"use_repeatable_frame",
|
||||||
],
|
],
|
||||||
|
# issue #14 — repeat-count op 의 정확한 스키마 (code 가 적용하는
|
||||||
|
# 유일한 op 계열). count 는 top-level 정수 필드 "count" 사용.
|
||||||
|
"operation_schema_example": {
|
||||||
|
"op": "set_repeat_count",
|
||||||
|
"target": "<repeat_axis_name>",
|
||||||
|
"count": 5,
|
||||||
|
},
|
||||||
"forbidden_content_fields": [
|
"forbidden_content_fields": [
|
||||||
"text",
|
"text",
|
||||||
"title",
|
"title",
|
||||||
|
|||||||
+136
-12
@@ -663,11 +663,20 @@ def _build_verbatim_compare_table_2col(unit) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _emergency_p4b_build_verbatim_slot_payload(unit, template_id: str) -> Optional[dict]:
|
def _emergency_p4b_build_verbatim_slot_payload(
|
||||||
|
unit, template_id: str, override_slot_count: Optional[int] = None,
|
||||||
|
) -> Optional[dict]:
|
||||||
"""Per-frame verbatim slot_payload builder. AI 없음, 원문 보존만.
|
"""Per-frame verbatim slot_payload builder. AI 없음, 원문 보존만.
|
||||||
|
|
||||||
Returns slot_payload dict OR None (해당 frame 의 builder 없음 OR Jinja partial
|
Returns slot_payload dict OR None (해당 frame 의 builder 없음 OR Jinja partial
|
||||||
없음 → 기존 adapter_needed path 로 fallback).
|
없음 → 기존 adapter_needed path 로 fallback).
|
||||||
|
|
||||||
|
override_slot_count (issue #14) — design_adaptation_plan 의
|
||||||
|
set_repeat_count / increase_repeat_count op 적용용. repeat 패턴
|
||||||
|
builder(cards_n_grid 계열)의 base slot_count 를 대체한다. 최종 count 는
|
||||||
|
max(override, len(groups)) 이므로 **원문 그룹은 절대 잘리지 않는다**
|
||||||
|
(Task 11c no-truncate) — override 는 빈 padding slot 의 개수만 조정.
|
||||||
|
비-repeat builder 에는 영향 없음.
|
||||||
"""
|
"""
|
||||||
# Partial template 존재 check — visual_pending frame (contract 있지만 .html 없음)
|
# Partial template 존재 check — visual_pending frame (contract 있지만 .html 없음)
|
||||||
# 차단. _is_visual_pending 기준 + 직접 file 존재 확인 동시.
|
# 차단. _is_visual_pending 기준 + 직접 file 존재 확인 동시.
|
||||||
@@ -685,7 +694,7 @@ def _emergency_p4b_build_verbatim_slot_payload(unit, template_id: str) -> Option
|
|||||||
return _build_verbatim_cycle_intersect_3(unit)
|
return _build_verbatim_cycle_intersect_3(unit)
|
||||||
if template_id == "construction_bim_three_usage":
|
if template_id == "construction_bim_three_usage":
|
||||||
return _build_verbatim_cards_n_grid(
|
return _build_verbatim_cards_n_grid(
|
||||||
unit, slot_count=3,
|
unit, slot_count=override_slot_count or 3,
|
||||||
label_pattern="category_{n}_label",
|
label_pattern="category_{n}_label",
|
||||||
body_pattern="category_{n}_body",
|
body_pattern="category_{n}_body",
|
||||||
)
|
)
|
||||||
@@ -693,19 +702,19 @@ def _emergency_p4b_build_verbatim_slot_payload(unit, template_id: str) -> Option
|
|||||||
return _build_verbatim_compare_table_2col(unit)
|
return _build_verbatim_compare_table_2col(unit)
|
||||||
if template_id == "bim_issues_quadrant_four":
|
if template_id == "bim_issues_quadrant_four":
|
||||||
return _build_verbatim_cards_n_grid(
|
return _build_verbatim_cards_n_grid(
|
||||||
unit, slot_count=4,
|
unit, slot_count=override_slot_count or 4,
|
||||||
label_pattern="quadrant_{n}_label",
|
label_pattern="quadrant_{n}_label",
|
||||||
body_pattern="quadrant_{n}_body",
|
body_pattern="quadrant_{n}_body",
|
||||||
)
|
)
|
||||||
if template_id == "sw_dependency_four_problems":
|
if template_id == "sw_dependency_four_problems":
|
||||||
return _build_verbatim_cards_n_grid(
|
return _build_verbatim_cards_n_grid(
|
||||||
unit, slot_count=4,
|
unit, slot_count=override_slot_count or 4,
|
||||||
label_pattern="problem_{n}_label",
|
label_pattern="problem_{n}_label",
|
||||||
body_pattern="problem_{n}_body",
|
body_pattern="problem_{n}_body",
|
||||||
)
|
)
|
||||||
if template_id == "pre_construction_model_info_stacked":
|
if template_id == "pre_construction_model_info_stacked":
|
||||||
return _build_verbatim_cards_n_grid(
|
return _build_verbatim_cards_n_grid(
|
||||||
unit, slot_count=5,
|
unit, slot_count=override_slot_count or 5,
|
||||||
label_pattern="pill_{n}_label",
|
label_pattern="pill_{n}_label",
|
||||||
body_pattern="pill_{n}_body",
|
body_pattern="pill_{n}_body",
|
||||||
)
|
)
|
||||||
@@ -2112,11 +2121,113 @@ def _run_step12_ai_repair(units) -> list[dict]:
|
|||||||
|
|
||||||
_REJECT_SUPPORTED_PROPOSAL_KINDS: frozenset[str] = frozenset({"partial_overrides"})
|
_REJECT_SUPPORTED_PROPOSAL_KINDS: frozenset[str] = frozenset({"partial_overrides"})
|
||||||
|
|
||||||
|
# ── issue #14 — design_adaptation_plan apply (Gitea #98 Task 12 완결) ──
|
||||||
|
# 롤백된 _emergency_p4_ai_redistribute docstring 이 명시한 승인 아키텍처의
|
||||||
|
# 5단계: "AI: frame adaptation plan only (구조 조정, 텍스트 X) → CODE: apply".
|
||||||
|
# op 분류 정책:
|
||||||
|
# set_repeat_count / increase_repeat_count → builder 재실행 (원문 무손실 —
|
||||||
|
# override 는 max(override, len(groups)) floor 로만 작동)
|
||||||
|
# preserve_source_order → already_default (Task 9 가 기본 보장)
|
||||||
|
# compact_spacing → policy_skip (T28.5 no_font_shrink + spacing lock —
|
||||||
|
# font/line-height/padding 축소는 fit lever 로 금지)
|
||||||
|
# rebalance_zone_ratio → policy_skip (CSS/grid 는 code 소유; zone ratio 는
|
||||||
|
# retry engine 소유 — AI CSS 주입 금지)
|
||||||
|
# split_group_across_repeat_blocks(frame_repeat) → unsupported (frame 다중
|
||||||
|
# 인스턴스 = 슬라이드 분할 axis, GitHub #26)
|
||||||
|
# use_repeatable_frame → unsupported (frame_id locked — V4 rank-1 보호)
|
||||||
|
_DESIGN_PLAN_KIND = "design_adaptation_plan"
|
||||||
|
_DESIGN_PLAN_COUNT_OPS = frozenset({"set_repeat_count", "increase_repeat_count"})
|
||||||
|
# 모델별 count 필드 어휘 변동 관측 (count / repeat_count / to) — 관용 추출.
|
||||||
|
_DESIGN_PLAN_COUNT_KEYS = ("count", "repeat_count", "to", "target_count")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_design_plan_count(op: dict) -> Optional[int]:
|
||||||
|
for key in _DESIGN_PLAN_COUNT_KEYS:
|
||||||
|
val = op.get(key)
|
||||||
|
if isinstance(val, bool):
|
||||||
|
continue
|
||||||
|
if isinstance(val, int) and val > 0:
|
||||||
|
return val
|
||||||
|
if isinstance(val, str) and val.isdigit() and int(val) > 0:
|
||||||
|
return int(val)
|
||||||
|
return None
|
||||||
|
_DESIGN_PLAN_OP_STATUS: dict[str, str] = {
|
||||||
|
"preserve_source_order": "already_default:task9_source_order",
|
||||||
|
"compact_spacing": "policy_skip:no_font_shrink_spacing_lock",
|
||||||
|
"rebalance_zone_ratio": "policy_skip:css_code_owned",
|
||||||
|
"split_group_across_repeat_blocks": "unsupported:multi_instance_requires_deck_split",
|
||||||
|
"use_repeatable_frame": "unsupported:frame_swap_locked",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_design_adaptation_plan(record: dict, unit, zone: Optional[dict]) -> None:
|
||||||
|
"""issue #14 — Apply one design_adaptation_plan proposal to its zone.
|
||||||
|
|
||||||
|
구조 op(repeat count)만 verbatim builder 재실행으로 반영하고, 나머지
|
||||||
|
op 는 분류 사유와 함께 기록만 한다 (침묵 drop 금지 — PZ-4 surface).
|
||||||
|
텍스트는 항상 code(verbatim builder)가 배치하므로 AI 텍스트 유입 경로
|
||||||
|
없음 (feedback_ai_isolation_contract).
|
||||||
|
"""
|
||||||
|
payload = (record.get("proposal") or {}).get("payload") or {}
|
||||||
|
operations = payload.get("operations") or []
|
||||||
|
op_status: list[dict] = []
|
||||||
|
target_count: Optional[int] = None
|
||||||
|
for op in operations:
|
||||||
|
if not isinstance(op, dict):
|
||||||
|
op_status.append({"op": str(op), "status": "unsupported_op"})
|
||||||
|
continue
|
||||||
|
name = str(op.get("op") or "")
|
||||||
|
if name in _DESIGN_PLAN_COUNT_OPS:
|
||||||
|
op_target = str(op.get("target") or "")
|
||||||
|
if "frame_contract" in op_target or "add_sub_zone" in (op.get("details") or {}):
|
||||||
|
# frame contract 자체 변이(cardinality/sub_zone 추가)는 catalog
|
||||||
|
# 소유 — AI plan 으로 적용 불가 (사용자/코드 승인 axis).
|
||||||
|
op_status.append({
|
||||||
|
"op": name,
|
||||||
|
"status": "unsupported:contract_mutation_requires_catalog_change",
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
count = _extract_design_plan_count(op)
|
||||||
|
if count is not None:
|
||||||
|
target_count = max(target_count or 0, count)
|
||||||
|
op_status.append({"op": name, "status": "effective"})
|
||||||
|
else:
|
||||||
|
op_status.append({"op": name, "status": "invalid_count"})
|
||||||
|
else:
|
||||||
|
op_status.append({
|
||||||
|
"op": name,
|
||||||
|
"status": _DESIGN_PLAN_OP_STATUS.get(name, "unsupported_op"),
|
||||||
|
})
|
||||||
|
record["design_plan_op_status"] = op_status
|
||||||
|
|
||||||
|
if target_count is None:
|
||||||
|
record["apply_status"] = "design_plan_no_applicable_op"
|
||||||
|
return
|
||||||
|
if zone is None:
|
||||||
|
record["apply_status"] = "no_zone_match"
|
||||||
|
return
|
||||||
|
if unit is None:
|
||||||
|
record["apply_status"] = "design_plan_unit_unavailable"
|
||||||
|
return
|
||||||
|
template_id = zone.get("template_id")
|
||||||
|
rebuilt = _emergency_p4b_build_verbatim_slot_payload(
|
||||||
|
unit, template_id, override_slot_count=target_count,
|
||||||
|
)
|
||||||
|
if rebuilt is None:
|
||||||
|
record["apply_status"] = f"design_plan_builder_unavailable:{template_id}"
|
||||||
|
return
|
||||||
|
if rebuilt == zone.get("slot_payload"):
|
||||||
|
record["apply_status"] = "applied:design_adaptation_plan_no_delta"
|
||||||
|
return
|
||||||
|
zone["slot_payload"] = rebuilt
|
||||||
|
record["apply_status"] = "applied:design_adaptation_plan"
|
||||||
|
|
||||||
|
|
||||||
def _apply_ai_repair_proposals_to_zones(
|
def _apply_ai_repair_proposals_to_zones(
|
||||||
ai_repair_records: list[dict],
|
ai_repair_records: list[dict],
|
||||||
unit_positions: list[str],
|
unit_positions: list[str],
|
||||||
zones_data: list[dict],
|
zones_data: list[dict],
|
||||||
|
units: Optional[list] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""IMP-47B u5 — Apply PARTIAL_OVERRIDES into zones_data.slot_payload.
|
"""IMP-47B u5 — Apply PARTIAL_OVERRIDES into zones_data.slot_payload.
|
||||||
|
|
||||||
@@ -2127,6 +2238,10 @@ def _apply_ai_repair_proposals_to_zones(
|
|||||||
untouched (human_review surfacing → u8). IMP-33 u5 validator
|
untouched (human_review surfacing → u8). IMP-33 u5 validator
|
||||||
guarantees declared-slot completeness, so ``dict.update`` is the
|
guarantees declared-slot completeness, so ``dict.update`` is the
|
||||||
structural merge (``feedback_ai_isolation_contract``).
|
structural merge (``feedback_ai_isolation_contract``).
|
||||||
|
|
||||||
|
issue #14 — ``design_adaptation_plan`` kind 는
|
||||||
|
:func:`_apply_design_adaptation_plan` 로 위임 (``units`` 필요 — 미제공
|
||||||
|
시 backward-compat 로 ``design_plan_unit_unavailable`` 기록).
|
||||||
"""
|
"""
|
||||||
zone_by_position = {z["position"]: z for z in zones_data}
|
zone_by_position = {z["position"]: z for z in zones_data}
|
||||||
for record in ai_repair_records:
|
for record in ai_repair_records:
|
||||||
@@ -2138,6 +2253,19 @@ def _apply_ai_repair_proposals_to_zones(
|
|||||||
# model_dump() 는 str-mixin Enum 멤버를 그대로 반환할 수 있음 —
|
# model_dump() 는 str-mixin Enum 멤버를 그대로 반환할 수 있음 —
|
||||||
# artifact 에 "ProposalKind.X" repr 이 박히지 않도록 value 로 정규화.
|
# artifact 에 "ProposalKind.X" repr 이 박히지 않도록 value 로 정규화.
|
||||||
kind = getattr(kind, "value", kind)
|
kind = getattr(kind, "value", kind)
|
||||||
|
unit_index = record["unit_index"]
|
||||||
|
position = (
|
||||||
|
unit_positions[unit_index]
|
||||||
|
if 0 <= unit_index < len(unit_positions) else None
|
||||||
|
)
|
||||||
|
zone = zone_by_position.get(position) if position is not None else None
|
||||||
|
if kind == _DESIGN_PLAN_KIND:
|
||||||
|
unit = (
|
||||||
|
units[unit_index]
|
||||||
|
if units is not None and 0 <= unit_index < len(units) else None
|
||||||
|
)
|
||||||
|
_apply_design_adaptation_plan(record, unit, zone)
|
||||||
|
continue
|
||||||
if kind not in _REJECT_SUPPORTED_PROPOSAL_KINDS:
|
if kind not in _REJECT_SUPPORTED_PROPOSAL_KINDS:
|
||||||
record["apply_status"] = f"unsupported_kind_for_reject_route:{kind}"
|
record["apply_status"] = f"unsupported_kind_for_reject_route:{kind}"
|
||||||
print(
|
print(
|
||||||
@@ -2147,12 +2275,6 @@ def _apply_ai_repair_proposals_to_zones(
|
|||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
unit_index = record["unit_index"]
|
|
||||||
position = (
|
|
||||||
unit_positions[unit_index]
|
|
||||||
if 0 <= unit_index < len(unit_positions) else None
|
|
||||||
)
|
|
||||||
zone = zone_by_position.get(position) if position is not None else None
|
|
||||||
if zone is None:
|
if zone is None:
|
||||||
record["apply_status"] = "no_zone_match"
|
record["apply_status"] = "no_zone_match"
|
||||||
continue
|
continue
|
||||||
@@ -9731,7 +9853,9 @@ def run_phase_z2_mvp1(
|
|||||||
if _plan_record is not None and _plan_record.get("position"):
|
if _plan_record is not None and _plan_record.get("position"):
|
||||||
_pos = _plan_record["position"]
|
_pos = _plan_record["position"]
|
||||||
unit_positions.append(_pos)
|
unit_positions.append(_pos)
|
||||||
_apply_ai_repair_proposals_to_zones(ai_repair_records, unit_positions, zones_data)
|
_apply_ai_repair_proposals_to_zones(
|
||||||
|
ai_repair_records, unit_positions, zones_data, units=units,
|
||||||
|
)
|
||||||
|
|
||||||
# ─── Step 12 IMP-47B u7 — Post-AI source_section_ids coverage invariant ───
|
# ─── Step 12 IMP-47B u7 — Post-AI source_section_ids coverage invariant ───
|
||||||
# Structural defense: AI repair must not silently drop a unit's
|
# Structural defense: AI repair must not silently drop a unit's
|
||||||
|
|||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"""issue #14 — design_adaptation_plan apply 계층 테스트 (Gitea #98 Task 12 완결).
|
||||||
|
|
||||||
|
계약 (승인 아키텍처 — _emergency_p4_ai_redistribute docstring 5단계):
|
||||||
|
AI 는 구조 조정 plan 만 내고, CODE 가 verbatim builder 재실행으로 적용한다.
|
||||||
|
- set_repeat_count / increase_repeat_count 만 effective (builder 재실행)
|
||||||
|
- override count 는 max(override, len(groups)) floor — 원문 그룹 절대 비손실
|
||||||
|
- compact_spacing / rebalance_zone_ratio 는 policy_skip (T28.5 no_font_shrink,
|
||||||
|
CSS code 소유), split_group(frame_repeat) 은 unsupported (#26 axis)
|
||||||
|
- 모든 op 는 design_plan_op_status 에 분류 사유와 함께 기록 (침묵 drop 금지)
|
||||||
|
- partial_overrides 기존 경로 및 구 시그니처(units 미전달) backward compat 유지
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from src.phase_z2_pipeline import (
|
||||||
|
_apply_ai_repair_proposals_to_zones,
|
||||||
|
_apply_design_adaptation_plan,
|
||||||
|
_emergency_p4b_build_verbatim_slot_payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_TEMPLATE = "pre_construction_model_info_stacked" # repeat 패턴 (pill_{n}, base 5)
|
||||||
|
|
||||||
|
|
||||||
|
def _unit(n_groups: int = 2):
|
||||||
|
lines = []
|
||||||
|
for i in range(1, n_groups + 1):
|
||||||
|
lines.append(f"- **그룹{i} 라벨**")
|
||||||
|
lines.append(f"- 항목 {i}-1")
|
||||||
|
lines.append(f"- 항목 {i}-2")
|
||||||
|
lines.append("")
|
||||||
|
return SimpleNamespace(title="테스트 제목", raw_content="\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
def _zone(unit) -> dict:
|
||||||
|
payload = _emergency_p4b_build_verbatim_slot_payload(unit, _TEMPLATE)
|
||||||
|
assert payload is not None, "전제: builder + partial 존재"
|
||||||
|
return {"position": "top", "template_id": _TEMPLATE, "slot_payload": payload}
|
||||||
|
|
||||||
|
|
||||||
|
def _record(operations: list, unit_index: int = 0) -> dict:
|
||||||
|
return {
|
||||||
|
"unit_index": unit_index,
|
||||||
|
"source_section_ids": ["04-1"],
|
||||||
|
"proposal": {
|
||||||
|
"proposal_kind": "design_adaptation_plan",
|
||||||
|
"payload": {"operations": operations},
|
||||||
|
"rationale": "test",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── set_repeat_count 적용 ────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_repeat_count_expands_padding_slots():
|
||||||
|
unit = _unit(n_groups=2)
|
||||||
|
zone = _zone(unit)
|
||||||
|
assert zone["slot_payload"]["_slot_count"] == 5 # base 5 > groups 2
|
||||||
|
rec = _record([{"op": "set_repeat_count", "target": "pills", "count": 7}])
|
||||||
|
|
||||||
|
_apply_ai_repair_proposals_to_zones([rec], ["top"], [zone], units=[unit])
|
||||||
|
|
||||||
|
assert rec["apply_status"] == "applied:design_adaptation_plan"
|
||||||
|
assert zone["slot_payload"]["_slot_count"] == 7
|
||||||
|
# 원문 텍스트 verbatim 보존 (AI 텍스트 유입 없음)
|
||||||
|
assert zone["slot_payload"]["pill_1_label"] == "그룹1 라벨"
|
||||||
|
assert zone["slot_payload"]["pill_7_label"] == "" # padding slot
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeat_count_never_truncates_source_groups():
|
||||||
|
"""count 1 < groups 4 → floor 는 len(groups) — 원문 무손실 (Task 11c)."""
|
||||||
|
unit = _unit(n_groups=4)
|
||||||
|
zone = _zone(unit)
|
||||||
|
rec = _record([{"op": "set_repeat_count", "target": "pills", "count": 1}])
|
||||||
|
|
||||||
|
_apply_ai_repair_proposals_to_zones([rec], ["top"], [zone], units=[unit])
|
||||||
|
|
||||||
|
assert zone["slot_payload"]["_slot_count"] == 4
|
||||||
|
assert zone["slot_payload"]["pill_4_label"] == "그룹4 라벨"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_delta_when_count_matches_current_build():
|
||||||
|
unit = _unit(n_groups=2)
|
||||||
|
zone = _zone(unit)
|
||||||
|
before = dict(zone["slot_payload"])
|
||||||
|
rec = _record([{"op": "set_repeat_count", "target": "pills", "count": 5}])
|
||||||
|
|
||||||
|
_apply_ai_repair_proposals_to_zones([rec], ["top"], [zone], units=[unit])
|
||||||
|
|
||||||
|
assert rec["apply_status"] == "applied:design_adaptation_plan_no_delta"
|
||||||
|
assert zone["slot_payload"] == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_increase_repeat_count_alias_and_repeat_count_key():
|
||||||
|
unit = _unit(n_groups=2)
|
||||||
|
zone = _zone(unit)
|
||||||
|
rec = _record([{"op": "increase_repeat_count", "repeat_count": 6}])
|
||||||
|
|
||||||
|
_apply_ai_repair_proposals_to_zones([rec], ["top"], [zone], units=[unit])
|
||||||
|
|
||||||
|
assert rec["apply_status"] == "applied:design_adaptation_plan"
|
||||||
|
assert zone["slot_payload"]["_slot_count"] == 6
|
||||||
|
|
||||||
|
|
||||||
|
# ── 정책 분류 (침묵 drop 금지) ────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_ops_are_classified_not_silently_dropped():
|
||||||
|
unit = _unit()
|
||||||
|
zone = _zone(unit)
|
||||||
|
before = dict(zone["slot_payload"])
|
||||||
|
rec = _record([
|
||||||
|
{"op": "preserve_source_order", "target": "pills"},
|
||||||
|
{"op": "compact_spacing", "target": ".f9b__pill", "line_height": 1.05},
|
||||||
|
{"op": "rebalance_zone_ratio", "target": ".f9b__pill-rows"},
|
||||||
|
{"op": "split_group_across_repeat_blocks", "target": "frame_repeat",
|
||||||
|
"repeat_count": 2},
|
||||||
|
{"op": "use_repeatable_frame", "target": "frame"},
|
||||||
|
{"op": "totally_unknown_op"},
|
||||||
|
])
|
||||||
|
|
||||||
|
_apply_ai_repair_proposals_to_zones([rec], ["top"], [zone], units=[unit])
|
||||||
|
|
||||||
|
assert rec["apply_status"] == "design_plan_no_applicable_op"
|
||||||
|
assert zone["slot_payload"] == before # zone 불변
|
||||||
|
statuses = {s["op"]: s["status"] for s in rec["design_plan_op_status"]}
|
||||||
|
assert statuses["preserve_source_order"] == "already_default:task9_source_order"
|
||||||
|
assert statuses["compact_spacing"] == "policy_skip:no_font_shrink_spacing_lock"
|
||||||
|
assert statuses["rebalance_zone_ratio"] == "policy_skip:css_code_owned"
|
||||||
|
assert statuses["split_group_across_repeat_blocks"] == (
|
||||||
|
"unsupported:multi_instance_requires_deck_split"
|
||||||
|
)
|
||||||
|
assert statuses["use_repeatable_frame"] == "unsupported:frame_swap_locked"
|
||||||
|
assert statuses["totally_unknown_op"] == "unsupported_op"
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_count_recorded():
|
||||||
|
unit = _unit()
|
||||||
|
zone = _zone(unit)
|
||||||
|
rec = _record([{"op": "set_repeat_count", "count": "다섯"}])
|
||||||
|
|
||||||
|
_apply_ai_repair_proposals_to_zones([rec], ["top"], [zone], units=[unit])
|
||||||
|
|
||||||
|
assert rec["apply_status"] == "design_plan_no_applicable_op"
|
||||||
|
assert rec["design_plan_op_status"][0]["status"] == "invalid_count"
|
||||||
|
|
||||||
|
|
||||||
|
def test_count_field_vocabulary_tolerance():
|
||||||
|
"""실측: 모델이 count 대신 to/repeat_count/digit-string 을 씀 — 관용 추출."""
|
||||||
|
unit = _unit(n_groups=2)
|
||||||
|
zone = _zone(unit)
|
||||||
|
rec = _record([{"op": "increase_repeat_count", "target": "pills",
|
||||||
|
"from": 5, "to": 6}])
|
||||||
|
|
||||||
|
_apply_ai_repair_proposals_to_zones([rec], ["top"], [zone], units=[unit])
|
||||||
|
|
||||||
|
assert rec["apply_status"] == "applied:design_adaptation_plan"
|
||||||
|
assert zone["slot_payload"]["_slot_count"] == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_frame_contract_mutation_classified_unsupported():
|
||||||
|
"""실측: target=frame_contract.cardinality + add_sub_zone — catalog 소유
|
||||||
|
변이는 적용 불가로 정직 분류 (invalid_count 로 오분류 금지)."""
|
||||||
|
unit = _unit()
|
||||||
|
zone = _zone(unit)
|
||||||
|
before = dict(zone["slot_payload"])
|
||||||
|
rec = _record([{
|
||||||
|
"op": "increase_repeat_count",
|
||||||
|
"target": "frame_contract.cardinality",
|
||||||
|
"details": {"from": {"strict": 3}, "to": {"strict": 4},
|
||||||
|
"add_sub_zone": {"id": "item_4_body"}},
|
||||||
|
}])
|
||||||
|
|
||||||
|
_apply_ai_repair_proposals_to_zones([rec], ["top"], [zone], units=[unit])
|
||||||
|
|
||||||
|
assert rec["apply_status"] == "design_plan_no_applicable_op"
|
||||||
|
assert rec["design_plan_op_status"][0]["status"] == (
|
||||||
|
"unsupported:contract_mutation_requires_catalog_change"
|
||||||
|
)
|
||||||
|
assert zone["slot_payload"] == before
|
||||||
|
|
||||||
|
|
||||||
|
# ── 가드 / backward compat ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_units_not_provided_backward_compat():
|
||||||
|
"""구 시그니처 호출 (units 미전달) — 예외 없이 명시적 status 기록."""
|
||||||
|
unit = _unit()
|
||||||
|
zone = _zone(unit)
|
||||||
|
rec = _record([{"op": "set_repeat_count", "count": 6}])
|
||||||
|
|
||||||
|
_apply_ai_repair_proposals_to_zones([rec], ["top"], [zone]) # units 없음
|
||||||
|
|
||||||
|
assert rec["apply_status"] == "design_plan_unit_unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_builder_unavailable_template():
|
||||||
|
unit = _unit()
|
||||||
|
zone = {"position": "top", "template_id": "no_such_template",
|
||||||
|
"slot_payload": {"title": "x"}}
|
||||||
|
rec = _record([{"op": "set_repeat_count", "count": 6}])
|
||||||
|
|
||||||
|
_apply_ai_repair_proposals_to_zones([rec], ["top"], [zone], units=[unit])
|
||||||
|
|
||||||
|
assert rec["apply_status"] == "design_plan_builder_unavailable:no_such_template"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_zone_match_status():
|
||||||
|
unit = _unit()
|
||||||
|
rec = _record([{"op": "set_repeat_count", "count": 6}])
|
||||||
|
_apply_ai_repair_proposals_to_zones([rec], [], [], units=[unit])
|
||||||
|
assert rec["apply_status"] == "no_zone_match"
|
||||||
|
|
||||||
|
|
||||||
|
def test_partial_overrides_path_unchanged():
|
||||||
|
"""기존 partial_overrides 경로 회귀 없음 (units kwarg 추가와 무관)."""
|
||||||
|
zone = {"position": "top", "template_id": _TEMPLATE,
|
||||||
|
"slot_payload": {"title": "old"}}
|
||||||
|
rec = {
|
||||||
|
"unit_index": 0,
|
||||||
|
"proposal": {
|
||||||
|
"proposal_kind": "partial_overrides",
|
||||||
|
"payload": {"slots": {"title": "new"}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_apply_ai_repair_proposals_to_zones([rec], ["top"], [zone], units=[_unit()])
|
||||||
|
assert rec["apply_status"] == "applied:partial_overrides"
|
||||||
|
assert zone["slot_payload"]["title"] == "new"
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_handler_unit_none():
|
||||||
|
rec = _record([{"op": "set_repeat_count", "count": 6}])
|
||||||
|
zone = {"position": "top", "template_id": _TEMPLATE, "slot_payload": {}}
|
||||||
|
_apply_design_adaptation_plan(rec, None, zone)
|
||||||
|
assert rec["apply_status"] == "design_plan_unit_unavailable"
|
||||||
Reference in New Issue
Block a user