Compare commits

...

4 commits

Author SHA1 Message Date
Adam Moussa
adf175daef
feat(form): render admin orders as mobile cards (#86)
Some checks failed
Deploy / deploy (push) Has been cancelled
* feat(form): render admin orders as mobile cards

* fix(form): address mobile admin review findings

* fix(tests): remove unused mobile fixture state
2026-07-31 10:15:43 -04:00
Adam Moussa
602b0c7fd0
fix(form): accessibility for qty, status, and descriptions (#81)
* fix(form): add status regions, live total, and a11y CSS

Dual alert/status slots for form and admin, aria-live on the sticky
total, success heading focus target, More/Less affordance styles, and
44px coarse-pointer chip padding.

* fix(form): wire a11y labels, status helper, and desc expand

Meal-scoped qty labels at card create time, aria-pressed filter chips,
dual-node showStatus replacing all alert() calls (confirm retained),
overflow-gated More/Less, and success-heading focus after submit.

* test(form): cover a11y labels, status region, and desc toggle

Structural checks for dual status nodes and no alert(); Playwright for
init-time qty labels, aria-pressed chips, overflow More/Less, and
failed-submit text landing in role=alert.

* fix: address review comments
2026-07-31 10:15:43 -04:00
Adam Moussa
83077f7aa9
test(form): cover sticky footer at narrow widths (#85)
* test(form): cover sticky footer at narrow widths

* fix(form): address review findings

* fix(form): guard stale admin edit responses

* test(form): stub admin lookup in browser tests
2026-07-31 10:15:42 -04:00
Adam Moussa
30fa7fe352
fix(form): wrap Google user bar at phone widths (#84)
* fix(form): wrap Google user bar at phone widths

Allow the signed-in Google identity and admin controls to wrap below 480px while preserving the desktop row, with generated-form Playwright coverage for phone widths.

* fix(form): preserve 480px user bar boundary

Keep the mobile layout below 480px and lock both sides of the breakpoint with browser coverage.

* style(tests): apply ruff formatting

* test(form): guarantee Playwright browser cleanup

* test(form): strengthen phone-width coverage

* fix: add @classmethod and use cls in tests/test_generate_form.py

Co-authored-by: seahaven-openswe[bot] <296972425+seahaven-openswe[bot]@users.noreply.github.com>

* fix(tests): restore class fixture discovery

---------

Co-authored-by: seahaven-openswe[bot] <296972425+seahaven-openswe[bot]@users.noreply.github.com>
2026-07-31 10:15:42 -04:00
7 changed files with 1253 additions and 122 deletions

View file

@ -97,9 +97,9 @@ def generate_form(
return template.render(
config=form_config,
config_json=_json_for_script(form_config),
css=(TEMPLATES_DIR / "form.css").read_text(),
form_js=(TEMPLATES_DIR / "form.js").read_text(),
admin_js=(TEMPLATES_DIR / "admin.js").read_text(),
css=(TEMPLATES_DIR / "form.css").read_text(encoding="utf-8"),
form_js=(TEMPLATES_DIR / "form.js").read_text(encoding="utf-8"),
admin_js=(TEMPLATES_DIR / "admin.js").read_text(encoding="utf-8"),
week=week,
scraped_at=scraped_at,
deadline=deadline,

View file

@ -4,11 +4,13 @@ const ADMIN_URL = window.ADMIN_URL;
const ADMIN_PDF_URL = window.ADMIN_PDF_URL;
const MEALS = window.MEALS;
const WEEK = window.WEEK;
const ADMIN_REQUEST_TIMEOUT_MS = 10000;
let currentAdminWeek = '';
let editingOrder = null;
let editQuantities = {};
let lastAdminData = null;
let latestAdminRequest = 0;
function adminHeaders() {
const h = {};
@ -17,24 +19,52 @@ function adminHeaders() {
return h;
}
function adminFetch(url, options = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ADMIN_REQUEST_TIMEOUT_MS);
return fetch(url, { ...options, signal: controller.signal })
.finally(() => clearTimeout(timeout));
}
function setAdminActionDisabled(action, disabled) {
document.querySelectorAll('[data-admin-action="' + action + '"]').forEach(btn => {
btn.disabled = disabled;
});
}
function loadWeekOrders(week) {
if (!week) return;
const requestId = ++latestAdminRequest;
window.FormUtils.clearStatus('admin');
currentAdminWeek = week;
editingOrder = null;
editQuantities = {};
const tbody = document.getElementById('admin-tbody');
tbody.innerHTML = '<tr><td colspan="5" class="admin-loading">Loading...</td></tr>';
const cards = document.getElementById('admin-cards');
cards.innerHTML = '<div class="admin-loading">Loading...</div>';
document.getElementById('admin-empty').classList.add('is-hidden');
document.getElementById('admin-stats').textContent = '';
fetch(ADMIN_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() })
adminFetch(ADMIN_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() })
.then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(data => renderAdminTable(data))
.then(data => {
if (requestId === latestAdminRequest && week === currentAdminWeek) {
renderAdminTable(data);
}
})
.catch(() => {
tbody.innerHTML = '<tr><td colspan="5" class="admin-error">Failed to load orders.</td></tr>';
if (requestId === latestAdminRequest && week === currentAdminWeek) {
tbody.innerHTML = '<tr><td colspan="5" class="admin-error">Failed to load orders.</td></tr>';
cards.innerHTML = '';
window.FormUtils.showStatus('Failed to load orders.', {
tone: 'error',
scope: 'admin',
});
}
});
}
@ -49,16 +79,19 @@ function buildEditQtyRow(name, qty) {
minusBtn.dataset.adminAction = 'edit-qty';
minusBtn.dataset.mealName = name;
minusBtn.dataset.delta = '-1';
minusBtn.setAttribute('aria-label', 'Remove one ' + name);
minusBtn.innerHTML = '&minus;';
const span = document.createElement('span');
span.textContent = qty;
span.setAttribute('aria-label', 'Quantity of ' + name);
const plusBtn = document.createElement('button');
plusBtn.type = 'button';
plusBtn.dataset.adminAction = 'edit-qty';
plusBtn.dataset.mealName = name;
plusBtn.dataset.delta = '1';
plusBtn.setAttribute('aria-label', 'Add one ' + name);
plusBtn.textContent = '+';
wrap.appendChild(minusBtn);
@ -128,6 +161,116 @@ function buildViewActions(email, name) {
return wrap;
}
function formatSubmittedAt(submittedAt) {
return submittedAt
? new Date(submittedAt).toLocaleString('en-US', {
timeZone: 'America/New_York',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
: '';
}
function editTotal() {
const menuPrices = {};
MEALS.forEach(m => { menuPrices[m.name] = m.price; });
return Object.keys(editQuantities).reduce((sum, name) => {
const price = menuPrices[name] || 0;
return sum + window.FormUtils.employeePrice(price) * (editQuantities[name] || 0);
}, 0);
}
function populateOrderItems(container, order, isEditing, separatorFactory) {
if (isEditing) {
Object.keys(editQuantities).forEach(name => {
container.appendChild(buildEditQtyRow(name, editQuantities[name] || 0));
if (separatorFactory) container.appendChild(separatorFactory());
});
const availableMeals = MEALS.filter(
m => !Object.prototype.hasOwnProperty.call(editQuantities, m.name)
);
if (availableMeals.length > 0) {
container.appendChild(buildAddItemSelect(availableMeals));
}
return '$' + editTotal().toFixed(2);
}
order.items.forEach((item, itemIdx) => {
if (separatorFactory && itemIdx > 0) {
container.appendChild(separatorFactory());
}
const row = separatorFactory ? container : document.createElement('div');
if (!separatorFactory) row.className = 'admin-item';
row.appendChild(document.createTextNode(item.name + ' ×' + item.quantity + ' '));
const sub = document.createElement('span');
sub.className = 'admin-muted';
sub.textContent = '($' + item.subtotal.toFixed(2) + ')';
row.appendChild(sub);
if (!separatorFactory) container.appendChild(row);
});
return '$' + order.total.toFixed(2);
}
function buildAdminCard(order) {
const card = document.createElement('article');
card.className = 'admin-order-card';
card.dataset.email = order.employee_email;
const isEditing = editingOrder === order.employee_email;
const header = document.createElement('div');
header.className = 'admin-card-header';
const employee = document.createElement('div');
employee.className = 'admin-card-employee';
const name = document.createElement('strong');
name.textContent = order.employee_name;
const email = document.createElement('span');
email.className = 'admin-muted';
email.textContent = order.employee_email;
employee.appendChild(name);
employee.appendChild(email);
const time = document.createElement('span');
time.className = 'admin-card-time';
time.textContent = formatSubmittedAt(order.submitted_at);
header.appendChild(employee);
header.appendChild(time);
const items = document.createElement('div');
items.className = 'admin-card-items';
const total = populateOrderItems(items, order, isEditing);
const footer = document.createElement('div');
footer.className = 'admin-card-footer';
const totalWrap = document.createElement('div');
totalWrap.className = 'admin-card-total';
const totalLabel = document.createElement('span');
totalLabel.textContent = 'Total';
const totalValue = document.createElement('strong');
totalValue.textContent = total;
totalWrap.appendChild(totalLabel);
totalWrap.appendChild(totalValue);
footer.appendChild(totalWrap);
footer.appendChild(
isEditing
? buildEditActions(order.employee_email)
: buildViewActions(order.employee_email, order.employee_name)
);
card.appendChild(header);
card.appendChild(items);
card.appendChild(footer);
return card;
}
function renderAdminCards(data) {
const cards = document.getElementById('admin-cards');
cards.innerHTML = '';
(data.orders || []).forEach(order => {
cards.appendChild(buildAdminCard(order));
});
}
function renderAdminTable(data) {
lastAdminData = data;
const hasOrders = !!(data.orders && data.orders.length > 0);
@ -135,6 +278,8 @@ function renderAdminTable(data) {
if (dlBtn) dlBtn.disabled = !hasOrders;
const tbody = document.getElementById('admin-tbody');
tbody.innerHTML = '';
const cards = document.getElementById('admin-cards');
cards.innerHTML = '';
if (!hasOrders) {
document.getElementById('admin-empty').classList.remove('is-hidden');
@ -176,39 +321,14 @@ function renderAdminTable(data) {
const itemsTd = document.createElement('td');
itemsTd.className = 'items-cell';
let totalHtml;
if (isEditing) {
const editNames = Object.keys(editQuantities);
editNames.forEach(name => {
itemsTd.appendChild(buildEditQtyRow(name, editQuantities[name] || 0));
itemsTd.appendChild(document.createElement('br'));
});
const availableMeals = MEALS.filter(m => !Object.prototype.hasOwnProperty.call(editQuantities, m.name));
if (availableMeals.length > 0) {
itemsTd.appendChild(buildAddItemSelect(availableMeals));
}
const menuPrices = {};
MEALS.forEach(m => { menuPrices[m.name] = m.price; });
const newTotal = editNames.reduce((sum, name) => {
const price = menuPrices[name] || 0;
return sum + window.FormUtils.employeePrice(price) * (editQuantities[name] || 0);
}, 0);
totalHtml = '$' + newTotal.toFixed(2);
tr.appendChild(empTd);
tr.appendChild(itemsTd);
} else {
order.items.forEach((item, itemIdx) => {
if (itemIdx > 0) itemsTd.appendChild(document.createElement('br'));
itemsTd.appendChild(document.createTextNode(item.name + ' ×' + item.quantity + ' '));
const sub = document.createElement('span');
sub.className = 'admin-muted';
sub.textContent = '($' + item.subtotal.toFixed(2) + ')';
itemsTd.appendChild(sub);
});
totalHtml = '$' + order.total.toFixed(2);
tr.appendChild(empTd);
tr.appendChild(itemsTd);
}
const totalHtml = populateOrderItems(
itemsTd,
order,
isEditing,
() => document.createElement('br')
);
tr.appendChild(empTd);
tr.appendChild(itemsTd);
const totalTd = document.createElement('td');
totalTd.className = 'total-cell';
@ -216,15 +336,7 @@ function renderAdminTable(data) {
const timeTd = document.createElement('td');
timeTd.className = 'time-cell';
timeTd.textContent = order.submitted_at
? new Date(order.submitted_at).toLocaleString('en-US', {
timeZone: 'America/New_York',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
})
: '';
timeTd.textContent = formatSubmittedAt(order.submitted_at);
const actionsTd = document.createElement('td');
if (isEditing) {
@ -245,7 +357,7 @@ function renderAdminTable(data) {
const t1 = document.createElement('td');
t1.textContent = 'Total';
const t2 = document.createElement('td');
t2.textContent = mealCount + ' meals';
t2.textContent = mealCount + (mealCount === 1 ? ' meal' : ' meals');
const t3 = document.createElement('td');
t3.className = 'total-cell';
t3.textContent = '$' + data.grand_total.toFixed(2);
@ -255,6 +367,7 @@ function renderAdminTable(data) {
totalRow.appendChild(document.createElement('td'));
totalRow.appendChild(document.createElement('td'));
tbody.appendChild(totalRow);
renderAdminCards(data);
}
function downloadOrderList() {
@ -289,7 +402,8 @@ function downloadSummaryPdf() {
const week = currentAdminWeek || WEEK;
const btn = document.getElementById('admin-pdf-btn');
if (btn) btn.disabled = true;
fetch(ADMIN_PDF_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() })
window.FormUtils.clearStatus('admin');
adminFetch(ADMIN_PDF_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() })
.then(r => r.json().then(data => ({ ok: r.ok, status: r.status, data })))
.then(({ ok, status, data }) => {
if (ok && data.url) {
@ -297,25 +411,43 @@ function downloadSummaryPdf() {
if (typeof data.url === 'string' && data.url.startsWith('https://')) {
window.location.href = data.url;
} else {
alert('Could not fetch the summary PDF: invalid URL');
window.FormUtils.showStatus('Could not fetch the summary PDF: invalid URL', {
tone: 'error',
scope: 'admin',
});
}
} else if (status === 404) {
alert('No summary PDF for ' + week + ' yet — it is generated when the week closes on Thursday.');
window.FormUtils.showStatus(
'No summary PDF for ' + week + ' yet — it is generated when the week closes on Thursday.',
{ tone: 'info', scope: 'admin' }
);
} else {
alert('Could not fetch the summary PDF: ' + (data.error || 'unknown error'));
window.FormUtils.showStatus(
'Could not fetch the summary PDF: ' + (data.error || 'unknown error'),
{ tone: 'error', scope: 'admin' }
);
}
})
.catch(() => alert('Could not fetch the summary PDF.'))
.catch(() => {
window.FormUtils.showStatus('Could not fetch the summary PDF.', {
tone: 'error',
scope: 'admin',
});
})
.finally(() => { if (btn) btn.disabled = false; });
}
function adminStartEdit(email) {
fetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek), { headers: adminHeaders() })
window.FormUtils.clearStatus('admin');
const requestedWeek = currentAdminWeek;
setAdminActionDisabled('start-edit', true);
adminFetch(ADMIN_URL + '?week=' + encodeURIComponent(requestedWeek), { headers: adminHeaders() })
.then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(data => {
if (currentAdminWeek !== requestedWeek) return;
lastAdminData = data;
const order = data.orders.find(o => o.employee_email === email);
if (!order) return;
@ -324,7 +456,13 @@ function adminStartEdit(email) {
order.items.forEach(i => { editQuantities[i.name] = i.quantity; });
renderAdminTable(data);
})
.catch(() => alert('Failed to load this order for editing.'));
.catch(() => {
window.FormUtils.showStatus('Failed to load this order for editing.', {
tone: 'error',
scope: 'admin',
});
})
.finally(() => setAdminActionDisabled('start-edit', false));
}
function adminCancelEdit() {
@ -350,31 +488,49 @@ function adminSaveEdit(email) {
.filter(([, q]) => q > 0)
.map(([name, quantity]) => ({ name, quantity }));
if (items.length === 0) {
// confirm() retained deliberately for destructive zero-qty → delete guard.
if (confirm('All quantities are zero. Delete this order instead?')) {
adminDeleteOrder(email, '');
}
return;
}
fetch(ADMIN_URL, {
window.FormUtils.clearStatus('admin');
setAdminActionDisabled('save-edit', true);
adminFetch(ADMIN_URL, {
method: 'PUT',
headers: { ...adminHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ week: currentAdminWeek, email, items }),
})
.then(r => { if (!r.ok) return r.json().then(d => { throw new Error(d.error); }); return r.json(); })
.then(() => { editingOrder = null; editQuantities = {}; loadWeekOrders(currentAdminWeek); })
.catch(e => alert('Failed to update: ' + e.message));
.catch(e => {
window.FormUtils.showStatus('Failed to update: ' + e.message, {
tone: 'error',
scope: 'admin',
});
})
.finally(() => setAdminActionDisabled('save-edit', false));
}
function adminDeleteOrder(email, name) {
const label = name ? name + ' (' + email + ')' : email;
// confirm() retained deliberately for destructive delete guard.
if (!confirm('Delete order for ' + label + '?')) return;
fetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek) + '&email=' + encodeURIComponent(email), {
window.FormUtils.clearStatus('admin');
setAdminActionDisabled('delete', true);
adminFetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek) + '&email=' + encodeURIComponent(email), {
method: 'DELETE',
headers: adminHeaders(),
})
.then(r => { if (!r.ok) return r.json().then(d => { throw new Error(d.error); }); return r.json(); })
.then(() => loadWeekOrders(currentAdminWeek))
.catch(e => alert('Failed to delete: ' + e.message));
.catch(e => {
window.FormUtils.showStatus('Failed to delete: ' + e.message, {
tone: 'error',
scope: 'admin',
});
})
.finally(() => setAdminActionDisabled('delete', false));
}
function handleAdminPanelClick(e) {

View file

@ -6,6 +6,7 @@
--radius-card: 12px;
--radius-control: 8px;
--shadow-card: 0 1px 3px rgba(0,0,0,0.08);
--sticky-footer-height: 80px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
@ -14,7 +15,7 @@ body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f7;
color: #1d1d1f;
padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px));
padding-bottom: calc(var(--sticky-footer-height) + env(safe-area-inset-bottom, 0px));
}
.is-hidden { display: none !important; }
@ -75,7 +76,7 @@ header p { font-size: 0.85rem; opacity: 0.8; }
margin-bottom: 12px;
}
.user-info-row { display: flex; align-items: center; gap: 12px; }
.user-bar { display: flex; align-items: center; gap: 12px; }
.user-avatar {
width: 40px;
@ -87,9 +88,13 @@ header p { font-size: 0.85rem; opacity: 0.8; }
.user-avatar:not(.is-hidden) { display: block; }
.user-meta { flex: 1; }
.user-meta { flex: 1; min-width: 0; }
.user-name { font-weight: 600; font-size: 0.95rem; }
.user-email { font-size: 0.85rem; color: var(--color-muted); }
.user-email {
font-size: 0.85rem;
color: var(--color-muted);
overflow-wrap: anywhere;
}
.count-display { font-size: 0.8rem; color: var(--color-muted); margin-left: 8px; }
.g-signin-wrap { display: flex; justify-content: center; }
.admin-muted { font-size: 0.8rem; color: #9ca3af; }
@ -147,6 +152,28 @@ header p { font-size: 0.85rem; opacity: 0.8; }
overflow: hidden;
}
.meal-desc.is-expanded {
display: block;
-webkit-line-clamp: unset;
overflow: visible;
}
.meal-desc-toggle {
display: inline-block;
margin: 0 0 4px;
padding: 0;
border: none;
background: none;
color: var(--color-brand);
font-size: 0.8rem;
font-weight: 600;
font-family: inherit;
cursor: pointer;
text-decoration: underline;
}
.meal-desc-toggle:hover { opacity: 0.85; }
.qty-control {
display: flex;
align-items: center;
@ -190,6 +217,7 @@ header p { font-size: 0.85rem; opacity: 0.8; }
background: #fff;
border-top: 1px solid #e5e7eb;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
min-height: calc(var(--sticky-footer-height) + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -2px 8px rgba(0,0,0,0.1);
z-index: 100;
}
@ -338,6 +366,12 @@ header p { font-size: 0.85rem; opacity: 0.8; }
font-size: 1rem;
}
@media (max-width: 479px) {
.user-bar { flex-wrap: wrap; }
.user-meta { flex-basis: calc(100% - 52px); }
.user-bar #admin-btn { margin-left: 52px; }
}
@media (max-width: 480px) {
.meal-card {
display: grid;
@ -355,6 +389,44 @@ header p { font-size: 0.85rem; opacity: 0.8; }
.qty-control .qty { width: 44px; height: 44px; }
.edit-qty button { width: 44px; height: 44px; }
.edit-qty span { width: 44px; height: 44px; line-height: 44px; }
.btn-chip { min-height: 44px; padding: 10px 16px; }
.admin-order-card .btn,
.admin-add-item select { min-height: 44px; }
}
.form-status {
max-width: 1400px;
margin: 0 auto 8px;
}
.sticky-footer .form-status {
margin-bottom: 8px;
}
.admin-panel > .form-status {
margin: 0 0 12px;
}
.form-status-msg:empty { display: none; }
.form-status-msg {
padding: 8px 12px;
border-radius: var(--radius-control);
font-size: 0.85rem;
font-weight: 500;
text-align: center;
}
.form-status-error {
background: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
.form-status-info {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
.discount-banner {
@ -442,6 +514,7 @@ header p { font-size: 0.85rem; opacity: 0.8; }
border-radius: var(--radius-card);
padding: 20px;
box-shadow: var(--shadow-card);
min-width: 0;
}
.admin-header {
@ -515,6 +588,64 @@ header p { font-size: 0.85rem; opacity: 0.8; }
.admin-empty { text-align: center; padding: 40px 20px; color: #9ca3af; }
.admin-cards { display: none; }
.admin-order-card {
min-width: 0;
padding: 16px;
border: 1px solid #e5e7eb;
border-radius: var(--radius-card);
background: #fff;
box-shadow: var(--shadow-card);
}
.admin-card-header,
.admin-card-footer {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
min-width: 0;
}
.admin-card-employee {
display: flex;
flex-direction: column;
min-width: 0;
overflow-wrap: anywhere;
}
.admin-card-time {
flex: 0 0 auto;
color: #9ca3af;
font-size: 0.8rem;
text-align: right;
}
.admin-card-items {
margin: 16px 0;
color: #4b5563;
font-size: 0.9rem;
min-width: 0;
overflow-wrap: anywhere;
}
.admin-item + .admin-item { margin-top: 6px; }
.admin-card-total {
display: flex;
flex-direction: column;
gap: 2px;
white-space: nowrap;
}
.admin-card-total span {
color: var(--color-muted);
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.admin-actions { display: flex; gap: 6px; white-space: nowrap; flex-wrap: wrap; }
.btn-edit { background: #eff6ff; color: #1d4ed8; border-color: #bfdbfe; }
@ -584,3 +715,35 @@ header p { font-size: 0.85rem; opacity: 0.8; }
.admin-loading { color: #9ca3af; }
.admin-error { color: #991b1b; }
@media (max-width: 767px) {
.admin-panel { padding: 12px; }
.admin-table-wrap { display: none; }
.admin-cards {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 12px;
}
.admin-card-header,
.admin-card-footer {
flex-wrap: wrap;
}
.admin-card-footer .admin-actions {
justify-content: flex-end;
}
.admin-card-items .edit-qty {
display: flex;
width: 100%;
margin-bottom: 8px;
}
.admin-card-items .edit-qty button { width: 44px; height: 44px; }
.admin-card-items .edit-qty span {
width: 44px;
height: 44px;
line-height: 44px;
}
.admin-add-item select {
width: 100%;
max-width: 100%;
}
}

View file

@ -51,7 +51,7 @@
{% if config.authMode == 'google' %}
<div class="employee-info">
<div id="user-info">
<div class="user-info-row">
<div class="user-bar">
<img id="user-avatar" class="user-avatar is-hidden" alt="">
<div class="user-meta">
<div id="user-name" class="user-name"></div>
@ -70,7 +70,7 @@
</div>
{% endif %}
<input type="text" class="search-bar" id="search" placeholder="Search meals...">
<input type="text" class="search-bar" id="search" placeholder="Search meals..." aria-label="Search meals">
<div class="filters" id="filters"></div>
@ -85,7 +85,7 @@
</div>
<div class="qty-control">
<button type="button" data-qty-delta="-1" aria-label="Decrease">&minus;</button>
<input class="qty" type="number" min="0" step="1" value="0">
<input class="qty" type="number" min="0" step="1" value="0" aria-label="Quantity">
<button type="button" data-qty-delta="1" aria-label="Increase">+</button>
</div>
</div>
@ -94,8 +94,12 @@
<div id="meals-list"></div>
<div class="sticky-footer{% if config.authMode == 'google' %} is-hidden{% endif %}" id="sticky-footer">
<div class="form-status" id="form-status">
<div id="form-status-alert" class="form-status-msg form-status-error" role="alert"></div>
<div id="form-status-info" class="form-status-msg form-status-info" role="status"></div>
</div>
<div class="inner">
<div>
<div aria-live="polite" aria-atomic="true">
<span class="total" id="total-display">$0.00</span>
<span class="count-display" id="count-display">0 meals</span>
</div>
@ -114,6 +118,10 @@
<button type="button" class="btn btn-outline back-btn" data-admin-action="hide-admin">Back to Menu</button>
</div>
</div>
<div class="form-status" id="admin-status">
<div id="admin-status-alert" class="form-status-msg form-status-error" role="alert"></div>
<div id="admin-status-info" class="form-status-msg form-status-info" role="status"></div>
</div>
<div id="admin-stats" class="admin-stats"></div>
<div class="admin-table-wrap">
<table class="admin-table">
@ -129,12 +137,13 @@
<tbody id="admin-tbody"></tbody>
</table>
</div>
<div id="admin-cards" class="admin-cards"></div>
<div id="admin-empty" class="admin-empty is-hidden">No orders for this week.</div>
</div>
</div>
<div id="success" class="success-msg is-hidden">
<h2>Order submitted!</h2>
<h2 id="success-heading" tabindex="-1">Order submitted!</h2>
<p id="success-detail"></p>
<button type="button" id="success-back-btn" class="btn btn-outline back-btn">Back to Menu</button>
</div>

View file

@ -29,6 +29,8 @@ let reopenAtMs = null;
let countdownTimer = null;
let mealsListDelegationBound = false;
const GOOGLE_AUTH_MAX_ATTEMPTS = 200;
window.googleCredential = null;
function employeePrice(retail) {
@ -38,7 +40,32 @@ function employeePrice(retail) {
return empPrice;
}
window.FormUtils = { employeePrice };
function clearStatus(scope) {
const prefix = scope === 'admin' ? 'admin-status' : 'form-status';
const alertEl = document.getElementById(prefix + '-alert');
const infoEl = document.getElementById(prefix + '-info');
if (alertEl) alertEl.textContent = '';
if (infoEl) infoEl.textContent = '';
}
function showStatus(message, options) {
const opts = options || {};
const tone = opts.tone || 'error';
const scope = opts.scope || 'form';
const prefix = scope === 'admin' ? 'admin-status' : 'form-status';
const alertEl = document.getElementById(prefix + '-alert');
const infoEl = document.getElementById(prefix + '-info');
if (!alertEl || !infoEl) return;
if (tone === 'info') {
alertEl.textContent = '';
infoEl.textContent = message || '';
} else {
infoEl.textContent = '';
alertEl.textContent = message || '';
}
}
window.FormUtils = { employeePrice, showStatus, clearStatus };
function syncGoogleCredential() {
window.googleCredential = googleCredential;
@ -52,11 +79,17 @@ function buildItems() {
}));
}
function waitForGoogleAuth() {
function waitForGoogleAuth(attempt = 0) {
if (typeof google !== 'undefined' && google.accounts && google.accounts.id) {
initGoogleAuth();
} else if (attempt < GOOGLE_AUTH_MAX_ATTEMPTS) {
setTimeout(() => waitForGoogleAuth(attempt + 1), 50);
} else {
setTimeout(waitForGoogleAuth, 50);
const signIn = document.getElementById('g-signin-btn');
if (signIn) {
signIn.textContent = 'Google sign-in failed to load. Refresh the page to try again.';
}
console.error('Google Identity Services failed to load.');
}
}
@ -176,6 +209,36 @@ function buildMealTagsEl(meal) {
return tagsEl;
}
function removeDescToggle(card) {
const existing = card.querySelector('.meal-desc-toggle');
if (existing) existing.remove();
}
function attachDescToggleIfOverflow(card) {
const descEl = card.querySelector('.meal-desc');
if (!descEl || descEl.classList.contains('is-hidden')) return;
removeDescToggle(card);
if (descEl.scrollHeight <= descEl.clientHeight + 1) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'meal-desc-toggle';
btn.textContent = 'More';
btn.setAttribute('aria-expanded', 'false');
btn.addEventListener('click', () => {
const expanded = descEl.classList.toggle('is-expanded');
btn.textContent = expanded ? 'Less' : 'More';
btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
});
descEl.after(btn);
}
function scheduleDescToggle(card) {
requestAnimationFrame(() => {
attachDescToggleIfOverflow(card);
});
}
function updateMealCard(card, meal, i) {
const qty = quantities[i] || 0;
card.dataset.mealIndex = String(i);
@ -195,9 +258,12 @@ function updateMealCard(card, meal, i) {
card.querySelector('.meal-name').textContent = meal.name;
const descEl = card.querySelector('.meal-desc');
removeDescToggle(card);
descEl.classList.remove('is-expanded');
if (meal.description) {
descEl.textContent = meal.description;
descEl.classList.remove('is-hidden');
scheduleDescToggle(card);
} else {
descEl.textContent = '';
descEl.classList.add('is-hidden');
@ -213,6 +279,12 @@ function updateMealCard(card, meal, i) {
const qtyInput = card.querySelector('.qty');
qtyInput.value = qty;
qtyInput.setAttribute('aria-label', 'Quantity of ' + meal.name);
const minusBtn = card.querySelector('[data-qty-delta="-1"]');
const plusBtn = card.querySelector('[data-qty-delta="1"]');
if (minusBtn) minusBtn.setAttribute('aria-label', 'Remove one ' + meal.name);
if (plusBtn) plusBtn.setAttribute('aria-label', 'Add one ' + meal.name);
const disabled = formClosed && !isAdmin;
card.querySelectorAll('[data-qty-delta]').forEach(btn => { btn.disabled = disabled; });
@ -253,10 +325,14 @@ function updateMealCardQty(index) {
if (qtyInput && qtyInput !== document.activeElement) qtyInput.value = qty;
}
function setQty(index, value) {
function setQtyState(index, value) {
const next = Math.max(0, parseInt(value, 10) || 0);
if (next === 0) delete quantities[index];
else quantities[index] = next;
}
function setQty(index, value) {
setQtyState(index, value);
updateMealCardQty(index);
updateTotal();
}
@ -285,7 +361,8 @@ function bindMealsListDelegation() {
const card = input.closest('[data-meal-index]');
if (!card) return;
const index = parseInt(card.dataset.mealIndex, 10);
setQty(index, input.value);
setQtyState(index, input.value);
updateTotal();
});
list.addEventListener('focusout', (e) => {
const input = e.target.closest('.qty');
@ -310,20 +387,38 @@ function updateTotal() {
}
async function submitOrder() {
if (formClosed && !isAdmin) { alert('Orders are closed.'); return; }
clearStatus('form');
if (formClosed && !isAdmin) {
showStatus('Orders are closed.', { tone: 'error', scope: 'form' });
return;
}
let payload;
let successName;
if (CONFIG.authMode === 'google') {
if (!googleCredential || !googleUser) { alert('Please sign in with Google first.'); return; }
if (!googleCredential || !googleUser) {
showStatus('Please sign in with Google first.', { tone: 'error', scope: 'form' });
return;
}
payload = { google_id_token: googleCredential, items: buildItems() };
successName = googleUser.name;
} else {
const name = document.getElementById('emp-name').value.trim();
const email = document.getElementById('emp-email').value.trim();
if (!name) { alert('Please enter your name.'); return; }
if (!email) { alert('Please enter your email.'); return; }
const nameEl = document.getElementById('emp-name');
const emailEl = document.getElementById('emp-email');
const name = nameEl.value.trim();
const email = emailEl.value.trim();
if (!name) {
showStatus('Please enter your name.', { tone: 'error', scope: 'form' });
nameEl.focus();
return;
}
if (!email) {
showStatus('Please enter your email.', { tone: 'error', scope: 'form' });
emailEl.focus();
return;
}
payload = { employee_name: name, employee_email: email, items: buildItems() };
successName = name;
}
@ -351,18 +446,42 @@ async function submitOrder() {
const footer = document.querySelector('.sticky-footer');
if (footer) footer.classList.add('is-hidden');
try { localStorage.setItem('lastOrderWeek', WEEK); } catch (e) {}
const successHeading = document.getElementById('success-heading');
if (successHeading) successHeading.focus();
} else {
alert(data.error || 'Something went wrong.');
showStatus(data.error || 'Something went wrong.', { tone: 'error', scope: 'form' });
btn.disabled = false;
btn.textContent = 'Submit Order';
}
} catch (e) {
alert('Failed to submit. Check your connection and try again.');
showStatus('Failed to submit. Check your connection and try again.', { tone: 'error', scope: 'form' });
btn.disabled = false;
btn.textContent = 'Submit Order';
}
}
function bindEmpNameChange(select) {
if (select.dataset.boundChange === '1') return;
select.dataset.boundChange = '1';
select.addEventListener('change', () => {
const selected = select.options[select.selectedIndex];
document.getElementById('emp-email').value = selected?.dataset?.email || '';
clearStatus('form');
});
}
function populateEmpSelect(select, employees) {
select.innerHTML = '<option value="">Select your name</option>';
employees.forEach(emp => {
const opt = document.createElement('option');
opt.value = emp.name;
opt.dataset.email = emp.email;
opt.textContent = emp.name;
select.appendChild(opt);
});
bindEmpNameChange(select);
}
async function loadRoster() {
let employees = ROSTER;
try {
@ -377,18 +496,7 @@ async function loadRoster() {
} catch (e) {}
const select = document.getElementById('emp-name');
select.innerHTML = '<option value="">Select your name</option>';
employees.forEach(emp => {
const opt = document.createElement('option');
opt.value = emp.name;
opt.dataset.email = emp.email;
opt.textContent = emp.name;
select.appendChild(opt);
});
select.addEventListener('change', () => {
const selected = select.options[select.selectedIndex];
document.getElementById('emp-email').value = selected?.dataset?.email || '';
});
populateEmpSelect(select, employees);
}
function showClosedOverlay() {
@ -575,11 +683,17 @@ function init() {
cats.forEach(cat => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-chip' + (cat === 'All Meals' ? ' active' : '');
const isActive = cat === 'All Meals';
btn.className = 'btn btn-chip' + (isActive ? ' active' : '');
btn.setAttribute('aria-pressed', isActive ? 'true' : 'false');
btn.textContent = cat;
btn.addEventListener('click', () => {
filtersEl.querySelectorAll('button').forEach(b => b.classList.remove('active'));
filtersEl.querySelectorAll('button').forEach(b => {
b.classList.remove('active');
b.setAttribute('aria-pressed', 'false');
});
btn.classList.add('active');
btn.setAttribute('aria-pressed', 'true');
renderMeals();
});
filtersEl.appendChild(btn);

View file

@ -19,7 +19,7 @@
"price": 14.5,
"calories": "620",
"protein": "35g",
"description": "With onions",
"description": "Stir-fried beef tenderloin with onions, tomatoes, and soy sauce, served over french fries with a side of steamed white rice and aji amarillo. A long fixture blurb used to exercise the three-line clamp and the More expand control in accessibility tests.",
"dietary_tags": [],
"image_url": "",
"is_new": true

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import base64
import json
import re
import sys
@ -66,6 +67,50 @@ def _render(*, google: bool = False, api_key: str = "test-key") -> str:
)
def _google_credential() -> str:
payload = json.dumps(
{
"name": "Test Admin",
"email": "test-admin@example.com",
"picture": (
"data:image/svg+xml,"
"<svg xmlns='http://www.w3.org/2000/svg' width='40' height='40'/>"
),
}
).encode()
encoded = base64.urlsafe_b64encode(payload).decode().rstrip("=")
return f"e30.{encoded}.signature"
def _mock_form_routes(page):
page.route(
"**/api/form-status/**",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"status":"open"}',
),
)
page.route(
"**/api/roster**",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body=json.dumps(
{"employees": [{"name": "Ada Lovelace", "email": "ada@example.com"}]}
),
),
)
page.route(
"**/api/admin/orders",
lambda route: route.fulfill(
status=403,
content_type="application/json",
body='{"error":"Forbidden"}',
),
)
class TestGenerateFormStructural:
def test_local_and_google_render(self):
local = _render(google=False)
@ -124,6 +169,26 @@ class TestGenerateFormStructural:
local = _render(google=False)
assert "<script src=" not in local
def test_mobile_viewport_and_safe_area_clearance_are_preserved(self):
html = _render()
css = (SERVER_DIR / "templates" / "form.css").read_text()
assert (
'content="width=device-width, initial-scale=1.0, viewport-fit=cover"'
in html
)
assert "--sticky-footer-height: 80px;" in css
assert (
"padding-bottom: calc(var(--sticky-footer-height) + "
"env(safe-area-inset-bottom, 0px));"
) in css
assert (
"padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));"
) in css
assert (
"min-height: calc(var(--sticky-footer-height) + "
"env(safe-area-inset-bottom, 0px));"
) in css
def test_script_order_form_then_admin(self):
html = _render()
form_pos = html.index("FORM_JS_MARKER")
@ -141,6 +206,20 @@ class TestGenerateFormStructural:
# Static markup must not interpolate runtime data into onclick
assert not re.search(r'onclick="[^"]*\$\{', html)
def test_mobile_admin_card_structure_and_breakpoint(self):
html = _render()
css = (SERVER_DIR / "templates" / "form.css").read_text()
admin_js = (SERVER_DIR / "templates" / "admin.js").read_text()
assert 'id="admin-cards"' in html
assert 'id="admin-status-alert"' in html
assert 'id="admin-status-info"' in html
assert "@media (max-width: 767px)" in css
assert ".admin-table-wrap { display: none; }" in css
assert "buildAdminCard" in admin_js
assert "dataset.adminAction" in admin_js
assert "confirm(" in admin_js
def test_script_breakout_meal_escaped(self):
html = _render()
assert "</script><script>alert(1)</script>" not in html
@ -170,6 +249,26 @@ class TestGenerateFormStructural:
assert "data-meal-index" in html
assert "data-qty-delta" in html
def test_a11y_status_and_live_total_markup(self):
html = _render()
assert 'id="form-status-alert"' in html
assert 'id="form-status-info"' in html
assert 'id="admin-status-alert"' in html
assert 'id="admin-status-info"' in html
assert 'role="alert"' in html
assert 'role="status"' in html
assert 'aria-live="polite"' in html
assert 'id="success-heading"' in html
assert 'tabindex="-1"' in html
def test_no_alert_dialogs_in_form_scripts(self):
form_js = (SERVER_DIR / "templates" / "form.js").read_text()
admin_js = (SERVER_DIR / "templates" / "admin.js").read_text()
assert "alert(" not in form_js
assert "alert(" not in admin_js
# confirm() retained for destructive admin delete / zero-qty save.
assert "confirm(" in admin_js
def test_design_tokens_only_in_definitions(self):
css = (SERVER_DIR / "templates" / "form.css").read_text()
# Strip :root block, then assert brand/muted/border hexes are gone
@ -208,37 +307,126 @@ class TestGenerateFormPlaywright:
) from exc
try:
page = browser.new_page()
page.route(
"**/api/form-status/**",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"status":"open"}',
),
)
page.route(
"**/api/roster**",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body=json.dumps(
{
"employees": [
{
"name": "Ada Lovelace",
"email": "ada@example.com",
}
]
}
),
),
)
_mock_form_routes(page)
page.goto(out.as_uri())
page.wait_for_selector('[data-meal-index="0"]')
yield page
finally:
browser.close()
@pytest.mark.parametrize("width", [320, 375, 480])
@pytest.mark.parametrize("google", [False, True], ids=["local", "google"])
def test_sticky_footer_fits_narrow_viewports(self, tmp_path, width, google):
sync_api = pytest.importorskip("playwright.sync_api")
sync_playwright = sync_api.sync_playwright
html_path = tmp_path / f"order-form-{'google' if google else 'local'}.html"
html_path.write_text(_render(google=google, api_key=""))
with sync_playwright() as p:
try:
browser = p.chromium.launch(headless=True)
except Exception as exc:
raise RuntimeError(
"Chromium is required for form Playwright tests. "
"Run: playwright install --with-deps chromium"
) from exc
try:
page = browser.new_page(viewport={"width": width, "height": 800})
page.add_init_script(
"""window.google = {
accounts: {
id: {
initialize() {},
renderButton() {},
disableAutoSelect() {},
},
},
};"""
)
page.route(
"https://accounts.google.com/gsi/client",
lambda route: route.abort(),
)
_mock_form_routes(page)
page.goto(html_path.as_uri(), wait_until="domcontentloaded")
page.wait_for_selector('[data-meal-index="0"]', state="attached")
if google:
page.evaluate(
"(credential) => handleCredentialResponse({ credential })",
_google_credential(),
)
page.wait_for_selector('[data-meal-index="0"]', state="visible")
first_increment = page.locator(
'[data-meal-index="0"] [data-qty-delta="1"]'
)
first_increment.click()
first_increment.click()
page.wait_for_function(
"() => document.getElementById('count-display').textContent === "
"'2 meals'"
)
page.evaluate("() => document.fonts.ready")
total = page.locator("#total-display")
count = page.locator("#count-display")
submit = page.locator("#submit-btn")
assert total.inner_text() == "$10.80"
assert count.inner_text() == "2 meals"
assert total.is_visible()
assert count.is_visible()
assert submit.is_visible()
assert submit.is_enabled()
submit.focus()
assert submit.evaluate(
"(element) => element === document.activeElement"
)
page.evaluate(
"() => window.scrollTo(0, document.documentElement.scrollHeight)"
)
layout = page.evaluate(
"""() => {
const rect = (selector) => {
const box = document.querySelector(selector)
.getBoundingClientRect();
return {
left: box.left,
right: box.right,
top: box.top,
bottom: box.bottom,
};
};
const footer = document.querySelector('#sticky-footer');
return {
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
viewportHeight: window.innerHeight,
footer: {
...rect('#sticky-footer'),
position: getComputedStyle(footer).position,
},
total: rect('#total-display'),
count: rect('#count-display'),
submit: rect('#submit-btn'),
lastCard: rect('#meals-list .meal-card:last-child'),
};
}"""
)
assert layout["scrollWidth"] == layout["clientWidth"]
for selector in ("footer", "total", "count", "submit"):
assert layout[selector]["left"] >= 0
assert layout[selector]["right"] <= layout["clientWidth"]
assert layout["footer"]["position"] == "fixed"
assert layout["footer"]["top"] >= 0
assert layout["footer"]["bottom"] <= layout["viewportHeight"]
assert layout["lastCard"]["bottom"] <= layout["footer"]["top"]
finally:
browser.close()
def test_admin_bundle_loads(self, browser_page):
assert browser_page.evaluate("typeof window.loadWeekOrders") == "function"
@ -276,6 +464,111 @@ class TestGenerateFormPlaywright:
assert page.locator("#count-display").inner_text() == "2 meals"
assert page.locator("#total-display").inner_text() == "$10.80"
def test_qty_labels_present_after_init(self, browser_page):
page = browser_page
card = page.locator('[data-meal-index="0"]')
assert (
card.locator('[data-qty-delta="-1"]').get_attribute("aria-label")
== "Remove one Chicken Bowl"
)
assert (
card.locator('[data-qty-delta="1"]').get_attribute("aria-label")
== "Add one Chicken Bowl"
)
assert (
card.locator(".qty").get_attribute("aria-label")
== "Quantity of Chicken Bowl"
)
def test_filter_chip_aria_pressed(self, browser_page):
page = browser_page
all_btn = page.locator("#filters button", has_text="All Meals")
assert all_btn.get_attribute("aria-pressed") == "true"
other = page.locator("#filters button").nth(1)
other_label = other.inner_text()
other.click()
page.wait_for_function(
"""(label) => {
const active = document.querySelector('#filters button.active');
return active && active.textContent === label
&& active.getAttribute('aria-pressed') === 'true';
}""",
arg=other_label,
)
assert all_btn.get_attribute("aria-pressed") == "false"
assert other.get_attribute("aria-pressed") == "true"
all_btn.click()
page.wait_for_function(
"""() => {
const active = document.querySelector('#filters button.active');
return active && active.textContent === 'All Meals'
&& active.getAttribute('aria-pressed') === 'true';
}"""
)
def test_desc_more_only_when_overflow(self, browser_page):
page = browser_page
# Ensure All Meals filter so both short and long cards are visible.
page.locator("#filters button", has_text="All Meals").click()
page.wait_for_selector('[data-meal-index="0"]')
page.wait_for_selector('[data-meal-index="1"]')
short_card = page.locator('[data-meal-index="0"]')
long_card = page.locator('[data-meal-index="1"]')
page.wait_for_function(
"""() => {
const long = document.querySelector('[data-meal-index="1"] .meal-desc-toggle');
return !!long;
}"""
)
assert short_card.locator(".meal-desc-toggle").count() == 0
toggle = long_card.locator(".meal-desc-toggle")
assert toggle.inner_text() == "More"
assert toggle.get_attribute("aria-expanded") == "false"
toggle.click()
assert toggle.inner_text() == "Less"
assert toggle.get_attribute("aria-expanded") == "true"
assert "is-expanded" in (
long_card.locator(".meal-desc").get_attribute("class") or ""
)
def test_submit_failure_lands_in_alert_region(self, browser_page):
page = browser_page
page.route(
"**/api/submit-order**",
lambda route: route.fulfill(
status=500,
content_type="application/json",
body='{"error":"Simulated submit failure"}',
),
)
page.locator("#emp-name").select_option(label="Ada Lovelace")
page.evaluate(
"""() => {
document.querySelectorAll('[data-meal-index] .qty').forEach((input) => {
input.value = '0';
input.dispatchEvent(new Event('change', { bubbles: true }));
});
}"""
)
page.wait_for_function(
"() => document.getElementById('count-display').textContent === '0 meals'"
)
page.locator('[data-meal-index="0"] [data-qty-delta="1"]').click()
page.wait_for_function(
"() => document.getElementById('count-display').textContent === '1 meal'"
)
page.locator("#submit-btn").click()
page.wait_for_function(
"""() => document.getElementById('form-status-alert').textContent
=== 'Simulated submit failure'"""
)
assert (
page.locator("#form-status-alert").inner_text()
== "Simulated submit failure"
)
assert page.locator("#form-status-info").inner_text() == ""
page.unroute("**/api/submit-order**")
def test_qty_typein_clamps_negative(self, browser_page):
page = browser_page
# Reset first card via type-in
@ -292,3 +585,399 @@ class TestGenerateFormPlaywright:
"() => document.getElementById('count-display').textContent === '2 meals'"
)
assert page.locator("#total-display").inner_text() == "$10.80"
@pytest.mark.parametrize("width", [320, 375, 479])
def test_local_roster_has_no_horizontal_overflow(self, browser_page, width):
page = browser_page
page.set_viewport_size({"width": width, "height": 800})
dimensions = page.evaluate(
"""() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
})"""
)
assert dimensions["scrollWidth"] == dimensions["clientWidth"]
@pytest.fixture
def mobile_admin_page(self, tmp_path, browser_page):
html = _render(google=False, api_key="")
out = tmp_path / "admin-order-form.html"
out.write_text(html)
hostile_name = """O'Reilly "<b>Ops</b>\""""
state = {
"fail_put": False,
"dialogs": [],
"orders": [
{
"employee_name": hostile_name,
"employee_email": "hostile@example.com",
"submitted_at": "2026-07-30T14:15:00Z",
"items": [
{
"name": "Chicken Bowl",
"quantity": 1,
"subtotal": 12.0,
}
],
"total": 12.0,
}
],
}
prices = {
"Chicken Bowl": 12.0,
"Luis' Lomo Saltado": 14.5,
'Meal with "quotes"': 11.0,
"</script><script>alert(1)</script>": 9.0,
}
def admin_payload():
return {
"week": "2026-W30",
"orders": state["orders"],
"total_employees": len(state["orders"]),
"grand_total": sum(order["total"] for order in state["orders"]),
}
def handle_admin(route):
request = route.request
if request.method == "PUT":
if state["fail_put"]:
route.fulfill(
status=500,
content_type="application/json",
body='{"error":"hostile update failed"}',
)
return
payload = request.post_data_json
order = state["orders"][0]
order["items"] = [
{
"name": item["name"],
"quantity": item["quantity"],
"subtotal": prices[item["name"]] * item["quantity"],
}
for item in payload["items"]
]
order["total"] = sum(item["subtotal"] for item in order["items"])
route.fulfill(
status=200,
content_type="application/json",
body='{"ok":true}',
)
return
if request.method == "DELETE":
state["orders"] = []
route.fulfill(
status=200,
content_type="application/json",
body='{"ok":true}',
)
return
route.fulfill(
status=200,
content_type="application/json",
body=json.dumps(admin_payload()),
)
browser = browser_page.context.browser
assert browser is not None
context = browser.new_context(
viewport={"width": 375, "height": 800},
has_touch=True,
is_mobile=True,
)
try:
page = context.new_page()
page.route(
"**/api/form-status/**",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"status":"open"}',
),
)
page.route(
"**/api/roster**",
lambda route: route.fulfill(
status=200,
content_type="application/json",
body='{"employees":[]}',
),
)
page.route("**/api/admin/orders**", handle_admin)
page.on(
"dialog",
lambda dialog: (
state["dialogs"].append(dialog.message),
dialog.accept()
if state.get("accept_dialogs", True)
else dialog.dismiss(),
),
)
page.goto(out.as_uri())
page.evaluate(
"() => { window.showAdmin(); window.loadWeekOrders('2026-W30'); }"
)
page.wait_for_selector(".admin-order-card")
yield page, state, hostile_name
finally:
context.close()
def test_mobile_admin_card_initial_render_and_overflow(self, mobile_admin_page):
page, _, hostile_name = mobile_admin_page
card = page.locator(".admin-order-card")
assert card.is_visible()
assert not page.locator(".admin-table-wrap").is_visible()
assert hostile_name in card.inner_text()
assert "hostile@example.com" in card.inner_text()
assert "Chicken Bowl ×1" in card.inner_text() # noqa: RUF001
assert "$12.00" in card.inner_text()
assert page.evaluate(
"() => document.documentElement.scrollWidth === "
"document.documentElement.clientWidth"
)
def test_mobile_admin_card_edit_and_save(self, mobile_admin_page):
page, state, hostile_name = mobile_admin_page
card = page.locator(".admin-order-card")
card.locator('[data-admin-action="start-edit"]').click()
page.wait_for_selector('.admin-order-card [data-admin-action="save-edit"]')
minus = page.locator(
'.admin-order-card [data-admin-action="edit-qty"][data-delta="-1"]'
)
assert minus.get_attribute("aria-label") == "Remove one Chicken Bowl"
assert minus.evaluate("(el) => el.getBoundingClientRect().height") >= 44
add_item = page.locator(
'.admin-order-card [data-admin-action="add-item-select"]'
)
add_item.select_option(label="Luis' Lomo Saltado ($14.50)")
page.wait_for_selector(
'.admin-order-card [data-meal-name="Luis\\\' Lomo Saltado"]'
)
page.locator(
'.admin-order-card [data-meal-name="Luis\\\' Lomo Saltado"][data-delta="1"]'
).click()
page.locator('.admin-order-card [data-admin-action="save-edit"]').click()
page.wait_for_function(
"() => document.querySelector('.admin-order-card')"
'.textContent.includes("Luis\' Lomo Saltado ×2")' # noqa: RUF001
)
assert state["orders"][0]["employee_name"] == hostile_name
assert state["orders"][0]["total"] == 41.0
def test_mobile_admin_card_cancel(self, mobile_admin_page):
page, _, _ = mobile_admin_page
page.locator('.admin-order-card [data-admin-action="start-edit"]').click()
page.wait_for_selector('.admin-order-card [data-admin-action="cancel-edit"]')
page.locator(
'.admin-order-card [data-admin-action="edit-qty"][data-delta="1"]'
).first.click()
page.locator('.admin-order-card [data-admin-action="cancel-edit"]').click()
assert page.locator(
'.admin-order-card [data-admin-action="start-edit"]'
).is_visible()
def test_mobile_admin_card_responsive_breakpoints(self, mobile_admin_page):
page, _, _ = mobile_admin_page
for width in (320, 375, 767):
page.set_viewport_size({"width": width, "height": 800})
assert page.locator(".admin-order-card").is_visible()
assert page.evaluate(
"() => document.documentElement.scrollWidth === "
"document.documentElement.clientWidth"
)
assert page.evaluate(
"""() => Array.from(document.querySelectorAll('#admin-panel *'))
.filter((el) => el.offsetParent !== null)
.every((el) => el.scrollWidth <= el.clientWidth + 1)"""
)
page.set_viewport_size({"width": 768, "height": 800})
assert page.locator(".admin-table-wrap").is_visible()
assert not page.locator(".admin-order-card").is_visible()
def test_mobile_admin_card_save_failure_and_retry(self, mobile_admin_page):
page, state, _ = mobile_admin_page
state["fail_put"] = True
page.locator('.admin-order-card [data-admin-action="start-edit"]').click()
page.wait_for_selector('.admin-order-card [data-admin-action="save-edit"]')
page.locator('.admin-order-card [data-admin-action="save-edit"]').click()
page.wait_for_function(
"() => document.getElementById('admin-status-alert').textContent"
".includes('hostile update failed')"
)
state["fail_put"] = False
page.locator('.admin-order-card [data-admin-action="save-edit"]').click()
page.wait_for_selector('.admin-order-card [data-admin-action="start-edit"]')
assert page.locator("#admin-status-alert").inner_text() == ""
def test_mobile_admin_card_delete(self, mobile_admin_page):
page, state, hostile_name = mobile_admin_page
orders_before = list(state["orders"])
state["accept_dialogs"] = False
page.locator('.admin-order-card [data-admin-action="delete"]').click()
assert page.locator(".admin-order-card").is_visible()
assert state["orders"] == orders_before
state["accept_dialogs"] = True
page.locator('.admin-order-card [data-admin-action="delete"]').click()
page.wait_for_selector("#admin-empty:not(.is-hidden)")
assert hostile_name in state["dialogs"][-1]
assert state["orders"] == []
class TestGenerateFormGooglePlaywright:
@pytest.fixture(scope="class")
@classmethod
def signed_in_admin_page(cls, tmp_path_factory):
try:
from playwright.sync_api import sync_playwright
except ImportError as exc: # pragma: no cover
raise RuntimeError(
"playwright package is required; install requirements.txt"
) from exc
out = tmp_path_factory.mktemp("google-form") / "order-form.html"
out.write_text(_render(google=True, api_key=""))
with sync_playwright() as p:
try:
browser = p.chromium.launch(headless=True)
except Exception as exc:
raise RuntimeError(
"Chromium is required for form Playwright tests. "
"Run: playwright install --with-deps chromium"
) from exc
try:
page = browser.new_page()
page.add_init_script(
"""window.google = {
accounts: {
id: {
initialize() {},
renderButton() {},
disableAutoSelect() {},
},
},
};"""
)
page.route(
"https://accounts.google.com/gsi/client",
lambda route: route.abort(),
)
def handle_api(route):
if "/api/form-status/" in route.request.url:
route.fulfill(
status=200,
content_type="application/json",
body='{"status":"open"}',
)
elif "/api/admin/orders" in route.request.url:
route.fulfill(
status=200,
content_type="application/json",
body='{"weeks":["2026-W30"]}',
)
else:
route.fulfill(
status=404,
content_type="application/json",
body="{}",
)
page.route("https://api.example.com/**", handle_api)
page.goto(out.as_uri(), wait_until="domcontentloaded")
page.evaluate(
"(credential) => handleCredentialResponse({ credential })",
_google_credential(),
)
page.locator("#admin-btn").wait_for(state="visible")
yield page
finally:
browser.close()
@pytest.mark.parametrize("width", [320, 375])
def test_signed_in_admin_user_bar_fits_phone_width(
self, signed_in_admin_page, width
):
page = signed_in_admin_page
page.set_viewport_size({"width": width, "height": 800})
for selector in (
"#user-avatar",
"#user-name",
"#user-email",
"#admin-btn",
"#sign-out-btn",
):
locator = page.locator(selector)
assert locator.is_visible()
bounds = locator.evaluate(
"""element => {
const rect = element.getBoundingClientRect();
return {
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
viewportWidth: window.innerWidth,
viewportHeight: window.innerHeight,
};
}"""
)
assert bounds["left"] >= 0
assert bounds["top"] >= 0
assert bounds["right"] <= bounds["viewportWidth"]
assert bounds["bottom"] <= bounds["viewportHeight"]
for selector in ("#user-name", "#user-email"):
assert page.locator(selector).evaluate(
"""element =>
element.scrollWidth <= element.clientWidth &&
element.scrollHeight <= element.clientHeight"""
)
assert page.locator("#admin-btn").is_enabled()
assert page.locator("#sign-out-btn").is_enabled()
dimensions = page.evaluate(
"""() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
})"""
)
assert dimensions["scrollWidth"] == dimensions["clientWidth"]
def test_user_bar_stays_single_row_at_480(self, signed_in_admin_page):
page = signed_in_admin_page
page.set_viewport_size({"width": 480, "height": 800})
assert (
page.locator(".user-bar").evaluate(
"(element) => getComputedStyle(element).flexWrap"
)
== "nowrap"
)
dimensions = page.evaluate(
"""() => ({
scrollWidth: document.documentElement.scrollWidth,
clientWidth: document.documentElement.clientWidth,
})"""
)
assert dimensions["scrollWidth"] == dimensions["clientWidth"]
def test_user_bar_wraps_below_shared_mobile_breakpoint(self, signed_in_admin_page):
page = signed_in_admin_page
page.set_viewport_size({"width": 479, "height": 800})
assert (
page.locator(".user-bar").evaluate(
"(element) => getComputedStyle(element).flexWrap"
)
== "wrap"
)