From 3d8778ba12cbbe2b8f6d5512d4b4a8f13a9c55ac Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Sat, 6 Jun 2026 10:58:46 -0500 Subject: 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. --- tests/test-duet-transfer.el | 176 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 tests/test-duet-transfer.el (limited to 'tests') 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 + +;; 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 . + +;;; 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 -- cgit v1.2.3