chore(release): 공개 소스 본문 내부 단서(· 호칭) 정리
공개 release 발행 시 파일 본문 누출 검사(check-public-release-leakage)에 걸리던 내부 단서를 정리해 발행을 차단하지 않도록 함. - 코드/테스트 주석·문서 본문의 "" 규정 인용을 "코어 규정/규칙" 등 중립 표현으로 치환 (코어·이커머스·board·kginicis·gdpr·pay·템플릿 전 영역) - 내부 역할 호칭 ""/""를 문맥에 맞는 중립 표현으로 일반화 - config/app.php 의 보호경로 목록(G7_UPDATE_PROTECTED_PATHS) 내 파일명은 기능 코드이므로 삭제 대신 검사기 화이트리스트에 등록 주석/설명 문자열/문서 본문만 변경 — 로직·데이터·동작 무변경. 검증: 누출 재검사 error 0 / warn 0, 영향 테스트 TS 140/140·PHP 113/113 통과.
This commit is contained in:
@@ -187,7 +187,7 @@ class AdminTemplateAssetController extends AdminBaseController
|
||||
/**
|
||||
* 편집기 프리뷰 전용 CSS 서빙 — 다크 셀렉터를 프리뷰 마커로 치환.
|
||||
*
|
||||
* PO admin 환경이 다크 테마면 `<html class="dark">` 가 조상으로 남아, 프리뷰
|
||||
* 관리자 admin 환경이 다크 테마면 `<html class="dark">` 가 조상으로 남아, 프리뷰
|
||||
* 프레임에서 `.dark` 를 빼도(라이트 토글) Tailwind `.dark &` 가 활성돼 프리뷰가
|
||||
* 라이트로 격리되지 않는다. 이를 해소하기 위해, 편집기 진입 시에만 components.css 의
|
||||
* 다크 조상 셀렉터(`.dark`)를 프리뷰 전용 마커(`.g7le-preview-dark`)로 치환해 서빙한다.
|
||||
|
||||
@@ -12,7 +12,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
* 레이아웃 편집 중 업로드되는 파일(배경 이미지 등)을 표현한다. 파일 자체는
|
||||
* 코어 StorageInterface 로 저장되고 본 모델은 저장 위치(disk/path)와 메타데이터를
|
||||
* 보관한다. 템플릿 삭제 시 행은 cascadeOnDelete 로 삭제되나, 스토리지 파일
|
||||
* 실삭제는 Service 가 명시적으로 수행한다(CLAUDE.md "DB CASCADE 의존 삭제 금지").
|
||||
* 실삭제는 Service 가 명시적으로 수행한다(코어 규정: DB CASCADE 의존 삭제 금지).
|
||||
*
|
||||
* @property int $id 첨부 파일 ID
|
||||
* @property int $template_id 소속 템플릿 ID
|
||||
|
||||
@@ -11,7 +11,7 @@ use App\Contracts\Extension\PluginManagerInterface;
|
||||
* 데이터소스 종류=websocket 편집 시 채널을 "구독 가능한 목록 기준 검색 드롭다운"으로
|
||||
* 제시하기 위해, "지금 이 설치본에서 구독 가능한 채널"을 런타임 수집한다. 코어/확장이
|
||||
* `getChannels()` 오버라이드로 등록한 채널명을 출처(core/module/plugin)와 함께 모은다
|
||||
* (CLAUDE.md 채널 프리픽스 규약 — 코어 `core.*`, 모듈 `module.{id}.*`, 플러그인
|
||||
* (코어 채널 프리픽스 규약 — 코어 `core.*`, 모듈 `module.{id}.*`, 플러그인
|
||||
* `plugin.{id}.*`).
|
||||
*
|
||||
* 이벤트는 `HookManager::broadcast($channel, $eventName, $payload)` 호출 시점에 이름이
|
||||
|
||||
@@ -18,7 +18,7 @@ use Illuminate\Support\Str;
|
||||
* 레이아웃 편집 중 업로드되는 파일(배경 이미지 등)의 업로드·조회·삭제를 처리한다.
|
||||
* 파일 저장은 코어 StorageInterface(CoreStorageDriver)를 통해서만 수행하고,
|
||||
* 저장 위치(disk/path)와 메타데이터를 template_layout_attachments 에 기록한다.
|
||||
* (Storage::disk() 직접 호출 금지 — CLAUDE.md 스토리지 규칙)
|
||||
* (Storage::disk() 직접 호출 금지 — 코어 스토리지 규칙)
|
||||
*/
|
||||
class TemplateLayoutAttachmentService
|
||||
{
|
||||
@@ -114,7 +114,7 @@ class TemplateLayoutAttachmentService
|
||||
* 첨부 파일을 삭제합니다 — 스토리지 파일 실삭제 후 DB 행 삭제.
|
||||
*
|
||||
* DB CASCADE 에 의존하지 않고 스토리지 파일을 명시적으로 삭제한다
|
||||
* (CLAUDE.md "DB CASCADE 의존 삭제 금지" — 파일 정리 보장).
|
||||
* (코어 규정: DB CASCADE 의존 삭제 금지 — 파일 정리 보장).
|
||||
*
|
||||
* @param TemplateLayoutAttachment $attachment 삭제할 첨부 파일
|
||||
* @return bool 삭제 성공 여부
|
||||
|
||||
@@ -136,7 +136,7 @@ editor-spec.json 이 커지면(코어 admin 템플릿은 단일 파일 18,000줄
|
||||
|
||||
### actionRecipes — 친화 명칭 → 핸들러 JSON
|
||||
|
||||
`params`(친화 입력란) + `build`(핸들러 JSON 생성 틀)로 구성한다. `build` 값의 `{{paramKey}}`(중괄호 2개)가 입력값으로 치환된다. `onSuccess`/`onError` 가 `{{key}}` 이고 그 파라미터가 `action-list` 면 치환 결과는 중첩 액션 **배열**이 된다. 핸들러명/파라미터 키는 사용자에게 노출하지 않는다 — 사용자는 `label` 친화 명칭만 본다. `build.handler` 는 CLAUDE.md CRITICAL RULES 의 올바른 핸들러(`navigate`/`apiCall`/`setState`/...)를 쓴다. `comment` 키는 레시피가 아닌 작성자 메모다.
|
||||
`params`(친화 입력란) + `build`(핸들러 JSON 생성 틀)로 구성한다. `build` 값의 `{{paramKey}}`(중괄호 2개)가 입력값으로 치환된다. `onSuccess`/`onError` 가 `{{key}}` 이고 그 파라미터가 `action-list` 면 치환 결과는 중첩 액션 **배열**이 된다. 핸들러명/파라미터 키는 사용자에게 노출하지 않는다 — 사용자는 `label` 친화 명칭만 본다. `build.handler` 는 코어 핸들러 규칙의 올바른 핸들러(`navigate`/`apiCall`/`setState`/...)를 쓴다. `comment` 키는 레시피가 아닌 작성자 메모다.
|
||||
|
||||
`build.handler` 는 리터럴 핸들러명 대신 `{{paramKey}}` **placeholder** 도 가능하다 — 호출할 핸들러를 응답값 등 데이터로 결정하는 provider-agnostic 액션(예: ecommerce 모듈 `requestPgPayment` "결제 진입" — 핸들러를 `{{response.data.pg_payment_handler}}` 데이터 칩으로 연결)에 쓴다. 결제 같은 **도메인 동작은 코어가 아니라 그 도메인 확장(모듈/플러그인/템플릿)이 자기 editor-spec `actionRecipes` 에 소유**한다 — 코어는 PG/도메인을 모른다. 확장 레시피는 로더가 `__source` 메타를 붙여 〔확장식별자〕 배지·'extension' 그룹으로 자동 분류한다. 라벨은 그 확장의 lang 네임스페이스를 명시한다(예: `$t:sirsoft-ecommerce.editor.action.request_pg_payment.label`).
|
||||
|
||||
|
||||
@@ -219,7 +219,7 @@ test('unauthenticated_401 × no_token', async ({ page }) => {
|
||||
|
||||
`audit-branch.cjs` 의 `test-scenario-coverage` 룰이 매니페스트 axes ↔ docblock 매칭을 자동 검증.
|
||||
|
||||
## §8. 회귀 테스트 4단계 (CLAUDE.md 의무 절차 준수)
|
||||
## §8. 회귀 테스트 4단계
|
||||
|
||||
버그 수정 시 다음 4단계를 스킵 불가:
|
||||
|
||||
@@ -236,7 +236,7 @@ E2E spec 작성 중 발견한 무관 에러는 같은 세션에서 처리:
|
||||
|
||||
- **테스트 stale** (logic 정상, 테스트가 오래됨) → 테스트 수정
|
||||
- **로직 회귀** (테스트 정상, 로직이 의도와 어긋남) → 코드 수정
|
||||
- **데이터 구조 불일치** (양쪽 모두 오래됨) → PO 보고 + 보류
|
||||
- **데이터 구조 불일치** (양쪽 모두 오래됨) → 보고 + 보류
|
||||
|
||||
상세: `testing-guide.md` "무관 에러 처리 분기".
|
||||
|
||||
|
||||
+2
-2
@@ -968,7 +968,7 @@ describe('_tab_report_policy.json - 신고 정책 탭', () => {
|
||||
it('admin-card 안 4 섹션 description 이 .card-description 시맨틱이고 card-title 의 직계 sibling 이다 (#408)', () => {
|
||||
// _tab_report_policy.json 의 4 섹션 (자동 숨김, abuse_prevention, permissions, notification)
|
||||
// description 이 stack 안에 묻혀있으면 .card-title:has(+ .card-description) 셀렉터 불발 →
|
||||
// card-title 의 하단 여백이 의도(mb-1)와 다르게 mb-6 으로 적용됨 (PO 보고 #408)
|
||||
// card-title 의 하단 여백이 의도(mb-1)와 다르게 mb-6 으로 적용됨 (보고 #408)
|
||||
//
|
||||
// 기대: admin-card 직계 자식 순서가 [card-title, card-description, stack, ...] 이고,
|
||||
// card-description 의 className 에 'card-description' 시맨틱 자산이 포함되어야 함.
|
||||
@@ -1045,7 +1045,7 @@ describe('_tab_report_policy.json - 신고 정책 탭', () => {
|
||||
});
|
||||
|
||||
it('board layout 의 모든 .card-description 은 Div 태그여야 한다 — Span/P 금지 (#408)', () => {
|
||||
// PO 표준: span.card-description / p.card-description 는 잘못된 시맨틱 HTML
|
||||
// 표준: span.card-description / p.card-description 는 잘못된 시맨틱 HTML
|
||||
// → div.card-description 만 허용.
|
||||
const layouts: Array<[string, any]> = [
|
||||
['mainLayout', mainLayout],
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
* 게시글 삭제로 함께 숨겨진(cascade) 댓글이 사용자 직접 삭제 댓글과 동일하게
|
||||
* "삭제된 댓글입니다" 로 마스킹되던 회귀의 방지.
|
||||
*
|
||||
* 정책(PO 확정): 관리 권한자에게 cascade 댓글은 마스킹 없이 원문을 노출한다.
|
||||
* 정책(확정): 관리 권한자에게 cascade 댓글은 마스킹 없이 원문을 노출한다.
|
||||
* 백엔드(CommentResource)가 is_cascade_deleted 플래그를 내려주고,
|
||||
* 레이아웃은 그 플래그로 분기를 결정한다.
|
||||
*
|
||||
|
||||
+1
-1
@@ -265,7 +265,7 @@ describe('admin_dashboard_community.json - community 슬롯 주입', () => {
|
||||
// 카드는 Pending/Review 신고만 표시한다 (ReportRepository::getPendingAcrossBoards).
|
||||
// 배지는 신고 대상(게시글/댓글)의 상태가 아니라 신고 건 자체의 처리상태(status)를 보여준다.
|
||||
// 색상은 신고현황 페이지(admin_board_reports_index 의 status 컬럼) 와 동일한 매핑을 사용한다.
|
||||
// PO 시각 검증 피드백: "대상 상태가 아닌 처리상태가 보여져야 한다" + "처리상태는 게시판 신고현황과 동일한 색상으로".
|
||||
// 시각 검증 피드백: "대상 상태가 아닌 처리상태가 보여져야 한다" + "처리상태는 게시판 신고현황과 동일한 색상으로".
|
||||
const statusBadge = findById(communityExt as any, 'report_status_badge');
|
||||
expect(statusBadge).not.toBeNull();
|
||||
expect(statusBadge.if).toBe('{{!!report?.status_label}}');
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
* @file board-copy-modal-info.test.tsx
|
||||
* @description 게시판 복사 확인 모달 - 안내 박스(배지 박스 + 불릿 3항목) 레이아웃 JSON 구조 검증
|
||||
*
|
||||
* 이슈 #413-16 후속(PO 요청): 복사 모달 안내를 "설정·권한·관리자 복사 / 게시글·댓글 제외 /
|
||||
* 이슈 #413-16 후속(요청): 복사 모달 안내를 "설정·권한·관리자 복사 / 게시글·댓글 제외 /
|
||||
* 새 슬러그 확인" 3항목으로 구조화. 안내 박스가 테두리+배경 컨테이너 안에 불릿 3개를
|
||||
* 포함하는지 JSON 트리로 검증한다 (DOM 비의존 구조 검증).
|
||||
*/
|
||||
|
||||
@@ -216,7 +216,7 @@ class CommentRepository implements CommentRepositoryInterface
|
||||
* 재귀적으로 모든 하위 자손 수를 계산합니다.
|
||||
*
|
||||
* tombstone(삭제) 댓글은 트리 구조 유지를 위해 노드로는 남기지만,
|
||||
* "답글 N" 집계 모수에서는 제외한다(PO 확정). 단, 삭제 댓글의
|
||||
* "답글 N" 집계 모수에서는 제외한다(확정 정책). 단, 삭제 댓글의
|
||||
* 살아있는 하위 자손은 계속 카운트해 누락되지 않게 한다.
|
||||
*
|
||||
* @param Collection $grouped parent_id로 그룹화된 댓글 컬렉션
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ use Modules\Sirsoft\Board\Tests\BoardTestCase;
|
||||
* 증상2 — cascade 로 함께 숨겨진 댓글이 user 선삭제 댓글과 동일하게
|
||||
* "삭제된 댓글입니다" 로 마스킹됨 (cascade/user 구분 신호 부재)
|
||||
*
|
||||
* 정책(PO 확정):
|
||||
* 정책(확정):
|
||||
* - 첨부: 관리 권한자에게는 댓글과 동일하게 cascade 삭제분도 노출
|
||||
* - cascade 댓글: 관리 권한자에게는 마스킹 없이 원문 노출 (is_cascade_deleted 신호 제공)
|
||||
* - user 선삭제분: 기존대로 마스킹 유지
|
||||
|
||||
@@ -132,7 +132,7 @@ class PostDeleteCascadeTest extends BoardTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시글 복원 시 cascade 항목만 복원되고, 사용자가 먼저 지운 것은 trashed 로 유지된다 (PO 우려 차단).
|
||||
* 게시글 복원 시 cascade 항목만 복원되고, 사용자가 먼저 지운 것은 trashed 로 유지된다 (우려 차단).
|
||||
*
|
||||
* @effects cascaded_comment_restored_on_post_restore,cascaded_attachment_restored_on_post_restore,user_pre_deleted_comment_stays_trashed,user_pre_deleted_attachment_stays_trashed
|
||||
*/
|
||||
|
||||
@@ -16,7 +16,7 @@ use PHPUnit\Framework\Attributes\Test;
|
||||
*
|
||||
* 게시판 내부 검색은 별도 엔드포인트가 아니라 목록 조회(index)에 search 필터를 얹은 것이다.
|
||||
* 비밀글(is_secret)·블라인드(status=blinded) 게시글은 목록/검색 응답에서 본문 요약이
|
||||
* 권한과 무관하게 빈 문자열로 차단되어야 한다 (PO 확정: 목록 미리보기는 권한자도 숨김).
|
||||
* 권한과 무관하게 빈 문자열로 차단되어야 한다 (확정 정책: 목록 미리보기는 권한자도 숨김).
|
||||
*
|
||||
* 제목은 현행 유지(노출), 차단 대상은 content_preview 뿐.
|
||||
* 상세 본문(content) 권한자 노출은 기존 정책(PostBlindedAccessControlTest)이 보장 — 본 테스트 범위 밖.
|
||||
@@ -237,7 +237,7 @@ class PostSearchSecretPreviewTest extends BoardTestCase
|
||||
|
||||
/**
|
||||
* 비밀글 미리보기는 작성자 본인에게도 목록에서는 차단된다(빈 문자열).
|
||||
* (PO 확정: 목록 미리보기는 권한자도 숨김 — 본문은 상세에서만 노출)
|
||||
* (확정 정책: 목록 미리보기는 권한자도 숨김 — 본문은 상세에서만 노출)
|
||||
*
|
||||
* @scenario viewer=author
|
||||
*
|
||||
|
||||
@@ -183,7 +183,7 @@ class BoardDashboardServiceTest extends ModuleTestCase
|
||||
// FormatsBoardDate 표준형 규칙:
|
||||
// - 24시간 이상 차이: 올해는 'MM-DD', 작년 이전은 'YY-MM-DD'
|
||||
// 캡션이 게시판 날짜 표시 규칙과 일관되어야 한다는 회귀 가드.
|
||||
// (게시글 created_at 표시와 동일 컨벤션 — PO 시각 검증 피드백)
|
||||
// (게시글 created_at 표시와 동일 컨벤션 — 시각 검증 피드백)
|
||||
$today = CarbonImmutable::today();
|
||||
// 어제 자정에 마지막 스케줄러가 돈 상황을 시뮬레이션
|
||||
$yesterdayBoundary = $today->subDay()->setTime(0, 0, 0);
|
||||
@@ -223,7 +223,7 @@ class BoardDashboardServiceTest extends ModuleTestCase
|
||||
public function test_get_recent_posts_excludes_replies(): void
|
||||
{
|
||||
// 대시보드 "최신 게시글" 카드는 본문 글만 보여줘야 한다 (답글 Re: 제외).
|
||||
// PO 시각 검증 피드백: "최신 게시글이 최신순으로 안 나오는 것 같다" — 원인은 답글이 섞여 표시된 것.
|
||||
// 시각 검증 피드백: "최신 게시글이 최신순으로 안 나오는 것 같다" — 원인은 답글이 섞여 표시된 것.
|
||||
// PostRepository::getRecentAcrossBoards 가 whereNull('parent_id') 누락했던 회귀 가드.
|
||||
$parent = $this->makePost('원글');
|
||||
$reply = $this->makePost('Re: 원글');
|
||||
|
||||
@@ -17,7 +17,7 @@ description: |
|
||||
(composite/ImageGallery.tsx 의 downloadAuthenticatedFile 과 동일 패턴).
|
||||
· 레이아웃 카드 3곳 전환: 사용자 _post_attachments(권한 분기 보존),
|
||||
관리자 _post_card_content / _reply_card_content(무분기) 의 <A href> → Div + click 핸들러.
|
||||
- PO 정책 확정:
|
||||
- 정책 확정:
|
||||
· 범위 = 게시판만 3곳 (페이지 첨부는 별도). 비회원 = NULL 유지(현행). 성공 토스트 없음.
|
||||
· 서버(라우트/컨트롤러/서비스) 무변경 — optional.sanctum 유지가 비회원 NULL 정책과 정합.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ description: |
|
||||
canViewBlindedContent(관리자 OR 작성자 본인) 게이트 추가, 권한 없으면 null
|
||||
· CommentResource::getFilteredContent — 동일 결함 → 동일 게이트 추가
|
||||
· PostResource::toListArray — 블라인드 글 content_preview 를 빈 문자열로 차단
|
||||
- PO 정책 확정 (일반 관례): 원문 = 관리자 + 작성자 본인. 안내/사유/제목은 전원 노출.
|
||||
- 정책 확정 (일반 관례): 원문 = 관리자 + 작성자 본인. 안내/사유/제목은 전원 노출.
|
||||
- 비밀글(canViewSecretContent)과 달리 비밀번호 검증·posts.read-secret 은 인정하지 않음
|
||||
(블라인드는 운영 제재이므로 비밀번호로 해제되지 않음).
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ description: |
|
||||
identifier 는 stripBoardScopeRoles 로 제거 (attachRoles 의 전역 whereIn 으로 인한 교차 누수 차단)
|
||||
· 새 게시판 manager/step 역할은 생성 시 injectBoardRolesToPermissions 가 자동 주입
|
||||
· 비-스코프 역할(member 등) 은 그대로 보존
|
||||
- PO 정책 확정: 복제는 원본 manager/step 명단을 승계 (순수 생성 모드는 본인 단독)
|
||||
- 정책 확정: 복제는 원본 manager/step 명단을 승계 (순수 생성 모드는 본인 단독)
|
||||
- role 조회 로직은 BoardResource::getBoardRoleData 를 public static 으로 공유 (SSoT)
|
||||
|
||||
axes:
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ description: |
|
||||
고아 자식의 조상 체인을 따라 누락된 삭제 부모를 일괄 IN 조회(N+1 회피)로
|
||||
tombstone 노드 복구. 자식 없는 삭제 부모는 고아 자식이 없어 자연 제외.
|
||||
· countDescendants() 보정 — tombstone(삭제) 댓글은 "답글 N" 집계 모수에서 제외
|
||||
(트리 노드로는 유지, 살아있는 자손은 계속 카운트). PO 확정.
|
||||
(트리 노드로는 유지, 살아있는 자손은 계속 카운트). 확정 정책.
|
||||
· content 권한·표시(tombstone 안내 + 관리자 원문 토글)는 #413-50 기구현 재사용.
|
||||
· withTrashed=true(manager del_cmt 토글) 경로는 이미 전체 노출 → 고아 복구 비적용.
|
||||
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ description: |
|
||||
(b) 저장 경로는 slug 기준({slug}/...)인데 삭제는 board_id 기준 디렉토리를
|
||||
지우려 해 경로 불일치로 아무 파일도 안 지워짐.
|
||||
|
||||
조치 (PO 결정 B안):
|
||||
조치 (결정 B안):
|
||||
· Repository 3종(Post/Comment/Attachment)에 forceDeleteByBoardId 신설
|
||||
(기존 softDeleteByBoardId 의 delete → forceDelete). 인터페이스 + 구현.
|
||||
· BoardService::deleteBoard() 의 자식 정리를 force 로 교체.
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ description: |
|
||||
미처리 → 비로그인·제3자도 게시판 내부 검색/목록 응답에서 비밀글 본문 요약 열람 가능 (보안 결함).
|
||||
- 수정: PostResource::getMaskedContentPreviewForList 신설 — status=Blinded 또는 is_secret 이면
|
||||
권한 무관 빈 문자열. toListArray 가 이 헬퍼를 사용.
|
||||
- PO 정책 확정: 목록 미리보기는 권한자(작성자/관리자)에게도 숨김(상세에서만 본문 노출).
|
||||
- 정책 확정: 목록 미리보기는 권한자(작성자/관리자)에게도 숨김(상세에서만 본문 노출).
|
||||
DB select 심층방어는 미포함(응답 계층 차단만 — 노출 경계 차단 관례).
|
||||
- 통합검색(/api/search)은 쿼리에서 published+non-secret 만 검색하므로 누수 없음(무관).
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ description: |
|
||||
정리되지 않아, 게시글만 trashed 되고 댓글·첨부는 살아남아 목록/상세에서 고아로
|
||||
노출되던 문제를 수정한다. 동시에 게시글 복원 시 cascade 로 지워진 항목만 선택
|
||||
복원하여, 삭제 이전에 사용자가 직접 지운 항목이 게시글 복원과 함께 부활하는
|
||||
문제(PO 우려)를 차단한다.
|
||||
문제(우려)를 차단한다.
|
||||
|
||||
근본 원인:
|
||||
· PostService::deletePost() 가 게시글 자신만 soft delete 하고 댓글·첨부는
|
||||
@@ -14,7 +14,7 @@ description: |
|
||||
· 첨부 테이블(board_attachments)에는 cascade 와 사용자 삭제를 구분할
|
||||
trigger_type 컬럼 자체가 없었다.
|
||||
|
||||
조치 (PO 결정 방법1: 연쇄 soft delete + cascade 플래그 기반 선별 복원):
|
||||
조치 (결정 방법1: 연쇄 soft delete + cascade 플래그 기반 선별 복원):
|
||||
· board_comments.trigger_type enum 에 'cascade' 추가 (마이그레이션).
|
||||
· board_attachments 에 trigger_type enum 컬럼 신설 (+ board_id/post_id/trigger_type
|
||||
복합 인덱스).
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
// e2e:allow MP13 — i18n/UX 소품 묶음(라벨 표시·링크 분기·모달 조건부 필드·placeholder 치환). 핵심 회귀는 Vitest(모듈 22 + 템플릿 13)로 커버, 라이브 검수는 계획서 §14 Chrome MCP 매트릭스(PO 환경)로 수행.
|
||||
// e2e:allow MP13 — i18n/UX 소품 묶음(라벨 표시·링크 분기·모달 조건부 필드·placeholder 치환). 핵심 회귀는 Vitest(모듈 22 + 템플릿 13)로 커버, 라이브 검수는 계획서 §14 Chrome MCP 매트릭스(검수 환경)로 수행.
|
||||
/**
|
||||
* bulkUpdateHandlers 테스트
|
||||
*
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
* 배경:
|
||||
* - partial_cancelled 주문상태 제거 후, 일부 취소 사실은 파생 플래그 is_partially_cancelled 로 표시한다.
|
||||
* - DataGrid order_status 컬럼 cellChildren 에 StatusBadge + 보조 배지를 평면 형제로 두면
|
||||
* 목록 셀과 모바일 카드(responsiveBreakpoint)에서 한 줄로 나란히 떠 깨진다(PO 발견).
|
||||
* 목록 셀과 모바일 카드(responsiveBreakpoint)에서 한 줄로 나란히 떠 깨진다(검수 발견).
|
||||
* → 둘을 flex-col 묶음 Div 로 감싸 상태 배지 아래(두 줄)에 보조 배지를 배치한다.
|
||||
* DataGrid 는 동일 columns 정의를 목록·카드 양쪽에 재사용하므로 한 곳 수정으로 둘 다 적용된다.
|
||||
*
|
||||
|
||||
+1
-1
@@ -406,7 +406,7 @@ describe('DataGrid (_transactions_table.json)', () => {
|
||||
expect(edit).toBeTruthy();
|
||||
// 비적립계 또는 권한없음이면 비활성 — Resource 가 can_edit = can_manage && isEarning 으로 계산
|
||||
expect(edit.disabledField).toBe('abilities.can_edit');
|
||||
// 원장 불변: 삭제 액션은 존재하지 않는다 (PO 확정)
|
||||
// 원장 불변: 삭제 액션은 존재하지 않는다 (정책 확정)
|
||||
const ids = grid.props.rowActions.map((a: any) => a.id);
|
||||
expect(ids).not.toContain('delete');
|
||||
expect(ids).not.toContain('manual');
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* @description
|
||||
* - 마일리지 설정은 이커머스 환경설정(admin_ecommerce_settings.json)의 서브탭으로 통합됨
|
||||
* (별도 독립 화면 아님 — PO 합의사항)
|
||||
* (별도 독립 화면 아님 — 합의사항)
|
||||
* - 환경설정 메인: mileage 탭 등록 + 탭 콘텐츠 partial(_tab_mileage.json) 참조 + 탭 if 가드
|
||||
* - 탭 콘텐츠 partial 5종(탭/기본/통화테이블/통화카드/유효기간/소멸알림) 구조 검증
|
||||
* - 카드 4종(기본/통화/유효기간/소멸알림), 토글 3종, 적립시점 기본=구매확정
|
||||
@@ -180,7 +180,7 @@ describe('마일리지 탭 콘텐츠 (_tab_mileage.json)', () => {
|
||||
});
|
||||
|
||||
it('마일리지 내역 보기 버튼이 기본 설정 카드 헤더에 있고 내역 화면으로 이동한다', () => {
|
||||
// 공간 절약 위해 별도 헤더 행 대신 기본 설정 카드 우측 상단으로 이동 (PO 요청)
|
||||
// 공간 절약 위해 별도 헤더 행 대신 기본 설정 카드 우측 상단으로 이동 (요청)
|
||||
const btn = findById(basicCard, 'view_transactions_button');
|
||||
expect(btn, '내역 보기 버튼이 기본 설정 카드에 없다').toBeTruthy();
|
||||
expect(btn.actions[0].handler).toBe('navigate');
|
||||
|
||||
+1
-1
@@ -102,7 +102,7 @@ describe('마이페이지 프로필 내 마일리지 카드 주입 (mypage-profi
|
||||
it('사용자 화면(마이페이지) 카드는 ExtensionBadge 를 사용하지 않는다 (회귀 차단)', () => {
|
||||
// ExtensionBadge 는 관리자 템플릿(sirsoft-admin_basic)에만 존재하는 컴포넌트로,
|
||||
// 사용자 템플릿(sirsoft-basic)에는 미등록이라 사용자 화면에 주입 시 "컴포넌트를 찾을 수 없습니다" 렌더 실패.
|
||||
// PO 정책: 사용자 화면에서 ExtensionBadge 사용 금지.
|
||||
// 정책: 사용자 화면에서 ExtensionBadge 사용 금지.
|
||||
const json = jsonOf(mypageCard);
|
||||
expect(json).not.toContain('ExtensionBadge');
|
||||
});
|
||||
|
||||
+2
-2
@@ -258,7 +258,7 @@ describe('productFormLayouts', () => {
|
||||
* "Failed to execute action: navigate" 오류 토스트가 추가로 발생하고
|
||||
* 목록 페이지로 이동하지 않음 (성공 토스트 + 오류 토스트 2건 동시 노출).
|
||||
* 원인: onSuccess 시퀀스의 navigate 핸들러가 params.url 로 경로를 전달했으나,
|
||||
* G7 navigate 핸들러는 params.path 만 인식한다 (CLAUDE.md "navigate path 필수").
|
||||
* G7 navigate 핸들러는 params.path 만 인식한다 (코어 규정: navigate path 필수).
|
||||
* url 키는 무시되어 finalPath=undefined → navigate throw → 오류 토스트.
|
||||
* 동일 모듈의 다른 navigate(주문 상세/브랜드 목록 등)는 모두 path 사용.
|
||||
* 해결: onSuccess navigate 의 params.url → params.path 로 정정.
|
||||
@@ -527,7 +527,7 @@ describe('productFormLayouts', () => {
|
||||
findNodeById(otherInfoPartial, 'field_max_purchase_qty'),
|
||||
]);
|
||||
// $event.target.value 는 허용하되, parseInt($event)/Number($event) 같은
|
||||
// 이벤트 객체 직접 숫자화 패턴은 금지 (CLAUDE.md 데이터 바인딩 규정)
|
||||
// 이벤트 객체 직접 숫자화 패턴은 금지 (코어 데이터 바인딩 규정)
|
||||
expect(serialized).not.toMatch(/parseInt\(\$event\)/);
|
||||
expect(serialized).not.toMatch(/Number\(\$event\)/);
|
||||
});
|
||||
|
||||
+2
-2
@@ -95,7 +95,7 @@ describe('header-currency-selector-user.json — 헤더 공용 셀렉터 2섹션
|
||||
});
|
||||
|
||||
it('루트는 if(마운트 게이트) 없이 항상 마운트 + className 으로 기본 hidden 토글 (깜빡임 방지)', () => {
|
||||
// PO 회귀(유저 헤더): if 마운트/언마운트 대신 항상 마운트 + className 기본 hidden(display:none) → 조건 충족 시 해제.
|
||||
// 회귀(유저 헤더): if 마운트/언마운트 대신 항상 마운트 + className 기본 hidden(display:none) → 조건 충족 시 해제.
|
||||
expect(root.if).toBeUndefined();
|
||||
const cls = root.props?.className ?? '';
|
||||
expect(cls).toContain("'hidden'");
|
||||
@@ -160,7 +160,7 @@ describe('header-currency-selector-admin.json — 관리자 헤더 2섹션', ()
|
||||
});
|
||||
|
||||
it('루트는 if(마운트 게이트) 없이 항상 마운트 + className 으로 기본 hidden 토글 (깜빡임 방지)', () => {
|
||||
// PO 회귀: if 로 마운트/언마운트하면 첫 페인트에 잠깐 보였다 사라진다.
|
||||
// 회귀: if 로 마운트/언마운트하면 첫 페인트에 잠깐 보였다 사라진다.
|
||||
// 항상 마운트하되 className 에 기본 hidden(display:none) 을 깔고 조건 충족 시에만 해제한다.
|
||||
expect(root.if).toBeUndefined();
|
||||
const cls = root.props?.className ?? '';
|
||||
|
||||
+1
-1
@@ -1201,7 +1201,7 @@ export function testShippingApiHandler(
|
||||
G7Core.state.set?.({ apiTestLoading: true, apiTestResult: null });
|
||||
|
||||
// apiCall 구조: URL 은 액션 top-level target, method/body 는 params,
|
||||
// onSuccess/onError 도 top-level (CLAUDE.md apiCall 규약). 백엔드 test-api-call
|
||||
// onSuccess/onError 도 top-level (코어 apiCall 규약). 백엔드 test-api-call
|
||||
// 엔드포인트가 사용자가 입력한 설정으로 외부 API 를 대신 호출하고 결과를 서빙한다.
|
||||
G7Core.dispatch({
|
||||
handler: 'apiCall',
|
||||
|
||||
+1
-1
@@ -421,7 +421,7 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "환불 우선순위 라디오 — PG 환불 가능(requires_pg_cancellation)일 때 노출한다. 포인트 사용분이 없으면(total_points_used_amount=0) PG↔포인트 배분 순서가 무의미하므로 노출은 하되 선택 불가(disabled)로 둔다 (A28, PO 2026-06-22).",
|
||||
"comment": "환불 우선순위 라디오 — PG 환불 가능(requires_pg_cancellation)일 때 노출한다. 포인트 사용분이 없으면(total_points_used_amount=0) PG↔포인트 배분 순서가 무의미하므로 노출은 하되 선택 불가(disabled)로 둔다 (A28, 2026-06-22).",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{order.data?.payment?.requires_pg_cancellation === true}}",
|
||||
|
||||
@@ -339,7 +339,7 @@ enum OrderStatusEnum: string
|
||||
* - 취소(CANCELLED) 진입은 이 목록에 포함하지 않는다(canTransitionTo 가 별도 통과 처리).
|
||||
* - 현재 상태가 CANCELLED 면 판매 반영 상태(salesEligibleStatuses)로만 복원 허용
|
||||
* (취소된 주문/옵션을 다시 판매 진행 상태로 되살리는 reactivation 운영 기능 — 재고 재차감 동반.
|
||||
* 결제 전 상태(pending_order/pending_payment)로의 복원은 차단. PO 2026-06-23).
|
||||
* 결제 전 상태(pending_order/pending_payment)로의 복원은 차단. 2026-06-23).
|
||||
*
|
||||
* @return array<self>
|
||||
*/
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ class MileageTransactionResource extends BaseApiResource
|
||||
* 행 레벨 능력을 계산합니다.
|
||||
*
|
||||
* 표준 can_manage 에 더해, 행 편집 메뉴('사유·기간 변경') 게이팅용 can_edit 를 노출합니다.
|
||||
* can_edit = manage 권한 보유 && 적립계 거래 (비적립계 행은 메뉴 비활성 — PO 합의 C-2).
|
||||
* can_edit = manage 권한 보유 && 적립계 거래 (비적립계 행은 메뉴 비활성 — 합의 C-2).
|
||||
*
|
||||
* @param Request $request HTTP 요청
|
||||
* @return array<string, bool> 능력 불리언 맵
|
||||
|
||||
@@ -175,7 +175,7 @@ class MileageTransactionListener implements HookListenerInterface
|
||||
|
||||
// 취소 전이: 기적립 건 회수.
|
||||
// 취소는 옵션 단위로 처리되므로 회수도 옵션(CANCELLED) 기준이다 — 부분취소든 전체취소든
|
||||
// 취소된 옵션마다 이 분기를 타 개별 회수된다(별도 partial_cancelled 상태 불요). PO 2026-06-22
|
||||
// 취소된 옵션마다 이 분기를 타 개별 회수된다(별도 partial_cancelled 상태 불요). 2026-06-22
|
||||
if ($newStatus === OrderStatusEnum::CANCELLED) {
|
||||
$this->mileageService->cancelEarnForOption($order, $option);
|
||||
}
|
||||
@@ -186,7 +186,7 @@ class MileageTransactionListener implements HookListenerInterface
|
||||
*
|
||||
* mileage.enabled 토글은 주문 당시 적립액 "계산"(OrderCalculationService) 단계에서만 작용한다.
|
||||
* 리스너는 이미 계산·저장된 subtotal_earned_points_amount 를 실행할 뿐이므로 enabled 를 재검사하지 않는다.
|
||||
* (저장값이 0 이면 Service 가 자연 no-op — PO 확정: 계산 완료된 적립은 토글로 막지 않음)
|
||||
* (저장값이 0 이면 Service 가 자연 no-op — 정책 확정: 계산 완료된 적립은 토글로 막지 않음)
|
||||
*
|
||||
* @param Order $order 주문
|
||||
* @param OrderOption $option 주문옵션
|
||||
|
||||
@@ -7,12 +7,12 @@ use App\Models\User;
|
||||
use Modules\Sirsoft\Ecommerce\Services\UserMileageService;
|
||||
|
||||
/**
|
||||
* 회원 탈퇴/삭제 시 마일리지 데이터 정리 리스너 (PO 확정 #8)
|
||||
* 회원 탈퇴/삭제 시 마일리지 데이터 정리 리스너 (정책 확정 #8)
|
||||
*
|
||||
* 코어 UserService 의 탈퇴(before_withdraw)·삭제(before_delete) 훅을 구독해,
|
||||
* 활성 lot 을 expired 거래로 소멸 기록(감사 흐름 보존)한 뒤 원장·잔액 캐시 행을 명시 삭제합니다.
|
||||
* 탈퇴는 soft 상태 변경이라 cascadeOnDelete 가 발화하지 않으므로 명시 정리가 필수입니다
|
||||
* (CLAUDE.md "DB CASCADE 의존 삭제 금지"). cascade 는 삭제 경로의 안전망으로만 둡니다.
|
||||
* (코어 규정: DB CASCADE 의존 삭제 금지). cascade 는 삭제 경로의 안전망으로만 둡니다.
|
||||
*/
|
||||
class UserMileageCleanupListener implements HookListenerInterface
|
||||
{
|
||||
|
||||
@@ -526,7 +526,7 @@ class Order extends Model
|
||||
*
|
||||
* 부분취소는 별도 주문 상태(partial_cancelled)가 아니라 "취소된 옵션이 있으면서
|
||||
* 잔여 활성 옵션도 존재"하는 파생 상태다. 주문 상태(order_status)는 잔여 활성 옵션 기준으로
|
||||
* 결정되고, "일부가 취소되었다"는 사실은 이 플래그로 노출한다 (PO 2026-06-22, partial_cancelled 제거).
|
||||
* 결정되고, "일부가 취소되었다"는 사실은 이 플래그로 노출한다 (2026-06-22, partial_cancelled 제거).
|
||||
*
|
||||
* @return bool 일부 옵션만 취소된 주문이면 true
|
||||
*/
|
||||
|
||||
@@ -664,7 +664,7 @@ class OrderAdjustmentService
|
||||
{
|
||||
$updates = [];
|
||||
|
||||
// 부분취소 재계산 시 옵션 레벨 다통화(mc_) 컬럼도 재산출 (PO 확정)
|
||||
// 부분취소 재계산 시 옵션 레벨 다통화(mc_) 컬럼도 재산출 (정책 확정)
|
||||
$currencySnapshot = $order->currency_snapshot;
|
||||
|
||||
// 재계산 결과에서 옵션별 금액 반영
|
||||
|
||||
@@ -118,7 +118,7 @@ class OrderCalculationService
|
||||
// 쿠폰 조합 가능 여부 검증 (is_combinable) — 전체 슬롯 통합 1회 (A15/MP06)
|
||||
// 슬롯별 독립 검증은 "상품별(false) 1개 + 주문(true) 1개" 같은 슬롯 교차 조합을
|
||||
// 각 슬롯 count=1 이라 놓친다. 적용 대상 전체 쿠폰을 한 집합으로 모아 1회 검증한다.
|
||||
// 스냅샷 모드(환불 재계산)에서도 동일 검증한다(PO 2026-06-19 확정).
|
||||
// 스냅샷 모드(환불 재계산)에서도 동일 검증한다(2026-06-19 확정).
|
||||
$allAppliedCoupons = $this->collectAllCouponsForCombination($coupons, $itemCouponIssues);
|
||||
$combinationViolations = $this->findNonCombinableViolations($allAppliedCoupons);
|
||||
foreach ($combinationViolations as $violationIssue) {
|
||||
@@ -874,7 +874,7 @@ class OrderCalculationService
|
||||
|
||||
// 마일리지 기능 비활성 시 주문 시점 적립 "계산" 자체를 수행하지 않는다 (전부 0).
|
||||
// mileage_value/mileage_type 은 상품 옵션(상품 등록 당시) 속성이므로,
|
||||
// 전역 OFF 면 상품옵션 명시 적립률·기본 적립률 모두 계산 대상에서 제외된다 (PO 확정).
|
||||
// 전역 OFF 면 상품옵션 명시 적립률·기본 적립률 모두 계산 대상에서 제외된다 (정책 확정).
|
||||
if (! (bool) $this->settingsService->getSetting('mileage.enabled', false)) {
|
||||
$pointsPerItem[$optionId] = 0;
|
||||
|
||||
|
||||
@@ -683,7 +683,7 @@ class OrderCancellationService
|
||||
}
|
||||
// 부분취소는 별도 주문 상태(partial_cancelled)를 두지 않는다 — 옵션은 이미 CANCELLED 로 전이됐고,
|
||||
// 주문 상태는 아래 syncParentOrderStatus 가 "취소 제외 잔여 활성 옵션" 기준으로 파생 결정한다.
|
||||
// (잔여 옵션이 모두 취소된 경우 syncParentOrderStatus 가 CANCELLED 로 전이.) — PO 2026-06-22
|
||||
// (잔여 옵션이 모두 취소된 경우 syncParentOrderStatus 가 CANCELLED 로 전이.) — 2026-06-22
|
||||
|
||||
$order->update($updateData);
|
||||
|
||||
|
||||
@@ -603,7 +603,7 @@ class OrderOptionService
|
||||
*
|
||||
* 부분취소는 별도 주문 상태가 아니라 "취소 옵션 + 잔여 활성 옵션 공존" 파생 상태다.
|
||||
* 따라서 주문 상태는 항상 잔여 활성 옵션 기준으로 결정하고, 부분취소 여부는
|
||||
* Order::isPartiallyCancelled() 파생 플래그로 노출한다 (PO 2026-06-22, partial_cancelled 제거).
|
||||
* Order::isPartiallyCancelled() 파생 플래그로 노출한다 (2026-06-22, partial_cancelled 제거).
|
||||
*
|
||||
* @param int $orderId 동기화할 주문 ID
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace Modules\Sirsoft\Ecommerce\Services;
|
||||
*
|
||||
* 유저 locale 을 환경설정의 통화별 언어(currencies[].locales) 매핑과 대조해 부여 통화를 결정한다.
|
||||
*
|
||||
* 매칭 우선순위(D-SIGNUP, PO 확정):
|
||||
* 매칭 우선순위(D-SIGNUP, 정책 확정):
|
||||
* 1) locale 이 매칭되는 통화가 1개 → 그 통화
|
||||
* 2) 여러 통화에 중복 매칭 → 매칭된 통화 중 is_default 통화 우선
|
||||
* 3) 매칭 없음/모호 → is_default 통화 → 그래도 없으면 default_currency 폴백
|
||||
|
||||
@@ -731,7 +731,7 @@ class UserMileageService
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 탈퇴/삭제 시 활성 lot 소멸 기록 (감사 흐름 보존 — PO 확정 #8)
|
||||
* 회원 탈퇴/삭제 시 활성 lot 소멸 기록 (감사 흐름 보존 — 정책 확정 #8)
|
||||
*
|
||||
* 활성 lot 을 expired 거래로 소멸 기록(활동로그에 금액·통화·회원ID 잔존)합니다.
|
||||
* 거래/캐시 행은 이후 cascade 또는 명시 삭제로 함께 사라집니다.
|
||||
@@ -764,7 +764,7 @@ class UserMileageService
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 탈퇴/삭제 시 마일리지 데이터를 명시적으로 정리합니다 (PO 확정 #8).
|
||||
* 회원 탈퇴/삭제 시 마일리지 데이터를 명시적으로 정리합니다 (정책 확정 #8).
|
||||
*
|
||||
* 활성 lot 을 expired 거래로 소멸 기록(활동로그 보존)한 뒤, 원장·잔액 캐시 행을
|
||||
* 동일 트랜잭션에서 삭제합니다. CASCADE 는 안전망으로만 두고 명시 삭제합니다
|
||||
@@ -986,7 +986,7 @@ class UserMileageService
|
||||
* 마일리지 사용이 가능한지 여부를 반환합니다.
|
||||
*
|
||||
* 기본 통화(base_currency)에 대한 마일리지 통화별 사용 단위 규칙이 설정되어 있지 않으면
|
||||
* 마일리지 사용을 허용하지 않는다(PO 정책). 마일리지는 base_currency 기준으로 사용/정산되므로
|
||||
* 마일리지 사용을 허용하지 않는다(정책). 마일리지는 base_currency 기준으로 사용/정산되므로
|
||||
* base 규칙이 없으면 사용 단위·최소 사용액·한도를 알 수 없어 사용 자체가 불가하다.
|
||||
*
|
||||
* @return bool 기본 통화 마일리지 사용 규칙이 설정되어 있으면 true
|
||||
|
||||
+1
-1
@@ -164,7 +164,7 @@ class CheckoutVerificationGuardTest extends ModuleTestCase
|
||||
*
|
||||
* 기존 G1(훅 발화)·코어 가드(enforce 예외)·리스너 구독 테스트는 각 조각만 따로 검증한다.
|
||||
* 이 테스트는 그 셋이 실제 HTTP 흐름에서 연결되어 최종적으로 428 이 나오는지를 통합 검증한다.
|
||||
* (PO 검수: 조각 테스트는 green 인데 실제로 본인인증이 발동하지 않던 결함의 회귀 가드)
|
||||
* (검수: 조각 테스트는 green 인데 실제로 본인인증이 발동하지 않던 결함의 회귀 가드)
|
||||
*
|
||||
* @scenario policy=checkout, enabled=on, actor=member, verified_state=unverified, path=http_order_create
|
||||
*
|
||||
|
||||
@@ -120,7 +120,7 @@ await page.route('https://api.tosspayments.com/v1/payments/confirm', route => ro
|
||||
```
|
||||
|
||||
분기별 가맹점 측 처리(주문 상태 전환, 재고 차감, 알림 발송 등) 가 정확한지 검증.
|
||||
실 카드 결제 자동화는 권장하지 않음 — PO 수동 검증.
|
||||
실 카드 결제 자동화는 권장하지 않음 — 수동 검증.
|
||||
|
||||
## 참고
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ class OrderStatusTransitionTest extends ModuleTestCase
|
||||
|
||||
/**
|
||||
* 취소 → 판매 반영 상태 복원(reactivation)은 허용, 결제 전 상태 복귀는 차단됨을 명시 단언합니다.
|
||||
* (취소 주문/옵션을 다시 판매 진행 상태로 되살리는 운영 기능 — 재고 재차감 동반. PO 2026-06-23)
|
||||
* (취소 주문/옵션을 다시 판매 진행 상태로 되살리는 운영 기능 — 재고 재차감 동반. 2026-06-23)
|
||||
*/
|
||||
public function test_cancelled_reactivation_to_sales_status_allowed_but_pre_payment_blocked(): void
|
||||
{
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ use Modules\Sirsoft\Ecommerce\Repositories\Contracts\MileageBalanceRepositoryInt
|
||||
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
|
||||
/**
|
||||
* UserMileageCleanupListener 테스트 (회원 탈퇴/삭제 시 마일리지 정리 — PO 확정 #8)
|
||||
* UserMileageCleanupListener 테스트 (회원 탈퇴/삭제 시 마일리지 정리 — 정책 확정 #8)
|
||||
*/
|
||||
class UserMileageCleanupListenerTest extends ModuleTestCase
|
||||
{
|
||||
|
||||
@@ -482,7 +482,7 @@ class OrderRepositoryTest extends ModuleTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* 주문대기(pending_order)는 통계에서 숨김 유지된다 (PO 확정, listHiddenValues).
|
||||
* 주문대기(pending_order)는 통계에서 숨김 유지된다 (정책 확정, listHiddenValues).
|
||||
*/
|
||||
public function test_user_statistics_hides_pending_order(): void
|
||||
{
|
||||
|
||||
@@ -367,7 +367,7 @@ class OrderResourceFieldsTest extends ModuleTestCase
|
||||
|
||||
/**
|
||||
* OrderResource 의 is_partially_cancelled 파생 플래그가 "취소 옵션 ∧ 잔여 활성 옵션" 일 때만 true 인지 확인.
|
||||
* (부분취소는 별도 order_status 가 아니라 파생 플래그로 표시 — PO 2026-06-22.)
|
||||
* (부분취소는 별도 order_status 가 아니라 파생 플래그로 표시 — 2026-06-22.)
|
||||
*/
|
||||
public function test_order_resource_exposes_is_partially_cancelled_flag(): void
|
||||
{
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
||||
* 마일리지 기본 통화 사용 규칙 게이트 테스트 (M2)
|
||||
*
|
||||
* 기본 통화(base_currency)에 대한 마일리지 통화별 사용 단위 규칙이 설정되어 있지 않으면
|
||||
* 마일리지 사용이 불가해야 한다(PO 정책).
|
||||
* 마일리지 사용이 불가해야 한다(정책).
|
||||
* - isMileageUsable(): base 규칙 존재 시 true, 없으면 false
|
||||
* - getMaxUsable(): base 규칙 없으면 0
|
||||
* - validateUsage(): base 규칙 없으면 MileageValidationException
|
||||
|
||||
+2
-2
@@ -6820,7 +6820,7 @@ class OrderAdjustmentServiceTest extends ModuleTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* §6.3 회귀: 부분취소 재계산 시 옵션 레벨 다통화(mc_) 컬럼이 재안분값으로 갱신된다 (PO 확정).
|
||||
* §6.3 회귀: 부분취소 재계산 시 옵션 레벨 다통화(mc_) 컬럼이 재안분값으로 갱신된다 (정책 확정).
|
||||
*
|
||||
* 결함: buildOptionUpdates 가 plain subtotal_* 만 갱신하고 mc_subtotal_* 는 부분취소 후 stale.
|
||||
*/
|
||||
@@ -6869,7 +6869,7 @@ class OrderAdjustmentServiceTest extends ModuleTestCase
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// §6.3 복합 상황: 배송정책 · 할인쿠폰 · 도서산간 · 상품별 정책 + 마일리지 (PO 추가 요구)
|
||||
// §6.3 복합 상황: 배송정책 · 할인쿠폰 · 도서산간 · 상품별 정책 + 마일리지 (추가 요구)
|
||||
// OrderCalculation 테스트 수준의 정확값 검증. 각 케이스는 적립 재안분값이
|
||||
// 환불 우선순위에 무관(우선순위는 refund split 만 변경)함을 함께 단언한다.
|
||||
// ================================================================
|
||||
|
||||
+1
-1
@@ -1208,7 +1208,7 @@ class OrderProcessingServiceTest extends ModuleTestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* PO 검증 4조합: base/order 통화 전 경우의 수.
|
||||
* 검증 4조합: base/order 통화 전 경우의 수.
|
||||
*
|
||||
* @return array<string, array{0:string,1:string,2:float,3:array,4:int}>
|
||||
*/
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ class OrderServiceStockRedeductTest extends ModuleTestCase
|
||||
public function test_update_to_non_sales_status_is_blocked_by_transition_rule(): void
|
||||
{
|
||||
// 전이 규칙 도입(A30) 이후: 취소 → 결제 전 상태(결제대기) 복귀는 전이 게이트가 차단한다.
|
||||
// (취소 복원은 판매 반영 상태로만 허용 — PO 2026-06-23.)
|
||||
// (취소 복원은 판매 반영 상태로만 허용 — 2026-06-23.)
|
||||
// 차단되므로 update 가 OrderProcessingException 을 던지고, 재차감은 물론 상태/재고 모두 불변.
|
||||
[$order, $option] = $this->createCancelledOrderWithRestoredStock(stock: 8, quantity: 3);
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ description: |
|
||||
shouldConvertToFullCancel 이 담당(프론트 isFullCancel 휴리스틱 제거).
|
||||
- U20③: 취소 차단 메시지를 쿠폰 단정 없이 일반화.
|
||||
- U20④: cancelled_at native 컬럼에 전체·부분취소 모두 기록 + Resource 노출(cancelled_at_formatted).
|
||||
- 부분취소 모델 변경(PO 2026-06-22): partial_cancelled 주문 상태를 제거. 부분취소는 별도 상태가 아니라
|
||||
- 부분취소 모델 변경(2026-06-22): partial_cancelled 주문 상태를 제거. 부분취소는 별도 상태가 아니라
|
||||
"취소 옵션 + 잔여 활성 옵션 공존"이며, 주문 상태는 잔여 활성 옵션 기준으로 파생(syncParentOrderStatus).
|
||||
잔여 항목 재취소는 진행 상태(payment_complete 등)가 취소 가능 목록에 있어 자연 허용. 잔여 전량 취소 시 CANCELLED 전이.
|
||||
"일부 취소됨" 표시는 파생 플래그 is_partially_cancelled(취소옵션∧활성옵션)로 노출(목록·상세 보조 배지).
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ description: |
|
||||
(SHIPPING_HOLD↔PREPARING, SHIPPING→{SHIPPING_READY,PREPARING}, DELIVERED→SHIPPING)만 추가 허용.
|
||||
- CONFIRMED 되돌림 전부 금지.
|
||||
- CANCELLED 발 전이는 판매 반영 상태(salesEligibleStatuses)로의 복원(reactivation)만 허용
|
||||
(재고 재차감 동반 운영 기능, PO 2026-06-23). 결제 전 상태(pending_order/pending_payment) 복귀는 차단.
|
||||
(재고 재차감 동반 운영 기능, 2026-06-23). 결제 전 상태(pending_order/pending_payment) 복귀는 차단.
|
||||
- 취소(CANCELLED) 진입은 게이트가 통과 처리(OrderCancellationService 전담, 이중 관리 방지).
|
||||
- 3경로 모두 FormRequest withValidator 1차 차단(422 + 다국어) + Service 진입부 2차 도메인 불변식 가드.
|
||||
- 일괄은 all-or-nothing: 1건이라도 위반이면 전체 422, DB 무변경.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 검출되지 않는다(다른 번들 동일).
|
||||
# base 통화 정책 axis 는 PHPUnit(OrderProcessingService/buildCurrencySnapshot/OrderAdjustmentService/
|
||||
# StoreEcommerceSettingsRequest)·Playwright 가 커버하며 green 이다.
|
||||
feature: base 통화 정책 + 변경 가드 + 결제/통지/부분환불 base 기준 (A2 — PO 신규)
|
||||
feature: base 통화 정책 + 변경 가드 + 결제/통지/부분환불 base 기준 (A2 — 신규)
|
||||
|
||||
description: |
|
||||
기본통화 = 모든 금액 테이블의 base(진실값). 비-base 선택 시 base 정수 × 스냅샷 환율로 PG 청구·통지·환불.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 검출되지 않는다(다른 번들 동일).
|
||||
# 가입 통화 부여 axis 는 PHPUnit(AssignDefaultCurrencyOnRegisterListener/StoreEcommerceSettingsRequest
|
||||
# locales 검증)·Vitest(통화별 언어 Tag UI)가 커버하며 green 이다.
|
||||
feature: 가입 시 locale 기반 통화 부여 + 통화별 언어 다중 매핑 (A4 — PO 신규)
|
||||
feature: 가입 시 locale 기반 통화 부여 + 통화별 언어 다중 매핑 (A4 — 신규)
|
||||
|
||||
description: |
|
||||
가입/회원생성 시 브라우저/locale 기반 기본통화 부여(환경설정>언어/통화의 통화별 언어 Tag 매핑 기준).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# cross product 자동 전개는 audit 실행 환경에 js-yaml 부재로 검출되지 않는다(다른 번들 동일).
|
||||
# 유저별 영속 통화 axis 는 PHPUnit(EcommerceUserProfile/Repository/통화 저장 컨트롤러)·
|
||||
# Vitest(설정 UI)·Playwright(설치 게이트)가 커버하며 green 이다.
|
||||
feature: 유저별 영속 통화 — user-profile 확장 + 4곳 설정 (A3·A5 — PO 신규)
|
||||
feature: 유저별 영속 통화 — user-profile 확장 + 4곳 설정 (A3·A5 — 신규)
|
||||
|
||||
description: |
|
||||
유저별 통화 = 커머스 책임. 코어 users 미변경, 모듈 ecommerce_user_profiles 테이블 + layout_extensions.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Playwright 코어 E2E 설정 (G7 코어 영역)
|
||||
*
|
||||
* 위치 규약 (CLAUDE.md 코어/확장 분리 원칙):
|
||||
* 위치 규약 (코어/확장 분리 원칙):
|
||||
* - 코어 spec : tests/Playwright/specs/
|
||||
* - 모듈 spec : modules/_bundled/{id}/tests/Playwright/specs/ (모듈 자체 config)
|
||||
* - 플러그인 : plugins/_bundled/{id}/tests/Playwright/specs/ (플러그인 자체 config)
|
||||
|
||||
+4
-4
@@ -490,7 +490,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au
|
||||
const modalText = JSON.stringify(publishModal);
|
||||
|
||||
it('발행 버튼은 isPublishing 동안 spinner + "발행 중..." 라벨 노출 + 양쪽 버튼 disabled', () => {
|
||||
// PO 피드백: 이전엔 closeModal 이 apiCall 앞에 있어 클릭 즉시 모달이 닫혀 사용자가
|
||||
// 피드백: 이전엔 closeModal 이 apiCall 앞에 있어 클릭 즉시 모달이 닫혀 사용자가
|
||||
// 발행 진행 상태를 알 수 없었음. _local.isPublishing 플래그로 발행 중 시각 피드백 +
|
||||
// 양쪽 버튼 disabled (취소 버튼도 발행 중엔 막아 도중 닫힘 방지).
|
||||
// JSON.stringify 기본 출력은 공백 없는 형식 (콜론 뒤 공백 없음).
|
||||
@@ -516,7 +516,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au
|
||||
});
|
||||
});
|
||||
|
||||
it('정책 버전 가이드는 카드 hint 영역에 2줄 P 로 통합된다 (PO 피드백: 토글/박스 제거)', () => {
|
||||
it('정책 버전 가이드는 카드 hint 영역에 2줄 P 로 통합된다 (피드백: 토글/박스 제거)', () => {
|
||||
const box = findById(root, 'policy_version_guide_box');
|
||||
expect(box).not.toBeNull();
|
||||
const serialized = JSON.stringify(box);
|
||||
@@ -666,7 +666,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au
|
||||
expect(layoutJson).toContain('partials/_shared/_policy_version_snapshot_modal.json');
|
||||
});
|
||||
|
||||
it('현재 버전 v배지 옆에 본문 보기 Button 이 노출된다 (PO 피드백 — 평문 + 명시 액션 분리)', () => {
|
||||
it('현재 버전 v배지 옆에 본문 보기 Button 이 노출된다 (피드백 — 평문 + 명시 액션 분리)', () => {
|
||||
// v배지는 평문 Span (text 'v...' 으로 항상 표시), 본문 보기 Button 은 if 분기 + sequence
|
||||
expect(layoutJson).toContain('"text":"v{{gdprPolicyVersionCurrent?.data?.data?.version ?? \'?\'}}"');
|
||||
expect(layoutJson).toContain('"if":"{{gdprPolicyVersionCurrent?.data?.data?.version}}"');
|
||||
@@ -683,7 +683,7 @@ describe('admin/plugin_settings.json — 3 카드 (operator / cookie_banner / au
|
||||
expect(layoutJson).toContain('"viewingPolicyVersion":"{{row?.version}}"');
|
||||
});
|
||||
|
||||
it('policy_version_guide_box wrapper 는 라벨 + hint 2줄 구조의 단순 Div (PO 피드백: 토글/박스 제거)', () => {
|
||||
it('policy_version_guide_box wrapper 는 라벨 + hint 2줄 구조의 단순 Div (피드백: 토글/박스 제거)', () => {
|
||||
const box = findById(root, 'policy_version_guide_box');
|
||||
expect(box).not.toBeNull();
|
||||
// wrapper 는 className 없음 (라벨 + hint 두 줄을 단순 그룹핑)
|
||||
|
||||
+10
-10
@@ -84,13 +84,13 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => {
|
||||
// 1. ResponseHelper::success($msg, $data) 응답은 데이터소스에서 그대로 보존되므로 단일 객체 응답은 `<id>.data.<key>` 한 단계 경로.
|
||||
// 과거 통합본에서 `?.data?.data?` 두 단계로 작성되어 항상 undefined → if false → 배너 미출력 사고.
|
||||
// 2. _global.gdprBannerDismissed 만으로는 새로고침 시 휘발 — 서버 조회 (gdprMyConsent.has_consented) 가 우선이며 클릭 직후 즉시 사라지는 효과만 글로벌 상태가 보장.
|
||||
// 3. needs_renewal=true 일 때도 배너만 노출 (모달 폐기 — PO 결정 모달/배너 중복 제거). 배너 본문에 사유 안내 + 회원 한정 "현 상태 유지" 액션 통합.
|
||||
// 3. needs_renewal=true 일 때도 배너만 노출 (모달 폐기 — 결정 모달/배너 중복 제거). 배너 본문에 사유 안내 + 회원 한정 "현 상태 유지" 액션 통합.
|
||||
expect(banner?.if).toBe(
|
||||
'{{gdprPublicSettings?.data?.banner_enabled === true && gdprMyConsent?.data?.has_consented !== true && _global.gdprBannerDismissed !== true}}'
|
||||
);
|
||||
});
|
||||
|
||||
it('needs_renewal 강제 모달 (gdpr_needs_renewal_modal) 이 더 이상 존재하지 않는다 (PO 결정 — 배너 통합)', () => {
|
||||
it('needs_renewal 강제 모달 (gdpr_needs_renewal_modal) 이 더 이상 존재하지 않는다 (결정 — 배너 통합)', () => {
|
||||
// 모달 폐기 회귀 가드: 모달과 배너의 시각적 중복 + UX 혼란 + dark pattern 회피 위해
|
||||
// 모달 통째 제거하고 배너에 사유 안내 + 회원 한정 "현 상태 유지" 액션 통합.
|
||||
const modal = findById(root, 'gdpr_needs_renewal_modal');
|
||||
@@ -114,7 +114,7 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => {
|
||||
expect(json).toContain('|renewed=');
|
||||
});
|
||||
|
||||
it('게스트 전용 갱신 버튼 (gdpr_keep_consent_button_guest) 은 더 이상 존재하지 않는다 (PO 결정 — 게스트는 status 미사용 + history 기반이라 이전 의사 자동 복원 인프라 부재 → "모두 동의" 그대로 사용)', () => {
|
||||
it('게스트 전용 갱신 버튼 (gdpr_keep_consent_button_guest) 은 더 이상 존재하지 않는다 (결정 — 게스트는 status 미사용 + history 기반이라 이전 의사 자동 복원 인프라 부재 → "모두 동의" 그대로 사용)', () => {
|
||||
const guestBtn = findById(root, 'gdpr_keep_consent_button_guest');
|
||||
expect(guestBtn).toBeNull();
|
||||
});
|
||||
@@ -143,12 +143,12 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => {
|
||||
|
||||
it('기본(bottom_bar) 위치는 풀폭 팝업 카드 (bottom-4 left-4 right-4 rounded-lg)', () => {
|
||||
// 회귀 가드: 이전엔 화면 하단에 딱 붙는 띠(bottom-0)였지만,
|
||||
// PO 결정으로 화면에서 살짝 떠 있는 풀폭 카드 형태로 변경됨.
|
||||
// 설계 결정으로 화면에서 살짝 떠 있는 풀폭 카드 형태로 변경됨.
|
||||
expect(className).toContain('bottom-4 left-4 right-4 rounded-lg');
|
||||
});
|
||||
|
||||
it('top_bar 분기는 제거되어 있다', () => {
|
||||
// 회귀 가드: PO 결정으로 banner_position 옵션에서 top_bar 제거됨.
|
||||
// 회귀 가드: 설계 결정으로 banner_position 옵션에서 top_bar 제거됨.
|
||||
expect(className).not.toContain("'top_bar'");
|
||||
});
|
||||
|
||||
@@ -170,9 +170,9 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('centered_modal 에서 dim 오버레이를 사용하지 않는다 (PO 의도: 다른 팝업과 동일 외형)', () => {
|
||||
it('centered_modal 에서 dim 오버레이를 사용하지 않는다 (의도: 다른 팝업과 동일 외형)', () => {
|
||||
// 회귀 가드: 이전 구현은 외곽에 bg-black/40 dim 을 깔았으나 자식 박스가 dim 위에 떠야 하는
|
||||
// 구조 — 외곽이 bg-white 와 함께 정의되어 dim 의도가 깨졌었다. PO 가 dim 강제 모달이 아닌
|
||||
// 구조 — 외곽이 bg-white 와 함께 정의되어 dim 의도가 깨졌었다. dim 강제 모달이 아닌
|
||||
// 단순 중앙 배치만 원하므로 dim 클래스 자체를 제거.
|
||||
expect(className).not.toContain('bg-black/40');
|
||||
expect(className).not.toContain('bg-black/50');
|
||||
@@ -280,7 +280,7 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => {
|
||||
});
|
||||
|
||||
it('배너 4버튼은 gdprBannerSubmittingAction 식별자 패턴으로 클릭한 버튼만 spinner + 다른 버튼은 원래 라벨 유지', () => {
|
||||
// 회귀 가드: PO 피드백 — 단일 boolean 플래그면 모든 버튼이 "동의 중..." 으로 동시 변경되어
|
||||
// 회귀 가드: 피드백 — 단일 boolean 플래그면 모든 버튼이 "동의 중..." 으로 동시 변경되어
|
||||
// 어느 버튼을 클릭했는지 시각적으로 알 수 없음. 식별자 string 으로 변경하여 클릭한 버튼만
|
||||
// 스피너 + 라벨 변경, 다른 버튼은 disabled + 원래 라벨 유지.
|
||||
// 4 action 식별자: accept_all / reject_all / save_selection / renew_consent
|
||||
@@ -365,7 +365,7 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => {
|
||||
});
|
||||
|
||||
it('"자세히 보기" 토글 + 펼침 영역이 통째로 제거되었다 (카테고리 description 한 줄로 일원화)', () => {
|
||||
// PO 결정: 펼침 영역의 user_info description 이 카테고리 카드 자체의 description 과
|
||||
// 설계 결정: 펼침 영역의 user_info description 이 카테고리 카드 자체의 description 과
|
||||
// 의미상 중복 → "자세히 보기" 토글 자체를 없애고, 카테고리 description 을 사용자 친화
|
||||
// 표현으로 갈아끼움 (시더 plugin.php / CookieCategoryService 의 기본값 갱신).
|
||||
expect(text).not.toContain('banner.category_details_expand');
|
||||
@@ -398,7 +398,7 @@ describe('extensions/cookie_banner.json — 쿠키 동의 배너', () => {
|
||||
});
|
||||
|
||||
describe('카테고리 카드 — 임의값 회귀 가드 (#14 글씨 키우기)', () => {
|
||||
// PO 결정: 카테고리 카드 자체의 보조 텍스트 (필수 배지, 카테고리 설명) 도 너무 작아
|
||||
// 설계 결정: 카테고리 카드 자체의 보조 텍스트 (필수 배지, 카테고리 설명) 도 너무 작아
|
||||
// 가독성 저하. 임의값 text-[10px] 11건을 모두 표준 text-xs 로 통일.
|
||||
it('cookie_banner.json 전체에서 text-[10px] 임의값이 사용되지 않는다', () => {
|
||||
const fullText = serializeForSearch(extension);
|
||||
|
||||
+1
-1
@@ -192,7 +192,7 @@ describe('layouts/admin/gdpr_consent_log.json — #27 체크박스 즉시 적용
|
||||
|
||||
/**
|
||||
* 작업 2 (B-3 옵션 X) — policy_version 컬럼이 평문 Span + 본문 보기 Button 분리.
|
||||
* PO 피드백: v배지 클릭만으로는 직관성 부족 — 평문 + 명시 버튼 패턴으로 변경.
|
||||
* 피드백: v배지 클릭만으로는 직관성 부족 — 평문 + 명시 버튼 패턴으로 변경.
|
||||
*/
|
||||
describe('작업 2: policy_version 컬럼 → 평문 + 본문 보기 Button', () => {
|
||||
const layoutJson = JSON.stringify(layout);
|
||||
|
||||
+4
-4
@@ -79,7 +79,7 @@ describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매
|
||||
});
|
||||
|
||||
it('카드 헤더(타이틀 + 구분선) 다음 형제 위치에 배치 (헤더 내부 X)', () => {
|
||||
// PO UX 결정: 메타 라인은 H2 타이틀 + 구분선 다음에 자연어 문장으로 노출.
|
||||
// UX 결정: 메타 라인은 H2 타이틀 + 구분선 다음에 자연어 문장으로 노출.
|
||||
// 헤더 내부(border-b 위)에 두면 타이틀과 같은 영역으로 보여 시각적 혼잡 → 헤더 외부로 분리.
|
||||
const header = findById(card ?? null, 'gdpr_card_header');
|
||||
const headerChildren = (header?.children ?? []) as AnyNode[];
|
||||
@@ -129,7 +129,7 @@ describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매
|
||||
});
|
||||
|
||||
it('가운데 점(·) 구분자 미사용 (자연어 한 줄 패턴이라 구분자 불필요)', () => {
|
||||
// 회귀 가드: 이전 디자인은 가로 인라인 + · 구분자였으나, PO 결정으로 자연어 한 줄로 재설계.
|
||||
// 회귀 가드: 이전 디자인은 가로 인라인 + · 구분자였으나, 설계 결정으로 자연어 한 줄로 재설계.
|
||||
const children = (meta?.children ?? []) as AnyNode[];
|
||||
const separator = children.find((c) => String(c.text ?? '') === '·');
|
||||
expect(separator).toBeUndefined();
|
||||
@@ -223,7 +223,7 @@ describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매
|
||||
const modal = (root.modals ?? []).find((m) => m.id === 'revoke_confirm_modal');
|
||||
|
||||
it('모달 본문에 raw key (cookie_functional 등) 대신 사용자 친화 description 노출', () => {
|
||||
// PO 피드백 — raw key 는 사용자에게 무의미하여 모달 본문에서 제거.
|
||||
// 피드백 — raw key 는 사용자에게 무의미하여 모달 본문에서 제거.
|
||||
// 라벨 + description 2줄 구조로 변경됨.
|
||||
const modal = (root.modals ?? []).find((m) => m.id === 'revoke_confirm_modal');
|
||||
const text = serializeForSearch(modal);
|
||||
@@ -329,7 +329,7 @@ describe('extensions/mypage_privacy_tab.json — 마이페이지 GDPR 동의 매
|
||||
});
|
||||
|
||||
describe('정책 갱신 amber 안내 박스 (마이페이지 카드 내 needs_renewal 안내)', () => {
|
||||
// PO 결정 — 마이페이지 카드 안에 amber 박스 (gdpr_needs_renewal_banner) 유지.
|
||||
// 설계 결정 — 마이페이지 카드 안에 amber 박스 (gdpr_needs_renewal_banner) 유지.
|
||||
// 조건: gdprMeConsents.data.needs_renewal === true 일 때만 노출.
|
||||
// 본문: 현 정책 버전 안내 (#23 informed consent) + 필수 쿠키 면제 hint
|
||||
// 액션: 「전체 항목 다시 동의」 (POST /consent/renew-all)
|
||||
|
||||
+1
-1
@@ -295,7 +295,7 @@ class GdprAdminSettingsControllerTest extends PluginTestCase
|
||||
* 노출하여 사용자가 어느 필드를 의미하는지 인지하기 어려움. FormRequest::attributes() 메서드로
|
||||
* 각 필드에 lang 의 사용자 친화 라벨을 매핑하여 메시지 안에 노출되도록 함.
|
||||
*
|
||||
* 검토 #10d — PO 가 사용자에게 영문 키 노출이 부적절하다는 피드백 후 추가.
|
||||
* 검토 #10d — 사용자에게 영문 키 노출이 부적절하다는 피드백 후 추가.
|
||||
*/
|
||||
public function test_update_validation_messages_use_friendly_attribute_names(): void
|
||||
{
|
||||
|
||||
@@ -75,7 +75,7 @@ class GdprAuthConsentListenerTest extends PluginTestCase
|
||||
*
|
||||
* 실제 훅 체인: HookManager::doAction('core.auth.record_consents', User, array, string $agreedAt, string $ip)
|
||||
* 를 발화했을 때 listener 가 TypeError 없이 받고 폼 동의를 status 에 반영해야 함.
|
||||
* 회귀 발견 시점: 2026-05-08 PO 가입 시 Argument #3 ($agreedAt) must be of type Carbon\Carbon, string given.
|
||||
* 회귀 발견 시점: 2026-05-08 관리자 가입 시 Argument #3 ($agreedAt) must be of type Carbon\Carbon, string given.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
@@ -139,7 +139,7 @@ class GdprAuthConsentListenerTest extends PluginTestCase
|
||||
* Listener 메서드는 현재 HTTP 요청 컨텍스트 (`request()->ip()`) + Carbon 인스턴스에
|
||||
* 의존한다. HookListenerRegistrar 의 기본 동작은 큐 디스패치인데, 큐 잡은 별도 컨텍스트라
|
||||
* 쿠키 소실 + Carbon → string 직렬화 mismatch 발생 → 폼 동의 기록 실패
|
||||
* (실제 회귀 — 2026-05-08 PO 가입 시도 시 발견).
|
||||
* (실제 회귀 — 2026-05-08 관리자 가입 시도 시 발견).
|
||||
*/
|
||||
public function test_subscribed_hook_forces_sync_to_preserve_request_context(): void
|
||||
{
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ class MobileCallbackRouteTest extends PluginTestCase
|
||||
/**
|
||||
* KG 이니시스 모바일 표준결제는 P_NEXT_URL 로 POST 콜백을 보낸다.
|
||||
* 라우트가 GET 만 허용하면 405 Method Not Allowed 회귀가 발생한다.
|
||||
* 가상계좌 결제 시 PO 운영 검증으로 발견된 회귀.
|
||||
* 가상계좌 결제 시 운영 검증으로 발견된 회귀.
|
||||
*/
|
||||
public function test_mobile_callback_route_accepts_post(): void
|
||||
{
|
||||
|
||||
+2
-2
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* 본 회차에서 추가한 두 가지 회귀 차단 포인트:
|
||||
* 1. 모달 헤더 제목/설명에 `purpose === 'inicis.adult_verification'` 분기 두 쌍 존재
|
||||
* 2. if 표현식이 CLAUDE.md 규정 (`{{식}}` 한 쌍 안에 비교 식 전체) 부합
|
||||
* 2. if 표현식이 코어 규정 (`{{식}}` 한 쌍 안에 비교 식 전체) 부합
|
||||
*
|
||||
* 레이아웃 JSON 파일을 직접 읽어 구조를 단언하는 가벼운 단위 테스트.
|
||||
* 코어 DynamicRenderer 환경 셋업 없이도 회귀 차단 가능.
|
||||
@@ -128,7 +128,7 @@ describe('identity_provider_inicis Extension Point — 본 회차 회귀 차단'
|
||||
expect(texts).toContain('$t:sirsoft-verification_kginicis.modal.description');
|
||||
});
|
||||
|
||||
it('모든 purpose 분기 if 표현식이 CLAUDE.md 규정 (식 전체를 `{{}}` 한 쌍에) 부합', () => {
|
||||
it('모든 purpose 분기 if 표현식이 코어 규정 (식 전체를 `{{}}` 한 쌍에) 부합', () => {
|
||||
const purposeIfs: string[] = [];
|
||||
|
||||
for (const root of spec.components) {
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
* 배경: onError 의 errors 바인딩이 `{{$error.errors}}` ($ 접두사) 였을 때 ActionDispatcher
|
||||
* 컨텍스트 변수명과 불일치하여 _local.errors 가 null 로 남고, validation_error 박스가
|
||||
* 렌더되지 않는 silent failure 회귀가 발생했다. 올바른 컨텍스트 변수명은 `{{error.errors}}`.
|
||||
* (CLAUDE.md 데이터 바인딩 규칙: onSuccess/onError 컨텍스트는 $ 접두사 없음)
|
||||
* (코어 데이터 바인딩 규칙: onSuccess/onError 컨텍스트는 $ 접두사 없음)
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
@@ -110,7 +110,7 @@ design_rationale: |
|
||||
신규 가입자는 가입 시점에 record 생성되므로 시간 흐를수록 자연 수렴.
|
||||
|
||||
- 외국인 (userDi 빈값) 통과 정책 — KG이니시스가 외국인 본인확인 시 DI 발급 못 하는 케이스 대응.
|
||||
PO 결정에 따라 향후 차단 정책 전환 가능 (별도 이슈).
|
||||
정책 결정에 따라 향후 차단 정책 전환 가능 (별도 이슈).
|
||||
|
||||
- PII 미노출 — IdentityDuplicateException / verify failure 메시지에 이메일/이름 절대 포함하지 않음.
|
||||
heads-up 공격 방어. i18n 키로만 처리.
|
||||
|
||||
@@ -155,7 +155,7 @@ design_rationale: |
|
||||
주석 명시), 모달 닫힘은 명시적 closeModal 책임. 코어 모달 partial 의 verify 버튼이 이미
|
||||
동일 sequence 패턴 사용 — 코어 default 와 일관성.
|
||||
2. 사용자 안내 강화 — 기존 "본인인증 시작" 버튼만으로는 사용자가 "왜 이게 뜨지" 의문을 가질 수
|
||||
있어 (PO 피드백), 사전 안내 카드 (아이콘 + 제목 + 설명 + 3단계 절차 안내) 로 확장. Phase E
|
||||
있어 (사용자 피드백), 사전 안내 카드 (아이콘 + 제목 + 설명 + 3단계 절차 안내) 로 확장. Phase E
|
||||
폐기 사유 #1 의 "사용자 제스처 확보" 본질은 그대로 유지.
|
||||
3. 진행 중 상태 표시 — 팝업이 열린 후 모달이 정적 상태로 남아있어 사용자가 "지금 뭘 해야 하지"
|
||||
혼란을 느낄 수 있음. providerInProgress 플래그로 슬롯 if 분기하여 "팝업창에서 진행 중" 스피너
|
||||
@@ -164,7 +164,7 @@ design_rationale: |
|
||||
providerInProgress 만 false 로 set → 모달은 열린 채 시작 버튼이 재활성화되어 사용자가 다시
|
||||
시도 가능. 강제 cancelled 처리 시 사용자가 모달 처음부터 다시 열어야 하는 부담 회피.
|
||||
|
||||
옵션 비교 — Phase E′-Redesign 의사결정 (PO 와 검토):
|
||||
옵션 비교 — Phase E′-Redesign 의사결정 (검토):
|
||||
- 옵션 1 (사전 안내 모달 + redirect): 페이지 전환 1회 발생, 데스크톱 UX 무거움
|
||||
- 옵션 2 (모달 + 팝업 = 본 안): 모달 안에서 완결, 모바일 일반 브라우저 (Safari/Chrome) 도 동일 흐름
|
||||
으로 동작 (window.open 이 새 탭으로 열려도 opener 유지 → bridge postMessage 동작)
|
||||
@@ -178,7 +178,7 @@ design_rationale: |
|
||||
- 코어 무수정 + 템플릿 minimal 수정 (모달 partial 에 if 분기 1줄 + 액션 버튼 if 조건 보강)
|
||||
- 모달 닫힘 결함만 fix 하면 작업 완결
|
||||
|
||||
PO 피드백 반영 (sirsoft-basic 1.0.0-beta.6):
|
||||
피드백 반영 (sirsoft-basic 1.0.0-beta.6):
|
||||
1. 외부 plugin 흐름에서 코어 모달의 [코드 재전송] / [확인] 버튼 숨김 — 외부 plugin 은 자기
|
||||
흐름 안에서 verify 까지 책임지므로 코어 액션이 불필요. 코어 mail/SMS 등 코드 발송형
|
||||
흐름은 기존대로 노출 (회귀 없음).
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ test_files:
|
||||
- plugins/_bundled/sirsoft-verification_kginicis/tests/Feature/Identity/InicisInstallDefaultPurposeProviderTest.php
|
||||
|
||||
design_rationale: |
|
||||
PO 결정:
|
||||
설계 결정:
|
||||
- 설치 시 1회만 세팅(install 훅), 활성화/업그레이드 시 재적용하지 않음.
|
||||
- 운영자 변경 가능(초기값 성격) — 코어 settings 에 저장하므로 환경설정 UI
|
||||
(본인인증 > 프로바이더 탭, 목적별 매핑)에서 그대로 변경된다.
|
||||
|
||||
+2
-2
@@ -48,7 +48,7 @@ rules_layer_coverage:
|
||||
coverage: name/phone/birthday nullable 변경 마이그레이션에 down() (NOT NULL 복귀) + 한국어 comment 포함.
|
||||
|
||||
design_rationale: |
|
||||
PO 결정 (부분 데이터 처리 방향):
|
||||
설계 결정 (부분 데이터 처리 방향):
|
||||
- 차단 대상 = 신원 핵심값(이름·생년월일·휴대폰·설정 기준 식별값). Chrome MCP 실측(2026-06-23,
|
||||
아이핀 채널)에서 이름·휴대폰·생년월일·CI·DI 가 모두 응답에 채워짐을 확인 → 휴대폰도 항상 오는
|
||||
핵심값으로 포함. 법규(CI/DI 는 주민/외국인등록번호 기반 → 정상 본인확인이면 제공)·코드(식별값
|
||||
@@ -61,7 +61,7 @@ design_rationale: |
|
||||
휴대폰은 신원 핵심값이라 누락 시 차단 대상 (위 incomplete_identity 케이스에 포함).
|
||||
- 토큰 재발급 가드(isVerified enum 비교)는 1번 PR, verify 실패 toast 매핑은 2번 PR 에서 처리됨.
|
||||
본 항목은 3번의 신원 가드 + 정합 저장 + INCOMPLETE_IDENTITY toast 1줄 보강.
|
||||
- (저장 원자성 보강) 토큰 발급(log update)과 PII 저장을 한 단위로 묶는다 (PO 결정 A안). PII 저장이
|
||||
- (저장 원자성 보강) 토큰 발급(log update)과 PII 저장을 한 단위로 묶는다 (설계 결정 A안). PII 저장이
|
||||
실패하면 토큰 발급까지 롤백하고 STORAGE_FAILED 로 인증을 실패시켜 "인증 성공=PII 저장됨" 무결성을
|
||||
보장한다. 로그인 record upsert 는 DB 트랜잭션으로 묶고, 비로그인 cache stash 실패는 트랜잭션 콜백을
|
||||
예외 종료시켜 log update 를 롤백한다. 비로그인 가입 backfill(after_register listener)은 record 가
|
||||
|
||||
+1
-1
@@ -273,7 +273,7 @@ describe('insertChipInValue — 커서 위치 자리표시 삽입', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('타이핑 안전성 — 비제어 평문(글자 뒤섞임 결함 회귀, PO)', () => {
|
||||
describe('타이핑 안전성 — 비제어 평문(글자 뒤섞임 결함 회귀)', () => {
|
||||
it('평문 타이핑(연속 입력) 시 평문 span 을 React 가 덮어쓰지 않는다(비제어)', () => {
|
||||
// 결함 재현 방지: controlled 였다면 onChange→value 갱신→리렌더로 span textContent 가
|
||||
// 매 입력마다 재설정되어 커서가 튀고 글자 순서가 깨졌다("하나둘"→"퉁둘낭나한하").
|
||||
|
||||
+1
-1
@@ -278,7 +278,7 @@ describe('셀 테두리/텍스트', () => {
|
||||
colors: [{ value: 'red', swatch: '#ef4444', token: 'border-red-500' }],
|
||||
};
|
||||
const paramsCat = { ...params, cellBorder: catalog };
|
||||
// 셀에 두께(border)를 미리 부여 → 색 피커가 즉시 노출(앵커 셀 className 기반). 실제 PO
|
||||
// 셀에 두께(border)를 미리 부여 → 색 피커가 즉시 노출(앵커 셀 className 기반). 실제 운영자
|
||||
// 흐름(얇게 선택된 상태)과 동일. (단위 테스트의 onPatchNode mock 은 node prop 을
|
||||
// 되먹이지 않아 두께 클릭→리렌더로는 색 피커가 안 떠 별도 셀 className 으로 노출.)
|
||||
const bc = (text: string): EditorNode => ({ type: 'basic', name: 'Td', text, props: { className: 'border' } });
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* ConditionBuilder 가 combineConditions 로 만든 단일 `{{ }}` 식이 실제 런타임
|
||||
* `evaluateStringCondition`(→ optional chaining 식이라 `evaluateExpression` 진입,
|
||||
* CLAUDE.md `if` 평가 규칙)으로 샘플 상태에 따라 표시/숨김 토글되는지 검증한다.
|
||||
* 코어 `if` 평가 규칙)으로 샘플 상태에 따라 표시/숨김 토글되는지 검증한다.
|
||||
*
|
||||
* 단위 시뮬레이션이 아닌 실제 평가 엔진 라운드트립 — 합성식의 문법 정합성을 잠근다.
|
||||
*/
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* actionRecipeEngine.test.ts — 친화 액션 레시피 → 핸들러 JSON DataProvider
|
||||
*
|
||||
* CLAUDE.md "CRITICAL RULES — API/핸들러 호출" 전수 회귀 가드 포함:
|
||||
* 코어 핸들러 규칙(API/핸들러 호출) 전수 회귀 가드 포함:
|
||||
* - apiCall(api 아님)/navigate(nav 아님)/setState+target/toast(showToast 아님)/
|
||||
* refetchDataSource.dataSourceId(id 아님).
|
||||
* - apiCall 의 target/onSuccess/onError 는 액션 top-level.
|
||||
@@ -70,7 +70,7 @@ describe('normalizeActionRecipes', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAction — CLAUDE.md 핸들러 규칙 준수', () => {
|
||||
describe('buildAction — 코어 핸들러 규칙 준수', () => {
|
||||
const recipes = normalizeActionRecipes(RECIPES);
|
||||
const byId = (id: string) => recipes.find((r) => r.id === id)!;
|
||||
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* conditionRecipeEngine.test.ts — 친화 조건 → if 식 합성 DataProvider
|
||||
*
|
||||
* CLAUDE.md `if` 규칙 회귀 가드:
|
||||
* 코어 `if` 규칙 회귀 가드:
|
||||
* - 최종 결과 전체가 단일 `{{ }}` 한 쌍 — 중첩 보간 `{{ {{x}} }}` 미발생.
|
||||
* - 옵셔널 체이닝(`?.`) / 배열 길이 `(arr?.length ?? 0)` 형태 보존.
|
||||
* - 로그인 판정 경로 보존(임의 식 금지).
|
||||
@@ -65,7 +65,7 @@ describe('buildClauseExpr — {paramKey} 치환', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('combineConditions — 단일 {{ }} 합성 (CLAUDE.md if 규칙)', () => {
|
||||
describe('combineConditions — 단일 {{ }} 합성 (코어 if 규칙)', () => {
|
||||
it('단일 조건 → {{ 식 }}', () => {
|
||||
const r = combineConditions([{ operator: 'isLoggedIn', params: {} }], 'and', ops);
|
||||
expect(r).toBe('{{ _global?.currentUser?.uuid }}');
|
||||
|
||||
+2
-2
@@ -9,7 +9,7 @@
|
||||
*
|
||||
* 가드 항목:
|
||||
* 1. 신규 6개 admin composite 가 nesting.draggable 등재 + capability 보유.
|
||||
* 2. 그 6개 tsx 가 editorAttrs 를 시각 루트에 spread (선택/클릭 가능 — PO 가 직접
|
||||
* 2. 그 6개 tsx 가 editorAttrs 를 시각 루트에 spread (선택/클릭 가능 — 검수자가 직접
|
||||
* 발견한 AdminSidebar/AdminFooter 선택 불가 결함의 회귀 가드).
|
||||
* 3. SectionHeader 가 componentPalette 에 등재 (팔레트 추가 가능).
|
||||
* 4. ecommerce editor-spec 에 신규 admin 화면 5개 states 그룹 등록 (상태 토글 노출).
|
||||
@@ -83,7 +83,7 @@ describe('편집기 갭 보강 — 신규 admin composite 편집 가능성', ()
|
||||
|
||||
describe('편집기 갭 보강 — editorAttrs 시각 루트 spread (선택/클릭 가능)', () => {
|
||||
// base 골격/위젯이 편집기 주입 editorAttrs 를 시각 루트에 흘려야 클릭이 그 노드에 닿는다.
|
||||
// (PO 가 base 편집 모드에서 AdminSidebar/AdminFooter 클릭 시 부모 Div 만 선택되던 결함)
|
||||
// (검수자가 base 편집 모드에서 AdminSidebar/AdminFooter 클릭 시 부모 Div 만 선택되던 결함)
|
||||
const FILES: Record<string, string> = {
|
||||
AdminSidebar: `${ADMIN}/src/components/composite/AdminSidebar.tsx`,
|
||||
AdminFooter: `${ADMIN}/src/components/composite/AdminFooter.tsx`,
|
||||
|
||||
+3
-3
@@ -283,7 +283,7 @@ describe('디그레이드 — 손상 0 폴백', () => {
|
||||
});
|
||||
|
||||
describe('hasDecomposableExpressionSegment — 다중 세그먼트 + 표현식 조각', () => {
|
||||
// 양 템플릿 실측 9건(다중 세그먼트 + 진짜 삼항 분기 토큰) — PO board/form 제목 케이스 포함.
|
||||
// 양 템플릿 실측 9건(다중 세그먼트 + 진짜 삼항 분기 토큰) — 실측 board/form 제목 케이스 포함.
|
||||
const REAL_MULTISEGMENT = [
|
||||
"{{route.id ? '$t:board.edit_post' : '$t:board.new_post'}} - {{form_meta?.data?.board?.name || ''}}",
|
||||
"{{_local.collapsedReplies?.[comment?.id] === false ? '$t:board.hide_replies' : '$t:board.show_replies'}} ({{comment?.replies_count}})",
|
||||
@@ -324,7 +324,7 @@ describe('previewSegments — 접힌 미리보기 토큰("한 값만 해석")',
|
||||
expect(toks[1]).toEqual({ kind: 'ellipsis', text: '⋯' });
|
||||
});
|
||||
|
||||
it('PO board/form 다중 세그먼트 → 게시글 수정⋯ + 평문 + 데이터 칩', () => {
|
||||
it('실측 board/form 다중 세그먼트 → 게시글 수정⋯ + 평문 + 데이터 칩', () => {
|
||||
const toks = previewSegments(
|
||||
"{{route.id ? '$t:board.edit_post' : '$t:board.new_post'}} - {{form_meta?.data?.board?.name || ''}}",
|
||||
resolve,
|
||||
@@ -606,7 +606,7 @@ describe('extractFirstLeafText / reduceExpressionToPlain — 표현식 → 일
|
||||
|
||||
// og.title `{{route.id ? '$t:edit': product.title}}` 류에서 첫 결과(then)가
|
||||
// 데이터인 경우. 미리보기 "기본 값 → 🔗 product.title" 과 적용이 일치해야 한다.
|
||||
it('조건식 첫 분기가 데이터면 그 데이터 바인딩으로 복구(PO og.title 케이스)', () => {
|
||||
it('조건식 첫 분기가 데이터면 그 데이터 바인딩으로 복구(실측 og.title 케이스)', () => {
|
||||
expect(reduceExpressionToPlain("{{route.id ? product.title : '$t:shop.new'}}")).toBe('{{product.title}}');
|
||||
});
|
||||
|
||||
|
||||
@@ -1060,7 +1060,7 @@ export function PreviewCanvas(props: PreviewCanvasProps = {}): React.ReactElemen
|
||||
>
|
||||
<div
|
||||
// 프리뷰 색상 테마 — dark 면 프레임에 `.g7le-preview-dark` 마커 부여.
|
||||
// 종전 `.dark` 는 PO admin 환경의 조상 `html.dark` 와 충돌해 프리뷰 라이트 격리가
|
||||
// 종전 `.dark` 는 관리자 admin 환경의 조상 `html.dark` 와 충돌해 프리뷰 라이트 격리가
|
||||
// 깨졌다(조상 어디든 `.dark` 면 Tailwind `dark:` 활성). 코어 CSS 서빙 API(serveEditorCss)
|
||||
// 가 편집기용 CSS 의 `.dark` 셀렉터를 이 마커로 치환해 서빙하므로, 프리뷰 프레임의 이
|
||||
// 마커만으로 다크가 켜지고 조상 html.dark 와 독립된다(라이트 토글 시 확실히 라이트).
|
||||
|
||||
+1
-1
@@ -311,7 +311,7 @@ function DataSegmentPicker({
|
||||
candidates={candidates}
|
||||
t={t}
|
||||
onSelect={(c) => {
|
||||
// shape 안전 바인딩(`{{src?.path ?? ''}}`) — 데이터 미도착 시 런타임 에러 방지(CLAUDE.md
|
||||
// shape 안전 바인딩(`{{src?.path ?? ''}}`) — 데이터 미도착 시 런타임 에러 방지(코어
|
||||
// fallback 필수). buildBindingExpression 이 scalar 폴백(`?? ''`)을 붙인다.
|
||||
onPick(buildBindingExpression(c.sourceId, c.path, 'scalar'));
|
||||
}}
|
||||
|
||||
+2
-2
@@ -829,7 +829,7 @@ function PlainInsertDataButton({
|
||||
return (
|
||||
<div data-testid={`${testidPrefix}-plus-data`} style={{ display: 'inline-block' }}>
|
||||
{/* `+` 버튼은 항상 자리 유지(피커가 열려도 플로우 폭 불변). 피커는 공용 FloatingDropdown 으로
|
||||
버튼 아래 띄우되 위치 자동 보정(flip/clamp)으로 좁은 입력 행 어디서든 잘리지 않는다(PO
|
||||
버튼 아래 띄우되 위치 자동 보정(flip/clamp)으로 좁은 입력 행 어디서든 잘리지 않는다(검수
|
||||
2026-06-13 — 하드코딩 right:0 으로 좌측 쏠려 가려지던 결함 → 범용 위치 보정으로 통일). */}
|
||||
<button
|
||||
ref={plusRef}
|
||||
@@ -861,7 +861,7 @@ function PlainInsertDataButton({
|
||||
}
|
||||
|
||||
/**
|
||||
* 표현식 접힌 미리보기 — 읽기전용 한 줄(키 해석·데이터 칩) + [수정] 버튼 (PO
|
||||
* 표현식 접힌 미리보기 — 읽기전용 한 줄(키 해석·데이터 칩) + [수정] 버튼 (검수
|
||||
* "기본 접힌 미리보기 + [수정]" 2026-06-13). 진입 시 정보 과다 방지 — [수정] 클릭 시 분해 빌더 펼침.
|
||||
*
|
||||
* 미리보기 = `previewSegments`("한 값만 해석" — 조건은 참 분기 + ⋯, 키는 해석, 바인딩은 칩).
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ export interface InlineBindingScalarPickerProps {
|
||||
*/
|
||||
defaultOpen?: boolean;
|
||||
/**
|
||||
* 펼침 시 검색창+결과를 **부유 드롭다운**(공용 `FloatingDropdown`)으로 띄울지 (PO
|
||||
* 펼침 시 검색창+결과를 **부유 드롭다운**(공용 `FloatingDropdown`)으로 띄울지 (검수
|
||||
* "돋보기 누르면 화면 50% 차지 — 부유 상태로 떠야" 2026-06-13 / "데이터 선택기가 부착된 모든 UI 를
|
||||
* 동일하게 부유 처리" 2026-06-14). **기본 true** — 어느 진입점이든 펼치면 토글 기준으로 떠서 행/패널을
|
||||
* 밀어내지 않는다(좁은 조건 행·데이터 조각·[속성]탭 폼 공통). 종전엔 기본 false(인라인)였으나, 새
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* 일부면 문자열 보간한다. `onSuccess`/`onError` 가 `action-list` 파라미터를 가리키면
|
||||
* 치환 결과는 **중첩 액션 배열**이다(12.2.3 — 문자열 보간 아님).
|
||||
*
|
||||
* 생성 JSON 은 CLAUDE.md "CRITICAL RULES — API/핸들러 호출" 을 위반하지 않는다.
|
||||
* 생성 JSON 은 코어 핸들러 규칙(API/핸들러 호출)을 위반하지 않는다.
|
||||
* `assertHandlerRules` 가 잘못된 핸들러명/위치를 dev 경고로 잡는다(레시피 작성자
|
||||
* 가드). 코어는 핸들러를 가정하지 않으므로(원칙 4.8) 레시피의 `build.handler` 를
|
||||
* 그대로 쓰되, 알려진 금지 패턴만 경고한다.
|
||||
@@ -34,7 +34,7 @@ export interface NormalizedActionRecipe {
|
||||
build: ActionBuildTemplate;
|
||||
}
|
||||
|
||||
/** CLAUDE.md 금지 → 올바른 핸들러 매핑 (레시피 작성 가드) */
|
||||
/** 코어 금지 → 올바른 핸들러 매핑 (레시피 작성 가드) */
|
||||
const HANDLER_ALIASES: Record<string, string> = {
|
||||
api: 'apiCall',
|
||||
nav: 'navigate',
|
||||
@@ -161,7 +161,7 @@ function substituteValue(value: unknown, values: Record<string, unknown>): unkno
|
||||
|
||||
/**
|
||||
* 알려진 핸들러 금지 패턴을 dev 경고로 잡고 올바른 핸들러로 교정.
|
||||
* (CLAUDE.md CRITICAL RULES — 레시피 작성자 가드. 코어는 핸들러를 강제하지 않되
|
||||
* (코어 핸들러 규칙 — 레시피 작성자 가드. 코어는 핸들러를 강제하지 않되
|
||||
* 명백한 금지 별칭만 교정한다.)
|
||||
*
|
||||
* @param action 빌드된 액션 1건
|
||||
@@ -173,7 +173,7 @@ export function assertHandlerRules(action: Record<string, unknown>): Record<stri
|
||||
const corrected = HANDLER_ALIASES[handler];
|
||||
if (corrected) {
|
||||
logger.warn(
|
||||
`action recipe used forbidden handler "${handler}" — corrected to "${corrected}" (CLAUDE.md CRITICAL RULES)`,
|
||||
`action recipe used forbidden handler "${handler}" — corrected to "${corrected}" (core handler rules)`,
|
||||
);
|
||||
return { ...action, handler: corrected };
|
||||
}
|
||||
@@ -185,7 +185,7 @@ export function assertHandlerRules(action: Record<string, unknown>): Record<stri
|
||||
for (const k of ['target', 'onSuccess', 'onError']) {
|
||||
if (k in (params as Record<string, unknown>)) {
|
||||
logger.warn(
|
||||
`action recipe build placed "${k}" inside params for handler "${handler}" — should be top-level (CLAUDE.md)`,
|
||||
`action recipe build placed "${k}" inside params for handler "${handler}" — should be top-level (core handler rules)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,7 +349,7 @@ export interface ParsedBinding {
|
||||
optional?: boolean;
|
||||
/**
|
||||
* 원본의 널 병합 폴백 식 — `?? []` 의 `[]`, `?? ''` 의 `''` 등(있으면). 재기입 시 동일
|
||||
* 폴백을 유지해 데이터 미도착 시 런타임 에러를 막는다(CLAUDE.md fallback 필수 규칙).
|
||||
* 폴백을 유지해 데이터 미도착 시 런타임 에러를 막는다(코어 fallback 필수 규칙).
|
||||
*/
|
||||
fallback?: string;
|
||||
/**
|
||||
@@ -515,7 +515,7 @@ export function parseBindingExpression(value: unknown): ParsedBinding | null {
|
||||
* 검색 피커에서 후보를 선택하면 그 후보의 `expression`(단순 형태)을 그대로 쓰지 않고,
|
||||
* shape 에 맞는 안전 형태로 다시 만든다 — `?.` 체이닝 + shape 별 폴백(array `?? []`,
|
||||
* scalar `?? ''`). 이렇게 해야 데이터 미도착 시 런타임 에러(`undefined.map`/`.length`)를
|
||||
* 막는다(CLAUDE.md "fallback 필수" 규칙).
|
||||
* 막는다(코어 "fallback 필수" 규칙).
|
||||
*
|
||||
* SEO 단일-경로 추출 함수(`$localized`)로 래핑된 바인딩을 다시 쓸 때는 `localeFn` 을 넘긴다.
|
||||
* `$localized(<src>.<path>)` 형태로 만들고 폴백/옵셔널 체이닝은 붙이지 않는다 — `$localized` 는
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* evaluateExpression` 을 그대로 호출한다(운영 렌더와 동일 식 해석 경로,
|
||||
* [[feedback_editor_sample_verify_computed_body_not_just_state]]).
|
||||
*
|
||||
* 모든 생성 출력은 **`{{ 식 }}` 한 쌍**이다(CLAUDE.md `if`/computed 규칙 — 중첩 보간
|
||||
* 모든 생성 출력은 **`{{ 식 }}` 한 쌍**이다(코어 `if`/computed 규칙 — 중첩 보간
|
||||
* `{{ {{x}} }}` 미발생). 매칭/역해석은 식 원문을 변경하지 않는다(인식만).
|
||||
*
|
||||
* @since engine-v1.50.0
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
* conditionRecipeEngine.ts — 친화 조건 레시피 ↔ `if` 표현식 변환
|
||||
*
|
||||
* 속성 모달 "표시조건" 탭이 친화 조건(12.4.3 A~H) + 파라미터로 `comp.if` 표현식을
|
||||
* 합성한다. 표현식 문법 규칙(12.4.2 / CLAUDE.md `if` 평가 규칙):
|
||||
* 합성한다. 표현식 문법 규칙(12.4.2 / 코어 `if` 평가 규칙):
|
||||
*
|
||||
* - 레시피 `expr` 은 **`{{ }}` 없이** 식 본문만 적고, 파라미터 자리는 레시피
|
||||
* 전용 플레이스홀더 `{paramKey}`(중괄호 **1개**)를 쓴다. 런타임 `{{}}` 와 구분.
|
||||
* - 여러 조건을 `&&`/`||` 로 결합한 뒤 **최종 결과 전체를 단일 `{{ }}` 한 쌍**
|
||||
* 으로 감싼다. 중첩 보간 `{{ {{x}} }}` 는 만들지 않는다(파서 모호성·CLAUDE.md).
|
||||
* 으로 감싼다. 중첩 보간 `{{ {{x}} }}` 는 만들지 않는다(파서 모호성·코어 규칙).
|
||||
* - 옵셔널 체이닝(`?.`) / 배열 길이 `(arr?.length ?? 0)` 형태는 레시피 작성자의
|
||||
* `expr` 책임 — 엔진은 치환·결합만 한다.
|
||||
*
|
||||
@@ -259,7 +259,7 @@ export function buildClauseExpr(
|
||||
* operator 는 제외. 유효 절이 없으면 빈 문자열(= if 제거).
|
||||
*
|
||||
* 중첩 보간 방지: 각 절 식 본문에는 `{{` 가 없으므로(레시피 `expr` 규칙), 최종
|
||||
* 한 번만 `{{ }}` 로 감싼다 → `{{ {{x}} }}` 미발생(CLAUDE.md `if` 규칙).
|
||||
* 한 번만 `{{ }}` 로 감싼다 → `{{ {{x}} }}` 미발생(코어 `if` 규칙).
|
||||
*
|
||||
* @param clauses 조건 절 목록
|
||||
* @param combinator 결합자 (and/or)
|
||||
|
||||
@@ -155,7 +155,7 @@ export function removeBindingSegment(text: string, bindingIndex: number): string
|
||||
* - 원문이 공백으로 끝나면 그대로 이어 붙이고, 아니면 공백 1개로 구분(`"안녕 {{이름}}"`).
|
||||
*
|
||||
* 표현식은 부록6 `buildBindingExpression` 으로 shape 안전 형태를 만든다(데이터 미도착 시
|
||||
* 런타임 에러 방지 — CLAUDE.md fallback 필수).
|
||||
* 런타임 에러 방지 — 코어 fallback 필수).
|
||||
*
|
||||
* @param text 원문
|
||||
* @param sourceId 소스 식별자(data_source id 또는 상태 scope)
|
||||
|
||||
@@ -703,9 +703,9 @@ export interface ActionRecipeParamSpec {
|
||||
* 치환 결과는 중첩 액션 **배열**이 된다(문자열 보간이 아니라 값 주입).
|
||||
*/
|
||||
export interface ActionBuildTemplate {
|
||||
/** 핸들러명 — CLAUDE.md CRITICAL RULES 의 올바른 핸들러 (navigate/apiCall/...) */
|
||||
/** 핸들러명 — 코어 핸들러 규칙의 올바른 핸들러 (navigate/apiCall/...) */
|
||||
handler: string;
|
||||
/** setState 등의 target (top-level — params 안에 두지 않음, CLAUDE.md) */
|
||||
/** setState 등의 target (top-level — params 안에 두지 않음, 코어 규칙) */
|
||||
target?: unknown;
|
||||
/** apiCall 성공 후속 — `{{paramKey}}`(action-list) 또는 정적 배열 */
|
||||
onSuccess?: unknown;
|
||||
|
||||
+1
-1
@@ -762,7 +762,7 @@ Route::prefix('admin')->middleware(['auth:sanctum', 'check.user_status', 'admin'
|
||||
->middleware('permission:admin,core.templates.layouts.edit')
|
||||
->name('api.admin.templates.editor-permission-candidates');
|
||||
// 편집기 프리뷰 전용 CSS — 다크 셀렉터를 프리뷰 마커(.g7le-preview-dark)로 치환해 서빙
|
||||
// PO admin 의 html.dark 조상과 독립적으로 프리뷰 라이트/다크 격리.
|
||||
// 관리자 admin 의 html.dark 조상과 독립적으로 프리뷰 라이트/다크 격리.
|
||||
Route::get('{identifier}/editor/components.css', [AdminTemplateAssetController::class, 'serveEditorCss'])
|
||||
->middleware('permission:admin,core.templates.layouts.edit')
|
||||
->name('api.admin.templates.editor-css');
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
* @file admin-base-header-lang-currency.test.tsx
|
||||
* @description 관리자 헤더 언어·통화 선택기 패리티 회귀 테스트
|
||||
*
|
||||
* 배경(PO 검수 지적):
|
||||
* 배경(검수 지적):
|
||||
* - 관리자 데스크톱 언어 버튼이 globe 아이콘만 표시하고 현재 선택 언어를 안 보여줌(유저는 코드 표시).
|
||||
* → LanguageSelector 에 showCode 도입, _admin_base 가 showCode:true 로 사용.
|
||||
* - 관리자 모바일 헤더에 언어·통화 버튼이 둘 다 없음(데스크톱에만 존재).
|
||||
|
||||
+1
-1
@@ -144,7 +144,7 @@ describe('환경설정 > 본인인증 정책 모달 회귀 테스트 (옵션 K)'
|
||||
* disabled 미부여(편집 가능). 안내 문구도 "키·시점·위치만 변경 불가" 로 정합.
|
||||
*
|
||||
* 배경: 과거 purpose/applies_to 까지 잠겼던 것은 "선언형 readonly" 원칙의 과확장이었으며,
|
||||
* 인증 목적은 어떤 정책이든 운영자가 자유 부여 가능해야 한다(PO).
|
||||
* 인증 목적은 어떤 정책이든 운영자가 자유 부여 가능해야 한다.
|
||||
*/
|
||||
const LOCK_PREDICATE = "!_global.identity_policy_form_modal?.isNew && _global.identity_policy_form_modal?.form?.source_type !== 'admin'";
|
||||
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
* 트리에 유지된다(백엔드 CommentRepository 가 tombstone 부모를 조회 트리에 복구).
|
||||
* - tombstone 안내는 deleted_at 만 게이트로 하며 content 존재에 의존하지 않는다
|
||||
* (비권한자는 content 가 "삭제된 댓글입니다" 문구라도 안내는 보여야 함).
|
||||
* - 원문은 블라인드와 동일하게 관리자(can_manage)만 토글 열람 (PO 확정 — 버튼 유지).
|
||||
* - 원문은 블라인드와 동일하게 관리자(can_manage)만 토글 열람 (정책 확정 — 버튼 유지).
|
||||
* - 자식 댓글 렌더는 부모의 삭제 상태와 독립적으로 동작한다.
|
||||
*
|
||||
* 회귀 배경: 과거 일반 조회에서 삭제 부모가 빠지며 자식까지 트리에서 누락 → 자식 동반 실종.
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
* @description 게시판 목록/검색 미리보기 비밀글·블라인드 차단 게이트 회귀 (이슈 #413-37)
|
||||
*
|
||||
* 검증 방식: 레이아웃 JSON 트리 직접 분석 (DOM 비의존).
|
||||
* 정책(PO 확정): 목록/검색 미리보기(content_preview)는 비밀글·블라인드 글에서
|
||||
* 정책(확정): 목록/검색 미리보기(content_preview)는 비밀글·블라인드 글에서
|
||||
* 권한과 무관하게 빈 문자열로 차단된다. 백엔드(PostResource::toListArray ->
|
||||
* getMaskedContentPreviewForList)가 빈 문자열을 응답하면, 미리보기 노드의 if 가
|
||||
* content_preview 존재(truthy)에 의존하므로 자동으로 미렌더된다.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @file coupon-download-modal.test.tsx
|
||||
* @description U10 — 쿠폰 다운로드 모달 무한스크롤 회귀 테스트 (MP10 §9, PO 재설계)
|
||||
* @description U10 — 쿠폰 다운로드 모달 무한스크롤 회귀 테스트 (MP10 §9, 재설계)
|
||||
*
|
||||
* 검증 항목 (최종 무한스크롤 설계 기준):
|
||||
* - last_page 기반 버튼식 페이지네이션 블록(이전/다음/카운터) 미렌더
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* 배경:
|
||||
* - partial_cancelled 주문상태 제거 후, 일부 취소 사실은 파생 플래그 is_partially_cancelled 로 표시한다.
|
||||
* - 목록(_list.json) 헤더 행은 flex justify-between [좌: 날짜/주문번호] vs [우: 상태 배지] 구조다.
|
||||
* 보조 배지를 헤더 행의 3번째 형제로 넣으면 justify-between 이 3개를 분산시켜 레이아웃이 깨진다(PO 발견).
|
||||
* 보조 배지를 헤더 행의 3번째 형제로 넣으면 justify-between 이 3개를 분산시켜 레이아웃이 깨진다(검수 중 발견).
|
||||
* → 상태 배지와 보조 배지를 하나의 세로 묶음(flex-col items-end) Div 로 감싸 "결제완료" 아래에 배치한다.
|
||||
*
|
||||
* 회귀 차단:
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@
|
||||
* A2) checkoutData GET 이 country_code 를 서버에 전달하지 않아 서버가 KR 폴백
|
||||
* A3) 배송국가 Select 변경 시 PUT /checkout(재계산) 호출이 없어 국가만 바뀌고 배송비 그대로
|
||||
*
|
||||
* 설계 원칙(PO 확정): 주문서에서 선택한 배송국가가 SSoT.
|
||||
* 설계 원칙(확정): 주문서에서 선택한 배송국가가 SSoT.
|
||||
* 헤더는 "기본값" 만 제공(_global.preferredShippingCountry), 진입 후엔 주문서 Select 가 우선.
|
||||
* 우편번호는 국가 결정과 무관 — 도서산간/주별 세부 차등에만 관여.
|
||||
*/
|
||||
|
||||
@@ -277,7 +277,7 @@ describe('guest_order_form.json — 비회원 조회 폼 ( 그룹 C)', () => {
|
||||
expect(clear, 'clearGuestTokenOnEntry 누락 — 토큰 보유자에게는 자동 진입되어 폼 노출 안 됨, 다른 주문 조회 불가').toBeDefined();
|
||||
});
|
||||
|
||||
it('onError 는 _local.lookupError boolean 플래그만 set 해야 한다 (toast 가 아닌 페이지 내 인라인 에러 박스로 통일 메시지 노출 — PO UX 결정 + 보안 정책 실패 사유 비노출)', () => {
|
||||
it('onError 는 _local.lookupError boolean 플래그만 set 해야 한다 (toast 가 아닌 페이지 내 인라인 에러 박스로 통일 메시지 노출 — UX 결정 + 보안 정책 실패 사유 비노출)', () => {
|
||||
const verifyCall = findNode(
|
||||
guestFormJson,
|
||||
(n) => n.handler === 'apiCall' && n.target?.endsWith('/guest/orders/verify')
|
||||
@@ -314,7 +314,7 @@ describe('guest_order_form.json — 비회원 조회 폼 ( 그룹 C)', () => {
|
||||
expect(resetAction, 'submit 시작 시 lookupError 초기화 누락 — 이전 실패 박스가 그대로 노출됨').toBeDefined();
|
||||
});
|
||||
|
||||
it('회원 로그인 사용자가 실수로 진입한 경우 lifecycle.onMount 가 toast + /mypage/orders 로 navigate 해야 한다 (PO UX 결정 — 회원은 본 페이지에서 회원 주문 조회 불가)', () => {
|
||||
it('회원 로그인 사용자가 실수로 진입한 경우 lifecycle.onMount 가 toast + /mypage/orders 로 navigate 해야 한다 (UX 결정 — 회원은 본 페이지에서 회원 주문 조회 불가)', () => {
|
||||
const memberGuardBlock = findNode(
|
||||
guestFormJson,
|
||||
(n) => n.if === '{{_global?.currentUser?.uuid}}' && findNode(n, (m: any) => m.lifecycle?.onMount)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "레이아웃 편집기 스펙 — Phase 3 (nesting + componentPalette 블록). controls/componentCapabilities/actionRecipes 등은 Phase 4/5 에서 추가.",
|
||||
"styleSystem": "tailwind",
|
||||
"darkMode": {
|
||||
"comment": "Tailwind 다크는 조상.dark 클래스. 편집기 프리뷰는 PO admin 의 html.dark 조상과 독립적으로 라이트/다크를 보여줘야 하므로, 코어 CSS 서빙 API 가 편집기용 CSS 의.dark 셀렉터를 프리뷰 전용 마커(.g7le-preview-dark)로 치환해 서빙한다. 사용자 페이지 CSS 는 원본 그대로.",
|
||||
"comment": "Tailwind 다크는 조상.dark 클래스. 편집기 프리뷰는 관리자 admin 의 html.dark 조상과 독립적으로 라이트/다크를 보여줘야 하므로, 코어 CSS 서빙 API 가 편집기용 CSS 의.dark 셀렉터를 프리뷰 전용 마커(.g7le-preview-dark)로 치환해 서빙한다. 사용자 페이지 CSS 는 원본 그대로.",
|
||||
"strategy": "ancestor-class",
|
||||
"ancestorSelector": ".dark",
|
||||
"previewIsolation": {
|
||||
|
||||
+1
-1
@@ -413,7 +413,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "신고 실패(409 중복/403/422) 시 모달을 닫는다 → 에러 토스트만 화면에 노출되어 '진행 불가'처럼 보이던 현상 해소 (이슈 #413-60 증상1, PO 결정: 모달 닫고 토스트)",
|
||||
"comment": "신고 실패(409 중복/403/422) 시 모달을 닫는다 → 에러 토스트만 화면에 노출되어 '진행 불가'처럼 보이던 현상 해소 (이슈 #413-60 증상1, 설계 결정: 모달 닫고 토스트)",
|
||||
"handler": "closeModal"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
"props": { "className": "space-y-4" },
|
||||
"children": [
|
||||
{
|
||||
"comment": "인라인 에러 박스 — 인증 실패 시 toast 대신 폼 상단에 통일 메시지 노출 (PO UX 결정). 실패 사유는 구분하지 않고 단일 boolean 플래그로 표시 (보안 정책).",
|
||||
"comment": "인라인 에러 박스 — 인증 실패 시 toast 대신 폼 상단에 통일 메시지 노출 (UX 결정). 실패 사유는 구분하지 않고 단일 boolean 플래그로 표시 (보안 정책).",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"if": "{{_local.lookupError}}",
|
||||
@@ -296,7 +296,7 @@
|
||||
],
|
||||
"onError": [
|
||||
{
|
||||
"comment": "보안 정책 — 실패 사유(주문 없음/비밀번호 오류/잠금)를 구분하지 않고 단일 boolean 플래그로 통일. 폼 상단 인라인 에러 박스가 lookupError 가 true 일 때 노출된다 (toast 대신 페이지 내 메시지 박스 — PO UX 결정).",
|
||||
"comment": "보안 정책 — 실패 사유(주문 없음/비밀번호 오류/잠금)를 구분하지 않고 단일 boolean 플래그로 통일. 폼 상단 인라인 에러 박스가 lookupError 가 true 일 때 노출된다 (toast 대신 페이지 내 메시지 박스 — UX 결정).",
|
||||
"handler": "setState",
|
||||
"params": { "target": "local", "isSubmitting": false, "errors": "{{error.errors ?? null}}", "lookupError": true }
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user