diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-06 15:36:25 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-06 15:36:25 -0500 |
| commit | 3b244ba0492fd86fca051713196067f833f34a1b (patch) | |
| tree | 177b51527c268262cf83b7b55ad6109bfd94761e /tests | |
| parent | 0155eb670c2f9e072c34671537d95c716a54e011 (diff) | |
| download | duet-3b244ba0492fd86fca051713196067f833f34a1b.tar.gz duet-3b244ba0492fd86fca051713196067f833f34a1b.zip | |
Phase 5 turns the pure transfer-specs from Phase 3 into running transfers. duet--run-transfer spawns the backend over make-process, and a serial queue (duet-max-concurrent-transfers, default 1) holds the rest until a slot frees. Each transfer carries a state machine: queued, running, stalled, cancelling, then a terminal done, failed, or cleanup-unverified.
The process filter is the hot path, so it stays cheap: it counts output, bounds it to a trailing window, and schedules one coalesced log render. It never refreshes panes. Pane refresh runs once per batch from the sentinel, also coalesced. A move deletes its sources only after the copy exits 0, so a failed move leaves the source untouched. Cancellation kills the process and, for a backend declaring verifiable cleanup, checks the destination for stray temp files before settling on failed versus cleanup-unverified.
The process boundary and the temp-file lister are injectable, so the queue and classification logic test against fake results while real rsync gets its own slow integration file. duet-copy, duet-move, duet-mkdir, and duet-delete are wired to the engine. The viewer actions stay stubs until their phase. In-process TRAMP and both-remote rsync still refuse to run here. They land with Phase 6.
172 tests, duet.el at 100% line coverage, compile/lint/complexity green.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test-duet-pane.el | 6 | ||||
| -rw-r--r-- | tests/test-duet-transfer-exec.el | 510 | ||||
| -rw-r--r-- | tests/test-integration-duet-transfer.el | 168 |
3 files changed, 682 insertions, 2 deletions
diff --git a/tests/test-duet-pane.el b/tests/test-duet-pane.el index df9866b..433035c 100644 --- a/tests/test-duet-pane.el +++ b/tests/test-duet-pane.el @@ -82,8 +82,10 @@ ;;; Not-yet-implemented action commands (ert-deftest test-duet-pane-unimplemented-actions-error () - "The transfer/file actions announce themselves until their phase lands." - (dolist (cmd '(duet-view duet-edit duet-copy duet-move duet-mkdir duet-delete)) + "The viewer actions announce themselves until the viewer phase lands. +The transfer actions (copy/move/mkdir/delete) are wired to the engine and are +covered in test-duet-transfer-exec.el." + (dolist (cmd '(duet-view duet-edit)) (should-error (funcall cmd) :type 'user-error))) ;;; Launch, quit, and two-window resolution against a real frame diff --git a/tests/test-duet-transfer-exec.el b/tests/test-duet-transfer-exec.el new file mode 100644 index 0000000..f93a4fa --- /dev/null +++ b/tests/test-duet-transfer-exec.el @@ -0,0 +1,510 @@ +;;; test-duet-transfer-exec.el --- Tests for the transfer execution engine -*- 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: + +;; Process-boundary tests for the Phase 5 transfer engine: the serial queue +;; and its concurrency limit, the bounded output filter, throttled log +;; rendering, terminal-result classification, coalesced pane refresh, +;; cancellation with cleanup verification, the failure explainer, and the +;; log-record schema. The process boundary is stubbed (a no-op launcher and +;; injected result plists), so no real subprocess runs here; real-rsync +;; coverage lives in test-integration-duet-transfer.el. + +;;; Code: + +(require 'test-bootstrap (expand-file-name "test-bootstrap.el")) + +;; A clean engine per test: fresh counters, empty queue/history, no live +;; timers, stall detection off (mark-stalled is exercised directly), a no-op +;; launcher so `duet--start-transfer' spawns nothing. Real render/refresh +;; timers created during a test are cancelled on exit so they never fire into +;; the next one. +(defmacro duet-test--with-clean-engine (&rest body) + (declare (indent 0)) + `(let ((duet--transfer-id-counter 0) + (duet--transfers nil) + (duet--transfer-queue nil) + (duet--log-render-timer nil) + (duet--refresh-pending nil) + (duet--refresh-timer nil) + (duet-transfer-stall-timeout nil) + (duet-max-concurrent-transfers 1) + (duet--transfer-launcher (lambda (_tr) nil))) + (unwind-protect (progn ,@body) + (when (timerp duet--log-render-timer) (cancel-timer duet--log-render-timer)) + (when (timerp duet--refresh-timer) (cancel-timer duet--refresh-timer))))) + +(defun duet-test--argv-spec (&rest overrides) + "A minimal runnable local rsync transfer-spec, with plist OVERRIDES applied." + (append overrides + (list :backend 'rsync :route :local + :sources '("/src/a.txt") + :destination-directory "/dst" + :argv '("rsync" "-a" "/src/a.txt" "/dst")))) + +;;; Transfer records and ids + +(ert-deftest test-duet-exec-make-transfer-assigns-monotonic-id () + (duet-test--with-clean-engine + (let ((a (duet--make-transfer (duet-test--argv-spec))) + (b (duet--make-transfer (duet-test--argv-spec)))) + (should (= 1 (duet-transfer-id a))) + (should (= 2 (duet-transfer-id b))) + (should (eq 'queued (duet-transfer-status a))) + (should (eq 'rsync (duet-transfer-backend a))) + (should (eq :local (duet-transfer-route a))) + (should (equal '("/src/") (duet-transfer-source-directories a)))))) + +;;; Queue + concurrency limit + +(ert-deftest test-duet-exec-queue-runs-one-at-a-time () + (duet-test--with-clean-engine + (let* ((duet-max-concurrent-transfers 1) + (a (duet--run-transfer (duet-test--argv-spec))) + (b (duet--run-transfer (duet-test--argv-spec)))) + (should (eq 'running (duet-transfer-status a))) + (should (eq 'queued (duet-transfer-status b))) + (duet--transfer-handle-result a '(:exit 0)) + (should (eq 'done (duet-transfer-status a))) + (should (eq 'running (duet-transfer-status b))) + (should (not (memq a duet--transfer-queue))) + (should (memq a duet--transfers))))) + +(ert-deftest test-duet-exec-queue-honors-max-concurrent () + (duet-test--with-clean-engine + (let ((duet-max-concurrent-transfers 2)) + (let ((a (duet--run-transfer (duet-test--argv-spec))) + (b (duet--run-transfer (duet-test--argv-spec))) + (c (duet--run-transfer (duet-test--argv-spec)))) + (should (eq 'running (duet-transfer-status a))) + (should (eq 'running (duet-transfer-status b))) + (should (eq 'queued (duet-transfer-status c))))))) + +(ert-deftest test-duet-exec-run-transfer-refuses-deferred-specs () + (duet-test--with-clean-engine + (should-error + (duet--run-transfer '(:backend tramp :route :local-remote :tramp t + :destination-directory "/dst")) + :type 'user-error) + (should-error + (duet--run-transfer '(:backend rsync :route :remote-roundtrip + :exec-mode rsync-remote-to-remote + :destination-directory "/dst")) + :type 'user-error))) + +;;; Bounded output filter + +(ert-deftest test-duet-exec-filter-bounds-retained-output () + (duet-test--with-clean-engine + (let ((duet-transfer-stderr-limit 10) + (tr (duet--make-transfer (duet-test--argv-spec)))) + (duet--transfer-filter tr "abcdefgh") + (duet--transfer-filter tr "ijklmnop") + (should (= 10 (length (duet-transfer-stderr tr)))) + (should (string= "ghijklmnop" (duet-transfer-stderr tr))) + (should (= 2 (duet-transfer-output-count tr))) + (should (= 16 (duet-transfer-stderr-bytes tr)))))) + +;;; Throttled log render + +(ert-deftest test-duet-exec-render-throttled-to-one-timer () + (duet-test--with-clean-engine + (let ((timer-calls 0)) + (cl-letf (((symbol-function 'run-with-timer) + (lambda (&rest _) (cl-incf timer-calls) 'fake-timer))) + (let ((tr (duet--make-transfer (duet-test--argv-spec)))) + (duet--transfer-filter tr "x") + (duet--transfer-filter tr "y") + (duet--transfer-filter tr "z") + (should (= 1 timer-calls)) + (should (eq 'fake-timer duet--log-render-timer))))))) + +(ert-deftest test-duet-exec-render-rearms-after-flush () + (duet-test--with-clean-engine + (let ((timer-calls 0)) + (cl-letf (((symbol-function 'run-with-timer) + (lambda (&rest _) (cl-incf timer-calls) 'fake-timer))) + (duet--schedule-log-render) + (should (= 1 timer-calls)) + (duet--render-transfer-log) + (should (null duet--log-render-timer)) + (duet--schedule-log-render) + (should (= 2 timer-calls)))))) + +;;; Stall flagging + +(ert-deftest test-duet-exec-stall-marks-and-recovers () + (duet-test--with-clean-engine + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (should (eq 'running (duet-transfer-status tr))) + (duet--mark-stalled tr) + (should (eq 'stalled (duet-transfer-status tr))) + ;; a stalled transfer still occupies a slot + (should (memq tr (duet--running-transfers))) + (duet--transfer-filter tr "more output") + (should (eq 'running (duet-transfer-status tr)))))) + +(ert-deftest test-duet-exec-mark-stalled-only-when-running () + (duet-test--with-clean-engine + (let ((tr (duet--make-transfer (duet-test--argv-spec)))) + (setf (duet-transfer-status tr) 'done) + (duet--mark-stalled tr) + (should (eq 'done (duet-transfer-status tr)))))) + +;;; Terminal-result classification + +(ert-deftest test-duet-exec-handle-result-success () + (duet-test--with-clean-engine + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (duet--transfer-handle-result tr '(:exit 0)) + (should (eq 'done (duet-transfer-status tr))) + (should (null (duet-transfer-failure tr))) + (should (= 0 (duet-transfer-exit tr))) + (should (duet-transfer-cleanup-verified tr))))) + +(ert-deftest test-duet-exec-handle-result-nonzero-exit-normalizes () + (duet-test--with-clean-engine + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (setf (duet-transfer-stderr tr) "rsync: mkstemp failed: Permission denied (13)") + (duet--transfer-handle-result tr '(:exit 23)) + (should (eq 'failed (duet-transfer-status tr))) + (should (eq 'permission-denied (plist-get (duet-transfer-failure tr) :class))) + (should (= 23 (duet-transfer-exit tr)))))) + +(ert-deftest test-duet-exec-handle-result-signal-fails () + (duet-test--with-clean-engine + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (duet--transfer-handle-result tr '(:signal 15)) + (should (eq 'failed (duet-transfer-status tr))) + (should (eq 'cancelled (plist-get (duet-transfer-failure tr) :class))) + (should (= 15 (duet-transfer-signal tr)))))) + +(ert-deftest test-duet-exec-handle-result-launch-error-fails () + (duet-test--with-clean-engine + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (duet--transfer-handle-result tr '(:launch-error "No such file or directory")) + (should (eq 'failed (duet-transfer-status tr))) + (should (eq 'launch-failure (plist-get (duet-transfer-failure tr) :class)))))) + +(ert-deftest test-duet-exec-start-transfer-launch-error-is-caught () + (duet-test--with-clean-engine + (let ((duet--transfer-launcher + (lambda (_tr) (error "boom: cannot spawn")))) + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (should (eq 'failed (duet-transfer-status tr))) + (should (eq 'launch-failure (plist-get (duet-transfer-failure tr) :class))))))) + +;;; Coalesced pane refresh + +(ert-deftest test-duet-exec-refresh-coalesced-to-one-timer () + (duet-test--with-clean-engine + (let ((timer-calls 0)) + (cl-letf (((symbol-function 'run-with-timer) + (lambda (&rest _) (cl-incf timer-calls) 'fake-timer))) + (duet--schedule-pane-refresh "/a") + (duet--schedule-pane-refresh "/a") + (duet--schedule-pane-refresh "/b") + (should (= 1 timer-calls)) + (should (= 2 (length duet--refresh-pending))) + (should (member "/a/" duet--refresh-pending)) + (should (member "/b/" duet--refresh-pending)))))) + +(ert-deftest test-duet-exec-success-schedules-dest-refresh () + (duet-test--with-clean-engine + (cl-letf (((symbol-function 'run-with-timer) (lambda (&rest _) 'fake-timer))) + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (duet--transfer-handle-result tr '(:exit 0)) + (should (member "/dst/" duet--refresh-pending)))))) + +(ert-deftest test-duet-exec-move-success-schedules-source-and-dest () + (duet-test--with-clean-engine + (cl-letf (((symbol-function 'run-with-timer) (lambda (&rest _) 'fake-timer))) + (let ((tr (duet--run-transfer + (duet-test--argv-spec :sources '("/src/a.txt")) + t))) + (duet--transfer-handle-result tr '(:exit 0)) + (should (member "/dst/" duet--refresh-pending)) + (should (member "/src/" duet--refresh-pending)))))) + +;;; Cancellation + cleanup verification + +(ert-deftest test-duet-exec-cancel-transitions-to-cancelling () + (duet-test--with-clean-engine + (let ((killed nil)) + (cl-letf (((symbol-function 'duet--kill-process) + (lambda (_tr) (setq killed t)))) + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (duet--cancel-transfer tr) + (should (eq 'cancelling (duet-transfer-status tr))) + (should killed)))))) + +(ert-deftest test-duet-exec-cancel-clean-temp-finishes-failed () + (duet-test--with-clean-engine + (let ((duet--temp-file-lister (lambda (_tr) nil))) + (cl-letf (((symbol-function 'duet--kill-process) #'ignore)) + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (duet--cancel-transfer tr) + (duet--transfer-handle-result tr '(:signal 15)) + (should (eq 'failed (duet-transfer-status tr))) + (should (duet-transfer-cleanup-verified tr))))))) + +(ert-deftest test-duet-exec-cancel-stray-temp-flags-cleanup-unverified () + (duet-test--with-clean-engine + (let ((duet--temp-file-lister (lambda (_tr) '("/dst/.a.txt.AbCdEf")))) + (cl-letf (((symbol-function 'duet--kill-process) #'ignore)) + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (duet--cancel-transfer tr) + (duet--transfer-handle-result tr '(:signal 15)) + (should (eq 'cleanup-unverified (duet-transfer-status tr))) + (should-not (duet-transfer-cleanup-verified tr))))))) + +(ert-deftest test-duet-exec-cancel-terminal-transfer-is-noop () + (duet-test--with-clean-engine + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (duet--transfer-handle-result tr '(:exit 0)) + (duet--cancel-transfer tr) + (should (eq 'done (duet-transfer-status tr)))))) + +;;; Failure explanation + +(ert-deftest test-duet-exec-format-failure-explanation () + (duet-test--with-clean-engine + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (setf (duet-transfer-stderr tr) "rsync: Permission denied (13)") + (duet--transfer-handle-result tr '(:exit 23)) + (let ((s (duet--format-failure-explanation tr))) + (should (string-match-p "permission denied" s)) + (should (string-match-p "fix-permissions" s)) + (should (string-match-p "Source unchanged" s)))))) + +(ert-deftest test-duet-exec-format-explanation-success () + (duet-test--with-clean-engine + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (duet--transfer-handle-result tr '(:exit 0)) + (should (string-match-p "completed successfully" + (duet--format-failure-explanation tr)))))) + +;;; Log-record schema + +(ert-deftest test-duet-exec-log-record-schema () + (duet-test--with-clean-engine + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (duet--transfer-handle-result tr '(:exit 0)) + (let ((rec (duet--transfer-log-record tr))) + (should (= (duet-transfer-id tr) (plist-get rec :id))) + (should (eq 'rsync (plist-get rec :backend))) + (should (eq :local (plist-get rec :route))) + (should (stringp (plist-get rec :argv))) + (should (eq 'done (plist-get rec :status))))))) + +(ert-deftest test-duet-exec-log-record-redacts-argv () + (duet-test--with-clean-engine + (duet-register-backend + (duet-backend-create + :name 'secret-tool + :handles (lambda (_s _d) nil) + :command (lambda (_s _d _o) '(:argv ("x"))) + :redaction '("\\(--password=\\)\\S-+") + :cleanup :none)) + (let ((tr (duet--make-transfer + '(:backend secret-tool :route :local :destination-directory "/dst" + :argv ("tool" "--password=hunter2" "/dst"))))) + (let ((argv (plist-get (duet--transfer-log-record tr) :argv))) + (should (string-match-p "--password=<redacted>" argv)) + (should-not (string-match-p "hunter2" argv)))))) + +;;; Default stray-temp lister (real tmp dir, no subprocess) + +(ert-deftest test-duet-exec-list-stray-temp-files-matches-pattern () + (duet-test--with-clean-engine + (let ((dir (make-temp-file "duet-temp-" t))) + (unwind-protect + (progn + (write-region "" nil (expand-file-name ".a.txt.AbCdEf" dir)) + (write-region "" nil (expand-file-name "a.txt" dir)) + (let* ((tr (duet--make-transfer + (duet-test--argv-spec :destination-directory dir))) + (strays (duet--list-stray-temp-files tr))) + (should (= 1 (length strays))) + (should (string-match-p "\\.a\\.txt\\.AbCdEf\\'" (car strays))))) + (delete-directory dir t))))) + +;;; Thin interactive glue + +(ert-deftest test-duet-exec-submit-builds-and-runs () + (duet-test--with-clean-engine + (let ((tr (duet--submit-transfer '("/src/a.txt") "/dst" nil))) + (should (duet-transfer-p tr)) + (should (eq 'rsync (duet-transfer-backend tr))) + (should (member "/src/a.txt" (plist-get (duet-transfer-spec tr) :sources)))))) + +(ert-deftest test-duet-exec-submit-no-sources-errors () + (duet-test--with-clean-engine + (should-error (duet--submit-transfer nil "/dst" nil) :type 'user-error))) + +(ert-deftest test-duet-copy-and-move-submit-via-engine () + (duet-test--with-clean-engine + (cl-letf (((symbol-function 'dired-get-marked-files) (lambda (&rest _) '("/src/a.txt"))) + ((symbol-function 'duet--other-pane) (lambda (&rest _) 'win)) + ((symbol-function 'duet--pane-directory) (lambda (_w) "/dst")) + ((symbol-function 'display-buffer) (lambda (&rest _) nil))) + (duet-copy) + (should (= 1 (length duet--transfers))) + (should-not (duet-transfer-move-p (car duet--transfers))) + (duet-move) + (should (= 2 (length duet--transfers))) + (should (duet-transfer-move-p (car duet--transfers)))))) + +(ert-deftest test-duet-mkdir-creates-and-refreshes () + (duet-test--with-clean-engine + (let ((made nil)) + (cl-letf (((symbol-function 'make-directory) (lambda (d &rest _) (setq made d))) + ((symbol-function 'run-with-timer) (lambda (&rest _) 'fake-timer))) + (let ((default-directory "/work/")) + (duet-mkdir "newdir") + (should (string-match-p "newdir" made)) + (should (member "/work/" duet--refresh-pending))))))) + +(ert-deftest test-duet-delete-trashes-files-and-directories () + "Marked files go through `delete-file' and directories through +`delete-directory', both to trash, then the active pane refreshes." + (duet-test--with-clean-engine + (let ((trashed-files nil) (removed-dirs nil)) + (cl-letf (((symbol-function 'dired-get-marked-files) + (lambda (&rest _) '("/work/a.txt" "/work/sub"))) + ((symbol-function 'yes-or-no-p) (lambda (&rest _) t)) + ((symbol-function 'file-directory-p) (lambda (f) (string= f "/work/sub"))) + ((symbol-function 'delete-file) (lambda (f &optional _trash) (push f trashed-files))) + ((symbol-function 'delete-directory) (lambda (d &rest _) (push d removed-dirs))) + ((symbol-function 'run-with-timer) (lambda (&rest _) 'fake-timer))) + (let ((default-directory "/work/")) + (duet-delete) + (should (member "/work/a.txt" trashed-files)) + (should (member "/work/sub" removed-dirs)) + (should (member "/work/" duet--refresh-pending))))))) + +(ert-deftest test-duet-cancel-transfer-command-no-active-errors () + (duet-test--with-clean-engine + (should-error (duet-cancel-transfer) :type 'user-error))) + +(ert-deftest test-duet-cancel-transfer-command-cancels-latest () + (duet-test--with-clean-engine + (cl-letf (((symbol-function 'duet--kill-process) #'ignore) + ((symbol-function 'message) (lambda (&rest _) nil))) + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (duet-cancel-transfer) + (should (eq 'cancelling (duet-transfer-status tr))))))) + +(ert-deftest test-duet-explain-transfer-failure-no-transfers-errors () + (duet-test--with-clean-engine + (should-error (duet-explain-transfer-failure) :type 'user-error))) + +(ert-deftest test-duet-explain-transfer-failure-shows-buffer () + (duet-test--with-clean-engine + (cl-letf (((symbol-function 'display-buffer) (lambda (b &rest _) b))) + (let ((tr (duet--run-transfer (duet-test--argv-spec)))) + (setf (duet-transfer-stderr tr) "rsync: Permission denied (13)") + (duet--transfer-handle-result tr '(:exit 23)) + (duet-explain-transfer-failure) + (with-current-buffer "*DUET Transfer Failure*" + (should (string-match-p "fix-permissions" (buffer-string)))))))) + +;;; Timer-arming and cancellation branches + +(ert-deftest test-duet-exec-arm-stall-timer-when-enabled () + (duet-test--with-clean-engine + (let ((duet-transfer-stall-timeout 60)) + (cl-letf (((symbol-function 'run-with-timer) (lambda (&rest _) 'armed))) + (let ((tr (duet--make-transfer (duet-test--argv-spec)))) + (duet--arm-stall-timer tr) + (should (eq 'armed (duet-transfer-stall-timer tr)))))))) + +(ert-deftest test-duet-exec-cancel-stall-timer-cancels-real-timer () + (duet-test--with-clean-engine + (let ((tr (duet--make-transfer (duet-test--argv-spec)))) + (setf (duet-transfer-stall-timer tr) (run-with-timer 100 nil #'ignore)) + (duet--cancel-stall-timer tr) + (should (null (duet-transfer-stall-timer tr)))))) + +(ert-deftest test-duet-exec-render-cancels-real-timer () + (duet-test--with-clean-engine + (setq duet--log-render-timer (run-with-timer 100 nil #'ignore)) + (duet--render-transfer-log) + (should (null duet--log-render-timer)))) + +;;; Pane-refresh execution + +(ert-deftest test-duet-exec-do-pane-refresh-clears-pending () + (duet-test--with-clean-engine + (let ((refreshed nil)) + (cl-letf (((symbol-function 'duet--refresh-dir) (lambda (d) (push d refreshed)))) + (setq duet--refresh-pending '("/x/" "/y/") + duet--refresh-timer (run-with-timer 100 nil #'ignore)) + (duet--do-pane-refresh) + (should (null duet--refresh-pending)) + (should (null duet--refresh-timer)) + (should (= 2 (length refreshed))))))) + +(ert-deftest test-duet-exec-refresh-dir-reverts-dired-buffer () + (duet-test--with-clean-engine + (let ((dir (make-temp-file "duet-refresh-" t))) + (unwind-protect + (let ((buf (dired-noselect dir))) + (unwind-protect + (progn + (duet--refresh-dir dir) + (should (buffer-live-p buf))) + (kill-buffer buf))) + (delete-directory dir t))))) + +;;; delete-source on a directory + +(ert-deftest test-duet-exec-delete-source-removes-directory () + (duet-test--with-clean-engine + (let ((dir (make-temp-file "duet-del-" t))) + (should (file-directory-p dir)) + (duet--delete-source dir) + (should-not (file-exists-p dir))))) + +;;; Safety-text variants + +(ert-deftest test-duet-exec-safety-text-variants () + (should (string= "ok" (duet--safety-text "ok"))) + (should (string-match-p "Outcome unverified" (duet--safety-text :generic))) + (should (string-match-p "Unknown" (duet--safety-text 'something-else)))) + +;;; submit with an empty backend registry + +(ert-deftest test-duet-exec-submit-no-backend-errors () + (duet-test--with-clean-engine + (let ((duet--backend-registry nil)) + (should-error (duet--submit-transfer '("/src/a.txt") "/dst" nil) + :type 'user-error)))) + +;;; delete edge cases + +(ert-deftest test-duet-delete-no-files-errors () + (duet-test--with-clean-engine + (cl-letf (((symbol-function 'dired-get-marked-files) (lambda (&rest _) nil))) + (let ((default-directory "/work/")) + (should-error (duet-delete) :type 'user-error))))) + +(provide 'test-duet-transfer-exec) +;;; test-duet-transfer-exec.el ends here diff --git a/tests/test-integration-duet-transfer.el b/tests/test-integration-duet-transfer.el new file mode 100644 index 0000000..3b7bcf6 --- /dev/null +++ b/tests/test-integration-duet-transfer.el @@ -0,0 +1,168 @@ +;;; test-integration-duet-transfer.el --- Real-rsync transfer integration -*- 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: + +;; Integration tests that exercise the whole transfer engine against a real +;; local rsync on temporary directories: file and directory copies, the +;; delete-after-success move, and the failed-move-preserves-source guarantee. +;; +;; Components integrated: +;; - duet--run-transfer / duet--launch-process (real make-process) +;; - rsync (REAL, local, version-independent flags) +;; - duet--transfer-sentinel / duet--process-result / duet--transfer-handle-result +;; - duet--finalize-move (real delete on success) +;; +;; Tagged :slow, so `make test' skips them and `make test-all' runs them. + +;;; Code: + +(require 'test-bootstrap (expand-file-name "test-bootstrap.el")) + +(defmacro duet-itest--with-clean-engine (&rest body) + "Run BODY with a fresh engine and the real launcher, cancelling timers after." + (declare (indent 0)) + `(let ((duet--transfer-id-counter 0) + (duet--transfers nil) + (duet--transfer-queue nil) + (duet--log-render-timer nil) + (duet--refresh-pending nil) + (duet--refresh-timer nil) + (duet-transfer-stall-timeout nil) + (duet-max-concurrent-transfers 1)) + (unwind-protect (progn ,@body) + (when (timerp duet--log-render-timer) (cancel-timer duet--log-render-timer)) + (when (timerp duet--refresh-timer) (cancel-timer duet--refresh-timer))))) + +(defun duet-itest--wait (tr &optional timeout) + "Block until transfer TR reaches a terminal status or TIMEOUT seconds pass." + (let ((deadline (+ (float-time) (or timeout 15)))) + (while (and (not (memq (duet-transfer-status tr) duet--terminal-statuses)) + (< (float-time) deadline)) + (accept-process-output (duet-transfer-process tr) 0.05)) + (duet-transfer-status tr))) + +(defun duet-itest--tmpdir () + "Create and return a fresh temporary directory." + (file-name-as-directory (make-temp-file "duet-itest-" t))) + +(defun duet-itest--write (path contents) + "Write CONTENTS into PATH, creating parent directories." + (make-directory (file-name-directory path) t) + (with-temp-file path (insert contents))) + +(ert-deftest test-integration-duet-rsync-copies-file () + :tags '(:slow) + (duet-itest--with-clean-engine + (let* ((src (duet-itest--tmpdir)) + (dst (duet-itest--tmpdir)) + (file (expand-file-name "hello.txt" src))) + (unwind-protect + (progn + (duet-itest--write file "payload\n") + (let ((tr (duet--run-transfer + (duet--transfer-spec (list file) dst)))) + (should (eq 'done (duet-itest--wait tr))) + (should (= 0 (duet-transfer-exit tr))) + (should (file-exists-p (expand-file-name "hello.txt" dst))) + (should (duet-transfer-cleanup-verified tr)))) + (delete-directory src t) + (delete-directory dst t))))) + +(ert-deftest test-integration-duet-rsync-copies-directory () + :tags '(:slow) + (duet-itest--with-clean-engine + (let* ((root (duet-itest--tmpdir)) + (dst (duet-itest--tmpdir)) + (tree (expand-file-name "tree" root))) + (unwind-protect + (progn + (duet-itest--write (expand-file-name "a/one.txt" tree) "1") + (duet-itest--write (expand-file-name "b/two.txt" tree) "2") + (let ((tr (duet--run-transfer + (duet--transfer-spec (list tree) dst)))) + (should (eq 'done (duet-itest--wait tr))) + (should (file-exists-p (expand-file-name "tree/a/one.txt" dst))) + (should (file-exists-p (expand-file-name "tree/b/two.txt" dst))))) + (delete-directory root t) + (delete-directory dst t))))) + +(ert-deftest test-integration-duet-move-deletes-source-on-success () + :tags '(:slow) + (duet-itest--with-clean-engine + (let* ((src (duet-itest--tmpdir)) + (dst (duet-itest--tmpdir)) + (file (expand-file-name "movable.txt" src))) + (unwind-protect + (progn + (duet-itest--write file "move me\n") + (let ((tr (duet--run-transfer + (duet--transfer-spec (list file) dst) t))) + (should (eq 'done (duet-itest--wait tr))) + (should (file-exists-p (expand-file-name "movable.txt" dst))) + (should-not (file-exists-p file)))) + (delete-directory src t) + (delete-directory dst t))))) + +(ert-deftest test-integration-duet-failed-move-preserves-source () + :tags '(:slow) + (when (zerop (user-uid)) + (ert-skip "root bypasses directory permissions")) + (duet-itest--with-clean-engine + (let* ((src (duet-itest--tmpdir)) + (dst (duet-itest--tmpdir)) + (file (expand-file-name "keep.txt" src))) + (unwind-protect + (progn + (duet-itest--write file "must survive\n") + ;; Read+execute only: rsync cannot create files in the destination. + (set-file-modes dst #o500) + (let ((tr (duet--run-transfer + (duet--transfer-spec (list file) dst) t))) + (should (eq 'failed (duet-itest--wait tr))) + (should (/= 0 (duet-transfer-exit tr))) + ;; The copy failed, so the move never deleted the source. + (should (file-exists-p file)))) + (set-file-modes dst #o700) + (delete-directory src t) + (delete-directory dst t))))) + +(ert-deftest test-integration-duet-kill-process-terminates-live () + :tags '(:slow) + (duet-itest--with-clean-engine + (let* ((proc (make-process :name "duet-kill-test" + :command '("sleep" "30") :noquery t)) + (tr (duet-transfer-create :id 1 :process proc))) + (should (process-live-p proc)) + (duet--kill-process tr) + (should-not (process-live-p proc))))) + +(ert-deftest test-integration-duet-process-result-classifies-signal () + :tags '(:slow) + (let ((proc (make-process :name "duet-signal-test" + :command '("sleep" "30") :noquery t))) + (interrupt-process proc) + (let ((deadline (+ (float-time) 5))) + (while (and (process-live-p proc) (< (float-time) deadline)) + (accept-process-output proc 0.05))) + (should (eq 'signal (process-status proc))) + (should (eq :signal (car (duet--process-result proc)))))) + +(provide 'test-integration-duet-transfer) +;;; test-integration-duet-transfer.el ends here |
