textmachine/backend/internal/pipeline/stopflag_test.go

112 lines
5 KiB
Go

package pipeline
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"textmachine/backend/internal/miner"
"textmachine/backend/internal/text"
)
// stopflag_test.go: unit pins of the §4.1 stop predicate. The end-to-end semantics live in
// bankchain_test.go (TestTheStopIsAFlagNotAGateOnDecisions) and miningstop_join_test.go; these pin the
// cluster geometry the harness cannot reach cheaply.
func TestAClusterSharingAnAliasWithTheMemoryReadsAsPresented(t *testing.T) {
// The representative of a cluster can CHANGE as the book grows — the first-ranked member may become
// an alias of the same entity. Memory by intersection is what keeps that from re-stopping: a term
// whose src is fresh but whose cluster touches a presented surface has been seen.
presented := map[string]bool{text.NormalizeSourceKey("方源"): true}
swapped := []miner.Term{{Src: "小方", Aliases: []string{"方源"}}}
if hasUnpresentedCluster(swapped, presented) {
t.Fatal("a cluster sharing a surface with the memory has been presented; a representative swap must not re-stop")
}
// …and the accepted harmful residue, recorded as the pin of a DECISION rather than of an accident: a
// genuinely new entity sharing an alias surface with a presented cluster is NOT presented either. It
// still reaches the editor as an unsigned auto row; changing this trade needs a new decision, not a
// refactor.
newEntity := []miner.Term{{Src: "老方", Aliases: []string{"方源", "方大人"}}}
if hasUnpresentedCluster(newEntity, presented) {
t.Fatal("intersection semantics: a cluster touching the memory reads as seen (the named two-sided residue)")
}
}
func TestAWhollyNewClusterTripsTheFlag(t *testing.T) {
presented := map[string]bool{text.NormalizeSourceKey("方源"): true}
fresh := []miner.Term{
{Src: "方源"}, // seen
{Src: "花家", Aliases: []string{"花家的人"}}, // wholly outside the memory
}
if !hasUnpresentedCluster(fresh, presented) {
t.Fatal("a map holding a cluster the memory has never seen must stop")
}
if hasUnpresentedCluster(nil, presented) {
t.Fatal("an empty map holds nothing to stop on")
}
}
func TestPresentedSurfacesFlattenEveryClusterMemberNormalized(t *testing.T) {
// What the memory stores is exactly what the next map is compared against: every member of every
// cluster, in the emission's own key form (NormalizeSourceKey — trad→simp, kana fold, casefold; NOT
// a whitespace trim, spaces are part of a key), blanks dropped. 陳博 arrives TRADITIONAL here (陳→陈)
// and must land simplified, or the memory would never match the miner's simplified candidate keys —
// the fixture char is chosen to actually cross the fold (review-workflow finding: the first fixture
// was an identity under normalization and the claim was vacuous).
got := presentedSurfaces([]miner.Term{
{Src: "陳博", Aliases: []string{"方大人", ""}},
{Src: "花家"},
})
if text.NormalizeSourceKey("陳博") == "陳博" {
t.Fatal("fixture premise broken: 陳博 no longer crosses the trad→simp fold")
}
want := map[string]bool{
text.NormalizeSourceKey("陳博"): true,
text.NormalizeSourceKey("方大人"): true,
text.NormalizeSourceKey("花家"): true,
}
if len(got) != len(want) {
t.Fatalf("got %v, want the members of %v", got, want)
}
for _, s := range got {
if !want[s] {
t.Fatalf("surface %q is not in the normalized member set %v", s, want)
}
}
}
// TestTheMemoryIsNotWrittenWhenTheMapCannotBe pins the §4.2-of-fix2 ORDER — the map on disk first, the
// memory second, never the reverse. The asymmetry is load-bearing: a crash between the two costs one
// benign re-stop, but a remembered surface whose map never reached disk is a SILENT LOSS — the run
// stops pointing the owner at a file that is not there, the clusters read as presented, and no later
// flag ever stops on them again. The acceptance planted «downgrade the map-write failure to a warning»
// and the battery stayed green in all three candidate packages; this is the pin that reddens on it.
func TestTheMemoryIsNotWrittenWhenTheMapCannotBe(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, miningStopProvider(t, rec, ""))
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{})
r := newVerifyRunner(t, bookPath)
defer r.Close()
// The map's path is occupied by a non-empty directory: the atomic replace cannot land.
if err := os.MkdirAll(filepath.Join(r.signatureMapPath(), "occupied"), 0o755); err != nil {
t.Fatal(err)
}
_, err := r.TranslateBook(context.Background())
var stop *WaveSignatureStop
if errors.As(err, &stop) {
t.Fatalf("the run STOPPED pointing the owner at a map that never reached disk (%d terms)", stop.Terms)
}
if err == nil {
t.Fatal("a run whose signature map cannot be written must fail loud, not proceed")
}
mem, merr := r.Store.StopPresentedSurfaces("test-book")
if merr != nil {
t.Fatal(merr)
}
if len(mem) != 0 {
t.Fatalf("surfaces were marked presented though their map never reached disk — no later flag will ever stop on them: %v", mem)
}
}