401 lines
15 KiB
Go
401 lines
15 KiB
Go
package helpers
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
)
|
||
|
||
// ──────────────────────────────────────────────────────────
|
||
// validateLocalDirAbs — 本地路径校验(必须为绝对路径)
|
||
// ──────────────────────────────────────────────────────────
|
||
|
||
func TestCrossPlatformCoverageValidateLocalDirAbs_empty(t *testing.T) {
|
||
if _, err := validateLocalDirAbs(""); err == nil {
|
||
t.Fatal("expected error for empty --local-folder")
|
||
}
|
||
}
|
||
|
||
func TestCrossPlatformCoverageValidateLocalDirAbs_relative(t *testing.T) {
|
||
if _, err := validateLocalDirAbs(filepath.Join(".", "repo")); err == nil {
|
||
t.Fatal("expected error for relative path")
|
||
}
|
||
}
|
||
|
||
func TestCrossPlatformCoverageValidateLocalDirAbs_ok(t *testing.T) {
|
||
abs := filepath.Join(t.TempDir(), "repo")
|
||
got, err := validateLocalDirAbs(abs)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
if got != filepath.Clean(abs) {
|
||
t.Errorf("got %q, want %q", got, filepath.Clean(abs))
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────
|
||
// walkLocalTree — 本地遍历
|
||
// ──────────────────────────────────────────────────────────
|
||
|
||
func TestCrossPlatformCoverageWalkLocalTree_collectsRegularFiles(t *testing.T) {
|
||
root := t.TempDir()
|
||
mustWrite(t, filepath.Join(root, "a.txt"), "hello")
|
||
mustWrite(t, filepath.Join(root, "sub", "b.txt"), "world")
|
||
|
||
files, err := walkLocalTree(root)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
if len(files) != 2 {
|
||
t.Fatalf("expected 2 files, got %d: %v", len(files), files)
|
||
}
|
||
// rel_path 用 / 分隔(跨平台一致)
|
||
if _, ok := files["sub/b.txt"]; !ok {
|
||
t.Errorf("expected key sub/b.txt, got keys %v", keys(files))
|
||
}
|
||
// walk 阶段不计算 hash(惰性策略),但记录 AbsPath 与 mtime
|
||
if files["a.txt"].Hash != "" {
|
||
t.Error("walkLocalTree should not compute MD5 eagerly")
|
||
}
|
||
if files["a.txt"].AbsPath == "" {
|
||
t.Error("walkLocalTree should record AbsPath")
|
||
}
|
||
if files["a.txt"].ModTimeMillis == 0 {
|
||
t.Error("walkLocalTree should record mtime")
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────
|
||
// compareTrees — 四类差异分类
|
||
// ──────────────────────────────────────────────────────────
|
||
|
||
func TestCrossPlatformCoverageCompareTrees_exact(t *testing.T) {
|
||
local := map[string]*localFile{
|
||
"same.txt": {RelPath: "same.txt", Hash: "h1"},
|
||
"diff.txt": {RelPath: "diff.txt", Hash: "local"},
|
||
"onlyme.txt": {RelPath: "onlyme.txt", Hash: "h3"},
|
||
}
|
||
remote := map[string]*remoteFile{
|
||
"same.txt": {RelPath: "same.txt", Hash: "h1"},
|
||
"diff.txt": {RelPath: "diff.txt", Hash: "remote"},
|
||
"onlythem.txt": {RelPath: "onlythem.txt", Hash: "h4"},
|
||
}
|
||
res, err := compareTrees(local, remote, "exact", false)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
|
||
assertPaths(t, "unchanged", res.Unchanged, "same.txt")
|
||
assertPaths(t, "modified", res.Modified, "diff.txt")
|
||
assertPaths(t, "new_local", res.NewLocal, "onlyme.txt")
|
||
assertPaths(t, "new_remote", res.NewRemote, "onlythem.txt")
|
||
if res.Detection != "exact" {
|
||
t.Errorf("detection = %q, want exact", res.Detection)
|
||
}
|
||
}
|
||
|
||
func TestCrossPlatformCoverageCompareTrees_exact_unknownRemoteHashIsUnknown(t *testing.T) {
|
||
local := map[string]*localFile{"a.txt": {RelPath: "a.txt", Hash: "h1"}}
|
||
remote := map[string]*remoteFile{"a.txt": {RelPath: "a.txt", Hash: ""}}
|
||
res, err := compareTrees(local, remote, "exact", false)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
// 远端无 md5 → 无法核对内容,归入 unknown(既非 unchanged 也非 modified)。
|
||
assertPaths(t, "unknown", res.Unknown, "a.txt")
|
||
if len(res.Unchanged) != 0 {
|
||
t.Error("unknown remote hash must not be judged unchanged")
|
||
}
|
||
if len(res.Modified) != 0 {
|
||
t.Error("unknown remote hash must not be judged modified")
|
||
}
|
||
}
|
||
|
||
// TestCompareTrees_exact_hashesOnlyIntersection 验证本地 hash 只在双端都存在时才
|
||
// 计算:local-only 文件即使 AbsPath 指向不存在的文件也不会触发 hash(不报错)。
|
||
func TestCrossPlatformCoverageCompareTrees_exact_hashesOnlyIntersection(t *testing.T) {
|
||
root := t.TempDir()
|
||
bothPath := filepath.Join(root, "both.txt")
|
||
mustWrite(t, bothPath, "same-content")
|
||
h, err := md5File(bothPath)
|
||
if err != nil {
|
||
t.Fatalf("md5File: %v", err)
|
||
}
|
||
|
||
local := map[string]*localFile{
|
||
"both.txt": {RelPath: "both.txt", AbsPath: bothPath},
|
||
"local.txt": {RelPath: "local.txt", AbsPath: filepath.Join(root, "does-not-exist.txt")},
|
||
}
|
||
remote := map[string]*remoteFile{
|
||
"both.txt": {RelPath: "both.txt", Hash: h},
|
||
}
|
||
res, err := compareTrees(local, remote, "exact", false)
|
||
if err != nil {
|
||
t.Fatalf("local-only file must not be hashed, got error: %v", err)
|
||
}
|
||
assertPaths(t, "unchanged", res.Unchanged, "both.txt")
|
||
assertPaths(t, "new_local", res.NewLocal, "local.txt")
|
||
// 双端都存在的 both.txt 被惰性 hash 后应回填 Hash
|
||
if local["both.txt"].Hash == "" {
|
||
t.Error("intersection file should have been hashed lazily")
|
||
}
|
||
if local["local.txt"].Hash != "" {
|
||
t.Error("local-only file should never be hashed")
|
||
}
|
||
}
|
||
|
||
// TestCompareTrees_exact_noRemoteMD5AllUnknown 验证 exact 模式在远端无 md5 时
|
||
// 一律归入 unknown——不再用 fileSize + modified_time 近似判 unchanged(否则大小与
|
||
// mtime 恰好相同但内容不同的文件会被误报为未变更),无论大小/时间是否相同。
|
||
func TestCrossPlatformCoverageCompareTrees_exact_noRemoteMD5AllUnknown(t *testing.T) {
|
||
local := map[string]*localFile{
|
||
"sameSizeTime.txt": {RelPath: "sameSizeTime.txt", Size: 100, ModTimeMillis: 1000},
|
||
"diffsize.txt": {RelPath: "diffsize.txt", Size: 100, ModTimeMillis: 1000},
|
||
"difftime.txt": {RelPath: "difftime.txt", Size: 100, ModTimeMillis: 1000},
|
||
}
|
||
remote := map[string]*remoteFile{
|
||
// 远端均无 md5(Hash 空)→ 全部 unknown,绝不判 unchanged。
|
||
"sameSizeTime.txt": {RelPath: "sameSizeTime.txt", Size: 100, ModifiedTime: 1000, ModifiedTimeValid: true},
|
||
"diffsize.txt": {RelPath: "diffsize.txt", Size: 200, ModifiedTime: 1000, ModifiedTimeValid: true},
|
||
"difftime.txt": {RelPath: "difftime.txt", Size: 100, ModifiedTime: 2000, ModifiedTimeValid: true},
|
||
}
|
||
res, err := compareTrees(local, remote, "exact", false)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
assertPaths(t, "unknown", res.Unknown, "diffsize.txt", "difftime.txt", "sameSizeTime.txt")
|
||
if len(res.Unchanged) != 0 {
|
||
t.Errorf("远端无 md5 时不应有 unchanged, got %v", entryPaths(res.Unchanged))
|
||
}
|
||
if len(res.Modified) != 0 {
|
||
t.Errorf("远端无 md5 时不应有 modified, got %v", entryPaths(res.Modified))
|
||
}
|
||
}
|
||
|
||
// TestCompareTrees_exact_base64RemoteMD5 验证 exact 用 base64 md5 比对:本地按
|
||
// base64 算 md5,与钉盘返回的 base64 md5 直接相等 → unchanged。
|
||
func TestCrossPlatformCoverageCompareTrees_exact_base64RemoteMD5(t *testing.T) {
|
||
root := t.TempDir()
|
||
p := filepath.Join(root, "a.txt")
|
||
mustWrite(t, p, "hello root\n") // 11 字节,md5 base64 = 6y9K7B0b2K0AInNZZV/70A==
|
||
remoteB64 := "6y9K7B0b2K0AInNZZV/70A=="
|
||
local := map[string]*localFile{"a.txt": {RelPath: "a.txt", AbsPath: p}}
|
||
remote := map[string]*remoteFile{"a.txt": {RelPath: "a.txt", Hash: remoteB64}}
|
||
res, err := compareTrees(local, remote, "exact", false)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
assertPaths(t, "unchanged", res.Unchanged, "a.txt")
|
||
if len(res.Modified) != 0 {
|
||
t.Errorf("同一 md5(base64) 不应判 modified, got modified=%d", len(res.Modified))
|
||
}
|
||
// 本地按需 hash 后应回填为 base64
|
||
if local["a.txt"].Hash != remoteB64 {
|
||
t.Errorf("本地 md5 应回填为 base64 %q, got %q", remoteB64, local["a.txt"].Hash)
|
||
}
|
||
}
|
||
|
||
func TestCrossPlatformCoverageCompareTrees_quick(t *testing.T) {
|
||
local := map[string]*localFile{
|
||
"eq.txt": {RelPath: "eq.txt", ModTimeMillis: 1000},
|
||
"ne.txt": {RelPath: "ne.txt", ModTimeMillis: 1000},
|
||
"bad.txt": {RelPath: "bad.txt", ModTimeMillis: 1000},
|
||
}
|
||
remote := map[string]*remoteFile{
|
||
"eq.txt": {RelPath: "eq.txt", ModifiedTime: 1000, ModifiedTimeValid: true},
|
||
"ne.txt": {RelPath: "ne.txt", ModifiedTime: 2000, ModifiedTimeValid: true},
|
||
"bad.txt": {RelPath: "bad.txt", ModifiedTime: 1000, ModifiedTimeValid: false},
|
||
}
|
||
res, err := compareTrees(local, remote, "quick", true)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
|
||
assertPaths(t, "unchanged", res.Unchanged, "eq.txt")
|
||
// ne.txt 时间不等、bad.txt 远端时间不可信 → 都记为 modified
|
||
assertPaths(t, "modified", res.Modified, "bad.txt", "ne.txt")
|
||
}
|
||
|
||
func TestCrossPlatformCoverageCompareTrees_emptyRemote_allNewLocal(t *testing.T) {
|
||
local := map[string]*localFile{
|
||
"a.txt": {RelPath: "a.txt", Hash: "h1"},
|
||
"b.txt": {RelPath: "b.txt", Hash: "h2"},
|
||
}
|
||
res, err := compareTrees(local, map[string]*remoteFile{}, "exact", false)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
assertPaths(t, "new_local", res.NewLocal, "a.txt", "b.txt")
|
||
if len(res.NewRemote) != 0 || len(res.Modified) != 0 || len(res.Unchanged) != 0 {
|
||
t.Error("with empty remote, only new_local should be populated")
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────
|
||
// helpers
|
||
// ──────────────────────────────────────────────────────────
|
||
|
||
func mustWrite(t *testing.T, path, content string) {
|
||
t.Helper()
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
t.Fatalf("mkdir: %v", err)
|
||
}
|
||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||
t.Fatalf("write %s: %v", path, err)
|
||
}
|
||
}
|
||
|
||
func keys(m map[string]*localFile) []string {
|
||
out := make([]string, 0, len(m))
|
||
for k := range m {
|
||
out = append(out, k)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func assertPaths(t *testing.T, label string, entries []driveStatusEntry, want ...string) {
|
||
t.Helper()
|
||
if len(entries) != len(want) {
|
||
t.Fatalf("%s: got %d entries %v, want %v", label, len(entries), entryPaths(entries), want)
|
||
}
|
||
for i, w := range want {
|
||
if entries[i].RelPath != w {
|
||
t.Errorf("%s[%d] = %q, want %q", label, i, entries[i].RelPath, w)
|
||
}
|
||
}
|
||
}
|
||
|
||
func entryPaths(entries []driveStatusEntry) []string {
|
||
out := make([]string, len(entries))
|
||
for i, e := range entries {
|
||
out[i] = e.RelPath
|
||
}
|
||
return out
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────
|
||
// parseDriveList — list_files 返回解析
|
||
// ──────────────────────────────────────────────────────────
|
||
|
||
func TestCrossPlatformCoverageParseDriveList_resultItems(t *testing.T) {
|
||
text := `{"result":{"items":[{"name":"a.txt","type":"file"},{"name":"sub","type":"folder"}],"nextToken":"tok"}}`
|
||
items, token, err := parseDriveList(text)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
if len(items) != 2 {
|
||
t.Fatalf("expected 2 items, got %d", len(items))
|
||
}
|
||
if token != "tok" {
|
||
t.Errorf("token = %q, want tok", token)
|
||
}
|
||
}
|
||
|
||
func TestCrossPlatformCoverageParseDriveList_topLevelArray(t *testing.T) {
|
||
text := `{"result":[{"name":"a.txt","type":"file"}]}`
|
||
items, token, err := parseDriveList(text)
|
||
if err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
if len(items) != 1 || items[0].name() != "a.txt" {
|
||
t.Errorf("unexpected items: %v", items)
|
||
}
|
||
if token != "" {
|
||
t.Errorf("expected empty token, got %q", token)
|
||
}
|
||
}
|
||
|
||
func TestCrossPlatformCoverageParseDriveList_invalid(t *testing.T) {
|
||
if _, _, err := parseDriveList("not json"); err == nil {
|
||
t.Fatal("expected error for invalid JSON")
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────
|
||
// driveItem — 字段访问器(带 fallback)
|
||
// ──────────────────────────────────────────────────────────
|
||
|
||
func TestCrossPlatformCoverageDriveItem_accessors(t *testing.T) {
|
||
it := driveItem{
|
||
"name": "报告.pdf",
|
||
"type": "file",
|
||
"fileId": "d-123",
|
||
"md5": "abc",
|
||
"path": "/dir/报告.pdf",
|
||
"modifyTime": float64(1700000000000),
|
||
}
|
||
if it.name() != "报告.pdf" {
|
||
t.Errorf("name() = %q", it.name())
|
||
}
|
||
if it.id() != "d-123" {
|
||
t.Errorf("id() = %q", it.id())
|
||
}
|
||
if it.typ() != "file" {
|
||
t.Errorf("typ() = %q, want file", it.typ())
|
||
}
|
||
if it.hash() != "abc" {
|
||
t.Errorf("hash() = %q", it.hash())
|
||
}
|
||
if it.path() != "/dir/报告.pdf" {
|
||
t.Errorf("path() = %q", it.path())
|
||
}
|
||
if it.isFolder() {
|
||
t.Error("type=file should not be a folder")
|
||
}
|
||
if !it.isFile() {
|
||
t.Error("type=file should be a file")
|
||
}
|
||
ms, ok := it.modifiedMillis()
|
||
if !ok || ms != 1700000000000 {
|
||
t.Errorf("modifiedMillis() = %d,%v", ms, ok)
|
||
}
|
||
}
|
||
|
||
func TestCrossPlatformCoverageDriveItem_folderAndFile(t *testing.T) {
|
||
folder := driveItem{"name": "sub", "type": "folder"}
|
||
if !folder.isFolder() || folder.isFile() {
|
||
t.Error("type=folder should be folder, not file")
|
||
}
|
||
file := driveItem{"name": "a.txt", "type": "file"}
|
||
if !file.isFile() || file.isFolder() {
|
||
t.Error("type=file should be file, not folder")
|
||
}
|
||
// 非 file / 非 folder 的类型(在线文档、快捷方式等)都不纳入比对。
|
||
doc := driveItem{"name": "设计稿", "type": "docx"}
|
||
if doc.isFile() {
|
||
t.Error("docx should not be treated as file")
|
||
}
|
||
shortcut := driveItem{"name": "快捷方式", "type": "shortcut"}
|
||
if shortcut.isFile() {
|
||
t.Error("shortcut should not be treated as file")
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────────────────
|
||
// toMillis — 时间字段归一化
|
||
// ──────────────────────────────────────────────────────────
|
||
|
||
func TestCrossPlatformCoverageToMillis(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
in any
|
||
want int64
|
||
ok bool
|
||
}{
|
||
{"epoch-ms-number", float64(1700000000000), 1700000000000, true},
|
||
{"epoch-ms-string", "1700000000000", 1700000000000, true},
|
||
{"rfc3339", "2023-11-14T22:13:20Z", 1700000000000, true},
|
||
{"zero", float64(0), 0, false},
|
||
{"empty", "", 0, false},
|
||
{"garbage", "not-a-time", 0, false},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
got, ok := toMillis(c.in)
|
||
if ok != c.ok || (ok && got != c.want) {
|
||
t.Errorf("toMillis(%v) = %d,%v; want %d,%v", c.in, got, ok, c.want, c.ok)
|
||
}
|
||
})
|
||
}
|
||
}
|