Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/web/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export function RestaurantEvidenceCarousel({ frames }: { frames: readonly Restau
{visible.some((frame) => !isLevel1SourceObserved(frame)) && (
<p className="mb-2 text-xs text-muted-foreground">현장 장면은 영상의 맥락을 보여주며, 매장 신원을 확인하는 간판·매장 안내와 구분해요.</p>
)}
<div className="flex gap-3 overflow-x-auto pb-2" tabIndex={0} aria-label="영상 장면 가로 스크롤">
<div className="flex gap-3 overflow-x-auto pb-2" tabIndex={0} role="region" aria-roledescription="carousel" aria-label="영상 장면 가로 스크롤">
{visible.map((frame) => {
const presentation = publicFramePresentation(frame);
return (
Expand Down
2 changes: 1 addition & 1 deletion apps/web/lib/home-map-keyboard-navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ type ArrowNavigationGuardInput = {
swipeableCount: number;
};

const KEYBOARD_NAV_INTERACTIVE_SELECTOR = 'button, a, [role="button"], [role="link"]';
const KEYBOARD_NAV_INTERACTIVE_SELECTOR = 'button, a, [role="button"], [role="link"], [aria-roledescription="carousel"]';
const KEYBOARD_NAV_MAP_CONTROL_SELECTOR = '[data-testid="map-container"] button';
const EDITABLE_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT']);

Expand Down
9 changes: 5 additions & 4 deletions apps/web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@
"tailwind-merge": "^3.7.0",
"three": "0.180.0",
"web-vitals": "^6.2.2",
"zod": "^4.6.5"
"zod": "^4.6.6"
},
"devDependencies": {
"@next/bundle-analyzer": "16.4.2",
Expand Down
3 changes: 2 additions & 1 deletion apps/web/tests-unit/admin-detail-close-focus-browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ async function bundle() {
`;
const built = await Bun.build({
entrypoints: ['detail-focus-fixture'], target: 'browser', format: 'iife', write: false,
define: { 'process.env.NODE_ENV': '"test"' },
// Isolated browser build: no Node process or operator environment is present.
define: { 'process.env': '{}', 'process.env.NODE_ENV': '"test"' },
plugins: [{ name: 'detail-focus-fixture', setup(builder) {
builder.onResolve({ filter: /^detail-focus-fixture$/ }, () => ({ path: 'fixture', namespace: 'fixture' }));
builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ contents: entry, loader: 'tsx', resolveDir: appRoot }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const entry = `
if (url.pathname === '/api/admin/evaluations' && url.searchParams.get('view') === 'page') {
return json(fixture.deleted ? { ...onePage, records: [], warnings: {}, filteredTotal: 0 } : onePage);
}
if (fixture.refreshOk && url.pathname === '/api/admin/submissions') return json({ submissions: [] });
if (fixture.refreshOk && url.pathname === '/api/admin/submissions') return json({ submissions: [], items: [] });
if (fixture.refreshOk && url.pathname === '/api/admin/restaurant-requests') return json({ requests: [] });
if (fixture.refreshOk && url.pathname === '/api/admin/pending-counts') return json({ submissions: 0, reviews: 0 });
if (url.pathname === '/api/admin/record-actions') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ async function bundle() {
`;
const built = await Bun.build({
entrypoints: ['storyboard-count-fixture'], target: 'browser', format: 'iife', write: false,
define: { 'process.env.NODE_ENV': '"test"' },
// Isolated browser build: no Node process or operator environment is present.
define: { 'process.env': '{}', 'process.env.NODE_ENV': '"test"' },
plugins: [{ name: 'storyboard-count-fixture', setup(builder) {
builder.onResolve({ filter: /^storyboard-count-fixture$/ }, () => ({ path: 'fixture', namespace: 'fixture' }));
builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ contents: entry, loader: 'tsx', resolveDir: appRoot }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@ const entry = `
async function bundle() {
const built = await Bun.build({
entrypoints: ['worker-visible-status-fixture'], target: 'browser', format: 'iife', write: false,
define: { 'process.env.NODE_ENV': '"test"' },
// Isolated browser build: no Node process or operator environment is present.
define: { 'process.env': '{}', 'process.env.NODE_ENV': '"test"' },
plugins: [{ name: 'worker-visible-status-fixture', setup(builder) {
builder.onResolve({ filter: /^worker-visible-status-fixture$/ }, () => ({ path: 'fixture', namespace: 'fixture' }));
builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ contents: entry, loader: 'tsx', resolveDir: appRoot }));
Expand Down
117 changes: 117 additions & 0 deletions apps/web/tests-unit/evidence-carousel-navigation-browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { expect, test } from 'bun:test';
import { existsSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import ts from '@typescript/old';
import { chromium, expect as ui } from '@playwright/test';

const appRoot = join(import.meta.dir, '..');
const chrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const localTest = existsSync(chrome) ? test : test.skip;
const rows = JSON.parse(readFileSync(resolve(appRoot, '../../docs/evidence/marker-evidence-live-20261021-v1/reviewed-frames.json'), 'utf8'));
const frames = rows.frames.slice(0, 3).map((row: { frame: unknown }) => row.frame);

function actualPanelSwipeDeclarations(): string {
const source = ts.createSourceFile('RestaurantDetailPanel.tsx', readFileSync(join(appRoot, 'components/restaurant/RestaurantDetailPanel.tsx'), 'utf8'), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const names = new Set(['RESTAURANT_DETAIL_SWIPE_THRESHOLD', 'RESTAURANT_DETAIL_SWIPE_INTENT_RATIO',
'contentSwipeStartXRef', 'contentSwipeStartYRef', 'contentSwipeDirectionRef', 'isContentSwipingRef',
'handleContentSwipeStart', 'handleContentSwipeMove', 'handleContentSwipeEnd', 'handleContentSwipeCancel']);
const declarations: string[] = [];
function visit(node: ts.Node) {
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && names.has(node.name.text)) {
names.delete(node.name.text);
declarations.push(`const ${node.getText(source)};`);
}
ts.forEachChild(node, visit);
}
visit(source);
expect([...names]).toEqual([]);
return declarations.join('\n');
}

async function bundle() {
const entry = `import React, { useCallback, useRef } from 'react'; import { createRoot } from 'react-dom/client';
import { RestaurantEvidenceCarousel } from './components/restaurant/RestaurantEvidenceCarousel';
import { handleDesktopArrowNavigationEvent } from './lib/home-map-keyboard-navigation';
let navigations = 0, panelStarts = 0;
window.sceneFixture = { reset: () => { navigations = 0; panelStarts = 0; }, count: () => navigations, starts: () => panelStarts };
window.addEventListener('keydown', event => handleDesktopArrowNavigationEvent({ event,
isDesktop: innerWidth >= 1024, isPanelOpen: true, hasCurrentRestaurant: true, swipeableCount: 2,
onNavigate: () => { navigations++; return true; } }));
function App() {
const onSwipeLeft = () => { navigations++; }, onSwipeRight = onSwipeLeft, hideSwipeHint = () => { panelStarts++; };
${actualPanelSwipeDeclarations()}
return <div onTouchStart={handleContentSwipeStart} onTouchMove={handleContentSwipeMove}
onTouchEnd={handleContentSwipeEnd} onTouchCancel={handleContentSwipeCancel}>
<div id='outside' tabIndex={0} style={{height:100}}>Restaurant navigation area</div>
<RestaurantEvidenceCarousel frames={[${JSON.stringify(frames)}]} />
</div>;
}
createRoot(document.getElementById('root')).render(<App />);`;
const built = await Bun.build({ entrypoints: ['scene-navigation-fixture'], target: 'browser', format: 'iife', write: false,
define: { 'process.env.NODE_ENV': '"test"' }, plugins: [{ name: 'scene-navigation', setup(builder) {
builder.onResolve({ filter: /^scene-navigation-fixture$/ }, () => ({ path: 'entry', namespace: 'fixture' }));
builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ contents: entry, loader: 'tsx', resolveDir: appRoot }));
builder.onResolve({ filter: /^@\// }, args => ({ path: Bun.resolveSync('./' + args.path.slice(2), appRoot) }));
}}] });
expect(built.success).toBe(true);
return built.outputs[0].text();
}

for (const width of [1440, 390, 820]) {
localTest(`scene navigation keeps the restaurant selected at ${width}px`, async () => {
const browser = await chromium.launch({ executablePath: chrome, headless: true });
try {
const context = await browser.newContext({ viewport: { width, height: 1000 }, hasTouch: true });
const page = await context.newPage();
const errors: string[] = [], external: string[] = [];
page.on('pageerror', error => errors.push(error.message));
await page.route('**/*', route => {
const url = new URL(route.request().url());
if (url.origin !== 'https://scene-navigation.test') { external.push(url.origin); return route.abort(); }
if (url.pathname.startsWith('/evidence-frames/')) return route.fulfill({contentType:'image/webp', body:readFileSync(join(appRoot,'public',url.pathname))});
return route.fulfill({contentType:'text/html; charset=utf-8',body:`<div id="root"></div><style>
[aria-label="영상 장면 가로 스크롤"] { display:flex; width:min(100%,420px); overflow-x:auto; gap:12px; }
[aria-label="영상 장면 가로 스크롤"] a { display:block; flex:0 0 280px; }
img { width:280px; height:158px; } </style>`});
});
await page.goto('https://scene-navigation.test/'); await page.addScriptTag({ content: await bundle() });
const scroller = page.getByLabel('영상 장면 가로 스크롤', {exact:true});
await ui(scroller.locator('img')).toHaveCount(3);
const metrics = await scroller.evaluate(element => ({width:element.clientWidth, contentWidth:element.scrollWidth, overflow:getComputedStyle(element).overflowX}));
expect(metrics.overflow).toBe('auto');
expect(metrics.contentWidth).toBeGreaterThan(metrics.width);
const count = () => page.evaluate(() => (window as unknown as {sceneFixture:{count():number}}).sceneFixture.count());
const starts = () => page.evaluate(() => (window as unknown as {sceneFixture:{starts():number}}).sceneFixture.starts());
const reset = () => page.evaluate(() => (window as unknown as {sceneFixture:{reset():void}}).sceneFixture.reset());
if (width === 1440) {
await scroller.focus(); await page.keyboard.press('ArrowRight');
expect(await count()).toBe(0);
await ui.poll(() => scroller.evaluate(element => element.scrollLeft)).toBeGreaterThan(0);
await page.locator('#outside').focus(); await page.keyboard.press('ArrowRight');
expect(await count()).toBe(1);
await reset();
}
const session = await context.newCDPSession(page);
async function swipe(selector: string) {
const box = await page.locator(selector).boundingBox();
if (!box) throw new Error('SWIPE_TARGET_MISSING');
const x = box.x + Math.min(box.width - 20, 300), y = box.y + Math.min(box.height / 2, 80);
await session.send('Input.dispatchTouchEvent', {type:'touchStart',touchPoints:[{x,y,id:1}]});
for (let step = 1; step <= 8; step++) {
await session.send('Input.dispatchTouchEvent', {type:'touchMove',touchPoints:[{x:x-step*25,y,id:1}]});
await page.waitForTimeout(20);
}
await session.send('Input.dispatchTouchEvent', {type:'touchEnd',touchPoints:[]});
}
await swipe('[aria-label="영상 장면 가로 스크롤"]');
expect(await starts()).toBe(0);
expect(await count()).toBe(0);
await ui.poll(() => scroller.evaluate(element => element.scrollLeft)).toBeGreaterThan(0);
await swipe('#outside');
expect(await starts()).toBe(1);
expect(await count()).toBe(1);
expect(external).toEqual([]); expect(errors).toEqual([]);
await context.close();
} finally { await browser.close(); }
}, 60_000);
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ createRoot(document.getElementById('root')).render(<LocalStoryboardWorkspace />)
`;
const bundle = await Bun.build({
entrypoints: ['storyboard-catalog-fixture'], target: 'browser', format: 'iife', write: false,
define: { 'process.env.NODE_ENV': '"test"' },
// Isolated browser build: no Node process or operator environment is present.
define: { 'process.env': '{}', 'process.env.NODE_ENV': '"test"' },
plugins: [{ name: 'offline-storyboard', setup(builder) {
builder.onResolve({ filter: /^storyboard-catalog-fixture$/ }, () => ({ path: 'fixture.tsx', namespace: 'fixture' }));
builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ contents: fixture, loader: 'tsx', resolveDir: join(import.meta.dir, '..') }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,8 @@ createRoot(document.getElementById('root')).render(<LocalStoryboardWorkspace />)
`;
const bundle = await Bun.build({
entrypoints: ['storyboard-control-fixture'], target: 'browser', format: 'iife', write: false,
define: { 'process.env.NODE_ENV': '"test"' },
// Isolated browser build: no Node process or operator environment is present.
define: { 'process.env': '{}', 'process.env.NODE_ENV': '"test"' },
plugins: [{ name: 'offline-storyboard', setup(builder) {
builder.onResolve({ filter: /^storyboard-control-fixture$/ }, () => ({ path: 'fixture.tsx', namespace: 'fixture' }));
builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ contents: fixture, loader: 'tsx', resolveDir: join(import.meta.dir, '..') }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ createRoot(document.getElementById('root')).render(<>
`;
const bundle = await Bun.build({
entrypoints: ['storyboard-departure-fixture'], target: 'browser', format: 'iife', write: false,
define: { 'process.env.NODE_ENV': '"test"' },
// Isolated browser build: no Node process or operator environment is present.
define: { 'process.env': '{}', 'process.env.NODE_ENV': '"test"' },
plugins: [{ name: 'offline-storyboard', setup(builder) {
builder.onResolve({ filter: /^storyboard-departure-fixture$/ }, () => ({ path: 'fixture.tsx', namespace: 'fixture' }));
builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ contents: fixture, loader: 'tsx', resolveDir: join(import.meta.dir, '..') }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ createRoot(document.getElementById('root')).render(<LocalStoryboardWorkspace />)
`;
const bundle = await Bun.build({
entrypoints: ['storyboard-history-fixture'], target: 'browser', format: 'iife', write: false,
define: { 'process.env.NODE_ENV': '"test"' },
// Isolated browser build: no Node process or operator environment is present.
define: { 'process.env': '{}', 'process.env.NODE_ENV': '"test"' },
plugins: [{ name: 'offline-storyboard', setup(builder) {
builder.onResolve({ filter: /^storyboard-history-fixture$/ }, () => ({ path: 'fixture.tsx', namespace: 'fixture' }));
builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ contents: fixture, loader: 'tsx', resolveDir: join(import.meta.dir, '..') }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ createRoot(document.getElementById('root')).render(<LocalStoryboardWorkspace />)
`;
const bundle = await Bun.build({
entrypoints: ['storyboard-project-refresh-fixture'], target: 'browser', format: 'iife', write: false,
define: { 'process.env.NODE_ENV': '"test"' },
// Isolated browser build: no Node process or operator environment is present.
define: { 'process.env': '{}', 'process.env.NODE_ENV': '"test"' },
plugins: [{ name: 'offline-storyboard', setup(builder) {
builder.onResolve({ filter: /^storyboard-project-refresh-fixture$/ }, () => ({ path: 'fixture.tsx', namespace: 'fixture' }));
builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ contents: fixture, loader: 'tsx', resolveDir: join(import.meta.dir, '..') }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ createRoot(document.getElementById('root')).render(<LocalStoryboardWorkspace />)
`;
const bundle = await Bun.build({
entrypoints: ['storyboard-worker-fixture'], target: 'browser', format: 'iife', write: false,
define: { 'process.env.NODE_ENV': '"test"' },
// Isolated browser build: no Node process or operator environment is present.
define: { 'process.env': '{}', 'process.env.NODE_ENV': '"test"' },
plugins: [{ name: 'offline-storyboard', setup(builder) {
builder.onResolve({ filter: /^storyboard-worker-fixture$/ }, () => ({ path: 'fixture.tsx', namespace: 'fixture' }));
builder.onLoad({ filter: /.*/, namespace: 'fixture' }, () => ({ contents: fixture, loader: 'tsx', resolveDir: join(import.meta.dir, '..') }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@ createRoot(document.getElementById('root')).render(<LocalStoryboardWorkspace/>);
`;
const bundle = await Bun.build({
entrypoints: ["storyboard-library-fixture"], target: "browser", format: "iife", write: false,
define: { "process.env.NODE_ENV": '"test"' },
// Isolated browser build: no Node process or operator environment is present.
define: { "process.env": "{}", "process.env.NODE_ENV": '"test"' },
plugins: [{ name: "library-fixture", setup(builder) {
builder.onResolve({ filter: /^storyboard-library-fixture$/ }, () => ({ path: "fixture.tsx", namespace: "fixture" }));
builder.onLoad({ filter: /.*/, namespace: "fixture" }, () => ({ contents: fixture, loader: "tsx", resolveDir: root }));
Expand Down
Loading
Loading