feature: 레이아웃 편집기 image 위젯 값 형태 — 단일 값 슬롯 축약 / 런타임 방어 / 저장 데이터 백필
# 입력 axis 는 5축 265조합이다. 전수 마킹은 실효가 없어(테스트 1건이 조합 1개만
# 덮으므로 265건이 필요하다) **pairwise** 로 좁힌다 — 어떤 두 축의 조합도 최소 한 번은
# 지나가므로 축 간 상호작용 결함은 그대로 잡히고, 마킹 대상은 실측 9건으로 줄어든다.
# effects 축은 면제하지 않는다 — 모든 effect 가 테스트 마킹으로 도달해야 한다.
coverage_strategy: pairwise
description: |
코어 `image` 위젯은 배경 이미지용으로 설계되어 `{url,size,repeat,position}` **객체**를
내보낸다. 그 값을 노드에 기록하는 apply 4종 중 `styleProp` 의 다중 props(배경 묶음)만
객체를 CSS 4속성으로 분해했고, `propValue`·`cssVar`·단일 `styleProp` 은 객체를 그대로
기록했다. 소비 컴포넌트가 `
` 로 받으면 브라우저가 `[object Object]` 를
URL 로 해석해 이미지가 깨진다(공개 #135, `sirsoft-basic` 헤더 「로고 이미지」).
이 결함은 예외도 콘솔 오류도 서버 로그도 남기지 않는다 — 깨진 이미지 요청은 SPA
catch-all 때문에 404 조차 아니라 200(HTML)을 받고, 편집기 위젯의 미리보기는 정상이라
조작 중에는 이상이 보이지 않는다. 유일한 증상은 화면의 엑박이다.
방어선은 넷이다:
1. **쓰기 축약** — 단일 값 슬롯 writer 3종이 공용 헬퍼 `scalarizeImageValue` 를 경유해
url 문자열만 남긴다. 게이트는 `widget === 'image'` 이며 값 형태 sniffing 이 아니다
(`isImageValueObject` 는 4키 중 하나만 있어도 참이라, 값만 보면 `{position:'left'}`
같은 정당한 객체 prop 을 이미지로 오인해 삭제한다).
2. **읽기 역조립** — 저장된 문자열을 `{url}` 로 되감아 위젯이 현재 값을 읽게 한다.
표현식 문자열도 감싼다 — 감싸지 않으면 빈 피커로 보이고 업로드 1클릭에 그 표현식이
소리 없이 소실된다. 깨진 미리보기 억제와 실수 덮어쓰기 방지는 위젯이 맡는다.
3. **런타임 방어** — 업그레이드 전 화면을 위해 템플릿 `Img` 가 객체값에서 url 을
해석하고, 손상값이면 `src` 를 아예 붙이지 않는다(엑박도 빈 문자열도 아니다).
4. **저장 데이터 백필** — 업그레이드 스텝이 기설치본의 prop 자리 객체를 축약한다.
판정식은 런타임 방어와 **완전히 같은 엄격도**(키 집합 ⊆ 4키 AND url 문자열)를 쓴다 —
두 방어선의 기준이 어긋나면 한쪽만 통과하는 값이 생긴다.
단일 값 슬롯 컨트롤에서는 표시모드(채움/맞춤/타일) 버튼을 **컨테이너째 미렌더**한다.
`size`/`repeat`/`position` 을 저장할 자리가 없어 눌러도 저장되지 않는 죽은 컨트롤이기
때문이다. `disabled` 로 두면 "URL 을 넣으면 살아나겠지" 라는 거짓 정보를 준다.
axes:
widget_output:
- scalar_string # 이미 문자열 (회귀 0)
- image_object # {url,size,repeat,position} 전체
- image_object_empty_url # url 이 빈 문자열
- image_object_nonstring_url # url 이 객체/배열/숫자
- undefined_cleared # 「제거」 → undefined
apply_type: [propValue, styleProp, cssVar, classToken]
stored_shape: [string, object, binding_expression, absent]
consumer: [img_src, header_logo, mobilenav_logo, div_background, none]
storage_scope:
- template_layouts
- template_layout_extensions
- layout_versions_untouched
- layout_previews_untouched
exclusions:
- { apply_type: styleProp, consumer: [img_src, header_logo, mobilenav_logo], reason: "styleProp 은 인라인 style 로 흐른다 —
sink 에 도달하지 않는다" }
- { apply_type: propValue, consumer: div_background, reason: "배경은 style 자리 — propValue 로 흐르지 않는다" }
- { apply_type: cssVar, consumer: [img_src, header_logo, mobilenav_logo, div_background], reason: "CSS 변수는 소비 sink 가 선언되지 않았다 — 축약만 검증(정적 룰이 신규 선언을 차단)" }
- { apply_type: classToken, widget_output: [image_object, image_object_empty_url, image_object_nonstring_url], reason: "image × classToken 은 정적 룰이 저장 이전에 차단해 런타임 도달 불가" }
- { widget_output: undefined_cleared, consumer: [img_src, div_background], reason: "해제는 폴백 축 — header_logo 에서 대표 검증(사이트명 텍스트 폴백)" }
- { widget_output: undefined_cleared, stored_shape: [object, string, binding_expression], reason: "해제 결과는 absent 하나뿐" }
- { stored_shape: binding_expression, widget_output: [image_object, image_object_empty_url, image_object_nonstring_url], reason: "표현식은 문자열이라 객체 형태와 배타" }
- { storage_scope: [layout_versions_untouched, layout_previews_untouched], apply_type: [styleProp, cssVar, classToken], reason: "스냅샷 미보존 단언은 apply 무관 단일 축 — propValue 에서 대표 검증" }
- { storage_scope: template_layout_extensions, consumer: [mobilenav_logo, div_background, none], reason: "확장 주입 경로는 injections[].props 순회 포섭만 검증 — 소비 sink 는 코어 경로와 동일" }
effects:
# ① 쓰기 축약 (코어 엔진)
- image_object_narrows_to_url_string_in_propvalue_slot
- image_object_narrows_to_wrapped_css_url_in_cssvar_and_single_styleprop
- legacy_css_url_wrapping_is_stripped_for_component_prop_sink
- image_object_without_url_or_with_empty_url_deletes_the_prop
- non_image_widget_object_and_array_props_are_preserved_intact
- styleprop_bundle_four_property_decomposition_is_unchanged
- apply_fixed_value_wins_over_control_value_then_gets_narrowed
# ② 읽기 역조립
- stored_string_is_rewrapped_as_url_object_for_the_widget
- binding_expression_is_rewrapped_so_one_click_upload_cannot_silently_lose_it
- legacy_object_stored_value_is_returned_losslessly
- non_image_widget_values_are_not_rewrapped
- node_axis_roundtrip_is_a_fixed_point_after_first_pass
- dark_scope_stays_readonly_for_inline_apply
# ③ 위젯 표면
- single_value_slot_control_hides_display_mode_buttons_entirely
- single_value_slot_control_emits_url_only_object_without_size_repeat_position
- single_value_slot_preview_is_pinned_to_contain_not_a_false_preview
- bundle_control_keeps_display_mode_buttons_and_active_state
- apply_undeclared_control_keeps_display_mode_buttons_blacklist_not_whitelist
- bound_value_shows_expression_badge_and_locks_destructive_controls
- replace_affordance_reopens_editing_without_emitting_a_change
- bound_value_protection_is_a_single_shared_gate_for_every_widget
- restore_affordance_recovers_the_original_binding_after_replacement
# ④ 런타임 방어 (템플릿 Img)
- img_string_src_passes_through_unchanged
- img_image_value_object_resolves_to_its_url
- img_corrupted_value_attaches_no_src_neither_object_object_nor_empty_string
- img_object_with_key_outside_the_four_is_rejected_same_strictness_as_backfill
- img_other_attributes_pass_through_spread_regression_zero
# ⑤ 백필 (업그레이드 스텝)
- backfill_narrows_prop_slot_objects_in_layouts_and_extensions
- backfill_strict_predicate_leaves_normal_props_untouched
- backfill_excludes_style_slot_where_background_decomposition_lives
- backfill_traverses_responsive_branches_nested_nodes_and_injections
- backfill_traverses_modals_in_both_list_and_map_shape
- backfill_drops_prop_key_on_corrupted_url_with_warning_log
- backfill_is_idempotent_and_performs_no_update_on_unchanged_rows
- backfill_increments_lock_version_only_on_changed_rows
- backfill_preserves_original_content_hash_so_modified_judgement_holds
- backfill_leaves_version_and_preview_snapshots_untouched
- backfill_converts_soft_deleted_rows_for_restore_safety
- backfill_uses_keyset_chunking_so_no_row_is_skipped_past_the_boundary
- backfill_skips_broken_json_rows_without_aborting_the_run
test_files:
- resources/js/core/template-engine/layout-editor/__tests__/spec/recipeEngine.test.ts
- resources/js/core/template-engine/layout-editor/__tests__/components/property-controls.test.tsx
- resources/js/core/template-engine/layout-editor/__tests__/components/ControlRenderer.boundValueGuard.test.tsx
- templates/_bundled/sirsoft-basic/src/components/basic/__tests__/Img.test.tsx
- tests/Feature/Upgrades/Upgrade7011ImageObjectPropBackfillTest.php
- tests/Playwright/specs/layout-editor/image-widget-value-shape.spec.ts
validation:
note: |
백필 축(⑤)은 DB 데이터 상태라 브라우저 E2E 로 재현할 수 없다. PHPUnit 26케이스가
판정식 엄격도·순회 범위·멱등·lock_version·hash 불변·스냅샷 미개입을 전수 잠근다.
`image` 위젯을 값이 스칼라로 축약되지 않는 apply 경로(classToken/cssVar/apply 미선언)에
새로 연결하는 것은 정적 검사가 저장 이전에 차단한다 — 그래서 그 조합은 런타임에 도달
불가이며 exclusions 에 명시했다.