Compare commits

..

11 commits

Author SHA1 Message Date
archeious
73825bc305 chore: drop dead --ratio attribute, tidy service.py whitespace (#21) 2026-04-17 19:19:51 -06:00
archeious
c2afacfe6e test: end-to-end template-edit isolation across months (#21) 2026-04-17 19:14:14 -06:00
archeious
fe419fe802 refactor(service): remove set_entry_notes, superseded by update_entry (#21) 2026-04-17 19:12:43 -06:00
archeious
1c525f0202 refactor: remove POST /entries/{id}/notes, superseded by save route (#21) 2026-04-17 19:11:01 -06:00
archeious
a814ec6e01 feat(routes): GET /sections/{section} for edit-mode cancel (#21) 2026-04-17 19:08:38 -06:00
archeious
c331211afd feat(routes): POST /entries/{id} saves edits with OOB totals (#21) 2026-04-17 19:05:09 -06:00
archeious
7de8f918fb feat(routes): add GET /entries/{id}/edit for edit-mode toggle (#21) 2026-04-17 19:01:52 -06:00
archeious
c96d3f5d2f fix(ui): restore target card layout, tighten edit-mode UX (#21) 2026-04-17 18:59:04 -06:00
archeious
af276f0eec feat(ui): rewrite budget section row for inline edit mode (#21) 2026-04-17 18:50:46 -06:00
archeious
6f98618b51 feat(service): add update_entry for template rows (#21) 2026-04-17 18:45:12 -06:00
archeious
ab5b88a52b docs: implementation plan for editing template entries (#21)
Ten-task TDD plan: service.update_entry, three new routes, template
rewrite, CSS, test migration for the removed notes route, end-to-end
isolation test, manual UI verification, and PR opening.
2026-04-17 18:42:09 -06:00
10 changed files with 1878 additions and 241 deletions

File diff suppressed because it is too large Load diff

View file

@ -10,7 +10,7 @@ from sqlalchemy.orm import Session
from quartermaster import month_service, service
from quartermaster.db import get_session
from quartermaster.models import SECTION_LABELS, Section
from quartermaster.models import SECTION_LABELS, Entry, Section
TEMPLATES_DIR = Path(__file__).parent / "templates"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
@ -39,11 +39,16 @@ def _section_view(db: Session, section: Section) -> service.SectionView:
def _render_section(
request: Request, db: Session, section: Section
request: Request,
db: Session,
section: Section,
editing_id: int | None = None,
) -> HTMLResponse:
view = _section_view(db, section)
return templates.TemplateResponse(
request, "partials/section.html", {"section": view}
request,
"partials/section.html",
{"section": view, "editing_id": editing_id},
)
@ -97,6 +102,7 @@ def index(request: Request, db: Session = Depends(get_session)) -> HTMLResponse:
"all_months": month_service.list_months(db),
"zero": zero,
"tone": service.zero_tone(zero),
"editing_id": None,
},
)
@ -144,17 +150,53 @@ def remove_entry(
return _append_oob(response, *extras)
@router.post("/entries/{entry_id}/notes", response_class=HTMLResponse)
def update_entry_notes(
@router.get("/entries/{entry_id}/edit", response_class=HTMLResponse)
def edit_entry(
entry_id: int,
request: Request,
db: Session = Depends(get_session),
) -> HTMLResponse:
entry = db.get(Entry, entry_id)
if entry is None:
raise HTTPException(status_code=404, detail="entry not found")
return _render_section(request, db, entry.section, editing_id=entry.id)
@router.post("/entries/{entry_id}", response_class=HTMLResponse)
def save_entry(
entry_id: int,
request: Request,
name: str = Form(...),
amount: str = Form(...),
notes: str | None = Form(None),
db: Session = Depends(get_session),
) -> HTMLResponse:
updated = service.set_entry_notes(db, entry_id, notes)
clean_name = name.strip()
if not clean_name:
raise HTTPException(status_code=400, detail="name is required")
parsed = _parse_amount(amount)
updated = service.update_entry(
db, entry_id, name=clean_name, amount=parsed, notes=notes
)
if updated is None:
raise HTTPException(status_code=404, detail="entry not found")
return _render_section(request, db, updated.section)
response = _render_section(request, db, updated.section)
extras: list[HTMLResponse] = [
_render_zero(request, db),
_render_group_totals(request, db),
]
if updated.section == Section.debt_minimum:
extras.append(_render_target(request, db))
return _append_oob(response, *extras)
@router.get("/sections/{section}", response_class=HTMLResponse)
def get_section(
section: Section,
request: Request,
db: Session = Depends(get_session),
) -> HTMLResponse:
return _render_section(request, db, section)
@router.post("/debt-target", response_class=HTMLResponse)

View file

@ -15,6 +15,8 @@ from quartermaster.groups import (
)
from quartermaster.models import SECTION_LABELS, DebtTarget, Entry, Section
_NOTES_SENTINEL = object()
def zero_tone(value: Decimal) -> str:
if value == 0:
@ -113,13 +115,23 @@ def _clean_notes(raw: str | None) -> str | None:
return stripped if stripped else None
def set_entry_notes(
db: Session, entry_id: int, notes: str | None
def update_entry(
db: Session,
entry_id: int,
*,
name: str | None = None,
amount: Decimal | None = None,
notes: str | None | object = _NOTES_SENTINEL,
) -> Entry | None:
entry = db.get(Entry, entry_id)
if entry is None:
return None
entry.notes = _clean_notes(notes)
if name is not None:
entry.name = name.strip()
if amount is not None:
entry.amount = amount
if notes is not _NOTES_SENTINEL:
entry.notes = _clean_notes(notes) # type: ignore[arg-type]
db.commit()
db.refresh(entry)
return entry

View file

@ -361,64 +361,160 @@ details.group[open] > summary .chevron::after { opacity: 0; }
.total .applied { color: var(--ink-soft); }
.total.empty { color: var(--muted); }
/* =============== ENTRY TABLE =============== */
/* =============== ENTRY LIST (BUDGET TEMPLATE) =============== */
table.entries {
width: 100%;
border-collapse: collapse;
font-family: var(--sans);
}
table.entries thead { display: none; }
.budget-entries { display: flex; flex-direction: column; }
table.entries tbody tr.entry {
.entry-row.reading {
display: grid;
grid-template-columns: minmax(0, 1fr) 5.5rem 5.5rem 1.2rem;
grid-template-columns: minmax(0, 1fr) 5.5rem auto;
gap: 0.6rem;
align-items: baseline;
padding: 0.26rem 0.25rem 0.28rem;
position: relative;
border-bottom: 1px dotted var(--rule);
position: relative;
}
table.entries tbody tr.entry:hover { background: var(--paper-stripe); }
table.entries tbody tr.entry td {
padding: 0;
border: none;
vertical-align: baseline;
.entry-row.reading:hover { background: var(--paper-stripe); }
.entry-row.reading .entry-name {
font-family: var(--sans);
font-weight: 500;
font-size: 1.02rem;
color: var(--ink);
letter-spacing: 0.01em;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entry-row.reading .note-badge {
font-family: var(--sans);
font-style: italic;
font-size: 0.82rem;
color: var(--muted);
margin-left: 0.5rem;
letter-spacing: 0.02em;
opacity: 0.85;
}
.entry-row.reading .note-badge::before {
content: "· ";
color: var(--rule);
font-style: normal;
}
.entry-row.reading .entry-amount {
font-family: var(--sans);
font-weight: 500;
font-size: 1rem;
color: var(--ink);
font-feature-settings: "lnum" 1, "tnum" 1;
text-align: right;
min-width: 0;
}
table.entries tbody tr.entry::after {
content: "";
position: absolute;
left: 0; right: 0; bottom: -1px;
height: 2px;
background: var(--sage-soft);
width: min(100%, calc(var(--ratio, 1) * 100%));
transition: width 0.25s ease;
opacity: 0.7;
.entry-row.reading .entry-actions {
display: flex;
gap: 0.3rem;
align-items: center;
}
table.entries tbody tr.entry.state-edited::after,
table.entries tbody tr.entry[data-deviation="over"]::after {
background: var(--accent);
opacity: 0.85;
.entry-row.reading .entry-actions button {
background: none;
border: none;
cursor: pointer;
padding: 0;
line-height: 1;
color: var(--rule);
opacity: 0;
transition: color 0.12s ease, opacity 0.12s ease;
}
table.entries tbody tr.entry.state-new_in_month::after {
background: var(--indigo);
opacity: 0.55;
.entry-row.reading:hover .entry-actions button { opacity: 1; }
.entry-row.reading .entry-actions button.edit {
font-size: 0.95rem;
color: var(--rule);
}
.entry-row.reading .entry-actions button.edit:hover { color: var(--ink); }
.entry-row.reading .entry-actions button.delete {
font-size: 1.2rem;
font-weight: 400;
}
.entry-row.reading .entry-actions button.delete:hover { color: var(--accent); }
.entry-row.editing {
display: grid;
grid-template-columns: minmax(0, 1fr) 5.5rem minmax(0, 1.4fr) auto;
gap: 0.5rem;
align-items: center;
padding: 0.26rem 0.25rem 0.28rem;
border-bottom: 1px dotted var(--rule);
background: var(--paper-soft);
margin: 0;
}
.entry-row.editing input {
font-family: var(--sans);
font-size: 0.95rem;
padding: 0.2rem 0.4rem;
border: 1px solid var(--rule);
background: var(--paper);
color: var(--ink);
outline: none;
transition: border-color 0.12s;
min-width: 0;
}
.entry-row.editing input:focus { border-color: var(--ink); }
.entry-row.editing input[type="number"] {
text-align: right;
font-variant-numeric: tabular-nums;
}
.entry-row.editing .notes-input {
font-style: italic;
font-size: 0.88rem;
color: var(--muted);
}
.entry-row.editing .entry-actions {
display: flex;
gap: 0.4rem;
align-items: center;
}
.entry-row.editing .save-btn,
.entry-row.editing .cancel-btn {
font-family: var(--sans);
font-weight: 600;
font-size: 0.78rem;
letter-spacing: 0.12em;
text-transform: uppercase;
padding: 0.22rem 0.6rem;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.entry-row.editing .save-btn {
border: 1px solid var(--ink);
background: var(--paper-soft);
color: var(--ink);
}
.entry-row.editing .save-btn:hover {
background: var(--sage);
color: var(--paper);
border-color: var(--sage);
}
.entry-row.editing .cancel-btn {
border: 1px solid var(--rule);
background: transparent;
color: var(--muted);
}
.entry-row.editing .cancel-btn:hover {
color: var(--accent);
border-color: var(--accent);
}
tr.empty td {
padding: 0.4rem 0.5rem !important;
.empty-row {
padding: 0.4rem 0.5rem;
color: var(--muted);
font-style: italic;
font-size: 0.9rem;
}
tr.add-row td {
padding: 0.4rem 0.25rem 0.2rem !important;
border-bottom: none !important;
.add-row {
padding: 0.4rem 0.25rem 0.2rem;
grid-column: 1 / -1;
display: block;
}
/* Add-entry disclosure: collapsed trigger, expanded form */
@ -455,112 +551,18 @@ details.add-entry > .month-add-form {
margin-top: 0.45rem;
}
.entry-name {
font-family: var(--sans);
font-weight: 500;
font-size: 1.02rem;
color: var(--ink);
letter-spacing: 0.01em;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@media (max-width: 520px) {
.entry-row.reading {
grid-template-columns: minmax(0, 1fr) 4.6rem auto;
gap: 0.4rem;
}
.entry-row.editing {
grid-template-columns: minmax(0, 1fr) 4.6rem;
gap: 0.4rem;
}
.entry-row.editing .notes-input,
.entry-row.editing .entry-actions { grid-column: 1 / -1; }
}
.entry-name input {
font: inherit;
color: inherit;
background: transparent;
border: none;
border-bottom: 1px solid transparent;
padding: 0;
width: 100%;
outline: none;
transition: border-color 0.12s;
}
.entry-name input:hover { border-bottom-color: var(--rule); }
.entry-name input:focus { border-bottom-color: var(--ink); }
.entry-amount {
font-family: var(--sans);
font-weight: 500;
font-size: 1rem;
color: var(--ink);
font-feature-settings: "lnum" 1, "tnum" 1;
text-align: right;
min-width: 0;
}
.entry-amount input {
font: inherit;
font-variant-numeric: tabular-nums;
color: inherit;
background: transparent;
border: none;
border-bottom: 1px solid transparent;
padding: 0;
width: 100%;
outline: none;
text-align: right;
transition: border-color 0.12s;
}
.entry-amount input:hover { border-bottom-color: var(--rule); }
.entry-amount input:focus { border-bottom-color: var(--ink); }
/* On the budget page the planned-amount cell is the only numeric cell;
still render it like a ledger number. */
tr.entry td.entry-amount:first-of-type { color: var(--ink); }
.entry-actions button.delete {
font-family: var(--sans);
font-size: 1.2rem;
line-height: 1;
color: var(--rule);
background: none;
border: none;
cursor: pointer;
padding: 0;
opacity: 0;
transition: color 0.12s ease, opacity 0.12s ease;
align-self: center;
font-weight: 400;
}
tr.entry:hover .entry-actions button.delete { opacity: 1; }
.entry-actions button.delete:hover { color: var(--accent); }
/* Notes row — hidden when empty, shown on hover or when value is set */
tr.entry-notes-row {
display: block;
grid-column: 1 / -1;
font-family: var(--sans);
font-style: italic;
font-weight: 400;
font-size: 0.85rem;
color: var(--muted);
line-height: 1.3;
padding: 0;
margin-top: -0.25rem;
}
tr.entry-notes-row td {
padding: 0 0.25rem 0.25rem !important;
border-bottom: 1px dotted var(--rule) !important;
}
tr.entry-notes-row input.notes-input {
font: inherit;
font-style: italic;
color: inherit;
background: transparent;
border: none;
padding: 0;
width: 100%;
outline: none;
}
tr.entry-notes-row input.notes-input::placeholder {
color: var(--rule);
font-style: italic;
}
/* Empty notes render subtly (placeholder only) so they stay clickable. */
tr.entry-notes-row:has(input:placeholder-shown) { opacity: 0.55; }
tr.entry-notes-row:hover,
tr.entry-notes-row:has(input:focus) { opacity: 1; }
.tag {
font-family: var(--sans);
@ -578,6 +580,31 @@ tr.entry-notes-row:has(input:focus) { opacity: 1; }
.tag-edited { color: var(--ochre); }
.tag-new { color: var(--indigo); }
/* Target card keeps the table-based layout from before Task 2 rewrite. */
.target-section table.entries { width: 100%; border-collapse: collapse; font-family: var(--sans); }
.target-section table.entries thead { display: none; }
.target-section table.entries tbody tr.entry {
display: grid;
grid-template-columns: minmax(0, 1fr) 5.5rem 5.5rem 1.2rem;
gap: 0.6rem;
align-items: baseline;
padding: 0.26rem 0.25rem 0.28rem;
position: relative;
border-bottom: 1px dotted var(--rule);
}
.target-section table.entries tbody tr.entry td {
padding: 0;
border: none;
vertical-align: baseline;
min-width: 0;
}
.target-section table.entries tr.add-row td {
padding: 0.4rem 0.25rem 0.2rem !important;
border-bottom: none !important;
grid-column: 1 / -1;
display: block;
}
/* =============== PRIMARY DEBT TARGET =============== */
.section.target-section {
@ -980,13 +1007,6 @@ form.add-posting-form button[type="submit"] {
}
form.add-posting-form button[type="submit"]:hover { background: var(--ink); color: var(--paper); }
.empty-row {
padding: 0.5rem 0.5rem;
color: var(--muted);
font-style: italic;
font-size: 0.9rem;
}
@media (max-width: 640px) {
.entry-block > summary {
grid-template-columns: 0.9rem minmax(0, 1fr) 4.2rem 7rem 1rem;
@ -1118,8 +1138,4 @@ button[disabled] {
text-align: center;
justify-self: center;
}
table.entries tbody tr.entry {
grid-template-columns: minmax(0, 1fr) 4.6rem 4.6rem 1rem;
gap: 0.4rem;
}
}

View file

@ -5,14 +5,68 @@
${{ '{:,.2f}'.format(section.total) }}
</span>
</div>
<table class="entries">
<tbody>
<div class="entries budget-entries">
{% for entry in section.entries %}
<tr class="entry state-unchanged" style="--ratio: 1">
<td class="entry-name">{{ entry.name }}</td>
<td class="entry-amount">${{ '{:,.2f}'.format(entry.amount) }}</td>
<td class="entry-amount"></td>
<td class="entry-actions">
{% if editing_id is not none and entry.id == editing_id %}
<form
class="entry-row editing"
hx-post="/entries/{{ entry.id }}"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
>
<input
class="name-input"
type="text"
name="name"
value="{{ entry.name }}"
required
autofocus
aria-label="Name"
>
<input
class="amount-input"
type="number"
step="0.01"
min="0"
name="amount"
value="{{ '%.2f' | format(entry.amount) }}"
required
aria-label="Amount"
>
<input
class="notes-input"
type="text"
name="notes"
value="{{ entry.notes or '' }}"
placeholder="notes (optional)"
aria-label="Notes"
>
<div class="entry-actions">
<button type="submit" class="save-btn">Save</button>
<button
type="button"
class="cancel-btn"
hx-get="/sections/{{ section.section.value }}"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
>Cancel</button>
</div>
</form>
{% else %}
<div class="entry-row reading">
<span class="entry-name">
{{ entry.name }}{% if entry.notes %}<span class="note-badge">{{ entry.notes }}</span>{% endif %}
</span>
<span class="entry-amount">${{ '{:,.2f}'.format(entry.amount) }}</span>
<div class="entry-actions">
<button
class="edit"
type="button"
hx-get="/entries/{{ entry.id }}/edit"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
aria-label="Edit {{ entry.name }}"
>&#x270E;</button>
<button
class="delete"
type="button"
@ -21,29 +75,13 @@
hx-swap="outerHTML"
aria-label="Delete {{ entry.name }}"
>&times;</button>
</td>
</tr>
<tr class="entry-notes-row">
<td colspan="4">
<input
class="notes-input"
type="text"
name="notes"
value="{{ entry.notes or '' }}"
placeholder="notes..."
hx-post="/entries/{{ entry.id }}/notes"
hx-trigger="change"
hx-target="#section-{{ section.section.value }}"
hx-swap="outerHTML"
aria-label="Notes for {{ entry.name }}"
>
</td>
</tr>
</div>
</div>
{% endif %}
{% else %}
<tr class="empty"><td colspan="4">No entries yet.</td></tr>
<div class="empty-row">No entries yet.</div>
{% endfor %}
<tr class="add-row">
<td colspan="4">
<div class="add-row">
<details class="add-entry">
<summary><span class="add-trigger">+ add {{ section.label|lower }}</span></summary>
<form
@ -59,8 +97,6 @@
<input class="notes-input add-notes" type="text" name="notes" placeholder="notes (optional)">
</form>
</details>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>

View file

@ -9,7 +9,7 @@
</div>
<table class="entries">
<tbody>
<tr class="entry" style="--ratio: 1">
<tr class="entry">
<td class="entry-name">
{% if target.entry %}{{ target.entry.name }}{% else %}<span class="muted">No target selected.</span>{% endif %}
</td>

View file

@ -24,16 +24,6 @@ def test_add_entry_strips_and_nulls_empty_notes(db):
assert entry.notes is None
def test_set_entry_notes_updates(db):
entry = service.add_entry(db, Section.food, "Groceries", Decimal("400.00"))
updated = service.set_entry_notes(db, entry.id, "weekly Costco run")
assert updated is not None
assert updated.notes == "weekly Costco run"
def test_set_entry_notes_missing_returns_none(db):
assert service.set_entry_notes(db, 9999, "oops") is None
def test_snapshot_copies_notes(db):
service.add_entry(
@ -91,26 +81,39 @@ def test_create_entry_route_accepts_notes(client):
assert "3 mo cushion" in response.text
def test_update_entry_notes_route(client):
def test_update_entry_notes_via_save_route(client):
client.post(
"/sections/food/entries",
data={"name": "Groceries", "amount": "400.00"},
)
response = client.post(
"/entries/1",
data={"name": "Groceries", "amount": "400.00", "notes": "weekly"},
)
assert response.status_code == 200
assert "weekly" in response.text
def test_update_entry_notes_empty_clears_via_save_route(client):
client.post(
"/sections/food/entries",
data={"name": "Groceries", "amount": "400.00", "notes": "weekly"},
)
response = client.post(
"/entries/1",
data={"name": "Groceries", "amount": "400.00", "notes": ""},
)
assert response.status_code == 200
assert "note-badge" not in response.text
def test_old_entry_notes_route_is_removed(client):
client.post(
"/sections/food/entries",
data={"name": "Groceries", "amount": "400.00"},
)
response = client.post("/entries/1/notes", data={"notes": "weekly"})
assert response.status_code == 200
assert "weekly" in response.text
def test_update_entry_notes_empty_clears(client):
client.post(
"/sections/food/entries",
data={"name": "Groceries", "amount": "400.00", "notes": "weekly"},
)
response = client.post("/entries/1/notes", data={"notes": ""})
assert response.status_code == 200
# the input's value="" still renders but the placeholder kicks in;
# specifically, no literal "weekly" anymore
assert "value=\"weekly\"" not in response.text
assert response.status_code == 404
def test_create_month_entry_route_accepts_notes(client):
@ -136,17 +139,27 @@ def test_update_month_entry_route_accepts_notes(client):
assert "auto-pay" in response.text
def test_budget_page_renders_notes_inputs(client):
def test_budget_page_renders_note_badge_when_notes_set(client):
client.post(
"/sections/fixed_bill/entries",
data={"name": "Rent", "amount": "1200.00", "notes": "due 1st"},
)
response = client.get("/")
assert response.status_code == 200
assert "entry-notes-row" in response.text
assert "note-badge" in response.text
assert "due 1st" in response.text
def test_budget_page_omits_note_badge_when_notes_empty(client):
client.post(
"/sections/food/entries",
data={"name": "Groceries", "amount": "400.00"},
)
response = client.get("/")
assert response.status_code == 200
assert "note-badge" not in response.text
def test_month_page_renders_notes_inputs(client):
client.post(
"/sections/fixed_bill/entries",

View file

@ -86,3 +86,142 @@ def test_reject_non_debt_minimum_target(client):
)
response = client.post("/debt-target", data={"debt_minimum_id": "1"})
assert response.status_code == 400
def test_get_entry_edit_returns_edit_form(client):
client.post(
"/sections/subscription/entries",
data={"name": "Twitch", "amount": "10.99"},
)
response = client.get("/entries/1/edit")
assert response.status_code == 200
assert 'class="entry-row editing"' in response.text
assert 'name="name"' in response.text
assert 'name="amount"' in response.text
assert 'name="notes"' in response.text
assert 'value="Twitch"' in response.text
def test_get_entry_edit_missing_returns_404(client):
response = client.get("/entries/9999/edit")
assert response.status_code == 404
def test_get_entry_edit_other_rows_stay_in_read_mode(client):
client.post("/sections/subscription/entries", data={"name": "Twitch", "amount": "10.99"})
client.post("/sections/subscription/entries", data={"name": "Netflix", "amount": "15.49"})
response = client.get("/entries/1/edit")
assert response.status_code == 200
assert response.text.count('entry-row editing') == 1
assert response.text.count('entry-row reading') == 1
def test_post_entry_updates_name_and_amount(client):
client.post(
"/sections/subscription/entries",
data={"name": "Twitch", "amount": "10.99"},
)
response = client.post(
"/entries/1",
data={"name": "Twitch Prime", "amount": "11.99", "notes": ""},
)
assert response.status_code == 200
assert "Twitch Prime" in response.text
assert "$11.99" in response.text
# returns to read mode
assert 'class="entry-row reading"' in response.text
# OOB swaps for zero widget and group total
assert 'id="zero-widget"' in response.text
assert 'id="group-total-flexible"' in response.text
def test_post_entry_updates_notes_as_badge(client):
client.post(
"/sections/subscription/entries",
data={"name": "Spotify", "amount": "17.48"},
)
response = client.post(
"/entries/1",
data={"name": "Spotify", "amount": "17.48", "notes": "family plan"},
)
assert response.status_code == 200
assert "note-badge" in response.text
assert "family plan" in response.text
def test_post_entry_debt_minimum_includes_target_oob(client):
client.post(
"/sections/debt_minimum/entries",
data={"name": "Card A", "amount": "50.00"},
)
response = client.post(
"/entries/1",
data={"name": "Card A", "amount": "60.00", "notes": ""},
)
assert response.status_code == 200
assert 'id="section-debt_target"' in response.text
def test_post_entry_empty_name_returns_400(client):
client.post(
"/sections/subscription/entries",
data={"name": "Twitch", "amount": "10.99"},
)
response = client.post(
"/entries/1",
data={"name": " ", "amount": "11.99", "notes": ""},
)
assert response.status_code == 400
def test_post_entry_negative_amount_returns_400(client):
client.post(
"/sections/subscription/entries",
data={"name": "Twitch", "amount": "10.99"},
)
response = client.post(
"/entries/1",
data={"name": "Twitch", "amount": "-1.00", "notes": ""},
)
assert response.status_code == 400
def test_post_entry_non_numeric_amount_returns_400(client):
client.post(
"/sections/subscription/entries",
data={"name": "Twitch", "amount": "10.99"},
)
response = client.post(
"/entries/1",
data={"name": "Twitch", "amount": "eleven", "notes": ""},
)
assert response.status_code == 400
def test_post_entry_missing_returns_404(client):
response = client.post(
"/entries/9999",
data={"name": "Whatever", "amount": "1.00", "notes": ""},
)
assert response.status_code == 404
def test_get_section_returns_read_mode(client):
client.post(
"/sections/subscription/entries",
data={"name": "Twitch", "amount": "10.99"},
)
# enter edit mode first
edit = client.get("/entries/1/edit")
assert 'entry-row editing' in edit.text
# now "cancel" via GET /sections/{section}
response = client.get("/sections/subscription")
assert response.status_code == 200
assert 'entry-row reading' in response.text
assert 'entry-row editing' not in response.text
def test_get_section_invalid_returns_422(client):
# FastAPI rejects an unknown Section enum value at routing
response = client.get("/sections/not_a_real_section")
assert response.status_code == 422

View file

@ -41,3 +41,83 @@ def test_debt_target_cleared_on_delete(db):
service.delete_entry(db, dm.id)
target = service.get_debt_target(db)
assert target.debt_minimum_id is None
def test_update_entry_name_only(db):
entry = service.add_entry(db, Section.subscription, "Twitch", Decimal("10.99"))
updated = service.update_entry(db, entry.id, name="Twitch Prime")
assert updated is not None
assert updated.name == "Twitch Prime"
assert updated.amount == Decimal("10.99")
def test_update_entry_amount_only(db):
entry = service.add_entry(db, Section.subscription, "Twitch", Decimal("10.99"))
updated = service.update_entry(db, entry.id, amount=Decimal("11.99"))
assert updated is not None
assert updated.name == "Twitch"
assert updated.amount == Decimal("11.99")
def test_update_entry_notes_set_and_clear(db):
entry = service.add_entry(db, Section.other, "Parking", Decimal("25.00"))
updated = service.update_entry(db, entry.id, notes="work")
assert updated is not None
assert updated.notes == "work"
updated = service.update_entry(db, entry.id, notes="")
assert updated is not None
assert updated.notes is None
service.update_entry(db, entry.id, notes="work again")
updated = service.update_entry(db, entry.id, notes=None)
assert updated is not None
assert updated.notes is None
def test_update_entry_all_three_atomic(db):
entry = service.add_entry(db, Section.food, "Groceries", Decimal("400.00"))
updated = service.update_entry(
db,
entry.id,
name="Groceries (Costco)",
amount=Decimal("450.00"),
notes="weekly run",
)
assert updated is not None
assert updated.name == "Groceries (Costco)"
assert updated.amount == Decimal("450.00")
assert updated.notes == "weekly run"
def test_update_entry_notes_untouched_when_sentinel(db):
entry = service.add_entry(
db, Section.other, "Gift", Decimal("25.00"), notes="birthday"
)
updated = service.update_entry(db, entry.id, amount=Decimal("30.00"))
assert updated is not None
assert updated.notes == "birthday"
def test_update_entry_missing_returns_none(db):
assert service.update_entry(db, 9999, name="Whatever") is None
def test_update_entry_does_not_mutate_existing_month_snapshot(db):
from quartermaster import month_service
entry = service.add_entry(
db, Section.subscription, "Twitch", Decimal("10.99")
)
month = month_service.create_month(db, "2026-04")
me = next(e for e in month.entries if e.source_entry_id == entry.id)
assert me.planned == Decimal("10.99")
assert me.origin_planned == Decimal("10.99")
assert me.name == "Twitch"
assert me.origin_name == "Twitch"
service.update_entry(
db, entry.id, name="Twitch Prime", amount=Decimal("11.99")
)
db.refresh(me)
assert me.planned == Decimal("10.99")
assert me.origin_planned == Decimal("10.99")
assert me.name == "Twitch"
assert me.origin_name == "Twitch"

View file

@ -0,0 +1,43 @@
from __future__ import annotations
from decimal import Decimal
from quartermaster import month_service, service
from quartermaster.models import Section
def test_template_edit_does_not_mutate_existing_month_and_applies_to_next(db):
# seed the template
twitch = service.add_entry(
db, Section.subscription, "Twitch", Decimal("10.99")
)
# create April 2026 (snapshots the current template)
april = month_service.create_month(db, "2026-04")
april_twitch = next(
e for e in april.entries if e.source_entry_id == twitch.id
)
assert april_twitch.planned == Decimal("10.99")
assert april_twitch.origin_planned == Decimal("10.99")
assert april_twitch.name == "Twitch"
assert april_twitch.origin_name == "Twitch"
# edit the template
service.update_entry(
db, twitch.id, name="Twitch Prime", amount=Decimal("11.99")
)
# april is untouched
db.refresh(april_twitch)
assert april_twitch.planned == Decimal("10.99")
assert april_twitch.origin_planned == Decimal("10.99")
assert april_twitch.name == "Twitch"
assert april_twitch.origin_name == "Twitch"
# creating May 2026 picks up the new values
may = month_service.create_month(db, "2026-05")
may_twitch = next(e for e in may.entries if e.source_entry_id == twitch.id)
assert may_twitch.planned == Decimal("11.99")
assert may_twitch.origin_planned == Decimal("11.99")
assert may_twitch.name == "Twitch Prime"
assert may_twitch.origin_name == "Twitch Prime"