{
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: Unlock the Moonlit Library
Traverse a nested magical archive with focused functions, a recursive generator, callback-selected clues, and an audited public unlock operation.
python-foundations
functions-call-behavior
unit-challenge
Course progress
0%
1. Open the archive without hard-coding its answer
At moonrise, the Moonlit Library rearranges its shelves. Nine readable scrolls contain numbered letters. Sealed scrolls are decoys, decorative scrolls have no clue, and shelves may contain smaller shelves. Your archive engine must discover the message from the data and return the final artifact:
You will build a small set of cohesive functions rather than one long solution:
- normalize scroll titles through a keyword-only option;
- traverse shelves with a recursive generator;
- select scrolls through a predicate callback;
- decode ordered clues into one returned word;
- compose those steps in one public unlock operation; and
- audit that public call with a decorator that preserves its contract.
NoteKeep the solution closed for the first attempt
Plan for one or two focused hours. Run one assertion group at a time. Open a hint only after recording the exact function, input, actual result, and expected result that currently disagree.
2. Read the archive shape and constraints
A node has one of two forms:
- a shelf has
kind,name, andentries, where every entry is another shelf or scroll; - a scroll has
kind,title,sealed, andclue, where a clue is either(position, letter)orNone.
The traversal order is depth first and left to right. When the current node is a scroll, yield it. When it is a shelf, recurse into each entry in order.
Constraints
- Preserve every supplied public name, signature, and docstring.
- Derive the artifact from the archive; do not assign
"MOONLIGHT"or the solved sentence directly in a function. - Do not mutate the archive or its scroll records.
- Keep traversal lazy:
iter_scrollsandselect_scrollsmust yield values, not build hidden result lists. - Pass a predicate function into
select_scrolls; do not hard-code the readable-scroll policy inside traversal. - Keep calculation functions free of printing.
- The decorator must forward arguments, return the original result, preserve metadata with
wraps, and append one audit event after a successful call. - Use only the Python standard library; classes, files, and exception handlers are outside this challenge.
3. Start from the contract
Run the data cell unchanged:
archive = {
"kind": "shelf",
"name": "Atrium",
"entries": [
{
"kind": "scroll",
"title": " moon map ",
"sealed": False,
"clue": (0, "M"),
},
{
"kind": "shelf",
"name": "East Gallery",
"entries": [
{
"kind": "scroll",
"title": "Owl Song",
"sealed": False,
"clue": (1, "O"),
},
{
"kind": "shelf",
"name": "Upper Nook",
"entries": [
{
"kind": "scroll",
"title": "Oracle Note",
"sealed": False,
"clue": (2, "O"),
},
{
"kind": "scroll",
"title": "Counterfeit Omen",
"sealed": True,
"clue": (2, "X"),
},
{
"kind": "scroll",
"title": "North Bell",
"sealed": False,
"clue": (3, "N"),
},
],
},
],
},
{
"kind": "shelf",
"name": "Lantern Garden",
"entries": [
{
"kind": "scroll",
"title": "Lantern Leaf",
"sealed": False,
"clue": (4, "L"),
},
{
"kind": "scroll",
"title": "Idle Decoration",
"sealed": False,
"clue": None,
},
{
"kind": "shelf",
"name": "Glass Cabinet",
"entries": [
{
"kind": "scroll",
"title": "Ink Index",
"sealed": False,
"clue": (5, "I"),
},
{
"kind": "scroll",
"title": "Glass Glyph",
"sealed": False,
"clue": (6, "G"),
},
{
"kind": "scroll",
"title": "Horizon Hymn",
"sealed": False,
"clue": (7, "H"),
},
],
},
],
},
{
"kind": "scroll",
"title": "Twilight Key",
"sealed": False,
"clue": (8, "T"),
},
{
"kind": "scroll",
"title": "Sealed Mirror",
"sealed": True,
"clue": None,
},
],
}Then copy the scaffold. Implement the functions in this order; do not change the public call shapes.
from functools import wraps
def normalize_title(title, *, separator="-"):
"""Return a case-folded title with words joined by separator."""
raise NotImplementedError
def iter_scrolls(node):
"""Yield scroll nodes depth first and left to right."""
raise NotImplementedError
def is_readable(scroll):
"""Return whether a scroll is unsealed and contains a clue."""
raise NotImplementedError
def select_scrolls(scrolls, predicate):
"""Yield scrolls for which predicate(scroll) is true."""
raise NotImplementedError
def decode_clues(scrolls):
"""Return clue letters ordered by numeric clue position."""
raise NotImplementedError
def audit_calls(log):
"""Return a decorator that appends one event after each successful call."""
raise NotImplementedError
audit_log = []
@audit_calls(audit_log)
def unlock_archive(archive, *, predicate=is_readable):
"""Return the decoded word followed by the archive-opening phrase."""
raise NotImplementedErrorAn audit event has exactly these fields:
keyword_names is the sorted list of keyword names received by the wrapper.
4. Build the engine in observable stages
Use this order so one missing contract does not hide another:
- Normalize ordinary, irregular-space, and empty titles. An empty title becomes
"untitled"; do not use the separator as a default title. - Implement the generator’s scroll base case. Then delegate shelf entries with a recursive call.
- Create a fresh generator and prove partial consumption before converting any complete traversal to a list.
- Implement the readable predicate and selector. Keep
predicate(scroll)in the selector, not inside the traversal function. - Extract clue tuples, sort them by numeric position, and join uppercase letters. An empty input returns the empty string.
- Compose a fresh traversal and selection in
unlock_archive. Reusing the exhausted probe will lose scrolls. - Implement the three decorator layers:
audit_calls(log),decorate(function), andwrapper(*args, **kwargs). - Call the decorated public operation only after all smaller assertions pass.
Write a short trace for the recursive route from Atrium to Oracle Note. Mark the shelf frame that waits, the smaller entry passed onward, and the scroll that is yielded.
5. Run progressive assertions
The 24 numbered assertions are the acceptance contract. Run each group after implementing its named behavior; do not weaken an expected value.
Title and interface evidence
Recursive traversal and source evidence
Partial consumption and clean exhaustion
Callback and decoding evidence
Decorated composition evidence
assert unlock_archive.__name__ == "unlock_archive" # 18
assert unlock_archive.__doc__.startswith("Return the decoded word") # 19
artifact = unlock_archive(archive)
assert artifact == "MOONLIGHT OPENS THE ARCHIVE" # 20
assert len(audit_log) == 1 # 21
assert audit_log[0] == { # 22
"function": "unlock_archive",
"args_count": 1,
"keyword_names": [],
"result": "MOONLIGHT OPENS THE ARCHIVE",
}Controlled variation evidence
Seal the first real clue and add an empty shelf. The decoder now returns the remaining ordered letters, and the recursive generator must cross the empty shelf without yielding a value.
changed_archive = copy.deepcopy(archive)
changed_archive["entries"][0]["sealed"] = True
changed_archive["entries"].append(
{"kind": "shelf", "name": "Silent Annex", "entries": []}
)
changed_artifact = unlock_archive(changed_archive)
assert changed_artifact == "OONLIGHT OPENS THE ARCHIVE" # 23
assert len(audit_log) == 2 and audit_log[-1]["result"] == changed_artifact # 24
WarningDo not turn an exhausted generator into a decoding bug
If a complete result is missing its first letters, check whether exploratory next(...) calls consumed the same generator. Create a fresh generator for each independent complete traversal.
6. Use the hint ladder only when needed
Hint 1: locate the contract that currently disagrees
Write down the function name, input shape, expected returned value, and intended side effect. A scroll is the generator’s base case. A shelf owns recursive progress through its smaller entries. Title normalization can split on whitespace and join the words without changing the source string.
Hint 2: follow the boundary between behaviors
iter_scrolls knows nothing about sealed clues. select_scrolls calls the supplied predicate for every yielded scroll. decode_clues receives only the selected scrolls, extracts their clue tuples, sorts by position, and joins the letters. Use a fresh traversal when composing those stages.
Hint 3: assemble the decorator and public pipeline
Pseudocode for the public operation:
The decorator factory returns decorate; decorate returns a @wraps(function) wrapper; the wrapper calls with *args, **kwargs, stores the result, appends the specified event, and returns the unchanged result.
7. Keep debugging evidence
Preserve one failed assertion from before your repair. Do not record only “it did not work.” Connect the evidence to one call boundary or state transition.
| Failure | Arguments and actual result | Expected contract | Single hypothesis | Controlled change and rerun |
|---|---|---|---|---|
| Which numbered assertion failed? | What exact values or error appeared? | What should this function yield, return, preserve, or append? | Which base case, callback, generator state, or wrapper step could explain it? | What one edit did you make, and which earlier checks still pass? |
A useful record might reveal that a probe had already consumed two scrolls, that the predicate was called too early, or that the wrapper forgot to return the original result. Keep the record even after the complete suite passes.
8. Compare with a complete solution
Open this only after making a serious attempt and using the hints in order.
Show one complete Moonlit Library solution
from functools import wraps
def normalize_title(title, *, separator="-"):
"""Return a case-folded title with words joined by separator."""
words = title.casefold().split()
if not words:
return "untitled"
return separator.join(words)
def iter_scrolls(node):
"""Yield scroll nodes depth first and left to right."""
if node["kind"] == "scroll":
yield node
return
for entry in node["entries"]:
yield from iter_scrolls(entry)
def is_readable(scroll):
"""Return whether a scroll is unsealed and contains a clue."""
return not scroll["sealed"] and scroll["clue"] is not None
def select_scrolls(scrolls, predicate):
"""Yield scrolls for which predicate(scroll) is true."""
for scroll in scrolls:
if predicate(scroll):
yield scroll
def decode_clues(scrolls):
"""Return clue letters ordered by numeric clue position."""
clues = []
for scroll in scrolls:
clues.append(scroll["clue"])
clues.sort(key=lambda clue: clue[0])
return "".join(letter.upper() for _, letter in clues)
def audit_calls(log):
"""Return a decorator that appends one event after each successful call."""
def decorate(function):
@wraps(function)
def wrapper(*args, **kwargs):
result = function(*args, **kwargs)
event = {
"function": function.__name__,
"args_count": len(args),
"keyword_names": sorted(kwargs),
"result": result,
}
log.append(event)
return result
return wrapper
return decorate
audit_log = []
@audit_calls(audit_log)
def unlock_archive(archive, *, predicate=is_readable):
"""Return the decoded word followed by the archive-opening phrase."""
scrolls = iter_scrolls(archive)
selected = select_scrolls(scrolls, predicate)
word = decode_clues(selected)
return f"{word} OPENS THE ARCHIVE"9. Predict an alternate library policy
The supplied is_readable rejects sealed clues. Try an alternate predicate that accepts every scroll containing a clue:
Before calling unlock_archive(archive, predicate=has_clue), predict:
- whether the sealed
Counterfeit Omenenters the selected stream; - where its
(2, "X")clue appears after stable sorting; - the exact decoded word;
- the new
keyword_namesaudit value; and - whether the original archive changes.
The extra clue should make the danger of changing policy visible, not merely rename the same result. Explain why traversal required no edits.
10. Check your understanding
Answer from the completed artifact and its traces rather than from the story alone.
11. Decide whether the challenge is complete
Evidence rubric
| Evidence | Ready to record when |
|---|---|
| Function contracts | Every supplied name, signature, docstring, result, and deliberate side effect matches its promise. |
| Recursive and lazy behavior | You can trace a nested shelf, partial consumption, resume, and exhaustion. |
| Callback policy | You can explain why changing the predicate changes selection without changing traversal. |
| Decorated behavior | Arguments, result, metadata, and one audit event survive the wrapper. |
| Debugging | One record connects a failed assertion to a specific state or boundary and verified rerun. |
| Reproducibility | All 24 assertions pass from a clean notebook state with the solution section closed. |
Record completion only when every statement is true:
This button stores a self-reported marker only in this browser. It does not submit the artifact, grade it, verify identity, or issue a certificate.
Not yet recorded.
Key points
- Focused contracts make a large challenge solvable one returned or yielded value at a time.
- Recursive traversal follows nested data; generators expose that traversal incrementally.
- Callback policy belongs outside the traversal, so the same mechanism supports controlled selection changes.
- Fresh generator objects prevent exploratory consumption from contaminating a complete result.
- A transparent audit decorator forwards arguments, preserves metadata, records one event, and returns the original artifact.
- The successful sentence is evidence produced by composition, not a value hidden directly in the implementation.