diff --git a/job-tracker-ui/scripts/verify-a3-ai-settings.mjs b/job-tracker-ui/scripts/verify-a3-ai-settings.mjs index 39a75867..98f2ea02 100644 --- a/job-tracker-ui/scripts/verify-a3-ai-settings.mjs +++ b/job-tracker-ui/scripts/verify-a3-ai-settings.mjs @@ -10,15 +10,16 @@ const { translations } = await import(`data:text/javascript;base64,${Buffer.from const reviewOnly = Boolean(process.env.A3_AI_REVIEW_ONLY); const visualMatrix = Boolean(process.env.A3_VISUAL_MATRIX); const settingsRecovery = Boolean(process.env.A3_SETTINGS_RECOVERY); -const settingsOnly = Boolean(process.env.A3_SETTINGS_FLAT) || settingsRecovery; +const rulesRecovery = Boolean(process.env.A3_RULES_RECOVERY); +const settingsOnly = Boolean(process.env.A3_SETTINGS_FLAT) || settingsRecovery || rulesRecovery; assert.ok(!settingsOnly || !(visualMatrix || reviewOnly), 'Settings qualification is a separate scope.'); assert.ok(!visualMatrix || reviewOnly, 'Visual matrix reuses read/review-only journeys.'); -const out = path.resolve(import.meta.dirname, settingsRecovery ? '../../docs/ui-modernisation/visual-correction/evidence/settings-recovery' : settingsOnly ? '../../docs/ui-modernisation/visual-correction/evidence/settings-flat' : visualMatrix ? '../../docs/ui-modernisation/visual-correction/evidence/cv' : reviewOnly ? '../../docs/ui-modernisation/evidence/cross-app/browser-ai-review' : '../../docs/ui-modernisation/evidence/ai-settings/browser'); +const out = path.resolve(import.meta.dirname, rulesRecovery ? '../../docs/ui-modernisation/visual-correction/evidence/rules-recovery' : settingsRecovery ? '../../docs/ui-modernisation/visual-correction/evidence/settings-recovery' : settingsOnly ? '../../docs/ui-modernisation/visual-correction/evidence/settings-flat' : visualMatrix ? '../../docs/ui-modernisation/visual-correction/evidence/cv' : reviewOnly ? '../../docs/ui-modernisation/evidence/cross-app/browser-ai-review' : '../../docs/ui-modernisation/evidence/ai-settings/browser'); fs.mkdirSync(out, { recursive: true }); const report = { screens: [], journeys: [], errors: [], unexpectedWrites: [], writes: [], proposals: [], unavailableReads: [], blockedExternal: [] }; const browser = await chromium.launch(); try { - const configurations = process.env.A3_PILOT ? (settingsOnly ? [[320, 'en', 'light'], [1440, 'en', 'light']] : [[320, 'en', 'light']]) : (visualMatrix || settingsOnly ? [320, 360, 390, 430, 768, 1024, 1280, 1440, 1920] : [320, 390, 768, 1440]).flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(mode => [width, language, mode]))); + const configurations = process.env.A3_PILOT ? (rulesRecovery ? [[390, 'nb', 'dark']] : settingsOnly ? [[320, 'en', 'light'], [1440, 'en', 'light']] : [[320, 'en', 'light']]) : (rulesRecovery ? [390, 768, 1440] : visualMatrix || settingsOnly ? [320, 360, 390, 430, 768, 1024, 1280, 1440, 1920] : [320, 390, 768, 1440]).flatMap(width => ['en', 'nb'].flatMap(language => ['light', 'dark'].map(mode => [width, language, mode]))); for (const [width, language, mode] of configurations) { console.log(`Checking ${width}/${language}/${mode}`); const t = key => { assert.ok(translations[language][key], key); return translations[language][key]; }; @@ -34,6 +35,9 @@ try { let notifications = { emailFollowUpRemindersEnabled: true }; let privacy = { enabled: true, externalProcessingAllowed: false, externalProcessingAvailable: false, effectiveExternalProcessing: false, provider: 'local' }; let privacyReads = 0; + let ruleReads = 0, releaseRuleSave; + const ruleSave = new Promise(resolve => { releaseRuleSave = resolve; }); + const savedRules = { id: 1, appliedFollowUpDays: 14, appliedGhostDays: 30, offerFollowUpDays: 7, offerGhostDays: 14, feedbackFollowUpDays: 7, feedbackGhostDays: 14 }; let releasePrivacyRead; const initialPrivacyRead = new Promise(resolve => { releasePrivacyRead = resolve; }); let variant = { id: 3, name: 'Teacher CV', version: 4, settings: { language: 'en', themeId: 'modern', showPhoto: false, showPageNumbers: false, showIcons: true, sections: [], overrides: {}, customSections: [], ...(reviewOnly ? { headline: 'Manual teacher headline' } : {}) }, isPublic: false, publicSlug: 'synthetic', jobApplicationId: null, updatedAtUtc: '2026-11-03T10:00:00Z' }; @@ -46,6 +50,15 @@ try { if (!p.startsWith('/api/')) return route.continue(); const answer = data => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(data) }); const note = action => { writes.push(action); report.writes.push({ width, language, mode, action }); }; + if (rulesRecovery && p === '/api/rules') { + if (req.method() === 'GET' && ++ruleReads === 1) return route.fulfill({ status: 503, json: {} }); + if (req.method() === 'PUT') { + assert.deepEqual(req.postDataJSON(), { ...savedRules, appliedFollowUpDays: writes.length ? 20 : 21 }); + note(writes.length ? 'rulesSaveRejected' : 'rulesSaveSnapshot'); + if (writes.length === 1) return ruleSave.then(() => answer({})); + return route.fulfill({ status: 503, json: {} }); + } + } if (settingsRecovery && p === '/api/ai/settings') { if (req.method() === 'GET' && ++privacyReads === 1) { return initialPrivacyRead.then(() => route.fulfill({ status: 503, contentType: 'application/problem+json', body: '{"detail":"Controlled preferences read unavailable."}' })); @@ -83,6 +96,7 @@ try { '/api/billing/status': { enabled: false, canCheckout: false, canManage: false }, ...(settingsOnly ? { '/api/account-lifecycle/status': { deletionEnabled: true, deletionStatus: 'active', requiredConfirmation: 'DELETE alex@example.test', request: null } } : {}), '/api/rules': { id: 1, appliedFollowUpDays: 14, appliedGhostDays: 30, offerFollowUpDays: 7, offerGhostDays: 14, feedbackFollowUpDays: 7, feedbackGhostDays: 14 }, + ...(rulesRecovery ? { '/api/companies': [] } : {}), '/api/cv/variants/3': variant, '/api/cv/variants/3/versions': [], '/api/cv/outline': { fullName: 'Alex Sample', headline: 'Recorded teacher headline', sections: [] }, '/api/cv/themes': [{ id: 'modern', name: 'Modern', category: 'Professional', description: 'Synthetic template', layout: 'header-band', accent: '#3157d5', photoShape: 'none', supportsIcons: false, atsFriendly: true, requiresPro: false, available: true, swatches: ['#3157d5'] }], }; @@ -106,6 +120,41 @@ try { if (width >= 600) await page.getByRole('tab', { name: t(key), exact: true }).click(); else { await page.getByRole('combobox', { name: t('settingsNavigation'), exact: true }).click(); await page.getByRole('option', { name: t(key), exact: true }).click(); } }; + if (rulesRecovery) { + await page.goto('http://localhost:4338/settings'); await settingsSection('settingsTabFollowUps'); + const card = page.getByRole('region', { name: t('rulesTitle'), exact: true }); + const retry = card.getByRole('button', { name: t('retry'), exact: true }); + await expect(retry).toBeVisible(); await scan('rules-read-error'); + await retry.press('Enter'); + const field = card.getByRole('spinbutton', { name: t('rulesAppliedFollowUpDays'), exact: true }); + await expect(field).toHaveValue('14'); await expect(card.getByRole('spinbutton')).toHaveCount(6); + await scan('rules-loaded'); + const rects = await card.getByRole('spinbutton').evaluateAll(inputs => inputs.map(input => { const r=input.closest('.MuiFormControl-root').getBoundingClientRect(); return { x:r.x, y:r.y, width:r.width, height:r.height }; })); + const formWidth = (await card.locator('form').boundingBox()).width; + if (width >= 1024) { + assert.ok(rects[0].width >= formWidth * 0.45, 'Two useful desktop columns fill the form'); + assert.ok(rects[1].x > rects[0].x); assert.ok(Math.abs(rects[2].x-rects[0].x)<2); + } else { + for (let i=1;i=8 && gap<=24, `No empty mobile/tablet spacer row: ${gap}`); } + } + const save = card.locator('button[type="submit"]'); + const discard = card.getByRole('button', { name: t('assetsDiscardChanges'), exact: true }); + await field.fill('0'); await save.press('Enter'); await expect(field).toHaveAttribute('aria-invalid','true'); + assert.deepEqual(writes, []); await scan('rules-invalid'); + await field.fill('21'); await save.press('Enter'); await expect(save).toBeDisabled(); + await expect(discard).toBeDisabled(); await field.fill('22'); await scan('rules-saving'); + releaseRuleSave(); await expect(save).toBeEnabled(); await expect(field).toHaveValue('22'); + await scan('rules-saved-newer-draft'); + await discard.press('Enter'); await expect(field).toHaveValue('21'); + await field.fill('20'); await save.press('Enter'); + await expect(card.getByText(t('rulesSaveFailed'), { exact: true })).toBeVisible(); await expect(field).toHaveValue('20'); + await scan('rules-save-error'); + await discard.press('Enter'); await expect(field).toHaveValue('21'); + await expect(card.getByText(t('rulesSaveFailed'), { exact: true })).toHaveCount(0); await scan('rules-discarded'); + assert.equal(ruleReads,2); assert.deepEqual(writes,['rulesSaveSnapshot','rulesSaveRejected']); + report.journeys.push({ width, language, mode, readRetry:'PASS', validColumnsAndGaps:rects, invalidWriteExcluded:'PASS', pendingSnapshotRetainsNewerEdit:'PASS', rejectedSaveRetainsDraft:'PASS', discardRestoresCommittedFixture:'PASS' }); + await context.close(); continue; + } if (settingsRecovery) { await page.goto('http://localhost:4338/settings'); const card = page.getByRole('region', { name: t('settingsAiPrivacyTitle'), exact: true }); @@ -190,10 +239,11 @@ try { assert.deepEqual(writes, ['notificationPreference', 'aiPreference', 'manualCvSave', 'aiPreference', 'explicitAiApply']); assert.deepEqual(proposals, ['en', 'nb-NO']); report.journeys.push({ width, language, mode, manualFallback: 'PASS', languageDiscardWithoutApply: 'PASS', explicitTokenApply: 'PASS', aiSettingsKeyboardFocus: 'PASS' }); await context.close(); } - const statesPerConfiguration = settingsRecovery ? 5 : settingsOnly ? 7 : reviewOnly ? visualMatrix ? 3 : 1 : 6; - const writesPerConfiguration = settingsRecovery ? 1 : reviewOnly || settingsOnly ? 0 : 5; + const statesPerConfiguration = rulesRecovery ? 7 : settingsRecovery ? 5 : settingsOnly ? 7 : reviewOnly ? visualMatrix ? 3 : 1 : 6; + const writesPerConfiguration = rulesRecovery ? 2 : settingsRecovery ? 1 : reviewOnly || settingsOnly ? 0 : 5; assert.equal(report.screens.length, configurations.length * statesPerConfiguration); assert.equal(report.journeys.length, configurations.length); assert.equal(report.writes.length, configurations.length * writesPerConfiguration); assert.equal(report.proposals.length, settingsOnly ? 0 : configurations.length * (reviewOnly ? 1 : 2)); assert.deepEqual(report.errors, []); assert.deepEqual(report.unexpectedWrites, []); assert.ok(report.blockedExternal.every(origin => origin === 'https://fonts.googleapis.com')); + if (rulesRecovery) assert.deepEqual(report.unavailableReads,[]); console.log(`PASS:${report.journeys.length} controlled journeys,${report.screens.length} axe scans/captures.`); } finally { fs.writeFileSync(path.join(out, reviewOnly ? 'REVIEW.json' : process.env.A3_PILOT ? 'PILOT.json' : 'BROWSER.json'), JSON.stringify({ ...report, limits: ['All API, AI and preview results are synthetic fixtures. No model activation, semantic translation quality, backend end-to-end or physical PDF qualification.', 'No native device, screen reader, provider, integrated security, deployment or release acceptance.'] }, null, 2)); diff --git a/job-tracker-ui/src/components/RulesSettingsCard.tsx b/job-tracker-ui/src/components/RulesSettingsCard.tsx index 26dac106..99dfed4b 100644 --- a/job-tracker-ui/src/components/RulesSettingsCard.tsx +++ b/job-tracker-ui/src/components/RulesSettingsCard.tsx @@ -56,16 +56,13 @@ export default function RulesSettingsCard({ draft, loadError, saveError, invalid { event.preventDefault(); onSave(); }}> {saveError ? {t("rulesSaveFailed")} : null} - + - - -