new classification ui

This commit is contained in:
Lars Nolden
2026-09-13 13:52:30 +02:00
parent 10314fb1cd
commit 62a7d6daf4
7 changed files with 494 additions and 115 deletions
+58 -7
View File
@@ -242,7 +242,7 @@ func TestCancelledPreviewRunProducesNoPreview(t *testing.T) {
}
time.Sleep(5 * time.Millisecond)
}
if _, err := a.ApplyPreview(context.Background(), start.ID, s.Revision, []string{"any"}); err == nil {
if _, err := a.ApplyPreview(context.Background(), start.ID, s.Revision, []string{"any"}, nil); err == nil {
t.Fatal("cancelled run produced an applicable preview")
}
after, err := a.Snapshot(context.Background())
@@ -334,7 +334,7 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T
t.Fatal("preview mutated canonical records")
}
id := preview.Changes[0].ID
applied, err := a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id})
applied, err := a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id}, nil)
if err != nil {
t.Fatal(err)
}
@@ -356,7 +356,7 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T
t.Fatal("unselected transaction changed")
}
}
if _, err = a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id}); err == nil {
if _, err = a.ApplyPreview(context.Background(), preview.ID, preview.Revision, []string{id}, nil); err == nil {
t.Fatal("consumed preview applied twice")
}
}
@@ -375,7 +375,7 @@ func TestStalePreviewCannotOverwriteManualCorrection(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if _, err = a.ApplyPreview(context.Background(), p.ID, p.Revision, []string{p.Changes[0].ID}); err == nil {
if _, err = a.ApplyPreview(context.Background(), p.ID, p.Revision, []string{p.Changes[0].ID}, nil); err == nil {
t.Fatal("stale preview overwrote manual edit")
}
after, err := a.Snapshot(context.Background())
@@ -410,13 +410,13 @@ func TestApplyPreviewSurvivesUnrelatedCommitsAndPartialApplies(t *testing.T) {
}); err != nil {
t.Fatal(err)
}
first, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[0].ID})
first, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[0].ID}, nil)
if err != nil {
t.Fatalf("unrelated commit invalidated the preview: %v", err)
}
// The partial apply moved the revision again; the remaining proposal must
// still apply without another paced provider run.
second, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[1].ID})
second, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[1].ID}, nil)
if err != nil {
t.Fatalf("partial apply consumed the remaining proposals: %v", err)
}
@@ -429,11 +429,62 @@ func TestApplyPreviewSurvivesUnrelatedCommitsAndPartialApplies(t *testing.T) {
}
}
// Both changes are consumed now; re-applying must fail, not double-write.
if _, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[0].ID}); err == nil {
if _, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{p.Changes[0].ID}, nil); err == nil {
t.Fatal("consumed change applied twice")
}
}
// A reviewer can correct a proposal before applying it: the corrected fields
// land instead of the model's, provenance becomes manual, and an invalid or
// unselected correction rejects the whole apply.
func TestApplyPreviewHonoursReviewerEdits(t *testing.T) {
ctx := context.Background()
a, s := testApp(t)
s = seed(t, a, s)
s, err := a.Mutate(ctx, s.Revision, func(d *domain.Dataset) error {
d.Categories = append(d.Categories, domain.Category{ID: "dining", Name: "Dining", ParentID: "cat_expenses", Kind: "expense"})
return nil
})
if err != nil {
t.Fatal(err)
}
mockClassifier(t, a)
p, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true, Tags: true}})
if err != nil {
t.Fatal(err)
}
if len(p.Changes) != 2 {
t.Fatalf("expected two proposed changes: %+v", p)
}
edited, other := p.Changes[0], p.Changes[1]
if _, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{edited.ID}, []EnrichmentEdit{{ID: edited.ID, CategoryID: "nonexistent", TagIDs: []string{}}}); err == nil {
t.Fatal("edit naming an unknown category was applied")
}
if _, err = a.ApplyPreview(ctx, p.ID, p.Revision, []string{edited.ID}, []EnrichmentEdit{{ID: other.ID, CategoryID: "dining", TagIDs: []string{}}}); err == nil {
t.Fatal("edit for an unselected transaction was accepted")
}
applied, err := a.ApplyPreview(ctx, p.ID, p.Revision, []string{edited.ID, other.ID}, []EnrichmentEdit{{ID: edited.ID, CategoryID: "dining", TagIDs: []string{"home"}}})
if err != nil {
t.Fatal(err)
}
for _, tx := range applied.Data.Transactions {
e := tx.Enrichment
switch tx.Facts.ID {
case edited.ID:
if e.CategoryID != "dining" || !reflect.DeepEqual(e.TagIDs, []string{"home"}) {
t.Fatalf("reviewer correction lost: %+v", e)
}
if e.Classification.Source != "manual" {
t.Fatalf("corrected change kept model provenance: %+v", e.Classification)
}
case other.ID:
if e.CategoryID != "groceries" || e.Classification.Source == "manual" {
t.Fatalf("uncorrected change altered: %+v", e)
}
}
}
}
// Imports auto-apply only what the model is sure about: a low-confidence
// category lands on the editable fallback while the merchant link and the
// recorded confidence survive for review in Analyse.