{
const scripts = Array.from(
document.querySelectorAll("script.fcpython-ojs-quiz-config")
);
const script = scripts.find(
(node) => node.dataset.fcpythonRendered !== "true"
);
if (!script) {
return html`<div class="fcpython-quiz fcpython-quiz-warning">
Quiz configuration was not found.
</div>`;
}
script.dataset.fcpythonRendered = "true";
const quiz = JSON.parse(script.textContent);
const container = html`<div class="fcpython-quiz"></div>`;
const title = document.createElement("h3");
title.textContent = quiz.title;
container.appendChild(title);
const instructions = document.createElement("p");
instructions.textContent = quiz.instructions;
container.appendChild(instructions);
const progress = document.createElement("div");
progress.className = "fcpython-quiz-progress";
const counter = document.createElement("p");
counter.className = "fcpython-quiz-counter";
counter.setAttribute("aria-live", "polite");
progress.appendChild(counter);
const tabs = document.createElement("div");
tabs.className = "fcpython-quiz-steps";
tabs.setAttribute("role", "tablist");
tabs.setAttribute("aria-label", "Quiz questions");
progress.appendChild(tabs);
container.appendChild(progress);
const questions = document.createElement("div");
questions.className = "fcpython-quiz-questions";
const feedbackNodes = [];
const questionPanels = [];
const stepButtons = [];
let currentQuestion = 0;
function setStepStatus(questionIndex, status) {
const step = stepButtons[questionIndex];
const question = quiz.questions[questionIndex];
const statuses = {
answered: "answered",
correct: "correct",
incorrect: "incorrect",
unanswered: "not answered",
};
step.classList.remove(
"is-answered",
"is-correct",
"is-incorrect",
"is-unanswered"
);
if (status) {
step.classList.add(`is-${status}`);
}
const statusLabel = status ? `, ${statuses[status]}` : "";
step.setAttribute(
"aria-label",
`Question ${questionIndex + 1}: ${question.prompt}${statusLabel}`
);
}
function showQuestion(questionIndex, focusPanel = false) {
currentQuestion = Math.max(
0,
Math.min(questionIndex, quiz.questions.length - 1)
);
questionPanels.forEach((panel, index) => {
panel.hidden = index !== currentQuestion;
});
stepButtons.forEach((step, index) => {
const isCurrent = index === currentQuestion;
step.classList.toggle("is-current", isCurrent);
step.setAttribute("aria-selected", String(isCurrent));
step.tabIndex = isCurrent ? 0 : -1;
});
counter.textContent = `Question ${currentQuestion + 1} of ${quiz.questions.length}`;
const selected = questionPanels[currentQuestion].querySelector(
"input[type='radio']:checked"
);
const isLastQuestion = currentQuestion === quiz.questions.length - 1;
previous.hidden = currentQuestion === 0;
next.hidden = isLastQuestion || !selected;
check.hidden = !isLastQuestion;
if (focusPanel) {
questionPanels[currentQuestion].focus();
}
}
quiz.questions.forEach((question, questionIndex) => {
const tabId = `${quiz.id}-question-tab-${questionIndex + 1}`;
const panelId = `${quiz.id}-question-panel-${questionIndex + 1}`;
const step = document.createElement("button");
step.type = "button";
step.className = "fcpython-quiz-step";
step.id = tabId;
step.textContent = String(questionIndex + 1);
step.setAttribute("role", "tab");
step.setAttribute("aria-controls", panelId);
step.setAttribute("aria-selected", "false");
step.tabIndex = -1;
step.addEventListener("click", () => showQuestion(questionIndex));
step.addEventListener("keydown", (event) => {
let destination = null;
if (event.key === "ArrowRight") {
destination = (questionIndex + 1) % quiz.questions.length;
} else if (event.key === "ArrowLeft") {
destination =
(questionIndex - 1 + quiz.questions.length) % quiz.questions.length;
} else if (event.key === "Home") {
destination = 0;
} else if (event.key === "End") {
destination = quiz.questions.length - 1;
}
if (destination !== null) {
event.preventDefault();
showQuestion(destination);
stepButtons[destination].focus();
}
});
stepButtons.push(step);
tabs.appendChild(step);
setStepStatus(questionIndex, "");
const panel = document.createElement("div");
panel.className = "fcpython-quiz-panel";
panel.id = panelId;
panel.setAttribute("role", "tabpanel");
panel.setAttribute("aria-labelledby", tabId);
panel.tabIndex = -1;
const fieldset = document.createElement("fieldset");
fieldset.className = "fcpython-quiz-question";
const legend = document.createElement("legend");
legend.textContent = question.prompt;
fieldset.appendChild(legend);
question.options.forEach((option, optionIndex) => {
const label = document.createElement("label");
label.className = "fcpython-quiz-option";
const input = document.createElement("input");
input.type = "radio";
input.name = `${quiz.id}-${question.id}`;
input.value = String(optionIndex);
input.addEventListener("change", () => {
setStepStatus(questionIndex, "answered");
feedbackNodes[questionIndex].textContent = "";
feedbackNodes[questionIndex].className = "fcpython-quiz-feedback";
score.textContent = "";
showQuestion(questionIndex);
});
const text = document.createElement("span");
text.textContent = option;
label.appendChild(input);
label.appendChild(text);
fieldset.appendChild(label);
});
const feedback = document.createElement("p");
feedback.className = "fcpython-quiz-feedback";
feedback.setAttribute("aria-live", "polite");
feedbackNodes.push(feedback);
fieldset.appendChild(feedback);
panel.appendChild(fieldset);
questionPanels.push(panel);
questions.appendChild(panel);
});
container.appendChild(questions);
const actions = document.createElement("div");
actions.className = "fcpython-quiz-actions";
const previous = document.createElement("button");
previous.type = "button";
previous.className = "fcpython-quiz-secondary";
previous.textContent = "Previous";
previous.addEventListener("click", () => {
showQuestion(currentQuestion - 1, true);
});
const next = document.createElement("button");
next.type = "button";
next.textContent = "Next question";
next.addEventListener("click", () => {
showQuestion(currentQuestion + 1, true);
});
const check = document.createElement("button");
check.type = "button";
check.textContent = "Check answers";
const reset = document.createElement("button");
reset.type = "button";
reset.className = "fcpython-quiz-secondary fcpython-quiz-reset";
reset.textContent = "Reset";
const score = document.createElement("p");
score.className = "fcpython-quiz-score";
score.setAttribute("aria-live", "polite");
check.addEventListener("click", () => {
let correctCount = 0;
let firstQuestionToReview = null;
quiz.questions.forEach((question, questionIndex) => {
const selected = container.querySelector(
`input[name="${quiz.id}-${question.id}"]:checked`
);
const feedback = feedbackNodes[questionIndex];
if (!selected) {
feedback.textContent = "Choose an answer before checking.";
feedback.className = "fcpython-quiz-feedback";
setStepStatus(questionIndex, "unanswered");
if (firstQuestionToReview === null) {
firstQuestionToReview = questionIndex;
}
return;
}
const selectedIndex = Number(selected.value);
if (selectedIndex === question.answer_index) {
correctCount += 1;
feedback.textContent = `✅ Correct. ${question.explanation}`;
feedback.className = "fcpython-quiz-feedback is-correct";
setStepStatus(questionIndex, "correct");
} else {
const answer = question.options[question.answer_index];
feedback.textContent = `❌ Not yet. Correct answer: ${answer}. ${question.explanation}`;
feedback.className = "fcpython-quiz-feedback is-incorrect";
setStepStatus(questionIndex, "incorrect");
if (firstQuestionToReview === null) {
firstQuestionToReview = questionIndex;
}
}
});
score.textContent = `Score: ${correctCount}/${quiz.questions.length}`;
if (firstQuestionToReview !== null) {
showQuestion(firstQuestionToReview, true);
}
});
reset.addEventListener("click", () => {
container.querySelectorAll("input[type='radio']").forEach((input) => {
input.checked = false;
});
feedbackNodes.forEach((feedback) => {
feedback.textContent = "";
feedback.className = "fcpython-quiz-feedback";
});
stepButtons.forEach((_, questionIndex) => {
setStepStatus(questionIndex, "");
});
score.textContent = "";
showQuestion(0, true);
});
actions.appendChild(previous);
actions.appendChild(next);
actions.appendChild(check);
actions.appendChild(reset);
container.appendChild(actions);
container.appendChild(score);
showQuestion(0);
return container;
}
FreeCampus Python
Unit Challenge: Repair the Glitched Arcade
Repair Pixel’s command parser, movement rules, and exception boundary so valid moves reach the portal while rejected commands keep useful evidence.
python-foundations
errors-exceptions-debugging
unit-challenge
debugging-puzzle
Course progress
0%
1. Get Pixel to the portal without hiding the glitches
An old arcade cabinet controls Pixel, a tiny character stranded behind a glitchy joystick. Every accepted command moves Pixel on a coordinate grid. The portal opens only when the final position is exactly (2, 1).
The cabinet has this command tape:
Some commands are valid moves. Others should be rejected with the turn number and a useful reason. The supplied implementation runs, but the portal stays dark. Your job is to use the staged evidence to repair the command boundary and the silent movement bug.
At the end, the game should report:
NoteYou have a repair manual, not an answer key
Work on one subsystem at a time. Run the closest checks, state a hypothesis, make one change, and rerun earlier checks. Open hints only after you can name the exact assertion, exception, or value blocking you.
2. Read the command and movement rules
Commands
- A command is a string containing exactly two whitespace-separated parts.
- The first part is a direction. Input is case-insensitive and normalizes to
UP,RIGHT,DOWN, orLEFT. - The second part must convert to a positive integer. Zero is not positive.
- Every rejected command raises a contextual
ValueErrorcontaining its 1-based turn number. - When unpacking or integer conversion raises the original
ValueError, the contextual error preserves it as__cause__withraise ... from error. - Unsupported direction and non-positive steps are explicit rule failures, so they have no invented lower-level cause.
Movement
Pixel begins at (0, 0). Coordinates use (x, y):
| Direction | Coordinate change | Example from (3, 2) |
|---|---|---|
UP |
add steps to y |
UP 2 → (3, 4) |
RIGHT |
add steps to x |
RIGHT 2 → (5, 2) |
DOWN |
subtract steps from y |
DOWN 2 → (3, 0) |
LEFT |
subtract steps from x |
LEFT 2 → (1, 2) |
move_pixel receives a validated command and returns a new tuple. It does not mutate the supplied position.
Round recovery
play_round parses each command in encounter order. It catches only an anticipated parser ValueError, records the message, and continues to the next turn. Movement happens outside that narrow try block. If movement code has an unexpected defect, that defect must remain visible instead of being called bad player input.
For the supplied tape:
- turns 3, 4, and 7 are rejected;
- the four accepted moves are
UP 2,RIGHT 3,DOWN 1, andLEFT 1; - accepted moves lead
(0, 0) → (0, 2) → (3, 2) → (3, 1) → (2, 1); and - the input list and strings remain unchanged.
3. Start from the contract
Run this starter unchanged. It is valid Python and intentionally looks plausible. Let the assertions reveal which promises it violates before editing. Preserve the three public names, signatures, and docstrings.
VALID_DIRECTIONS = {"UP", "RIGHT", "DOWN", "LEFT"}
def parse_command(text, turn):
"""Return a normalized (direction, steps) tuple or raise contextual ValueError."""
try:
direction, raw_steps = text.split()
except ValueError:
raise ValueError(f"turn {turn}: command needs exactly two parts")
direction = direction.upper()
if direction not in VALID_DIRECTIONS:
raise ValueError(f"turn {turn}: unsupported direction {direction!r}")
try:
steps = int(raw_steps)
except ValueError:
raise ValueError(f"turn {turn}: steps must be an integer")
if steps < 0:
raise ValueError(f"turn {turn}: steps must be positive")
return direction, steps
def move_pixel(position, command):
"""Return Pixel's new position after one validated command."""
x, y = position
direction, steps = command
if direction == "UP":
y += steps
elif direction == "RIGHT":
x += steps
elif direction == "DOWN":
y -= steps
elif direction == "LEFT":
x += steps
return x, y
def play_round(commands, portal):
"""Return final position, rejected messages, and whether the portal opened."""
position = (0, 0)
rejected = []
for turn, text in enumerate(commands, start=1):
try:
command = parse_command(text, turn)
position = move_pixel(position, command)
except Exception as error:
rejected.append(str(error))
return position, rejected, position == portalDo not list possible repairs yet. First run the parser checks in Section 5 and keep the first failure. A failed check is a map to one contract boundary.
4. Isolate one arcade subsystem at a time
Treat the arcade as three cooperating systems:
| Subsystem | Input | Normal result | Anticipated failure | Focused evidence |
|---|---|---|---|---|
| parser | text and turn | normalized tuple | contextual ValueError |
return value, message, __cause__ |
| movement | position and validated command | new position | none for valid commands | before/after coordinates |
| round runner | command sequence and portal | final tuple, messages, opened flag | parser rejection and continuation | accepted path, rejection count, final state |
Follow this order:
- Normalization: make valid lower/mixed-case commands return uppercase directions and integer steps.
- Command shape and conversion: inspect the new error and its original cause separately.
- Domain validation: reject unsupported directions, zero, and negatives.
- Coordinates: verify one direction at a time from the same starting point.
- Minimal regression: reduce the wrong round to one
LEFTmove. - Recovery boundary: parse inside the protected region, then move outside it.
- Integration: run the supplied tape and confirm final position, rejection evidence, input ownership, and portal state.
For one defect, use this investigation loop:
WarningDo not edit the expected values to create green output
If an assertion surprises you, compare it with the written command or movement rule. Repair an incorrect oracle only when you can demonstrate that the written contract contradicts it.
5. Run progressive assertions
Run each stage after the previous stage passes. The helper below captures the specific exception and fails clearly when no ValueError is raised:
Stage A: normalize valid commands
Stage B: preserve a malformed-shape cause
Unpacking one or three parts raises the lower-level ValueError. Add turn context without losing that cause:
shape_error = captured_value_error(lambda: parse_command("UP", 5))
assert "turn 5" in str(shape_error)
assert "two" in str(shape_error) or "parts" in str(shape_error)
assert isinstance(shape_error.__cause__, ValueError)
extra_part_error = captured_value_error(lambda: parse_command("UP 2 NOW", 6))
assert "turn 6" in str(extra_part_error)
assert isinstance(extra_part_error.__cause__, ValueError)Stage C: preserve an integer-conversion cause
Stage D: reject unsupported and non-positive moves
These are direct contract decisions, not translated lower-level failures:
direction_error = captured_value_error(lambda: parse_command("JUMP 1", 4))
assert "turn 4" in str(direction_error)
assert "JUMP" in str(direction_error)
assert direction_error.__cause__ is None
zero_error = captured_value_error(lambda: parse_command("RIGHT 0", 7))
assert "positive" in str(zero_error)
assert zero_error.__cause__ is None
negative_error = captured_value_error(lambda: parse_command("DOWN -2", 8))
assert "turn 8" in str(negative_error)
assert "positive" in str(negative_error)Stage E: move in all four directions
Keep this smallest regression check even after the full round passes:
Stage F: reject bad commands and keep playing
commands_before = commands.copy()
position, rejected, opened = play_round(commands, portal)
assert position == (2, 1)
assert len(rejected) == 3
assert "turn 3" in rejected[0]
assert "turn 4" in rejected[1]
assert "turn 7" in rejected[2]
assert opened is True
assert commands == commands_before
assert portal == (2, 1)Confirm the round boundary does not hide a movement defect. Temporarily replace move_pixel with a version that raises RuntimeError, call play_round, and observe that the failure propagates. Restore the real function before the full rerun. Do not add a RuntimeError handler merely to make this experiment quiet.
Finally, create a short command list of your own. Predict every accepted move, every rejection, and the final position on paper, then add assertions for its result without changing any supplied expected values.
6. Use the hint ladder only when needed
Hint 1
Let the failing assertion identify the subsystem. __cause__ belongs to parser translation, a wrong coordinate belongs to movement, and a swallowed RuntimeError belongs to the round’s try boundary. Do not repair all three at once.
Hint 2
When Python itself raises during unpacking or int, retain that exception after adding turn context. “Positive” excludes zero. LEFT and RIGHT must use opposite x-coordinate operations. The round can catch parser ValueError without placing movement in the same protected block.
Hint 3
Fill only the missing decisions in these focused shapes:
try:
direction, raw_steps = text.split()
except ValueError as error:
raise ValueError(...) from error
if steps <= 0: # Replace the comparison if your evidence supports another one.
raise ValueError(...)
elif direction == "LEFT":
x = ... # Replace the value with the intended coordinate update.
try:
command = parse_command(text, turn)
except ValueError as error:
rejected.append(...)
else:
position = move_pixel(position, command)7. Keep debugging evidence
Preserve one failure that changed your understanding. The one-move LEFT check is a good candidate because it removes parsing, the loop, and the portal while retaining the coordinate defect.
| Reproduction | Actual evidence | Hypothesis | One change | Focused rerun | Full rerun | Regression check |
|---|---|---|---|---|---|---|
| exact call/check | value, message, or cause | one mechanism that could be false | one line or boundary | nearest check result | all earlier stages | smallest permanent check |
Example evidence should be specific:
After every repair, restart the notebook runtime and run from the starter or your final definitions through all progressive assertions. A portal that opens only because an old function remains in memory is not reliable evidence.
8. Compare with a complete solution
Show the complete arcade repair after attempting every stage
VALID_DIRECTIONS = {"UP", "RIGHT", "DOWN", "LEFT"}
def parse_command(text, turn):
"""Return a normalized (direction, steps) tuple or raise contextual ValueError."""
try:
direction, raw_steps = text.split()
except ValueError as error:
raise ValueError(
f"turn {turn}: command needs exactly two parts"
) from error
direction = direction.upper()
if direction not in VALID_DIRECTIONS:
raise ValueError(
f"turn {turn}: unsupported direction {direction!r}"
)
try:
steps = int(raw_steps)
except ValueError as error:
raise ValueError(
f"turn {turn}: steps must be an integer; got {raw_steps!r}"
) from error
if steps <= 0:
raise ValueError(
f"turn {turn}: steps must be positive; got {steps}"
)
return direction, steps
def move_pixel(position, command):
"""Return Pixel's new position after one validated command."""
x, y = position
direction, steps = command
if direction == "UP":
y += steps
elif direction == "RIGHT":
x += steps
elif direction == "DOWN":
y -= steps
elif direction == "LEFT":
x -= steps
else:
raise ValueError(f"unvalidated direction reached movement: {direction!r}")
return x, y
def play_round(commands, portal):
"""Return final position, rejected messages, and whether the portal opened."""
position = (0, 0)
rejected = []
for turn, text in enumerate(commands, start=1):
try:
command = parse_command(text, turn)
except ValueError as error:
rejected.append(str(error))
else:
position = move_pixel(position, command)
return position, rejected, position == portalWhy the repaired boundaries matter:
- unpacking and integer conversion can fail at a lower level, so chaining keeps both Python’s cause and the command’s turn context;
- unsupported direction and non-positive steps are direct parser rules, so no unrelated cause is manufactured;
LEFTsubtracts from x and the tuple returned is new;- only parsing sits inside the round’s
try, so an unexpected movement failure still produces honest developer evidence; and - the portal result derives from the final position rather than being hard-coded.
Run every assertion from Section 5 unchanged after loading this solution.
9. Predict a changed arcade rule
The cabinet designer proposes two changes:
STAY 1becomes a supported command that leaves Pixel in place; and- the portal opens if Pixel finishes at Manhattan distance at most one from its center, not only on the exact coordinate.
Before editing:
- Which parser constant, movement branch, docstrings, and assertions change?
- Should
STAY 0become valid, or does the positive-step rule still reject it? - For portal
(2, 1), which neighboring coordinates now open it? - Which behavior belongs in a small helper instead of making
play_roundharder to read? - Which old assertions should remain unchanged as regression evidence?
Implement the changed rule only after writing examples for exact, adjacent, and two-steps-away positions. Do not weaken command validation accidentally while adding a new direction.
10. Check your understanding
11. Decide whether the challenge is complete
Evidence rubric
| Evidence | Ready to record when |
|---|---|
| Parser | Normalization, shape, conversion, direction, and positive-step checks pass unchanged. |
| Movement | Four directions and the minimal LEFT regression behave as specified without input mutation. |
| Boundary | Three anticipated command failures are recorded, play continues, and an unexpected movement defect would propagate. |
| Payoff | Pixel finishes at (2, 1) and the computed portal flag is true. |
| Reasoning | One debugging record connects exact evidence, one hypothesis, one controlled change, and focused/full reruns. |
| Reproducibility | The definitions and all assertions pass after a clean restart and top-to-bottom run. |
This button stores a self-reported marker only in this browser. It does not submit work, grade it, verify identity, or issue a certificate.
Not yet recorded.
Key points
- Progressive assertions turn a multi-defect program into parser, movement, and boundary investigations.
- Exception chaining preserves a lower-level cause while adding turn context.
- Narrow handling continues after anticipated command mistakes without hiding programmer defects.
- A minimal logic reproduction exposes direction state without needing the whole arcade round.
- The strongest finish is a clean rerun you can explain, not merely a glowing portal from stale notebook state.