{
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: Clear the Buggy Spaceport for Launch
Build a layered pytest suite for a playful spaceport dispatcher, expose six contract-backed defects, repair them one at a time, and leave boundary, file, process, regression, and property evidence.
python-foundations
testing-python-programs
unit-challenge
Course progress
0%
1. Receive the launch inspector’s warning
Tiny research ships are queuing at Aurora Pocket Spaceport. The dispatcher normalizes callsigns, calculates landing fees, stamps clearances, stores a JSON manifest, and exposes one small command. It looks ready—until the launch inspector reports six strange observations:
- tabs sometimes survive inside callsigns;
- a cargo pod exactly on a fee boundary receives the lower price;
Truecan be accepted as a mass of one kilogram;- clearance timestamps ignore the spaceport’s controlled clock;
- a saved manifest can quietly lose all but its first clearance; and
- an invalid command writes its diagnostic into the data channel.
Your job is to build the protection system before opening the launch gates. Every defect is tied to the public contract below. There are no secret trivia rules. Write a red test for one observation, confirm its failure phase and values, repair one production cause, and rerun the focused node before moving on.
NoteAct as an inspector, not a guesser
Do not rewrite the module from scratch or change expected results to match its current output. Let each public rule produce a focused failure. Keep the smallest useful report, one repair, and the clean rerun as evidence.
The finished result should feel satisfying: one compact suite stops six shape-shifting bugs from boarding the ships again.
2. Translate the spaceport rules into observable behavior
Callsigns
normalize_callsign(text) must:
- accept a string;
- trim outside whitespace;
- collapse every inside run of Unicode whitespace—including spaces and tabs—to one ordinary space;
- uppercase letters;
- preserve hyphens and digits; and
- raise
ValueError("callsign must not be empty")when no non-whitespace text remains.
Examples:
| Input | Result |
|---|---|
" nova 7 " |
"NOVA 7" |
"nova\t7" |
"NOVA 7" |
"lx-42" |
"LX-42" |
Landing fees
landing_fee(mass_kg, hazardous=False) must reject booleans, non-numeric values, and negative values. For accepted finite numeric values:
| Mass | Base fee |
|---|---|
0 <= mass < 100 |
5 credits |
100 <= mass < 500 |
12 credits |
mass >= 500 |
25 credits |
Hazardous cargo adds exactly 7 credits after the base tier is selected.
Clearances
build_clearance(record, clock) receives a mapping with callsign, mass_kg, and optional hazardous. It must return a new dictionary containing:
- normalized
callsign; - original numeric
mass_kg; - Boolean
hazardous; - calculated
fee; and created_atfrom exactly one call to the supplied zero-argumentclock, formatted withdatetime.isoformat().
It must not mutate record.
Manifests
save_clearances(path, records) writes every record as one UTF-8 JSON array and returns the number written. load_clearances(path) returns the complete list. An empty list and non-ASCII callsign must round-trip. Invalid JSON is allowed to raise json.JSONDecodeError with its original context.
Command boundary
The module supports:
On success it writes only the integer fee plus a newline to stdout, writes nothing to stderr, and returns status 0. Invalid mass writes spaceport-dispatch: <message> plus a newline to stderr, leaves stdout empty, and returns status 2.
3. Start from the contract
Create a disposable project:
Install the tools in an isolated environment if they are not already present:
Copy this deliberately buggy but syntactically valid module into spaceport_dispatch.py:
import argparse
import json
import math
import sys
from datetime import datetime, timezone
def normalize_callsign(text):
"""Trim, collapse whitespace, and uppercase a non-empty callsign."""
if not isinstance(text, str):
raise TypeError("callsign must be text")
normalized = " ".join(text.strip().split(" ")).upper()
if not normalized:
raise ValueError("callsign must not be empty")
return normalized
def landing_fee(mass_kg, hazardous=False):
"""Return the landing fee for one finite non-negative cargo mass."""
if not isinstance(mass_kg, (int, float)):
raise TypeError("mass must be numeric")
if not math.isfinite(mass_kg) or mass_kg < 0:
raise ValueError("mass must be finite and non-negative")
if mass_kg <= 100:
fee = 5
elif mass_kg < 500:
fee = 12
else:
fee = 25
if hazardous:
fee += 7
return fee
def build_clearance(record, clock):
"""Build a new normalized clearance dictionary."""
hazardous = bool(record.get("hazardous", False))
return {
"callsign": normalize_callsign(record["callsign"]),
"mass_kg": record["mass_kg"],
"hazardous": hazardous,
"fee": landing_fee(record["mass_kg"], hazardous),
"created_at": datetime.now(timezone.utc).isoformat(),
}
def save_clearances(path, records):
"""Write every clearance as a UTF-8 JSON array and return its count."""
records = list(records)
path.write_text(
json.dumps(records[:1], ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
return len(records)
def load_clearances(path):
"""Load and return a clearance list from UTF-8 JSON."""
return json.loads(path.read_text(encoding="utf-8"))
def build_parser():
parser = argparse.ArgumentParser(prog="spaceport-dispatch")
subparsers = parser.add_subparsers(dest="command", required=True)
fee_parser = subparsers.add_parser("fee")
fee_parser.add_argument("mass")
fee_parser.add_argument("--hazardous", action="store_true")
return parser
def main(argv=None, stdout=None, stderr=None):
"""Run the fee command and return a process-style status."""
if stdout is None:
stdout = sys.stdout
if stderr is None:
stderr = sys.stderr
arguments = build_parser().parse_args(argv)
try:
mass = float(arguments.mass)
fee = landing_fee(mass, arguments.hazardous)
except (TypeError, ValueError) as error:
stdout.write(f"spaceport-dispatch: {error}\n")
return 2
stdout.write(f"{fee}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())Do not fix a suspicious line before a test exposes its violated rule. Begin with this scaffold:
from datetime import datetime, timezone
from io import StringIO
import pytest
from spaceport_dispatch import (
build_clearance,
landing_fee,
load_clearances,
main,
normalize_callsign,
save_clearances,
)
@pytest.fixture
def fixed_clock():
def clock():
return datetime(2042, 4, 5, 6, 7, tzinfo=timezone.utc)
return clock
def test_ordinary_callsign_is_normalized():
assert normalize_callsign(" nova 7 ") == "NOVA 7"
def test_light_non_hazardous_pod_costs_five():
assert landing_fee(25) == 5
def test_main_prints_an_ordinary_fee():
stdout = StringIO()
stderr = StringIO()
status = main(["fee", "25"], stdout, stderr)
assert status == 0
assert stdout.getvalue() == "5\n"
assert stderr.getvalue() == ""Run the baseline:
These ordinary cases should pass. That does not clear the spaceport; it only confirms the project imports and three ordinary routes are ready for deeper inspection.
4. Inspect one subsystem at a time
Build the suite in this order:
- callsign examples and empty input;
- fee thresholds, hazardous addition, and invalid inputs;
- fixed-clock clearance construction and input immutability;
- manifest round trips with several records, Unicode, and an empty list;
- direct command stdout/stderr/status behavior;
- one child-process smoke test;
- branch coverage questions; and
- callsign properties and a named tab regression.
This order keeps the earliest red report narrow. If you add every test at once, six failures compete for attention and encourage changing several causes together.
Required public test names
Use these names where they apply so the progressive commands remain useful:
test_tab_is_collapsed_in_callsign_regressiontest_fee_boundariestest_hazardous_fee_adds_seventest_invalid_masses_are_rejectedtest_clearance_uses_supplied_clock_without_mutating_recordtest_manifest_round_trips_every_recordtest_invalid_mass_uses_stderr_and_status_twotest_module_command_runs_as_a_processtest_normalization_is_idempotenttest_normalized_callsign_contains_only_single_spaces
You may add descriptive helpers, fixtures, and test names. Do not rename production functions or broaden their signatures to avoid testing the contract.
5. Run progressive assertions
Check 1 — collect the baseline
Expected starting evidence: three collected tests pass.
Checks 2–6 — normalize callsigns
Create a five-row parameter table for ordinary spaces, repeated spaces, a tab, hyphen/digits, and outside whitespace. Add a separate empty-input exception test. Run:
The tab or repeated-space node should fail against the starter. Preserve the smallest failing tab as the required regression before repairing the split.
Checks 7–20 — protect fees
Create seven base-fee rows including values just below, at, and just above 100 and 500. Add three hazardous rows and four invalid rows: True, -0.1, float("inf"), and "heavy".
The 100 and Boolean cases should become separate red nodes. Confirm the compared fee or missing exception before editing production.
Check 21 — control the clock and preserve the caller’s record
Use fixed_clock. Copy the input record before the call. Assert the complete clearance, unchanged input, and exact timestamp. The starter should fail because its timestamp comes from the real clock.
Checks 22–24 — round-trip manifests
Use tmp_path to save and load two records including "ÓRBITA-7"; assert the returned count and complete list. Add an empty-list round trip and an invalid JSON exception test.
The two-record test should reveal that only one record was written.
Checks 25–27 — inspect the command boundary
Retain the ordinary direct test. Add the named invalid-mass test and one subprocess smoke test using sys.executable -m spaceport_dispatch fee 100.
The invalid direct test should show a diagnostic in stdout instead of stderr. The process test should use a valid boundary and expect status 0, stdout "12\n", and empty stderr.
Checks 28–29 — search normalization properties
Define a bounded text strategy containing letters, digits, spaces, tabs, and hyphens. Add idempotence and allowed-whitespace properties. The no-single-space property may find the tab before your named regression does; retain both forms of evidence.
Inspect coverage without manufacturing a grade
For every missing line or branch, write one of:
- public risk → add a focused assertion;
- already covered by a clearer boundary → explain why duplication adds little;
- unreachable after validation → consider simplifying production; or
- outside this challenge’s public contract → record the limit.
Do not change assertions merely to reach 100%.
Finish from a clean state
The suite should collect approximately 30 cases and pass. The real process should print 12 and exit successfully.
6. Use the hint ladder only when needed
Hint 1
For callsigns, no-argumentsplit() recognizes runs of whitespace; split(" ") recognizes only ordinary spaces and retains empty pieces between repeated spaces. For fees, remember that bool is a subclass of int, so reject booleans before the ordinary numeric type check. Write each failing test before making either repair.
Hint 2
The fixed clock is a zero-argument callable.build_clearance should call it once and format that returned datetime. The manifest writer should serialize the complete records list, not a slice. Read the real temporary file back to prove what crossed the boundary.
Hint 3
For the invalid command, inspectstdout.getvalue(), stderr.getvalue(), and status independently. For the property strategy, generate the accepted callsign alphabet directly and assert that output contains neither tabs nor double spaces and equals its stripped form. Preserve the minimal tab example as a named regression.
7. Keep debugging evidence
Retain one record for a defect that was not the first one you noticed:
| Field | Your evidence |
|---|---|
| Contract rule | What exact public promise applies? |
| Focused node | Which node or parameter ID did you run? |
| Red observation | Which phase, expression, and values appeared? |
| Hypothesis | Which single production cause explains them? |
| Controlled repair | Which one change tested the hypothesis? |
| Focused rerun | What changed in the same node? |
| Complete rerun | What did the approximately 30-case suite report? |
| Remaining limit | Which risk is still outside this suite? |
Avoid entries such as “tests failed, fixed code.” A useful record could say:
test_fee_boundaries[one-hundred-starts-middle-tier]failed in the call phase: observed 5, expected 12. The contract makes 100 inclusive in the middle tier, while production uses<= 100for the light tier. Changing only that comparison to< 100made the node and complete fee table green.
8. Compare a complete inspection suite
Open this only after your own focused repairs. The solution is one design, not a reason to replace a working readable suite with identical formatting.
Show the complete test suite
import json
import subprocess
import sys
from datetime import datetime, timezone
from io import StringIO
from pathlib import Path
import pytest
from hypothesis import given
from hypothesis import strategies as st
from spaceport_dispatch import (
build_clearance,
landing_fee,
load_clearances,
main,
normalize_callsign,
save_clearances,
)
@pytest.fixture
def fixed_clock():
def clock():
return datetime(2042, 4, 5, 6, 7, tzinfo=timezone.utc)
return clock
@pytest.mark.parametrize(
("raw", "expected"),
[
pytest.param("nova 7", "NOVA 7", id="ordinary-space"),
pytest.param("nova 7", "NOVA 7", id="repeated-spaces"),
pytest.param("nova\t7", "NOVA 7", id="tab"),
pytest.param("lx-42", "LX-42", id="hyphen-and-digits"),
pytest.param(" órbita 7 ", "ÓRBITA 7", id="outside-space"),
],
)
def test_callsign_examples(raw, expected):
assert normalize_callsign(raw) == expected
def test_tab_is_collapsed_in_callsign_regression():
assert normalize_callsign("0\t0") == "0 0"
def test_empty_callsign_is_rejected():
with pytest.raises(ValueError, match="callsign must not be empty"):
normalize_callsign(" \t ")
@pytest.mark.parametrize(
("mass_kg", "expected"),
[
pytest.param(0, 5, id="zero"),
pytest.param(99.9, 5, id="below-one-hundred"),
pytest.param(100, 12, id="one-hundred-starts-middle-tier"),
pytest.param(100.1, 12, id="above-one-hundred"),
pytest.param(499.9, 12, id="below-five-hundred"),
pytest.param(500, 25, id="five-hundred-starts-heavy-tier"),
pytest.param(900, 25, id="ordinary-heavy"),
],
)
def test_fee_boundaries(mass_kg, expected):
assert landing_fee(mass_kg) == expected
@pytest.mark.parametrize(
("mass_kg", "expected"),
[
pytest.param(25, 12, id="light-plus-seven"),
pytest.param(100, 19, id="middle-plus-seven"),
pytest.param(500, 32, id="heavy-plus-seven"),
],
)
def test_hazardous_fee_adds_seven(mass_kg, expected):
assert landing_fee(mass_kg, hazardous=True) == expected
@pytest.mark.parametrize(
("mass_kg", "error_type"),
[
pytest.param(True, TypeError, id="boolean"),
pytest.param("heavy", TypeError, id="text"),
pytest.param(-0.1, ValueError, id="negative"),
pytest.param(float("inf"), ValueError, id="infinite"),
],
)
def test_invalid_masses_are_rejected(mass_kg, error_type):
with pytest.raises(error_type):
landing_fee(mass_kg)
def test_clearance_uses_supplied_clock_without_mutating_record(fixed_clock):
record = {"callsign": " nova 7 ", "mass_kg": 100, "hazardous": True}
original = record.copy()
observed = build_clearance(record, fixed_clock)
assert observed == {
"callsign": "NOVA 7",
"mass_kg": 100,
"hazardous": True,
"fee": 19,
"created_at": "2042-04-05T06:07:00+00:00",
}
assert record == original
def test_manifest_round_trips_every_record(tmp_path):
path = tmp_path / "clearances.json"
records = [
{"callsign": "NOVA 7", "fee": 12},
{"callsign": "ÓRBITA-7", "fee": 25},
]
count = save_clearances(path, records)
assert count == 2
assert load_clearances(path) == records
assert "ÓRBITA-7" in path.read_text(encoding="utf-8")
def test_empty_manifest_round_trips(tmp_path):
path = tmp_path / "empty.json"
assert save_clearances(path, []) == 0
assert load_clearances(path) == []
def test_invalid_manifest_preserves_json_error(tmp_path):
path = tmp_path / "broken.json"
path.write_text("{not-json", encoding="utf-8")
with pytest.raises(json.JSONDecodeError):
load_clearances(path)
def test_main_prints_an_ordinary_fee():
stdout = StringIO()
stderr = StringIO()
status = main(["fee", "25"], stdout, stderr)
assert status == 0
assert stdout.getvalue() == "5\n"
assert stderr.getvalue() == ""
def test_invalid_mass_uses_stderr_and_status_two():
stdout = StringIO()
stderr = StringIO()
status = main(["fee", "nan"], stdout, stderr)
assert status == 2
assert stdout.getvalue() == ""
assert stderr.getvalue() == (
"spaceport-dispatch: mass must be finite and non-negative\n"
)
def test_module_command_runs_as_a_process():
project_root = Path(__file__).resolve().parents[1]
result = subprocess.run(
[sys.executable, "-m", "spaceport_dispatch", "fee", "100"],
cwd=project_root,
text=True,
capture_output=True,
check=False,
)
assert result.returncode == 0
assert result.stdout == "12\n"
assert result.stderr == ""
callsign_text = st.text(
alphabet=st.characters(
categories=("L", "N"),
include_characters=" \t-",
),
min_size=1,
max_size=40,
).filter(lambda text: bool(text.split()))
@given(callsign_text)
def test_normalization_is_idempotent(text):
once = normalize_callsign(text)
assert normalize_callsign(once) == once
@given(callsign_text)
def test_normalized_callsign_contains_only_single_spaces(text):
normalized = normalize_callsign(text)
assert "\t" not in normalized
assert " " not in normalized
assert normalized == normalized.strip()cwd deliberately; the goal is a fresh child process that can find the module without depending on state from a previous test.
Show the repaired application module
import argparse
import json
import math
import sys
def normalize_callsign(text):
"""Trim, collapse whitespace, and uppercase a non-empty callsign."""
if not isinstance(text, str):
raise TypeError("callsign must be text")
normalized = " ".join(text.split()).upper()
if not normalized:
raise ValueError("callsign must not be empty")
return normalized
def landing_fee(mass_kg, hazardous=False):
"""Return the landing fee for one finite non-negative cargo mass."""
if isinstance(mass_kg, bool) or not isinstance(mass_kg, (int, float)):
raise TypeError("mass must be numeric")
if not math.isfinite(mass_kg) or mass_kg < 0:
raise ValueError("mass must be finite and non-negative")
if mass_kg < 100:
fee = 5
elif mass_kg < 500:
fee = 12
else:
fee = 25
if hazardous:
fee += 7
return fee
def build_clearance(record, clock):
"""Build a new normalized clearance dictionary."""
hazardous = bool(record.get("hazardous", False))
return {
"callsign": normalize_callsign(record["callsign"]),
"mass_kg": record["mass_kg"],
"hazardous": hazardous,
"fee": landing_fee(record["mass_kg"], hazardous),
"created_at": clock().isoformat(),
}
def save_clearances(path, records):
"""Write every clearance as a UTF-8 JSON array and return its count."""
records = list(records)
path.write_text(
json.dumps(records, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
return len(records)
def load_clearances(path):
"""Load and return a clearance list from UTF-8 JSON."""
return json.loads(path.read_text(encoding="utf-8"))
def build_parser():
parser = argparse.ArgumentParser(prog="spaceport-dispatch")
subparsers = parser.add_subparsers(dest="command", required=True)
fee_parser = subparsers.add_parser("fee")
fee_parser.add_argument("mass")
fee_parser.add_argument("--hazardous", action="store_true")
return parser
def main(argv=None, stdout=None, stderr=None):
"""Run the fee command and return a process-style status."""
if stdout is None:
stdout = sys.stdout
if stderr is None:
stderr = sys.stderr
arguments = build_parser().parse_args(argv)
try:
mass = float(arguments.mass)
fee = landing_fee(mass, arguments.hazardous)
except (TypeError, ValueError) as error:
stderr.write(f"spaceport-dispatch: {error}\n")
return 2
stdout.write(f"{fee}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())9. Verify the changed requirement and process boundary
The launch inspector adds one rule after the first clean suite:
Cargo with mass exactly 1,000 kg receives a 4-credit heavy-lift surcharge, applied after the hazardous surcharge. Values below 1,000 are unchanged.
Use red–green–refactor:
- add rows for
999.9and1000, with and without hazardous cargo; - run the
1000node against the current implementation and confirm the fee differs by exactly 4; - add the smallest clear production rule;
- run the fee group, complete suite, and process command; and
- inspect branch coverage for the new threshold.
Do not change old expected fees below 1,000. Do not mix the feature with a refactor until the changed contract is green.
Then launch from a fresh shell:
Expected result after the change is 36: heavy base 25, hazardous 7, and heavy-lift surcharge 4.
10. Check your understanding
11. Record the challenge result
Evidence rubric
| Evidence | Ready to record when |
|---|---|
| Callsigns | Ordinary, repeated, tab, Unicode, hyphen, empty, regression, and property behavior pass. |
| Fees | Both thresholds, hazardous addition, Boolean/text/negative/infinite rejection, and the changed 1,000-kg rule pass. |
| Clearance | The exact injected time appears and the caller’s record remains unchanged. |
| Manifest | Several records, Unicode, empty data, and invalid JSON use a real isolated file boundary. |
| Command | Direct and child-process evidence preserve stdout, stderr, and status. |
| Coverage | Missing statements and branches are interpreted as risks or explicit limits, not chased as a grade. |
| Reproducibility | The focused groups and complete suite pass in a clean process. |
| Debugging | One record connects the contract, failed node, exact observation, one repair, and clean rerun. |
This button stores a self-reported marker only in this browser. It does not submit work, grade the suite, verify identity, or issue a certificate.
Not yet recorded.
Key points
- A testing challenge begins with a public contract and a reproducible red observation, not a hunt for secret bugs.
- Parametrized boundary IDs make fee failures precise without repetitive test bodies.
tmp_path, explicit streams, a supplied clock, and one subprocess provide real evidence at different boundaries.- Logic bugs remain valid Python; tests expose their observed behavior rather than syntax markers.
- A Hypothesis property explores a general whitespace rule while a named tab regression preserves the discovered history.
- Statement and branch coverage guide investigation but never replace assertions or justify changing correct expected values.
- Red–green–refactor adds the later 1,000-kg requirement without mixing it into the original repairs.
- A clean complete rerun and an evidence-rich debugging record make the suite explainable to the next inspector.
Continue learning
- pytest documentation: getting started and reading failures
- pytest documentation: fixtures and temporary test worlds
- pytest documentation: parametrization
- Coverage.py documentation: branch coverage
- Hypothesis documentation: generated examples and shrinking
- Next: Code Quality and Maintainability Overview