better convert

This commit is contained in:
mats committed 2026-06-29 13:17:43 +02:00
1 parent 2a66754f9e
commit b128608316
7 files changed
+591 -20

No files matched your search

+187
View File
@@ -0,0 +1,187 @@
package projection
import (
"errors"
"fmt"
"sort"
"testing"
)
// busToStore wires an Emitter's frames straight into a Store via HandleFrame —
// the in-process equivalent of publishing to the bus and a receiver consuming.
// Returns a PublishFunc the emitter calls per frame.
func busToStore[W, T any](t *testing.T, s *Store[W, T]) PublishFunc {
t.Helper()
return func(meta map[string]string, payload []byte) error {
return s.HandleFrame(meta, payload)
}
}
// newIdentityStore builds a Store where wire == stored (W == T == tProj), using
// the generic JSON codec. opts forwards e.g. WithKeyHashing.
func newIdentityStore(t *testing.T, opts ...StoreOption) *Store[tProj, tProj] {
t.Helper()
s, err := NewStore[tProj, tProj](t.TempDir(), "emit.bin",
JSONDecode[tProj], func(p tProj) tProj { return p }, nil, opts...)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Close() })
return s
}
func ups(items ...tProj) []Update[tProj] {
out := make([]Update[tProj], len(items))
for i, it := range items {
out[i] = Update[tProj]{Key: it.SKU, Value: it}
}
return out
}
// TestEmitter_SnapshotRoundTripsIntoStore — a full snapshot emitted frame-by-frame
// reassembles in the receiver's Store, served by Get.
func TestEmitter_SnapshotRoundTripsIntoStore(t *testing.T) {
s := newIdentityStore(t)
e := NewEmitter[tProj](JSONEncode[tProj], busToStore(t, s), WithTenant("se"), WithChunkSize(2))
items := ups(
makeProjection("A", 100), makeProjection("B", 200),
makeProjection("C", 300), makeProjection("D", 400), makeProjection("E", 500),
)
chunks, err := e.Snapshot(1000, items)
if err != nil {
t.Fatalf("Snapshot: %v", err)
}
if chunks != 3 { // 5 items / chunkSize 2 = ceil = 3
t.Errorf("chunks = %d, want 3", chunks)
}
if ep, base, _ := s.Stats(); ep != 1000 || base != 5 {
t.Fatalf("stats = epoch %d base %d, want 1000/5", ep, base)
}
for _, k := range []string{"A", "C", "E"} {
if _, ok := s.Get(k); !ok {
t.Errorf("Get(%q) miss after snapshot", k)
}
}
}
// TestEmitter_DeltaAfterSnapshot — a same-epoch delta lands in the overlay and
// wins over the base; a tombstone hides the key.
func TestEmitter_DeltaAfterSnapshot(t *testing.T) {
s := newIdentityStore(t)
e := NewEmitter[tProj](JSONEncode[tProj], busToStore(t, s))
if _, err := e.Snapshot(10, ups(makeProjection("A", 100), makeProjection("B", 200))); err != nil {
t.Fatal(err)
}
// upsert A to a new price + delete B, same epoch.
if err := e.Delta(10, []Update[tProj]{
{Key: "A", Value: makeProjection("A", 999)},
{Key: "B", Deleted: true},
}); err != nil {
t.Fatal(err)
}
if got, _ := s.Get("A"); got.PriceIncVat != 999 {
t.Errorf("A = %d, want overlay 999", got.PriceIncVat)
}
if _, ok := s.Get("B"); ok {
t.Error("B should be tombstoned")
}
if !s.IsDeleted("B") {
t.Error("IsDeleted(B) should be true")
}
}
// TestEmitter_EmptySnapshotRefused — emitting nothing must not wipe receivers.
func TestEmitter_EmptySnapshotRefused(t *testing.T) {
var published int
e := NewEmitter[tProj](JSONEncode[tProj], func(map[string]string, []byte) error {
published++
return nil
})
if _, err := e.Snapshot(1, nil); !errors.Is(err, ErrEmptySnapshot) {
t.Fatalf("empty Snapshot err = %v, want ErrEmptySnapshot", err)
}
if published != 0 {
t.Errorf("published %d frames for an empty snapshot, want 0", published)
}
}
// TestEmitter_PublishErrorAbortsStream — a publish failure mid-stream stops the
// emitter and the receiver keeps its previous base.
func TestEmitter_PublishErrorAbortsStream(t *testing.T) {
s := newIdentityStore(t)
// Seed a good base at epoch 5.
good := NewEmitter[tProj](JSONEncode[tProj], busToStore(t, s))
if _, err := good.Snapshot(5, ups(makeProjection("OLD", 1))); err != nil {
t.Fatal(err)
}
// New emitter whose publish fails on the first chunk of the next snapshot.
calls := 0
flaky := NewEmitter[tProj](JSONEncode[tProj], func(meta map[string]string, payload []byte) error {
calls++
if meta[MetaSnapshotPhase] == SnapshotChunk {
return fmt.Errorf("boom")
}
return s.HandleFrame(meta, payload)
})
if _, err := flaky.Snapshot(9, ups(makeProjection("NEW", 2))); err == nil {
t.Fatal("expected Snapshot to surface the publish error")
}
// Base stays at the old epoch; OLD still served, NEW never applied.
if ep := s.Epoch(); ep != 5 {
t.Errorf("epoch = %d, want 5 (aborted snapshot didn't swap)", ep)
}
if _, ok := s.Get("OLD"); !ok {
t.Error("OLD should survive the aborted rebuild")
}
if _, ok := s.Get("NEW"); ok {
t.Error("NEW must not be visible from an aborted snapshot")
}
}
// TestEmitter_HashedKeysEndToEnd — long keys flow producer→consumer in hashed
// mode: Get by original key works, and Scan yields digests with the original
// recoverable from the value. Ranked top-N (the popularity use case) works.
func TestEmitter_HashedKeysEndToEnd(t *testing.T) {
s := newIdentityStore(t, WithKeyHashing())
e := NewEmitter[tProj](JSONEncode[tProj], busToStore(t, s))
// Keys that blow past 32 bytes (query strings), values carry the original +
// a score in PriceIncVat to rank by.
mk := func(q string, score int64) tProj { return tProj{SKU: q, PriceIncVat: score} }
q1 := "the quick brown fox jumps over the lazy dog (query one)"
q2 := "another unbounded length search query that a user typed"
q3 := "short-ish but still over thirty-two bytes for sure yes"
if _, err := e.Snapshot(1, ups(mk(q1, 30), mk(q2, 10), mk(q3, 20))); err != nil {
t.Fatal(err)
}
// Point lookup by the ORIGINAL key.
if got, ok := s.Get(q1); !ok || got.PriceIncVat != 30 {
t.Fatalf("Get(q1) = %+v ok=%v, want score 30", got, ok)
}
// Ranked enumeration: collect via Scan (keys are digests), sort by score,
// recover the original query from the value. Top-N popularity in a nutshell.
type row struct {
query string
score int64
}
var rows []row
s.Scan(func(key string, v tProj) bool {
if len(key) != 32 {
t.Errorf("hashed Scan key len = %d, want 32", len(key))
}
rows = append(rows, row{v.SKU, v.PriceIncVat})
return true
})
if len(rows) != 3 {
t.Fatalf("scanned %d rows, want 3", len(rows))
}
sort.Slice(rows, func(i, j int) bool { return rows[i].score > rows[j].score })
if rows[0].query != q1 || rows[1].query != q3 || rows[2].query != q2 {
t.Fatalf("ranked order wrong: %+v", rows)
}
}