V13-FE-011: finalize search list layout slice

This commit is contained in:
2026-08-09 02:57:26 +09:00
parent 9efd202e76
commit 6422cb2b13
984 changed files with 120811 additions and 1498 deletions
@@ -0,0 +1,20 @@
import { test, expect } from '@playwright/test'
test.describe('KBX application shell',()=>{
test('root opens business home without creating a workspace tab',async({page})=>{await page.goto('/');await expect(page).toHaveURL(/\/home$/);await expect(page.getByRole('heading',{name:'홈'})).toBeVisible();await expect(page.getByRole('navigation',{name:'열린 업무'})).toContainText('열린 업무가 없습니다.')})
test('mouse user opens menu search and order screen',async({page})=>{await page.goto('/');await page.getByRole('button',{name:/메뉴 검색/}).click();await page.getByRole('searchbox').fill('주문관리');await page.keyboard.press('Enter');await expect(page).toHaveURL(/\/oms\/orders/)})
test('keyboard user opens same menu with Ctrl+K',async({page})=>{await page.goto('/');await page.keyboard.press('Control+k');await page.getByRole('searchbox').fill('ERP-INV-001');await page.keyboard.press('Enter');await expect(page).toHaveURL(/\/erp\/inventory/)})
test('workspace keeps multiple business contexts',async({page})=>{await page.goto('/oms/orders');await page.goto('/erp/inventory');await expect(page.getByRole('navigation',{name:'열린 업무'})).toContainText('주문관리');await expect(page.getByRole('navigation',{name:'열린 업무'})).toContainText('재고현황')})
test('dirty transaction does not close silently',async({page})=>{await page.goto('/oms/orders/new');await page.getByLabel('수취인').fill('홍길동');await page.getByRole('button',{name:'닫기'}).click();await expect(page.getByText('저장하지 않은 변경사항이 있습니다.')).toBeVisible()})
})
test.describe('KBX v29 shell completion',()=>{
test('home promotes business quick-start without duplicating dashboard cards',async({page})=>{await page.goto('/home');await expect(page.getByRole('heading',{name:'바로 시작'})).toBeVisible();await expect(page.getByRole('button',{name:/주문관리/}).first()).toBeVisible()})
test('menu search keeps focus inside the modal and exposes listbox semantics',async({page})=>{await page.goto('/home');await page.keyboard.press('Control+k');const search=page.getByRole('combobox');await expect(search).toBeFocused();await expect(page.getByRole('listbox')).toBeVisible();await page.keyboard.press('Shift+Tab');await page.keyboard.press('Tab');await expect(page.getByRole('dialog',{name:/메뉴/})).toBeVisible()})
test('screen header exposes help utility from the common host',async({page})=>{await page.goto('/oms/orders');await expect(page.getByRole('button',{name:'도움말'}).first()).toBeVisible()})
})
test.describe('KBX v30 shell recovery',()=>{
test('unknown deep link provides safe navigation recovery',async({page})=>{await page.goto('/unknown/deep/link');await expect(page.getByRole('heading',{name:'화면을 찾을 수 없습니다.'})).toBeVisible();await page.getByRole('button',{name:'메뉴 검색'}).click();await expect(page.getByRole('dialog')).toBeVisible()})
test('global module selector and side navigation share one module context',async({page})=>{await page.goto('/home');const moduleSelect=page.getByLabel('업무 모듈');await moduleSelect.selectOption('ERP');await expect(page.getByRole('heading',{name:'ERP 업무'})).toBeVisible()})
})
@@ -0,0 +1,14 @@
import { test, expect } from '@playwright/test'
import { requireKbxScenario, scenarioTitle } from './helpers/kbxScenario'
const queueRecovery=requireKbxScenario('scenario.common.operations.queue-recovery')
test.describe('COMMON-OPS-001 업무 예외 센터', () => {
test(scenarioTitle(queueRecovery.id), async ({ page }) => {
await page.goto('/operations/exceptions')
await expect(page.locator('[data-screen-id="COMMON-OPS-001"]')).toBeVisible()
await expect(page.locator('[data-kbx-surface="exception-summary"]')).toBeVisible()
await expect(page.locator('[data-kbx-surface="queue/content"]')).toBeVisible()
await expect(page.getByText(/정상 건은 표시하지 않습니다/)).toBeVisible()
})
})
@@ -0,0 +1,13 @@
import { test, expect } from '@playwright/test'
import { requireKbxScenario, scenarioTitle } from './helpers/kbxScenario'
const mismatchRecovery=requireKbxScenario('scenario.common.reconcile.mismatch-recovery')
test.describe('COMMON-REC-001 업무 데이터 대사', () => {
test(scenarioTitle(mismatchRecovery.id), async ({ page }) => {
await page.goto('/operations/reconcile')
await expect(page.locator('[data-screen-id="COMMON-REC-001"]')).toBeVisible()
await expect(page.locator('[data-kbx-surface="criteria/search"]')).toBeVisible()
await expect(page.locator('[data-kbx-surface="comparison-grid"]')).toBeVisible()
})
})
@@ -0,0 +1,8 @@
import { test, expect } from '@playwright/test'
test('T05 inventory keeps master context and exposes locations, history, drill-down', async ({ page }) => {
await page.goto('/erp/inventory')
await expect(page.getByText('창고/로케이션')).toBeVisible()
await expect(page.getByText('재고이력')).toBeVisible()
await expect(page.getByText('품목을 선택하면 로케이션과 재고이력을 함께 조회합니다.')).toBeVisible()
})
@@ -0,0 +1,14 @@
import { test, expect } from '@playwright/test'
import { requireKbxScenario, scenarioTitle } from './helpers/kbxScenario'
const masterRecovery=requireKbxScenario('scenario.erp.item-master.keyboard-recovery')
test.describe('ERP-MST-ITEM-001 T02 Lifecycle',()=>{
test(scenarioTitle(masterRecovery.id),async({page})=>{
await page.goto('/erp/items')
await expect(page.locator('[data-screen-id="ERP-MST-ITEM-001"]')).toBeVisible()
await expect(page.locator('[data-kbx-surface="master-list"]')).toBeVisible()
await expect(page.locator('[data-kbx-surface="detail"]')).toBeVisible()
await expect(page.getByRole('button',{name:/저장 F8/})).toBeDisabled()
})
})
@@ -0,0 +1,9 @@
import { test, expect } from '@playwright/test'
test('T04 item price fast entry supports lookup, paste, fill down and save', async ({ page }) => {
await page.goto('/erp/item-prices')
await expect(page.getByText('품목 단가 일괄등록')).toBeVisible()
await expect(page.getByText('Excel 붙여넣기')).toBeVisible()
await expect(page.getByRole('button', { name: '행 추가' })).toBeVisible()
await expect(page.getByRole('button', { name: '아래 채우기' })).toBeVisible()
})
@@ -0,0 +1,5 @@
import { test, expect } from '@playwright/test'
test('ERP-PUR-001 supports keyboard-first header/detail purchase entry', async ({page}) => {
await page.goto('/erp/purchases/new'); await expect(page.locator('[data-screen-id="ERP-PUR-001"]')).toBeVisible(); await expect(page.getByText('현재 상태')).toBeVisible();
})
@@ -0,0 +1,12 @@
import { kbxTestScenarioCatalog } from '@kbx/contracts'
export function requireKbxScenario(id: keyof typeof kbxTestScenarioCatalog) {
const scenario = kbxTestScenarioCatalog[id]
if (!scenario) throw new Error(`Unknown KBX scenario: ${String(id)}`)
return scenario
}
export function scenarioTitle(id: keyof typeof kbxTestScenarioCatalog) {
const s = requireKbxScenario(id)
return `[${s.id}] ${s.title}`
}
@@ -0,0 +1,17 @@
import { createHash, randomUUID } from 'node:crypto'
import { readFile } from 'node:fs/promises'
export type KbxScenarioEvidenceArtifact = {
type: string
path: string
sha256?: string
}
export async function sha256File(path: string) {
const bytes = await readFile(path)
return createHash('sha256').update(bytes).digest('hex')
}
export function createScenarioRunId() {
return `kbx-scenario-${randomUUID()}`
}
@@ -0,0 +1,21 @@
import { test, expect } from '@playwright/test'
import { requireKbxScenario, scenarioTitle } from './helpers/kbxScenario'
const orderKeyboard = requireKbxScenario('scenario.oms.order-register.keyboard')
const shellDirty = requireKbxScenario('scenario.shell.unsaved-tab')
test(scenarioTitle(orderKeyboard.id), async ({ page }) => {
await page.goto('/oms/orders/register')
await page.keyboard.press('F2')
await expect(page.getByRole('dialog')).toBeVisible()
await page.keyboard.press('Escape')
await page.keyboard.press('F8')
// Host app should seed synthetic lookup data and assert saved/problem outcome.
})
test(scenarioTitle(shellDirty.id), async ({ page }) => {
await page.goto('/oms/orders/register')
await page.getByLabel('수취인').fill('테스트수취인')
await page.getByRole('button', { name: /주문등록.*닫기|닫기/ }).click()
await expect(page.getByText('저장하지 않은 변경사항이 있습니다.')).toBeVisible()
})
@@ -0,0 +1,14 @@
import { expect, test } from '@playwright/test'
for (const scenario of [
{ density:'compact', width:1440, height:900 },
{ density:'comfortable', width:1440, height:900 },
{ density:'touch', width:390, height:844 },
] as const) {
test(`KBX catalog ${scenario.density} visual baseline`, async ({ page }) => {
await page.setViewportSize({ width:scenario.width, height:scenario.height })
await page.goto(`/internal/kbx/catalog?density=${scenario.density}`)
await expect(page.getByRole('heading',{name:'KBX 컴포넌트 카탈로그'})).toBeVisible()
await expect(page).toHaveScreenshot(`kbx-catalog-${scenario.density}.png`, { fullPage:true })
})
}
@@ -0,0 +1,2 @@
import { test, expect } from '@playwright/test'
test('experiment governance screen keeps safe scope visible',async({page})=>{await page.goto('/internal/kbx/experiments');await expect(page.getByText('안전 범위')).toBeVisible();await expect(page.getByText(/권한·민감정보·Domain Rule/)).toBeVisible()})
@@ -0,0 +1,12 @@
import { test, expect } from '@playwright/test'
test.describe('KBX Fast Entry / Bulk Grid',()=>{
test('catalog exposes explicit fast-entry actions and server-side whole-result selection',async({page})=>{
await page.goto('/internal/kbx/catalog?density=compact')
await expect(page.getByRole('heading',{name:'Fast Entry Grid'})).toBeVisible()
await expect(page.getByRole('button',{name:'행 추가'})).toBeVisible()
await expect(page.getByRole('button',{name:'행 복제'})).toBeVisible()
await expect(page.getByRole('button',{name:'아래 채우기'})).toBeVisible()
await expect(page.getByRole('button',{name:/검색결과 82,415건 전체선택/})).toBeVisible()
})
})
@@ -0,0 +1,42 @@
import { expect, test } from '@playwright/test'
test('core ERP keyboard contract remains consistent', async ({ page }) => {
await page.goto('/oms/orders/new')
await page.keyboard.press('F2')
await page.keyboard.press('Escape')
await page.keyboard.press('Tab')
await page.keyboard.press('Shift+Tab')
await page.keyboard.press('Enter')
await page.keyboard.press('F8')
await expect(page.locator('body')).toBeVisible()
})
test('search screen keeps F3 and mouse-complete behavior', async ({ page }) => {
await page.goto('/oms/orders')
await page.keyboard.press('F3')
await expect(page.getByRole('button',{name:/조회/})).toBeVisible()
})
test('menu search is available by mouse and Ctrl+K', async ({ page }) => {
await page.goto('/')
await expect(page.getByRole('button',{name:/메뉴 검색/})).toBeVisible()
await page.keyboard.press('Control+K')
await page.keyboard.type('재고')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('Enter')
})
test('component tabs support arrow, Home and End navigation', async ({ page }) => {
await page.goto('/internal/kbx/catalog?density=compact')
const basic=page.getByRole('tab',{name:'기본'})
const audit=page.getByRole('tab',{name:/변경이력/})
await basic.focus()
await page.keyboard.press('ArrowRight')
await expect(audit).toHaveAttribute('aria-selected','true')
await page.keyboard.press('Home')
await expect(basic).toHaveAttribute('aria-selected','true')
await page.keyboard.press('End')
await expect(audit).toHaveAttribute('aria-selected','true')
await page.keyboard.press('ArrowLeft')
await expect(basic).toHaveAttribute('aria-selected','true')
})
@@ -0,0 +1,5 @@
import { test, expect } from '@playwright/test'
test('OMS-CLM-001 keeps claim processing inside familiar list/workflow grammar', async ({page}) => {
await page.goto('/oms/claims'); await expect(page.locator('[data-screen-id="OMS-CLM-001"]')).toBeVisible(); await page.keyboard.press('F3');
})
@@ -0,0 +1,19 @@
import { test, expect } from '@playwright/test'
test.describe('OMS-ORD-003 주문 Excel Import Golden Flow', () => {
test('파일 → 매핑 → 검증 → 정상건 반영', async ({ page }) => {
await page.goto('/oms/orders/import')
await expect(page.getByRole('heading', { name: '주문 Excel 업로드' })).toBeVisible()
// Host repository should supply tests/fixtures/orders-valid-and-invalid.xlsx.
await page.locator('input[type=file]').setInputFiles('tests/fixtures/orders-valid-and-invalid.xlsx')
await expect(page.getByText('2 매핑')).toBeVisible()
await page.getByRole('button', { name: '검증 시작' }).click()
await expect(page.getByText('검증 결과')).toBeVisible({ timeout: 30_000 })
await expect(page.getByText('오류')).toBeVisible()
await page.getByRole('button', { name: '정상 데이터 반영' }).click()
await expect(page.getByText('신규')).toBeVisible({ timeout: 30_000 })
})
})
@@ -0,0 +1,18 @@
import { test, expect } from '@playwright/test'
test.describe('OMS-ORD-001 주문관리', () => {
test('F3 조회 후 주문을 선택할 수 있다', async ({ page }) => {
await page.goto('/oms/orders')
await page.keyboard.press('F3')
const screen = page.locator('[data-screen-id="OMS-ORD-001"]')
await expect(screen).toBeVisible()
// 실제 프로젝트에서는 API fixture를 고정하고 grid row selector를 KBX testing helper로 감싼다.
})
test('선택 전 출고지시는 비활성화된다', async ({ page }) => {
await page.goto('/oms/orders')
await expect(page.getByRole('button', { name: /출고지시/ })).toBeDisabled()
})
})
@@ -0,0 +1,20 @@
import { test, expect } from '@playwright/test'
test.describe('OMS-ORD-002 주문등록 Golden Screen', () => {
test('키보드 중심 주문등록: F2 lookup → detail entry → F8 save', async ({ page }) => {
await page.goto('/oms/orders/new')
await expect(page.locator('[data-screen-id="OMS-ORD-002"]')).toBeVisible()
// 거래처 Lookup: real test environment should seed deterministic customer fixture.
const customerCode = page.getByLabel('코드').first()
await customerCode.focus()
await page.keyboard.press('F2')
await expect(page.getByText('거래처 검색')).toBeVisible()
await page.keyboard.press('Escape')
// Header validation is visible without losing entered data.
await page.keyboard.press('F8')
await expect(page.getByText(/항목을 확인하세요/)).toBeVisible()
})
})
@@ -0,0 +1,20 @@
import { test, expect } from '@playwright/test'
test.describe('KBX production resilience UX', () => {
test('version conflict does not silently overwrite latest server data', async ({ page }) => {
await page.goto('/oms/orders/new')
// Host repository should stub a 409 KbxConflictProblem after editing.
// Verify KbxConflictResolver shows latest server values and no second mutation occurs automatically.
})
test('long-running operation remains visible after route change', async ({ page }) => {
await page.goto('/oms/orders/import')
// Start a background import, navigate away, then verify the runtime Operation Center still exposes progress.
})
test('degraded mode is visible without blocking unaffected reads', async ({ page }) => {
await page.goto('/oms/orders')
// Stub /api/kbx/runtime/notice as degraded and verify the banner is visible while the list remains usable.
await expect(page.locator('[data-screen-id="OMS-ORD-001"]')).toBeVisible()
})
})
@@ -0,0 +1,109 @@
import { test, expect } from '@playwright/test'
const taskId = '00000000-0000-0000-0000-000000000101'
function task(stage: string, version: number, pickedQty = 0) {
const completed = stage === 'completed'
return {
taskId,
taskNo: 'PICK-20260808-001',
stage,
status: completed ? 'COMPLETED' : stage === 'ready' ? 'READY' : 'IN_PROGRESS',
completedLines: completed ? 1 : 0,
totalLines: 1,
completedQty: completed ? 2 : pickedQty,
totalQty: 2,
version,
currentLine: completed ? null : {
lineId: '00000000-0000-0000-0000-000000000201',
lineNo: 1,
locationCode: 'A-03-02',
itemId: '00000000-0000-0000-0000-000000000301',
itemCode: 'ABC001',
itemName: '운동화',
itemOption: 'BLACK / 270',
barcode: '8801234567890',
requiredQty: 2,
pickedQty,
remainingQty: 2 - pickedQty,
},
}
}
test.describe('WMS-PICK-001 피킹 Golden Screen', () => {
test('위치 → 상품 스캔을 확인 modal 없이 연속 처리한다', async ({ page }) => {
let current = task('ready', 1)
await page.route(`**/api/wms/picking/tasks/${taskId}`, route =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(current) }))
await page.route(`**/api/wms/picking/tasks/${taskId}/start`, route => {
current = task('await-location', 2)
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(current) })
})
await page.route(`**/api/wms/picking/tasks/${taskId}/scan`, async route => {
const body = route.request().postDataJSON()
if (body.barcode === 'LOC-A-03-02') {
current = task('await-item', 3)
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
accepted: true, duplicate: false, task: current, feedback: 'success',
message: '위치를 확인했습니다. 상품을 스캔하세요.',
}) })
}
current = task('await-item', 4, 1)
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({
accepted: true, duplicate: false, task: current, feedback: 'success', message: '1개 피킹했습니다.',
}) })
})
await page.goto(`/wms/picking/${taskId}`)
await expect(page.locator('[data-screen-id="WMS-PICK-001"]')).toBeVisible()
await page.getByRole('button', { name: '작업 시작' }).click()
await expect(page.getByText('위치를 스캔하세요.')).toBeVisible()
// Keyboard-wedge scanners typically emit fast characters followed by Enter.
await page.keyboard.type('LOC-A-03-02', { delay: 10 })
await page.keyboard.press('Enter')
await expect(page.getByText('상품을 스캔하세요.')).toBeVisible()
await page.keyboard.type('8801234567890', { delay: 10 })
await page.keyboard.press('Enter')
await expect(page.getByText('1개 피킹했습니다.')).toBeVisible()
await expect(page.getByText('남음').locator('..').getByText('1')).toBeVisible()
})
test('잘못된 위치는 retry queue 대상이 아닌 업무 오류로 즉시 안내한다', async ({ page }) => {
let current = task('await-location', 2)
await page.route(`**/api/wms/picking/tasks/${taskId}`, route =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(current) }))
await page.route(`**/api/wms/picking/tasks/${taskId}/scan`, route =>
route.fulfill({ status: 422, contentType: 'application/json', body: JSON.stringify({
accepted: false, duplicate: false, task: current, feedback: 'error',
message: '잘못된 위치입니다. A-03-02 위치로 이동하세요.',
}) }))
await page.goto(`/wms/picking/${taskId}`)
await page.keyboard.type('WRONG-LOC', { delay: 10 })
await page.keyboard.press('Enter')
await expect(page.getByText('잘못된 위치입니다. A-03-02 위치로 이동하세요.')).toBeVisible()
await expect(page.getByText('전송 대기')).toHaveCount(0)
})
test('오프라인이면 다음 authoritative scan을 받지 않는다', async ({ page, context }) => {
await page.route(`**/api/wms/picking/tasks/${taskId}`, route =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(task('await-item', 3)) }))
await page.goto(`/wms/picking/${taskId}`)
await context.setOffline(true)
await page.evaluate(() => window.dispatchEvent(new Event('offline')))
await expect(page.getByText(/오프라인/)).toBeVisible()
await expect(page.getByText('SCAN PAUSED')).toBeVisible()
})
})
@@ -0,0 +1,5 @@
import { test, expect } from '@playwright/test'
for (const [path,id] of [['/wms/receiving/task-1','WMS-REC-001'],['/wms/putaway/task-1','WMS-PUT-001'],['/wms/counting/task-1','WMS-COUNT-001']] as const) {
test(`${id} uses the WMS mobile shell`, async ({page}) => { await page.goto(path); await expect(page.locator(`[data-screen-id="${id}"]`)).toBeVisible() })
}