fix(tosspayments,templates,core): 결제수단 체크박스 라벨 미표시·저장 실패 수정

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).
This commit is contained in:
HeuJung
2026-08-06 13:27:45 +09:00
parent faa79b0ae1
commit 3f2674f1a7
8 changed files with 697 additions and 40 deletions
+57
View File
@@ -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+
@@ -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<string, unknown>)?.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<string, unknown>[];
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<string, unknown>[];
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<string, unknown>;
// 자동바인딩의 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<string, unknown>[];
const change = actions.find((a) => a.type === 'change');
expect(change, `${name}: change 액션 누락`).toBeDefined();
const params = (change!.params ?? {}) as Record<string, unknown>;
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();
@@ -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"
}
]
}
]
},
@@ -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('트러블슈팅 회귀 테스트 - 렌더링 구조', () => {
@@ -59,6 +59,7 @@
- 게시판 목록 세 종류(일반형·카드형·갤러리형) 모두에서 블라인드 처리된 글의 아이콘 색이 배경과 대비가 낮아 잘 보이지 않던 문제를 수정했습니다.
- 목록의 이전/다음 이동이 페이지가 더 있는데도 눌리지 않던 문제를 수정했습니다. 마이페이지의 주문·상품 문의·내가 쓴 글 목록이 해당합니다.
- 상품 상세를 열어도 "최근 본 상품" 이 계속 비어 있던 문제를 수정했습니다.
- 레이아웃 편집기에서 체크박스를 추가하면 라벨 없이 네모 칸만 놓이던 문제를 수정했습니다 — 이제 라벨이 함께 놓이며, 라벨을 눌러도 체크가 전환됩니다.
### Changed
@@ -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();
});
});
});
@@ -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": {
@@ -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<void> {
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);
});