fix(sirsoft-ecommerce): 주문상세 상품 목록 숫자 열 우측 정렬 통일

상품 목록의 숫자 6개 열 중 `구매수량`·`적립예정` 만 가운데 정렬이라 나머지 네 열과
세로 기준선이 어긋나 보였다. 두 열은 값 아래 배지를 쌓기 위해 `flex flex-col` 을
쓰는데 교차축 정렬이 `items-center` 로 남아 있던 것이 원인. `items-end` 로 바꿔
배지 세로 배치를 유지한 채 `text-right` 열과 같은 우측 기준선에 맞췄다.

공유 컴포넌트인 DataGrid 는 8개 이상 레이아웃이 사용하므로 손대지 않았다.

배포 후 실제 DOM 실측으로 확인: Quantity·Points 는 alignItems=flex-end,
Price·Discount·Actual Price·Subtotal 은 textAlign=right, 비숫자 열(상품정보·상태·
배송)은 정렬 대상에서 제외.

회귀 spec 추가 — `items-center` 는 text-align 이 아니라 align-items 로 정렬하므로
textAlign 만 단언하면 두 셀을 영영 못 잡는다. 콘텐츠의 우측 여백을 재고, 절대
임계값 대신 같은 행 안에서 열끼리 여백이 서로 같은지를 단언한다. 헤더 텍스트로
열을 찾지 않는다(E2E 세션 로케일이 en).

함께 수정 — 모듈 E2E 스위트가 수집 단계에서 전체 실패하고 있었다. 모듈 config 는
--config 로만 실행되어 여태 아무도 돌린 적이 없었다.

- 미정의 fixture 3종 추가(userToken/customerToken/ordersReadToken). Playwright 는
 describe.skip 블록도 수집 시점에 fixture 파라미터를 해석한다.
- mp06 spec 주석에서 `OrderCalculation*/` 의 `*/` 가 블록 주석을 조기 종료시켜
 SyntaxError 가 나던 문제 수정.

검증: 모듈 E2E 2 passed/63 skipped/0 failed, Vitest 2382 passed/5 failed
(clean HEAD 와 동일 — 선행 결함, 무회귀), audit 0 error.
This commit is contained in:
HeuJung
2026-07-10 19:27:26 +09:00
parent 0ff4b674ac
commit 0f84657a5c
5 changed files with 155 additions and 4 deletions
@@ -18,6 +18,7 @@
- 휴대폰 화면에서 쇼핑몰 환경설정 화면이 좌우로 넘쳐 가로 스크롤이 생기던 문제를 수정했습니다 (이메일 입력 줄, 쇼핑몰 이름·종목 입력칸, 탭 영역).
- 휴대폰 화면에서 쿠폰 발급기간 입력칸과 엑셀 다운로드 기간 검색 입력칸이 화면 밖으로 넘치던 문제를 수정했습니다.
- 은행 관리·도서산간 배송비 팝업의 표가 좁은 화면에서 가로로 스크롤되도록 명시해, 앞으로 표가 잘려 보이지 않도록 했습니다.
- 관리자 주문상세의 상품 목록에서 구매수량·적립예정만 가운데로 놓여 다른 금액 열과 세로줄이 어긋나 보이던 문제를 수정했습니다. 이제 여섯 개 숫자 열이 모두 오른쪽 끝에 맞춰 정렬됩니다. 휴대폰 화면의 카드 형태에서도 동일하게 적용됩니다.
## [1.0.1] - 2026-07-08
@@ -430,7 +430,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-col items-center gap-1 whitespace-nowrap"
"className": "flex flex-col items-end gap-1 whitespace-nowrap"
},
"children": [
{
@@ -683,7 +683,7 @@
"type": "basic",
"name": "Div",
"props": {
"className": "flex flex-col items-center gap-1 whitespace-nowrap"
"className": "flex flex-col items-end gap-1 whitespace-nowrap"
},
"children": [
{
@@ -27,6 +27,12 @@ type EcommerceAuthFixtures = {
mileageReadOnlyToken: string;
/** 상품 조회 + 수정 권한 보유 토큰 (상품폼 등록/수정 검증용) */
productManageToken: string;
/** 주문 조회(`orders.read`) 권한 보유 토큰 (주문 상세 표시 검증용) */
ordersReadToken: string;
/** 일반 쇼핑 사용자 토큰 (관리자 권한 없음 — 유저 화면 검증용) */
userToken: string;
/** 구매 고객 토큰 (장바구니/추가옵션 등 쇼핑 플로우 검증용) */
customerToken: string;
};
export const test = base.extend<EcommerceAuthFixtures>({
@@ -49,6 +55,18 @@ export const test = base.extend<EcommerceAuthFixtures>({
productManageToken: async ({}, use) => {
await use(issueToken('sirsoft-ecommerce.products.read', 'sirsoft-ecommerce.products.update'));
},
ordersReadToken: async ({}, use) => {
await use(issueToken('sirsoft-ecommerce.orders.read'));
},
// userToken / customerToken 은 현재 둘 다 "관리자 권한 없는 인증 사용자" 로 동일하다.
// 유저 화면(마이페이지/헤더)과 구매 플로우(장바구니/주문서)는 요구 권한이 갈릴 수 있어
// 호출부의 의도를 이름으로 남긴다 — 한쪽에 권한이 붙어도 다른 쪽이 영향받지 않는다.
userToken: async ({}, use) => {
await use(issueToken());
},
customerToken: async ({}, use) => {
await use(issueToken());
},
});
export { authenticatePage };
@@ -13,8 +13,8 @@
* - 무통장 1회 제한 쿠폰은 주문 생성 즉시 USED 차감되어 입금 전까지 재사용 불가(공개#57).
*
* Select 렌더/disabled 바인딩은 레이아웃 렌더 테스트(adminEcommerceSettingsOrder.test.tsx)가,
* 차감 시점 분기·쿠폰 검증·선차감/반환은 PHPUnit(OrderCalculation*/CouponUse*/CouponRestore*/
* EcommerceSettingsMileageTiming/MigrateMileageDeductionTiming)가 커버한다. 본 spec 은 브라우저에서
* 차감 시점 분기·쿠폰 검증·선차감/반환은 PHPUnit(OrderCalculation·CouponUse·CouponRestore·
* EcommerceSettingsMileageTiming·MigrateMileageDeductionTiming)가 커버한다. 본 spec 은 브라우저에서
* 설정 disabled 연동 + 주문서 위반 안내 노출만 왕복 검증한다.
*
* 활성화 전 사전 작업(data-testid 보강):
@@ -0,0 +1,132 @@
/**
* 주문 상세 — 상품 목록 숫자 열 정렬 일치 회귀.
*
* 배경: 상품 목록의 숫자 열 6개 중 `정가/판매가`·`할인가격`·`실 구매가격`·`소계` 는
* `text-right` 로 우측 정렬인데, `구매수량`·`적립예정` 만 `flex flex-col items-center`
* 로 가운데 정렬이라 값이 서로 어긋나 보였다. 표(≥768px)·카드(<768px) 양쪽에서 발생.
*
* 측정 규율 (이 spec 을 고칠 사람에게):
* - `items-center` 는 `text-align` 이 아니라 `align-items` 로 정렬하므로,
* `textAlign === 'right'` 만 단언하면 두 셀을 영영 못 잡는다. 실제 **콘텐츠의
* 우측 여백(rightGap)** 이 열끼리 같은지를 본다.
* - 절대 좌표/임계값(`toBeGreaterThan(N)`)은 레이아웃이 바뀌면 거짓 통과한다.
* 같은 행 안에서 **열끼리 서로 같은가**(집합 크기 1)를 단언한다.
*
* @scenario order_detail_numeric_alignment viewport=1280|390
* @effects numeric_columns_right_aligned_consistently
*/
import { test, expect, authenticatePage } from '../../fixtures/ecommerce-auth';
import type { Page } from '@playwright/test';
/**
* 숫자 값을 담는 열의 수 (정가/판매가·구매수량·할인가격·실 구매가격·적립예정·소계).
*
* 헤더 텍스트로 찾지 않는다 — E2E 세션 로케일이 en 이면 `Quantity` 로 렌더돼
* 한글 문자열 단언이 통째로 새기 때문(실측 확인). 대신 "우측 정렬로 선언된 셀" 을
* 숫자 열로 식별한다. 회귀(= `items-center` 복귀)가 나면 그 셀이 목록에서 빠지므로
* 개수 단언이 먼저 깨진다.
*/
const NUMERIC_COLUMN_COUNT = 6;
/**
* 첫 주문의 상세 URL 을 얻는다 (주문번호 하드코딩 회피).
*
* 목록 화면은 좁은 뷰포트에서 표가 아니라 카드로 렌더되므로, 진입은 항상 데스크톱 폭에서
* 한다. 상세 진입은 `<a href>` 가 아니라 주문번호 `<span>` 클릭(DataGrid onRowClick 계열).
* 상세 URL 은 숫자 id 가 아니라 주문번호(예: `20260709-0144414102`) 다.
*/
async function firstOrderDetailUrl(page: Page): Promise<string> {
await page.setViewportSize({ width: 1280, height: 900 });
await page.goto('/admin/ecommerce/orders');
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
const orderNumber = page.locator('table tbody tr span.cursor-pointer').first();
await orderNumber.waitFor({ state: 'visible', timeout: 30_000 });
await orderNumber.click();
await page.waitForURL(/\/admin\/ecommerce\/orders\/[\w-]+$/, { timeout: 30_000 });
return new URL(page.url()).pathname;
}
/**
* 우측 정렬로 선언된 셀들의 "콘텐츠 우측 여백" 을 잰다.
*
* 셀 경계와 실제 콘텐츠 우측 끝의 거리. 모든 숫자 열이 같은 방식으로 우측 정렬돼 있으면
* 이 값이 열끼리 같다. `구매수량`·`적립예정` 이 `items-center` 로 돌아가면 그 셀은
* 우측 정렬 목록에서 빠지므로 개수가 줄고, 설령 잡히더라도 여백이 달라진다.
*
* `text-right`(block) 와 `items-end`(flex) 둘 다 우측 정렬이므로 함께 센다.
*/
async function rightGaps(page: Page): Promise<number[]> {
return page.evaluate(() => {
const isRightAligned = (el: Element): boolean => {
const cs = getComputedStyle(el);
return cs.textAlign === 'right' || cs.alignItems === 'flex-end';
};
const gapOf = (container: Element, inner: Element): number => {
const content = inner.firstElementChild ?? inner;
return Math.round(container.getBoundingClientRect().right - content.getBoundingClientRect().right);
};
// 표 뷰(≥768px): thead 가 있는 상품 목록 table
const table = [...document.querySelectorAll('table')].find((t) => t.querySelector('tbody tr td'));
if (table) {
const row = table.querySelector('tbody tr');
if (!row) return [];
return [...row.querySelectorAll('td')]
.map((td) => {
const inner = td.firstElementChild;
return inner && isRightAligned(inner) ? gapOf(td, inner) : null;
})
.filter((v): v is number => v !== null);
}
// 카드 뷰(<768px): DataGrid 가 컬럼을 grid 셀로 편다
const grid = [...document.querySelectorAll('[class*="grid-cols-"]')].find((g) => g.querySelector('.min-w-0'));
if (!grid) return [];
return [...grid.children]
.map((cell) => {
const label = cell.querySelector('span');
const value = [...cell.children].find((c) => c !== label);
const inner = value?.firstElementChild;
return inner && isRightAligned(inner) ? gapOf(cell, inner) : null;
})
.filter((v): v is number => v !== null);
});
}
test.describe('@sirsoft-ecommerce 주문 상세 상품 목록 정렬', () => {
// @scenario order_detail_numeric_alignment:desktop_table
// @effects numeric_columns_right_aligned_consistently
test('1280px 표 뷰: 숫자 열이 모두 같은 우측 여백으로 정렬된다', async ({ page, ordersReadToken }) => {
await authenticatePage(page, ordersReadToken);
await firstOrderDetailUrl(page); // 진입만 하면 이미 1280px 표 뷰
// 회귀 시 `구매수량`·`적립예정` 이 items-center 로 돌아가 우측 정렬 셀이 4개로 줄어든다.
await expect
.poll(async () => (await rightGaps(page)).length, { timeout: 20_000 })
.toBe(NUMERIC_COLUMN_COUNT);
const gaps = await rightGaps(page);
expect(new Set(gaps).size, `열별 우측 여백: ${JSON.stringify(gaps)}`).toBe(1);
});
// @scenario order_detail_numeric_alignment:mobile_card
// @effects numeric_columns_right_aligned_consistently
test('390px 카드 뷰: 숫자 열이 모두 같은 우측 여백으로 정렬된다', async ({ page, ordersReadToken }) => {
await authenticatePage(page, ordersReadToken);
// 목록은 데스크톱 폭에서 진입(좁은 폭에선 목록도 카드라 주문번호 span 이 없다)
const detailPath = await firstOrderDetailUrl(page);
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(detailPath);
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
await expect
.poll(async () => (await rightGaps(page)).length, { timeout: 20_000 })
.toBe(NUMERIC_COLUMN_COUNT);
const gaps = await rightGaps(page);
expect(new Set(gaps).size, `열별 우측 여백: ${JSON.stringify(gaps)}`).toBe(1);
});
});