fix(admin_basic): 레이아웃 코드 편집 화면의 패널 폭·목록 갱신·버전 이력 도달 범위
리베이스 결과를 브라우저로 검수하다 같은 화면에서 셋을 확인했다. 셋 다 응답에도 콘솔에도 신호가 없고 화면에서만 드러나는 종류다. - 두 카드 어디에도 폭 규약이 없어 flex 가 max-content 비율로 폭을 나눴다. 모듈 파티션 레이아웃의 공백 없는 긴 파일명이 목록의 max-content 를 밀어 올려 편집기가 절반 이하로 눌렸다(실측 목록 814 / 편집기 473 → 384 / 903). 이름은 자르지 않고 접는다 — 잘라내면 어느 파티션인지 구분할 수 없다. - 저장 뒤 상세만 다시 불러서, 방금 저장한 파일의 목록 행이 저장 전 설명·크기·수정일을 그대로 달고 있었다. 목록 응답이 본문을 싣지 않게 된 뒤로 재조회가 수십 KB 라 매 저장마다 부른다. - 서버가 버전 목록에 기본 상한 100 을 도입했는데 이 화면에는 limit 도 '더 보기' 도 없어 상한 밖 버전이 도달 불가였다(실측 114건 중 14건). 페이징이 위지윅 편집기에만 들어가 상한 도입이 이 화면에서는 그대로 기능 축소가 됐다. 회귀는 레이아웃 구조(5건)와 브라우저(6건) 양쪽에서 잠근다. 브라우저 쪽은 수정을 되돌린 상태로 fail 을 먼저 확인했다.
This commit is contained in:
@@ -4,6 +4,12 @@
|
||||
형식은 [Keep a Changelog](https://keepachangelog.com/ko/1.1.0/)를 따르며,
|
||||
[Semantic Versioning](https://semver.org/lang/ko/)을 준수합니다.
|
||||
|
||||
## [1.0.6] - 2026-08-05
|
||||
|
||||
### Added
|
||||
|
||||
- 레이아웃 버전 이력의 「이전 버전 더 보기」 버튼 문구 일본어 번역 추가 — 기본 표시 개수를 넘는 오래된 버전을 여는 버튼이 일본어 로케일에서도 정상 표기됩니다.
|
||||
|
||||
## [1.0.5] - 2026-08-01
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2159,7 +2159,9 @@
|
||||
"saved_by": "保存者:{{name}}",
|
||||
"lines": "行",
|
||||
"modified": "編集",
|
||||
"chars": "個"
|
||||
"chars": "個",
|
||||
"load_more": "以前のバージョンをもっと見る",
|
||||
"limit_reached": "表示できる最大件数に達しました。"
|
||||
},
|
||||
"wysiwyg_select_layout_title": "ウィジウィグ編集 - レイアウト選択",
|
||||
"wysiwyg_select_layout_description": "編集するレイアウトを選択してください。選択したレイアウトが新しいタブでウィジウィグ編集モードで開きます。",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"en": "G7 template (sirsoft-admin_basic) Japanese language pack (bundled)",
|
||||
"ja": "G7 テンプレート (sirsoft-admin_basic) 日本語 言語パック(バンドル)"
|
||||
},
|
||||
"version": "1.0.5",
|
||||
"version": "1.0.6",
|
||||
"license": "MIT",
|
||||
"scope": "template",
|
||||
"target_identifier": "sirsoft-admin_basic",
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
|
||||
### Fixed
|
||||
|
||||
- 레이아웃 편집 화면에서 파일 목록이 편집기보다 넓게 자리를 차지해 코드가 좁게 보이던 문제를 수정했습니다. 파일 이름이 긴 레이아웃(모듈이 제공하는 부분 화면 등)이 많을수록 편집 영역이 좁아졌습니다. 이제 파일 목록은 일정한 너비를 쓰고 남는 공간은 모두 편집기가 사용하며, 긴 이름은 잘리지 않고 줄바꿈되어 표시됩니다.
|
||||
- 레이아웃을 저장해도 왼쪽 파일 목록의 설명·크기·수정일이 저장 전 값 그대로 남아 있던 문제를 수정했습니다. 이제 저장 즉시 목록에도 반영됩니다.
|
||||
- 레이아웃 버전 이력에서 최근 100개까지만 볼 수 있고 그보다 오래된 버전은 열 수 없던 문제를 수정했습니다. 이력 창 아래에 「이전 버전 더 보기」를 추가해 오래된 버전까지 확인하고 복구할 수 있습니다.
|
||||
- 표와 카드 목록에서 항목별 표시 조건이 서로 다르게 판정되던 문제를 수정했습니다. 같은 조건을 써도 표에서는 항상 보이고 카드에서는 아무것도 보이지 않는 식으로 결과가 갈렸으며, 특히 화면 상태값을 참조하는 조건에서 그랬습니다. 이제 두 목록 모두 다른 화면 요소와 같은 기준으로 조건을 판정합니다.
|
||||
- 표 하단 합계 영역에 넣은 내용이 표 본문과 다른 방식으로 처리되던 문제를 수정했습니다. 합계 영역도 각 항목과 같은 규칙으로 값을 해석합니다.
|
||||
- 표 하단 합계 영역의 값이 계산되지 않고 표현식 원문 그대로 표시되던 문제를 수정했습니다. 주문 상세의 합계 행에서 수량·적립금·배송비 자리에 값 대신 알 수 없는 문자열이 보였습니다.
|
||||
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* @file admin-template-layout-edit-panels-and-versions.test.tsx
|
||||
* @description 레이아웃 코드 편집 화면 — 패널 폭 규약 / 저장 후 목록 갱신 / 버전 이력 상한 회귀 테스트
|
||||
*
|
||||
* 브라우저 실측(2026-08-05)에서 이 화면의 결함 3건이 드러났다. 셋 다 응답이나 콘솔에는
|
||||
* 아무 신호가 없고 화면에서만 드러나는 종류라, 계약을 레이아웃 JSON 수준에서 고정한다.
|
||||
*
|
||||
* 1. **패널 폭 규약 부재** — 파일 목록과 편집기 카드 어느 쪽에도 폭 지정이 없어 flex 가
|
||||
* 두 카드의 max-content 비율로 폭을 나눴다. 모듈 파티션 레이아웃 이름은 공백이 없는
|
||||
* 80자 이상이라 목록의 max-content 를 900px 이상으로 밀어 올렸고, 편집기가 473px 로
|
||||
* 눌렸다(실측 목록 814 / 편집기 473). 이름이 길어질수록 편집기가 좁아지는 구조다.
|
||||
*
|
||||
* 2. **저장 후 목록 미갱신** — 저장 성공 시 상세(`current_layout`)만 다시 불러서, 방금
|
||||
* 저장한 파일의 목록 행이 저장 전 설명·크기·수정일을 그대로 달고 있었다. 목록 응답이
|
||||
* 본문을 싣지 않게 된 뒤로 재조회가 수십 KB 라 매 저장마다 불러도 된다.
|
||||
*
|
||||
* 3. **버전 이력 상한 도달 불가** — 서버가 버전 목록에 기본 상한 100 을 도입(#518)했는데
|
||||
* 이 화면의 데이터소스는 `limit` 도 '더 보기' 도 없었다. 실측: DB 114건 중 100건만
|
||||
* 내려오고 나머지 14건은 어떤 조작으로도 열 수 없었다.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
const LAYOUT = path.resolve(__dirname, '../../layouts/admin_template_layout_edit.json');
|
||||
const VERSION_MODAL = path.resolve(
|
||||
__dirname,
|
||||
'../../layouts/partials/admin_template_layout_edit/_modal_version_history.json',
|
||||
);
|
||||
|
||||
/** 서버 `LayoutVersionListRequest` 의 DEFAULT_LIMIT / MAX_LIMIT 과 같아야 하는 값 */
|
||||
const VERSION_PAGE_SIZE = 100;
|
||||
const VERSION_MAX_LIMIT = 500;
|
||||
|
||||
/**
|
||||
* JSON 레이아웃을 읽어 파싱합니다.
|
||||
*
|
||||
* @param file 대상 파일 절대경로
|
||||
* @returns 파싱된 레이아웃 객체
|
||||
*/
|
||||
function readLayout(file: string): any {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 트리에서 조건을 만족하는 첫 노드를 찾습니다.
|
||||
*
|
||||
* @param node 탐색 시작 노드
|
||||
* @param predicate 판정 함수
|
||||
* @returns 찾은 노드 또는 null
|
||||
*/
|
||||
function findNode(node: any, predicate: (n: any) => boolean): any | null {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
if (!Array.isArray(node) && predicate(node)) return node;
|
||||
|
||||
for (const value of Array.isArray(node) ? node : Object.values(node)) {
|
||||
const hit = findNode(value, predicate);
|
||||
if (hit) return hit;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 트리에서 조건을 만족하는 모든 노드를 모읍니다.
|
||||
*
|
||||
* @param node 탐색 시작 노드
|
||||
* @param predicate 판정 함수
|
||||
* @returns 찾은 노드 배열
|
||||
*/
|
||||
function collectNodes(node: any, predicate: (n: any) => boolean): any[] {
|
||||
const found: any[] = [];
|
||||
|
||||
const walk = (n: any): void => {
|
||||
if (!n || typeof n !== 'object') return;
|
||||
if (!Array.isArray(n) && predicate(n)) found.push(n);
|
||||
for (const value of Array.isArray(n) ? n : Object.values(n)) walk(value);
|
||||
};
|
||||
|
||||
walk(node);
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
describe('레이아웃 코드 편집 화면 — 패널 폭 규약', () => {
|
||||
it('파일 목록과 편집기가 각자 폭 규약을 갖는다 (내용 길이로 폭이 갈리지 않는다)', () => {
|
||||
const layout = readLayout(LAYOUT);
|
||||
|
||||
const listCard = findNode(layout, (n) => n.id === 'file_list_card');
|
||||
const editorCard = findNode(layout, (n) => n.id === 'editor_card');
|
||||
|
||||
expect(listCard, 'file_list_card 노드가 사라졌다').toBeTruthy();
|
||||
expect(editorCard, 'editor_card 노드가 사라졌다').toBeTruthy();
|
||||
|
||||
const listClass = String(listCard.props?.className ?? '');
|
||||
const editorClass = String(editorCard.props?.className ?? '');
|
||||
|
||||
// 목록: 고정 폭 + 줄어들지 않음
|
||||
expect(listClass, '파일 목록에 고정 폭이 없으면 긴 파일명이 편집기 폭을 빼앗는다').toMatch(/\bw-\d+\b/);
|
||||
expect(listClass, '파일 목록이 shrink 되면 고정 폭 선언이 무력해진다').toContain('shrink-0');
|
||||
|
||||
// 편집기: 남는 폭 전부 + 최소폭 해제
|
||||
expect(editorClass, '편집기가 flex-1 이 아니면 남는 폭을 가져가지 못한다').toContain('flex-1');
|
||||
expect(editorClass, 'min-w-0 이 없으면 코드의 긴 줄이 편집기 최소폭을 밀어 올린다').toContain('min-w-0');
|
||||
});
|
||||
|
||||
it('파일명과 설명이 줄바꿈되어 목록 폭을 넘지 않는다', () => {
|
||||
const layout = readLayout(LAYOUT);
|
||||
|
||||
// 파일명 노드 — `{{file.name}}.json`
|
||||
const nameNode = findNode(layout, (n) => typeof n.text === 'string' && n.text.includes('{{file.name}}.json'));
|
||||
expect(nameNode, '파일명 노드를 찾지 못했다').toBeTruthy();
|
||||
expect(
|
||||
String(nameNode.props?.className ?? ''),
|
||||
'모듈 파티션 이름은 공백이 없어 break-all 없이는 끊기지 않는다',
|
||||
).toContain('break-all');
|
||||
|
||||
// 설명 노드 — 설명이 없으면 이름으로 폴백하므로 같은 규칙이 필요하다
|
||||
const descNode = findNode(
|
||||
layout,
|
||||
(n) => typeof n.text === 'string' && n.text.includes('file.description') && n.text.includes('file.name'),
|
||||
);
|
||||
expect(descNode, '설명 노드를 찾지 못했다').toBeTruthy();
|
||||
expect(
|
||||
String(descNode.props?.className ?? ''),
|
||||
'설명은 이름으로 폴백하므로 파일명과 같은 줄바꿈 규칙이 필요하다',
|
||||
).toContain('break-all');
|
||||
});
|
||||
});
|
||||
|
||||
describe('레이아웃 코드 편집 화면 — 저장 후 목록 갱신', () => {
|
||||
it('저장 성공 시 상세와 목록을 모두 다시 부른다', () => {
|
||||
const layout = readLayout(LAYOUT);
|
||||
|
||||
const saveCall = findNode(
|
||||
layout,
|
||||
(n) => n.handler === 'apiCall' && n.params?.method === 'PUT' && String(n.target ?? '').includes('/layouts/'),
|
||||
);
|
||||
expect(saveCall, '저장 apiCall 액션을 찾지 못했다').toBeTruthy();
|
||||
|
||||
const refetched = collectNodes(saveCall.onSuccess ?? [], (n) => n.handler === 'refetchDataSource').map(
|
||||
(n) => n.params?.dataSourceId,
|
||||
);
|
||||
|
||||
expect(refetched, '저장 후 상세를 다시 부르지 않으면 lock_version 이 낡는다').toContain('current_layout');
|
||||
expect(
|
||||
refetched,
|
||||
'저장 후 목록을 다시 부르지 않으면 방금 저장한 행이 저장 전 설명·크기·수정일을 그대로 단다',
|
||||
).toContain('layout_files');
|
||||
});
|
||||
});
|
||||
|
||||
describe('레이아웃 코드 편집 화면 — 버전 이력 상한', () => {
|
||||
it('버전 목록 조회에 limit 이 붙고 열 때마다 첫 묶음으로 초기화된다', () => {
|
||||
const layout = readLayout(LAYOUT);
|
||||
|
||||
const source = (layout.data_sources ?? []).find((d: any) => d.id === 'layout_versions');
|
||||
expect(source, 'layout_versions 데이터소스가 사라졌다').toBeTruthy();
|
||||
expect(
|
||||
String(source.endpoint),
|
||||
'limit 이 없으면 서버 기본 상한을 넘는 버전은 어떤 조작으로도 열 수 없다',
|
||||
).toContain('limit=');
|
||||
|
||||
// 모달 진입 시 상한 초기화 — 남겨두면 다른 레이아웃 이력 조회로 이월된다
|
||||
const openSequence = collectNodes(
|
||||
layout,
|
||||
(n) => n.handler === 'setState' && n.params?.layoutVersionLimit !== undefined,
|
||||
);
|
||||
expect(openSequence.length, '모달을 열 때 조회 상한을 초기화하지 않는다').toBeGreaterThan(0);
|
||||
expect(openSequence.some((n) => n.params.layoutVersionLimit === VERSION_PAGE_SIZE)).toBe(true);
|
||||
});
|
||||
|
||||
it('더 보기 버튼이 상한을 한 묶음씩 넓히고 서버 상한에서 멈춘다', () => {
|
||||
const modal = readLayout(VERSION_MODAL);
|
||||
|
||||
const loadMore = findNode(modal, (n) => n.props?.['data-testid'] === 'layout-version-history-load-more');
|
||||
expect(loadMore, '더 보기 버튼이 없으면 상한 밖 버전은 도달 불가다').toBeTruthy();
|
||||
|
||||
const wrapper = findNode(
|
||||
modal,
|
||||
(n) => typeof n.if === 'string' && n.if.includes('layoutVersionLimit') && n.if.includes('layout_versions'),
|
||||
);
|
||||
expect(wrapper, '더 보기 노출 조건이 없다').toBeTruthy();
|
||||
expect(
|
||||
String(wrapper.if),
|
||||
`서버 상한(${VERSION_MAX_LIMIT})에서 멈추지 않으면 그 이상 요청이 422 가 되어 목록 전체가 사라진다`,
|
||||
).toContain(String(VERSION_MAX_LIMIT));
|
||||
|
||||
const raw = JSON.stringify(loadMore);
|
||||
expect(raw, '더 보기가 상한을 넓히지 않는다').toContain('layoutVersionLimit');
|
||||
expect(raw, '넓힌 뒤 다시 조회하지 않으면 화면이 그대로다').toContain('layout_versions');
|
||||
expect(raw, `한 묶음(${VERSION_PAGE_SIZE})씩 넓혀야 한다`).toContain(String(VERSION_PAGE_SIZE));
|
||||
});
|
||||
});
|
||||
@@ -2156,7 +2156,9 @@
|
||||
"saved_by": "Saved by: {{name}}",
|
||||
"lines": "lines",
|
||||
"modified": "modified",
|
||||
"chars": "chars"
|
||||
"chars": "chars",
|
||||
"load_more": "Load older versions",
|
||||
"limit_reached": "Reached the maximum number of versions that can be listed."
|
||||
},
|
||||
"extension_version_history": {
|
||||
"title": "Extension Version History",
|
||||
|
||||
@@ -2160,7 +2160,9 @@
|
||||
"saved_by": "저장자: {{name}}",
|
||||
"lines": "라인",
|
||||
"modified": "수정",
|
||||
"chars": "자"
|
||||
"chars": "자",
|
||||
"load_more": "이전 버전 더 보기",
|
||||
"limit_reached": "표시할 수 있는 최대 개수에 도달했습니다."
|
||||
},
|
||||
"extension_version_history": {
|
||||
"title": "확장 버전 히스토리",
|
||||
|
||||
@@ -76,7 +76,8 @@
|
||||
"id": "layout_versions",
|
||||
"label_key": "$t:editor.data_source.layout_versions",
|
||||
"type": "api",
|
||||
"endpoint": "/api/admin/templates/{{route.identifier}}/layouts/{{_global.selectedLayoutName}}/versions",
|
||||
"comment": "limit 는 서버 기본 상한(100)과 같은 값에서 출발해 '더 보기' 마다 한 묶음씩 넓힌다. 서버가 상한 조회만 지원하므로(#518) 이 파라미터가 없으면 100건을 넘는 버전은 이 화면에서 도달할 수 없다. 상한 500 은 서버 LayoutVersionListRequest::MAX_LIMIT 과 같아야 하며, 넘기면 목록 전체가 422 가 된다.",
|
||||
"endpoint": "/api/admin/templates/{{route.identifier}}/layouts/{{_global.selectedLayoutName}}/versions?limit={{_global.layoutVersionLimit ?? 100}}",
|
||||
"method": "GET",
|
||||
"auto_fetch": false,
|
||||
"auth_required": true,
|
||||
@@ -229,7 +230,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "fa-regular fa-eye",
|
||||
"className": "w-4 h-4"
|
||||
"className": "text-base"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -312,7 +313,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "fa-regular fa-eye",
|
||||
"className": "w-4 h-4"
|
||||
"className": "text-base"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -330,8 +331,10 @@
|
||||
"name": "Button",
|
||||
"if": "{{_global.selectedNodeKind !== 'extension' && current_layout?.data?.abilities?.can_update !== false}}",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "flex-center gap-2 px-4 py-2 bg-gray-900 dark:bg-white text-white dark:text-gray-900 rounded-lg hover:bg-gray-800 dark:hover:bg-gray-100 transition-colors disabled:opacity-50",
|
||||
"disabled": "{{_global.isSaving}}"
|
||||
"disabled": "{{_global.isSaving}}",
|
||||
"data-testid": "layout-save"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -376,6 +379,13 @@
|
||||
"params": {
|
||||
"dataSourceId": "current_layout"
|
||||
}
|
||||
},
|
||||
{
|
||||
"comment": "목록도 함께 다시 부른다 — 파일 목록은 저장으로 바뀌는 값(설명·크기·수정일)을 표시하므로, 상세만 갱신하면 방금 저장한 파일의 행이 저장 전 값으로 남는다. 목록 응답이 본문을 싣지 않게 된 뒤로 재조회 비용이 수십 KB 수준이라 매 저장마다 불러도 된다.",
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "layout_files"
|
||||
}
|
||||
}
|
||||
],
|
||||
"onError": [
|
||||
@@ -405,7 +415,7 @@
|
||||
"if": "{{!_global.isSaving}}",
|
||||
"props": {
|
||||
"name": "fa-regular fa-floppy-disk",
|
||||
"className": "w-4 h-4"
|
||||
"className": "text-base"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -510,7 +520,7 @@
|
||||
"if": "{{!_global.isSaving}}",
|
||||
"props": {
|
||||
"name": "fa-regular fa-floppy-disk",
|
||||
"className": "w-4 h-4"
|
||||
"className": "text-base"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -565,7 +575,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "fa-regular fa-folder-open",
|
||||
"className": "text-secondary w-5 h-5"
|
||||
"className": "text-secondary text-xl"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -763,8 +773,9 @@
|
||||
"id": "file_list_card",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"comment": "파일 목록은 고정 폭(w-96)이다. 폭 규약이 없으면 flex 가 두 카드의 max-content 비율로 나누는데, 모듈 파티션 레이아웃의 긴 파일명(줄바꿈 지점 없는 80자 이상)이 목록의 max-content 를 900px 이상으로 밀어 올려 편집기가 절반 이하로 눌린다.",
|
||||
"props": {
|
||||
"className": "admin-card"
|
||||
"className": "admin-card w-96 shrink-0"
|
||||
},
|
||||
"responsive": {
|
||||
"portable": {
|
||||
@@ -793,7 +804,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "fa-regular fa-folder-open",
|
||||
"className": "text-secondary w-5 h-5"
|
||||
"className": "text-secondary text-xl"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -895,7 +906,7 @@
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex-center gap-2"
|
||||
"className": "flex-center gap-2 min-w-0"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -903,14 +914,15 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "fa-regular fa-file-code",
|
||||
"className": "w-4 h-4 text-gray-400 dark:text-gray-500"
|
||||
"className": "text-base text-gray-400 dark:text-gray-500"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"comment": "break-all — 모듈 파티션 레이아웃 이름은 공백이 없어 기본 줄바꿈 규칙으로는 끊기지 않는다. 자르지 않고 접어서 전체 이름을 보이게 한다(잘라내면 어느 파티션인지 구분 불가).",
|
||||
"props": {
|
||||
"className": "text-primary font-medium text-sm"
|
||||
"className": "text-primary font-medium text-sm break-all"
|
||||
},
|
||||
"text": "{{file.name}}.json"
|
||||
},
|
||||
@@ -928,8 +940,9 @@
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "P",
|
||||
"comment": "설명이 없는 레이아웃은 이름으로 폴백하므로 파일명과 같은 줄바꿈 규칙이 필요하다. break-all 이 없으면 모듈 파티션 이름이 목록에 가로 스크롤을 만든다.",
|
||||
"props": {
|
||||
"className": "form-hint"
|
||||
"className": "form-hint break-all"
|
||||
},
|
||||
"text": "{{raw:file.description || file.name}}"
|
||||
},
|
||||
@@ -1008,7 +1021,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "{{extGroup.source_type === 'plugin' ? 'fa-solid fa-plug' : 'fa-solid fa-puzzle-piece'}}",
|
||||
"className": "w-3.5 h-3.5 text-gray-500 dark:text-gray-400"
|
||||
"className": "text-sm text-gray-500 dark:text-gray-400"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1090,7 +1103,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "fa-solid fa-circle-plus",
|
||||
"className": "w-3.5 h-3.5 text-gray-400 dark:text-gray-500"
|
||||
"className": "text-sm text-gray-400 dark:text-gray-500"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1176,8 +1189,9 @@
|
||||
"id": "editor_card",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"comment": "편집기는 남는 폭을 전부 가져간다. min-w-0 이 없으면 코드 영역의 긴 줄이 flex 아이템의 최소 폭을 밀어 올려 목록 쪽으로 폭을 되돌려준다.",
|
||||
"props": {
|
||||
"className": "admin-card"
|
||||
"className": "admin-card flex-1 min-w-0"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -1217,7 +1231,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "fa-regular fa-file-code",
|
||||
"className": "text-secondary w-5 h-5"
|
||||
"className": "text-secondary text-xl"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1297,7 +1311,9 @@
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"className": "flex-center gap-2 px-3 py-1.5 border border-gray-300 dark:border-gray-600 text-body rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
"type": "button",
|
||||
"className": "flex-center gap-2 px-3 py-1.5 border border-gray-300 dark:border-gray-600 text-body rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors",
|
||||
"data-testid": "layout-version-history-open"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
@@ -1321,6 +1337,14 @@
|
||||
},
|
||||
{
|
||||
"then": [
|
||||
{
|
||||
"comment": "모달을 열 때마다 조회 상한을 첫 묶음으로 되돌린다. 남겨두면 이전에 넓혀 둔 상한이 다른 레이아웃의 이력 조회에 그대로 이월된다.",
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"layoutVersionLimit": 100
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
@@ -1342,7 +1366,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "fa-solid fa-clock-rotate-left",
|
||||
"className": "w-4 h-4"
|
||||
"className": "text-base"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1408,7 +1432,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "fa-solid fa-circle-info",
|
||||
"className": "w-4 h-4"
|
||||
"className": "text-base"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+67
-4
@@ -23,7 +23,8 @@
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "space-y-3 max-h-96 overflow-y-auto"
|
||||
"className": "space-y-3 max-h-96 overflow-y-auto",
|
||||
"data-testid": "layout-version-history-list"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -36,7 +37,8 @@
|
||||
},
|
||||
"blur_until_loaded": "{{_global.isRestoring}}",
|
||||
"props": {
|
||||
"className": "border border-gray-200 dark:border-gray-700 rounded-lg p-4"
|
||||
"className": "border border-gray-200 dark:border-gray-700 rounded-lg p-4",
|
||||
"data-testid": "layout-version-row-{{version.version ?? idx}}"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
@@ -166,7 +168,7 @@
|
||||
"if": "{{!_global.isRestoring}}",
|
||||
"props": {
|
||||
"name": "refresh",
|
||||
"className": "w-4 h-4"
|
||||
"className": "text-base"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -232,6 +234,67 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "받은 묶음이 상한만큼 꽉 찼으면 더 오래된 버전이 남아 있다는 뜻이라 '더 보기' 를 노출한다. 서버 상한(500)에 도달하면 감춘다 — 더 넓히면 목록 전체가 422 가 되어 보고 있던 이력까지 사라진다.",
|
||||
"if": "{{(layout_versions?.data?.length ?? 0) >= (_global.layoutVersionLimit ?? 100) && (_global.layoutVersionLimit ?? 100) < 500}}",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "flex-center justify-center mt-3"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Button",
|
||||
"props": {
|
||||
"type": "button",
|
||||
"className": "btn-secondary w-full",
|
||||
"data-testid": "layout-version-history-load-more"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"type": "click",
|
||||
"handler": "sequence",
|
||||
"params": {
|
||||
"actions": [
|
||||
{
|
||||
"handler": "setState",
|
||||
"params": {
|
||||
"target": "global",
|
||||
"layoutVersionLimit": "{{Math.min((_global.layoutVersionLimit ?? 100) + 100, 500)}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"handler": "refetchDataSource",
|
||||
"params": {
|
||||
"dataSourceId": "layout_versions"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Span",
|
||||
"text": "$t:admin.templates.modals.version_history.load_more"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"comment": "서버 상한까지 넓혔는데도 묶음이 꽉 차 있으면 더 오래된 버전이 남아 있지만 이 화면에서는 도달할 수 없다. '더 보기' 만 조용히 사라지면 목록이 끝난 것으로 읽히므로 도달 한계를 명시한다.",
|
||||
"if": "{{(layout_versions?.data?.length ?? 0) >= (_global.layoutVersionLimit ?? 100) && (_global.layoutVersionLimit ?? 100) >= 500}}",
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
"props": {
|
||||
"className": "text-xs text-secondary text-center mt-3",
|
||||
"data-testid": "layout-version-history-limit-reached"
|
||||
},
|
||||
"text": "$t:admin.templates.modals.version_history.limit_reached"
|
||||
},
|
||||
{
|
||||
"type": "basic",
|
||||
"name": "Div",
|
||||
@@ -251,7 +314,7 @@
|
||||
"name": "Icon",
|
||||
"props": {
|
||||
"name": "info-circle",
|
||||
"className": "w-4 h-4 text-blue-600 dark:text-blue-400"
|
||||
"className": "text-base text-blue-600 dark:text-blue-400"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* sirsoft-admin_basic — 코드 편집기 패널 폭 규약과 저장 후 목록 갱신
|
||||
*
|
||||
* 브라우저 실측(2026-08-05)에서 드러난 두 결함을 화면 쪽에서 잠근다. 둘 다 응답에도
|
||||
* 콘솔에도 신호가 없고 화면에서만 드러나는 종류라, 레이아웃 JSON 구조 단언만으로는
|
||||
* "실제로 그렇게 그려지는가 / 실제로 그 요청이 나가는가" 가 남지 않는다.
|
||||
*
|
||||
* 1. **패널 폭** — 두 카드 어디에도 폭 규약이 없어 flex 가 max-content 비율로 폭을
|
||||
* 나눴다. 모듈 파티션 레이아웃 이름(줄바꿈 지점 없는 80자 이상)이 목록을 밀어 올려
|
||||
* 편집기가 절반 이하로 눌렸다(실측 목록 814 / 편집기 473).
|
||||
* 2. **저장 후 목록** — 저장 성공 시 상세만 다시 불러서, 방금 저장한 파일의 목록 행이
|
||||
* 저장 전 설명·크기·수정일을 그대로 달고 있었다.
|
||||
*
|
||||
* 저장은 응답을 고정해 실제 파일을 건드리지 않는다. 검사 대상은 **브라우저가 실제로
|
||||
* 만든 요청 순서** 다 — 목록 재조회가 저장 뒤에 오지 않으면 화면은 낡은 행을 그린다.
|
||||
*
|
||||
* @scenario surface=code_editor,entry=direct
|
||||
* @effects code_editor_editor_panel_wider_than_file_list, code_editor_list_refetched_after_save
|
||||
*/
|
||||
import { test, expect, authenticatePage } from '../../fixtures/admin-template-auth';
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
|
||||
/** 목록 조회 — 뒤에 세그먼트가 붙는 단건/버전 조회와 구분한다 */
|
||||
const LIST_URL_RE = /\/api\/admin\/templates\/[^/]+\/layouts(\?[^/]*)?$/;
|
||||
|
||||
/** 단건 저장 — 목록 URL 뒤에 레이아웃 이름 한 세그먼트 */
|
||||
const SAVE_URL_RE = /\/api\/admin\/templates\/[^/]+\/layouts\/[^/?]+(\?.*)?$/;
|
||||
|
||||
/**
|
||||
* 코드 편집기를 열고 파일 목록이 그려질 때까지 기다립니다.
|
||||
*
|
||||
* @param page Playwright page
|
||||
* @param token 인증 토큰
|
||||
*/
|
||||
async function openCodeEditor(page: Page, token: string): Promise<void> {
|
||||
await authenticatePage(page, token);
|
||||
await page.goto('/admin/templates/sirsoft-admin_basic/edit');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
await page.waitForSelector('#file_list_card', { timeout: 30_000 });
|
||||
await expect(page.locator('#editor_card').getByText(/\.json$/).first()).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe('@sirsoft-admin_basic 코드 편집기 패널 폭', () => {
|
||||
test('편집기가 파일 목록보다 넓고, 긴 파일명이 목록 폭을 넘지 않는다', async ({ page, layoutEditToken }) => {
|
||||
await openCodeEditor(page, layoutEditToken);
|
||||
|
||||
const measured = await page.evaluate(() => {
|
||||
const list = document.querySelector('#file_list_card')!;
|
||||
const editor = document.querySelector('#editor_card')!;
|
||||
|
||||
// 목록 안에서 가로 스크롤을 만드는 요소 — 긴 파일명이 끊기지 않으면 여기서 잡힌다
|
||||
const overflowing = [...list.querySelectorAll('*')].filter((e) => e.scrollWidth > e.clientWidth + 1).length;
|
||||
|
||||
return {
|
||||
listWidth: Math.round(list.getBoundingClientRect().width),
|
||||
editorWidth: Math.round(editor.getBoundingClientRect().width),
|
||||
overflowing,
|
||||
};
|
||||
});
|
||||
|
||||
expect(measured.editorWidth, '편집기가 파일 목록보다 좁으면 코드를 볼 수 없다').toBeGreaterThan(
|
||||
measured.listWidth,
|
||||
);
|
||||
expect(measured.overflowing, '목록 안에 가로 스크롤이 생기면 파일명이 끊기지 않은 것이다').toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('@sirsoft-admin_basic 코드 편집기 저장 후 목록 갱신', () => {
|
||||
test('저장이 성공하면 파일 목록을 다시 부른다', async ({ page, layoutEditToken }) => {
|
||||
const calls: string[] = [];
|
||||
|
||||
// 저장은 고정 응답 — 실제 파일과 lock_version 을 건드리지 않는다
|
||||
await page.route(SAVE_URL_RE, async (route: Route) => {
|
||||
if (route.request().method() !== 'PUT') {
|
||||
await route.fallback();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
calls.push('save');
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: true, message: '저장되었습니다.', data: { lock_version: 1 } }),
|
||||
});
|
||||
});
|
||||
|
||||
page.on('request', (req) => {
|
||||
if (req.method() === 'GET' && LIST_URL_RE.test(req.url())) calls.push('list');
|
||||
});
|
||||
|
||||
await openCodeEditor(page, layoutEditToken);
|
||||
|
||||
const before = calls.length;
|
||||
await page.getByTestId('layout-save').click();
|
||||
|
||||
await expect
|
||||
.poll(() => calls.includes('save'), { timeout: 20_000 })
|
||||
.toBe(true);
|
||||
|
||||
// 저장 뒤에 목록 조회가 한 번 더 나가야 한다. 상세만 다시 부르면 방금 저장한 행이
|
||||
// 저장 전 설명·크기·수정일을 그대로 단 채 남는다.
|
||||
await expect
|
||||
.poll(() => calls.slice(calls.indexOf('save')).includes('list'), { timeout: 20_000 })
|
||||
.toBe(true);
|
||||
|
||||
expect(calls.length, '진입 시점 요청만으로 통과하면 안 된다').toBeGreaterThan(before);
|
||||
});
|
||||
});
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* sirsoft-admin_basic — 코드 편집기 버전 이력의 상한 조회와 '더 보기'
|
||||
*
|
||||
* 버전 행은 저장할 때마다 쌓이고 정리되지 않는다. 목록이 전량을 내려주면 오래 쓴 레이아웃일수록
|
||||
* 모달 하나를 여는 데 수천 행이 실린다(#518). 서버가 조회 건수를 제한한 뒤로 화면이 상한을
|
||||
* 넘겨주지 않으면 100건 밖의 버전은 이 화면에서 도달할 수 없다 — 오류 없이 목록이 끝난 것처럼
|
||||
* 보일 뿐이라 화면만 보고는 알 수 없다.
|
||||
*
|
||||
* 위지윅 편집기의 같은 기능은 React 훅(useLayoutVersions)이 담당하고 코어
|
||||
* layout-editor/version-history-paging.spec.ts 가 잠근다. 이 spec 은 **레이아웃 JSON 이
|
||||
* 구동하는 코드 편집기** 라는 별개 표면을 잠근다 — 두 화면은 코드를 공유하지 않으므로
|
||||
* 한쪽이 초록이어도 다른 쪽은 전량 요청일 수 있다.
|
||||
*
|
||||
* 응답은 고정한다(개발 사이트의 실제 버전 수에 좌우되지 않도록). 요청 URL 은 브라우저가
|
||||
* 실제로 만든 것을 검사한다.
|
||||
*
|
||||
* @scenario resource=layout_version,endpoint=list,surface=code_editor
|
||||
* @effects version_list_request_carries_limit, version_list_load_more_widens_limit, version_list_load_more_hidden_when_batch_not_full, version_list_limit_ceiling_disclosed
|
||||
*/
|
||||
import { test, expect, authenticatePage } from '../../fixtures/admin-template-auth';
|
||||
import type { Page, Route } from '@playwright/test';
|
||||
|
||||
/** 한 묶음 크기 — admin_template_layout_edit.json 의 layoutVersionLimit 기본값과 같은 값 */
|
||||
const VERSION_PAGE_SIZE = 100;
|
||||
|
||||
/** 서버 상한 — LayoutVersionListRequest::MAX_LIMIT 과 같은 값 */
|
||||
const VERSION_MAX_LIMIT = 500;
|
||||
|
||||
const VERSIONS_URL_RE = /\/api\/admin\/templates\/[^/]+\/layouts\/[^/]+\/versions(\?|$)/;
|
||||
|
||||
/**
|
||||
* 버전 목록 응답 한 건을 만듭니다.
|
||||
*
|
||||
* @param version 버전 번호
|
||||
* @returns 목록 항목
|
||||
*/
|
||||
function versionRow(version: number): Record<string, unknown> {
|
||||
return {
|
||||
id: version,
|
||||
version,
|
||||
is_latest: version === 1,
|
||||
created_at: '2026-01-01T00:00:00+09:00',
|
||||
created_by_name: 'tester',
|
||||
description: `v${version}`,
|
||||
lines_count: 10,
|
||||
chars_count: 100,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 버전 목록 응답을 대체하고, 나간 요청 URL 을 수집합니다.
|
||||
*
|
||||
* `count` 를 생략하면 요청한 상한만큼(서버가 가진 버전이 상한보다 많은 상태) 돌려준다.
|
||||
* 응답 크기가 상한을 따라 커져야 화면에 "이 회차가 반영됐다" 는 관측 지점이 생긴다 —
|
||||
* 매번 같은 개수를 돌려주면 재렌더를 기다릴 방법이 없어 다음 클릭이 이전 화면에 꽂힌다.
|
||||
*
|
||||
* @param page Playwright page
|
||||
* @param count 응답에 담을 행 수 (생략 시 요청 상한과 동일)
|
||||
* @param seen 요청 URL 수집 배열
|
||||
*/
|
||||
async function stubVersions(page: Page, count: number | null, seen: string[]): Promise<void> {
|
||||
await page.route(VERSIONS_URL_RE, async (route: Route) => {
|
||||
const url = route.request().url();
|
||||
seen.push(url);
|
||||
|
||||
const requested = Number(new URL(url).searchParams.get('limit') ?? VERSION_PAGE_SIZE);
|
||||
const rows = count ?? Math.min(requested, VERSION_MAX_LIMIT);
|
||||
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
success: true,
|
||||
data: Array.from({ length: rows }, (_, i) => versionRow(i + 1)),
|
||||
}),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 편집기를 열고 버전 이력 모달을 띄웁니다.
|
||||
*
|
||||
* @param page Playwright page
|
||||
* @param token 인증 토큰
|
||||
*/
|
||||
async function openVersionHistory(page: Page, token: string): Promise<void> {
|
||||
await authenticatePage(page, token);
|
||||
await page.goto('/admin/templates/sirsoft-basic/edit');
|
||||
await page.waitForLoadState('domcontentloaded', { timeout: 30_000 });
|
||||
|
||||
await page.waitForSelector('[data-testid="layout-version-history-open"]', { timeout: 30_000 });
|
||||
await page.getByTestId('layout-version-history-open').click();
|
||||
await page.waitForSelector('[data-testid="layout-version-history-list"]', { timeout: 20_000 });
|
||||
}
|
||||
|
||||
test.describe('@sirsoft-admin_basic 코드 편집기 버전 이력 — 상한 조회', () => {
|
||||
test('첫 조회가 상한(limit)을 달고 나간다', async ({ page, layoutEditToken }) => {
|
||||
const seen: string[] = [];
|
||||
await stubVersions(page, 3, seen);
|
||||
|
||||
await openVersionHistory(page, layoutEditToken);
|
||||
|
||||
expect(seen.length).toBeGreaterThan(0);
|
||||
// 상한 없이 나가면 오래 쓴 레이아웃에서 전량이 실린다
|
||||
expect(seen[seen.length - 1]).toContain(`limit=${VERSION_PAGE_SIZE}`);
|
||||
});
|
||||
|
||||
test('한 묶음이 가득 차지 않으면 더 보기가 없다', async ({ page, layoutEditToken }) => {
|
||||
const seen: string[] = [];
|
||||
await stubVersions(page, 3, seen);
|
||||
|
||||
await openVersionHistory(page, layoutEditToken);
|
||||
|
||||
// 존재를 먼저 확정한 뒤 부재를 단언한다 — 렌더 전에는 어떤 부재 단언도 무조건 통과한다
|
||||
await expect(page.getByTestId('layout-version-row-1')).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByTestId('layout-version-history-load-more')).toHaveCount(0);
|
||||
await expect(page.getByTestId('layout-version-history-limit-reached')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('묶음이 가득 차면 더 보기로 상한을 넓혀 재조회한다', async ({ page, layoutEditToken }) => {
|
||||
const seen: string[] = [];
|
||||
await stubVersions(page, VERSION_PAGE_SIZE, seen);
|
||||
|
||||
await openVersionHistory(page, layoutEditToken);
|
||||
|
||||
const loadMore = page.getByTestId('layout-version-history-load-more');
|
||||
await expect(loadMore).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const before = seen.length;
|
||||
await loadMore.click();
|
||||
await expect.poll(() => seen.length, { timeout: 20_000 }).toBeGreaterThan(before);
|
||||
|
||||
// 두 번째 요청은 반드시 더 넓은 상한이어야 한다 — 같은 상한으로 다시 부르면
|
||||
// 같은 묶음이 돌아와 '더 보기' 가 영원히 아무 일도 하지 않는다
|
||||
expect(seen[seen.length - 1]).toContain(`limit=${VERSION_PAGE_SIZE * 2}`);
|
||||
});
|
||||
|
||||
test('서버 상한까지 넓히면 더 보기 대신 도달 한계를 알린다', async ({ page, layoutEditToken }) => {
|
||||
const seen: string[] = [];
|
||||
// 서버에 상한보다 많은 버전이 있는 상태 — 응답은 늘 요청한 상한만큼 꽉 차서 돌아온다
|
||||
await stubVersions(page, null, seen);
|
||||
|
||||
await openVersionHistory(page, layoutEditToken);
|
||||
|
||||
const loadMore = page.getByTestId('layout-version-history-load-more');
|
||||
await expect(loadMore).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// 100 → 200 → 300 → 400 → 500. 다음 회차를 누르기 전에 화면이 그 묶음을 다 그렸는지
|
||||
// 확인한다 — 요청이 나갔다는 것만으로 누르면 아직 이전 상한을 들고 있는 화면에 꽂힌다.
|
||||
for (const expected of [200, 300, 400, VERSION_MAX_LIMIT]) {
|
||||
await loadMore.click();
|
||||
await expect
|
||||
.poll(() => seen.some((url) => url.includes(`limit=${expected}`)), { timeout: 20_000 })
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(async () => page.locator('[data-testid^="layout-version-row-"]').count(), { timeout: 20_000 })
|
||||
.toBe(expected);
|
||||
}
|
||||
|
||||
// 상한을 넘겨 요청하면 목록 전체가 422 가 되어 보고 있던 이력까지 사라진다.
|
||||
// 그래서 더 보기는 감추되, 그냥 사라지면 목록이 끝난 것으로 읽히므로 한계를 명시한다.
|
||||
await expect(page.getByTestId('layout-version-history-limit-reached')).toBeVisible();
|
||||
await expect(loadMore).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user