style(settings): use full rules form width without spacer rows

This commit is contained in:
cesnimda
2026-11-11 15:17:36 +01:00
parent 511b68a19d
commit 99b144f482
2 changed files with 56 additions and 9 deletions
@@ -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<rects.length;i++) { const gap=rects[i].y-rects[i-1].y-rects[i-1].height; assert.ok(gap>=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));
@@ -56,16 +56,13 @@ export default function RulesSettingsCard({ draft, loadError, saveError, invalid
<PageSection title={t("rulesTitle")} description={t("rulesBody")}>
<Box component="form" noValidate onSubmit={(event) => { event.preventDefault(); onSave(); }}>
{saveError ? <Alert severity="error" sx={{ mb: 2 }}>{t("rulesSaveFailed")}</Alert> : null}
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr 1fr" }, gap: 2 }}>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
<TextField label={t("rulesAppliedFollowUpDays")} {...num("appliedFollowUpDays")} />
<TextField label={t("rulesAppliedGhostDays")} {...num("appliedGhostDays")} />
<Box />
<TextField label={t("rulesOfferFollowUpDays")} {...num("offerFollowUpDays")} />
<TextField label={t("rulesOfferGhostDays")} {...num("offerGhostDays")} />
<Box />
<TextField label={t("rulesFeedbackFollowUpDays")} {...num("feedbackFollowUpDays")} />
<TextField label={t("rulesFeedbackGhostDays")} {...num("feedbackGhostDays")} />
<Box />
</Box>
<Box sx={{ display: "flex", justifyContent: "flex-end", flexWrap: "wrap", gap: 1, mt: 2 }}>