V13-FE-011: finalize search list layout slice
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Kbx.Tests.Scenarios;
|
||||
|
||||
public sealed record KbxScenarioArtifact(string Type, string Path, string? Sha256 = null);
|
||||
|
||||
public sealed record KbxScenarioEvidence(
|
||||
string ScenarioId,
|
||||
string RunId,
|
||||
DateTimeOffset StartedAt,
|
||||
DateTimeOffset? FinishedAt,
|
||||
string Result,
|
||||
IReadOnlyDictionary<string,string> ContractVersions,
|
||||
IReadOnlyList<string> CorrelationIds,
|
||||
IReadOnlyList<KbxScenarioArtifact> Artifacts);
|
||||
|
||||
public static class KbxScenarioHash
|
||||
{
|
||||
public static string Sha256File(string path)
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
return Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Xunit;
|
||||
using Npgsql;
|
||||
using Testcontainers.PostgreSql;
|
||||
|
||||
namespace Kbx.Tests.Scenarios;
|
||||
|
||||
/// <summary>
|
||||
/// Host repository reference fixture. Requires Testcontainers.PostgreSql and Npgsql.
|
||||
/// The container is disposable; never point this fixture at a production connection string.
|
||||
/// </summary>
|
||||
public sealed class KbxScenarioPostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:17-alpine")
|
||||
.WithDatabase("kbx_scenario_test")
|
||||
.WithUsername("kbx")
|
||||
.WithPassword("kbx-test-only")
|
||||
.Build();
|
||||
|
||||
public string ConnectionString => _postgres.GetConnectionString();
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
await _postgres.StartAsync();
|
||||
// Host: run DbUp migrations in filename order, then bootstrap/reset/seed SQL.
|
||||
}
|
||||
|
||||
public Task DisposeAsync() => _postgres.DisposeAsync().AsTask();
|
||||
|
||||
public static async Task AssertTestGuardAsync(string connectionString, CancellationToken ct = default)
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(connectionString);
|
||||
await connection.OpenAsync(ct);
|
||||
await using var command = new NpgsqlCommand(
|
||||
"select exists(select 1 from kbx_test.environment_guard where marker='KBX_SCENARIO_TEST_ONLY')", connection);
|
||||
if (await command.ExecuteScalarAsync(ct) is not true)
|
||||
throw new InvalidOperationException("KBX scenario database guard is missing.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.Text.Json;
|
||||
using ClosedXML.Excel;
|
||||
|
||||
namespace Kbx.Tests.Scenarios;
|
||||
|
||||
/// <summary>Builds an XLSX from the checked-in synthetic JSON fixture. No production spreadsheet is stored in source control.</summary>
|
||||
public static class KbxSyntheticImportWorkbook
|
||||
{
|
||||
private sealed record Fixture(string Sheet, string[] Columns, JsonElement[][] Rows);
|
||||
|
||||
public static void Build(string jsonPath, string xlsxPath)
|
||||
{
|
||||
var fixture = JsonSerializer.Deserialize<Fixture>(File.ReadAllText(jsonPath),
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||
?? throw new InvalidOperationException("Invalid synthetic import fixture.");
|
||||
|
||||
using var workbook = new XLWorkbook();
|
||||
var sheet = workbook.Worksheets.Add(fixture.Sheet);
|
||||
for (var c = 0; c < fixture.Columns.Length; c++)
|
||||
sheet.Cell(1, c + 1).Value = fixture.Columns[c];
|
||||
|
||||
for (var r = 0; r < fixture.Rows.Length; r++)
|
||||
for (var c = 0; c < fixture.Rows[r].Length; c++)
|
||||
{
|
||||
var cell = sheet.Cell(r + 2, c + 1);
|
||||
var value = fixture.Rows[r][c];
|
||||
cell.Value = value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number when value.TryGetDecimal(out var number) => number,
|
||||
JsonValueKind.String => value.GetString() ?? string.Empty,
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
_ => value.ToString()
|
||||
};
|
||||
}
|
||||
workbook.SaveAs(xlsxPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { kbxAiCapabilities, getSensitivePolicyForField } from '../../apps/web/src/permissions/authorizationPolicy'
|
||||
|
||||
describe('KBX authorization contracts', () => {
|
||||
it('does not grant AI execute from AI-use permission alone', () => {
|
||||
expect(kbxAiCapabilities(['common.ai.use'], ['explain', 'suggest', 'draft', 'execute']))
|
||||
.toEqual(['explain', 'suggest', 'draft'])
|
||||
})
|
||||
|
||||
it('requires explicit AI execute permission', () => {
|
||||
expect(kbxAiCapabilities(['common.ai.use', 'common.ai.execute'], ['explain', 'execute']))
|
||||
.toEqual(['explain', 'execute'])
|
||||
})
|
||||
|
||||
it('maps phone to the order recipient sensitive policy', () => {
|
||||
expect(getSensitivePolicyForField('phone')?.id).toBe('oms.order.recipient')
|
||||
})
|
||||
})
|
||||
@@ -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() })
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"sheet": "업로드",
|
||||
"columns": [
|
||||
"주문번호",
|
||||
"주문일",
|
||||
"거래처코드",
|
||||
"출고창고",
|
||||
"수취인",
|
||||
"연락처",
|
||||
"주소",
|
||||
"상품코드",
|
||||
"수량",
|
||||
"단가"
|
||||
],
|
||||
"rows": [
|
||||
[
|
||||
"TEST-IMP-001",
|
||||
"2026-08-08",
|
||||
"TEST-C001",
|
||||
"TEST-WH01",
|
||||
"테스트수취인01",
|
||||
"010-0000-0001",
|
||||
"테스트주소 1",
|
||||
"TEST-ITEM-001",
|
||||
1,
|
||||
10000
|
||||
],
|
||||
[
|
||||
"TEST-IMP-002",
|
||||
"2026-08-08",
|
||||
"TEST-C001",
|
||||
"TEST-WH01",
|
||||
"테스트수취인02",
|
||||
"010-0000-0002",
|
||||
"테스트주소 2",
|
||||
"TEST-ITEM-001",
|
||||
2,
|
||||
10000
|
||||
],
|
||||
[
|
||||
"TEST-IMP-003",
|
||||
"2026-08-08",
|
||||
"TEST-C001",
|
||||
"TEST-WH01",
|
||||
"테스트수취인03",
|
||||
"010-0000-0003",
|
||||
"테스트주소 3",
|
||||
"TEST-ITEM-001",
|
||||
3,
|
||||
10000
|
||||
],
|
||||
[
|
||||
"TEST-IMP-004",
|
||||
"2026-08-08",
|
||||
"TEST-C001",
|
||||
"TEST-WH01",
|
||||
"테스트수취인04",
|
||||
"010-0000-0004",
|
||||
"테스트주소 4",
|
||||
"TEST-ITEM-001",
|
||||
4,
|
||||
10000
|
||||
],
|
||||
[
|
||||
"TEST-IMP-005",
|
||||
"2026-08-08",
|
||||
"TEST-C001",
|
||||
"TEST-WH01",
|
||||
"테스트수취인05",
|
||||
"010-0000-0005",
|
||||
"테스트주소 5",
|
||||
"TEST-NOT-EXIST",
|
||||
1,
|
||||
10000
|
||||
],
|
||||
[
|
||||
"TEST-IMP-006",
|
||||
"2026-08-08",
|
||||
"TEST-C001",
|
||||
"TEST-WH01",
|
||||
"테스트수취인06",
|
||||
"010-0000-0006",
|
||||
"테스트주소 6",
|
||||
"TEST-ITEM-001",
|
||||
0,
|
||||
10000
|
||||
]
|
||||
],
|
||||
"expected": {
|
||||
"valid": 4,
|
||||
"invalid": 2,
|
||||
"invalidExcelRows": [
|
||||
7,
|
||||
8
|
||||
]
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
-- TEST ONLY. Run only against an ephemeral/scenario database.
|
||||
create schema if not exists kbx_test;
|
||||
create table if not exists kbx_test.environment_guard(
|
||||
marker text primary key,
|
||||
created_at timestamptz not null default now()
|
||||
);
|
||||
insert into kbx_test.environment_guard(marker)
|
||||
values('KBX_SCENARIO_TEST_ONLY')
|
||||
on conflict(marker) do nothing;
|
||||
@@ -0,0 +1,52 @@
|
||||
do $$
|
||||
begin
|
||||
if not exists(select 1 from kbx_test.environment_guard where marker='KBX_SCENARIO_TEST_ONLY') then
|
||||
raise exception 'KBX scenario reset refused: test environment guard is missing';
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
-- Delete only deterministic KBX synthetic records. Never bulk-delete arbitrary host data.
|
||||
delete from kbx.command_receipts where idempotency_key like 'kbx-test-%';
|
||||
delete from kbx.import_sessions where tenant_id='KBX-TEST-TENANT';
|
||||
|
||||
delete from oms.order_lines where order_id in (select id from oms.orders where order_no like 'TEST-IMP-%');
|
||||
delete from audit.entries where aggregate_id in (select id from oms.orders where order_no like 'TEST-IMP-%');
|
||||
delete from integration.outbox where aggregate_id in (select id from oms.orders where order_no like 'TEST-IMP-%');
|
||||
delete from oms.orders where order_no like 'TEST-IMP-%';
|
||||
delete from wms.scan_receipts where task_id='60000000-0000-4000-8000-000000000001';
|
||||
delete from wms.quantity_receipts where task_id='60000000-0000-4000-8000-000000000001';
|
||||
delete from wms.picking_exceptions where task_id='60000000-0000-4000-8000-000000000001';
|
||||
delete from wms.picking_lines where task_id='60000000-0000-4000-8000-000000000001';
|
||||
delete from wms.picking_tasks where id='60000000-0000-4000-8000-000000000001';
|
||||
|
||||
delete from oms.order_lines where order_id in (
|
||||
'50000000-0000-4000-8000-000000000001','50000000-0000-4000-8000-000000000002',
|
||||
'50000000-0000-4000-8000-000000000003','50000000-0000-4000-8000-000000000004');
|
||||
delete from audit.entries where aggregate_id in (
|
||||
'50000000-0000-4000-8000-000000000001','50000000-0000-4000-8000-000000000002',
|
||||
'50000000-0000-4000-8000-000000000003','50000000-0000-4000-8000-000000000004');
|
||||
delete from integration.outbox where aggregate_id in (
|
||||
'50000000-0000-4000-8000-000000000001','50000000-0000-4000-8000-000000000002',
|
||||
'50000000-0000-4000-8000-000000000003','50000000-0000-4000-8000-000000000004');
|
||||
delete from oms.orders where id in (
|
||||
'50000000-0000-4000-8000-000000000001','50000000-0000-4000-8000-000000000002',
|
||||
'50000000-0000-4000-8000-000000000003','50000000-0000-4000-8000-000000000004');
|
||||
|
||||
delete from kbx.work_item_audit where tenant_id='KBX-TEST-TENANT';
|
||||
delete from kbx.work_items where tenant_id='KBX-TEST-TENANT';
|
||||
delete from kbx.reconcile_items where tenant_id='KBX-TEST-TENANT';
|
||||
|
||||
delete from erp.inventory_move_lines where move_id in ('70000000-0000-4000-8000-000000000001','70000000-0000-4000-8000-000000000002');
|
||||
delete from erp.inventory_moves where id in ('70000000-0000-4000-8000-000000000001','70000000-0000-4000-8000-000000000002');
|
||||
delete from audit.entries where aggregate_id in ('40000000-0000-4000-8000-000000000001','40000000-0000-4000-8000-000000000002');
|
||||
delete from integration.outbox where aggregate_id in ('40000000-0000-4000-8000-000000000001','40000000-0000-4000-8000-000000000002');
|
||||
delete from erp.item_prices where item_id in ('40000000-0000-4000-8000-000000000001','40000000-0000-4000-8000-000000000002');
|
||||
delete from erp_inventory_ledger_projection where item_id in ('40000000-0000-4000-8000-000000000001','40000000-0000-4000-8000-000000000002');
|
||||
delete from erp_inventory_snapshot_projection where item_id in ('40000000-0000-4000-8000-000000000001','40000000-0000-4000-8000-000000000002');
|
||||
|
||||
delete from oms.customers where id='20000000-0000-4000-8000-000000000001';
|
||||
delete from catalog.items where id in ('40000000-0000-4000-8000-000000000001','40000000-0000-4000-8000-000000000002');
|
||||
delete from inventory.warehouses where id in ('30000000-0000-4000-8000-000000000001','30000000-0000-4000-8000-000000000002');
|
||||
|
||||
delete from kbx.external_data_observations where tenant_id='11111111-1111-4111-8111-111111111111' and cache_key like 'TEST-%';
|
||||
delete from kbx.external_data_cache where tenant_id='11111111-1111-4111-8111-111111111111' and cache_key like 'TEST-%';
|
||||
@@ -0,0 +1,60 @@
|
||||
do $$
|
||||
begin
|
||||
if not exists(select 1 from kbx_test.environment_guard where marker='KBX_SCENARIO_TEST_ONLY') then
|
||||
raise exception 'KBX scenario seed refused: test environment guard is missing';
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
insert into oms.customers(id,code,name,is_active) values
|
||||
('20000000-0000-4000-8000-000000000001','TEST-C001','테스트거래처A',true);
|
||||
insert into inventory.warehouses(id,code,name,is_active) values
|
||||
('30000000-0000-4000-8000-000000000001','TEST-WH01','테스트서울센터',true),
|
||||
('30000000-0000-4000-8000-000000000002','TEST-WH02','테스트부산센터',true);
|
||||
insert into catalog.items(id,code,name,is_active) values
|
||||
('40000000-0000-4000-8000-000000000001','TEST-ITEM-001','테스트운동화',true),
|
||||
('40000000-0000-4000-8000-000000000002','TEST-ITEM-999','테스트오상품',true);
|
||||
|
||||
insert into erp_inventory_snapshot_projection(snapshot_key,item_id,item_code,item_name,specification,warehouse_id,warehouse_name,location_id,location_code,on_hand_qty,allocated_qty,hold_qty,available_qty,search_text) values
|
||||
('TEST-INV-001-A','40000000-0000-4000-8000-000000000001','TEST-ITEM-001','테스트운동화','BLACK/270','30000000-0000-4000-8000-000000000001','테스트서울센터',null,'A-03-02',10,2,1,7,'TEST-ITEM-001 테스트운동화 BLACK/270'),
|
||||
('TEST-INV-001-B','40000000-0000-4000-8000-000000000001','TEST-ITEM-001','테스트운동화','BLACK/270','30000000-0000-4000-8000-000000000002','테스트부산센터',null,'B-01-01',5,1,0,4,'TEST-ITEM-001 테스트운동화 BLACK/270');
|
||||
insert into erp_inventory_ledger_projection(entry_id,item_id,occurred_at,business_type,reference_no,warehouse_name,location_code,inbound_qty,outbound_qty,balance_qty,actor) values
|
||||
('72000000-0000-4000-8000-000000000001','40000000-0000-4000-8000-000000000001','2026-08-08T08:40:00Z','입고','TEST-GR-001','테스트서울센터','A-03-02',10,0,10,'kbx-test-operator'),
|
||||
('72000000-0000-4000-8000-000000000002','40000000-0000-4000-8000-000000000001','2026-08-08T08:50:00Z','출고할당','TEST-ORD-SHIP-001','테스트서울센터','A-03-02',0,3,7,'kbx-test-operator');
|
||||
insert into erp.item_prices(id,item_id,effective_date,unit_price,remark,version,created_at,created_by) values
|
||||
('73000000-0000-4000-8000-000000000001','40000000-0000-4000-8000-000000000001','2026-08-01',9000,'KBX v26 synthetic',1,'2026-08-08T08:30:00Z','kbx-test');
|
||||
|
||||
insert into oms.orders(id,order_no,order_date,customer_id,warehouse_id,receiver_name,phone,postal_code,address1,address2,status,version,created_at,created_by) values
|
||||
('50000000-0000-4000-8000-000000000001','TEST-ORD-SHIP-001','2026-08-08','20000000-0000-4000-8000-000000000001','30000000-0000-4000-8000-000000000001','테스트수취인01','010-0000-0001','00001','테스트주소 1',null,'READY',1,'2026-08-08T09:00:00Z','kbx-test'),
|
||||
('50000000-0000-4000-8000-000000000002','TEST-ORD-SHIP-002','2026-08-08','20000000-0000-4000-8000-000000000001','30000000-0000-4000-8000-000000000001','테스트수취인02','010-0000-0002','00002','테스트주소 2',null,'DRAFT',3,'2026-08-08T09:00:00Z','kbx-test'),
|
||||
('50000000-0000-4000-8000-000000000003','TEST-ORD-SHIP-003','2026-08-08','20000000-0000-4000-8000-000000000001','30000000-0000-4000-8000-000000000001','테스트수취인03','010-0000-0003','00003','테스트주소 3',null,'SHIPPED',5,'2026-08-08T09:00:00Z','kbx-test'),
|
||||
('50000000-0000-4000-8000-000000000004','TEST-ORD-CONFLICT-001','2026-08-08','20000000-0000-4000-8000-000000000001','30000000-0000-4000-8000-000000000001','테스트수취인04','010-0000-0004','00004','테스트주소 4',null,'DRAFT',7,'2026-08-08T09:00:00Z','kbx-test');
|
||||
|
||||
insert into oms.order_lines(id,order_id,line_no,item_id,quantity,unit_price,amount,remark) values
|
||||
('51000000-0000-4000-8000-000000000001','50000000-0000-4000-8000-000000000001',1,'40000000-0000-4000-8000-000000000001',1,10000,10000,null),
|
||||
('51000000-0000-4000-8000-000000000002','50000000-0000-4000-8000-000000000002',1,'40000000-0000-4000-8000-000000000001',1,10000,10000,null),
|
||||
('51000000-0000-4000-8000-000000000003','50000000-0000-4000-8000-000000000003',1,'40000000-0000-4000-8000-000000000001',1,10000,10000,null),
|
||||
('51000000-0000-4000-8000-000000000004','50000000-0000-4000-8000-000000000004',1,'40000000-0000-4000-8000-000000000001',2,10000,20000,null);
|
||||
|
||||
insert into wms.picking_tasks(id,task_no,status,assigned_to,version,created_at) values
|
||||
('60000000-0000-4000-8000-000000000001','TEST-PICK-001','READY','kbx-test-operator',1,'2026-08-08T09:00:00Z');
|
||||
insert into wms.picking_lines(id,task_id,line_no,location_code,location_barcode,location_confirmed,item_id,item_option,barcode,required_qty,picked_qty,status) values
|
||||
('61000000-0000-4000-8000-000000000001','60000000-0000-4000-8000-000000000001',1,'A-03-02','TEST-LOC-A0302',false,'40000000-0000-4000-8000-000000000001','BLACK/270','TEST-EAN-000001',2,0,'READY');
|
||||
|
||||
insert into kbx.work_items(id,tenant_id,source_module,source_type,source_id,reference_no,source_screen_id,source_version,code,title,detail,severity,status,allow_manual_resolution,occurred_at,version) values
|
||||
('80000000-0000-4000-8000-000000000001','KBX-TEST-TENANT','OMS','Order','TEST-ORD-EXC-001','TEST-ORD-EXC-001','OMS-ORD-001',10,'STOCK_SHORTAGE','테스트 재고부족','합성 테스트 예외','warning','open',false,'2026-08-08T09:00:00Z',1);
|
||||
insert into kbx.reconcile_items(id,tenant_id,reconcile_type,reference_no,source_label,target_label,expected_value,actual_value,difference_value,reason_code,reason_text,status,source_id,target_id,identity_key,evidence,occurred_at,version) values
|
||||
('81000000-0000-4000-8000-000000000001','KBX-TEST-TENANT','OMS_WMS_SHIP_QTY','TEST-ORD-REC-001','OMS','WMS','10','8','-2','INTEGRATION_PENDING','테스트 대사','pending','TEST-ORD-REC-001','TEST-PICK-REC-001','TEST-ORD-REC-001|SHIP_QTY','{}','2026-08-08T09:00:00Z',1);
|
||||
|
||||
insert into erp.inventory_moves(id,tenant_id,move_no,move_date,from_warehouse_id,to_warehouse_id,status,version) values
|
||||
('70000000-0000-4000-8000-000000000001','11111111-1111-4111-8111-111111111111','TEST-MOVE-001','2026-08-08','30000000-0000-4000-8000-000000000001','30000000-0000-4000-8000-000000000002','DRAFT',1);
|
||||
insert into erp.inventory_move_lines(id,move_id,item_id,move_qty,lot_no,remark) values
|
||||
('71000000-0000-4000-8000-000000000001','70000000-0000-4000-8000-000000000001','40000000-0000-4000-8000-000000000001',2,null,'KBX v18 synthetic');
|
||||
|
||||
-- KBX v21 external-data deterministic cache snapshots.
|
||||
insert into kbx.external_data_cache(tenant_id,dataset_id,provider_id,cache_key,request_descriptor,normalized_data,provider_observed_at,requested_at,received_at,ingested_at,fresh_until,usable_until,payload_sha256,normalizer_version,state,correlation_id) values
|
||||
('11111111-1111-4111-8111-111111111111','dataset.kis.domestic-stock.current-price','provider.kis.market-data','TEST-KIS-005930','{"marketDivisionCode":"J","stockCode":"005930","environment":"sandbox"}','{"marketDivisionCode":"J","stockCode":"005930","currentPrice":70000}',null,'2026-08-08T08:59:49Z','2026-08-08T08:59:50Z','2026-08-08T08:59:50Z','2026-08-08T08:59:53Z','2026-08-08T08:59:53Z',repeat('a',64),'1.0.0','fresh','TEST-CORR-KIS-001'),
|
||||
('11111111-1111-4111-8111-111111111111','dataset.opendart.company-profile','provider.opendart','TEST-DART-001','{"corpCode":"TEST0001"}','{"corpCode":"TEST0001","corpName":"테스트주식회사","stockCode":"000000","businessNumber":"0000000000"}',null,'2026-08-07T07:59:59Z','2026-08-07T08:00:00Z','2026-08-07T08:00:00Z','2026-08-08T08:00:00Z','2026-08-15T08:00:00Z',repeat('b',64),'1.0.0','fresh','TEST-CORR-DART-001');
|
||||
|
||||
insert into kbx.external_data_observations(tenant_id,dataset_id,provider_id,cache_key,payload_sha256,normalizer_version,provider_observed_at,received_at,ingested_at,state,correlation_id)
|
||||
select tenant_id,dataset_id,provider_id,cache_key,payload_sha256,normalizer_version,provider_observed_at,received_at,ingested_at,state,correlation_id
|
||||
from kbx.external_data_cache where tenant_id='11111111-1111-4111-8111-111111111111' and cache_key like 'TEST-%';
|
||||
@@ -0,0 +1,13 @@
|
||||
# KBX Scenario PostgreSQL fixtures
|
||||
|
||||
이 폴더의 SQL은 **테스트 전용**이다.
|
||||
|
||||
권장 순서:
|
||||
|
||||
1. 모든 DbUp migration 적용
|
||||
2. `00-bootstrap-test-database.sql`
|
||||
3. 각 시나리오/테스트 suite 전 `10-reset-v18.sql`
|
||||
4. `20-seed-v18.sql`
|
||||
|
||||
`reset`과 `seed`는 `kbx_test.environment_guard`가 없으면 즉시 실패한다. Production DB에 marker를 만들지 않는다.
|
||||
Fixture는 고정 UUID와 `TEST-*` 업무번호를 사용하며 실데이터 복사본을 포함하지 않는다.
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"rt_cd": "0",
|
||||
"msg_cd": "TEST0000",
|
||||
"msg1": "synthetic success",
|
||||
"output": {
|
||||
"stck_prpr": "70000"
|
||||
}
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"status": "000",
|
||||
"message": "synthetic success",
|
||||
"corp_code": "TEST0001",
|
||||
"corp_name": "테스트주식회사",
|
||||
"stock_code": "000000",
|
||||
"bizr_no": "0000000000"
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "013",
|
||||
"message": "조회된 데이타가 없습니다."
|
||||
}
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": "020",
|
||||
"message": "요청 제한을 초과하였습니다."
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"scenarioId": "scenario.oms.order-list.bulk-ship",
|
||||
"runId": "kbx-scenario-example",
|
||||
"startedAt": "2026-08-08T09:00:00Z",
|
||||
"finishedAt": "2026-08-08T09:00:03Z",
|
||||
"result": "passed",
|
||||
"environment": {
|
||||
"name": "reference-example",
|
||||
"syntheticDataOnly": true,
|
||||
"browser": "chromium",
|
||||
"database": "ephemeral-postgres"
|
||||
},
|
||||
"contractVersions": {
|
||||
"scenario": "1.0.0",
|
||||
"fixture": "1.0.0",
|
||||
"kbx": "1.5.1"
|
||||
},
|
||||
"correlationIds": [
|
||||
"example-correlation-id"
|
||||
],
|
||||
"artifacts": [
|
||||
{
|
||||
"type": "playwright-trace",
|
||||
"path": "artifacts/scenario.oms.order-list.bulk-ship/trace.zip"
|
||||
}
|
||||
],
|
||||
"notes": [
|
||||
"Example only; not evidence of an executed host scenario."
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isKbxProblem, kbxApiCatalog } from '@kbx/contracts'
|
||||
|
||||
describe('KBX API contract', () => {
|
||||
it('keeps operation IDs and routes unique', () => {
|
||||
const operations = Object.values(kbxApiCatalog)
|
||||
expect(new Set(operations.map(x => x.id)).size).toBe(operations.length)
|
||||
expect(new Set(operations.map(x => `${x.method} ${x.path}`)).size).toBe(operations.length)
|
||||
})
|
||||
|
||||
it('requires a stable key only for mutation operations', () => {
|
||||
const required = Object.values(kbxApiCatalog).filter(x => x.idempotency === 'required')
|
||||
expect(required.length).toBeGreaterThan(0)
|
||||
expect(required.every(x => x.method !== 'GET')).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes the public problem discriminator family', () => {
|
||||
for (const type of ['validation','business-rule','conflict','permission','not-found','integration','system']) {
|
||||
expect(isKbxProblem({ type, title: 'test' })).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isKbxBarcodeDuplicate, normalizeKbxBarcode } from '@kbx/ui'
|
||||
|
||||
describe('KbxBarcodeCapture contract', () => {
|
||||
it('normalizes scanner text but never invents another barcode', () => {
|
||||
expect(normalizeKbxBarcode(' 8801234567890 ', 'keyboard-wedge', 3, 256, 1)).toEqual({
|
||||
rawValue: ' 8801234567890 ',
|
||||
normalizedValue: '8801234567890',
|
||||
source: 'keyboard-wedge',
|
||||
occurredAt: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects implausibly short and oversized scanner buffers', () => {
|
||||
expect(normalizeKbxBarcode('A', 'keyboard-wedge', 3, 256, 1)).toBeNull()
|
||||
expect(normalizeKbxBarcode('123456', 'keyboard-wedge', 3, 5, 1)).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores only the same barcode inside the short device debounce window', () => {
|
||||
const last=normalizeKbxBarcode('ABC001','keyboard-wedge',3,256,1000)!
|
||||
const duplicate=normalizeKbxBarcode('ABC001','keyboard-wedge',3,256,1100)!
|
||||
const legitimateLaterScan=normalizeKbxBarcode('ABC001','keyboard-wedge',3,256,1400)!
|
||||
expect(isKbxBarcodeDuplicate(duplicate,last,180)).toBe(true)
|
||||
expect(isKbxBarcodeDuplicate(legitimateLaterScan,last,180)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { kbxComponentCatalog, kbxComponentManifest } from '@kbx/ui'
|
||||
|
||||
const requiredCore=[
|
||||
'KbxButton','KbxInput','KbxNumberField','KbxMoneyField','KbxQuantityField','KbxDateField','KbxDateRange','KbxSelect','KbxLookup','KbxCheckbox','KbxRadio','KbxTextarea','KbxTabs','KbxBadge','KbxTooltip','KbxBarcodeField',
|
||||
'KbxPageHeader','KbxCommandBar','KbxSearchPanel','KbxFormSection','KbxSectionHeader','KbxDataGrid','KbxBulkActionBar','KbxQuickFilterBar','KbxSummaryBar','KbxStatus',
|
||||
'KbxDialog','KbxDrawer','KbxToast','KbxConfirm','KbxExcelMenu','KbxExcelImport','KbxJobProgress','KbxAuditTrail','KbxHelpPanel','KbxAiPanel','KbxProposalPanel',
|
||||
'KbxListPage','KbxMasterPage','KbxTransactionPage','KbxMasterDetailPage','KbxQueuePage','KbxReconcilePage','KbxWmsMobilePage',
|
||||
]
|
||||
|
||||
describe('KBX source-standard core component contract',()=>{
|
||||
it('publishes every v1 core component',()=>{
|
||||
const names=new Set(kbxComponentManifest.map(x=>x.name))
|
||||
for(const name of requiredCore) expect(names.has(name),name).toBe(true)
|
||||
})
|
||||
it('catalogs the highest-risk interactive components',()=>{
|
||||
const catalogued=new Set(kbxComponentCatalog.map(x=>x.component))
|
||||
for(const name of ['KbxInput','KbxLookup','KbxDataGrid','KbxCommandBar','KbxExcelImport','KbxWmsMobilePage','KbxApplicationShell']) expect(catalogued.has(name),name).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { kbxExperiments, kbxFeatureFlags } from '../../packages/kbx-contracts/src/generated/experimentCatalog'
|
||||
describe('KBX experiment contract',()=>{
|
||||
it('ships reference experiment disabled by default',()=>{const exp=kbxExperiments['exp.oms.order-list.exception-summary-v2'];expect(exp.state).toBe('draft');expect(exp.rolloutPercent).toBe(0);expect(kbxFeatureFlags[exp.flagId].killSwitch).toBe(true)})
|
||||
it('keeps variants balanced and has control',()=>{const variants=kbxExperiments['exp.oms.order-list.exception-summary-v2'].variants;expect(variants.reduce((n,x)=>n+x.weight,0)).toBe(100);expect(variants.some(x=>x.key==='control')).toBe(true)})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getKbxField, kbxImportField, kbxFieldCatalog } from '@kbx/contracts'
|
||||
|
||||
describe('KBX canonical field dictionary', () => {
|
||||
it('keeps sensitive fields maskable', () => {
|
||||
const sensitive = Object.values(kbxFieldCatalog).filter(field => field.sensitive)
|
||||
expect(sensitive.length).toBeGreaterThan(0)
|
||||
expect(sensitive.every(field => 'masking' in field && Boolean(field.masking))).toBe(true)
|
||||
})
|
||||
|
||||
it('uses orderQty as the OMS order quantity field', () => {
|
||||
expect(getKbxField('orderQty').dataType).toBe('quantity')
|
||||
expect(kbxImportField('orderQty').required).toBe(true)
|
||||
})
|
||||
|
||||
it('does not make server/domain rules part of metadata', () => {
|
||||
const field = getKbxField('orderQty') as Record<string, unknown>
|
||||
expect(field.allowedStatuses).toBeUndefined()
|
||||
expect(field.stockPolicy).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { KbxGridColumn, KbxSelectionState } from '@kbx/contracts'
|
||||
import { normalizeKbxGridClipboardData, toKbxBulkSelectionRequest } from '@kbx/ui'
|
||||
|
||||
type Row={code:string;qty:number;price:number;date:string;active:boolean}
|
||||
const columns:KbxGridColumn<Row>[]=[
|
||||
{field:'code',header:'코드',type:'code'},
|
||||
{field:'qty',header:'수량',type:'quantity'},
|
||||
{field:'price',header:'단가',type:'money'},
|
||||
{field:'date',header:'일자',type:'date'},
|
||||
{field:'active',header:'사용',type:'boolean'},
|
||||
]
|
||||
|
||||
describe('KBX grid productivity contract',()=>{
|
||||
it('normalizes Excel-like pasted values before cell validation',()=>{
|
||||
const {data,result}=normalizeKbxGridClipboardData([[' ABC001 ','1,200','3,500','20260808','Y']],columns,0)
|
||||
expect(data[0]).toEqual(['ABC001',1200,3500,'2026-08-08',true])
|
||||
expect(result.cellCount).toBe(5)
|
||||
expect(result.rejectedCount).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps invalid pasted values visible for inline validation',()=>{
|
||||
const {data,result}=normalizeKbxGridClipboardData([['ABC001','not-number']],columns,0)
|
||||
expect(data[0][1]).toBe('not-number')
|
||||
expect(result.issues[0]).toMatchObject({field:'qty',code:'INVALID_NUMBER'})
|
||||
})
|
||||
|
||||
it('does not expand all-filtered selection into browser ids',()=>{
|
||||
const state:KbxSelectionState<string>={mode:'all-filtered',selectedIds:[],excludedIds:['ORDER-9']}
|
||||
expect(toKbxBulkSelectionRequest(state,{status:'READY'})).toEqual({mode:'filter',filter:{status:'READY'},excludedIds:['ORDER-9']})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildKbxHomeAttention, buildKbxHomeAttentionQueue } from '@kbx/ui'
|
||||
|
||||
describe('KBX Home attention queue',()=>{
|
||||
const entries=[{screenId:'OMS-ORD-001',path:'/oms/orders',section:'주문',title:'주문관리',module:'OMS' as const}]
|
||||
|
||||
it('prioritizes dirty, failed work, urgent notifications, running work, then ordinary notifications',()=>{
|
||||
const tabs=[{key:'tab-1',screenId:'OMS-ORD-001',title:'주문관리',path:'/oms/orders',dirty:true,openedAt:'2026-08-08T00:00:00Z',lastActivatedAt:'2026-08-08T00:05:00Z'}]
|
||||
const operations=[{id:'op-f',type:'import',title:'주문 반영',status:'failed' as const,requestedAt:'2026-08-08T00:00:00Z'},{id:'op-r',type:'export',title:'주문 내보내기',status:'running' as const,requestedAt:'2026-08-08T00:00:00Z'}]
|
||||
const notifications=[{id:'n-w',severity:'warning' as const,title:'확인 필요',createdAt:'2026-08-08T00:00:00Z'},{id:'n-i',severity:'info' as const,title:'완료 안내',createdAt:'2026-08-08T00:00:00Z'}]
|
||||
expect(buildKbxHomeAttention(entries,tabs,operations,notifications).map(item=>item.source)).toEqual(['dirty','operation-failed','notification-urgent','operation-running','notification'])
|
||||
})
|
||||
|
||||
it('sorts newest-first within priority and reports overflow without hiding the real total',()=>{
|
||||
const operations=[
|
||||
{id:'old',type:'import',title:'이전 실패',status:'failed' as const,requestedAt:'2026-08-08T00:00:00Z',completedAt:'2026-08-08T00:10:00Z'},
|
||||
{id:'new',type:'import',title:'최근 실패',status:'failed' as const,requestedAt:'2026-08-08T00:20:00Z',completedAt:'2026-08-08T00:30:00Z'},
|
||||
]
|
||||
const queue=buildKbxHomeAttentionQueue(entries,[],operations,[],1)
|
||||
expect(queue.items.map(item=>item.title)).toEqual(['최근 실패'])
|
||||
expect(queue.totalCount).toBe(2)
|
||||
expect(queue.overflowCount).toBe(1)
|
||||
expect(queue.sourceCounts['operation-failed']).toBe(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { KbxImportDefinition, KbxImportMapping } from '@kbx/contracts'
|
||||
import { validateKbxImportFileCandidate, validateKbxImportMappings } from '@kbx/ui'
|
||||
|
||||
const definition:KbxImportDefinition={
|
||||
id:'orders',screenId:'OMS-ORD-003',entity:'order',title:'주문 Excel 업로드',allowCreate:true,allowUpdate:true,maxFileSizeBytes:1048576,
|
||||
fields:[
|
||||
{key:'orderNo',label:'주문번호',dataType:'text',required:true,importable:true},
|
||||
{key:'itemCode',label:'품목코드',dataType:'code',required:true,importable:true},
|
||||
{key:'remark',label:'비고',dataType:'text',importable:true},
|
||||
],
|
||||
}
|
||||
|
||||
describe('KbxExcelImport guard contract',()=>{
|
||||
it('rejects missing required targets and duplicate target mappings before server validation',()=>{
|
||||
const mappings:KbxImportMapping[]=[
|
||||
{sourceColumn:'주문번호',targetField:'orderNo',source:'exact'},
|
||||
{sourceColumn:'상품번호',targetField:'orderNo',source:'manual'},
|
||||
]
|
||||
expect(validateKbxImportMappings(definition,mappings).map(x=>x.code)).toEqual(['duplicate-target','required-missing'])
|
||||
})
|
||||
|
||||
it('accepts one-to-one required mappings',()=>{
|
||||
const mappings:KbxImportMapping[]=[
|
||||
{sourceColumn:'주문번호',targetField:'orderNo',source:'exact'},
|
||||
{sourceColumn:'상품번호',targetField:'itemCode',source:'alias'},
|
||||
]
|
||||
expect(validateKbxImportMappings(definition,mappings)).toEqual([])
|
||||
})
|
||||
|
||||
it('preflights xlsx extension and configured upload size without parsing business rows',()=>{
|
||||
expect(validateKbxImportFileCandidate(definition,{name:'orders.csv',size:100})).toContain('.xlsx')
|
||||
expect(validateKbxImportFileCandidate(definition,{name:'orders.xlsx',size:2097152})).toContain('1MB')
|
||||
expect(validateKbxImportFileCandidate(definition,{name:'orders.xlsx',size:512})).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { KbxLookupProvider } from '@kbx/contracts'
|
||||
|
||||
const provider: KbxLookupProvider<string> = {
|
||||
async search(request) {
|
||||
return { items: [{ id: '1', code: '10001', displayName: '대한상사' }], totalCount: 1 }
|
||||
},
|
||||
async resolveById(id) {
|
||||
return id === '1' ? { id: '1', code: '10001', displayName: '대한상사' } : null
|
||||
},
|
||||
async resolveByCode(code) {
|
||||
return code === '10001' ? { id: '1', code: '10001', displayName: '대한상사' } : null
|
||||
},
|
||||
}
|
||||
|
||||
describe('KbxLookup provider contract', () => {
|
||||
it('never invents an entity: unknown code resolves null', async () => {
|
||||
expect(await provider.resolveByCode('UNKNOWN')).toBeNull()
|
||||
})
|
||||
|
||||
it('search returns stable domain id + code + displayName', async () => {
|
||||
const result = await provider.search({ query: '대한', page: 1, pageSize: 30 })
|
||||
expect(result.items[0]).toEqual(expect.objectContaining({ id: '1', code: '10001', displayName: '대한상사' }))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { KbxNavigationResolvedEntry } from '@kbx/contracts'
|
||||
import { buildKbxHomeQuickStart, buildKbxHomeWorkbench, matchKbxRoutePattern, resolveKbxSafeRecentPath, resolveKbxSafeWorkspacePath } from '@kbx/ui'
|
||||
|
||||
const entry=(screenId:string,path:string,homePriority?:number):KbxNavigationResolvedEntry=>({screenId,path,title:screenId,module:'ERP',section:'업무',permissions:[],menuVisible:true,favoriteAllowed:true,recentPolicy:'screen',homePriority})
|
||||
|
||||
describe('KBX navigation hardening',()=>{
|
||||
it('deduplicates home quick start in favorite, tab, recent, priority order',()=>{
|
||||
const a=entry('A','/a',30),b=entry('B','/b',10),c=entry('C','/c',20)
|
||||
const result=buildKbxHomeQuickStart([a,b,c],[a],[{screenId:'B',path:'/b',title:'B',visitedAt:'2026-08-08T10:00:00Z'}],[{key:'C:/c',screenId:'C',title:'C',path:'/c',openedAt:'2026-08-08T09:00:00Z',lastActivatedAt:'2026-08-08T11:00:00Z'}])
|
||||
expect(result.map(x=>x.screenId)).toEqual(['A','C','B'])
|
||||
})
|
||||
|
||||
it('never trusts a persisted route unless route mode and path boundary are both valid',()=>{
|
||||
const base={...entry('A','/erp/items'),recentPolicy:'route' as const}
|
||||
expect(resolveKbxSafeRecentPath(base,'/erp/items/123?tab=stock')).toBe('/erp/items/123')
|
||||
expect(resolveKbxSafeRecentPath(base,'/erp/items-evil/123')).toBe('/erp/items')
|
||||
expect(resolveKbxSafeRecentPath(base,'https://evil.example/x')).toBe('/erp/items')
|
||||
expect(resolveKbxSafeRecentPath({...base,recentPolicy:'screen'},'/erp/items/123')).toBe('/erp/items')
|
||||
})
|
||||
|
||||
it('keeps dirty/open work ahead of favorites and avoids duplicate home launch rows',()=>{
|
||||
const a=entry('A','/a',30),b=entry('B','/b',10)
|
||||
const result=buildKbxHomeWorkbench([a,b],[a],[],[
|
||||
{key:'A:/a',screenId:'A',title:'A',path:'/a',openedAt:'2026-08-08T09:00:00Z',lastActivatedAt:'2026-08-08T11:00:00Z',dirty:true},
|
||||
])
|
||||
expect(result.filter(x=>x.screenId==='A')).toHaveLength(1)
|
||||
expect(result[0]?.source).toBe('dirty')
|
||||
})
|
||||
|
||||
it('validates workspace deep links against the registered route capability',()=>{
|
||||
expect(matchKbxRoutePattern('/oms/orders/:orderId/edit','/oms/orders/123/edit')).toBe(true)
|
||||
expect(matchKbxRoutePattern('/oms/orders/:orderId/edit','/oms/orders/123/delete')).toBe(false)
|
||||
expect(resolveKbxSafeWorkspacePath(['/oms/orders/:orderId/edit'],'/oms/orders/123/edit','/home')).toBe('/oms/orders/123/edit')
|
||||
expect(resolveKbxSafeWorkspacePath(['/oms/orders/:orderId/edit'],'https://evil.example/oms/orders/123/edit','/home')).toBe('/home')
|
||||
})
|
||||
|
||||
it('rejects oversized and control-character workspace paths',()=>{
|
||||
const fallback='/home'
|
||||
expect(resolveKbxSafeWorkspacePath(['/erp/items/:itemId'],`/erp/items/${'a'.repeat(2100)}`,fallback)).toBe(fallback)
|
||||
expect(resolveKbxSafeWorkspacePath(['/erp/items/:itemId'],'/erp/items/abc\n123',fallback)).toBe(fallback)
|
||||
expect(resolveKbxSafeWorkspacePath(['/erp/items/:itemId'],'/erp/items/%2e%2e',fallback)).toBe(fallback)
|
||||
})
|
||||
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { KbxWorkItem } from '@kbx/contracts'
|
||||
|
||||
function nextActions(item: KbxWorkItem) {
|
||||
return new Set((item.actions ?? []).map(x => x.kind))
|
||||
}
|
||||
|
||||
describe('KBX operations contract', () => {
|
||||
it('domain exception can navigate without offering unsafe manual resolution', () => {
|
||||
const item: KbxWorkItem = {
|
||||
id: '1', sourceModule: 'WMS', sourceType: 'picking-task', sourceId: 'uuid-p100', referenceNo: 'P-100', sourceScreenId: 'WMS-PICK-001', code: 'PICKING_SHORTAGE',
|
||||
title: '피킹 수량 부족', severity: 'warning', status: 'open', occurredAt: new Date().toISOString(), ageMinutes: 3, version: 1,
|
||||
actions: [{ id: 'navigate', label: '원 업무 보기', kind: 'navigate' }],
|
||||
}
|
||||
expect(nextActions(item).has('navigate')).toBe(true)
|
||||
expect(nextActions(item).has('resolve')).toBe(false)
|
||||
})
|
||||
|
||||
it('retry is explicit rather than inferred from the error text', () => {
|
||||
const item: KbxWorkItem = {
|
||||
id: '2', sourceModule: 'OMS', sourceType: 'shipment', sourceId: 'uuid-s1', referenceNo: 'S-1', sourceScreenId: 'OMS-ORD-001', code: 'LABEL_FAILED',
|
||||
title: '송장 발급 실패', severity: 'warning', status: 'claimed', occurredAt: new Date().toISOString(), ageMinutes: 1, version: 2,
|
||||
actions: [{ id: 'retry', label: '재처리', kind: 'retry' }],
|
||||
}
|
||||
expect(nextActions(item)).toEqual(new Set(['retry']))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { describe,it,expect } from 'vitest'
|
||||
import { kbxFieldReadonly, resolveKbxRecordStatePolicy, type KbxRecordStatePolicy } from '@kbx/contracts'
|
||||
const policies:KbxRecordStatePolicy[]=[{status:'작성',editability:'editable'},{status:'확정',editability:'readonly'},{status:'부분수정',editability:'restricted',editableFields:['remark']}]
|
||||
describe('record lifecycle policy',()=>{
|
||||
it('makes confirmed records read-only',()=>expect(kbxFieldReadonly(resolveKbxRecordStatePolicy(policies,'확정'),'receiverName')).toBe(true))
|
||||
it('allows only explicitly editable fields in restricted state',()=>{const p=resolveKbxRecordStatePolicy(policies,'부분수정');expect(kbxFieldReadonly(p,'remark')).toBe(false);expect(kbxFieldReadonly(p,'quantity')).toBe(true)})
|
||||
it('fails safe for unknown states',()=>expect(resolveKbxRecordStatePolicy(policies,'UNKNOWN').editability).toBe('readonly'))
|
||||
})
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { KbxConflictSnapshot, KbxOperationRun, KbxRuntimeNotice } from '@kbx/contracts'
|
||||
|
||||
describe('KBX runtime contract', () => {
|
||||
it('distinguishes degraded mode from normal business state', () => {
|
||||
const notice: KbxRuntimeNotice = { mode: 'degraded', title: '택배 연계 지연', retryAllowed: true }
|
||||
expect(notice.mode).toBe('degraded')
|
||||
})
|
||||
|
||||
it('keeps concurrency conflict explicit instead of silent overwrite', () => {
|
||||
const conflict: KbxConflictSnapshot = {
|
||||
code: 'ORDER_VERSION_CONFLICT', title: '다른 사용자가 변경했습니다.', requestedVersion: 3, currentVersion: 4,
|
||||
changes: [{ field: 'quantity', label: '수량', mine: 10, latest: 8 }],
|
||||
}
|
||||
expect(conflict.currentVersion).toBeGreaterThan(conflict.requestedVersion!)
|
||||
})
|
||||
|
||||
it('can represent partial completion without reporting full success', () => {
|
||||
const run: KbxOperationRun = {
|
||||
id: 'op-1', type: 'bulk-ship', title: '대량 출고지시', status: 'partially-completed', requestedAt: new Date().toISOString(),
|
||||
total: 100, processed: 100, succeeded: 97, failed: 3,
|
||||
}
|
||||
expect(run.status).toBe('partially-completed')
|
||||
expect(run.failed).toBe(3)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { kbxScreenRecipeCatalog, kbxScreenRecipeVerificationCatalog } from '@kbx/contracts'
|
||||
import { kbxTemplateManifest } from '@kbx/ui'
|
||||
|
||||
describe('KBX screen recipes',()=>{
|
||||
it('closes every T01-T09 template to an implementation and verification recipe',()=>{
|
||||
expect(Object.keys(kbxScreenRecipeCatalog)).toHaveLength(9)
|
||||
for(const template of kbxTemplateManifest){
|
||||
const recipe=kbxScreenRecipeCatalog[template.code]
|
||||
const verification=kbxScreenRecipeVerificationCatalog[template.code]
|
||||
expect(recipe.type).toBe(template.type)
|
||||
expect(recipe.templateComponent).toBe(template.component)
|
||||
expect(recipe.requiredPolicies.length).toBeGreaterThan(0)
|
||||
expect(recipe.recoveryPolicies.length).toBeGreaterThan(0)
|
||||
expect(recipe.securityPolicies.length).toBeGreaterThan(0)
|
||||
expect(recipe.canonicalScenarioIds.length).toBeGreaterThan(0)
|
||||
expect(recipe.testProfile.requiredChecks.length).toBeGreaterThan(0)
|
||||
expect(recipe.testProfile.requiredScenarioKinds).toContain('e2e')
|
||||
expect(verification.complete).toBe(true)
|
||||
expect(verification.missingScenarioKinds).toEqual([])
|
||||
expect(verification.missingTags).toEqual([])
|
||||
expect(verification.missingEvidence).toEqual([])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getKbxShortcutDebugSnapshot, registerKbxShortcuts } from '@kbx/ui'
|
||||
|
||||
describe('KBX shortcut manager contract',()=>{
|
||||
it('rejects protected browser shortcuts',()=>{
|
||||
for(const key of ['F5','Ctrl+L','Ctrl+T','Ctrl+W','Ctrl+R']){
|
||||
expect(()=>registerKbxShortcuts([{key,scope:'application',execute(){}}])).toThrow(/protected/)
|
||||
}
|
||||
})
|
||||
|
||||
it('registers semantic scope without page-level window ownership',()=>{
|
||||
const unregister=registerKbxShortcuts([{key:'F8',scope:'page',priority:10,execute(){}}])
|
||||
expect(getKbxShortcutDebugSnapshot()).toEqual(expect.arrayContaining([expect.objectContaining({key:'F8',scope:'page',priority:10})]))
|
||||
unregister()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
import { describe,expect,it } from 'vitest'
|
||||
import { kbxTelemetryCatalog,kbxUxMetricCatalog } from '@kbx/contracts'
|
||||
|
||||
describe('KBX telemetry contract',()=>{
|
||||
it('contains Manual Intervention Rate',()=>expect(kbxUxMetricCatalog.manual_intervention_rate).toBeTruthy())
|
||||
it('never allows raw identity attributes',()=>{for(const event of Object.values(kbxTelemetryCatalog)){expect(event.allowedAttributes.join('|')).not.toMatch(/phone|address|orderNo|customer|barcode|keyword|query|entityId/i)}})
|
||||
it('requires duration for completion metrics',()=>expect(kbxTelemetryCatalog['task.complete'].requiresDuration).toBe(true))
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { kbxTemplateManifest } from '@kbx/ui'
|
||||
|
||||
describe('KBX template state matrix',()=>{
|
||||
it('requires explicit idle/runtime/permission states where applicable',()=>{
|
||||
const byCode=new Map(kbxTemplateManifest.map(item=>[item.code,item]))
|
||||
expect(byCode.get('T01')?.runtimeStates).toContain('idle')
|
||||
for(const template of kbxTemplateManifest)expect(template.stateCapabilities).toContain('permission')
|
||||
expect(byCode.get('T02')?.stateCapabilities).toEqual(expect.arrayContaining(['dirty','conflict']))
|
||||
expect(byCode.get('T03')?.stateCapabilities).toEqual(expect.arrayContaining(['dirty','conflict','validation']))
|
||||
expect(byCode.get('T04')?.stateCapabilities).toContain('validation')
|
||||
expect(byCode.get('T08')?.stateCapabilities).toContain('job')
|
||||
expect(byCode.get('T09')?.stateCapabilities).toContain('network')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
kbxTestScenarioCatalog,
|
||||
kbxTestFixtureSetCatalog,
|
||||
kbxTestFixtureRefs,
|
||||
kbxTestReferenceClock,
|
||||
} from '@kbx/contracts'
|
||||
|
||||
describe('KBX canonical test scenario contract', () => {
|
||||
it('covers the three golden screens', () => {
|
||||
const screens = new Set(Object.values(kbxTestScenarioCatalog).map(x => x.screenId))
|
||||
expect(screens.has('OMS-ORD-001')).toBe(true)
|
||||
expect(screens.has('OMS-ORD-002')).toBe(true)
|
||||
expect(screens.has('WMS-PICK-001')).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps deterministic synthetic references', () => {
|
||||
expect(kbxTestReferenceClock).toBe('2026-08-08T09:00:00Z')
|
||||
expect(Object.keys(kbxTestFixtureSetCatalog).length).toBeGreaterThan(0)
|
||||
expect(kbxTestFixtureRefs['order.ship.ready.1'].orderNo).toMatch(/^TEST-/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
import { describe,it,expect } from 'vitest'
|
||||
import { allowedKbxTransitions, type KbxWorkflowDefinition } from '@kbx/contracts'
|
||||
const wf:KbxWorkflowDefinition={id:'x',version:'1',states:[{value:'DRAFT',label:'작성',semantic:'draft'},{value:'DONE',label:'완료',semantic:'completed'}],transitions:[{id:'done',from:['DRAFT'],to:'DONE',label:'완료'}]}
|
||||
describe('workflow contract',()=>{it('returns only transitions allowed from current state',()=>expect(allowedKbxTransitions(wf,'DRAFT').map(x=>x.id)).toEqual(['done']));it('returns none from terminal-like state',()=>expect(allowedKbxTransitions(wf,'DONE')).toHaveLength(0))})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { searchNavigationEntries } from '../../packages/kbx-ui/src/shell/navigationSearch'
|
||||
import type { KbxNavigationResolvedEntry } from '@kbx/contracts'
|
||||
const entries:KbxNavigationResolvedEntry[]=[
|
||||
{screenId:'OMS-ORD-001',title:'주문관리',module:'OMS',section:'주문',path:'/oms/orders',keywords:['출고대기','주문조회']},
|
||||
{screenId:'ERP-INV-001',title:'재고현황',module:'ERP',section:'재고',path:'/erp/inventory',keywords:['현재고','가용재고']},
|
||||
]
|
||||
describe('navigation search',()=>{
|
||||
it('finds by Korean business keyword',()=>expect(searchNavigationEntries(entries,'출고')[0]?.screenId).toBe('OMS-ORD-001'))
|
||||
it('finds by screen id',()=>expect(searchNavigationEntries(entries,'ERP-INV-001')[0]?.screenId).toBe('ERP-INV-001'))
|
||||
it('finds familiar menu title without command syntax',()=>expect(searchNavigationEntries(entries,'주문관리')[0]?.title).toBe('주문관리'))
|
||||
})
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { KbxWmsScanCommand } from '@kbx/contracts'
|
||||
|
||||
function replayOrder(commands: KbxWmsScanCommand[]) {
|
||||
// Scanner commands are sequence-sensitive and must never be parallelized.
|
||||
return [...commands].sort((a, b) => a.occurredAt.localeCompare(b.occurredAt))
|
||||
}
|
||||
|
||||
describe('WMS scanner retry contract', () => {
|
||||
it('preserves the same idempotency key across transport retry', () => {
|
||||
const command: KbxWmsScanCommand = {
|
||||
taskId: 'task-1',
|
||||
barcode: 'LOC-A-01',
|
||||
source: 'keyboard-wedge',
|
||||
idempotencyKey: 'task-1:abc',
|
||||
expectedVersion: 7,
|
||||
occurredAt: '2026-08-08T07:00:00Z',
|
||||
}
|
||||
const retried = { ...command }
|
||||
expect(retried.idempotencyKey).toBe(command.idempotencyKey)
|
||||
})
|
||||
|
||||
it('replays sequential scans in occurrence order', () => {
|
||||
const make = (key: string, occurredAt: string): KbxWmsScanCommand => ({
|
||||
taskId: 'task-1', barcode: key, source: 'keyboard-wedge', idempotencyKey: key,
|
||||
expectedVersion: 7, occurredAt,
|
||||
})
|
||||
expect(replayOrder([
|
||||
make('B', '2026-08-08T07:00:02Z'),
|
||||
make('A', '2026-08-08T07:00:01Z'),
|
||||
]).map(x => x.idempotencyKey)).toEqual(['A', 'B'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user