공개 는 헤더 「로고 이미지」에 이미지를 지정하면 화면이 엑박이 되는 제보였다.
image 위젯은 배경용으로 설계되어 {url,size,repeat,position} 객체를 내보내는데, 값 슬롯이
하나뿐인 apply 경로(propValue/cssVar/단일 styleProp)가 그 객체를 그대로 props 에 기록해
소비 컴포넌트가 [object Object] 를 URL 로 받았다. 예외도 콘솔 오류도 서버 로그도 남지
않는다 — 깨진 이미지 요청은 SPA catch-all 때문에 404 조차 아니라 200(HTML)이고, 편집기
미리보기는 정상이라 조작 중에는 이상이 보이지 않는다.
방어선을 넷으로 세웠다. 쓰기 축약(공용 헬퍼 scalarizeImageValue 단일 지점, 게이트는 위젯
이름이며 값 형태 sniffing 이 아니다) · 읽기 역조립(표현식 문자열도 되감아 업로드 1클릭에
소실되지 않게 한다) · 런타임 방어(업그레이드 전 화면을 위해 템플릿 Img 가 url 을 해석하고
손상값이면 src 를 아예 붙이지 않는다) · 저장 데이터 백필(업그레이드 스텝). 런타임과 백필은
완전히 같은 엄격 판정식(키 집합 ⊆ 4키 AND url 보유)을 쓴다 — 엔진의 느슨한 판정식을 백필에
이식하면 레이아웃 전수에서 정상 props 2,219건을 파괴한다(실측).
전수조사에서 파생한 인접 결함 넷을 함께 고쳤다.
- number 위젯이 코어 레지스트리에 미등록이라 「탭 표시 게시판 수」 같은 컨트롤이
「지원하지 않는 컨트롤」로 폴백해 편집 자체가 불가했다. nodeKey apply 는 coreProps 가
선언만 하고 엔진 switch 에 case 가 없어 무음 no-op 이었다.
- 상속(base)·주입(extension) 노드 중 바인딩을 가진 것이 data_bound 로 분류돼 편집이
열려 있었는데, 저장 마스킹이 그 노드를 통째로 폐기하므로 편집분이 오류도 경고도 없이
사라졌다(저장은 200 이고 history 는 clear 돼 undo 도 불가). 출처 잠금이 항상 우선하도록
판정 순서를 통일하고, 단일 판정 헬퍼로 인라인 편집·복제·Delete·잘라내기·드래그 commit
까지 전 표면을 같은 기준으로 막았다.
- prop 자리의 표현식 값을 위젯이 해석하지 못해 빈 컨트롤로 보이고, 조작하는 순간 환경설정
과의 연결이 소리 없이 끊겼다. 판정·배지·잠금·해제·복구를 ControlRenderer 공용 게이트
한 곳으로 올려 신규 위젯에도 자동 적용되게 했다. 「직접 지정으로 바꾸기」에는 「되돌리기」를
동반해 편도가 되지 않게 한다.
- 편집기 모드에서 updateTemplateData 가 빈 레이아웃으로 같은 reactRoot 에 두 번째 커밋을
걸어 편집기 트리를 통째로 제거했다. renderTemplate 의 편집기 분기가 비동기라 부팅 중
setGlobalState 가 그 커밋 뒤에 도착할 때만 발현하는 경합이었다.
128 lines
9.2 KiB
YAML
128 lines
9.2 KiB
YAML
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` 은 객체를 그대로
|
||
기록했다. 소비 컴포넌트가 `<Img src={객체}>` 로 받으면 브라우저가 `[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 로 흐른다 — <Img src> 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 에 명시했다.
|