fix refresh

This commit is contained in:
Lars Nolden
2026-09-14 10:01:57 +02:00
parent 46e02d95cb
commit f9e829e6ba
5 changed files with 78 additions and 38 deletions
+54 -14
View File
@@ -171,10 +171,8 @@ func TestPreviewCooldownProtectsLaterPreviewsAndImports(t *testing.T) {
defer cancel()
for _, model := range []string{"test/model", "test/another-model"} {
preview, err := runPreview(t, a, PreviewRequest{
Revision: s.Revision, From: "2026-09-01", To: "2026-09-30",
Model: model, Fields: Fields{Category: true},
})
preview, err := runPreview(t, a, PreviewRequest{From: "2026-09-01", To: "2026-09-30",
Model: model, Fields: Fields{Category: true}})
if err != nil {
t.Fatal(err)
}
@@ -224,10 +222,8 @@ func TestCancelledPreviewRunProducesNoPreview(t *testing.T) {
}))
defer provider.Close()
a.classifier = classification.Client{APIKey: "test", Model: "test/model", BaseURL: provider.URL}
start, err := a.StartPreview(context.Background(), PreviewRequest{
Revision: s.Revision, From: "2026-09-09", To: "2026-09-09",
Model: "test/model", Fields: Fields{Category: true},
})
start, err := a.StartPreview(context.Background(), PreviewRequest{From: "2026-09-09", To: "2026-09-09",
Model: "test/model", Fields: Fields{Category: true}})
if err != nil {
t.Fatal(err)
}
@@ -319,7 +315,7 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T
}
mockClassifier(t, a)
before := domain.Clone(s.Data)
preview, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "improved/model", Fields: Fields{Category: true}})
preview, err := runPreview(t, a, PreviewRequest{From: "2026-09-01", To: "2026-09-30", Model: "improved/model", Fields: Fields{Category: true}})
if err != nil {
t.Fatal(err)
}
@@ -361,6 +357,50 @@ func TestPreviewIsReadOnlySelectedApplyPreservesFactsAndOtherFields(t *testing.T
}
}
func TestPreviewUsesLatestSnapshotWithoutClientRevision(t *testing.T) {
ctx := context.Background()
a, page := testApp(t)
page = seed(t, a, page)
mockClassifier(t, a)
request := PreviewRequest{From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}}
id := page.Data.Transactions[0].Facts.ID
// Another writer changes the journal after the page loaded its state.
current, err := a.Mutate(ctx, page.Revision, func(d *domain.Dataset) error {
for i := range d.Transactions {
if d.Transactions[i].Facts.ID == id {
d.Transactions[i].Enrichment.TagIDs = []string{"home"}
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
preview, err := runPreview(t, a, request)
if err != nil {
t.Fatal(err)
}
if preview.Revision != current.Revision || len(preview.Changes) != 2 {
t.Fatalf("preview did not use the latest snapshot: %+v", preview)
}
unchanged, err := a.Snapshot(ctx)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(unchanged.Data, current.Data) {
t.Fatal("starting analysis changed the journal")
}
applied, err := a.ApplyPreview(ctx, preview.ID, preview.Revision, []string{id}, nil)
if err != nil {
t.Fatal(err)
}
for _, tx := range applied.Data.Transactions {
if tx.Facts.ID == id && (tx.Enrichment.CategoryID != "groceries" || !reflect.DeepEqual(tx.Enrichment.TagIDs, []string{"home"})) {
t.Fatalf("analysis overwrote an edit made after the page loaded: %+v", tx.Enrichment)
}
}
}
func TestPreviewExpiresAfterTwentyFourHours(t *testing.T) {
for _, tc := range []struct {
name string
@@ -378,7 +418,7 @@ func TestPreviewExpiresAfterTwentyFourHours(t *testing.T) {
a, s := testApp(t)
s = seed(t, a, s)
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}})
p, err := runPreview(t, a, PreviewRequest{From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
if err != nil {
t.Fatal(err)
}
@@ -392,7 +432,7 @@ func TestPreviewExpiresAfterTwentyFourHours(t *testing.T) {
if tc.newPreview {
// Completing another run performs expired-preview cleanup.
// An empty range needs no additional provider request.
if _, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2025-01-01", To: "2025-01-31", Model: "test/model", Fields: Fields{Category: true}}); err != nil {
if _, err := runPreview(t, a, PreviewRequest{From: "2025-01-01", To: "2025-01-31", Model: "test/model", Fields: Fields{Category: true}}); err != nil {
t.Fatal(err)
}
}
@@ -423,7 +463,7 @@ func TestStalePreviewCannotOverwriteManualCorrection(t *testing.T) {
a, s := testApp(t)
s = seed(t, a, s)
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}})
p, err := runPreview(t, a, PreviewRequest{From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
if err != nil {
t.Fatal(err)
}
@@ -455,7 +495,7 @@ func TestApplyPreviewSurvivesUnrelatedCommitsAndPartialApplies(t *testing.T) {
a, s := testApp(t)
s = seed(t, a, s)
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}})
p, err := runPreview(t, a, PreviewRequest{From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
if err != nil {
t.Fatal(err)
}
@@ -508,7 +548,7 @@ func TestApplyPreviewHonoursReviewerEdits(t *testing.T) {
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}})
p, err := runPreview(t, a, PreviewRequest{From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true, Tags: true}})
if err != nil {
t.Fatal(err)
}
+7 -7
View File
@@ -10,9 +10,9 @@ import (
"testing"
)
func checkOpenRouterPreview(t *testing.T, a *App, s State, auth <-chan string, key string) {
func checkOpenRouterPreview(t *testing.T, a *App, auth <-chan string, key string) {
t.Helper()
p, err := runPreview(t, a, PreviewRequest{Revision: s.Revision, From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
p, err := runPreview(t, a, PreviewRequest{From: "2026-09-01", To: "2026-09-30", Model: "test/model", Fields: Fields{Category: true}})
if err != nil {
t.Fatal(err)
}
@@ -68,7 +68,7 @@ func TestOpenRouterKeyRotationChangesProviderAuthorization(t *testing.T) {
if strings.Contains(string(encoded), "private-key") {
t.Fatal("saved credential leaked into browser state")
}
checkOpenRouterPreview(t, a, s, auth, key)
checkOpenRouterPreview(t, a, auth, key)
}
}
@@ -101,7 +101,7 @@ func TestOpenRouterSavedKeyAndDisableSurviveRestartOverrideEnvironment(t *testin
}
})
reopen()
checkOpenRouterPreview(t, a, s, auth, "environment-private-key")
checkOpenRouterPreview(t, a, auth, "environment-private-key")
for _, key := range []string{"saved-private-key", ""} {
var err error
s, err = a.SaveOpenRouterKey(context.Background(), key)
@@ -119,7 +119,7 @@ func TestOpenRouterSavedKeyAndDisableSurviveRestartOverrideEnvironment(t *testin
if s.Status.AIConfigured != (key != "") {
t.Fatal("restarted credential status ignored saved preference")
}
checkOpenRouterPreview(t, a, s, auth, key)
checkOpenRouterPreview(t, a, auth, key)
}
}
@@ -187,7 +187,7 @@ func TestOpenRouterRejectedKeysPreserveActiveCredential(t *testing.T) {
}
})
}
checkOpenRouterPreview(t, a, s, auth, key)
checkOpenRouterPreview(t, a, auth, key)
}
func TestOpenRouterFailedWritePreservesActiveCredential(t *testing.T) {
@@ -216,5 +216,5 @@ func TestOpenRouterFailedWritePreservesActiveCredential(t *testing.T) {
t.Fatal("persistence error leaked credential content")
}
}
checkOpenRouterPreview(t, a, s, auth, "active-private-key")
checkOpenRouterPreview(t, a, auth, "active-private-key")
}
+9 -14
View File
@@ -21,11 +21,10 @@ type Fields struct {
Tags bool `json:"tags"`
}
type PreviewRequest struct {
Revision string `json:"revision"`
From string `json:"from"`
To string `json:"to"`
Model string `json:"model"`
Fields Fields `json:"fields"`
From string `json:"from"`
To string `json:"to"`
Model string `json:"model"`
Fields Fields `json:"fields"`
}
type Change struct {
ID string `json:"id"`
@@ -119,12 +118,11 @@ func previewEligible(t domain.Transaction, r PreviewRequest) bool {
t.Enrichment.Kind != "transfer" && t.Enrichment.Kind != domain.KindInvestment
}
// StartPreview validates the request against the current journal and starts a
// background classification run. The provider is paced to one request every
// few seconds, so any real range takes minutes: the caller polls
// PreviewProgress instead of holding an HTTP request open for the duration.
// Only one run exists at a time; the run owns its own snapshot and never
// touches canonical data.
// StartPreview takes a fresh journal snapshot and starts a read-only
// classification run. It does not require the page's revision: a sync or edit
// while the page is open must not block analysis. ApplyPreview checks for
// conflicting changes before writing. Only one run exists at a time; callers
// poll PreviewProgress instead of holding an HTTP request open.
func (a *App) StartPreview(ctx context.Context, r PreviewRequest) (PreviewProgress, error) {
if err := validatePreviewRequest(r); err != nil {
return PreviewProgress{}, err
@@ -138,9 +136,6 @@ func (a *App) StartPreview(ctx context.Context, r PreviewRequest) (PreviewProgre
if err != nil {
return PreviewProgress{}, err
}
if r.Revision != s.Revision {
return PreviewProgress{}, errors.New("revision conflict: reload before analysing")
}
client := a.classifier.WithModel(r.Model)
total := 0
for _, t := range s.Data.Transactions {