V13-FE-006: consolidate approved UI and contract hardening
deploy / deploy (push) Successful in 1m52s
deploy / notify (push) Successful in 1s

This commit is contained in:
2026-08-13 02:41:00 +09:00
parent d79edae546
commit 3f293d8aa8
1278 changed files with 14384 additions and 1664 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,44 @@
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()
})
test('HTTP 200이어도 해결 거절이면 성공으로 가장하지 않고 authoritative receipt로 복구한다', async ({ page }) => {
await page.route('**/api/operations/work-items/resolve', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ resolvedCount: 0, rejectedCount: 1 }),
}))
await page.goto('/operations/exceptions')
await page.getByText('주문 재고 부족', { exact: true }).dblclick()
await expect(page.getByRole('button', { name: '해결 처리' })).toBeVisible()
await page.getByRole('button', { name: '해결 처리' }).click()
const receipt=page.locator('[data-kbx-surface="authoritative-action-receipt"]')
await expect(receipt).toBeVisible()
await expect(receipt).toBeFocused()
await expect(receipt).toContainText('해결 처리가 완료되지 않았습니다.')
await expect(receipt).toContainText('해결 0건')
await expect(receipt).toContainText('거절 1건')
await expect(page.getByRole('button', { name: '최신 상태 조회' })).toBeVisible()
})
test('다음 긴급건은 demo DTO의 critical work item을 실제 Drawer로 연다', async ({ page }) => {
await page.goto('/operations/exceptions')
const nextUrgent=page.getByRole('button', { name: '다음 긴급건' })
await expect(nextUrgent).toBeEnabled()
await nextUrgent.click()
await expect(page.getByText('출고 연계 지연', { exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: '재처리' })).toBeVisible()
})
})
@@ -0,0 +1,35 @@
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()
await expect(page.getByText('ORD-260811-0001', { exact: true })).toBeVisible()
})
test('예외등록 HTTP 200의 skippedCount를 실제 업무 결과로 표시하고 원천값은 변경하지 않는다', async ({ page }) => {
await page.route('**/api/reconcile/items/create-exceptions', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ createdOrUpdatedCount: 0, skippedCount: 1 }),
}))
await page.goto('/operations/reconcile')
await page.getByText('ORD-260811-0001', { exact: true }).dblclick()
await expect(page.getByText(/원천값을 화면에서 직접 덮어쓰지 않습니다/)).toBeVisible()
await page.getByRole('button', { name: '이 건 예외 등록' }).click()
const receipt=page.locator('[data-kbx-surface="reconcile-authoritative-receipt"]')
await expect(receipt).toBeVisible()
await expect(receipt).toBeFocused()
await expect(receipt).toContainText('예외 업무 등록이 일부 처리되었습니다.')
await expect(receipt).toContainText('생성/갱신 0건')
await expect(receipt).toContainText('건너뜀 1건')
await expect(page.getByRole('button', { name: '최신 대사 조회' })).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,53 @@
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')
})
test('lookup Escape returns focus to the invoking field instead of losing keyboard context', async ({ page }) => {
await page.goto('/oms/orders/new')
const customerCode=page.getByRole('textbox',{name:'거래처 코드'})
await customerCode.focus()
await page.keyboard.press('F2')
await expect(page.getByRole('dialog')).toBeVisible()
await page.keyboard.press('Escape')
await expect(customerCode).toBeFocused()
})
@@ -0,0 +1,39 @@
import { test, expect } from '@playwright/test'
test.describe('OMS-CLM-001 클레임 업무 흐름', () => {
test('선택 계약과 WorkflowBar가 실제 승인 Command까지 연결된다', async ({ page }) => {
await page.goto('/oms/claims')
await expect(page.locator('[data-screen-id="OMS-CLM-001"]')).toBeVisible()
await page.keyboard.press('F3')
await expect(page.getByText('CLM-260811-01', { exact: true })).toBeVisible()
await page.getByText('CLM-260811-01', { exact: true }).click()
const workflow=page.getByLabel('업무 상태')
await expect(workflow).toBeVisible()
await expect(workflow.getByRole('button', { name: '승인', exact: true })).toBeVisible()
await workflow.getByRole('button', { name: '승인', exact: true }).click()
const receipt=page.locator('[data-kbx-surface="claim-action-receipt"]')
await expect(receipt).toBeVisible()
await expect(receipt).toBeFocused()
await expect(receipt).toContainText('요청 1건 · 성공 1건 · 실패 0건')
})
test('승인 상태는 처리 시작으로 이어지고 보류는 명시적 상태로 표시된다', async ({ page }) => {
await page.goto('/oms/claims')
await page.getByLabel('상태').selectOption('APPROVED')
await page.keyboard.press('F3')
await page.getByText('CLM-260811-02', { exact: true }).click()
const workflow=page.getByLabel('업무 상태')
await expect(workflow).toContainText('승인')
await workflow.getByRole('button', { name: '처리 시작', exact: true }).click()
await expect(page.locator('[data-kbx-surface="claim-action-receipt"]')).toContainText('처리 시작 결과')
await page.getByLabel('상태').selectOption('HOLD')
await page.keyboard.press('F3')
await page.getByText('CLM-260811-04', { exact: true }).click()
await expect(page.getByLabel('업무 상태')).toContainText('보류')
await expect(page.getByLabel('업무 상태')).toContainText('현재 상태에서 실행 가능한 다음 작업이 없습니다.')
})
})
@@ -0,0 +1,38 @@
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()
// Demo API validates the browser workflow, not workbook parsing. Keep this fixture in-memory so
// the E2E contract never depends on an untracked binary workbook in the repository.
await page.locator('input[type=file]').setInputFiles({
name: 'orders-valid-and-invalid.xlsx',
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
buffer: Buffer.from('KBX E2E demo workbook'),
})
const mappingHeading=page.getByRole('heading',{name:'필드 매핑'})
await expect(mappingHeading).toBeVisible()
await expect(mappingHeading).toBeFocused()
await page.getByRole('button', { name: '검증 시작' }).click()
const previewHeading=page.getByRole('heading',{name:'반영 미리보기'})
await expect(previewHeading).toBeVisible({ timeout: 30_000 })
await expect(previewHeading).toBeFocused()
await expect(page.getByText('오류').first()).toBeVisible()
await page.getByRole('button', { name: '첫 오류' }).click()
await expect(page.locator('[data-import-error-index="0"]')).toBeFocused()
await page.getByRole('button', { name: '정상 데이터 반영' }).click()
await page.getByRole('button', { name: /건 반영$/ }).click()
const resultHeading=page.getByRole('heading',{name:'반영 결과'})
await expect(resultHeading).toBeVisible({ timeout: 30_000 })
await expect(resultHeading).toBeFocused()
await expect(page.getByText(/정상 데이터는 반영되었고 오류 데이터는 제외되었습니다/)).toBeVisible()
await expect(page.getByRole('button',{name:'오류파일 다운로드'})).toBeVisible()
})
})
@@ -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,149 @@
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()
})
})
test.describe('WMS-PICK-001 authoritative retry evidence', () => {
test('response loss 재전송은 최초 command의 동일 idempotencyKey를 사용하고 서버 확인 뒤에만 최근 확인을 갱신한다', async ({ page, context }) => {
let current = task('await-item', 3)
const scanBodies: Array<Record<string, unknown>> = []
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`, async route => {
const body=route.request().postDataJSON() as Record<string, unknown>
scanBodies.push(body)
if(scanBodies.length===1) return route.abort('failed')
current=task('await-item',4,1)
return route.fulfill({
status:200,
contentType:'application/json',
body:JSON.stringify({accepted:true,duplicate:true,task:current,feedback:'success',message:'재전송된 스캔을 서버에서 확인했습니다.'}),
})
})
await page.goto(`/wms/picking/${taskId}`)
await page.keyboard.type('8801234567890', { delay: 10 })
await page.keyboard.press('Enter')
await expect(page.getByText(/서버 확인이 필요합니다/)).toBeVisible()
await expect(page.getByText('아직 확인된 스캔 없음')).toBeVisible()
await context.setOffline(true)
await page.evaluate(() => window.dispatchEvent(new Event('offline')))
await context.setOffline(false)
await page.evaluate(() => window.dispatchEvent(new Event('online')))
await expect.poll(() => scanBodies.length).toBe(2)
expect(scanBodies[0]?.idempotencyKey).toBeTruthy()
expect(scanBodies[1]?.idempotencyKey).toBe(scanBodies[0]?.idempotencyKey)
await expect(page.getByText(/재전송된 스캔을 서버에서 확인했습니다/)).toBeVisible()
await expect(page.getByText(/8801234567890/)).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() })
}
@@ -0,0 +1,21 @@
import { test, expect } from '@playwright/test'
test.describe('WMS-WORK-001 운영 Queue', () => {
test('첫 진입부터 전체 내 작업과 예외 신호를 숨기지 않는다', async ({ page }) => {
await page.goto('/wms/work')
await expect(page.locator('[data-screen-id="WMS-WORK-001"]')).toBeVisible()
await expect(page.getByText('PICK-260811-009', { exact: true })).toBeVisible()
const exceptionSummary=page.getByLabel('업무 예외 요약')
await expect(exceptionSummary).toContainText('확인 필요한 작업')
await expect(exceptionSummary).toContainText('1')
})
test('예외 필터는 BLOCKED만 좁혀 보고 작업 시작은 fail-closed 한다', async ({ page }) => {
await page.goto('/wms/work')
await page.getByLabel('업무 예외 요약').getByRole('button').click()
await expect(page.getByText('PICK-260811-009', { exact: true })).toBeVisible()
await page.getByText('PICK-260811-009', { exact: true }).click()
await expect(page.getByRole('button', { name: '선택 작업 시작' })).toBeDisabled()
await expect(page.getByText('예외 작업은 행을 열어 원인을 먼저 확인하세요.')).toBeVisible()
})
})