cint university / 401 / ced, the cint editor
401-01: an exact ledger
This is lab 401-01 of CU-401.2, checked against cint 0.2.0.
You will keep a bank ledger in whole cents, pay a day of interest rounded by a stated rule, refuse an overdraft as an error and stop on an overflow. Then you will save a checked copy of the ledger's output, called a golden. The golden catches a mistake the tests miss, and the tests catch a mistake the golden misses.
Take it after 201-04. The walk-through explains the parts of the language the program uses that 201-00 to 201-04 do not cover.
the program
// ledger.ci: an exact bank ledger. Money is whole cents in I64.
error LedgerError { overdrawn, unknown_kind }
// The kinds of entry, as typed constants.
const U8 DEPOSIT = 0;
const U8 WITHDRAW = 1;
const U8 INTEREST = 2;
struct Entry {
U8 kind;
I64 cents; // the amount; for interest, the yearly rate in basis points
}
// One day of interest at a yearly rate in basis points, rounded half to even, to the cent.
I64 daily_interest(I64 balance, I64 basis_points) {
return muldiv(I64, balance, basis_points, 10_000 * 365, half_even);
}
// Apply one entry. Overdrawing is an expected error; overflow is a fault.
LedgerError!I64 apply(I64 balance, Entry e) {
switch (e.kind) {
case DEPOSIT:
return balance + e.cents;
case WITHDRAW:
if (e.cents > balance) {
return LedgerError.overdrawn;
}
return balance - e.cents;
case INTEREST:
return balance + daily_interest(balance, e.cents);
default:
return LedgerError.unknown_kind;
}
}
void print_kind(U8 kind) {
switch (kind) {
case DEPOSIT: "deposit ";
case WITHDRAW: "withdraw";
case INTEREST: "interest";
default: "unknown ";
}
}
void main() {
Entry[5] book;
book[0] = Entry(DEPOSIT, 125_000);
book[1] = Entry(WITHDRAW, 40_050);
book[2] = Entry(INTEREST, 450); // 4.50 percent a year, for one day
book[3] = Entry(WITHDRAW, 200_000); // more than the balance: rejected
book[4] = Entry(DEPOSIT, 99);
I64 balance = 0;
for i in 0..5 {
Entry e = book[i];
I64 next = apply(balance, e) catch (err) {
switch (err) {
case .overdrawn: "{i}: rejected, overdrawn\n";
case .unknown_kind: "{i}: rejected, unknown kind\n";
}
continue;
};
balance = next;
"{i}: ";
print_kind(e.kind);
" {e.cents:>8} -> {balance:/100}\n";
}
"final balance: {balance:/100}\n";
}
test "interest ties round to even" {
assert(daily_interest(1_825_000, 1) == 0); // exactly half a cent: rounds to 0
assert(daily_interest(5_475_000, 1) == 2); // exactly 1.5 cents: rounds to 2
}
test "an overdraft is an error" {
I64 after = apply(100, Entry(WITHDRAW, 101)) catch -1;
assert(after == -1);
}
test "an overflow is a fault" expect_fault E_OVERFLOW {
_ = apply(I64.max, Entry(DEPOSIT, 1));
}
run it
$ cint run ledger.ci
0: deposit 125000 -> 1250.00
1: withdraw 40050 -> 849.50
2: interest 450 -> 849.60
3: rejected, overdrawn
4: deposit 99 -> 850.59
final balance: 850.59
One day of interest on 849.50 (84950 cents) at 4.50 percent a year is 84950 × 450 / 3650000 cents, about 10.47 cents. half_even rounds it to 10, and the balance becomes 849.60. Entry 3 asks for 2000.00 from a balance of 849.60, so apply returns the error overdrawn. The loop prints a line and moves on, and the balance does not change.
test it
$ cint test ledger.ci
test ledger.ci:71 "interest ties round to even" ... ok
test ledger.ci:76 "an overdraft is an error" ... ok
test ledger.ci:81 "an overflow is a fault" ... ok
3 of 3 tests passed
The first test checks two ties, where a rounding rule matters: an exact half cent rounds to the even neighbor, 0, and an exact cent and a half rounds to 2. The third test passes because the deposit faults: expect_fault E_OVERFLOW says a fault with that code is the expected result.
errors and faults
The ledger has two different ways to fail.
- An overdraft is an error. It is something a ledger expects: a customer asks for more than the balance.
applyreturns it as a value of typeLedgerError!I64, and the caller decides what to do with it, here withcatch. - An overflow is a fault. A balance past
I64.max, about 92 quadrillion dollars, means the program is wrong. No caller can catch it. The program stops at the operation and prints a fault report.
keep a golden
A golden is a saved output that you have checked by hand and that later runs must match byte for byte. The output above is right: you checked the interest and the rejected entry. Save it:
$ cint run ledger.ci > golden.txt
Now make a mistake. Change the interest entry's rate from 450 to 45 and run again into a second file:
$ cint run ledger.ci > after.txt
Compare the two files. In VS Code, right-click golden.txt in the Explorer and choose Select for Compare, then right-click after.txt and choose Compare with Selected. In a terminal on macOS or Linux, use diff:
$ diff golden.txt after.txt
3c3
< 2: interest 450 -> 849.60
---
> 2: interest 45 -> 849.51
5,6c5,6
< 4: deposit 99 -> 850.59
< final balance: 850.59
---
> 4: deposit 99 -> 850.50
> final balance: 850.50
In PowerShell on Windows, fc.exe /b golden.txt after.txt says whether the two files match, and VS Code's compare shows the lines that differ. Type fc.exe in full, since fc alone means something else in PowerShell.
Read the first line that differs. Line 3 is the first difference, and it names entry 2. Every later difference follows from it: the balance after entry 2 is 9 cents short, so every balance after it is too. The first differing line points at the change; the lines after it are consequences.
The tests still pass with the typo, because none of them reads book. That is one thing a golden is for: it checks the whole run, including data no test looks at.
Put 450 back and check that the run matches the golden again. diff prints nothing when the files are the same.
a mistake only the tests catch
Now make a different change. Replace the body of daily_interest with a plain division that looks as if it does the same thing:
return balance * basis_points / (10_000 * 365);
Run it and compare with the golden. There is no difference: 10.47 cents rounds down to 10, the same result as rounding to the nearest cent. The tests disagree:
$ cint test ledger.ci
test ledger.ci:71 "interest ties round to even" ... FAILED
fault[E_ASSERT]: assertion failed
--> ledger.ci:73:12
|
73 | assert(daily_interest(5_475_000, 1) == 2); // exactly 1.5 cents: rounds to 2
| ^ assert.checked.bool
|
= operation: assert.checked.bool
= operand-count: 2
= operand: I64 1
= operand: I64 2
...
test ledger.ci:76 "an overdraft is an error" ... ok
test ledger.ci:81 "an overflow is a fault" ... ok
2 of 3 tests passed
The two operands are the two sides of the comparison: the function returned 1 where the test wanted 2, because / rounds down. A golden holds what one run printed; a test holds a case chosen because it is hard. A program you intend to keep needs both. Put muldiv back.
when it faults
Change the last entry to deposit the largest I64:
book[4] = Entry(DEPOSIT, I64.max);
$ cint run ledger.ci
0: deposit 125000 -> 1250.00
1: withdraw 40050 -> 849.50
2: interest 450 -> 849.60
3: rejected, overdrawn
fault[E_OVERFLOW]: the exact result is outside the type's range
--> ledger.ci:24:28
|
24 | return balance + e.cents;
| ^ add.checked.i64
|
= operation: add.checked.i64
= operand-count: 2
= operand: I64 84960
= operand: I64 9223372036854775807
= exact: 9223372036854860767
= limit: I64 9223372036854775807
...
= stack-depth: 1
= called from: ledger.ci:56:20
The four lines before the fault are the program's output up to that point. The fault report goes to standard error, not standard output, so cint run ledger.ci > after.txt saves the four lines and the report still appears in the terminal. cint run exits with status 1.
Run it again. With the same cint release, the report is the same, byte for byte, every time and on every computer: the same operation, the same operands and the same line. That makes a fault report a golden too. Save it and fix the program. When a fault comes back after a later change, compare the new report with the saved one to see whether it is the same fault or a new one. 201-07 builds this into a method.
walk-through
const U8 DEPOSIT = 0; gives each kind of entry a name with a type. switch (e.kind) matches against the names, and default takes any other value, which apply returns as unknown_kind.
Entry[5] book; declares five entries, all zero, and the lines after it fill them in.
catch (err) { ... continue; } handles the error where apply is called. err holds which error it was, and the switch prints a line for each. continue goes on to the next entry, so the line I64 next = ... never completes for a rejected entry.
apply(100, Entry(WITHDRAW, 101)) catch -1 is the short form: if apply returns an error, the whole expression is -1.
{e.cents:>8} prints the amount right-aligned in 8 characters, and {balance:/100} prints cents as dollars with two decimal places.
A basis point is a hundredth of a percent. That makes 450 basis points 4.50 percent. Dividing by 10,000 turns basis points into a fraction, and by 365 a yearly rate into a daily one. muldiv multiplies before it divides, holds the product exactly and rounds once.
extend it
- Add a
FEEkind that takes a fixed 2.50 from the balance and is refused asoverdrawnwhen the balance is smaller. Save a new golden only after you have checked the new output by hand. - Add a test for a withdrawal of exactly the balance. Should it be accepted?
- Pay 365 days of interest on 1000.00 at 4.50 percent, compounding daily, and print the balance at the end. Then round each day with
floorinstead ofhalf_even. How much less does the account earn in a year?
reference
error sets, catch, structs, muldiv, rounding modes, tests, fault record, golden
Next: 401-02, hunting an overflow.