Add four harder benchmark tasks
All seven newer local models passed the original four tasks 20 of 20, so the benchmark no longer told them apart. The new tasks keep plans explicit, as the planner is told to write them, and make the work itself harder: - trace-bug: a wrong multi-currency total whose cause is a shared rate helper; discounts and tax go through it too, so a fix at the call site fails the check. - refactor-rename: rename load_config across a ten-module package, leaving load_config_defaults and reload_config alone. - spec-parser: implement parse_duration from an eight-rule SPEC.md given three examples, and write tests for every rule. - long-milestone: eight dependent steps adding due dates to a task tracker, from the model through storage versions to the CLI. Self-tests prove that a call-site fix, a blind text replace and a partial rename all fail their checks. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
f2c48d8b16
commit
25fd57351d
67 files changed
+2115
-2
No files matched your search
@@ -0,0 +1,38 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from invoicing.invoice import Invoice
|
||||
from invoicing.lines import LineItem
|
||||
from invoicing.report import render
|
||||
|
||||
|
||||
def d(value: str) -> Decimal:
|
||||
return Decimal(value)
|
||||
|
||||
|
||||
def test_single_currency_total() -> None:
|
||||
invoice = Invoice(
|
||||
"USD", [LineItem("widget", d("19.99"), 3), LineItem("gadget", d("5.00"), 2)]
|
||||
)
|
||||
assert invoice.total() == d("69.97")
|
||||
|
||||
|
||||
def test_twenty_percent_tax() -> None:
|
||||
invoice = Invoice("USD", [LineItem("service", d("100.00"), 1)], tax_percent=d("20"))
|
||||
assert invoice.total() == d("120.00")
|
||||
|
||||
|
||||
def test_multi_currency_total() -> None:
|
||||
# 100 USD is 91.95 EUR at the reference rates (1 EUR = 1.0875 USD).
|
||||
invoice = Invoice(
|
||||
"EUR",
|
||||
[
|
||||
LineItem("hosting", d("100.00"), 1, "USD"),
|
||||
LineItem("support", d("50.00"), 1, "EUR"),
|
||||
],
|
||||
)
|
||||
assert invoice.total() == d("141.95")
|
||||
|
||||
|
||||
def test_report_shows_the_total() -> None:
|
||||
invoice = Invoice("USD", [LineItem("widget", d("10.00"), 2)])
|
||||
assert render(invoice).splitlines()[-1].split() == ["Total", "20.00", "USD"]
|
||||
@@ -0,0 +1,76 @@
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from invoicing.currency import convert
|
||||
from invoicing.discounts import Discount
|
||||
from invoicing.invoice import Invoice
|
||||
from invoicing.lines import LineItem
|
||||
|
||||
|
||||
def d(value: str) -> Decimal:
|
||||
return Decimal(value)
|
||||
|
||||
|
||||
def test_the_visible_tests_were_not_changed() -> None:
|
||||
original = (Path(__file__).parent / "original_test_invoice.py.txt").read_text()
|
||||
assert Path("tests/test_invoice.py").read_text() == original
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("amount", "source", "target", "expected"),
|
||||
[
|
||||
("100.00", "USD", "EUR", "91.95"),
|
||||
("100.00", "EUR", "USD", "108.75"),
|
||||
("250.00", "GBP", "EUR", "290.44"),
|
||||
("10000", "JPY", "USD", "67.12"),
|
||||
("42.00", "USD", "USD", "42.00"),
|
||||
],
|
||||
)
|
||||
def test_conversion(amount: str, source: str, target: str, expected: str) -> None:
|
||||
assert convert(d(amount), source, target) == d(expected)
|
||||
|
||||
|
||||
def test_multi_currency_total() -> None:
|
||||
invoice = Invoice(
|
||||
"EUR",
|
||||
[
|
||||
LineItem("hosting", d("100.00"), 1, "USD"),
|
||||
LineItem("support", d("50.00"), 1, "EUR"),
|
||||
],
|
||||
)
|
||||
assert invoice.total() == d("141.95")
|
||||
|
||||
|
||||
def test_fractional_discount() -> None:
|
||||
# Same shared helper, no currency involved: 12.5% of 1000.00 is 125.00.
|
||||
invoice = Invoice(
|
||||
"USD", [LineItem("licence", d("1000.00"), 1)], discount=Discount(d("12.5"))
|
||||
)
|
||||
assert invoice.discount_total() == d("125.00")
|
||||
assert invoice.total() == d("875.00")
|
||||
|
||||
|
||||
def test_fractional_tax() -> None:
|
||||
# 8.25% of 1000.00 is 82.50.
|
||||
invoice = Invoice(
|
||||
"USD", [LineItem("licence", d("1000.00"), 1)], tax_percent=d("8.25")
|
||||
)
|
||||
assert invoice.tax() == d("82.50")
|
||||
|
||||
|
||||
def test_everything_together() -> None:
|
||||
invoice = Invoice(
|
||||
"GBP",
|
||||
[
|
||||
LineItem("seats", d("40.00"), 10, "EUR"),
|
||||
LineItem("setup", d("99.00"), 1, "GBP"),
|
||||
],
|
||||
discount=Discount(d("7.5")),
|
||||
tax_percent=d("17.5"),
|
||||
)
|
||||
# 400 EUR = 344.31 GBP; subtotal 443.31; discount 33.25; tax 71.76.
|
||||
assert invoice.subtotal() == d("443.31")
|
||||
assert invoice.discount_total() == d("33.25")
|
||||
assert invoice.tax() == d("71.76")
|
||||
assert invoice.total() == d("481.82")
|
||||
@@ -0,0 +1 @@
|
||||
"""Invoices with lines in several currencies, discounts and tax."""
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Currency conversion at fixed reference rates."""
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from invoicing.money import apply_rate, money, to_cents
|
||||
|
||||
# Value of one unit of each currency in US dollars.
|
||||
RATES_TO_USD: dict[str, Decimal] = {
|
||||
"USD": money("1"),
|
||||
"EUR": money("1.0875"),
|
||||
"GBP": money("1.2634"),
|
||||
"JPY": money("0.006712"),
|
||||
}
|
||||
|
||||
|
||||
def rate(from_currency: str, to_currency: str) -> Decimal:
|
||||
"""How many units of `to_currency` one unit of `from_currency` buys."""
|
||||
try:
|
||||
return RATES_TO_USD[from_currency] / RATES_TO_USD[to_currency]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"unknown currency: {exc.args[0]}") from None
|
||||
|
||||
|
||||
def convert(amount: Decimal, from_currency: str, to_currency: str) -> Decimal:
|
||||
if from_currency == to_currency:
|
||||
return to_cents(amount)
|
||||
return apply_rate(amount, rate(from_currency, to_currency))
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Percentage discounts on an invoice subtotal."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
|
||||
from invoicing.money import apply_rate
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Discount:
|
||||
percent: Decimal # 12.5 means 12.5%
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not 0 <= self.percent <= 100:
|
||||
raise ValueError(f"discount must be 0-100%, not {self.percent}")
|
||||
|
||||
|
||||
def discount_amount(subtotal: Decimal, discount: Discount | None) -> Decimal:
|
||||
if discount is None:
|
||||
return Decimal("0.00")
|
||||
return apply_rate(subtotal, discount.percent / 100)
|
||||
@@ -0,0 +1,38 @@
|
||||
"""An invoice: lines converted to its currency, then discount, then tax."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
|
||||
from invoicing.currency import convert
|
||||
from invoicing.discounts import Discount, discount_amount
|
||||
from invoicing.lines import LineItem, line_total
|
||||
from invoicing.money import apply_rate
|
||||
|
||||
|
||||
@dataclass
|
||||
class Invoice:
|
||||
currency: str
|
||||
lines: list[LineItem] = field(default_factory=list)
|
||||
discount: Discount | None = None
|
||||
tax_percent: Decimal = Decimal(0)
|
||||
|
||||
def subtotal(self) -> Decimal:
|
||||
"""Every line, converted into the invoice's currency."""
|
||||
return sum(
|
||||
(
|
||||
convert(line_total(line), line.currency, self.currency)
|
||||
for line in self.lines
|
||||
),
|
||||
Decimal("0.00"),
|
||||
)
|
||||
|
||||
def discount_total(self) -> Decimal:
|
||||
return discount_amount(self.subtotal(), self.discount)
|
||||
|
||||
def tax(self) -> Decimal:
|
||||
"""Tax on the discounted subtotal."""
|
||||
taxable = self.subtotal() - self.discount_total()
|
||||
return apply_rate(taxable, self.tax_percent / 100)
|
||||
|
||||
def total(self) -> Decimal:
|
||||
return self.subtotal() - self.discount_total() + self.tax()
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Invoice lines."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
|
||||
from invoicing.money import to_cents
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LineItem:
|
||||
description: str
|
||||
unit_price: Decimal
|
||||
quantity: int
|
||||
currency: str = "USD"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.quantity < 0:
|
||||
raise ValueError("quantity cannot be negative")
|
||||
|
||||
|
||||
def line_total(line: LineItem) -> Decimal:
|
||||
"""The line's amount in its own currency."""
|
||||
return to_cents(line.unit_price * line.quantity)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Money arithmetic shared by every module: Decimal amounts, cent rounding."""
|
||||
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
CENT = Decimal("0.01")
|
||||
|
||||
# Rates and fractions (exchange rates, discount and tax fractions) are held
|
||||
# at this precision before they are applied to an amount.
|
||||
RATE_PLACES = Decimal("0.01")
|
||||
|
||||
|
||||
def money(value: str | int) -> Decimal:
|
||||
"""An amount from a string or int; never from a float."""
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def to_cents(amount: Decimal) -> Decimal:
|
||||
return amount.quantize(CENT, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def normalise_rate(rate: Decimal) -> Decimal:
|
||||
"""A rate or fraction at the precision used for all arithmetic."""
|
||||
return rate.quantize(RATE_PLACES, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def apply_rate(amount: Decimal, rate: Decimal) -> Decimal:
|
||||
"""`amount` scaled by `rate`, in whole cents."""
|
||||
return to_cents(amount * normalise_rate(rate))
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Plain-text rendering of an invoice."""
|
||||
|
||||
from invoicing.invoice import Invoice
|
||||
from invoicing.lines import line_total
|
||||
|
||||
|
||||
def render(invoice: Invoice) -> str:
|
||||
rows = [
|
||||
f"{line.description:<20} {line.quantity:>4} x {line.unit_price:>10} "
|
||||
f"{line.currency} = {line_total(line):>10}"
|
||||
for line in invoice.lines
|
||||
]
|
||||
rows.append(f"{'Subtotal':<20} {invoice.subtotal():>32} {invoice.currency}")
|
||||
if invoice.discount is not None:
|
||||
rows.append(
|
||||
f"{'Discount ' + str(invoice.discount.percent) + '%':<20} "
|
||||
f"{-invoice.discount_total():>32} {invoice.currency}"
|
||||
)
|
||||
if invoice.tax_percent:
|
||||
rows.append(
|
||||
f"{'Tax ' + str(invoice.tax_percent) + '%':<20} "
|
||||
f"{invoice.tax():>32} {invoice.currency}"
|
||||
)
|
||||
rows.append(f"{'Total':<20} {invoice.total():>32} {invoice.currency}")
|
||||
return "\n".join(rows)
|
||||
@@ -0,0 +1,38 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from invoicing.invoice import Invoice
|
||||
from invoicing.lines import LineItem
|
||||
from invoicing.report import render
|
||||
|
||||
|
||||
def d(value: str) -> Decimal:
|
||||
return Decimal(value)
|
||||
|
||||
|
||||
def test_single_currency_total() -> None:
|
||||
invoice = Invoice(
|
||||
"USD", [LineItem("widget", d("19.99"), 3), LineItem("gadget", d("5.00"), 2)]
|
||||
)
|
||||
assert invoice.total() == d("69.97")
|
||||
|
||||
|
||||
def test_twenty_percent_tax() -> None:
|
||||
invoice = Invoice("USD", [LineItem("service", d("100.00"), 1)], tax_percent=d("20"))
|
||||
assert invoice.total() == d("120.00")
|
||||
|
||||
|
||||
def test_multi_currency_total() -> None:
|
||||
# 100 USD is 91.95 EUR at the reference rates (1 EUR = 1.0875 USD).
|
||||
invoice = Invoice(
|
||||
"EUR",
|
||||
[
|
||||
LineItem("hosting", d("100.00"), 1, "USD"),
|
||||
LineItem("support", d("50.00"), 1, "EUR"),
|
||||
],
|
||||
)
|
||||
assert invoice.total() == d("141.95")
|
||||
|
||||
|
||||
def test_report_shows_the_total() -> None:
|
||||
invoice = Invoice("USD", [LineItem("widget", d("10.00"), 2)])
|
||||
assert render(invoice).splitlines()[-1].split() == ["Total", "20.00", "USD"]
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Money arithmetic shared by every module: Decimal amounts, cent rounding."""
|
||||
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
CENT = Decimal("0.01")
|
||||
|
||||
# Rates and fractions (exchange rates, discount and tax fractions) are held
|
||||
# at this precision before they are applied to an amount.
|
||||
RATE_PLACES = Decimal("0.000001")
|
||||
|
||||
|
||||
def money(value: str | int) -> Decimal:
|
||||
"""An amount from a string or int; never from a float."""
|
||||
return Decimal(str(value))
|
||||
|
||||
|
||||
def to_cents(amount: Decimal) -> Decimal:
|
||||
return amount.quantize(CENT, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def normalise_rate(rate: Decimal) -> Decimal:
|
||||
"""A rate or fraction at the precision used for all arithmetic."""
|
||||
return rate.quantize(RATE_PLACES, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def apply_rate(amount: Decimal, rate: Decimal) -> Decimal:
|
||||
"""`amount` scaled by `rate`, in whole cents."""
|
||||
return to_cents(amount * normalise_rate(rate))
|
||||
@@ -0,0 +1,19 @@
|
||||
title: Trace a wrong total to its cause in a shared helper
|
||||
check: python -m pytest -q .bench_check
|
||||
plan: |
|
||||
Milestone 1: fix the wrong totals on multi-currency invoices.
|
||||
|
||||
1. Run `python -m pytest -q`. `test_multi_currency_total` in
|
||||
`tests/test_invoice.py` fails: totals are slightly off when an invoice has
|
||||
lines in another currency. The test is correct. Do not change any file
|
||||
under `tests/`.
|
||||
2. Find the cause. Start at `Invoice.total` in `invoicing/invoice.py` and
|
||||
follow the functions it calls, reading each one, until you find where
|
||||
precision is lost. The cause is in a function that more than one module
|
||||
uses.
|
||||
3. Fix it at its source, in that shared function or the value it uses. Do
|
||||
not special-case currencies in `invoicing/invoice.py` or
|
||||
`invoicing/currency.py`: every caller of the shared function has the
|
||||
same problem, and all of them must be fixed by your change.
|
||||
4. Run `python -m pytest -q` until every test passes.
|
||||
5. Call finish.
|
||||
Reference in new issue
Block a user