fix(gate-b): Svelte 5 event directives + label/input association for Select Photos

- Add id='photo-file-input' to file input and for='photo-file-input' to label
- Convert on:change → onchange, on:click → onclick, on:input → oninput
  (Svelte 5 requires native DOM event properties, not on: directives)
- Add Playwright BVT suite confirming file selection → mask step advance
- Test image: 100x60 PNG with green border (hue=66°) and white interior
This commit is contained in:
grokbot_ty
2026-09-07 19:38:46 +00:00
parent 2a8b0e5518
commit 17b6e6ccd6
6 changed files with 235 additions and 24 deletions
+114
View File
@@ -8,10 +8,12 @@
"name": "slabuploader-frontend", "name": "slabuploader-frontend",
"version": "0.0.0", "version": "0.0.0",
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.63.0",
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.16.0", "@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"sirv-cli": "^3.0.1",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.1.1", "svelte-check": "^4.1.1",
"typescript": "^5.6.3", "typescript": "^5.6.3",
@@ -496,6 +498,21 @@
"node": "^22.20 || ^24.12 || >=25" "node": "^22.20 || ^24.12 || >=25"
} }
}, },
"node_modules/@playwright/test": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
"dev": true,
"dependencies": {
"playwright": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@polka/url": { "node_modules/@polka/url": {
"version": "1.0.0-next.29", "version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -1128,6 +1145,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/console-clear": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/console-clear/-/console-clear-1.1.1.tgz",
"integrity": "sha512-pMD+MVR538ipqkG5JXeOEbKWS5um1H4LUUccUQG68qpeqBYbzYy79Gh55jkd2TtPdRfUaLWdv6LPP//5Zt0aPQ==",
"dev": true,
"engines": {
"node": ">=4"
}
},
"node_modules/cookie": { "node_modules/cookie": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
@@ -1297,6 +1323,18 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/get-port": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz",
"integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==",
"dev": true,
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-reference": { "node_modules/is-reference": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@@ -1315,6 +1353,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/local-access": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/local-access/-/local-access-1.1.0.tgz",
"integrity": "sha512-XfegD5pyTAfb+GY6chk283Ox5z8WexG56OvM06RWLpAc/UHozO8X6xAxEkIitZOtsSMM1Yr3DkHgW5W+onLhCw==",
"dev": true,
"engines": {
"node": ">=6"
}
},
"node_modules/locate-character": { "node_modules/locate-character": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
@@ -1411,6 +1458,33 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"dev": true,
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/pngjs": { "node_modules/pngjs": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz",
@@ -1518,6 +1592,15 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/semiver": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/semiver/-/semiver-1.1.0.tgz",
"integrity": "sha512-QNI2ChmuioGC1/xjyYwyZYADILWyW6AmS1UH6gDj/SFUUUS4MBAWs/7mxnkRPc/F4iHezDP+O8t0dO8WHiEOdg==",
"dev": true,
"engines": {
"node": ">=6"
}
},
"node_modules/set-cookie-parser": { "node_modules/set-cookie-parser": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz",
@@ -1544,6 +1627,28 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/sirv-cli": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/sirv-cli/-/sirv-cli-3.0.1.tgz",
"integrity": "sha512-ICXaF2u6IQhLZ0EXF6nqUF4YODfSQSt+mGykt4qqO5rY+oIiwdg7B8w2PVDBJlQulaS2a3J8666CUoDoAuCGvg==",
"dev": true,
"dependencies": {
"console-clear": "^1.1.0",
"get-port": "^5.1.1",
"kleur": "^4.1.4",
"local-access": "^1.0.1",
"sade": "^1.6.0",
"semiver": "^1.0.0",
"sirv": "^3.0.0",
"tinydate": "^1.0.0"
},
"bin": {
"sirv": "bin.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1621,6 +1726,15 @@
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true "dev": true
}, },
"node_modules/tinydate": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/tinydate/-/tinydate-1.3.0.tgz",
"integrity": "sha512-7cR8rLy2QhYHpsBDBVYnnWXm8uRTr38RoZakFSW7Bs7PzfMPNZthuMLkwqZv7MTu8lhQ91cOFYS5a7iFj2oR3w==",
"dev": true,
"engines": {
"node": ">=4"
}
},
"node_modules/tinyexec": { "node_modules/tinyexec": {
"version": "0.3.2", "version": "0.3.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
+2
View File
@@ -11,10 +11,12 @@
"test:watch": "vitest" "test:watch": "vitest"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.63.0",
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.16.0", "@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3", "@sveltejs/vite-plugin-svelte": "^5.0.3",
"pngjs": "^7.0.0", "pngjs": "^7.0.0",
"sirv-cli": "^3.0.1",
"svelte": "^5.0.0", "svelte": "^5.0.0",
"svelte-check": "^4.1.1", "svelte-check": "^4.1.1",
"typescript": "^5.6.3", "typescript": "^5.6.3",
+24 -24
View File
@@ -453,8 +453,8 @@
<h2>Capture Photos</h2> <h2>Capture Photos</h2>
<p class="instruction">Select 1–5 photos of the slab. Supported: JPG, PNG, WEBP.</p> <p class="instruction">Select 1–5 photos of the slab. Supported: JPG, PNG, WEBP.</p>
<div class="drop-zone"> <div class="drop-zone">
<input type="file" multiple accept="image/*" on:change={(e) => handleFiles(e.target.files)} class="file-input" /> <input type="file" id="photo-file-input" multiple accept="image/*" onchange={(e) => handleFiles(e.target.files)} class="file-input" />
<label class="file-label"> <label for="photo-file-input" class="file-label">
<span class="camera-icon">📷</span> <span class="camera-icon">📷</span>
<span>Select Photos</span> <span>Select Photos</span>
</label> </label>
@@ -468,7 +468,7 @@
</div> </div>
{/each} {/each}
</div> </div>
<button class="btn btn-primary" on:click={() => state.step = 'mask'} disabled={state.photos.length === 0}> <button class="btn btn-primary" onclick={() => state.step = 'mask'} disabled={state.photos.length === 0}>
Process Photos → Process Photos →
</button> </button>
{/if} {/if}
@@ -484,7 +484,7 @@
<div class="knobs-panel"> <div class="knobs-panel">
<div class="knob-group"> <div class="knob-group">
<label>Sheet Mode</label> <label>Sheet Mode</label>
<select value={getMaskKnobs().sheet} on:change={(e) => { <select value={getMaskKnobs().sheet} onchange={(e) => {
const knobs = getMaskKnobs(); const knobs = getMaskKnobs();
applyMaskKnobs({ ...knobs, sheet: e.target.value as KnobSettings['sheet'] }); applyMaskKnobs({ ...knobs, sheet: e.target.value as KnobSettings['sheet'] });
maskCanvas && drawMaskOverlay(maskCanvas, currentPhoto()!); maskCanvas && drawMaskOverlay(maskCanvas, currentPhoto()!);
@@ -497,7 +497,7 @@
<div class="knob-group"> <div class="knob-group">
<label>Sensitivity: {getMaskKnobs().sensitivity.toFixed(2)}</label> <label>Sensitivity: {getMaskKnobs().sensitivity.toFixed(2)}</label>
<input type="range" min="0" max="1" step="0.01" value={getMaskKnobs().sensitivity} <input type="range" min="0" max="1" step="0.01" value={getMaskKnobs().sensitivity}
on:input={(e) => { oninput={(e) => {
const knobs = getMaskKnobs(); const knobs = getMaskKnobs();
applyMaskKnobs({ ...knobs, sensitivity: parseFloat(e.target.value) }); applyMaskKnobs({ ...knobs, sensitivity: parseFloat(e.target.value) });
maskCanvas && drawMaskOverlay(maskCanvas, currentPhoto()!); maskCanvas && drawMaskOverlay(maskCanvas, currentPhoto()!);
@@ -506,7 +506,7 @@
<div class="knob-group"> <div class="knob-group">
<label>Edge Offset: {getMaskKnobs().edgeOffset}</label> <label>Edge Offset: {getMaskKnobs().edgeOffset}</label>
<input type="range" min="-20" max="20" step="1" value={getMaskKnobs().edgeOffset} <input type="range" min="-20" max="20" step="1" value={getMaskKnobs().edgeOffset}
on:input={(e) => { oninput={(e) => {
const knobs = getMaskKnobs(); const knobs = getMaskKnobs();
applyMaskKnobs({ ...knobs, edgeOffset: parseInt(e.target.value) }); applyMaskKnobs({ ...knobs, edgeOffset: parseInt(e.target.value) });
maskCanvas && drawMaskOverlay(maskCanvas, currentPhoto()!); maskCanvas && drawMaskOverlay(maskCanvas, currentPhoto()!);
@@ -515,7 +515,7 @@
<div class="knob-group"> <div class="knob-group">
<label>Feather: {getMaskKnobs().feather}</label> <label>Feather: {getMaskKnobs().feather}</label>
<input type="range" min="0" max="10" step="1" value={getMaskKnobs().feather} <input type="range" min="0" max="10" step="1" value={getMaskKnobs().feather}
on:input={(e) => { oninput={(e) => {
const knobs = getMaskKnobs(); const knobs = getMaskKnobs();
applyMaskKnobs({ ...knobs, feather: parseInt(e.target.value) }); applyMaskKnobs({ ...knobs, feather: parseInt(e.target.value) });
maskCanvas && drawMaskOverlay(maskCanvas, currentPhoto()!); maskCanvas && drawMaskOverlay(maskCanvas, currentPhoto()!);
@@ -523,7 +523,7 @@
</div> </div>
<div class="photo-selector"> <div class="photo-selector">
<label>Photo:</label> <label>Photo:</label>
<select value={state.currentPhotoIndex} on:change={(e) => { <select value={state.currentPhotoIndex} onchange={(e) => {
state.currentPhotoIndex = parseInt(e.target.value); state.currentPhotoIndex = parseInt(e.target.value);
maskCanvas && drawMaskOverlay(maskCanvas, currentPhoto()!); maskCanvas && drawMaskOverlay(maskCanvas, currentPhoto()!);
}}> }}>
@@ -535,8 +535,8 @@
</div> </div>
</div> </div>
<div class="btn-row"> <div class="btn-row">
<button class="btn btn-secondary" on:click={() => state.step = 'capture'}>← Back</button> <button class="btn btn-secondary" onclick={() => state.step = 'capture'}>← Back</button>
<button class="btn btn-primary" on:click={confirmMaskStep}>Confirm Mask →</button> <button class="btn btn-primary" onclick={confirmMaskStep}>Confirm Mask →</button>
</div> </div>
</div> </div>
{/if} {/if}
@@ -551,8 +551,8 @@
<div class="knob-group"> <div class="knob-group">
<label>Rotation</label> <label>Rotation</label>
<div class="rotation-controls"> <div class="rotation-controls">
<button class="btn btn-small" on:click={() => { rotateAxisStep(-90); axisCanvas && drawAxisOverlay(axisCanvas, currentPhoto()!); }}>↺ −90°</button> <button class="btn btn-small" onclick={() => { rotateAxisStep(-90); axisCanvas && drawAxisOverlay(axisCanvas, currentPhoto()!); }}>↺ −90°</button>
<button class="btn btn-small" on:click={() => { rotateAxisStep(90); axisCanvas && drawAxisOverlay(axisCanvas, currentPhoto()!); }}>↻ +90°</button> <button class="btn btn-small" onclick={() => { rotateAxisStep(90); axisCanvas && drawAxisOverlay(axisCanvas, currentPhoto()!); }}>↻ +90°</button>
</div> </div>
</div> </div>
<div class="knob-group"> <div class="knob-group">
@@ -561,7 +561,7 @@
</div> </div>
<div class="photo-selector"> <div class="photo-selector">
<label>Photo:</label> <label>Photo:</label>
<select value={state.currentPhotoIndex} on:change={(e) => { <select value={state.currentPhotoIndex} onchange={(e) => {
state.currentPhotoIndex = parseInt(e.target.value); state.currentPhotoIndex = parseInt(e.target.value);
axisCanvas && drawAxisOverlay(axisCanvas, currentPhoto()!); axisCanvas && drawAxisOverlay(axisCanvas, currentPhoto()!);
}}> }}>
@@ -573,8 +573,8 @@
</div> </div>
</div> </div>
<div class="btn-row"> <div class="btn-row">
<button class="btn btn-secondary" on:click={() => state.step = 'mask'}>← Back</button> <button class="btn btn-secondary" onclick={() => state.step = 'mask'}>← Back</button>
<button class="btn btn-primary" on:click={confirmAxisStep}>Confirm Axis →</button> <button class="btn btn-primary" onclick={confirmAxisStep}>Confirm Axis →</button>
</div> </div>
</div> </div>
{/if} {/if}
@@ -587,19 +587,19 @@
<div class="form-group"> <div class="form-group">
<label>Length (inches)</label> <label>Length (inches)</label>
<input type="number" step="0.125" min="0.125" value={state.lengthIn} <input type="number" step="0.125" min="0.125" value={state.lengthIn}
on:input={(e) => updateLengthIn(e.target.value)} /> oninput={(e) => updateLengthIn(e.target.value)} />
<small>1/8" steps</small> <small>1/8" steps</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>Thickness (inches)</label> <label>Thickness (inches)</label>
<input type="number" step="0.125" min="0.125" value={state.thicknessIn} <input type="number" step="0.125" min="0.125" value={state.thicknessIn}
on:input={(e) => updateThicknessIn(e.target.value)} /> oninput={(e) => updateThicknessIn(e.target.value)} />
<small>1/8" steps</small> <small>1/8" steps</small>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>SKU</label> <label>SKU</label>
<input type="text" value={state.sku} <input type="text" value={state.sku}
on:input={(e) => updateSku(e.target.value)} oninput={(e) => updateSku(e.target.value)}
class:invalid={!state.skuValidation.valid} /> class:invalid={!state.skuValidation.valid} />
<small>{state.skuValidation.valid ? '✓ Valid' : state.skuValidation.errors.join(', ')}</small> <small>{state.skuValidation.valid ? '✓ Valid' : state.skuValidation.errors.join(', ')}</small>
</div> </div>
@@ -633,8 +633,8 @@
<div class="warning">⚠️ Source image is too small for 1600px minimum output. Consider retaking photos.</div> <div class="warning">⚠️ Source image is too small for 1600px minimum output. Consider retaking photos.</div>
{/if} {/if}
<div class="btn-row"> <div class="btn-row">
<button class="btn btn-secondary" on:click={() => state.step = 'axis'}>← Back</button> <button class="btn btn-secondary" onclick={() => state.step = 'axis'}>← Back</button>
<button class="btn btn-primary" on:click={continueToReview} <button class="btn btn-primary" onclick={continueToReview}
disabled={!isSkuValid(state.sku) || state.lengthIn <= 0 || state.thicknessIn <= 0}> disabled={!isSkuValid(state.sku) || state.lengthIn <= 0 || state.thicknessIn <= 0}>
Continue → Continue →
</button> </button>
@@ -652,13 +652,13 @@
<h3>✓ Upload Successful</h3> <h3>✓ Upload Successful</h3>
<p>Slab ID: <code>{state.reviewData?.id}</code></p> <p>Slab ID: <code>{state.reviewData?.id}</code></p>
<p>Server Rev: {state.reviewData?.serverRev}</p> <p>Server Rev: {state.reviewData?.serverRev}</p>
<button class="btn btn-primary" on:click={goToCapture}>Start New Slab</button> <button class="btn btn-primary" onclick={goToCapture}>Start New Slab</button>
</div> </div>
{:else if state.uploadError} {:else if state.uploadError}
<div class="error"> <div class="error">
<h3>✗ Upload Failed</h3> <h3>✗ Upload Failed</h3>
<p>{state.uploadError}</p> <p>{state.uploadError}</p>
<button class="btn btn-secondary" on:click={() => state.uploadError = null}>Dismiss</button> <button class="btn btn-secondary" onclick={() => state.uploadError = null}>Dismiss</button>
</div> </div>
{:else} {:else}
<div class="review-grid"> <div class="review-grid">
@@ -681,8 +681,8 @@
</div> </div>
</div> </div>
<div class="btn-row"> <div class="btn-row">
<button class="btn btn-secondary" on:click={() => state.step = 'numbers'}>← Back</button> <button class="btn btn-secondary" onclick={() => state.step = 'numbers'}>← Back</button>
<button class="btn btn-primary" on:click={uploadDraft} disabled={state.uploading}> <button class="btn btn-primary" onclick={uploadDraft} disabled={state.uploading}>
{state.uploading ? 'Uploading...' : 'Upload Draft'} {state.uploading ? 'Uploading...' : 'Upload Draft'}
</button> </button>
</div> </div>
+31
View File
@@ -0,0 +1,31 @@
import { defineConfig } from '@playwright/test';
import { fileURLToPath } from 'node:url';
import * as path from 'node:path';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
testDir: '.',
fullyParallel: true,
forbidOnly: false,
retries: 1,
workers: 1,
use: {
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
webServer: {
command: 'npx sirv build --port 18080',
port: 18080,
reuseExistingServer: true,
cwd: path.resolve(__dirname, '../../'),
stdout: 'ignore',
stderr: 'pipe',
},
projects: [
{
name: 'chromium',
use: { browserName: 'chromium' },
},
],
});
+64
View File
@@ -0,0 +1,64 @@
import { test, expect } from '@playwright/test';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const TEST_IMAGE = resolve(__dirname, 'test-slab.png');
test.describe('Select Photos BVT', () => {
test('label "Select Photos" has for attribute pointing to the file input id', async ({ page }) => {
await page.goto('/');
const label = page.locator('label.file-label');
await expect(label).toBeVisible();
const forAttr = await label.getAttribute('for');
expect(forAttr).toBe('photo-file-input');
const fileInput = page.locator('#photo-file-input');
await expect(fileInput).toHaveAttribute('type', 'file');
});
test('file selection advances to mask step', async ({ page }) => {
await page.goto('/');
const fileInput = page.locator('#photo-file-input');
await fileInput.setInputFiles(TEST_IMAGE);
// handleFiles runs → state.step becomes 'mask'
await expect(page.locator('h2')).toContainText('Mask & Knobs');
await expect(page.locator('.knobs-panel')).toBeVisible();
// Photo selector should have one option
const photoSelect = page.locator('.photo-selector select');
await expect(photoSelect).toBeVisible();
const optionCount = await photoSelect.locator('option').count();
expect(optionCount).toBe(1);
});
test('Process Photos button is enabled after file selection', async ({ page }) => {
await page.goto('/');
const fileInput = page.locator('#photo-file-input');
await fileInput.setInputFiles(TEST_IMAGE);
// After processing, the mask step appears with Confirm Mask button
await expect(page.locator('h2')).toContainText('Mask & Knobs');
const confirmBtn = page.locator('button:has-text("Confirm Mask")');
await expect(confirmBtn).toBeEnabled();
});
test('full flow: select photo → mask step appears → knobs can be adjusted', async ({ page }) => {
await page.goto('/');
const fileInput = page.locator('#photo-file-input');
await fileInput.setInputFiles(TEST_IMAGE);
// Should advance to mask step
await expect(page.locator('h2')).toContainText('Mask & Knobs');
await expect(page.locator('.knobs-panel')).toBeVisible();
// Verify knobs are present and adjustable
const sensitivitySlider = page.locator('input[type="range"]');
await expect(sensitivitySlider.first()).toBeVisible();
});
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 172 B