From 3f2674f1a767a9541c7caf4d4c5dab691e0bf4ff Mon Sep 17 00:00:00 2001 From: HeuJung Date: Tue, 14 Jul 2026 10:58:01 +0900 Subject: [PATCH] =?UTF-8?q?fix(tosspayments,templates,core):=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=EC=88=98=EB=8B=A8=20=EC=B2=B4=ED=81=AC=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=EB=9D=BC=EB=B2=A8=20=EB=AF=B8=ED=91=9C=EC=8B=9C?= =?UTF-8?q?=C2=B7=EC=A0=80=EC=9E=A5=20=EC=8B=A4=ED=8C=A8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checkbox 는 label prop 을 렌더하지 않는다(부모 Label 이 표시 담당). 그 prop 에만 의존해 결제수단 9종의 라벨이 화면에서 전부 비어 있었다. Label 로 감싸고 텍스트를 형제 Span 으로 분리했다 — 라벨 클릭 토글과 접근성 이름도 함께 확보된다. 수정 중 저장 결함을 함께 발견했다. 폼 자동바인딩은 값이 boolean 일 때만 checked 로 붙고, null 이면 value 분기로 떨어져 빈 문자열을 전송한다. 서버가 그것을 null 로 저장하면 기본값(false)을 덮어 화면에서 다시 켤 수 없게 고착된다. initLocal 로는 막을 수 없어(shallow 는 form 객체를 교체하고, deep 은 source 의 null 을 그대로 반영) 레이아웃이 boolean 을 직접 보장하도록 했다 — autoBinding:false + checked 강제 + change 액션이 $event.target.checked 를 기록. 같은 원인으로 편집기 팔레트도 라벨 없는 체크박스를 삽입하고 있어 함께 고쳤다. 회귀 방어: 레이아웃/팔레트 계약 테스트, 엔진 동작(isCheckedBinding·deepMergeState) 고정, 저장 페이로드가 boolean 인지 검사하는 E2E, audit 룰 layout-checkbox-label-and-boolean(label prop=error, 자동바인딩 의존=warn). --- docs/frontend/state-management-forms.md | 57 ++++ .../pluginSettingsPaymentMethods.test.tsx | 56 ++++ .../layouts/admin/plugin_settings.json | 306 +++++++++++++++--- .../troubleshooting-components.test.ts | 80 +++++ templates/_bundled/sirsoft-basic/CHANGELOG.md | 1 + .../layouts/editor-spec-default-node.test.tsx | 43 +++ .../editor-spec/componentPalette.json | 21 +- ...osspayments-settings-method-labels.spec.ts | 173 ++++++++++ 8 files changed, 697 insertions(+), 40 deletions(-) create mode 100644 tests/Playwright/specs/admin/tosspayments-settings-method-labels.spec.ts diff --git a/docs/frontend/state-management-forms.md b/docs/frontend/state-management-forms.md index 7ebe8db5..6e03c867 100644 --- a/docs/frontend/state-management-forms.md +++ b/docs/frontend/state-management-forms.md @@ -265,6 +265,63 @@ } ``` +#### 체크박스 — boolean 값 보장 + +자동 바인딩은 **현재 값이 boolean 일 때만** `checked` 로 바인딩한다 (`DynamicRenderer` 의 `isCheckedBinding`: `typeof currentValue === 'boolean'`). 값이 `null` 이나 `undefined` 면 `value` 바인딩으로 떨어져 **빈 문자열(`''`)이 전송**된다. + +서버가 그 빈 문자열을 `null` 로 저장하면(Laravel `ConvertEmptyStringsToNull` + `nullable` 규칙) 이후 `array_merge(defaults, saved)` 에서 `null` 이 기본값 `false` 를 덮는다. 그러면 화면에는 계속 해제 상태로 보이고, 다시 켜도 또 빈 문자열이 나가 **영구 고착**된다. + +`initLocal` 로는 막을 수 없다. 기본 병합은 shallow 라 데이터소스 응답이 `form` 객체를 통째로 교체하고, `_merge: "deep"` 을 써도 `deepMergeState` 가 `source` 의 `null` 을 그대로 반영한다. + +따라서 **저장값이 boolean 이 아닐 수 있는 체크박스는 레이아웃이 boolean 을 직접 보장**한다: + +```json +{ + "type": "basic", + "name": "Checkbox", + "props": { + "name": "method_card", + "autoBinding": false, + "checked": "{{!!_local.form?.method_card}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "form.method_card": "{{$event.target.checked}}", + "hasChanges": true + } + } + ] +} +``` + +- `autoBinding: false` — 자동 바인딩의 `value` 분기 개입을 차단한다 +- `checked: "{{!!...}}"` — 저장값이 `null` 이어도 `false` 로 렌더한다 +- `change` 액션 — `$event.target.checked` 는 항상 boolean 이므로 상태에 boolean 만 기록된다 + +저장값이 항상 boolean 임이 보장되는 경우(예: 서버가 `null` 을 반환하지 않고 마이그레이션으로 기본값이 채워진 컬럼)에는 `name` 만으로 자동 바인딩해도 된다. + +#### 체크박스 — 라벨 표시 + +`Checkbox` 컴포넌트는 `label` prop 을 받지만 **렌더하지 않는다**. 표시는 부모 `Label` 이 담당한다. `label` prop 에만 의존하면 라벨이 화면에 나타나지 않으므로, `Label` 로 감싸고 텍스트를 형제 노드로 둔다. + +```json +{ + "type": "basic", + "name": "Label", + "props": { "className": "flex items-center gap-2 cursor-pointer" }, + "children": [ + { "type": "basic", "name": "Checkbox", "props": { "name": "agree_terms" } }, + { "type": "basic", "name": "Span", "text": "$t:auth.register.agree_terms" } + ] +} +``` + +`Label` 로 감싸면 라벨 텍스트를 클릭해도 토글되고 접근성 이름이 부여된다. + #### 전역 상태 바인딩 (`_global.` 접두사) > **버전**: engine-v1.3.0+ diff --git a/plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/layouts/pluginSettingsPaymentMethods.test.tsx b/plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/layouts/pluginSettingsPaymentMethods.test.tsx index d2baf715..18f4f04c 100644 --- a/plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/layouts/pluginSettingsPaymentMethods.test.tsx +++ b/plugins/_bundled/sirsoft-tosspayments/resources/js/__tests__/layouts/pluginSettingsPaymentMethods.test.tsx @@ -61,6 +61,62 @@ describe('plugin_settings — 결제 방식 섹션', () => { expect(names).toHaveLength(9); }); + it('결제수단 9종의 라벨이 Label 래핑 + Span 텍스트로 렌더된다', () => { + // Checkbox 컴포넌트는 label prop 을 렌더하지 않는다 (부모 Label 이 처리). + // label prop 에만 의존하면 화면에 라벨이 표시되지 않으므로 사용을 금지한다. + const checkboxes = collect(pluginSettingsLayout, (n) => n.name === 'Checkbox'); + const withLabelProp = checkboxes.filter((n) => (n.props as Record)?.label !== undefined); + expect(withLabelProp).toHaveLength(0); + + // 각 method_* 체크박스는 Label 로 감싸이고 형제 Span 이 라벨 텍스트를 갖는다. + const wrappers = collect(pluginSettingsLayout, (n) => { + if (n.name !== 'Label' || !Array.isArray(n.children)) return false; + const children = n.children as Record[]; + return children.some((c) => c.name === 'Checkbox' && nameOf(c).startsWith('method_')); + }); + expect(wrappers).toHaveLength(9); + + for (const wrapper of wrappers) { + const children = wrapper.children as Record[]; + const checkbox = children.find((c) => c.name === 'Checkbox')!; + const span = children.find((c) => c.name === 'Span'); + expect(span, `${nameOf(checkbox)} 라벨 Span 누락`).toBeDefined(); + expect(String(span!.text)).toBe(`$t:sirsoft-tosspayments.settings.${nameOf(checkbox)}`); + } + }); + + it('결제수단 체크박스는 boolean 저장을 보장한다 (autoBinding 우회 + checked 강제 + change 액션)', () => { + // 엔진의 폼 자동바인딩은 현재 값이 boolean 일 때만 checked 바인딩을 쓴다 + // (DynamicRenderer `isCheckedBinding`: typeof currentValue === 'boolean'). + // 저장값이 null/undefined 면 value 바인딩으로 떨어져 빈 문자열('')이 전송되고, + // 서버가 그것을 null 로 저장하면 defaults(false) 를 덮어 영구 고착된다. + // → 레이아웃이 boolean 을 직접 보장해야 한다. + const checkboxes = collect(pluginSettingsLayout, (n) => n.name === 'Checkbox' && nameOf(n).startsWith('method_')); + expect(checkboxes).toHaveLength(9); + + for (const box of checkboxes) { + const name = nameOf(box); + const props = (box.props ?? {}) as Record; + + // 자동바인딩의 value 분기 개입을 차단한다. + expect(props.autoBinding, `${name}: autoBinding=false 누락`).toBe(false); + + // checked 는 !! 로 boolean 강제 — 저장값이 null 이어도 false 로 렌더된다. + expect(String(props.checked), `${name}: checked 가 boolean 강제(!!)가 아님`).toBe( + `{{!!_local.form?.${name}}}` + ); + + // change 액션이 $event.target.checked(항상 boolean)를 직접 기록한다. + const actions = (box.actions ?? []) as Record[]; + const change = actions.find((a) => a.type === 'change'); + expect(change, `${name}: change 액션 누락`).toBeDefined(); + + const params = (change!.params ?? {}) as Record; + expect(params[`form.${name}`], `${name}: boolean 이 아닌 값을 기록`).toBe('{{$event.target.checked}}'); + expect(params.hasChanges, `${name}: hasChanges 미설정 — 저장 버튼이 비활성 유지됨`).toBe(true); + } + }); + it('결제수단 그룹은 order_sheet_mode 가 켜졌을 때만 노출된다 (if)', () => { const group = findById(pluginSettingsLayout, 'enabled_methods_group'); expect(group).toBeDefined(); diff --git a/plugins/_bundled/sirsoft-tosspayments/resources/layouts/admin/plugin_settings.json b/plugins/_bundled/sirsoft-tosspayments/resources/layouts/admin/plugin_settings.json index c2dc9641..7c672c34 100644 --- a/plugins/_bundled/sirsoft-tosspayments/resources/layouts/admin/plugin_settings.json +++ b/plugins/_bundled/sirsoft-tosspayments/resources/layouts/admin/plugin_settings.json @@ -705,75 +705,309 @@ "children": [ { "type": "basic", - "name": "Checkbox", + "name": "Label", "props": { - "name": "method_card", - "label": "$t:sirsoft-tosspayments.settings.method_card" - } + "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-300" + }, + "children": [ + { + "type": "basic", + "name": "Checkbox", + "props": { + "name": "method_card", + "autoBinding": false, + "checked": "{{!!_local.form?.method_card}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "form.method_card": "{{$event.target.checked}}", + "hasChanges": true + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-tosspayments.settings.method_card" + } + ] }, { "type": "basic", - "name": "Checkbox", + "name": "Label", "props": { - "name": "method_virtual_account", - "label": "$t:sirsoft-tosspayments.settings.method_virtual_account" - } + "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-300" + }, + "children": [ + { + "type": "basic", + "name": "Checkbox", + "props": { + "name": "method_virtual_account", + "autoBinding": false, + "checked": "{{!!_local.form?.method_virtual_account}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "form.method_virtual_account": "{{$event.target.checked}}", + "hasChanges": true + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-tosspayments.settings.method_virtual_account" + } + ] }, { "type": "basic", - "name": "Checkbox", + "name": "Label", "props": { - "name": "method_transfer", - "label": "$t:sirsoft-tosspayments.settings.method_transfer" - } + "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-300" + }, + "children": [ + { + "type": "basic", + "name": "Checkbox", + "props": { + "name": "method_transfer", + "autoBinding": false, + "checked": "{{!!_local.form?.method_transfer}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "form.method_transfer": "{{$event.target.checked}}", + "hasChanges": true + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-tosspayments.settings.method_transfer" + } + ] }, { "type": "basic", - "name": "Checkbox", + "name": "Label", "props": { - "name": "method_mobile_phone", - "label": "$t:sirsoft-tosspayments.settings.method_mobile_phone" - } + "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-300" + }, + "children": [ + { + "type": "basic", + "name": "Checkbox", + "props": { + "name": "method_mobile_phone", + "autoBinding": false, + "checked": "{{!!_local.form?.method_mobile_phone}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "form.method_mobile_phone": "{{$event.target.checked}}", + "hasChanges": true + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-tosspayments.settings.method_mobile_phone" + } + ] }, { "type": "basic", - "name": "Checkbox", + "name": "Label", "props": { - "name": "method_tosspay", - "label": "$t:sirsoft-tosspayments.settings.method_tosspay" - } + "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-300" + }, + "children": [ + { + "type": "basic", + "name": "Checkbox", + "props": { + "name": "method_tosspay", + "autoBinding": false, + "checked": "{{!!_local.form?.method_tosspay}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "form.method_tosspay": "{{$event.target.checked}}", + "hasChanges": true + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-tosspayments.settings.method_tosspay" + } + ] }, { "type": "basic", - "name": "Checkbox", + "name": "Label", "props": { - "name": "method_kakaopay", - "label": "$t:sirsoft-tosspayments.settings.method_kakaopay" - } + "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-300" + }, + "children": [ + { + "type": "basic", + "name": "Checkbox", + "props": { + "name": "method_kakaopay", + "autoBinding": false, + "checked": "{{!!_local.form?.method_kakaopay}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "form.method_kakaopay": "{{$event.target.checked}}", + "hasChanges": true + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-tosspayments.settings.method_kakaopay" + } + ] }, { "type": "basic", - "name": "Checkbox", + "name": "Label", "props": { - "name": "method_naverpay", - "label": "$t:sirsoft-tosspayments.settings.method_naverpay" - } + "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-300" + }, + "children": [ + { + "type": "basic", + "name": "Checkbox", + "props": { + "name": "method_naverpay", + "autoBinding": false, + "checked": "{{!!_local.form?.method_naverpay}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "form.method_naverpay": "{{$event.target.checked}}", + "hasChanges": true + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-tosspayments.settings.method_naverpay" + } + ] }, { "type": "basic", - "name": "Checkbox", + "name": "Label", "props": { - "name": "method_payco", - "label": "$t:sirsoft-tosspayments.settings.method_payco" - } + "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-300" + }, + "children": [ + { + "type": "basic", + "name": "Checkbox", + "props": { + "name": "method_payco", + "autoBinding": false, + "checked": "{{!!_local.form?.method_payco}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "form.method_payco": "{{$event.target.checked}}", + "hasChanges": true + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-tosspayments.settings.method_payco" + } + ] }, { "type": "basic", - "name": "Checkbox", + "name": "Label", "props": { - "name": "method_samsungpay", - "label": "$t:sirsoft-tosspayments.settings.method_samsungpay" - } + "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-300" + }, + "children": [ + { + "type": "basic", + "name": "Checkbox", + "props": { + "name": "method_samsungpay", + "autoBinding": false, + "checked": "{{!!_local.form?.method_samsungpay}}" + }, + "actions": [ + { + "type": "change", + "handler": "setState", + "params": { + "target": "local", + "form.method_samsungpay": "{{$event.target.checked}}", + "hasChanges": true + } + } + ] + }, + { + "type": "basic", + "name": "Span", + "text": "$t:sirsoft-tosspayments.settings.method_samsungpay" + } + ] } ] }, diff --git a/resources/js/core/template-engine/__tests__/troubleshooting-components.test.ts b/resources/js/core/template-engine/__tests__/troubleshooting-components.test.ts index c6e887b5..4ed97ada 100644 --- a/resources/js/core/template-engine/__tests__/troubleshooting-components.test.ts +++ b/resources/js/core/template-engine/__tests__/troubleshooting-components.test.ts @@ -15,6 +15,7 @@ import { hasExplicitSetStateForField } from '../FormContext'; import { responsiveManager } from '../ResponsiveManager'; import { hasPipes } from '../PipeRegistry'; import { DataBindingEngine } from '../DataBindingEngine'; +import { deepMergeState } from '../DynamicRenderer'; describe('트러블슈팅 회귀 테스트 - flex 압착(shrink)', () => { /** @@ -843,6 +844,85 @@ describe('트러블슈팅 회귀 테스트 - Form 자동 바인딩 bindingType expect(calcIsCheckedBinding('checkable', true, 'checkbox')).toBe(true); }); }); + + /** + * [사례 5] 저장값이 null 이면 체크박스가 빈 문자열을 전송한다 + * + * troubleshooting-components-form.md 사례 6 회귀 가드. + * + * bindingType: 'checked' 로 등록된 Checkbox 라도 현재 값이 boolean 이 아니면 + * isCheckedBinding 이 false 가 되어 value 바인딩 분기로 떨어지고, + * 거기서 `currentValue ?? ''` 로 빈 문자열이 만들어진다. + * 그 빈 문자열이 서버에서 null 로 저장되면 기본값(false)을 덮어 영구 고착된다. + */ + describe('[사례 5] 저장값이 boolean 이 아니면 value 바인딩으로 떨어져 빈 문자열이 된다', () => { + /** DynamicRenderer 의 value 바인딩 분기와 동일 (`effectiveValue = currentValue ?? ''`) */ + const calcValueBindingValue = (currentValue: any): any => currentValue ?? ''; + + it('null 이면 Checkbox(checked) 여도 checked 바인딩이 아니다', () => { + expect(calcIsCheckedBinding('checked', null)).toBe(false); + }); + + it('undefined 여도 checked 바인딩이 아니다 (미저장 키)', () => { + expect(calcIsCheckedBinding('checked', undefined)).toBe(false); + }); + + it('null 이 value 바인딩으로 가면 빈 문자열이 전송된다 (고착의 시작점)', () => { + expect(calcValueBindingValue(null)).toBe(''); + expect(calcValueBindingValue(undefined)).toBe(''); + }); + + it('boolean 이면 정상적으로 checked 바인딩된다 (false 도 포함)', () => { + expect(calcIsCheckedBinding('checked', true)).toBe(true); + expect(calcIsCheckedBinding('checked', false)).toBe(true); + }); + + it('레이아웃이 !! 로 boolean 을 강제하면 null 이어도 checked 바인딩이 성립한다', () => { + // 해결책: props.checked = "{{!!_local.form?.x}}" → 항상 boolean + const coerced = !!(null as any); + expect(typeof coerced).toBe('boolean'); + expect(calcIsCheckedBinding('checked', coerced)).toBe(true); + }); + + it('$event.target.checked 는 항상 boolean 이므로 change 액션은 boolean 만 기록한다', () => { + // 해결책: change 액션이 setState 로 $event.target.checked 를 직접 쓴다 + const eventChecked: boolean = true; + expect(typeof eventChecked).toBe('boolean'); + expect(calcIsCheckedBinding('checked', eventChecked)).toBe(true); + }); + }); + + /** + * [사례 6] initLocal 로는 null 고착을 막을 수 없다 + * + * troubleshooting-components-form.md 사례 6 의 "initLocal 로는 막을 수 없다" 근거. + * deepMergeState 는 source 의 null 을 그대로 반영하므로, 레이아웃이 선언한 + * boolean 기본값이 API 응답의 null 에 덮인다. + */ + describe('[사례 6] deepMergeState 는 source 의 null 로 기본값을 덮는다', () => { + it('deep 병합에서 null 이 boolean 기본값(false)을 덮는다', () => { + const layoutDefaults = { form: { method_card: false, method_samsungpay: false } }; + const apiResponse = { form: { method_card: true, method_samsungpay: null } }; + + const merged = deepMergeState(layoutDefaults, apiResponse); + + expect(merged.form.method_card).toBe(true); + // 기본값 false 가 남지 않고 null 로 덮인다 → 이것이 고착의 원인 + expect(merged.form.method_samsungpay).toBeNull(); + expect(typeof merged.form.method_samsungpay).not.toBe('boolean'); + }); + + it('shallow 병합(기본값)은 form 객체를 통째로 교체하므로 기본값이 아예 남지 않는다', () => { + const prev = { form: { method_card: false, method_samsungpay: false } }; + const initData = { form: { method_card: true, method_samsungpay: null } }; + + // DynamicRenderer 의 shallow 분기: { ...prev, ...initDataWithoutMeta } + const merged = { ...prev, ...initData }; + + expect(merged.form).toEqual({ method_card: true, method_samsungpay: null }); + expect(merged.form.method_samsungpay).toBeNull(); + }); + }); }); describe('트러블슈팅 회귀 테스트 - 렌더링 구조', () => { diff --git a/templates/_bundled/sirsoft-basic/CHANGELOG.md b/templates/_bundled/sirsoft-basic/CHANGELOG.md index 6547b788..5cca2d8c 100644 --- a/templates/_bundled/sirsoft-basic/CHANGELOG.md +++ b/templates/_bundled/sirsoft-basic/CHANGELOG.md @@ -59,6 +59,7 @@ - 게시판 목록 세 종류(일반형·카드형·갤러리형) 모두에서 블라인드 처리된 글의 아이콘 색이 배경과 대비가 낮아 잘 보이지 않던 문제를 수정했습니다. - 목록의 이전/다음 이동이 페이지가 더 있는데도 눌리지 않던 문제를 수정했습니다. 마이페이지의 주문·상품 문의·내가 쓴 글 목록이 해당합니다. - 상품 상세를 열어도 "최근 본 상품" 이 계속 비어 있던 문제를 수정했습니다. +- 레이아웃 편집기에서 체크박스를 추가하면 라벨 없이 네모 칸만 놓이던 문제를 수정했습니다 — 이제 라벨이 함께 놓이며, 라벨을 눌러도 체크가 전환됩니다. ### Changed diff --git a/templates/_bundled/sirsoft-basic/__tests__/layouts/editor-spec-default-node.test.tsx b/templates/_bundled/sirsoft-basic/__tests__/layouts/editor-spec-default-node.test.tsx index 1398bff0..689b5023 100644 --- a/templates/_bundled/sirsoft-basic/__tests__/layouts/editor-spec-default-node.test.tsx +++ b/templates/_bundled/sirsoft-basic/__tests__/layouts/editor-spec-default-node.test.tsx @@ -165,4 +165,47 @@ describe('sirsoft-basic editor-spec defaultNode 공통 디자인', () => { t.cleanup(); }); }); + + // Checkbox 라벨 렌더 회귀 가드. + // Checkbox 컴포넌트는 label prop 을 받기만 하고 렌더하지 않는다(부모 Label 이 표시 담당). + // 팔레트가 label prop 만 심으면 편집기 삽입 시 라벨 없는 빈 체크박스가 나온다. + describe('Checkbox defaultNode — 라벨이 실제로 렌더된다', () => { + it('Checkbox defaultNode 는 label prop 이 아니라 Label 래핑 + Span 텍스트를 쓴다', () => { + const node = entries.Checkbox?.defaultNode; + expect(node).toBeDefined(); + + // 최상위는 Label — Checkbox 를 감싸 클릭 영역과 표시를 함께 제공한다. + expect(node?.name).toBe('Label'); + + const children = node?.children ?? []; + const checkbox = children.find((c) => c.name === 'Checkbox'); + const span = children.find((c) => c.name === 'Span'); + + expect(checkbox, 'Checkbox 자식 누락').toBeDefined(); + expect(span, '라벨 텍스트 Span 누락').toBeDefined(); + expect(span?.text).toBe('$t:layout_editor.palette.checkbox.default_label'); + + // 렌더되지 않는 label prop 에 의존하지 않는다. + expect(node?.props?.label, 'Label 에 label prop 사용 금지').toBeUndefined(); + expect(checkbox?.props?.label, 'Checkbox 의 label prop 은 렌더되지 않는다').toBeUndefined(); + }); + + it('Checkbox defaultNode 를 렌더하면 라벨 텍스트가 DOM 에 나타난다', async () => { + const node = entries.Checkbox?.defaultNode as DefaultNode; + const registry = createMockComponentRegistryWithBasics(); + const t = createLayoutTest(wrapAsLayout(node), { + templateId: 'sirsoft-basic', + componentRegistry: registry, + locale: 'ko', + }); + const { container } = await t.render(); + + // 체크박스 input 과 라벨 텍스트가 함께 렌더된다(빈 체크박스 회귀 차단). + const label = container.querySelector('label'); + expect(label, 'Label 래퍼가 렌더되어야 함').not.toBeNull(); + expect((label?.textContent ?? '').trim().length, '라벨 텍스트가 비어 있음').toBeGreaterThan(0); + + t.cleanup(); + }); + }); }); diff --git a/templates/_bundled/sirsoft-basic/editor-spec/componentPalette.json b/templates/_bundled/sirsoft-basic/editor-spec/componentPalette.json index 3eca29e9..7913ab62 100644 --- a/templates/_bundled/sirsoft-basic/editor-spec/componentPalette.json +++ b/templates/_bundled/sirsoft-basic/editor-spec/componentPalette.json @@ -436,11 +436,24 @@ "requiresDefaultNode": true, "defaultNode": { "type": "basic", - "name": "Checkbox", + "name": "Label", "props": { - "label": "$t:layout_editor.palette.checkbox.default_label", - "className": "w-4 h-4 text-gray-900 dark:text-white border-gray-300 dark:border-gray-600 rounded focus:ring-gray-900 dark:focus:ring-white" - } + "className": "flex items-center gap-2 cursor-pointer text-sm text-gray-700 dark:text-gray-300" + }, + "children": [ + { + "type": "basic", + "name": "Checkbox", + "props": { + "className": "w-4 h-4 text-gray-900 dark:text-white border-gray-300 dark:border-gray-600 rounded focus:ring-gray-900 dark:focus:ring-white" + } + }, + { + "type": "basic", + "name": "Span", + "text": "$t:layout_editor.palette.checkbox.default_label" + } + ] } }, "PasswordInput": { diff --git a/tests/Playwright/specs/admin/tosspayments-settings-method-labels.spec.ts b/tests/Playwright/specs/admin/tosspayments-settings-method-labels.spec.ts new file mode 100644 index 00000000..e656f6c0 --- /dev/null +++ b/tests/Playwright/specs/admin/tosspayments-settings-method-labels.spec.ts @@ -0,0 +1,173 @@ +/** + * E2E: 토스페이먼츠 설정 — 주문서형 결제수단 체크박스 라벨 렌더 (#454) + * + * @scenario toss_payment_methods_vbank_escrow + * @effects enabled_methods_persisted + * + * 배경(회귀): + * Checkbox 컴포넌트는 `label` prop 을 받지만 렌더하지 않는다 (부모 Label 이 표시를 담당). + * 레이아웃이 `label` prop 에만 의존하면 체크박스만 9개 뜨고 이름이 전부 빈 칸이 된다. + * 레이아웃 구조 테스트(JSON walk)는 이를 잡지 못했고 브라우저에서만 드러났다 — + * 따라서 "라벨 텍스트가 실제로 화면에 보이는가" 는 E2E 로 고정한다. + * + * 검증: + * 1. 주문서형 결제 토글을 켜면 결제수단 9종이 노출되고, 각 체크박스에 라벨 텍스트가 보인다 + * 2. 라벨 클릭으로 체크 상태가 토글된다 (Label 래핑이 input 과 연결됨) + * 3. 체크 해제 → 저장 → 재진입 시 상태가 유지된다 (폼 자동바인딩 회귀 가드) + */ +import { test, expect, issueToken, authenticatePage } from '../../fixtures/auth'; + +const SETTINGS_PATH = '/admin/plugins/sirsoft-tosspayments/settings'; + +/** 결제수단 체크박스 9종의 name (레이아웃의 method_* 와 1:1) */ +const METHOD_NAMES = [ + 'method_card', + 'method_virtual_account', + 'method_transfer', + 'method_mobile_phone', + 'method_tosspay', + 'method_kakaopay', + 'method_naverpay', + 'method_payco', + 'method_samsungpay', +] as const; + +/** 설정 화면 진입 + 결제수단 그룹이 마운트될 때까지 대기 */ +async function gotoSettings(page: import('@playwright/test').Page): Promise { + await page.goto(SETTINGS_PATH); + await page.waitForLoadState('domcontentloaded', { timeout: 30_000 }); + expect(page.url()).not.toMatch(/\/admin\/login/); + + // 주문서형 결제가 꺼져 있으면 결제수단 그룹이 if 로 숨겨진다 → 켜서 노출시킨다. + const orderSheetToggle = page.locator('input[type=checkbox][name="order_sheet_mode"]'); + await expect(orderSheetToggle).toBeAttached({ timeout: 20_000 }); + if (!(await orderSheetToggle.isChecked())) { + // Toggle 컴포넌트는 input 을 시각적으로 감추고 형제 트랙을 노출하므로 force 클릭이 필요하다. + await orderSheetToggle.click({ force: true }); + } + + await expect(page.locator('input[type=checkbox][name="method_card"]')).toBeAttached({ timeout: 10_000 }); +} + +// @scenario order_sheet_mode=on, method_labels=rendered +// @effects enabled_methods_persisted +test('@smoke #454 - 결제수단 9종 체크박스에 라벨 텍스트가 표시된다', async ({ page }) => { + const token = issueToken('core.plugins.update'); + await authenticatePage(page, token); + + await gotoSettings(page); + + for (const name of METHOD_NAMES) { + const checkbox = page.locator(`input[type=checkbox][name="${name}"]`); + await expect(checkbox).toBeAttached(); + + // Checkbox 는 label prop 을 렌더하지 않으므로, 부모 Label 이 텍스트를 갖고 있어야 한다. + const wrapper = page.locator(`label:has(input[type=checkbox][name="${name}"])`); + await expect(wrapper, `${name} 라벨 래퍼 누락`).toBeVisible(); + + const text = (await wrapper.innerText()).trim(); + expect(text, `${name} 라벨 텍스트가 비어 있음 (label prop 은 렌더되지 않는다)`).not.toBe(''); + } +}); + +// @scenario label_click=toggles_checkbox +test('#454 - 라벨을 클릭하면 체크 상태가 토글된다', async ({ page }) => { + const token = issueToken('core.plugins.update'); + await authenticatePage(page, token); + + await gotoSettings(page); + + const checkbox = page.locator('input[type=checkbox][name="method_card"]'); + const label = page.locator('label:has(input[type=checkbox][name="method_card"])'); + + const before = await checkbox.isChecked(); + await label.click(); + await expect(checkbox).toBeChecked({ checked: !before }); + + // 원복 (다음 테스트 간섭 방지) + await label.click(); + await expect(checkbox).toBeChecked({ checked: before }); +}); + +// @scenario save=enabled_methods, payload=boolean +// @effects enabled_methods_persisted +test('#454 - 결제수단 저장 페이로드가 boolean 으로 전송된다 (빈 문자열 회귀 가드)', async ({ page }) => { + const token = issueToken('core.plugins.update'); + await authenticatePage(page, token); + + await gotoSettings(page); + + // 저장 요청 본문을 가로챈다. + // gotoSettings 가 주문서형 토글을 켜면서 별도 저장이 먼저 날 수 있으므로, + // 결제수단 키를 담은 요청만 잡는다 (상태 의존 flaky 방지). + const savePayload = page.waitForRequest( + (req) => + req.method() === 'PUT' && + req.url().includes('/plugins/sirsoft-tosspayments/settings') && + (req.postData() ?? '').includes('method_card'), + ); + + // 토글해서 dirty 를 만든다 (값 자체는 무엇이든 boolean 이어야 한다). + const target = page.locator('input[type=checkbox][name="method_samsungpay"]'); + const before = await target.isChecked(); + await target.click(); + await expect(target).toBeChecked({ checked: !before }); + + await expect(page.locator('#save_button')).toBeEnabled({ timeout: 10_000 }); + await page.locator('#save_button').click(); + + const body = JSON.parse((await savePayload).postData() ?? '{}'); + + // 회귀: 저장값이 null/미정의였던 항목이 빈 문자열('')로 전송되면 서버가 null 로 저장하고, + // 그 null 이 기본값(false)을 덮어 화면에서 다시 켤 수 없게 고착된다. + for (const name of METHOD_NAMES) { + expect(typeof body[name], `${name} 이 boolean 이 아님 (실제: ${JSON.stringify(body[name])})`).toBe('boolean'); + } + + // 원상 복구 + await gotoSettings(page); + const restored = page.locator('input[type=checkbox][name="method_samsungpay"]'); + if ((await restored.isChecked()) !== before) { + await restored.click(); + await expect(page.locator('#save_button')).toBeEnabled({ timeout: 10_000 }); + await page.locator('#save_button').click(); + await page.waitForTimeout(2_000); + } +}); + +// @scenario save=enabled_methods, reload=persisted +// @effects enabled_methods_persisted +test('#454 - 결제수단 체크 해제 후 저장하면 재진입 시 유지된다', async ({ page }) => { + const token = issueToken('core.plugins.update'); + await authenticatePage(page, token); + + await gotoSettings(page); + + const target = page.locator('input[type=checkbox][name="method_samsungpay"]'); + // 저장 버튼은 세션 로케일에 따라 라벨이 달라지므로 id 로 잡는다 (name 기반은 en 세션에서 깨진다). + const saveButton = page.locator('#save_button'); + + // 체크 해제 상태를 만든다 (이미 해제되어 있으면 켰다가 다시 끈다 — dirty 를 확실히 만든다). + if (!(await target.isChecked())) { + await target.click(); + await expect(target).toBeChecked(); + } + await target.click(); + await expect(target).not.toBeChecked(); + + // disabled 는 {{!_local.hasChanges}} 바인딩 — 활성화되었다는 것은 폼 dirty 감지가 살아있다는 뜻. + await expect(saveButton).toBeEnabled({ timeout: 10_000 }); + await saveButton.click(); + + await page.waitForTimeout(2_000); + await gotoSettings(page); + await expect(page.locator('input[type=checkbox][name="method_samsungpay"]')).not.toBeChecked({ timeout: 10_000 }); + + // 원상 복구 — 다른 시나리오/실환경에 해제 상태를 남기지 않는다. + const restored = page.locator('input[type=checkbox][name="method_samsungpay"]'); + await restored.click(); + await expect(restored).toBeChecked(); + await expect(page.locator('#save_button')).toBeEnabled({ timeout: 10_000 }); + await page.locator('#save_button').click(); + await page.waitForTimeout(2_000); +});