Skip to content

CS301 · Examination paper

2 hours 30 minutes · 100 marks · pass 40 · Merit 60 · Distinction 70 Closed book, one A4 sheet of handwritten notes. Section E runs on a laptop against a local copy of the repository; no internet, no editor autocompletion, no AI tooling.

Time budget. A 20 marks / 28 min · B 20 marks / 28 min · C 20 marks / 30 min · D 20 marks / 30 min · E 20 marks / 24 min · checking 10 min.

Sections A–D are written. Section E must run: an answer that does not execute scores nothing, however correct it looks. Marks are in bold on every question.


Section A — Write the specifications (20 marks)

Section titled “Section A — Write the specifications (20 marks)”

Specifications in the house form: precondition, postcondition, exceptions, frame. No implementation. A clause that restates the signature earns nothing.

A1. (6) EnquiryBand.mark_unavailable in booking/models.py carries this docstring and nothing else:

Pure: validate + set status. True if changed, False if already there.
Raises TransitionRefused if the row may not make this move. Never saves.

Write the full specification. The status field is an IntegerField whose values are CANCELLED −100, CLOSED −10, BAND_UNAVAILABLE −1, OPEN 0, BOOKED 1000.

(a) (4) Pre, post (per state), exceptions, frame. (b) (2) Name the state the docstring leaves undefined, and write the clause that closes it.

A2. (6) booking/services.py holds mark_band_unavailable, which refuses when an invoice exists with code has_invoice, calls the model method, saves with update_fields=['status'], then queues an email on commit.

(a) (4) Specify it: pre, post, exceptions, frame. (b) (2) State the invariant that spans the service and the model method, and say which document can hold it.

A3. (4) Two candidate postconditions for extract_quote_fee in payments/wise.py:

S1: returns the fee of the payment option whose payIn equals the request
S2: returns the fee of the option whose payIn equals the request, and raises
when no enabled option matches

(a) (2) Which is stronger, and by which clause. (b) (2) Which is the weakest specification that still serves the caller, and why the weaker one is the better choice here.

A4. (4) Against the specification you wrote in A1, write one test that falsifies the current body. Give the status it constructs, the call, and the single assert. State what the current code returns.

Examines. Ch6 A1, A2, A4 · Ch7 A3.


Section B — What the tests cannot catch (20 marks)

Section titled “Section B — What the tests cannot catch (20 marks)”

B1. (5) A suite calls split_fee(10000) and asserts (1800, 8200). It is green. Name three distinct defect classes that pass it, and for each the test that would catch it.

B2. (5) A permission function’s suite reports 100% line and branch coverage. Name three ways the function can still be wrong, one sentence each.

B3. (5) ExtractQuoteFeeTests contains test_skips_disabled_options, which asserts 2.00 when BALANCE is disabled. State what that expectation pins, then rewrite the test so it fails on the current fallback.

B4. (5) A fee refactor loses eighteen pence per invoice. check.py prints the failures and exits 0.

(a) (2) State what git bisect run python3 check.py reports, and why. (b) (1) The two failures differ by exactly 0.18 from expectation. State what that rules out and which function it accuses. (c) (1) git bisect run over 1000 commits: state the step count. (d) (1) State the one property the script must have for bisect to work.

Examines. Ch3 B1–B3 · Ch8 B4.


Section C — Design an ADT with its rep invariant (20 marks)

Section titled “Section C — Design an ADT with its rep invariant (20 marks)”

C1. (6) InstallmentPlan holds installments: tuple[Decimal, ...] and total: Decimal.

(a) (3) Write the representation invariant and the abstraction function. (b) (2) total is a stored field, not a property returning sum(self.installments). State why, in terms of what an invariant is for. (c) (1) Name the clause that catches a £250 invoice recorded with one £100 payment.

C2. (5) A colleague removes frozen=True from InstallmentPlan, changes the rep to list[Decimal], and adds add_instalment(amount: Decimal) -> None, which appends without touching total.

(a) (3) Give two distinct ways to obtain an instance that violates your RI, one through the new mutator and one that never calls it. Name which is which. (b) (2) Give the smallest change that closes each.

C3. (4) FeeBreakdown defines __eq__ and __hash__ over (amount, booking_fee, planner_fee) and its fields are writable. A corrected booking_fee is assigned after the object was used as a dictionary key.

(a) (2) State what k in payouts returns afterwards, and what len(payouts) returns. (b) (2) State the fix and the one-word reason hashing requires it.

C4. (5) Invoice enforces amount >= booking_fee + planner_fee in Meta.constraints, computes band_fee as a GeneratedField, and checks deposit_amount <= amount with an assert in clean().

(a) (3) For each of the three, state what forces the choice of enforcement point. (b) (2) State what none of the three catches.

Examines. Ch9 C2 · Ch10 C1, C2, C4 · Ch11 C1(a), C3.


D1. (8) A PR claims to fix the case defect in extract_quote_fee.

@@ -712,6 +712,7 @@ def extract_quote_fee(quote: dict, pay_in: str = "BALANCE") -> float:
payment_options = quote.get('paymentOptions', [])
+ pay_in = pay_in.upper()
for option in payment_options:
def test_lowercase_pay_in(self):
quote = {'paymentOptions': [
{'payIn': 'BANK_TRANSFER', 'disabled': False, 'fee': {'total': 1.90}},
{'payIn': 'BALANCE', 'disabled': False, 'fee': {'total': 4.30}},
]}
self.assertEqual(extract_quote_fee(quote, pay_in='balance'), 1.90)

(a) (3) State why the new test is no evidence, naming the value that gives it away. (b) (3) Write the blocking comment: the input, the observed outcome, the intended outcome. Then give your verdict. (c) (2) State the question the fix raises, and whether it blocks.

D2. (6) A branch was cut before a colleague’s PR merged, then edited from that older checkout. The review page shows a small, mostly-additions diff.

(a) (2) State which pair of commits the review page diffs, and why the lost lines cannot appear in it. (b) (2) Give the two commands whose output differs, and what the extra - line is. (c) (2) Give the gate command that would have caught it, and the exit status that means failure.

D3. (6) A diff adds # type: ignore[arg-type] with no reason and no test.

(a) (2) State the three things you ask the author for. (b) (2) State why warn_unused_ignores = False in mypy.ini makes the request urgent rather than pedantic. (c) (2) The ignore moves a defect from one stage to another. Name both stages.

Examines. Ch4 D1 · Ch5 D2 · Ch1 D3(c) · Ch2 D3(a)–(b).


Section E — Practical, on the laptop (20 marks)

Section titled “Section E — Practical, on the laptop (20 marks)”

Work in a local clone of backend/. Paste real output for every part. No internet, no AI tooling.

E1. (6) In payments/test_wise.py, add to ExtractQuoteFeeTests a test asserting that pay_in="balance" returns the balance fee, 4.30, with BANK_TRANSFER listed first in the fixture. Run:

Terminal window
pytest payments/test_wise.py -q

Paste the summary and the failing assert.

E2. (5) Make the smallest fix in payments/wise.py. Rerun the same command. Paste git diff and the new summary line.

E3. (5) Revert only the fix, keeping the test, and rerun. Paste both summaries side by side, then state in one sentence why the pair is the evidence and either run alone is not.

E4. (4) Run mypy-once with the fix reverted. Paste what it reports about extract_quote_fee, and state in one sentence why its silence on the buggy version is not evidence of correctness.

Examines. Ch3 E1–E3 · Ch1 E4 · Ch2 E4.


A1. (a) 4 = 1 pre (status in the five constants) · 2 post, one mark for BOOKED → raises, status unchanged, one for BAND_UNAVAILABLE → False, status unchanged, with the OPEN/CLOSED → True + −1 case required for full marks · 1 frame (never saves). (b) 2 = 1 for naming CANCELLED · 1 for the clause (raises TransitionRefused('cancelled')). A post that lists only BOOKED and BAND_UNAVAILABLE scores 2 of 4: the specification is the defect, and an answer that reproduces it is the answer A1 is testing.

A2. (a) 4 = 1 pre · 1 post on the return and the saved status · 1 exceptions naming has_invoice and booked · 1 frame (update_fields=['status'], email queued on commit, nothing else written). (b) 2 = 1 for the invariant (a band with an outstanding invoice cannot go unavailable) · 1 for “the service’s docstring and a test”, not the model method’s.

A3. (a) 2 = S2 stronger; the clause is the exception, which S1 leaves open. 1 mark if the stronger one is named without the clause. (b) 2 = 1 for S1 being weakest · 1 for the reason: the caller can supply the raise itself, and the weaker spec leaves an implementation free to return the fallback fee without breaking a client.

A4. 4 = 1 for Status.CANCELLED · 1 for the call · 1 for assert raises TransitionRefused (or is False under the closed specification — accept either only if it matches the A1(b) clause written) · 1 for “returns True and sets −1”. An answer that asserts the current behaviour scores 0 on the last mark.

B1. 5 = 1 per class, 2 for the third only if its catching test is specific: (a) each share rounded independently, so the pair can exceed the gross — property test over 1..500; (b) the function ignores its argument and returns a constant — second input, 999; (c) round() banker’s rounding instead of half up — an input whose 18% ends in half a penny. A class named without a test scores 0 for that class; three classes and no tests scores 0 overall.

B2. 5 = 1 each, 2 for the third: wrong branch selected by data no fixture holds · an authorisation check on an object type never in the fixture, enforced only in the UI · lines executed is not outcomes asserted (assertions of type and sign pass for every value the annotation permits). “Missing tests” is not an answer — coverage already claims there are none missing.

B3. 5 = 2 for what it pins (the fallback’s value, 2.00, i.e. current behaviour not the contract) · 3 for the rewrite: BANK_TRANSFER first in the fixture, pay_in="BALANCE" with BALANCE disabled, pytest.raises(ValueError). A rewrite that only tightens the number to 4.30 scores 2.

B4. (a) 2 = it reports the last commit as good, or names the wrong commit, because the script’s exit status is 0 and bisect reads only the status. 1 mark for “it finds nothing” without the status mechanism. (b) 1 = a subtraction error would differ by the planner fee or by a non-round amount; a constant whole-pound difference accuses the quantiser in booking_fee. (c) 1 = 10 steps. (d) 1 = non-zero on failure, no interactive input.

C1. (a) 3 = 1 for len(installments) >= 1 · 1 for every amount > 0 · 1 for sum(installments) == total; the AF is worth 0 on its own and 1 of the 3 must come from it — an answer with an RI and no AF scores 2. (b) 2 = a property makes the predicate true by construction, so it can never fail, and an invariant that cannot fail is a comment. (c) 1 = sum(installments) == total.

C2. (a) 3 = 2 for the mutator path (add_instalment appends without checking the sum or the sign) · 1 for the leak (add_instalment never called; a caller mutating the list the accessor handed out, or a float-derived Decimal passed to the constructor). Naming two paths that are both “the mutator” scores 2. (b) 2 = 1 per fix: re-check the RI in the mutator (or make it total-aware) · return tuple(self.installments) so the rep is unreachable.

C3. (a) 2 = 1 for False · 1 for len(payouts) == 1 — the entry is still there under the old hash. (b) 2 = 1 for @dataclass(frozen=True) (or a hash over immutable fields plus no setter) · 1 for “immutability” as the reason, not “consistency”.

C4. (a) 3 = 1 each: several writers bypass clean(), so the invariant must sit in the database · band_fee is derived, so it is computed and cannot drift · one writer, so an assert is enough and an O(n) check in a hot loop is not. (b) 2 = consistency, not intent: whether the fees are the agreed ones, or whether booking_fee was computed at the right percentage.

D1. (a) 3 = 1 for “it passes on the base commit” · 2 for naming 1.90 as the value the bug produced, so the assertion was copied from output. (b) 3 = 2 for the comment carrying input, observed, intended (pay_in='balance' with BANK_TRANSFER first returns 1.90; the balance fee is 4.30) · 1 for “request changes, on the test only”. A verdict of approve scores 0 on the second half however good the comment. (c) 2 = 1 for “where is the legal set of payIn values defined, and should an unknown value raise” · 1 for “ask, not block” with the reason (the two-line fix is correct).

D2. (a) 2 = 1 for merge base → head · 1 for “a line that exists only on master’s newer side was never in the base, so it appears in no hunk”. (b) 2 = 1 for git diff --stat origin/master...HEAD against git diff --stat origin/master HEAD · 1 for identifying the extra - as the colleague’s import. (c) 2 = 1 for git merge-base --is-ancestor origin/master HEAD (or git log --oneline HEAD..origin/master listing nothing) · 1 for exit status 1.

D3. (a) 2 = the specific error suppressed, the reason on the same line with a ticket and date, and a test that fails without it — all three for 2, two for 1. (b) 2 = nothing ever reports the ignore as stale, so it outlives its reason and silently widens the Any hole. (c) 2 = 1 for static → never (not static → dynamic); 1 for the consequence: no stage catches the defect the ignore hides.

E1. 6 = 2 fixture lists BANK_TRANSFER first (otherwise the fallback satisfies the assert) · 2 asserts 4.30 · 2 for a pasted run in which the new test fails. A test that passes scores 0: on the current code it cannot pass unless the expectation is 1.90.

E2. 5 = 3 for pay_in = pay_in.upper() before the loop (accept a str.upper() bound once outside; an if per option scores 1) · 2 for the pasted git diff and a green summary.

E3. 5 = 3 for two pasted summaries, one failing before the fix and one passing after · 2 for the sentence: a test that is green on both sides is decoration, because it cannot report the regression returning.

E4. 4 = 2 for real mypy-once output (an error scores 0 unless explained) · 2 for the reason: pay_in is a str and "balance" is a str, and quote: dict is dict[Any, Any], so the checker has nothing to disagree with.


BandThe script
Distinction (70+)A gives four parts for both functions and names CANCELLED as the specification’s hole; A3 chooses the weaker spec and justifies it per caller; B names three classes with the test that catches each and explains the exit status; C writes an RI as a predicate, an AF that is total on it, and places each invariant by writer count; D blocks the test and names 1.90, and reads the two-dot/three-dot pair correctly; E shows red-before-green-after with real output and explains mypy’s silence by Any.
Merit (60–69)A complete for one function, frame missing on the other; B1 two classes with tests; C1 RI right, AF vague (“the instalments”), C2 finds the mutator only; D1 blocks but the comment names the line rather than the input; E runs, but the fixture lists BALANCE first so the test would pass on the broken code.
Pass (40–59)A has pre and post, no exceptions or frame; B lists “missing tests” or names classes without tests; C1 copies the RI from the chapter but cannot say why total is stored; D approves with questions, or requests changes without naming the blocking item; E produces a test that runs and passes on the buggy code.
Fail (<40)A restates the signature; C has no predicate, only prose; D reviews the description rather than the diff; E has no pasted output, or the test asserts 1.90.

Section floors. A ≥ 8/20, C ≥ 10/20, E ≥ 8/20. A candidate who cannot write a contract, state a rep invariant, or run the suite has not met the module’s outcome, whatever the aggregate.

Boundaries. 40 pass, 60 Merit, 70 Distinction, on the total out of 100, with the floors applied first.