fix(cart): 장바구니 전체 선택 개수 최초 로딩 미표시 수정

표시식이 selectedCount 를 fallback 없이 보간했다. 이 키는 체크박스 change
핸들러에서만 기록되므로 최초 마운트 구간에 undefined 이고, 엔진이 undefined 를
예외 없이 빈 문자열로 서식해 (/27) 로 보였다. 선택 상태의 SSoT 인 selectedItems
파생으로 교체해 주문 요약과 계산 방식을 일치시켰다.

같은 결함 클래스(초기값 없는 키를 옵셔널 없이 하위 접근) 28곳을 함께 방어하고,
3조건 동시 충족만 잡는 audit 룰로 codify 했다. 브라우저 실측(T9)에서 다운로드
이력 모달의 iteration 문자열 선언(매 렌더 TypeError)·미존재 아이콘명·정적 id
중복 3종을 추가로 발견해 정정했다.

공개 이슈: gnuboard/g7
This commit is contained in:
HeuJung
2026-08-12 10:17:22 +09:00
parent 95957bb167
commit 7765695339
25 changed files with 839 additions and 99 deletions
+1 -1
View File
@@ -517,8 +517,8 @@ cp .env.example .env
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a>
+1 -1
View File
@@ -533,8 +533,8 @@ Thanks to everyone who reported an issue or suggested a feature that shipped —
<a href="https://github.com/laelbe" title="laelbe"><img src="https://github.com/laelbe.png" width="48" alt="laelbe"></a>
<a href="https://github.com/bigmsg" title="bigmsg"><img src="https://github.com/bigmsg.png" width="48" alt="bigmsg"></a>
<a href="https://github.com/hwaryeon1234" title="hwaryeon1234"><img src="https://github.com/hwaryeon1234.png" width="48" alt="hwaryeon1234"></a>
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
<a href="https://github.com/koojunho" title="koojunho"><img src="https://github.com/koojunho.png" width="48" alt="koojunho"></a>
<a href="https://github.com/abc101" title="abc101"><img src="https://github.com/abc101.png" width="48" alt="abc101"></a>
<a href="https://github.com/yks118" title="yks118"><img src="https://github.com/yks118.png" width="48" alt="yks118"></a>
<a href="https://github.com/kitrio" title="kitrio"><img src="https://github.com/kitrio.png" width="48" alt="kitrio"></a>
<a href="https://github.com/movielee2020" title="movielee2020"><img src="https://github.com/movielee2020.png" width="48" alt="movielee2020"></a>
@@ -10,6 +10,9 @@
- 언어/통화 설정에서 기본 제공 통화(달러·엔·위안·유로)를 삭제해도 저장 직후 다시 나타나던 문제를 수정했습니다. 삭제한 통화는 설정 화면과 쇼핑몰 화면 양쪽에서 유지되며, 통화 추가로 다시 등록하면 복원됩니다. 사용 중지된 통화를 표시 통화로 갖고 있던 구매자의 주문은 기본 통화로 안전하게 진행됩니다. (#91 @koojunho 님께서 제보해주셨습니다.)
- 통화를 삭제한 뒤 그 통화로 결제된 과거 주문의 금액 표기가 달라지던 문제를 수정했습니다. 주문·환불 금액의 소수 자릿수는 주문 시점 기준을 유지하므로, 엔화처럼 소수점을 쓰지 않는 통화가 `¥14,835.00` 으로 바뀌거나 소수점 이하 자릿수가 많은 통화의 표시 금액이 잘리지 않습니다. (#91 @koojunho 님께서 제보해주셨습니다.)
- 관리자 엑셀 다운로드 관리의 다운로드 이력 모달에서, 이력 항목이 아직 선택되지 않은 순간에 파일 정보·상태 배지가 비정상적으로 처리되던 문제를 보완했습니다. 항목을 선택한 뒤의 표시는 종전과 동일합니다.
- 같은 모달의 "다운로드 기록" 목록이 항상 비어 보이던 문제를 수정했습니다. 이제 누가 언제 몇 번째로 내려받았는지 기록이 표시됩니다.
- 같은 모달의 "처리중"·"실패"·"만료" 상태 배지에서 아이콘이 표시되지 않던 문제를 수정했습니다.
## [1.1.0] - 2026-08-10
@@ -97,11 +97,17 @@ describe('B2 — 주문상세 모달 상품표 카드형 전환', () => {
const cancel = load('partials/admin_ecommerce_order_detail/_modal_cancel_order.json');
const batch = load('partials/admin_ecommerce_order_detail/_modal_batch_change_confirm.json');
const unitPrice = collect(
// 금액 노드는 서버가 통화까지 포함해 서식한 `*_formatted` 를 그대로 출력한다.
// (한때 `원` 리터럴로 이 노드를 찾았으나 통화 하드코딩 제거 후 선택자가 아무것도 못 찾아
// `cls(undefined)` 를 단언하는 빈 가드가 되어 있었다 — 선택자를 서식 필드 기준으로 고정한다)
const unitPrices = collect(
cancel,
(n) => typeof n.text === 'string' && n.text.includes('unit_price') && n.text.includes('원'),
)[0];
expect(cls(unitPrice)).toContain('whitespace-nowrap');
(n) => typeof n.text === 'string' && n.text.includes('unit_price_formatted'),
);
expect(unitPrices.length, '단가 표시 노드를 찾지 못했다').toBeGreaterThan(0);
for (const node of unitPrices) {
expect(cls(node)).toContain('whitespace-nowrap');
}
const batchAmounts = collect(
batch,
@@ -0,0 +1,131 @@
/**
* @file excel-download-history-modal-fallback.test.tsx
* @description 엑셀 다운로드 이력 모달의 미선택 상태 표시 가드 (공개 이슈 #92 동일 결함 클래스)
*
* 결함 클래스: 부모 레이아웃 state 에 `null` 로만 선언된 키를 옵셔널 체이닝 없이 하위 접근하면,
* 그 키를 채우는 유일한 지점(행 클릭 setState)이 아직 실행되지 않은 구간에서 표시가 비거나
* 조건식이 예외로 떨어진다. `admin_ecommerce_excel_download_index.json` 의 `selectedItem` 이
* 그 형태이고, 이 모달에는 selectedItem 을 검사하는 `if` 가드가 없다.
*
* 정정: 모달의 모든 `selectedItem` 하위 접근에 `?.` + 표시식 폴백(`?? ''`, count 인자는 `?? 0`)을 부여한다.
* 값이 채워진 뒤의 출력은 이전과 바이트 동일하므로 기능 변화는 없다.
*
* @vitest-environment jsdom
*/
import { describe, it, expect } from 'vitest';
import downloadHistoryModal from '../../../layouts/admin/partials/admin_ecommerce_excel_download_index/_modal_download_history.json';
import excelDownloadIndex from '../../../layouts/admin/admin_ecommerce_excel_download_index.json';
/**
* JSON 트리에서 특정 문자열을 포함하는 문자열 값을 모두 수집합니다.
*
* @param node 순회 시작 노드
* @param needle 찾을 부분 문자열
* @returns 매칭된 문자열 값 목록
*/
function collectStringsContaining(node: unknown, needle: string): string[] {
const out: string[] = [];
const walk = (n: unknown): void => {
if (Array.isArray(n)) {
n.forEach(walk);
return;
}
if (!n || typeof n !== 'object') return;
const obj = n as Record<string, unknown>;
for (const v of Object.values(obj)) {
if (typeof v === 'string' && v.includes(needle)) out.push(v);
}
Object.values(obj).forEach(walk);
};
walk(node);
return out;
}
const refs = collectStringsContaining(downloadHistoryModal, 'selectedItem');
describe('_modal_download_history.json — selectedItem 표시 가드', () => {
/**
* @scenario selection_state=unselected
* @effects history_modal_display_guarded
*/
it('부모 레이아웃이 selectedItem 을 null 로만 시드한다 (전제 확인)', () => {
const state = (excelDownloadIndex as any).state ?? {};
expect(
Object.prototype.hasOwnProperty.call(state, 'selectedItem'),
'selectedItem 시드가 사라졌다면 이 테스트의 전제를 재검토할 것'
).toBe(true);
expect(state.selectedItem).toBeNull();
});
/**
* @scenario selection_state=unselected
* @effects history_modal_display_guarded
*/
it('selectedItem 하위 접근은 전부 옵셔널 체이닝을 쓴다', () => {
expect(refs.length, 'selectedItem 참조를 찾지 못했다').toBeGreaterThan(0);
const unguarded = refs.filter((r) => /selectedItem\.[A-Za-z_]/.test(r));
expect(
unguarded,
`selectedItem 뒤에 '?.' 없는 역참조: ${unguarded.join(' | ')}`
).toEqual([]);
});
/**
* 다운로드 기록 목록이 문자열 형태 `"iteration": "{{...}}"` 로 선언되어 있어
* 엔진이 `iteration.source` 를 읽지 못하고 매 렌더마다 TypeError 로 떨어졌다
* (목록이 통째로 비어 보임 — 브라우저 실측에서 발견).
*
* @scenario selection_state=selected
* @effects history_list_renders
*/
it('다운로드 기록 iteration 이 source/item_var/index_var 객체 형태다', () => {
const iterations: unknown[] = [];
const walk = (n: unknown): void => {
if (Array.isArray(n)) { n.forEach(walk); return; }
if (!n || typeof n !== 'object') return;
const obj = n as Record<string, unknown>;
if ('iteration' in obj) iterations.push(obj.iteration);
Object.values(obj).forEach(walk);
};
walk(downloadHistoryModal);
expect(iterations.length, 'iteration 선언을 찾지 못했다').toBeGreaterThan(0);
for (const it of iterations) {
expect(typeof it, `문자열 형태 iteration 은 엔진이 해석하지 못한다: ${String(it)}`).toBe('object');
const cfg = it as Record<string, unknown>;
expect(typeof cfg.source).toBe('string');
expect(cfg.item_var).toBeTruthy();
}
});
/**
* @scenario selection_state=selected
* @effects history_modal_display_guarded
*/
it('표시식(text)은 값 부재 시 폴백을 갖는다', () => {
const textRefs: string[] = [];
const walk = (n: unknown): void => {
if (Array.isArray(n)) {
n.forEach(walk);
return;
}
if (!n || typeof n !== 'object') return;
const obj = n as Record<string, unknown>;
if (typeof obj.text === 'string' && obj.text.includes('selectedItem')) {
textRefs.push(obj.text);
}
Object.values(obj).forEach(walk);
};
walk(downloadHistoryModal);
// 개수를 고정한다 — `> 0` 만 두면 표시식이 삭제돼도 남은 것들이 폴백을 갖는 한 통과한다.
// 현재 6건: file_content / category_name / created_date / expire_date / download_count /
// download_history.length (마지막 1건은 원래부터 가드가 있어 #92 수정 대상 5건에 포함되지 않는다)
expect(textRefs.length, `selectedItem 표시식 개수 변동: ${textRefs.join(' | ')}`).toBe(6);
for (const t of textRefs) {
expect(t, `폴백 없는 표시식: ${t}`).toMatch(/\?\?\s*('{2}|0)/);
}
});
});
@@ -41,7 +41,7 @@
"props": {
"className": "text-primary text-right"
},
"text": "{{_local.selectedItem.file_content}}"
"text": "{{_local.selectedItem?.file_content ?? ''}}"
},
{
"type": "basic",
@@ -57,7 +57,7 @@
"props": {
"className": "text-primary text-right"
},
"text": "{{_local.selectedItem.category_name}}"
"text": "{{_local.selectedItem?.category_name ?? ''}}"
},
{
"type": "basic",
@@ -77,7 +77,7 @@
{
"type": "basic",
"name": "Span",
"condition": "{{_local.selectedItem.status === 'completed'}}",
"condition": "{{_local.selectedItem?.status === 'completed'}}",
"props": {
"className": "badge-status badge-status-success"
},
@@ -87,7 +87,7 @@
"name": "Icon",
"props": {
"name": "check-circle",
"className": "w-3 h-3"
"className": "text-xs"
}
},
{
@@ -100,7 +100,7 @@
{
"type": "basic",
"name": "Span",
"condition": "{{_local.selectedItem.status === 'processing'}}",
"condition": "{{_local.selectedItem?.status === 'processing'}}",
"props": {
"className": "badge-status badge-status-processing"
},
@@ -109,8 +109,8 @@
"type": "basic",
"name": "Icon",
"props": {
"name": "loader",
"className": "w-3 h-3 animate-spin"
"name": "spinner",
"className": "text-xs animate-spin"
}
},
{
@@ -123,7 +123,7 @@
{
"type": "basic",
"name": "Span",
"condition": "{{_local.selectedItem.status === 'failed'}}",
"condition": "{{_local.selectedItem?.status === 'failed'}}",
"props": {
"className": "badge-status badge-status-error"
},
@@ -132,8 +132,8 @@
"type": "basic",
"name": "Icon",
"props": {
"name": "alert-circle",
"className": "w-3 h-3"
"name": "circle-exclamation",
"className": "text-xs"
}
},
{
@@ -146,7 +146,7 @@
{
"type": "basic",
"name": "Span",
"condition": "{{_local.selectedItem.status === 'expired'}}",
"condition": "{{_local.selectedItem?.status === 'expired'}}",
"props": {
"className": "badge-status badge-status-expired"
},
@@ -155,8 +155,8 @@
"type": "basic",
"name": "Icon",
"props": {
"name": "file-text",
"className": "w-3 h-3"
"name": "file-lines",
"className": "text-xs"
}
},
{
@@ -182,7 +182,7 @@
"props": {
"className": "text-primary text-right"
},
"text": "{{_local.selectedItem.created_date}}"
"text": "{{_local.selectedItem?.created_date ?? ''}}"
},
{
"type": "basic",
@@ -198,7 +198,7 @@
"props": {
"className": "text-primary text-right"
},
"text": "{{_local.selectedItem.expire_date}}"
"text": "{{_local.selectedItem?.expire_date ?? ''}}"
},
{
"type": "basic",
@@ -214,7 +214,7 @@
"props": {
"className": "text-primary text-right font-medium"
},
"text": "$t:sirsoft-ecommerce.admin.excel_download.modal.download_history.download_count|count={{_local.selectedItem.download_count}}"
"text": "$t:sirsoft-ecommerce.admin.excel_download.modal.download_history.download_count|count={{_local.selectedItem?.download_count ?? 0}}"
}
]
}
@@ -259,10 +259,14 @@
},
"children": [
{
"id": "history_item",
"id": "history_item_{{index}}",
"type": "basic",
"name": "Div",
"iteration": "{{_local.selectedItem?.download_history ?? []}}",
"iteration": {
"source": "{{_local.selectedItem?.download_history ?? []}}",
"item_var": "item",
"index_var": "index"
},
"props": {
"className": "admin-card mb-3"
},
@@ -286,7 +290,7 @@
"name": "Icon",
"props": {
"name": "user",
"className": "w-4 h-4"
"className": "text-base"
}
}
]
@@ -329,7 +333,7 @@
"name": "Icon",
"props": {
"name": "calendar",
"className": "w-3 h-3"
"className": "text-xs"
}
},
{
@@ -0,0 +1,36 @@
feature: 엑셀 다운로드 이력 모달의 선택 항목 표시 가드
description: |
관리자 "엑셀 다운로드 관리" 화면의 다운로드 이력 모달은 목록에서 고른 항목(`_local.selectedItem`)의
파일 내용·분류·생성일·만료일·다운로드 횟수를 표시한다. 이 키는 부모 레이아웃이 `null` 로만 시드하고
행 클릭 시에만 채워지므로, 아직 아무 행도 고르지 않은 구간이 항상 존재한다.
수정 이력 (공개 이슈 #92 와 동일한 결함 클래스):
- 모든 표시식이 `{{_local.selectedItem.<field>}}` 형태로 옵셔널 체이닝이 없었고,
모달에 selectedItem 을 검사하는 `if` 가드도 없었다. 상태 배지의 조건식
(`selectedItem.status === '...'`)은 값 부재 구간에서 조건 평가가 실패해
경고 로그를 남기며 false 로 떨어지고 있었다.
- 모든 하위 접근에 `?.` 를, 표시식에 폴백(`?? ''`, 횟수는 `?? 0`)을 부여했다.
값이 채워진 뒤의 출력은 이전과 동일하므로 기능 변화는 없다.
- 브라우저 실측에서 별개 결함이 함께 드러났다 — "다운로드 기록" 목록의 iteration 이
문자열 형태로 선언되어 엔진이 source 를 읽지 못하고 매 렌더마다 예외로 떨어졌다.
목록이 통째로 비어 보이던 문제이며, 객체 형태(source/item_var/index_var)로 정정했다.
- 상태 배지 3종의 아이콘 이름이 Font Awesome Free 에 없는 값이라 빈 글리프로 렌더됐다.
axes:
selection_state: [unselected, selected] # 이력 행 미선택 / 선택
effects:
- history_modal_display_guarded # 값 부재 구간에서 조건 평가 실패·표시 공백이 발생하지 않음
- history_list_renders # 다운로드 기록 목록이 실제로 렌더된다 (iteration 선언 형태)
test_files:
- modules/_bundled/sirsoft-ecommerce/resources/js/__tests__/layouts/excel-download-history-modal-fallback.test.tsx
validation:
audit_rule: frontend-change-requires-e2e
browser_check: Chrome MCP # 관리자 화면 실측(정밀 점검 매트릭스 T9)으로 브라우저 검증 대체
note: |
레이아웃 JSON 은 헤더 주석을 가질 수 없어 `e2e:allow` 를 붙일 수 없다. 표시식의 옵셔널/폴백
유무는 정적 구조로 판정 가능하므로 레이아웃 정적 가드 테스트가 두 selection_state 를 모두
떠받치고, 실제 모달 열기 전/후 화면은 Chrome MCP 정밀 점검이 확인한다.
@@ -4,6 +4,15 @@
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
## [1.1.1] - 2026-08-11
### Fixed
- 장바구니를 처음 열었을 때 "전체 선택" 옆 개수가 `(/27)` 처럼 선택 개수 없이 표시되던 문제를 수정했습니다. 이제 진입·새로고침·다른 화면에서 돌아온 경우 모두 `(27/27)` 로 바로 표시되며, 체크박스 상태 및 주문 요약의 "선택 상품 N / M개" 와 항상 같은 값을 가리킵니다. (#92 @koojunho 님께서 제보해주셨습니다.)
- 장바구니 옵션변경 모달의 재고 안내와 마이페이지 프로필의 입력 오류 메시지가, 표시할 값이 아직 준비되지 않은 순간에도 빈칸으로 남지 않도록 보완했습니다. 값이 준비된 뒤의 표시는 종전과 동일합니다.
- 마이페이지 게시판 알림 수신 설정(댓글·답글 등 4개 항목)의 체크 상태가 저장값과 어긋날 수 있던 가능성을 차단했습니다. 저장된 값이 어떤 형태든 화면의 체크 표시와 저장 결과가 항상 일치합니다.
- 장바구니 "전체 선택" 체크박스가 다크 모드에서 테두리 색을 따르지 않던 문제를 수정했습니다.
## [1.1.0] - 2026-08-10
### Added
@@ -10,7 +10,7 @@
* 회귀 차단: 버튼 액션이 (a) redirectToLoginWithReturn 핸들러이고 (b) 깨진
* navigate+{{_global.currentPath}} 패턴이 잔존하지 않는지 고정한다.
*
* @scenario entry_point:secret_post_login_button
* @scenario entry_point=secret_post_login_button
* @effects secret_button_redirect_not_empty,login_returns_to_original_path
*/
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -524,7 +524,7 @@
"props": {
"className": "text-sm text-red-600 dark:text-red-400"
},
"text": "{{_local.profileErrors.name[0]}}"
"text": "{{_local.profileErrors?.name?.[0] ?? ''}}"
}
]
},
@@ -562,7 +562,7 @@
"props": {
"className": "text-sm text-red-600 dark:text-red-400"
},
"text": "{{_local.profileErrors.nickname[0]}}"
"text": "{{_local.profileErrors?.nickname?.[0] ?? ''}}"
}
]
}
@@ -651,7 +651,7 @@
"props": {
"className": "text-sm text-red-600 dark:text-red-400"
},
"text": "{{_local.profileErrors.mobile[0]}}"
"text": "{{_local.profileErrors?.mobile?.[0] ?? ''}}"
}
]
},
@@ -689,7 +689,7 @@
"props": {
"className": "text-sm text-red-600 dark:text-red-400"
},
"text": "{{_local.profileErrors.phone[0]}}"
"text": "{{_local.profileErrors?.phone?.[0] ?? ''}}"
}
]
},
@@ -727,7 +727,7 @@
"props": {
"className": "text-sm text-red-600 dark:text-red-400"
},
"text": "{{_local.profileErrors.homepage[0]}}"
"text": "{{_local.profileErrors?.homepage?.[0] ?? ''}}"
}
]
}
@@ -799,7 +799,7 @@
"props": {
"className": "text-sm text-red-600 dark:text-red-400"
},
"text": "{{_local.profileErrors.country[0]}}"
"text": "{{_local.profileErrors?.country?.[0] ?? ''}}"
}
]
},
@@ -836,7 +836,7 @@
"props": {
"className": "text-sm text-red-600 dark:text-red-400"
},
"text": "{{_local.profileErrors.language[0]}}"
"text": "{{_local.profileErrors?.language?.[0] ?? ''}}"
}
]
},
@@ -875,7 +875,7 @@
"props": {
"className": "text-sm text-red-600 dark:text-red-400"
},
"text": "{{_local.profileErrors.timezone[0]}}"
"text": "{{_local.profileErrors?.timezone?.[0] ?? ''}}"
}
]
},
@@ -963,7 +963,7 @@
"props": {
"className": "text-sm text-red-600 dark:text-red-400"
},
"text": "{{_local.profileErrors?.zipcode ? _local.profileErrors.zipcode[0] : (_local.profileErrors?.address ? _local.profileErrors.address[0] : (_local.profileErrors?.address_detail ? _local.profileErrors.address_detail[0] : ''))}}"
"text": "{{_local.profileErrors?.zipcode?.[0] ?? _local.profileErrors?.address?.[0] ?? _local.profileErrors?.address_detail?.[0] ?? ''}}"
}
]
}
@@ -1038,7 +1038,7 @@
"props": {
"className": "text-sm text-red-600 dark:text-red-400"
},
"text": "{{_local.profileErrors.signature[0]}}"
"text": "{{_local.profileErrors?.signature?.[0] ?? ''}}"
},
{
"type": "basic",
@@ -1084,7 +1084,7 @@
"props": {
"className": "text-sm text-red-600 dark:text-red-400"
},
"text": "{{_local.profileErrors.bio[0]}}"
"text": "{{_local.profileErrors?.bio?.[0] ?? ''}}"
},
{
"type": "basic",
@@ -1182,10 +1182,23 @@
{
"type": "basic",
"name": "Checkbox",
"comment": "저장값이 boolean 이 아닐 수 있는 구간(설정 미저장 등)에도 checked 가 확정되도록 명시 바인딩 — 자동바인딩은 boolean 일 때만 checked 로 붙는다",
"props": {
"name": "notify_post_complete",
"checked": "{{!!_local.profileEditForm?.notify_post_complete}}",
"autoBinding": false,
"className": "w-5 h-5 mt-0.5 text-gray-900 dark:text-white bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-gray-900 dark:focus:ring-white"
}
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"profileEditForm.notify_post_complete": "{{$event.target.checked}}"
}
}
]
},
{
"type": "basic",
@@ -1221,10 +1234,23 @@
{
"type": "basic",
"name": "Checkbox",
"comment": "저장값이 boolean 이 아닐 수 있는 구간(설정 미저장 등)에도 checked 가 확정되도록 명시 바인딩 — 자동바인딩은 boolean 일 때만 checked 로 붙는다",
"props": {
"name": "notify_post_reply",
"checked": "{{!!_local.profileEditForm?.notify_post_reply}}",
"autoBinding": false,
"className": "w-5 h-5 mt-0.5 text-gray-900 dark:text-white bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-gray-900 dark:focus:ring-white"
}
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"profileEditForm.notify_post_reply": "{{$event.target.checked}}"
}
}
]
},
{
"type": "basic",
@@ -1260,10 +1286,23 @@
{
"type": "basic",
"name": "Checkbox",
"comment": "저장값이 boolean 이 아닐 수 있는 구간(설정 미저장 등)에도 checked 가 확정되도록 명시 바인딩 — 자동바인딩은 boolean 일 때만 checked 로 붙는다",
"props": {
"name": "notify_comment",
"checked": "{{!!_local.profileEditForm?.notify_comment}}",
"autoBinding": false,
"className": "w-5 h-5 mt-0.5 text-gray-900 dark:text-white bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-gray-900 dark:focus:ring-white"
}
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"profileEditForm.notify_comment": "{{$event.target.checked}}"
}
}
]
},
{
"type": "basic",
@@ -1299,10 +1338,23 @@
{
"type": "basic",
"name": "Checkbox",
"comment": "저장값이 boolean 이 아닐 수 있는 구간(설정 미저장 등)에도 checked 가 확정되도록 명시 바인딩 — 자동바인딩은 boolean 일 때만 checked 로 붙는다",
"props": {
"name": "notify_reply_comment",
"checked": "{{!!_local.profileEditForm?.notify_reply_comment}}",
"autoBinding": false,
"className": "w-5 h-5 mt-0.5 text-gray-900 dark:text-white bg-white dark:bg-gray-700 border-gray-300 dark:border-gray-600 rounded focus:ring-gray-900 dark:focus:ring-white"
}
},
"actions": [
{
"type": "change",
"handler": "setState",
"params": {
"target": "local",
"profileEditForm.notify_reply_comment": "{{$event.target.checked}}"
}
}
]
},
{
"type": "basic",
@@ -31,7 +31,7 @@
"id": "selectAll",
"name": "selectAll",
"checked": "{{_local.allSelected}}",
"className": "w-5 h-5 rounded border-gray-300 accent-gray-900 focus:ring-gray-500"
"className": "w-5 h-5 rounded border-gray-300 dark:border-gray-600 accent-gray-900 focus:ring-gray-500"
},
"actions": [
{
@@ -54,7 +54,8 @@
"type": "basic",
"name": "Span",
"props": { "className": "text-sm text-gray-500 dark:text-gray-400" },
"text": "({{_local.selectedCount}}/{{cartItems.data.items.length}})"
"comment": "선택 개수는 선택 상태의 SSoT 인 selectedItems 에서 파생한다 (주문 요약 _cart_summary.json 과 동일 표현식)",
"text": "({{_local.selectedItems?.length ?? 0}}/{{cartItems.data.item_count ?? 0}})"
}
]
},
@@ -66,7 +67,7 @@
"disabled": "{{!_local.selectedItems || _local.selectedItems.length === 0}}"
},
"children": [
{ "type": "basic", "name": "Icon", "props": { "name": "trash-can", "className": "w-4 h-4" } },
{ "type": "basic", "name": "Icon", "props": { "name": "trash-can", "className": "text-base" } },
{ "type": "basic", "name": "Span", "text": "$t:shop.cart.delete_selected" }
],
"actions": [
@@ -122,14 +123,14 @@
"if": "{{!cartItems.data.items || cartItems.data.items.length === 0}}",
"props": { "className": "text-center py-16" },
"children": [
{ "type": "basic", "name": "Icon", "props": { "name": "shopping-cart", "className": "w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" } },
{ "type": "basic", "name": "Icon", "props": { "name": "shopping-cart", "className": "text-6xl mx-auto mb-4 text-gray-300 dark:text-gray-600" } },
{ "type": "basic", "name": "P", "props": { "className": "text-gray-500 dark:text-gray-400 mb-4" }, "text": "$t:shop.cart.empty" },
{
"type": "basic",
"name": "Button",
"props": { "className": "inline-flex items-center gap-2 px-4 py-2 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg font-medium cursor-pointer" },
"children": [
{ "type": "basic", "name": "Icon", "props": { "name": "arrow-left", "className": "w-4 h-4" } },
{ "type": "basic", "name": "Icon", "props": { "name": "arrow-left", "className": "text-base" } },
{ "type": "basic", "name": "Span", "text": "$t:shop.cart.continue_shopping" }
],
"actions": [{ "type": "click", "handler": "navigate", "params": { "path": "{{_global.shopBase}}/products" } }]
@@ -345,7 +345,7 @@
"name": "Div",
"props": { "className": "flex justify-between items-start" },
"children": [
{ "type": "basic", "name": "Span", "props": { "className": "text-sm text-gray-700 dark:text-gray-300" }, "text": "{{$parent._local.matchedOption.option_name_localized ?? $parent._local.matchedOption.option_name?.ko ?? ''}}" },
{ "type": "basic", "name": "Span", "props": { "className": "text-sm text-gray-700 dark:text-gray-300" }, "text": "{{$parent._local.matchedOption?.option_name_localized ?? $parent._local.matchedOption?.option_name?.ko ?? ''}}" },
{
"type": "basic",
"name": "Div",
@@ -355,7 +355,7 @@
"type": "basic",
"name": "Div",
"props": { "className": "text-sm font-bold text-gray-900 dark:text-white" },
"text": "{{$parent._local.matchedOption.multi_currency_selling_price?.[_global.preferredCurrency ?? _global.defaultCurrency]?.formatted ?? $parent._local.matchedOption.selling_price_formatted}}"
"text": "{{$parent._local.matchedOption?.multi_currency_selling_price?.[_global.preferredCurrency ?? _global.defaultCurrency]?.formatted ?? $parent._local.matchedOption?.selling_price_formatted ?? ''}}"
},
{
"comment": "보조 통화들",
@@ -363,7 +363,7 @@
"name": "Div",
"props": { "className": "flex flex-wrap gap-x-2 justify-end" },
"iteration": {
"source": "{{Object.entries($parent._local.matchedOption.multi_currency_selling_price ?? {}).filter(([code]) => code !== (_global.preferredCurrency ?? _global.defaultCurrency))}}",
"source": "{{Object.entries($parent._local.matchedOption?.multi_currency_selling_price ?? {}).filter(([code]) => code !== (_global.preferredCurrency ?? _global.defaultCurrency))}}",
"item_var": "currency"
},
"children": [
@@ -393,16 +393,16 @@
{
"type": "basic",
"name": "Div",
"if": "{{$parent._local.matchedOption.stock_quantity <= 10}}",
"props": { "className": "{{$parent._local.matchedOption.stock_quantity <= 0 ? 'text-xs text-red-500 dark:text-red-400 mt-1 font-medium' : 'text-xs text-gray-500 dark:text-gray-400 mt-1'}}" },
"text": "{{$parent._local.matchedOption.stock_quantity <= 0 ? '$t:shop.cart.option_modal.out_of_stock' : '$t:shop.cart.option_modal.stock_count|count=' + $parent._local.matchedOption.stock_quantity}}"
"if": "{{$parent._local.matchedOption?.stock_quantity <= 10}}",
"props": { "className": "{{$parent._local.matchedOption?.stock_quantity <= 0 ? 'text-xs text-red-500 dark:text-red-400 mt-1 font-medium' : 'text-xs text-gray-500 dark:text-gray-400 mt-1'}}" },
"text": "{{$parent._local.matchedOption?.stock_quantity <= 0 ? '$t:shop.cart.option_modal.out_of_stock' : '$t:shop.cart.option_modal.stock_count|count=' + ($parent._local.matchedOption?.stock_quantity ?? 0)}}"
},
{
"type": "basic",
"name": "Div",
"if": "{{$parent._local.matchedOption.stock_quantity > 10}}",
"if": "{{$parent._local.matchedOption?.stock_quantity > 10}}",
"props": { "className": "text-xs text-gray-500 dark:text-gray-400 mt-1" },
"text": "$t:shop.cart.option_modal.stock_count|count={{$parent._local.matchedOption.stock_quantity}}"
"text": "$t:shop.cart.option_modal.stock_count|count={{$parent._local.matchedOption?.stock_quantity ?? 0}}"
}
]
},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sirsoft-basic",
"version": "1.1.0",
"version": "1.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sirsoft-basic",
"version": "1.1.0",
"version": "1.1.1",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -1,6 +1,6 @@
{
"name": "sirsoft-basic",
"version": "1.1.0",
"version": "1.1.1",
"description": "Gnuboard7 Basic User Template Components - Nexibase Style",
"type": "module",
"main": "dist/components.js",
@@ -9,8 +9,7 @@
* 4. auth_required가 아닌 auth_mode: "optional"이 사용되는가
* 5. 기존 403/404 핸들러가 유지되는가
*
* @scenario entry_point:board_list_401,board_show_401
* @effects redirect_preserves_pathname,login_returns_to_original_path
* @effects redirect_preserves_pathname, login_returns_to_original_path
*/
import { describe, it, expect } from 'vitest';
@@ -27,6 +26,10 @@ function getDataSourceErrorHandling(layout: any, dataSourceId: string) {
}
describe('게시판 401 errorHandling — sequence + redirect 방식 (이슈 #228 B-5)', () => {
/**
* @scenario entry_point=board_show_401
* @effects redirect_preserves_pathname, login_returns_to_original_path
*/
describe('show.json — auth_mode 및 데이터소스 401', () => {
// redirect: /board/{{route.slug}}/posts/{{route.id}}
const ds = getDataSource(showLayout, 'post');
@@ -89,6 +92,10 @@ describe('게시판 401 errorHandling — sequence + redirect 방식 (이슈 #22
});
});
/**
* @scenario entry_point=board_list_401
* @effects redirect_preserves_pathname, login_returns_to_original_path
*/
describe('index.json — auth_mode 및 데이터소스 401', () => {
const ds = getDataSource(indexLayout, 'posts');
const dsErrorHandling = ds?.errorHandling ?? null;
@@ -0,0 +1,418 @@
/**
* @file cart-select-count-initial.test.tsx
* @description 장바구니 전체 선택 개수 최초 로딩 표시 회귀 (공개 이슈 #92)
*
* 결함: 장바구니를 처음 열면 전체 선택 옆 개수가 `(/27)` 처럼 선택 개수 없이 나온다.
* 표시식이 `{{_local.selectedCount}}` 인데 `selectedCount` 는 체크박스 change 핸들러에서만
* 기록되므로 최초 마운트 시점에는 undefined 이고, 엔진은 undefined 를 빈 문자열로 서식한다
* (예외·콘솔 에러 없음 — 표시 전용 결함). 체크박스를 한 번 건드리면 정상화된다.
*
* 정정: 표시식을 선택 상태의 SSoT 인 `_local.selectedItems` 파생으로 교체한다.
* 같은 화면 주문 요약(`_cart_summary.json`)이 이미 쓰던 표현식과 동일 형태로 통일해
* 두 카운터의 계산 방식 비대칭도 함께 해소한다.
*
* 함께 검증(동일 근본 원인 — `null` 로만 선언된 키를 옵셔널 없이 하위 접근):
* - `_modal_cart_option_change.json` 재고 안내 (`matchedOption`)
* - `_edit.json` 프로필 오류 메시지 (`profileErrors` — 가드/표시식 옵셔널 엇갈림)
*
* @vitest-environment jsdom
*/
import React from 'react';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import {
createLayoutTest,
createMockComponentRegistryWithBasics,
screen,
} from '@core/template-engine/__tests__/utils/layoutTestUtils';
import cartListPartial from '../../../layouts/partials/shop/_cart_list.json';
import cartOptionChangeModal from '../../../layouts/partials/shop/_modal_cart_option_change.json';
import profileEditPartial from '../../../layouts/partials/mypage/profile/_edit.json';
// ========== 공통 유틸 ==========
/**
* JSON 트리를 순회하며 모든 노드에 콜백을 적용합니다.
*
* @param node 순회 시작 노드
* @param visit 노드 방문 콜백
*/
function walk(node: unknown, visit: (n: Record<string, unknown>) => void): void {
if (Array.isArray(node)) {
node.forEach((n) => walk(n, visit));
return;
}
if (!node || typeof node !== 'object') return;
const obj = node as Record<string, unknown>;
visit(obj);
Object.values(obj).forEach((v) => walk(v, visit));
}
/**
* JSON 트리에서 특정 문자열을 포함하는 문자열 값을 모두 수집합니다.
*
* @param node 순회 시작 노드
* @param needle 찾을 부분 문자열
* @returns 매칭된 문자열 값 목록
*/
function collectStringsContaining(node: unknown, needle: string): string[] {
const out: string[] = [];
walk(node, (obj) => {
for (const v of Object.values(obj)) {
if (typeof v === 'string' && v.includes(needle)) out.push(v);
}
});
return out;
}
/**
* 장바구니 데이터소스 mock 응답을 만듭니다.
*
* @param count 장바구니 상품 수
* @returns cart/query 응답 형태
*/
function cartResponse(count: number) {
const items = Array.from({ length: count }, (_, i) => ({
id: i + 1,
quantity: 1,
product: { name: `상품 ${i + 1}` },
product_option: { option_name: '기본', price: 1000 },
}));
return {
data: {
items,
item_ids: items.map((it) => it.id),
item_count: count,
calculation: null,
},
};
}
/** 실 파일(`_cart_list.json`)을 그대로 감싼 테스트용 래퍼 레이아웃 */
const wrapperLayout = {
version: '1.0.0',
layout_name: 'shop/cart#test-wrapper',
data_sources: [
{
id: 'cartItems',
type: 'api',
endpoint: '/api/modules/sirsoft-ecommerce/cart/query',
method: 'POST',
auto_fetch: true,
},
],
components: [cartListPartial],
};
/** 카운터 Span 의 현재 텍스트를 읽는다 (`(선택/전체)` 형태) */
function readCounterText(): string {
// `(3/3)` 정상, `(/3)` 결함 — 두 형태를 모두 잡는 패턴
const el = screen.getByText(/^\(\d*\/\d+\)$/);
return el.textContent ?? '';
}
let registry: ReturnType<typeof createMockComponentRegistryWithBasics>;
beforeEach(() => {
registry = createMockComponentRegistryWithBasics();
// basics 에 없는 컴포넌트 보강
registry.register('basic', 'Checkbox', ({ id, name, checked, className }: any) =>
React.createElement('input', {
type: 'checkbox',
id,
name,
checked: !!checked,
className,
readOnly: true,
})
);
registry.register('basic', 'Icon', ({ name, className }: any) =>
React.createElement('i', { 'data-icon': name, className })
);
});
// ========== 1. 렌더링 회귀 (이슈 #92 본체) ==========
describe('_cart_list.json — 전체 선택 개수 최초 표시 (#92)', () => {
const ITEM_COUNT = 3;
/**
* 최초 마운트 상태를 재현해 카운터 텍스트를 읽는다.
* `selectedCount` 는 의도적으로 주입하지 않는다 — 체크박스를 아직 건드리지 않은 상태.
*
* @param selectedItems 선택된 상품 id 목록
* @returns 렌더 유틸과 카운터 텍스트
*/
async function mountAndRead(selectedItems: number[]) {
const testUtils = createLayoutTest(wrapperLayout as any, {
componentRegistry: registry,
initialState: {
_local: {
selectedItems,
allSelected: selectedItems.length === ITEM_COUNT,
},
},
});
testUtils.mockApi('cartItems', { response: cartResponse(ITEM_COUNT) });
await testUtils.render();
const text = readCounterText();
return { testUtils, text };
}
/**
* @scenario mount_path=initial_load, selection=all
* @effects counter_matches_checked_state, counter_matches_summary_panel
*/
it('최초 로딩 + 전체 선택 상태에서 (3/3) 을 표시한다', async () => {
const { testUtils, text } = await mountAndRead([1, 2, 3]);
expect(text).toBe('(3/3)');
testUtils.cleanup();
});
/**
* @scenario mount_path=initial_load, selection=partial
* @effects counter_matches_checked_state
*/
it('최초 로딩 + 부분 선택 상태에서 (2/3) 을 표시한다', async () => {
const { testUtils, text } = await mountAndRead([1, 2]);
expect(text).toBe('(2/3)');
testUtils.cleanup();
});
/**
* @scenario mount_path=initial_load, selection=none
* @effects counter_matches_checked_state
*/
it('최초 로딩 + 미선택 상태에서 (0/3) 을 표시한다 (빈 문자열 금지)', async () => {
const { testUtils, text } = await mountAndRead([]);
expect(text).toBe('(0/3)');
testUtils.cleanup();
});
/**
* @scenario mount_path=reload, selection=all
* @effects counter_matches_checked_state, counter_matches_summary_panel
*/
it('새로고침(재마운트) 후에도 전체 선택 카운터가 (3/3) 이다', async () => {
const first = await mountAndRead([1, 2, 3]);
first.testUtils.cleanup();
const { testUtils, text } = await mountAndRead([1, 2, 3]);
expect(text).toBe('(3/3)');
testUtils.cleanup();
});
/**
* @scenario mount_path=reload, selection=partial
* @effects counter_matches_checked_state
*/
it('새로고침(재마운트) 후 부분 선택 카운터가 (2/3) 이다', async () => {
const first = await mountAndRead([1, 2]);
first.testUtils.cleanup();
const { testUtils, text } = await mountAndRead([1, 2]);
expect(text).toBe('(2/3)');
testUtils.cleanup();
});
/**
* @scenario mount_path=reload, selection=none
* @effects counter_matches_checked_state
*/
it('새로고침(재마운트) 후 미선택 카운터가 (0/3) 이다', async () => {
const first = await mountAndRead([]);
first.testUtils.cleanup();
const { testUtils, text } = await mountAndRead([]);
expect(text).toBe('(0/3)');
testUtils.cleanup();
});
/**
* @scenario mount_path=spa_return, selection=all
* @effects counter_matches_checked_state, counter_matches_summary_panel
*/
it('SPA 이동 후 복귀(마운트 해제 → 재마운트)에도 (3/3) 이다', async () => {
const away = await mountAndRead([1, 2, 3]);
away.testUtils.cleanup();
const { testUtils, text } = await mountAndRead([1, 2, 3]);
expect(text).toBe('(3/3)');
testUtils.cleanup();
});
/**
* @scenario mount_path=spa_return, selection=partial
* @effects counter_matches_checked_state
*/
it('SPA 복귀 + 부분 선택에서 (2/3) 이다', async () => {
const away = await mountAndRead([1, 2]);
away.testUtils.cleanup();
const { testUtils, text } = await mountAndRead([1, 2]);
expect(text).toBe('(2/3)');
testUtils.cleanup();
});
/**
* @scenario mount_path=spa_return, selection=none
* @effects counter_matches_checked_state
*/
it('SPA 복귀 + 미선택에서 (0/3) 이다', async () => {
const away = await mountAndRead([]);
away.testUtils.cleanup();
const { testUtils, text } = await mountAndRead([]);
expect(text).toBe('(0/3)');
testUtils.cleanup();
});
});
describe('_cart_list.json — 빈 장바구니 (T8 에지)', () => {
/**
* 브라우저 실측에서 이 축은 기존 장바구니를 통째로 비워야만 재현되므로,
* 데이터 파괴 없이 여기서 결정적으로 고정한다.
*
* @scenario mount_path=reload, selection=none
* @effects counter_matches_checked_state
*/
it('상품이 0건이면 전체 선택 카드 자체가 렌더되지 않는다', async () => {
const testUtils = createLayoutTest(wrapperLayout as any, {
componentRegistry: registry,
initialState: { _local: { selectedItems: [], allSelected: false } },
});
testUtils.mockApi('cartItems', { response: cartResponse(0) });
await testUtils.render();
// 감싸는 if 가 `items.length > 0` 이므로 카운터 Span 자체가 없다
expect(screen.queryByText(/^\(\d*\/\d+\)$/)).toBeNull();
testUtils.cleanup();
});
/**
* @scenario mount_path=spa_return, selection=none
* @effects counter_matches_checked_state
*/
it('분모·분자 모두 폴백이 있어 데이터가 비어도 빈 문자열이 되지 않는다', () => {
const counter = collectStringsContaining(cartListPartial, '/{{cartItems').find((t) =>
t.startsWith('(')
);
// 값이 없을 때 나오는 문자열을 실제로 계산해 본다 (엔진과 동일한 네이티브 평가)
const exprs = [...(counter ?? '').matchAll(/\{\{([\s\S]*?)\}\}/g)].map((m) => m[1]);
expect(exprs.length).toBe(2);
const rendered = exprs.map((e) =>
// eslint-disable-next-line no-new-func
new Function('_local', 'cartItems', `return (${e});`)({}, { data: {} })
);
expect(rendered).toEqual([0, 0]);
});
});
// ========== 2. 정적 회귀 가드 ==========
describe('_cart_list.json — 카운터 표시식 정적 가드 (#92)', () => {
const counterTexts = collectStringsContaining(cartListPartial, '/{{cartItems');
it('카운터 표시식이 존재한다', () => {
expect(counterTexts.length, '카운터 표시식을 찾지 못했다').toBeGreaterThan(0);
});
it('초기화되지 않는 _local.selectedCount 를 더 이상 참조하지 않는다', () => {
const json = JSON.stringify(cartListPartial);
expect(json).not.toContain('_local.selectedCount');
});
it('선택 개수는 SSoT(selectedItems) 파생 + 0 폴백으로 표시한다', () => {
const counter = counterTexts.find((t) => t.startsWith('('));
expect(counter, '`(선택/전체)` 형태의 카운터가 없다').toBeTruthy();
expect(counter).toContain('_local.selectedItems?.length ?? 0');
});
it('분모는 주문 요약과 동일한 item_count 폴백을 쓴다', () => {
const counter = counterTexts.find((t) => t.startsWith('('));
expect(counter).toContain('cartItems.data.item_count ?? 0');
});
});
describe('_modal_cart_option_change.json — 재고 표시 옵셔널 가드 (B-6/B-7)', () => {
const refs = collectStringsContaining(cartOptionChangeModal, 'matchedOption');
/**
* @scenario mount_path=initial_load, selection=all
* @effects modal_stock_display_guarded
*/
it('matchedOption 하위 접근은 전부 옵셔널 체이닝을 쓴다', () => {
expect(refs.length).toBeGreaterThan(0);
const unguarded = refs.filter((r) => /matchedOption\.[A-Za-z_]/.test(r));
expect(
unguarded,
`matchedOption 뒤에 '?.' 없는 역참조: ${unguarded.join(' | ')}`
).toEqual([]);
});
});
describe('_edit.json — 알림 수신 Checkbox 명시 바인딩', () => {
/** 게시판 알림 설정 체크박스 4종 */
const NOTIFY_FIELDS = [
'notify_post_complete',
'notify_post_reply',
'notify_comment',
'notify_reply_comment',
];
/** 이름으로 Checkbox 노드를 찾는다 */
function findCheckbox(name: string): any {
let found: any = null;
walk(profileEditPartial, (n) => {
if (n.name === 'Checkbox' && (n.props as any)?.name === name) found = n;
});
return found;
}
/**
* 자동바인딩은 저장값이 boolean 일 때만 checked 로 붙고, 아니면 빈 문자열을 보내
* 저장값을 null 로 고착시킨다. 값의 타입을 화면이 전제하지 않도록 명시 바인딩을 쓴다.
*
* 마운트 경로·선택 상태 축과 무관한 정적 계약이라 @scenario 는 달지 않는다
* (조합 커버리지는 위 9건이 이미 전수 담당한다).
*
* @effects profile_notification_checkbox_explicit_binding
*/
it.each(NOTIFY_FIELDS)('%s 가 autoBinding:false + checked + change 액션을 갖는다', (field) => {
const node = findCheckbox(field);
expect(node, `${field} Checkbox 를 찾지 못했다`).toBeTruthy();
expect(node.props.autoBinding).toBe(false);
expect(node.props.checked).toBe(`{{!!_local.profileEditForm?.${field}}}`);
const change = (node.actions ?? []).find((a: any) => a.type === 'change');
expect(change, `${field} 에 change 액션이 없다`).toBeTruthy();
expect(change.handler).toBe('setState');
expect(change.params.target).toBe('local');
expect(change.params[`profileEditForm.${field}`]).toBe('{{$event.target.checked}}');
});
});
describe('_edit.json — 프로필 오류 메시지 옵셔널 가드 (회색지대)', () => {
const refs = collectStringsContaining(profileEditPartial, 'profileErrors');
/**
* @scenario mount_path=initial_load, selection=none
* @effects profile_error_display_guarded
*/
it('profileErrors 하위 접근은 전부 옵셔널 체이닝을 쓴다', () => {
expect(refs.length).toBeGreaterThan(0);
const unguarded = refs.filter((r) => /profileErrors\.[A-Za-z_]/.test(r));
expect(
unguarded,
`profileErrors 뒤에 '?.' 없는 역참조: ${unguarded.join(' | ')}`
).toEqual([]);
});
/**
* @scenario mount_path=initial_load, selection=partial
* @effects profile_error_display_guarded
*/
it('오류 메시지 표시식은 배열 인덱스에도 옵셔널 + 빈 문자열 폴백을 쓴다', () => {
const messageTexts = refs.filter((r) => /profileErrors\??\.[A-Za-z_]+\??\.\[0\]/.test(r));
expect(messageTexts.length, '오류 메시지 표시식을 찾지 못했다').toBeGreaterThan(0);
for (const t of messageTexts) {
expect(t, `폴백 없는 오류 메시지 표시식: ${t}`).toContain("?? ''");
}
});
});
@@ -201,7 +201,7 @@ const cartLayoutFixture = {
type: 'basic',
name: 'Span',
props: { className: 'text-gray-500', 'data-testid': 'selected-count' },
text: '({{_local.selectedCount ?? 0}}/{{cartItems.data.items?.length ?? 0}})',
text: '({{_local.selectedItems?.length ?? 0}}/{{cartItems.data.item_count ?? 0}})',
},
],
},
@@ -605,7 +605,7 @@ describe('장바구니 페이지 레이아웃 렌더링', () => {
const testUtils = createLayoutTest(cartLayoutFixture, {
componentRegistry: registry,
initialState: {
_local: { selectedItems: [1, 2], allSelected: true, selectedCount: 2 },
_local: { selectedItems: [1, 2], allSelected: true },
},
});
@@ -643,7 +643,7 @@ describe('장바구니 페이지 레이아웃 렌더링', () => {
const testUtils = createLayoutTest(cartLayoutFixture, {
componentRegistry: registry,
initialState: {
_local: { selectedItems: [1], allSelected: false, selectedCount: 1 },
_local: { selectedItems: [1], allSelected: false },
},
});
@@ -680,7 +680,7 @@ describe('장바구니 페이지 레이아웃 렌더링', () => {
const testUtils = createLayoutTest(cartLayoutFixture, {
componentRegistry: registry,
initialState: {
_local: { selectedItems: [1, 2], allSelected: true, selectedCount: 2 },
_local: { selectedItems: [1, 2], allSelected: true },
},
});
@@ -717,7 +717,7 @@ describe('장바구니 페이지 레이아웃 렌더링', () => {
const testUtils = createLayoutTest(cartLayoutFixture, {
componentRegistry: registry,
initialState: {
_local: { selectedItems: [], allSelected: false, selectedCount: 0 },
_local: { selectedItems: [], allSelected: false },
},
});
@@ -117,8 +117,14 @@ describe('cartHandlers', () => {
}));
});
// #131: 선택 변경 후 selectedCount 업데이트
it('#131 선택 변경 후 selectedCount가 업데이트된다', () => {
/**
* #131: 선택 개수는 selectedItems 파생 — 별도 카운터를 기록하지 않는다 (공개 이슈 #92).
* 화면 카운터가 파생해 쓰는 SSoT 를 핸들러가 실제로 갱신하는지 고정한다.
*
* @scenario mount_path=initial_load, selection=partial
* @effects counter_matches_checked_state
*/
it('#131 선택 변경 시 selectedItems 만 갱신하고 selectedCount 는 기록하지 않는다', () => {
// Given
mockG7Core.state.get.mockReturnValue({
cartItems: {
@@ -137,10 +143,14 @@ describe('cartHandlers', () => {
// When
toggleCartItemSelectionHandler(action);
// Then
expect(mockG7Core.state.setLocal).toHaveBeenCalledWith(expect.objectContaining({
selectedCount: 1,
}));
// Then: 선택 목록이 SSoT — 화면 카운터는 여기서 파생한다
expect(mockG7Core.state.setLocal).toHaveBeenCalledWith({
selectedItems: [2],
allSelected: false,
});
// 파생 가능한 값을 별도 키로 중복 저장하면 최초 마운트 구간에서 비어 보인다 (#92)
const payload = mockG7Core.state.setLocal.mock.calls[0][0];
expect(payload).not.toHaveProperty('selectedCount');
});
});
@@ -162,7 +172,6 @@ describe('cartHandlers', () => {
expect(mockG7Core.state.setLocal).toHaveBeenCalledWith({
selectedItems: [],
allSelected: false,
selectedCount: 0,
});
});
@@ -183,7 +192,6 @@ describe('cartHandlers', () => {
expect(mockG7Core.state.setLocal).toHaveBeenCalledWith({
selectedItems: [1, 2, 3],
allSelected: true,
selectedCount: 3,
});
});
});
@@ -6,8 +6,10 @@
* G7Core.dispatch 로 navigate 를 발화한다. errorHandling context 에 route/query 가 없는
* 제약을 우회하는 핵심 동작이므로 실제 호출 인자를 단언한다(거짓 통과 방지).
*
* @scenario has_query:with_query,without_query
* @effects redirect_preserves_pathname,redirect_preserves_query_string
* 이 핸들러는 세 진입점이 공유하므로 특정 entry_point 축에 매이지 않는다 —
* query 유무(with/without) 양쪽을 여기서 한 번 실측하고 effects 로만 마킹한다.
*
* @effects redirect_preserves_pathname, redirect_preserves_query_string
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
@@ -126,10 +126,11 @@ export function toggleCartItemSelectionHandler(
const allItems = globalState.cartItems?.data?.items || [];
const allSelected = allItems.length > 0 && newSelected.length === allItems.length;
// 선택 개수는 selectedItems 에서 파생해 표시하므로 별도 카운터를 기록하지 않는다.
// (카운터를 따로 두면 최초 마운트처럼 핸들러가 아직 실행되지 않은 구간에서 값이 비어 있다 — 공개 이슈 #92)
setLocalState({
selectedItems: newSelected,
allSelected,
selectedCount: newSelected.length,
});
// 선택 변경 후 계산 결과 갱신을 위해 데이터소스 재조회
@@ -151,10 +152,10 @@ export function selectAllCartItemsHandler(
): void {
const { allItemIds, isSelected } = action?.params || {};
// 선택 개수는 selectedItems 파생 (별도 카운터 미기록 — 공개 이슈 #92)
setLocalState({
selectedItems: isSelected ? allItemIds : [],
allSelected: isSelected,
selectedCount: isSelected ? allItemIds.length : 0,
});
// 선택 변경 후 계산 결과 갱신을 위해 데이터소스 재조회
@@ -5,7 +5,7 @@
"ko": "Basic",
"en": "Basic"
},
"version": "1.1.0",
"version": "1.1.1",
"license": "MIT",
"description": {
"ko": "그누보드7 기본 사용자 템플릿",
@@ -11,9 +11,15 @@ description: |
- 비밀글 로그인 버튼이 미설정 {{_global.currentPath}} 를 redirect 로 써 항상 빈 값 → 동일 핸들러로 교체.
- 로그인 폼은 기존 규약 {{query.redirect ?? '/'}} 로 복귀(변경 없음).
# axis 의미:
# 갈라지는 축은 진입 지점 하나다 — 세 지점 각각이 redirectToLoginWithReturn 을 실제로
# 물고 있는지가 화면마다 달라진다.
# 원경로 query 유무는 그 축과 교차하지 않는다. 세 진입점이 같은 핸들러 하나로 수렴하고,
# query 보존은 그 핸들러가 window.location 을 읽는 방식이 정하므로 진입점과 무관하다.
# 따라서 3×2 조합이 아니라 (진입점 3) + (핸들러 성질 1) 이며, query 보존은
# redirect_preserves_query_string effect 로 핸들러 단위테스트가 with/without 양쪽을 실측한다.
axes:
entry_point: [board_list_401, board_show_401, secret_post_login_button] # 진입 지점 3종
has_query: [with_query, without_query] # 원경로 query 유무
effects:
- redirect_preserves_pathname # /login?redirect=<pathname>
@@ -30,10 +36,12 @@ validation:
audit_rule: frontend-change-requires-e2e
browser_check: Chrome MCP # Playwright 인프라 미구축 — Chrome MCP 정밀 점검으로 브라우저 검증 대체
note: |
test-scenario-coverage 는 axes cross-product 와 effects 가 테스트 docblock @scenario/@effects 로
커버되길 요구한다. Playwright spec 부재이므로 위 단위/레이아웃 테스트 docblock 에 @scenario(axis 키)
/@effects 를 마킹해 cross-product·effects 를 커버한다.
- 핸들러 단위테스트: has_query(with/without) + redirect_preserves_pathname/query_string
- 401 레이아웃 테스트: entry_point(board_list_401,board_show_401) + redirect_preserves_pathname/login_returns_to_original_path
- 비밀글 레이아웃 테스트: entry_point(secret_post_login_button) + secret_button_redirect_not_empty/login_returns_to_original_path
test-scenario-coverage 는 axes cross product 와 effects 가 테스트 docblock 의
`@scenario k=v` / `@effects e1, e2` 마킹으로 커버되길 요구한다 (구분자는 `=` 와 `,` 다 —
`k:v` 로 적으면 파서가 조합을 읽지 못해 아무것도 커버하지 못한 채 조용히 통과한다).
Playwright spec 부재이므로 아래 단위/레이아웃 테스트 docblock 이 그 역할을 대신한다.
- 401 레이아웃 테스트: describe 별로 entry_point=board_show_401 / board_list_401 분리 마킹
- 비밀글 레이아웃 테스트: entry_point=secret_post_login_button
- 핸들러 단위테스트: 특정 진입점에 매이지 않으므로 @effects 만 —
redirect_preserves_pathname / redirect_preserves_query_string 을 with/without 양쪽 실측
sirsoft-basic 템플릿 Playwright E2E 인프라 구축은 별도 후속 과제(구축 후 정식 spec 으로 이관).
@@ -0,0 +1,53 @@
feature: 장바구니 전체 선택 개수 표시 (선택 상태 파생 카운터)
description: |
장바구니 상단 "전체 선택" 옆 개수는 선택 상태의 SSoT 인 `_local.selectedItems` 에서 파생해
`(선택/전체)` 로 표시한다. 어떤 마운트 경로(최초 진입 / 새로고침 / SPA 복귀)에서도, 어떤 선택
상태(전체 / 부분 / 미선택)에서도 값이 비지 않고 체크박스·주문 요약과 일치해야 한다.
수정 이력 (공개 이슈 #92):
- 표시식이 `{{_local.selectedCount}}` 였다. `selectedCount` 는 체크박스 change 핸들러에서만
기록되므로 최초 마운트 구간에서는 undefined 이고, 엔진이 undefined 를 빈 문자열로 서식해
`(/27)` 로 보였다. 예외·콘솔 에러가 없어 표시로만 드러나는 결함이었다.
- 표시식을 `{{_local.selectedItems?.length ?? 0}}/{{cartItems.data.item_count ?? 0}}` 로 교체.
같은 화면 주문 요약(`_cart_summary.json`)이 쓰던 표현식과 동일 형태여서 두 카운터의
계산 방식 비대칭도 함께 해소했다.
- 핸들러(`cartHandlers.ts`)의 `selectedCount` 기록은 소비처가 사라져 제거했다.
같은 결함 클래스(초기값 없는 키를 옵셔널 없이 하위 접근)의 방어 수정도 이 시나리오가 함께 고정한다:
- 장바구니 옵션변경 모달 재고 안내(`matchedOption`)
- 마이페이지 프로필 오류 메시지(`profileErrors` — 가드와 표시식의 옵셔널 엇갈림)
- 마이페이지 알림 수신 체크박스 4종 — 값의 타입(boolean)을 화면이 전제하지 않도록
자동바인딩 대신 `autoBinding:false` + `checked` + `change` 명시 바인딩으로 전환
axes:
mount_path: [initial_load, reload, spa_return] # 진입 / 새로고침 / SPA 이동 후 복귀
selection: [all, partial, none] # 선택 상태 3종
effects:
- counter_matches_checked_state # 카운터 선택 수 = 실제 체크된 항목 수 (빈 문자열 금지)
- counter_matches_summary_panel # 카운터 = 주문 요약 "선택 상품 N / M개" 와 동일 값
- modal_stock_display_guarded # 옵션변경 모달 재고 표기가 값 부재 구간에서 공백/NaN 이 아님
- profile_error_display_guarded # 프로필 오류 메시지 표시식이 가드 소실 시에도 안전
- profile_notification_checkbox_explicit_binding # 알림 수신 체크박스가 저장값 타입에 무관하게 checked 를 확정 (자동바인딩 미의존)
test_files:
- templates/_bundled/sirsoft-basic/src/__tests__/layouts/cart-select-count-initial.test.tsx
- templates/_bundled/sirsoft-basic/src/handlers/__tests__/cartHandlers.test.ts
validation:
audit_rule: frontend-change-requires-e2e
browser_check: Chrome MCP # sirsoft-basic Playwright E2E 인프라 미구축 — Chrome MCP 정밀 점검으로 대체
note: |
test-scenario-coverage 는 axes cross product(3×3=9)와 effects 를 테스트 docblock
`@scenario k=v, k=v` / `@effects e1, e2` 마킹으로 커버하길 요구한다. Playwright spec 이
없으므로 레이아웃 렌더링 테스트의 docblock 으로 9개 조합을 전수 마킹했다.
마운트 경로 축의 한계: jsdom 렌더링 테스트에서 initial_load / reload / spa_return 은 모두
"초기 _local 시드 상태로부터의 새 마운트" 라는 같은 코드 경로다. 세 경로가 실제로 갈라지는
지점(브라우저 새로고침, SPA 라우팅 복귀 시 데이터소스 재조회)의 구분은 Chrome MCP 정밀 점검
매트릭스 T1/T6/T7 이 실측으로 담당한다. 단위 계층은 "어느 마운트에서도 카운터가 비지 않는다"
를 고정하는 역할이다.
counter_matches_summary_panel 은 두 카운터가 동일 표현식(selectedItems 파생 + item_count 분모)
을 쓰는지로 판정한다 — 표현식이 같으면 값이 갈릴 수 없다.