ブラウザ上で自動採点つき練習アプリ(HTML + JS)
<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>JS 演算子 練習アプリ(自動採点)</title>
<style>
:root{--bg:#0f1724;--card:#0b1220;--accent:#7dd3fc;--muted:#94a3b8}
body{font-family:Inter,ui-sans-serif,system-ui,Segoe UI,Roboto,"Helvetica Neue",Arial; background:linear-gradient(180deg,#071021, #05203a); color:#e6eef6; margin:0; padding:24px}
.container{max-width:980px;margin:0 auto}
header{display:flex;gap:16px;align-items:center}
h1{margin:0;font-size:20px}
p.lead{color:var(--muted);margin:6px 0 20px}
.card{background:var(--card);border-radius:12px;padding:16px;margin-bottom:12px;box-shadow:0 6px 18px rgba(3,7,18,.6)}
pre.code{background:#071226;padding:12px;border-radius:8px;overflow:auto;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace;font-size:13px}
label{display:block;margin:8px 0 6px;color:var(--muted);font-size:13px}
input[type=text], textarea{width:100%;padding:10px;border-radius:8px;border:1px solid #15324a;background:#061827;color:#e6eef6}
textarea{min-height:56px}
.row{display:flex;gap:8px}
button{background:transparent;border:1px solid rgba(125,211,252,.18);color:var(--accent);padding:8px 12px;border-radius:10px;cursor:pointer}
button.primary{background:linear-gradient(90deg,#0369a1,#0891b2);border:0;color:white}
.hint{color:#9bd0e8;margin-top:8px}
.explain{background:#05283b;padding:10px;border-radius:8px;margin-top:8px;color:var(--muted)}
.ok{color:#86efac}
.ng{color:#fda4af}
.score{font-weight:700;color:var(--accent)}
footer{color:var(--muted);margin-top:18px;font-size:13px}
</style>
</head>
<body>
<div class="container">
<header>
<div>
<h1>JavaScript 演算子 練習アプリ(自動採点)</h1>
<p class="lead">コードを読んで予想される出力を入力し、「採点」を押すと正誤・解説・合計スコアが表示されます。ブラウザの開発者コンソールではなくこのページ内で評価します。</p>
</div>
</header>
<div id="exercises"></div>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center">
<div>合計スコア: <span id="score" class="score">0</span> / <span id="total">0</span></div>
<div class="row">
<button id="checkAll" class="primary">すべて採点</button>
<button id="reset">リセット</button>
</div>
</div>
</div>
<footer>この練習は学習用です。コードはページ内で安全に評価しています(与えられたスニペットのみを実行)。複雑な式は括弧で順序を明示する癖をつけましょう。</footer>
</div>
<script>
const exercises = [
{
id: 1,
title: '問題①:数値の計算',
code: `let result = 2 + 3 * 4; console.log(result);`,
hint: '掛け算は足し算より優先される',
},
{
id: 2,
title: '問題②:複合代入',
code: `let x = 5; x += 3; x *= 2; console.log(x);`,
hint: '左から順に実行される(逐次的)',
},
{
id: 3,
title: '問題③:型変換を含む比較',
code: `console.log(3 == "3"); console.log(3 === "3");`,
hint: '== は型変換を行うが === は型も一致させる',
},
{
id: 4,
title: '問題④:短絡評価(ショートサーキット)',
code: `console.log(0 || "default"); console.log("hello" && 123); console.log(null ?? "fallback");`,
hint: '|| は左が falsy のとき右を返す、&& は左が truthy のとき右を返す。?? は nullish(null/undefined)のときだけ右を使う',
},
{
id: 5,
title: '問題⑤:条件(三項)演算子',
code: `let age = 20; let result = (age >= 18) ? "大人" : "未成年"; console.log(result);`,
hint: '条件 ? 真の値 : 偽の値',
},
{
id: 6,
title: '問題⑥:論理代入(モダン構文)',
code: `let a = null; let b = 0; a ??= "default"; b ||= 10; console.log(a, b);`,
hint: '??= は nullish のとき代入。||= は falsy のとき代入。',
},
{
id: 7,
title: '問題⑦:右結合を理解する',
code: `let a = 2 ** 3 ** 2; console.log(a);`,
hint: '**(べき乗)は右結合',
},
{
id: 8,
title: '問題⑧:代入も右結合',
code: `let x, y, z; x = y = z = 5; console.log(x, y, z);`,
hint: '代入は右から評価される',
},
{
id: 9,
title: '問題⑨:カンマ演算子のトリック',
code: `let value = (console.log("A"), console.log("B"), 42); console.log(value);`,
hint: 'カンマ演算子は最後の値を返す。console.log の出力は順に表示される',
},
{
id: 10,
title: 'チャレンジ:複合(順序注意)',
code: `let x = 3; let y = 4; let result = x++ * 2 + (--y) ** 2; console.log(result); console.log('x after', x); console.log('y after', y);`,
hint: '後置・前置、優先順位、べき乗の結合などを意識する',
}
];
const container = document.getElementById('exercises');
function makeExercise(ex){
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `
<h3>${ex.title}</h3>
<pre class="code">${escapeHtml(ex.code)}</pre>
<label>予想されるコンソール出力(改行で区切る)</label>
<textarea id="answer-${ex.id}" placeholder="例: 14"></textarea>
<div style="margin-top:8px;display:flex;gap:8px">
<button data-id="${ex.id}" class="check">採点</button>
<button data-id="${ex.id}" class="hintBtn">ヒントを表示</button>
<button data-id="${ex.id}" class="explainBtn">解説を表示</button>
</div>
<div id="feedback-${ex.id}" class="explain" style="display:none"></div>
`;
return card;
}
function escapeHtml(s){ return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
exercises.forEach(ex => container.appendChild(makeExercise(ex)));
document.getElementById('total').textContent = exercises.length;
// 実行用:与えられたスニペットだけを実行して console.log を捕まえる
function runSnippet(snippet){
const logs = [];
const originalLog = console.log;
try{
console.log = function(...args){ logs.push(args.map(a=>String(a)).join(' ')); };
// 評価用に IIFE に包んで安全に実行
(function(){
eval(snippet);
})();
}catch(e){ logs.push('<<エラー: '+e.message+ '>>'); }
finally{ console.log = originalLog; }
return logs;
}
function compareOutputs(userText, actualArr){
const userLines = userText.split('\n').map(l=>l.trim()).filter(l=>l.length>0);
const actualLines = actualArr.map(l=>l.trim());
// 緩めの比較:行数と各行を順に比較
if(userLines.length !== actualLines.length) return {ok:false, userLines, actualLines};
for(let i=0;i<userLines.length;i++){
if(userLines[i] !== actualLines[i]) return {ok:false, userLines, actualLines};
}
return {ok:true, userLines, actualLines};
}
// ハンドラ
container.addEventListener('click', (ev)=>{
const target = ev.target;
if(target.classList.contains('check')){
const id = Number(target.dataset.id);
gradeOne(id);
}
if(target.classList.contains('hintBtn')){
const id = Number(target.dataset.id);
const fb = document.getElementById('feedback-'+id);
fb.style.display = 'block';
fb.innerHTML = `<div class="hint">ヒント: ${exercises.find(e=>e.id===id).hint}</div>`;
}
if(target.classList.contains('explainBtn')){
const id = Number(target.dataset.id);
showExplain(id);
}
});
function gradeOne(id){
const ex = exercises.find(e=>e.id===id);
const answerEl = document.getElementById('answer-'+id);
const feedback = document.getElementById('feedback-'+id);
const user = (answerEl.value || '').trim();
const actual = runSnippet(ex.code);
const cmp = compareOutputs(user, actual);
feedback.style.display = 'block';
if(cmp.ok){
feedback.innerHTML = `<div class=\"ok\">✅ 正解</div><div class=\"explain\">実際の出力:<pre>${actual.join('\n')}</pre></div>`;
} else {
feedback.innerHTML = `<div class=\"ng\">❌ 不正解</div>
<div class=\"explain\">あなたの入力:<pre>${cmp.userLines.join('\n')||'<空>'}</pre>
実際の出力:<pre>${cmp.actualLines.join('\n')}</pre></div>`;
}
updateScore();
}
function showExplain(id){
const ex = exercises.find(e=>e.id===id);
const fb = document.getElementById('feedback-'+id);
const actual = runSnippet(ex.code);
let explanation = '';
switch(id){
case 1:
explanation = '掛け算が先に評価されます。3*4=12、2+12=14。'; break;
case 2:
explanation = 'x += 3 -> 8、x *= 2 -> 16。複合代入はその場で計算して再代入します。'; break;
case 3:
explanation = '== は型変換して比較するため true。=== は型も比較するため false。'; break;
case 4:
explanation = '0 || "default" は 0 が falsy なので "default" を返す。"hello" && 123 は "hello" が truthy なので 123 を返す。null ?? "fallback" は nullish の場合だけ fallback を使う。'; break;
case 5:
explanation = 'age >= 18 が true なので "大人" が返る。'; break;
case 6:
explanation = 'a は null なので ??= により "default" が代入される。b は 0 なので || = により 10 が代入される。'; break;
case 7:
explanation = '** は右結合。3 ** 2 = 9、2 ** 9 = 512。'; break;
case 8:
explanation = '代入は右結合。右から評価して全て 5 になる。'; break;
case 9:
explanation = 'カンマ演算子はすべての式を評価し最後の値(42)を返す。console.log は実行順に A, B を出力する。'; break;
case 10:
explanation = 'x++ は後置なので計算時は 3 を使い、その後 x は 4 に増える。--y は先に減るので y は 3 になり (3)**2 = 9。結果 3*2 + 9 = 15。'; break;
}
fb.style.display = 'block';
fb.innerHTML = `<div class="explain">${explanation}<hr>実際の出力:<pre>${actual.join('\n')}</pre></div>`;
}
function updateScore(){
let score = 0; let total = exercises.length;
exercises.forEach(ex=>{
const user = (document.getElementById('answer-'+ex.id).value||'').trim();
if(user.length===0) return;
const actual = runSnippet(ex.code);
const cmp = compareOutputs(user, actual);
if(cmp.ok) score++;
});
document.getElementById('score').textContent = score;
}
document.getElementById('checkAll').addEventListener('click', ()=>{
exercises.forEach(ex=>gradeOne(ex.id));
});
document.getElementById('reset').addEventListener('click', ()=>{
exercises.forEach(ex=>{
document.getElementById('answer-'+ex.id).value = '';
const fb = document.getElementById('feedback-'+ex.id);
fb.style.display = 'none';
fb.innerHTML = '';
});
updateScore();
});
// 初期スコア
updateScore();
</script>
</body>
</html>
HTML使い方の要点:
- 各問題の下に予想されるコンソール出力を入力(改行で複数行)して「採点」ボタンを押すと、自動で正誤と実際の出力・解説が表示されます。
- 「すべて採点」を押すと全問まとめて採点、スコアが表示されます。
- 「ヒント」「解説」ボタンで段階的に学べます。
