Capture Coupon Order Before One Rate Extract
Pin coupon order before you extract one rate function. A mixed checkout hides stacking, mutation, and file writes in one body. Move only the pure rate math after those edges hold.
The cut this article defends
A wide helper can change cents without a red test. Coupon order and member rate often share one block. Pin both, then move the smallest pure piece.
This draft does not claim a measured production incident. It uses a small unexecuted example to show the sequence. Run the commands yourself before you trust the baseline.
The messy function
Treat the listing below as unexecuted proposal code only. It is not a billing service, a tax engine, or an audit. It only exposes the mixed behavior you should not split blindly.
from decimal import Decimal, ROUND_HALF_UP
from pathlib import Path
def checkout(cart, coupon, dest):
total = Decimal('0')
for item in cart['items']:
total += Decimal(str(item['price'])) * item['qty']
cart['seen'] = cart.get('seen', 0) + 1
if coupon == 'SAVE10':
total = total * Decimal('0.90')
elif coupon == 'FIVE':
total = total - Decimal('5.00')
if cart.get('member'):
total = total * Decimal('0.95')
total = total.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
Path(dest).write_text(str(total))
return total
Three distinct smells sit inside that one function. The loop mutates cart while it sums prices. A percent coupon and a member rate stack in fixed order.
The same call writes a file before it returns. Those smells need different tests and different diffs. A pure extract can keep the math and still drop the mutation.
A file move can change path errors without touching cents. Split one concern only after each pinned edge is green. Leave the write and the mutation in the wrapper.
What the tests must lock
Lock six observable edges before any rename or move. Empty carts, stacked rates, negative results, and file text all count. Skip any edge and the later extract can lie.
Use these fixtures as the contract rows for the suite. Prices stay strings so binary float noise never enters. Expected cents come from a capture run, not from memory.
| Row | Items | Coupon | Member | Also assert |
|---|---|---|---|---|
| E1 | none | none | no | return text, missing seen, file text |
| E2 | 10.00 x2 | SAVE10 | yes | SAVE10 before member, seen equals 1 |
| E3 | 1.00 x1 | FIVE | no | current negative total, file matches |
| E4 | 1.015 x1 | none | no | captured half-up text, not a guess |
| E5 | 1.00 and 2.50 | none | no | seen equals 2, sum text |
| E6 | 10.00 x1 | FIVE | yes | FIVE before member rate |
Do not hand-guess the half-up row in that table. Decimal half-up on a third digit is easy to misremember. Capture the return text once, then paste that string.
Read the decimal quantize notes beside your capture. The official library page is the primary source for that rule. A blog memory of 1.005 is not a contract.
The FIVE row may show a negative total today. Pin that sign when the current function returns it. Do not fix the sign in the same change as the extract.
Steps
1. Freeze the module boundary
Copy checkout into a tiny module named checkout_mix.py. Import it from the test file without editing the body. A cleanup during the copy hides the baseline you need.
2. Add a capture command
Print six rows before you write any assertions. Each row should show return text, seen count, and file text. Save that transcript beside the tests as a golden file.
import tempfile
from pathlib import Path
from checkout_mix import checkout
ROWS = [
('E1', {'items': []}, None),
('E2', {'items': [{'price': '10.00', 'qty': 2}], 'member': True}, 'SAVE10'),
('E3', {'items': [{'price': '1.00', 'qty': 1}]}, 'FIVE'),
('E4', {'items': [{'price': '1.015', 'qty': 1}]}, None),
('E5', {'items': [{'price': '1.00', 'qty': 1}, {'price': '2.50', 'qty': 1}]}, None),
('E6', {'items': [{'price': '10.00', 'qty': 1}], 'member': True}, 'FIVE'),
]
def main():
lines = []
with tempfile.TemporaryDirectory() as raw:
root = Path(raw)
for name, cart, coupon in ROWS:
dest = root / (name + '.txt')
got = checkout(cart, coupon, dest)
seen = cart.get('seen', 'missing')
line = name + '|' + str(got) + '|' + str(seen) + '|' + dest.read_text()
lines.append(line)
print('\n'.join(lines))
if __name__ == '__main__':
main()
The capture script should call the current function only. It should not import the future helper or a new path. If the script fails, stop and fix the fixture data.
python capture_checkout.py > tests/golden_checkout.txt
That redirect is a proposal command, not a recorded run. Review the golden file before you convert rows into asserts. A blank line or a traceback means the baseline is not ready.
3. Turn the transcript into tests
Write one test function for each row in the table. Assert the return value, the destination text, and the seen count. Use tmp_path so the file write stays inside the sandbox.
from checkout_mix import checkout
def test_save10_then_member_rate(tmp_path):
cart = {
'items': [{'price': '10.00', 'qty': 2}],
'member': True,
}
dest = tmp_path / 'total.txt'
got = checkout(cart, 'SAVE10', dest)
assert str(got) == '17.10'
assert dest.read_text() == '17.10'
assert cart['seen'] == 1
def test_half_up_row_matches_capture(tmp_path):
cart = {'items': [{'price': '1.015', 'qty': 1}]}
dest = tmp_path / 'total.txt'
got = checkout(cart, None, dest)
captured = 'PASTE_FROM_GOLDEN_FILE'
assert str(got) == captured
assert dest.read_text() == captured
assert cart['seen'] == 1
The SAVE10 example uses values that stay exact in Decimal. Twenty dollars times 0.90 then times 0.95 equals 17.10. Confirm that string in the golden file before you lock it.
The half-up test must not ship with a guessed cent string. Paste the captured token in place of the placeholder. A later mismatch means rounding moved, not that the file drifted alone.
4. Run tests against unchanged code
The first green run is the baseline, not a refactor win. If a test fails now, the assertion guessed the wrong text. Change the test until it matches the current function.
python -m pytest tests/test_checkout_mix.py -q
Keep this command stable for the later rerun. A new flag or a new marker can hide a miss. The same command is the comparison you want.
5. Extract only the pure rate function
Move only the arithmetic into a helper named apply_rates. Pass a Decimal, a coupon string, and a member flag. Do not pass cart, dest, or Path into that helper.
def apply_rates(total, coupon, member):
if coupon == 'SAVE10':
total = total * Decimal('0.90')
elif coupon == 'FIVE':
total = total - Decimal('5.00')
if member:
total = total * Decimal('0.95')
return total.quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
def checkout(cart, coupon, dest):
total = Decimal('0')
for item in cart['items']:
total += Decimal(str(item['price'])) * item['qty']
cart['seen'] = cart.get('seen', 0) + 1
member = bool(cart.get('member'))
total = apply_rates(total, coupon, member)
Path(dest).write_text(str(total))
return total
The wrapper still owns the loop, the mutation, and the write. The public signature of checkout stays the same. The diff should stay small enough to read in one pass.
Passing a bool of the member field keeps the old truth check. A missing member still behaves as false under that check. Do not invent a default member policy in this diff.
6. Re-run the same six tests
Do not add assertions in this same diff. A new rule, such as banning negative totals, is a later change. Green tests mean the pinned edges survived the move.
python -m pytest tests/test_checkout_mix.py -q
git diff -- checkout_mix.py
Read the diff before you commit the extract. You should see one new function and one call site. You should not see a changed coupon string or a network import.
7. Reject a wider helper
Stop if the new function opens files or reads env vars. Stop if it mutates cart or renames public callers. Stop if you cannot explain the change in four lines.
Decision table
| Signal you see | Smallest safe action | Do not do this |
|---|---|---|
| Math mixed with a file write | Extract rate math only | Move Path into the helper |
| First test run fails | Fix the assertion | Edit checkout to satisfy a guess |
| Negative FIVE result | Pin the current sign | Clamp to zero in this diff |
| seen key untested | Add the mutation assert | Extract the loop and the write |
| Half-up value unclear | Paste the captured string | Type a remembered cent value |
| Helper needs the cart dict | Pass plain inputs instead | Share the mutable cart |
Use the table when a review comment asks for a bigger split. The right column is a stop rule, not a style preference. A wider helper waits for its own pinned edges.
Failures this sequence is meant to catch
Test and extract in one commit, and blame becomes unclear. You cannot tell whether the assert or the math moved. Split those commits even when the change feels small.
Asserting file text alone still misses a return drift. The function can write the new text and return it too. Pin both sides, plus the seen count, on every row.
Sharing the cart dict into the helper moves the mutation. Seen counts then depend on call order inside the helper. Pass plain inputs so the loop remains the only writer.
Updating the golden file after the extract blesses the bug. The capture step is legal only on unchanged code. A post-extract refresh is a behavior change with extra steps.
Using float literals for prices makes the pin unstable. Binary fractions can change the cent you meant to lock. Keep price strings and Decimal conversion in one place.
Where a free runner fits
A free model can list candidate rows from the messy function. Keep a row only when you can trace it to current code. Delete any row that invents a tax rule or a currency.
Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode's free model access can draft that candidate row list. MonkeyCode's free server option can run the same pytest command.
Both claims here are availability only, not a speed benchmark. No customer count, quota, or hardware detail is stated here. The note above is a relationship label, not a test result.
Use the server run as a second green check. A local pass can hide an edited file or a shadowed package. A remote pass still does not prove the money rule is complete.
Do not send secrets, customer carts, or private prices remotely. Use the synthetic rows from this article, or rows you may share. If the server locale differs, avoid locale-formatted money strings.
Limits of this cut
This sequence preserves behavior on the pinned rows only. It does not certify tax, coupons, or member policy. A captured negative total can still be the wrong product rule.
The sample ignores currency codes and concurrent cart edits. It also ignores permission errors on the destination path. Those gaps need their own tests before you call the module safe.
The pure helper still uses ROUND_HALF_UP from the old function. That mode is a captured choice, not a universal ledger law. Another codebase may need a different rounding mode entirely.
This text did not execute the sample or the capture script. Treat every expected string you did not capture as unverified. Run the capture command before you merge the extract.
Who should skip this cut
Skip this cut when you cannot run tests on the real module. A paper extract without a green baseline is only a rewrite. You will not know which edge moved during the split.
Skip it when the next change must alter totals on purpose. Characterization tests will fail until you update those rows. That failure is useful, but it is the wrong tool here.
Skip it when money rules are still undecided by the owner. Pinning a guess freezes the wrong contract into the suite. Wait until the current function is the source you mean to keep.
Skip it for a live incident hotfix with no fixture data. The smallest safe act then is a guarded revert or a flag. Do not learn the contract while production is still failing.
Close
Hold the six edges, then move one pure function. Leave mutation and file writes in the wrapper for now. A smaller diff is easier to reject when a cent changes.
If a MonkeyCode account is already available, draft rows with the free model. Then run that same pytest file on the free server before merge.
Top comments (1)
tr.ee/dev-to