WA Exam Prep v2: 318 questions (249 original + 69 EM019 manual), timed 50-Q mock exam mode with 120-min timer + pass/fail review
This commit is contained in:
206
rebuild_wa_prep.py
Normal file
206
rebuild_wa_prep.py
Normal file
@@ -0,0 +1,206 @@
|
||||
import json, sys, re
|
||||
|
||||
p = "/Users/drjones/pest-nocturne/index.html"
|
||||
s = open(p).read()
|
||||
|
||||
# build merged question bank
|
||||
sys.path.insert(0, "/Users/drjones/pest-nocturne")
|
||||
import wa_exam_data2 as d2
|
||||
WA = []
|
||||
for cat in ["Core", "Structural", "Ornamental & Turf", "Agricultural"]:
|
||||
for q, a, c, x in d2.ALL[cat]:
|
||||
WA.append({"cat": cat, "q": q, "a": a, "c": c, "x": x})
|
||||
|
||||
# add EM019 manual questions
|
||||
EM = json.load(open("/tmp/em019_exam.json"))
|
||||
ALLQ = WA + EM
|
||||
|
||||
print(f"WA original: {len(WA)}, EM019 manual: {len(EM)}, total: {len(ALLQ)}")
|
||||
|
||||
# remove the old WA Prep block (from its <script> to EOF)
|
||||
marker = "<script>\n/* ==== WA PREP"
|
||||
idx = s.find(marker)
|
||||
if idx == -1:
|
||||
marker2 = "/* ==== WA PREP"
|
||||
idx = s.find(marker2)
|
||||
# back up to the preceding <script>
|
||||
idx = s.rfind("<script>", 0, idx)
|
||||
assert idx != -1, "old WA Prep block not found"
|
||||
s = s[:idx].rstrip() + "\n"
|
||||
|
||||
data_json = json.dumps(ALLQ)
|
||||
|
||||
RENDER = r'''
|
||||
<script>
|
||||
/* ==== WA PREP — original question bank + mock exam ==== */
|
||||
const WAEXAM = __WADATA__;
|
||||
|
||||
(function(){
|
||||
const CATS = ['All','Core','Structural','Ornamental & Turf','Agricultural','EM019 Manual'];
|
||||
let mode = 'practice'; // 'practice' | 'mock'
|
||||
let curCat = 'All', qi = 0, score = 0, answered = 0, qlist = [];
|
||||
let mock = { qs: [], answers: [], secs: 0, timer: null };
|
||||
|
||||
function pick(n, arr){ return arr.map((x,i)=>({...x,_r:Math.random()})).sort((a,b)=>a._r-b._r).slice(0,n); }
|
||||
function buildList(){
|
||||
qlist = curCat === 'All' ? ALLQ.slice() : ALLQ.filter(q=>q.cat===curCat);
|
||||
qlist = qlist.map((x,i)=>({...x,_r:Math.random()})).sort((a,b)=>a._r-b._r);
|
||||
qi = 0; score = 0; answered = 0;
|
||||
}
|
||||
|
||||
function fmt(secs){ const m=Math.floor(secs/60), ss=secs%60; return m+':'+String(ss).padStart(2,'0'); }
|
||||
|
||||
function render(){
|
||||
const root = document.getElementById('v-waprep');
|
||||
const cats = CATS.map(c=>`<button class="wac${c===curCat?' on':''}" data-wacat="${c}">${c}</button>`).join('');
|
||||
let body = '';
|
||||
|
||||
if (mode === 'mock') {
|
||||
const total = mock.qs.length;
|
||||
if (mock.submitted) {
|
||||
const pct = Math.round(mock.score/total*100);
|
||||
body = `<div class="wa-res"><div class="wa-big">${mock.score} / ${total}</div>
|
||||
<div class="wa-pct ${pct>=70?'pass':'fail'}">${pct}% — ${pct>=70?'PASS':'NOT PASSING (70% needed)'}</div>
|
||||
<p class="lede">Time used: ${fmt(mock.secs)} (of 120:00). Review below.</p></div>
|
||||
${mock.qs.map((q,i)=>{
|
||||
const u = mock.answers[i];
|
||||
const ok = u === q.c;
|
||||
return `<div class="wa-rev ${ok?'ok':'bad'}">
|
||||
<div class="wa-rq">${i+1}. ${q.q}</div>
|
||||
<div class="wa-ro">Your answer: <b>${u==null?'(skipped)':String.fromCharCode(65+u)+'. '+q.a[u]}</b></div>
|
||||
${ok?'':`<div class="wa-ro">Correct: <b>${String.fromCharCode(65+q.c)+'. '+q.a[q.c]}</b></div>`}
|
||||
<div class="wa-expl" style="display:block">${q.x}</div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
<div class="wa-nav" style="display:block;text-align:center"><button class="btn" id="wa-again">New Mock Exam</button></div>`;
|
||||
} else {
|
||||
const q = mock.qs[qi];
|
||||
body = `<div class="wa-prog"><span>Question ${qi+1} / ${total}</span><span class="wa-timer" id="wa-timer">${fmt(mock.secs)}</span></div>
|
||||
<div class="wa-q">${q.q}</div>
|
||||
<div class="wa-opts">${q.a.map((opt,i)=>`<button class="wa-opt${mock.answers[qi]===i?' sel':''}" data-i="${i}">${String.fromCharCode(65+i)}. ${opt}</button>`).join('')}</div>
|
||||
<div class="wa-nav" style="display:flex;justify-content:space-between;align-items:center">
|
||||
<button class="btn ghost" id="wa-prev" ${qi===0?'disabled':''}>← Back</button>
|
||||
${qi+1<total?'<button class="btn" id="wa-next2">Next →</button>':'<button class="btn" id="wa-submit">Submit Exam</button>'}
|
||||
</div>`;
|
||||
}
|
||||
} else {
|
||||
const total = qlist.length;
|
||||
const q = qlist[qi];
|
||||
body = `
|
||||
<div class="wa-prog"><span>${Math.min(answered+1, total)} / ${total}</span><span class="wa-score">Score ${score}/${answered||1}</span></div>
|
||||
<div class="wa-q">${q.q}</div>
|
||||
<div class="wa-opts">${q.a.map((opt, i)=>`<button class="wa-opt" data-i="${i}">${String.fromCharCode(65+i)}. ${opt}</button>`).join('')}</div>
|
||||
<div class="wa-expl" id="wa-expl" style="display:none"></div>
|
||||
<div class="wa-nav" style="display:none"><button class="btn" id="wa-next">${qi+1 < total ? 'Next →' : 'Restart'}</button></div>`;
|
||||
}
|
||||
|
||||
root.innerHTML = `
|
||||
<section class="hero" style="padding-top:26px">
|
||||
<div class="eyebrow">${ALLQ.length} questions · original + the manual's own exam · not scraped</div>
|
||||
<h1><span class="lo">Pass the license</span>WA<br><em>Exam Prep</em></h1>
|
||||
<p class="lede">Practice by category with instant feedback, or sit a timed 50-question mock exam (120 minutes, 70% to pass) built from the full bank.</p>
|
||||
<div class="wa-cats">${cats}</div>
|
||||
<div style="margin-top:14px">
|
||||
<button class="btn" id="wa-mode">${mode==='practice'?'Start Mock Exam (50 Q · 120 min)':'Back to Practice'}</button>
|
||||
</div>
|
||||
</section>
|
||||
<section class="section" style="padding-top:10px">
|
||||
<div class="wa-card">${body}</div>
|
||||
</section>`;
|
||||
|
||||
// wire category buttons
|
||||
root.querySelectorAll('[data-wacat]').forEach(b=>b.addEventListener('click', ()=>{ curCat = b.dataset.wacat; buildList(); render(); }));
|
||||
const modeBtn = root.querySelector('#wa-mode');
|
||||
if (modeBtn) modeBtn.addEventListener('click', ()=>{
|
||||
if (mode === 'practice') { startMock(); } else { mode = 'practice'; buildList(); render(); }
|
||||
});
|
||||
|
||||
if (mode === 'practice') {
|
||||
root.querySelectorAll('.wa-opt').forEach(b=>b.addEventListener('click', ()=>{
|
||||
const i = +b.dataset.i, q = qlist[qi];
|
||||
const correct = i === q.c;
|
||||
if (correct) score++;
|
||||
answered++;
|
||||
root.querySelectorAll('.wa-opt').forEach(o=>{ o.disabled = true; if (+o.dataset.i === q.c) o.classList.add('right'); });
|
||||
if (!correct) b.classList.add('wrong');
|
||||
const expl = root.querySelector('#wa-expl');
|
||||
expl.style.display = 'block';
|
||||
expl.innerHTML = `<strong>${correct?'Correct':'Incorrect'}.</strong> ${q.x}`;
|
||||
root.querySelector('.wa-nav').style.display = 'block';
|
||||
root.querySelector('.wa-score').textContent = `Score ${score}/${answered}`;
|
||||
}));
|
||||
const nx = root.querySelector('#wa-next');
|
||||
if (nx) nx.addEventListener('click', ()=>{ if (qi+1 < qlist.length) qi++; else buildList(); render(); });
|
||||
} else if (!mock.submitted) {
|
||||
root.querySelectorAll('.wa-opt').forEach(b=>b.addEventListener('click', ()=>{
|
||||
mock.answers[qi] = +b.dataset.i;
|
||||
render();
|
||||
}));
|
||||
const prev = root.querySelector('#wa-prev');
|
||||
if (prev) prev.addEventListener('click', ()=>{ if (qi>0){ qi--; render(); } });
|
||||
const n2 = root.querySelector('#wa-next2');
|
||||
if (n2) n2.addEventListener('click', ()=>{ qi++; render(); });
|
||||
const sub = root.querySelector('#wa-submit');
|
||||
if (sub) sub.addEventListener('click', ()=>{ submitMock(); });
|
||||
}
|
||||
const again = root.querySelector('#wa-again');
|
||||
if (again) again.addEventListener('click', ()=>{ startMock(); });
|
||||
}
|
||||
|
||||
function startMock(){
|
||||
mode = 'mock';
|
||||
const qs = pick(50, ALLQ);
|
||||
mock = { qs: qs, answers: new Array(qs.length).fill(null), secs: 0, submitted: false, timer: null, score: 0 };
|
||||
clearInterval(mock.timer);
|
||||
mock.timer = setInterval(()=>{ if (!mock.submitted) { mock.secs++; const t=document.getElementById('wa-timer'); if (t) t.textContent = fmt(mock.secs); if (mock.secs >= 7200) submitMock(); } }, 1000);
|
||||
qi = 0;
|
||||
render();
|
||||
}
|
||||
function submitMock(){
|
||||
clearInterval(mock.timer);
|
||||
mock.score = mock.qs.reduce((s,q,i)=> s + (mock.answers[i]===q.c ? 1 : 0), 0);
|
||||
mock.submitted = true;
|
||||
render();
|
||||
}
|
||||
|
||||
const origGo = window.go;
|
||||
window.go = function(v){
|
||||
if (v === 'waprep') { if (mode==='mock' && !mock.submitted && mock.qs.length) { /* keep state */ } else { buildList(); } render(); }
|
||||
origGo(v);
|
||||
};
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
.wa-cats{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
|
||||
.wa-cats .wac{padding:8px 16px;border-radius:999px;border:1px solid var(--line,#2C3455);background:var(--surface,#141A30);color:var(--text,#EDE7D6);cursor:pointer;font-size:.85rem}
|
||||
.wa-cats .wac.on{background:var(--brass,#E3A53F);color:#1a1206;border-color:var(--brass,#E3A53F);font-weight:700}
|
||||
.wa-card{background:var(--surface,#141A30);border:1px solid var(--line,#2C3455);border-radius:14px;padding:24px;max-width:760px;margin:0 auto}
|
||||
.wa-prog{display:flex;justify-content:space-between;font-family:"JetBrains Mono",monospace;font-size:.78rem;color:var(--text-faint,#6E7688);margin-bottom:16px}
|
||||
.wa-timer{color:var(--brass,#E3A53F);font-weight:700}
|
||||
.wa-score{color:var(--verdigris,#54BFA9)}
|
||||
.wa-q{font-family:Fraunces,serif;font-size:1.3rem;line-height:1.4;color:var(--text,#EDE7D6);margin-bottom:18px}
|
||||
.wa-opts{display:flex;flex-direction:column;gap:10px}
|
||||
.wa-opt{text-align:left;padding:12px 16px;border-radius:10px;border:1px solid var(--line,#2C3455);background:var(--surface-2,#1B2239);color:var(--text,#EDE7D6);cursor:pointer;font-size:.95rem;line-height:1.4}
|
||||
.wa-opt:hover:not(:disabled){border-color:var(--brass,#E3A53F)}
|
||||
.wa-opt.sel{border-color:var(--brass,#E3A53F);background:color-mix(in srgb,var(--brass,#E3A53F) 14%,transparent)}
|
||||
.wa-opt.right{background:color-mix(in srgb,var(--verdigris,#54BFA9) 22%,transparent);border-color:var(--verdigris,#54BFA9)}
|
||||
.wa-opt.wrong{background:color-mix(in srgb,var(--crimson,#DE5F45) 22%,transparent);border-color:var(--crimson,#DE5F45)}
|
||||
.wa-opt:disabled{cursor:default;opacity:.9}
|
||||
.wa-expl{margin-top:16px;padding:12px 16px;border-left:3px solid var(--verdigris,#54BFA9);background:color-mix(in srgb,var(--verdigris,#54BFA9) 9%,transparent);border-radius:8px;font-size:.9rem;line-height:1.5;color:var(--text,#EDE7D6)}
|
||||
.wa-nav{margin-top:16px;text-align:right}
|
||||
.wa-res{text-align:center;padding:20px 0}
|
||||
.wa-big{font-family:Fraunces,serif;font-size:3rem;color:var(--text,#EDE7D6)}
|
||||
.wa-pct{font-size:1.2rem;font-weight:700;margin:6px 0 14px}
|
||||
.wa-pct.pass{color:var(--verdigris,#54BFA9)}
|
||||
.wa-pct.fail{color:var(--crimson,#DE5F45)}
|
||||
.wa-rev{border:1px solid var(--line,#2C3455);border-radius:10px;padding:14px;margin-bottom:12px;text-align:left}
|
||||
.wa-rev.ok{border-left:3px solid var(--verdigris,#54BFA9)}
|
||||
.wa-rev.bad{border-left:3px solid var(--crimson,#DE5F45)}
|
||||
.wa-rq{font-weight:600;color:var(--text,#EDE7D6);margin-bottom:6px}
|
||||
.wa-ro{font-size:.88rem;color:var(--text-dim,#A3A99C);margin-bottom:4px}
|
||||
</style>
|
||||
'''.replace('__WADATA__', data_json)
|
||||
|
||||
s = s.rstrip() + '\n' + RENDER + '\n'
|
||||
open(p, "w").write(s)
|
||||
print(f"rebuilt WA Prep with {len(ALLQ)} questions + mock exam; size {len(s)}")
|
||||
Reference in New Issue
Block a user