forked from Rockachopa/Timmy-time-dashboard
423 lines
14 KiB
HTML
423 lines
14 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Mobile Test — Timmy Time{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="container-fluid mc-content" style="height:auto; overflow:visible;">
|
|
|
|
<!-- ── Page header ─────────────────────────────────────────────────── -->
|
|
<div class="mt-hitl-header">
|
|
<div>
|
|
<span class="mt-title">// MOBILE TEST SUITE</span>
|
|
<span class="mt-sub">HUMAN-IN-THE-LOOP</span>
|
|
</div>
|
|
<div class="mt-score-wrap">
|
|
<span class="mt-score" id="score-display">0 / {{ total }}</span>
|
|
<span class="mt-score-label">PASSED</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Progress bar ────────────────────────────────────────────────── -->
|
|
<div class="mt-progress-wrap">
|
|
<div class="progress" style="height:6px; background:var(--bg-card); border-radius:3px;">
|
|
<div class="progress-bar mt-progress-bar"
|
|
id="progress-bar"
|
|
role="progressbar"
|
|
style="width:0%; background:var(--green);"
|
|
aria-valuenow="0" aria-valuemin="0" aria-valuemax="{{ total }}"></div>
|
|
</div>
|
|
<div class="mt-progress-legend">
|
|
<span><span class="mt-dot green"></span>PASS</span>
|
|
<span><span class="mt-dot red"></span>FAIL</span>
|
|
<span><span class="mt-dot amber"></span>SKIP</span>
|
|
<span><span class="mt-dot" style="background:var(--text-dim);box-shadow:none;"></span>PENDING</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ── Reset / Back ────────────────────────────────────────────────── -->
|
|
<div class="mt-actions">
|
|
<a href="/" class="mc-btn-clear">← MISSION CONTROL</a>
|
|
<button class="mc-btn-clear" onclick="resetAll()" style="border-color:var(--red);color:var(--red);">RESET ALL</button>
|
|
</div>
|
|
|
|
<!-- ── Scenario cards ──────────────────────────────────────────────── -->
|
|
{% for category, items in categories.items() %}
|
|
<div class="mt-category-label">{{ category | upper }}</div>
|
|
|
|
{% for s in items %}
|
|
<div class="card mc-panel mt-card" id="card-{{ s.id }}" data-scenario="{{ s.id }}">
|
|
<div class="card-header mc-panel-header d-flex justify-content-between align-items-center">
|
|
<div>
|
|
<span class="mt-id-badge" id="badge-{{ s.id }}">{{ s.id }}</span>
|
|
<span class="mt-scenario-title">{{ s.title }}</span>
|
|
</div>
|
|
<span class="mt-state-chip" id="chip-{{ s.id }}">PENDING</span>
|
|
</div>
|
|
<div class="card-body p-3">
|
|
|
|
<div class="mt-steps-label">STEPS</div>
|
|
<ol class="mt-steps">
|
|
{% for step in s.steps %}
|
|
<li>{{ step }}</li>
|
|
{% endfor %}
|
|
</ol>
|
|
|
|
<div class="mt-expected-label">EXPECTED</div>
|
|
<div class="mt-expected">{{ s.expected }}</div>
|
|
|
|
<div class="mt-btn-row">
|
|
<button class="mt-btn mt-btn-pass" onclick="mark('{{ s.id }}', 'pass')">✓ PASS</button>
|
|
<button class="mt-btn mt-btn-fail" onclick="mark('{{ s.id }}', 'fail')">✗ FAIL</button>
|
|
<button class="mt-btn mt-btn-skip" onclick="mark('{{ s.id }}', 'skip')">— SKIP</button>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
{% endfor %}
|
|
|
|
<!-- ── Summary footer ──────────────────────────────────────────────── -->
|
|
<div class="card mc-panel mt-summary" id="summary">
|
|
<div class="card-header mc-panel-header">// SUMMARY</div>
|
|
<div class="card-body p-3" id="summary-body">
|
|
<p class="mt-summary-hint">Mark all scenarios above to see your final score.</p>
|
|
</div>
|
|
</div>
|
|
|
|
</div><!-- /container -->
|
|
|
|
|
|
<!-- ── Styles (scoped to this page) ────────────────────────────────────── -->
|
|
<style>
|
|
.mt-hitl-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-end;
|
|
padding: 16px 0 12px;
|
|
border-bottom: 1px solid var(--border);
|
|
margin-bottom: 12px;
|
|
}
|
|
.mt-title {
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
color: var(--text-bright);
|
|
letter-spacing: 0.18em;
|
|
display: block;
|
|
}
|
|
.mt-sub {
|
|
font-size: 10px;
|
|
color: var(--text-dim);
|
|
letter-spacing: 0.2em;
|
|
display: block;
|
|
margin-top: 2px;
|
|
}
|
|
.mt-score-wrap { text-align: right; }
|
|
.mt-score {
|
|
font-size: 22px;
|
|
font-weight: 700;
|
|
color: var(--green);
|
|
letter-spacing: 0.06em;
|
|
display: block;
|
|
}
|
|
.mt-score-label { font-size: 9px; color: var(--text-dim); letter-spacing: 0.2em; }
|
|
|
|
.mt-progress-wrap { margin-bottom: 10px; }
|
|
.mt-progress-legend {
|
|
display: flex;
|
|
gap: 16px;
|
|
font-size: 9px;
|
|
color: var(--text-dim);
|
|
letter-spacing: 0.12em;
|
|
margin-top: 6px;
|
|
}
|
|
.mt-dot {
|
|
display: inline-block;
|
|
width: 7px;
|
|
height: 7px;
|
|
border-radius: 50%;
|
|
margin-right: 4px;
|
|
vertical-align: middle;
|
|
}
|
|
.mt-dot.green { background: var(--green); box-shadow: 0 0 5px var(--green); }
|
|
.mt-dot.red { background: var(--red); box-shadow: 0 0 5px var(--red); }
|
|
.mt-dot.amber { background: var(--amber); box-shadow: 0 0 5px var(--amber); }
|
|
|
|
.mt-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.mt-category-label {
|
|
font-size: 9px;
|
|
font-weight: 700;
|
|
color: var(--text-dim);
|
|
letter-spacing: 0.25em;
|
|
margin: 20px 0 8px;
|
|
padding-left: 2px;
|
|
}
|
|
|
|
.mt-card {
|
|
margin-bottom: 10px;
|
|
transition: border-color 0.2s;
|
|
}
|
|
.mt-card.state-pass { border-color: var(--green) !important; }
|
|
.mt-card.state-fail { border-color: var(--red) !important; }
|
|
.mt-card.state-skip { border-color: var(--amber) !important; opacity: 0.7; }
|
|
|
|
.mt-id-badge {
|
|
font-size: 9px;
|
|
font-weight: 700;
|
|
background: var(--border);
|
|
color: var(--text-dim);
|
|
border-radius: 2px;
|
|
padding: 2px 6px;
|
|
letter-spacing: 0.12em;
|
|
margin-right: 8px;
|
|
}
|
|
.mt-card.state-pass .mt-id-badge { background: var(--green-dim); color: var(--green); }
|
|
.mt-card.state-fail .mt-id-badge { background: var(--red-dim); color: var(--red); }
|
|
.mt-card.state-skip .mt-id-badge { background: var(--amber-dim); color: var(--amber); }
|
|
|
|
.mt-scenario-title {
|
|
font-size: 12px;
|
|
font-weight: 700;
|
|
color: var(--text-bright);
|
|
letter-spacing: 0.05em;
|
|
}
|
|
|
|
.mt-state-chip {
|
|
font-size: 9px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.15em;
|
|
color: var(--text-dim);
|
|
padding: 2px 8px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 2px;
|
|
white-space: nowrap;
|
|
}
|
|
.mt-card.state-pass .mt-state-chip { color: var(--green); border-color: var(--green); }
|
|
.mt-card.state-fail .mt-state-chip { color: var(--red); border-color: var(--red); }
|
|
.mt-card.state-skip .mt-state-chip { color: var(--amber); border-color: var(--amber); }
|
|
|
|
.mt-steps-label, .mt-expected-label {
|
|
font-size: 9px;
|
|
font-weight: 700;
|
|
color: var(--text-dim);
|
|
letter-spacing: 0.2em;
|
|
margin-bottom: 6px;
|
|
}
|
|
.mt-expected-label { margin-top: 12px; }
|
|
|
|
.mt-steps {
|
|
padding-left: 18px;
|
|
margin: 0;
|
|
font-size: 12px;
|
|
line-height: 1.8;
|
|
color: var(--text);
|
|
}
|
|
.mt-expected {
|
|
font-size: 12px;
|
|
line-height: 1.65;
|
|
color: var(--text-bright);
|
|
background: var(--bg-card);
|
|
border-left: 3px solid var(--border-glow);
|
|
padding: 8px 12px;
|
|
border-radius: 0 3px 3px 0;
|
|
}
|
|
|
|
.mt-btn-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-top: 14px;
|
|
}
|
|
.mt-btn {
|
|
flex: 1;
|
|
min-height: 44px;
|
|
border: 1px solid var(--border);
|
|
border-radius: 3px;
|
|
background: var(--bg-deep);
|
|
color: var(--text-dim);
|
|
font-family: var(--font);
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
letter-spacing: 0.12em;
|
|
cursor: pointer;
|
|
touch-action: manipulation;
|
|
transition: background 0.15s, color 0.15s, border-color 0.15s;
|
|
}
|
|
.mt-btn-pass:hover, .mt-btn-pass.active { background: var(--green-dim); color: var(--green); border-color: var(--green); }
|
|
.mt-btn-fail:hover, .mt-btn-fail.active { background: var(--red-dim); color: var(--red); border-color: var(--red); }
|
|
.mt-btn-skip:hover, .mt-btn-skip.active { background: var(--amber-dim); color: var(--amber); border-color: var(--amber); }
|
|
|
|
.mt-summary { margin-top: 24px; margin-bottom: 32px; }
|
|
.mt-summary-hint { color: var(--text-dim); font-size: 12px; margin: 0; }
|
|
|
|
.mt-summary-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid var(--border);
|
|
font-size: 12px;
|
|
}
|
|
.mt-summary-row:last-child { border-bottom: none; }
|
|
.mt-summary-score { font-size: 28px; font-weight: 700; color: var(--green); margin: 12px 0 4px; }
|
|
.mt-summary-pct { font-size: 13px; color: var(--text-dim); }
|
|
|
|
@media (max-width: 768px) {
|
|
.mt-btn-row { gap: 6px; }
|
|
.mt-btn { font-size: 10px; padding: 0 4px; }
|
|
}
|
|
</style>
|
|
|
|
|
|
<!-- ── HITL State Machine (sessionStorage) ─────────────────────────────── -->
|
|
<script>
|
|
const TOTAL = {{ total }};
|
|
const KEY = "timmy-mobile-test-results";
|
|
|
|
function loadResults() {
|
|
try { return JSON.parse(sessionStorage.getItem(KEY) || "{}"); }
|
|
catch { return {}; }
|
|
}
|
|
function saveResults(r) {
|
|
sessionStorage.setItem(KEY, JSON.stringify(r));
|
|
}
|
|
|
|
function mark(id, state) {
|
|
const results = loadResults();
|
|
results[id] = state;
|
|
saveResults(results);
|
|
applyState(id, state);
|
|
updateScore(results);
|
|
updateSummary(results);
|
|
}
|
|
|
|
function applyState(id, state) {
|
|
const card = document.getElementById("card-" + id);
|
|
const chip = document.getElementById("chip-" + id);
|
|
const labels = { pass: "PASS", fail: "FAIL", skip: "SKIP" };
|
|
|
|
card.classList.remove("state-pass", "state-fail", "state-skip");
|
|
if (state) card.classList.add("state-" + state);
|
|
chip.textContent = state ? labels[state] : "PENDING";
|
|
|
|
// highlight active button
|
|
card.querySelectorAll(".mt-btn").forEach(btn => btn.classList.remove("active"));
|
|
const activeBtn = card.querySelector(".mt-btn-" + state);
|
|
if (activeBtn) activeBtn.classList.add("active");
|
|
}
|
|
|
|
function updateScore(results) {
|
|
const passed = Object.values(results).filter(v => v === "pass").length;
|
|
const decided = Object.values(results).filter(v => v !== undefined).length;
|
|
document.getElementById("score-display").textContent = passed + " / " + TOTAL;
|
|
|
|
const pct = TOTAL ? (decided / TOTAL) * 100 : 0;
|
|
const bar = document.getElementById("progress-bar");
|
|
bar.style.width = pct + "%";
|
|
|
|
// colour the bar by overall health
|
|
const failCount = Object.values(results).filter(v => v === "fail").length;
|
|
bar.style.background = failCount > 0
|
|
? "var(--red)"
|
|
: passed === TOTAL ? "var(--green)" : "var(--amber)";
|
|
}
|
|
|
|
function updateSummary(results) {
|
|
const passed = Object.values(results).filter(v => v === "pass").length;
|
|
const failed = Object.values(results).filter(v => v === "fail").length;
|
|
const skipped = Object.values(results).filter(v => v === "skip").length;
|
|
const decided = passed + failed + skipped;
|
|
const summaryBody = document.getElementById("summary-body");
|
|
|
|
if (decided < TOTAL) {
|
|
summaryBody.innerHTML = '';
|
|
const p = document.createElement('p');
|
|
p.className = 'mt-summary-hint';
|
|
p.textContent = (TOTAL - decided) + ' scenario(s) still pending.';
|
|
summaryBody.appendChild(p);
|
|
return;
|
|
}
|
|
|
|
const pct = TOTAL ? Math.round((passed / TOTAL) * 100) : 0;
|
|
const color = failed > 0 ? "var(--red)" : "var(--green)";
|
|
|
|
// Safely build summary UI using DOM API to avoid XSS from potentially untrusted variables
|
|
summaryBody.innerHTML = '';
|
|
|
|
const scoreDiv = document.createElement('div');
|
|
scoreDiv.className = 'mt-summary-score';
|
|
scoreDiv.style.color = color;
|
|
scoreDiv.textContent = passed + ' / ' + TOTAL;
|
|
summaryBody.appendChild(scoreDiv);
|
|
|
|
const pctDiv = document.createElement('div');
|
|
pctDiv.className = 'mt-summary-pct';
|
|
pctDiv.textContent = pct + '% pass rate';
|
|
summaryBody.appendChild(pctDiv);
|
|
|
|
const statsContainer = document.createElement('div');
|
|
statsContainer.style.marginTop = '16px';
|
|
|
|
const createRow = (label, value, colorVar) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'mt-summary-row';
|
|
const labelSpan = document.createElement('span');
|
|
labelSpan.textContent = label;
|
|
const valSpan = document.createElement('span');
|
|
valSpan.style.color = 'var(--' + colorVar + ')';
|
|
valSpan.style.fontWeight = '700';
|
|
valSpan.textContent = value;
|
|
row.appendChild(labelSpan);
|
|
row.appendChild(valSpan);
|
|
return row;
|
|
};
|
|
|
|
statsContainer.appendChild(createRow('PASSED', passed, 'green'));
|
|
statsContainer.appendChild(createRow('FAILED', failed, 'red'));
|
|
statsContainer.appendChild(createRow('SKIPPED', skipped, 'amber'));
|
|
summaryBody.appendChild(statsContainer);
|
|
|
|
const statusMsg = document.createElement('p');
|
|
statusMsg.style.marginTop = '12px';
|
|
statusMsg.style.fontSize = '11px';
|
|
if (failed > 0) {
|
|
statusMsg.style.color = 'var(--red)';
|
|
statusMsg.textContent = '⚠ ' + failed + ' failure(s) need attention before release.';
|
|
} else {
|
|
statusMsg.style.color = 'var(--green)';
|
|
statusMsg.textContent = 'All tested scenarios passed — ship it.';
|
|
}
|
|
summaryBody.appendChild(statusMsg);
|
|
}
|
|
|
|
function resetAll() {
|
|
if (!confirm("Reset all test results?")) return;
|
|
sessionStorage.removeItem(KEY);
|
|
const results = {};
|
|
document.querySelectorAll("[data-scenario]").forEach(card => {
|
|
const id = card.dataset.scenario;
|
|
applyState(id, null);
|
|
});
|
|
updateScore(results);
|
|
|
|
const summaryBody = document.getElementById("summary-body");
|
|
summaryBody.innerHTML = '';
|
|
const p = document.createElement('p');
|
|
p.className = 'mt-summary-hint';
|
|
p.textContent = 'Mark all scenarios above to see your final score.';
|
|
summaryBody.appendChild(p);
|
|
}
|
|
|
|
// Restore saved state on load
|
|
(function init() {
|
|
const results = loadResults();
|
|
Object.entries(results).forEach(([id, state]) => applyState(id, state));
|
|
updateScore(results);
|
|
updateSummary(results);
|
|
})();
|
|
</script>
|
|
|
|
{% endblock %}
|