Files
acp/compat_af05f05c_test.go
Samuel Cui ac02c71d3f feat!: stream caller-owned items and own the result queue
Reshape the public API around one push engine and restore the af05f05c
surface as a compatibility shell over it.

- NewStream takes caller-owned items (Source/Targets), reports every accepted
  item through one results callback, and Wait returns the run's terminal error.
  Run, BatchSource and the item callbacks are gone.
- The engine owns the result path: WithResultBuffer is the depth of the result
  queue, WithResultBatch is how many results one delivery carries, and
  WithResultFlushInterval is how long a successful result may wait. A result
  that carries any error is delivered immediately as its own batch.
- Restore the af05f05c API byte-compatibly in the same package: New, Copyer,
  Wait/WaitErr, AccurateJob, WildcardJob, the report events and the JSON report
  round-trip through encoding/json alone.
- One descriptor serves a whole item; the stored hash is read through the
  descriptor that reads the content and the computed hash is published through
  it before it closes.
- Make every handoff escapable by a hard stop: the read buffer, a chunk send,
  an event send and the result buffer.
- Fix the recorded sweep findings: report indent and round-trip, cmd/acp exit
  status, acp-rewrite final paths, mmap EOF/close/slice, disk-usage
  reservations, ErrTargetIO identity, the fatal-panic safety net, option
  validation, and the FreeBSD/Windows build paths.
- Cover the contract with semantic tests, add Makefile check/race/vet/cross
  targets, and document the withdrawn v0.1.0/v0.2.0 migration in README.
2026-09-19 15:18:13 +08:00

299 lines
11 KiB
Go

// Package acp_test keeps a compile-level guard for the af05f05c public API. ACP is a public
// library: every function, method and struct of that commit keeps its name, and the report row
// keeps its field names, types and JSON tags, so a caller written against af05f05c still
// compiles and runs without changing a line.
//
// The declarations and the run below only use the exported surface, exactly like a caller does,
// so a break of the old shape fails this build instead of a downstream one.
package acp_test
import (
"context"
"encoding/json"
"errors"
"io/fs"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"github.com/samuelncui/acp"
"github.com/sirupsen/logrus"
)
// The af05f05c functions, options and methods with their af05f05c signatures. WaitErr is the
// additive companion of Wait: it is what a caller reads the run's outcome from.
var (
_ func(context.Context, ...acp.Option) (*acp.Copyer, error) = acp.New
_ func(*acp.Copyer) = (*acp.Copyer).Wait
_ func(*acp.Copyer) error = (*acp.Copyer).WaitErr
_ func(string, []string) acp.Option = acp.AccurateJob
_ func(...acp.WildcardJobOption) acp.Option = acp.WildcardJob
_ func(...string) acp.WildcardJobOption = acp.Source
_ func(string, ...[]string) acp.WildcardJobOption = acp.AccurateSource
_ func(...string) acp.WildcardJobOption = acp.Target
_ func(...acp.DeviceOption) acp.Option = acp.SetFromDevice
_ func(...acp.DeviceOption) acp.Option = acp.SetToDevice
_ func(bool) acp.DeviceOption = acp.LinearDevice
_ func(int) acp.DeviceOption = acp.DeviceThreads
_ func(bool) acp.Option = acp.Overwrite
_ func(bool) acp.Option = acp.WithHash
_ func(*logrus.Logger) acp.Option = acp.WithLogger
_ func(acp.EventHandler) acp.Option = acp.WithEventHandler
_ func() acp.Option = acp.WithProgressBar
_ func() acp.EventHandler = acp.NewProgressBar
_ func() (acp.EventHandler, acp.ReportGetter) = acp.NewReportGetter
_ func(*acp.Report, bool) string = (*acp.Report).ToJSONString
_ func(func(string) int) func(string) int = acp.Cache[string, int]
_ acp.ReportGetter = func() *acp.Report { return new(acp.Report) }
_ acp.EventHandler = func(acp.Event) {}
_ error = (*acp.Error)(nil)
)
// The af05f05c report row: every field with the type and the JSON tag of that commit. A field
// that changed type, such as Path, breaks this literal.
func af05f05cReportRow() *acp.Job {
return &acp.Job{
Base: "/source",
Path: []string{"nested", "data.txt"},
Status: acp.JobStatusFinished,
SuccessTargets: []string{"/target/nested/data.txt"},
FailTargets: map[string]error{"/target/other.txt": errors.New("file exists")},
Size: int64(6),
Mode: fs.FileMode(0o644),
ModTime: time.Unix(1700000000, 0),
WriteTime: time.Unix(1700000001, 0),
SHA256: "abc",
}
}
// The af05f05c events, including the row event the report machinery consumes.
func af05f05cEvents() []acp.Event {
return []acp.Event{
&acp.EventUpdateJob{Job: af05f05cReportRow()},
&acp.EventUpdateCount{Bytes: 6, Files: 1, Finished: true},
&acp.EventUpdateProgress{Bytes: 6, Files: 1, Finished: true},
&acp.EventReportError{Error: &acp.Error{Src: "src", Dst: "dst", Err: errors.New("boom")}},
&acp.EventFinished{},
}
}
// af05f05cJobs is the job vocabulary of that commit: an exact pair and a wildcard tree that
// uses both a plain source and a segmented one.
func af05f05cJobs(treeSource, exactSource, exactTarget, mappedTarget string) []acp.Option {
return []acp.Option{
acp.AccurateJob(exactSource, []string{exactTarget}),
acp.WildcardJob(
acp.Source(treeSource),
acp.AccurateSource(filepath.Dir(exactSource), []string{filepath.Base(exactSource)}),
acp.Target(mappedTarget),
),
}
}
// TestAf05f05cRowJSONShape pins the old document: base is a string, path is an array, and the
// fields the old release wrote keep their names and order-independent values.
func TestAf05f05cRowJSONShape(t *testing.T) {
encoded, err := json.Marshal(af05f05cReportRow())
if err != nil {
t.Fatalf("marshal af05f05c row: %v", err)
}
var shape struct {
Base string `json:"base"`
Path []string `json:"path"`
Status string `json:"status"`
Size int64 `json:"size"`
Mode uint32 `json:"mode"`
SHA256 string `json:"sha256"`
Success []string `json:"success_target"`
Fail map[string]string `json:"fail_target"`
}
if err := json.Unmarshal(encoded, &shape); err != nil {
t.Fatalf("decode af05f05c row: %v\n%s", err, encoded)
}
if shape.Base != "/source" || !slices.Equal(shape.Path, []string{"nested", "data.txt"}) {
t.Fatalf("row identity = %s", encoded)
}
if shape.Status != acp.JobStatusFinished || shape.Size != 6 || shape.Mode != 0o644 || shape.SHA256 != "abc" {
t.Fatalf("row facts = %s", encoded)
}
if len(shape.Success) != 1 || shape.Success[0] != "/target/nested/data.txt" {
t.Fatalf("row success targets = %s", encoded)
}
if shape.Fail["/target/other.txt"] != "file exists" {
t.Fatalf("row fail targets = %s", encoded)
}
// The status vocabulary and the walk mask of that commit stay exported.
for _, status := range []string{
acp.JobStatusPending, acp.JobStatusPreparing, acp.JobStatusCopying,
acp.JobStatusFinishing, acp.JobStatusFinished,
} {
if status == "" {
t.Fatal("empty job status constant")
}
}
if acp.UnexpectFileMode == 0 {
t.Fatal("UnexpectFileMode is empty")
}
}
// TestShellSurfaceRunsThroughTheOldCalls drives the af05f05c shell exactly as the old callers
// did: New plus job options plus WaitErr, with the report read through NewReportGetter and the
// old events delivered to an old handler.
func TestShellSurfaceRunsThroughTheOldCalls(t *testing.T) {
root := t.TempDir()
source := filepath.Join(root, "source")
if err := os.MkdirAll(filepath.Join(source, "nested"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(source, "nested", "data.txt"), []byte("nested"), 0o644); err != nil {
t.Fatal(err)
}
target := filepath.Join(root, "target")
if err := os.MkdirAll(target, 0o755); err != nil {
t.Fatal(err)
}
// Every event shape of af05f05c still satisfies the exported interface.
events := af05f05cEvents()
if len(events) != 5 {
t.Fatalf("events = %d, want 5", len(events))
}
if event, ok := events[0].(*acp.EventUpdateJob); !ok || event.Job.Base != "/source" {
t.Fatalf("row event = %#v", events[0])
}
cached := acp.Cache(func(in string) int { return len(in) })
if got := cached("acp"); got != 3 {
t.Fatalf("Cache() = %d, want 3", got)
}
handler, getter := acp.NewReportGetter()
options := af05f05cJobs(
source,
filepath.Join(source, "nested", "data.txt"),
filepath.Join(root, "exact.txt"),
target,
)
options = append(options,
acp.SetFromDevice(acp.LinearDevice(false), acp.DeviceThreads(1)),
acp.SetToDevice(acp.DeviceThreads(1)),
acp.Overwrite(false),
acp.WithHash(true),
acp.WithLogger(logrus.StandardLogger()),
acp.WithEventHandler(handler),
)
copyer, err := acp.New(context.Background(), options...)
if err != nil {
t.Fatalf("New() error = %v", err)
}
if err := copyer.WaitErr(); err != nil {
t.Fatalf("WaitErr() error = %v, want nil", err)
}
copyer.Wait()
report := getter()
if report == nil || len(report.Errors) != 0 {
t.Fatalf("report = %#v, want rows without pipeline errors", report)
}
if len(report.Jobs) == 0 {
t.Fatal("report has no rows")
}
for _, row := range report.Jobs {
if row.Base == "" || len(row.Path) == 0 {
t.Fatalf("row identity = %#v, want a base and a segmented path", row)
}
if row.FullPath == "" {
t.Fatalf("row = %#v, want the additive whole path", row)
}
}
if _, err := os.Stat(filepath.Join(target, "source", "nested", "data.txt")); err != nil {
t.Fatalf("copied file: %v", err)
}
}
// TestShellPublishesTheOldBaseAndSegmentedPath pins the row of a nested source file: the shell
// reports the source base and the source-relative path segments af05f05c wrote, and the report
// document carries that path as an array.
func TestShellPublishesTheOldBaseAndSegmentedPath(t *testing.T) {
root := t.TempDir()
source := filepath.Join(root, "source")
if err := os.MkdirAll(filepath.Join(source, "nested"), 0o755); err != nil {
t.Fatal(err)
}
nested := filepath.Join(source, "nested", "data.txt")
if err := os.WriteFile(nested, []byte("nested"), 0o644); err != nil {
t.Fatal(err)
}
target := filepath.Join(root, "target")
if err := os.MkdirAll(target, 0o755); err != nil {
t.Fatal(err)
}
handler, getter := acp.NewReportGetter()
copyer, err := acp.New(
context.Background(),
acp.WildcardJob(acp.Source(source), acp.Target(target)),
acp.WithEventHandler(handler),
)
if err != nil {
t.Fatalf("New() error = %v", err)
}
if err := copyer.WaitErr(); err != nil {
t.Fatalf("WaitErr() error = %v, want nil", err)
}
report := getter()
if len(report.Jobs) != 1 {
t.Fatalf("report rows = %d, want 1", len(report.Jobs))
}
row := report.Jobs[0]
// The enumeration starts at the source itself, so the relative path keeps the source name,
// which is the row af05f05c published for /root/source/nested/data.txt.
base, name := filepath.Split(source)
wantPath := []string{name, "nested", "data.txt"}
if row.Base != base || !slices.Equal(row.Path, wantPath) {
t.Fatalf("row identity = base %q path %#v, want %q and %#v", row.Base, row.Path, base, wantPath)
}
if row.FullPath != nested {
t.Fatalf("row full path = %q, want %q", row.FullPath, nested)
}
encoded, err := json.Marshal(row)
if err != nil {
t.Fatalf("marshal row: %v", err)
}
if want := `"path":["` + name + `","nested","data.txt"]`; !strings.Contains(string(encoded), want) {
t.Fatalf("row JSON = %s, want %s", encoded, want)
}
}
// TestReportGetterKeysRowsByTheOldRelativePath pins the af05f05c lookup: rows that share a
// relative path are one entry, because that is the key the old report getter used.
func TestReportGetterKeysRowsByTheOldRelativePath(t *testing.T) {
handler, getter := acp.NewReportGetter()
handler(&acp.EventUpdateJob{Job: &acp.Job{Base: "/first/", Path: []string{"tree", "a.txt"}}})
handler(&acp.EventUpdateJob{Job: &acp.Job{Base: "/second/", Path: []string{"tree", "a.txt"}}})
report := getter()
if len(report.Jobs) != 1 {
t.Fatalf("report rows = %d, want one row per relative path", len(report.Jobs))
}
if report.Jobs[0].Base != "/second/" {
t.Fatalf("report row base = %q, want the last row of the key", report.Jobs[0].Base)
}
}