aboutsummaryrefslogtreecommitdiff
path: root/internal/cli/cmd_push_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/cli/cmd_push_test.go')
-rw-r--r--internal/cli/cmd_push_test.go633
1 files changed, 633 insertions, 0 deletions
diff --git a/internal/cli/cmd_push_test.go b/internal/cli/cmd_push_test.go
new file mode 100644
index 0000000..9a199b2
--- /dev/null
+++ b/internal/cli/cmd_push_test.go
@@ -0,0 +1,633 @@
+package cli
+
+import (
+ "archive/tar"
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+ "testing"
+
+ "tsne.dev/hopper/internal/bunny"
+ "tsne.dev/hopper/internal/log"
+)
+
+// runPush drives the push flow against a fake storage API for the given archive,
+// using the default concurrency.
+func runPush(source string, api storageAPI) error {
+ return runPushN(source, api, 8)
+}
+
+func runPushN(source string, api storageAPI, concurrency uint) error {
+ cmd := &pushCommand{concurrency: concurrency}
+ return cmd.push(context.Background(), api, source)
+}
+
+func TestPushAppliesPrefix(t *testing.T) {
+ arc := makeArchive(t, []tarEntry{
+ {name: "index.html", body: "<html>"},
+ {name: "assets/app.abc.js", body: "console.log(1)"},
+ {name: "assets/skip.js", body: "same"},
+ })
+ fc := newFakeAPI()
+ // The remote walk starts under the prefix and returns already-prefixed keys.
+ fc.lists["foo"] = []bunny.Object{{Path: "foo/assets", IsDirectory: true}}
+ fc.lists["foo/assets"] = []bunny.Object{
+ {Path: "foo/assets/skip.js", Size: int(len("same"))},
+ }
+ out, _ := captureLogs(t)
+
+ cmd := &pushCommand{concurrency: 8, prefix: "foo"}
+ if err := cmd.push(context.Background(), fc, arc); err != nil {
+ t.Fatalf("push: %v", err)
+ }
+
+ got := fc.paths()
+ want := []string{"foo/assets/app.abc.js", "foo/index.html"}
+ if strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Fatalf("paths = %v, want %v", got, want)
+ }
+ if !strings.Contains(out.String(), "skipping foo/assets/skip.js (exists)") {
+ t.Fatalf("skip-check must compare prefixed key: %q", out.String())
+ }
+}
+
+func TestPushSelectsRootAndStrips(t *testing.T) {
+ arc := makeArchive(t, []tarEntry{
+ {name: "README.md", body: "ignored"},
+ {name: "app/index.html", body: "<html>"},
+ {name: "app/assets/x.js", body: "x"},
+ {name: "other/y.js", body: "ignored"},
+ })
+ fc := newFakeAPI()
+ captureLogs(t)
+
+ cmd := &pushCommand{concurrency: 8, root: "app"}
+ if err := cmd.push(context.Background(), fc, arc); err != nil {
+ t.Fatalf("push: %v", err)
+ }
+
+ got := fc.paths()
+ want := []string{"assets/x.js", "index.html"}
+ if strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Fatalf("paths = %v, want %v", got, want)
+ }
+}
+
+func TestPushSelectsNestedRoot(t *testing.T) {
+ arc := makeArchive(t, []tarEntry{
+ {name: "dist/public/index.html", body: "<html>"},
+ {name: "dist/public/assets/x.js", body: "x"},
+ {name: "dist/private/secret", body: "ignored"},
+ })
+ fc := newFakeAPI()
+ captureLogs(t)
+
+ cmd := &pushCommand{concurrency: 8, root: "dist/public"}
+ if err := cmd.push(context.Background(), fc, arc); err != nil {
+ t.Fatalf("push: %v", err)
+ }
+
+ got := fc.paths()
+ want := []string{"assets/x.js", "index.html"}
+ if strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Fatalf("paths = %v, want %v", got, want)
+ }
+}
+
+func TestPushRootWithPrefix(t *testing.T) {
+ arc := makeArchive(t, []tarEntry{
+ {name: "app/assets/x.js", body: "x"},
+ })
+ fc := newFakeAPI()
+ captureLogs(t)
+
+ cmd := &pushCommand{concurrency: 8, root: "app", prefix: "foo"}
+ if err := cmd.push(context.Background(), fc, arc); err != nil {
+ t.Fatalf("push: %v", err)
+ }
+
+ got := fc.paths()
+ want := []string{"foo/assets/x.js"}
+ if strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Fatalf("paths = %v, want %v", got, want)
+ }
+}
+
+func TestPushRootMatchesNothing(t *testing.T) {
+ arc := makeArchive(t, []tarEntry{
+ {name: "app/index.html", body: "<html>"},
+ })
+ fc := newFakeAPI()
+ out, _ := captureLogs(t)
+
+ cmd := &pushCommand{concurrency: 8, root: "nope"}
+ if err := cmd.push(context.Background(), fc, arc); err != nil {
+ t.Fatalf("push: %v", err)
+ }
+
+ if got := fc.paths(); len(got) != 0 {
+ t.Fatalf("paths = %v, want none", got)
+ }
+ if !strings.Contains(out.String(), "uploaded: 0") {
+ t.Fatalf("summary missing: %q", out.String())
+ }
+}
+
+func TestPushUploadsRegularFiles(t *testing.T) {
+ arc := makeArchive(t, []tarEntry{
+ {name: "index.html", body: "<html>"},
+ {name: "assets/app.abc.js", body: "console.log(1)"},
+ {name: "dir/", typeflag: tar.TypeDir},
+ })
+ fc := newFakeAPI()
+ out, _ := captureLogs(t)
+
+ if err := runPush(arc, fc); err != nil {
+ t.Fatalf("push: %v", err)
+ }
+
+ got := fc.paths()
+ want := []string{"assets/app.abc.js", "index.html"}
+ if strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Fatalf("paths = %v, want %v", got, want)
+ }
+ if string(fc.puts["index.html"]) != "<html>" {
+ t.Fatalf("bad body for index.html: %q", fc.puts["index.html"])
+ }
+ if string(fc.puts["assets/app.abc.js"]) != "console.log(1)" {
+ t.Fatalf("bad body for app.abc.js: %q", fc.puts["assets/app.abc.js"])
+ }
+ if !strings.Contains(out.String(), "uploaded: 2") {
+ t.Fatalf("summary missing: %q", out.String())
+ }
+}
+
+func TestPushUploadsHTMLAfterAllAssets(t *testing.T) {
+ arc := makeArchive(t, []tarEntry{
+ {name: "index.html", body: "<html>"},
+ {name: "a.js", body: "x"},
+ {name: "nested/page.HTML", body: "<p>"}, // case-insensitive .html
+ {name: "b.css", body: "y"},
+ })
+ fc := newFakeAPI()
+ captureLogs(t)
+
+ if err := runPush(arc, fc); err != nil {
+ t.Fatalf("push: %v", err)
+ }
+
+ // Every HTML Put must happen after the last asset Put.
+ lastAsset := -1
+ firstHTML := len(fc.order)
+ for i, p := range fc.order {
+ if strings.HasSuffix(strings.ToLower(p), ".html") {
+ if i < firstHTML {
+ firstHTML = i
+ }
+ } else if i > lastAsset {
+ lastAsset = i
+ }
+ }
+ if firstHTML <= lastAsset {
+ t.Fatalf("HTML uploaded before an asset; order = %v", fc.order)
+ }
+}
+
+func TestPushDoesNotUploadHTMLWhenAssetFails(t *testing.T) {
+ arc := makeArchive(t, []tarEntry{
+ {name: "index.html", body: "<html>"},
+ {name: "a.js", body: "x"},
+ })
+ fc := newFakeAPI()
+ fc.failOn = "a.js"
+ captureLogs(t)
+
+ if err := runPush(arc, fc); err == nil {
+ t.Fatal("expected error")
+ }
+ for _, p := range fc.order {
+ if strings.HasSuffix(strings.ToLower(p), ".html") {
+ t.Fatalf("HTML must not be uploaded when an asset fails; order = %v", fc.order)
+ }
+ }
+}
+
+func TestPushSkipsSymlinkWithWarning(t *testing.T) {
+ arc := makeArchive(t, []tarEntry{
+ {name: "a.js", body: "x"},
+ {name: "link", typeflag: tar.TypeSymlink, linkname: "a.js"},
+ })
+ fc := newFakeAPI()
+ _, errb := captureLogs(t)
+
+ if err := runPush(arc, fc); err != nil {
+ t.Fatalf("push: %v", err)
+ }
+ if got := fc.paths(); len(got) != 1 || got[0] != "a.js" {
+ t.Fatalf("paths = %v", got)
+ }
+ if !strings.Contains(errb.String(), "Warning") {
+ t.Fatalf("expected warning on stderr, got %q", errb.String())
+ }
+}
+
+func TestPushFailsOnUploadError(t *testing.T) {
+ arc := makeArchive(t, []tarEntry{
+ {name: "a.js", body: "x"},
+ {name: "b.js", body: "y"},
+ })
+ fc := newFakeAPI()
+ fc.failOn = "a.js"
+ captureLogs(t)
+
+ if err := runPush(arc, fc); err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func TestPushSkipsUnchanged(t *testing.T) {
+ arc := makeArchive(t, []tarEntry{
+ {name: "index.html", body: "<html>"},
+ {name: "assets/app.abc.js", body: "console.log(1)"},
+ })
+ fc := newFakeAPI()
+ fc.lists[""] = []bunny.Object{
+ {Path: "index.html", Size: int(len("<html>"))},
+ {Path: "assets", IsDirectory: true},
+ }
+ fc.lists["assets"] = []bunny.Object{
+ {Path: "assets/app.abc.js", Size: int(len("console.log(1)"))},
+ }
+ out, _ := captureLogs(t)
+
+ if err := runPush(arc, fc); err != nil {
+ t.Fatalf("push: %v", err)
+ }
+ // The asset is skipped (matching size); the *.html is the commit point and is
+ // always uploaded even when an identically-sized copy exists remotely.
+ if got := fc.paths(); len(got) != 1 || got[0] != "index.html" {
+ t.Fatalf("expected only index.html uploaded, got %v", got)
+ }
+ s := out.String()
+ if !strings.Contains(s, "skipping assets/app.abc.js (exists)") {
+ t.Fatalf("missing skip line: %q", s)
+ }
+ if strings.Contains(s, "skipping index.html") {
+ t.Fatalf("index.html must never be skipped: %q", s)
+ }
+ if !strings.Contains(s, "uploaded: 1") || !strings.Contains(s, "skipped: 1") {
+ t.Fatalf("summary missing: %q", s)
+ }
+}
+
+func TestPushReuploadsOnSizeMismatch(t *testing.T) {
+ arc := makeArchive(t, []tarEntry{
+ {name: "a.js", body: "hello"}, // 5 bytes
+ {name: "b.js", body: "world"}, // present, matching size -> skip
+ })
+ fc := newFakeAPI()
+ fc.lists[""] = []bunny.Object{
+ {Path: "a.js", Size: 1}, // size mismatch -> upload
+ {Path: "b.js", Size: int(len("world"))},
+ }
+ captureLogs(t)
+
+ if err := runPush(arc, fc); err != nil {
+ t.Fatalf("push: %v", err)
+ }
+ if got := fc.paths(); len(got) != 1 || got[0] != "a.js" {
+ t.Fatalf("paths = %v, want [a.js]", got)
+ }
+}
+
+func TestPushErrorsOnDirectoryFileCollision(t *testing.T) {
+ arc := makeArchive(t, []tarEntry{
+ {name: "data", body: ""}, // 0-byte asset colliding with a remote dir
+ })
+ fc := newFakeAPI()
+ // Remote has a directory named "data"; a file at the same path is a hard
+ // conflict (and a 0-size dir must never silently shadow a 0-byte file).
+ fc.lists[""] = []bunny.Object{{Path: "data", IsDirectory: true}}
+ captureLogs(t)
+
+ if err := runPush(arc, fc); err == nil {
+ t.Fatal("expected error on directory/file collision")
+ }
+ if got := fc.paths(); len(got) != 0 {
+ t.Fatalf("expected no uploads, got %v", got)
+ }
+}
+
+func TestPushAbortsOnListFailure(t *testing.T) {
+ arc := makeArchive(t, []tarEntry{{name: "a.js", body: "x"}})
+ fc := newFakeAPI()
+ fc.listErr = errors.New("boom")
+ captureLogs(t)
+
+ if err := runPush(arc, fc); err == nil {
+ t.Fatal("expected error")
+ }
+ if got := fc.paths(); len(got) != 0 {
+ t.Fatalf("expected no uploads before abort, got %v", got)
+ }
+}
+
+func TestPushRunRejectsBadSourceArgs(t *testing.T) {
+ captureLogs(t)
+ cmd := newPushCommand(&mainCommand{})
+
+ var ue usageError
+ if err := cmd.Run(context.Background(), nil); !errors.As(err, &ue) {
+ t.Fatalf("missing source: expected usageError, got %v", err)
+ }
+ if err := cmd.Run(context.Background(), []string{"a.tgz", "b.tgz"}); !errors.As(err, &ue) {
+ t.Fatalf("multiple sources: expected usageError, got %v", err)
+ }
+}
+
+func TestPushRejectsNegativeConcurrencyAtParse(t *testing.T) {
+ captureLogs(t)
+ // A negative value is not a valid uint, so pflag rejects it at parse time.
+ cmd := newPushCommand(&mainCommand{})
+ if err := cmd.flags.Parse([]string{"--concurrency", "-1", "a.tgz"}); err == nil {
+ t.Fatal("--concurrency -1: expected a parse error, got nil")
+ }
+}
+
+func TestPushRejectsEscapingPrefix(t *testing.T) {
+ captureLogs(t)
+ cmd := newPushCommand(&mainCommand{})
+ var ue usageError
+ if err := cmd.flags.Parse([]string{"--prefix", "../x", "a.tgz"}); !errors.As(err, &ue) {
+ t.Fatalf("--prefix ../x: expected usageError, got %v", err)
+ }
+}
+
+func TestPushBoundsInFlightUploads(t *testing.T) {
+ arc := makeArchive(t, []tarEntry{
+ {name: "a.js", body: "1"},
+ {name: "b.js", body: "2"},
+ {name: "c.js", body: "3"},
+ {name: "d.js", body: "4"},
+ {name: "e.js", body: "5"},
+ })
+ const limit = 2
+ gate := &gatingAPI{fakeAPI: newFakeAPI(), release: make(chan struct{})}
+ captureLogs(t)
+
+ done := make(chan error, 1)
+ go func() { done <- runPushN(arc, gate, limit) }()
+
+ // The reader blocks on the semaphore once the pool is saturated, so no more
+ // uploads can begin until one drains; release them so the run finishes.
+ for i := 0; i < 5; i++ {
+ gate.release <- struct{}{}
+ }
+ if err := <-done; err != nil {
+ t.Fatalf("push: %v", err)
+ }
+ if peak := gate.peak(); peak > limit {
+ t.Fatalf("peak in-flight = %d, want <= %d", peak, limit)
+ }
+}
+
+func TestPushAssetFailureReportsOneFileAndSkipsHTML(t *testing.T) {
+ arc := makeArchive(t, []tarEntry{
+ {name: "index.html", body: "<html>"},
+ {name: "a.js", body: "1"},
+ {name: "b.js", body: "2"},
+ })
+ fc := newFakeAPI()
+ // Every asset fails: fail-fast cancels the pool after the first genuine
+ // failure. All genuine failures that landed before cancellation are
+ // aggregated (§8.3); cancellation collateral (context.Canceled) is filtered
+ // out (see TestPushFailFastFiltersCancellationCollateral). HTML is never
+ // written.
+ fc.failAll = true
+ captureLogs(t)
+
+ err := runPushN(arc, fc, 8)
+ var ee exitError
+ if !errors.As(err, &ee) {
+ t.Fatalf("expected exitError, got %T", err)
+ }
+ for _, p := range fc.order {
+ if strings.HasSuffix(strings.ToLower(p), ".html") {
+ t.Fatalf("HTML must not be written when an asset fails; order = %v", fc.order)
+ }
+ }
+}
+
+// TestPushFailFastFiltersCancellationCollateral verifies that the fail-fast
+// report names ONLY the genuine upload failure, not the in-flight uploads that
+// were abandoned by the cancellation. A context.Canceled is a consequence of the
+// stop decision, not an independent file failure, and is swallowed uniformly so
+// push (fail-fast) and a user SIGINT behave identically (§8.3). The collateral
+// errors WRAP context.Canceled.
+func TestPushFailFastFiltersCancellationCollateral(t *testing.T) {
+ arc := makeArchive(t, []tarEntry{
+ {name: "a.js", body: "1"},
+ {name: "b.js", body: "2"},
+ {name: "c.js", body: "3"},
+ {name: "index.html", body: "<html>"},
+ })
+ api := &ctxCancelAPI{
+ fakeAPI: newFakeAPI(),
+ triggerPath: "a.js",
+ triggerErr: fileError{path: "a.js", err: errors.New("boom")},
+ }
+ _, errb := captureLogs(t)
+
+ // Concurrency covers all three assets so b.js/c.js are genuinely in-flight
+ // (blocked) when a.js triggers the cancellation.
+ err := runPushN(arc, api, 3)
+ var ee exitError
+ if !errors.As(err, &ee) {
+ t.Fatalf("expected exitError, got %T (%v)", err, err)
+ }
+ report := errb.String()
+ if !strings.Contains(report, "a.js") || !strings.Contains(report, "boom") {
+ t.Fatalf("report must name the genuine failure a.js: %q", report)
+ }
+ if strings.Contains(report, "b.js") || strings.Contains(report, "c.js") {
+ t.Fatalf("report must NOT name cancellation collateral b.js/c.js: %q", report)
+ }
+ for _, p := range api.order {
+ if strings.HasSuffix(strings.ToLower(p), ".html") {
+ t.Fatalf("HTML must not be written when an asset fails; order = %v", api.order)
+ }
+ }
+}
+
+func TestPushHTMLFailuresAreAllReported(t *testing.T) {
+ arc := makeArchive(t, []tarEntry{
+ {name: "index.html", body: "<html>"},
+ {name: "about.html", body: "<html>"},
+ {name: "a.js", body: "1"},
+ })
+ fc := newFakeAPI()
+ // Assets succeed; both HTML commit uploads fail. The drain phase reports
+ // every failure under the friendly headline.
+ fc.failErr = errors.New("boom")
+ fc.failWhen = func(path string) bool { return strings.HasSuffix(path, ".html") }
+ out, errb := captureLogs(t)
+
+ err := runPushN(arc, fc, 8)
+ var exit exitError
+ if !errors.As(err, &exit) {
+ t.Fatalf("expected exitError, got %v", err)
+ }
+ // The drain report lists every HTML failure on stderr...
+ if !strings.Contains(errb.String(), "index.html") || !strings.Contains(errb.String(), "about.html") {
+ t.Fatalf("drain report must list every HTML failure: %q", errb.String())
+ }
+ // ...and the summary is still printed (last) on stdout.
+ if !strings.Contains(out.String(), "failed:") {
+ t.Fatalf("summary must be printed after a commit-phase failure: %q", out.String())
+ }
+}
+
+func TestShouldHintPrune(t *testing.T) {
+ cases := []struct {
+ name string
+ sourceFiles int
+ remoteFiles int
+ want bool
+ }{
+ {"below floor", 1, 999, false},
+ {"at floor within ratio", 1000, 6000, false},
+ {"at exact multiple", 100, 600, false},
+ {"one over multiple but below floor", 10, 61, false},
+ {"one over multiple and at floor", 200, 1201, true},
+ {"empty source", 0, 100000, false},
+ }
+ for _, tc := range cases {
+ if got := shouldHintPrune(tc.sourceFiles, tc.remoteFiles); got != tc.want {
+ t.Errorf("%s: shouldHintPrune(%d, %d) = %v, want %v",
+ tc.name, tc.sourceFiles, tc.remoteFiles, got, tc.want)
+ }
+ }
+}
+
+// stalePushSetup builds a one-file source (source_files == 1) and a fake API
+// whose remote listing holds remoteFiles file objects and remoteDirs directory
+// objects, so tests can drive the hint decision at chosen boundaries.
+func stalePushSetup(t *testing.T, remoteFiles, remoteDirs int) (string, *fakeAPI) {
+ t.Helper()
+ arc := makeArchive(t, []tarEntry{
+ {name: "index.html", body: "<html>"},
+ })
+ fc := newFakeAPI()
+ objs := make([]bunny.Object, 0, remoteFiles+remoteDirs)
+ for i := 0; i < remoteFiles; i++ {
+ objs = append(objs, bunny.Object{Path: fmt.Sprintf("stale/%d.js", i), Size: 1})
+ }
+ for i := 0; i < remoteDirs; i++ {
+ objs = append(objs, bunny.Object{Path: fmt.Sprintf("dir%d", i), IsDirectory: true})
+ }
+ fc.lists[""] = objs
+ return arc, fc
+}
+
+func TestPushHintFiresWhenStale(t *testing.T) {
+ arc, fc := stalePushSetup(t, 1001, 0)
+ out, _ := captureLogs(t)
+
+ if err := runPush(arc, fc); err != nil {
+ t.Fatalf("push: %v", err)
+ }
+ s := out.String()
+ if !strings.Contains(s, "remote: 1001") {
+ t.Fatalf("summary missing remote count: %q", s)
+ }
+ if !strings.Contains(s, "\n\nHint:") {
+ t.Fatalf("expected prune hint: %q", s)
+ }
+ // The hint rides on stdout with the summary, not the stderr warning path.
+ if strings.Contains(s, "\n\nHint:") && strings.Index(s, "remote:") > strings.Index(s, "\n\nHint:") {
+ t.Fatalf("hint must follow the summary: %q", s)
+ }
+}
+
+func TestPushNoHintWithinRetentionWindow(t *testing.T) {
+ arc, fc := stalePushSetup(t, 6, 0)
+ out, _ := captureLogs(t)
+
+ if err := runPush(arc, fc); err != nil {
+ t.Fatalf("push: %v", err)
+ }
+ if strings.Contains(out.String(), "hint:") {
+ t.Fatalf("unexpected hint: %q", out.String())
+ }
+}
+
+func TestPushNoHintBelowFloor(t *testing.T) {
+ arc, fc := stalePushSetup(t, 999, 0)
+ out, _ := captureLogs(t)
+
+ if err := runPush(arc, fc); err != nil {
+ t.Fatalf("push: %v", err)
+ }
+ if strings.Contains(out.String(), "hint:") {
+ t.Fatalf("unexpected hint below floor: %q", out.String())
+ }
+}
+
+func TestPushHintIgnoresRemoteDirectories(t *testing.T) {
+ // Many directory nodes, few files: directories must not inflate remote_files.
+ arc, fc := stalePushSetup(t, 5, 2000)
+ out, _ := captureLogs(t)
+
+ if err := runPush(arc, fc); err != nil {
+ t.Fatalf("push: %v", err)
+ }
+ s := out.String()
+ if !strings.Contains(s, "remote: 5") {
+ t.Fatalf("directories must not count: %q", s)
+ }
+ if strings.Contains(s, "hint:") {
+ t.Fatalf("unexpected hint from directory nodes: %q", s)
+ }
+}
+
+func TestPushHintFiresUnderQuiet(t *testing.T) {
+ arc, fc := stalePushSetup(t, 1001, 0)
+ out, _ := captureLogs(t)
+ log.SetQuiet(true)
+
+ if err := runPush(arc, fc); err != nil {
+ t.Fatalf("push: %v", err)
+ }
+ if !strings.Contains(out.String(), "\n\nHint:") {
+ t.Fatalf("hint must appear under --quiet: %q", out.String())
+ }
+}
+
+func TestPushHintFiresUnderDryRun(t *testing.T) {
+ arc, fc := stalePushSetup(t, 1001, 0)
+ out, _ := captureLogs(t)
+ dryRun = true
+
+ if err := runPush(arc, dryRunStorageAPI{fc}); err != nil {
+ t.Fatalf("push: %v", err)
+ }
+ if !strings.Contains(out.String(), "\n\nHint:") {
+ t.Fatalf("hint must appear under dry-run: %q", out.String())
+ }
+}
+
+func TestHumanSize(t *testing.T) {
+ cases := map[int]string{
+ 0: "0 B",
+ 512: "512 B",
+ 1024: "1.0 KiB",
+ 1536: "1.5 KiB",
+ 1024 * 1024: "1.0 MiB",
+ }
+ for in, want := range cases {
+ if got := humanSize(in); got != want {
+ t.Errorf("humanSize(%d) = %q, want %q", in, got, want)
+ }
+ }
+}