aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-06 10:58:46 -0500
committerCraig Jennings <c@cjennings.net>2026-06-06 10:58:46 -0500
commit3d8778ba12cbbe2b8f6d5512d4b4a8f13a9c55ac (patch)
tree9b6c1d5475c3074be59c0407e26470de34a50a08 /tests
parentfdc5f550d35d97272e3e5cac2f46ca7f892dda09 (diff)
downloadduet-3d8778ba12cbbe2b8f6d5512d4b4a8f13a9c55ac.tar.gz
duet-3d8778ba12cbbe2b8f6d5512d4b4a8f13a9c55ac.zip
feat: add transfer-spec, the endpoint matrix, and the conflict/move planners
duet--transfer-spec classifies both endpoints, selects a backend through the registry, determines the route, and delegates argv construction to the backend (Phase 3 in the design spec). It returns the plist the executor will run: sources, destination, backend, route, argv, and async flag. The route is decided independently of backend by duet--transfer-route: local, local-remote, remote-same-host, remote-direct, or remote-roundtrip. Different remote hosts default to the round-trip through this machine. Direct host-to-host runs only when a per-connection override asks for it, never automatically, because a direct route can silently fail where a round-trip always works. This phase also registers the two stage-1 backends through the same duet-register-backend seam a plugin uses: rsync for local and ssh-reachable endpoints, TRAMP as the universal fallback that costs more so rsync wins whenever it applies. rsync receives its source and destination as separate argv elements, so a filename with spaces or shell metacharacters stays inert. The two planners are pure and prompt-free, so the dangerous decisions are testable before a byte moves. duet--plan-conflicts resolves overwrite/skip/rename per file with an apply-to-all that stops asking, taking the existence check and the resolver as injected functions. duet--plan-move pairs each source's copy with a delete gated on that source's copy success, so a failed copy can never delete its source. Remote-to-remote execution (honoring the round-trip route as a two-step through local) and TRAMP's in-process copy land in Phase 6. Here transfer-spec records the route and the rsync path the executor will use.
Diffstat (limited to 'tests')
-rw-r--r--tests/test-duet-transfer.el176
1 files changed, 176 insertions, 0 deletions
diff --git a/tests/test-duet-transfer.el b/tests/test-duet-transfer.el
new file mode 100644
index 0000000..a5d0429
--- /dev/null
+++ b/tests/test-duet-transfer.el
@@ -0,0 +1,176 @@
+;;; test-duet-transfer.el --- Tests for transfer-spec + planning -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2026 Craig Jennings
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;; This program is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; Tests for the endpoint matrix (route + backend selection), transfer-spec
+;; assembly, and the two pure planners (conflict resolution and move
+;; sequencing). Selection tests register the built-in rsync/TRAMP backends
+;; into a let-bound registry so they run against a known, isolated set. The
+;; planners are pure: existence and conflict-resolution decisions are injected
+;; as functions, so no test touches a real file.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+
+(defmacro test-duet-transfer--with-builtins (&rest body)
+ "Run BODY with only the built-in backends registered, isolated."
+ `(let ((duet--backend-registry nil))
+ (duet--register-builtin-backends)
+ ,@body))
+
+(defun test-duet-transfer--classify (path)
+ "Classify PATH; convenience wrapper for route tests."
+ (duet--classify-path path))
+
+;;; Route determination — every matrix cell
+
+(ert-deftest test-duet-transfer-route-local-to-local ()
+ (should (eq :local
+ (duet--transfer-route (test-duet-transfer--classify "/tmp/a")
+ (test-duet-transfer--classify "/tmp/b")
+ nil))))
+
+(ert-deftest test-duet-transfer-route-local-to-remote ()
+ (should (eq :local-remote
+ (duet--transfer-route (test-duet-transfer--classify "/tmp/a")
+ (test-duet-transfer--classify "/ssh:host:/b")
+ nil)))
+ (should (eq :local-remote
+ (duet--transfer-route (test-duet-transfer--classify "/ssh:host:/a")
+ (test-duet-transfer--classify "/tmp/b")
+ nil))))
+
+(ert-deftest test-duet-transfer-route-remote-same-host ()
+ (should (eq :remote-same-host
+ (duet--transfer-route (test-duet-transfer--classify "/ssh:user@host:/a")
+ (test-duet-transfer--classify "/ssh:user@host:/b")
+ nil))))
+
+(ert-deftest test-duet-transfer-route-remote-different-host-defaults-roundtrip ()
+ (should (eq :remote-roundtrip
+ (duet--transfer-route (test-duet-transfer--classify "/ssh:hostA:/a")
+ (test-duet-transfer--classify "/ssh:hostB:/b")
+ nil))))
+
+(ert-deftest test-duet-transfer-route-remote-different-host-direct-only-on-override ()
+ (should (eq :remote-direct
+ (duet--transfer-route (test-duet-transfer--classify "/ssh:hostA:/a")
+ (test-duet-transfer--classify "/ssh:hostB:/b")
+ '(:direct-remote-to-remote t)))))
+
+;;; Backend selection — the matrix
+
+(ert-deftest test-duet-transfer-selects-rsync-for-local-pair ()
+ (test-duet-transfer--with-builtins
+ (should (eq 'rsync
+ (duet-backend-name
+ (duet--select-backend (test-duet-transfer--classify "/tmp/a")
+ (test-duet-transfer--classify "/tmp/b")))))))
+
+(ert-deftest test-duet-transfer-selects-rsync-for-local-ssh-pair ()
+ (test-duet-transfer--with-builtins
+ (should (eq 'rsync
+ (duet-backend-name
+ (duet--select-backend (test-duet-transfer--classify "/tmp/a")
+ (test-duet-transfer--classify "/ssh:host:/b")))))))
+
+(ert-deftest test-duet-transfer-falls-back-to-tramp-for-non-ssh-method ()
+ "An FTP endpoint is not ssh-reachable, so rsync declines and TRAMP wins."
+ (test-duet-transfer--with-builtins
+ (should (eq 'tramp
+ (duet-backend-name
+ (duet--select-backend (test-duet-transfer--classify "/tmp/a")
+ (test-duet-transfer--classify "/ftp:host:/b")))))))
+
+;;; Transfer-spec assembly
+
+(ert-deftest test-duet-transfer-spec-local-copy-shape ()
+ "A local copy spec names the rsync backend, the local route, and an argv."
+ (test-duet-transfer--with-builtins
+ (let ((spec (duet--transfer-spec '("/tmp/a/file.txt") "/tmp/b" nil)))
+ (should (eq 'rsync (plist-get spec :backend)))
+ (should (eq :local (plist-get spec :route)))
+ (should (equal '("/tmp/a/file.txt") (plist-get spec :sources)))
+ (should (equal "/tmp/b" (plist-get spec :destination-directory)))
+ (should (equal "rsync" (car (plist-get spec :argv))))
+ (should (member "/tmp/a/file.txt" (plist-get spec :argv)))
+ (should (eq t (plist-get spec :async))))))
+
+(ert-deftest test-duet-transfer-spec-carries-route-for-remote ()
+ (test-duet-transfer--with-builtins
+ (let ((spec (duet--transfer-spec '("/tmp/a/file.txt") "/ssh:host:/b" nil)))
+ (should (eq 'rsync (plist-get spec :backend)))
+ (should (eq :local-remote (plist-get spec :route))))))
+
+;;; Conflict planning — pure, prompt-free
+
+(ert-deftest test-duet-plan-conflicts-no-collisions-all-copy ()
+ "With nothing at the destination, every item plans a plain copy."
+ (let ((plan (duet--plan-conflicts '(("/a/x" . "/b/x") ("/a/y" . "/b/y"))
+ (lambda (_d) nil)
+ (lambda (_i) (error "resolver must not be called")))))
+ (should (cl-every (lambda (e) (eq 'copy (plist-get e :action))) plan))))
+
+(ert-deftest test-duet-plan-conflicts-resolver-skip-and-overwrite ()
+ "The resolver's action is recorded for each colliding item."
+ (let ((plan (duet--plan-conflicts '(("/a/x" . "/b/x"))
+ (lambda (_d) t)
+ (lambda (_i) 'skip))))
+ (should (eq 'skip (plist-get (car plan) :action)))))
+
+(ert-deftest test-duet-plan-conflicts-rename-computes-free-name ()
+ "A rename action computes a destination name the existence check reports free."
+ (let* ((taken '("/b/file.txt" "/b/file (1).txt"))
+ (plan (duet--plan-conflicts '(("/a/file.txt" . "/b/file.txt"))
+ (lambda (d) (member d taken))
+ (lambda (_i) 'rename))))
+ (should (eq 'rename (plist-get (car plan) :action)))
+ (should (equal "file (2).txt" (plist-get (car plan) :new-name)))))
+
+(ert-deftest test-duet-plan-conflicts-apply-to-all ()
+ "An (action . all) resolution applies to every remaining collision unprompted."
+ (let ((calls 0))
+ (let ((plan (duet--plan-conflicts
+ '(("/a/x" . "/b/x") ("/a/y" . "/b/y") ("/a/z" . "/b/z"))
+ (lambda (_d) t)
+ (lambda (_i) (setq calls (1+ calls)) '(skip . all)))))
+ (should (= 1 calls))
+ (should (cl-every (lambda (e) (eq 'skip (plist-get e :action))) plan)))))
+
+;;; Move planning — delete only after copy success
+
+(ert-deftest test-duet-plan-move-pairs-copy-then-gated-delete ()
+ "Each source gets a copy step and a delete step gated on its copy success."
+ (let ((plan (duet--plan-move '("/a/x" "/a/y"))))
+ (should (= 4 (length plan)))
+ (dolist (step plan)
+ (when (eq 'delete (plist-get step :op))
+ (should (eq 'copy-success (plist-get step :gate)))))))
+
+(ert-deftest test-duet-plan-move-no-ungated-delete ()
+ "No delete step is ever emitted without the copy-success gate."
+ (let ((plan (duet--plan-move '("/a/x" "/a/y" "/a/z"))))
+ (should-not (cl-some (lambda (s) (and (eq 'delete (plist-get s :op))
+ (not (plist-get s :gate))))
+ plan))))
+
+(provide 'test-duet-transfer)
+;;; test-duet-transfer.el ends here