(ns arthur.clock-test "The clock is four lines of arithmetic and the whole take's sync depends on them, so they are asserted rather than eyeballed. A drift bug is invisible for the first second and unmistakable by the tenth, which is the worst possible shape for a bug to have." (:require [cljs.test :refer [deftest is testing]] [arthur.clock :as clock])) (defn- fake "Stands in for the audio element. The clock only ever reads `currentTime`, `paused`, `ended` and `playbackRate` and writes `currentTime`, which is the entire coupling — so the whole of it can be asserted in node." [& {:keys [t paused? ended? rate duration] :or {t 0 paused? true ended? false rate 1.0 duration 7.6}}] #js {:currentTime t :paused paused? :ended ended? :playbackRate rate :duration duration}) (deftest the-frame-is-derived-from-the-audio-not-counted ;; frame = ⌊currentTime · fps⌋. The whole point: nothing accumulates, so ;; nothing can drift. (clock/attach! (fake :t 0.0)) (is (= 0 (clock/frame 30 229))) (doseq [[t want] [[0.0 0] [0.033 0] [0.034 1] [1.0 30] [1.999 59] [2.0 60]]] (clock/attach! (fake :t t)) (is (= want (clock/frame 30 229)) (str t "s at 30fps")))) (deftest a-dropped-frame-lands-where-the-audio-already-is ;; THE property the derivation buys. A loop that stalled for a third of a ;; second resumes at the frame the audio reached, not a third of a second ;; behind it — the failure is a visible stutter rather than an invisible slide ;; out of sync, and those are very different bugs to own. (clock/attach! (fake :t 1.0)) (let [before (clock/frame 30 229)] (clock/attach! (fake :t 1.5)) ; fifteen frames' worth of stall (is (= 45 (clock/frame 30 229))) (is (= 30 before) "and nothing was counted in between"))) (deftest the-frame-is-clamped-into-the-clip ;; Audio is 7.601s and the clip is 229 frames at 30fps = 7.633s, so the last ;; fraction of a second has no audio and the end of the audio has no frame ;; past the last. Neither may produce an out-of-range index. (clock/attach! (fake :t 99.0)) (is (= 228 (clock/frame 30 229))) (clock/attach! (fake :t -1.0)) (is (= 0 (clock/frame 30 229)))) (deftest a-seek-round-trips ;; Seeking to the START of a frame rather than its middle is what makes this ;; idempotent: seek to f, read back f, at every f. (let [a (fake)] (clock/attach! a) (doseq [f [0 1 57 114 171 228]] (clock/seek! 30 229 f) (is (= f (clock/frame 30 229)) (str "seek to " f))))) (deftest a-seek-past-either-end-is-clamped-before-it-reaches-the-element (let [a (fake)] (clock/attach! a) (clock/seek! 30 229 9999) (is (= 228 (clock/frame 30 229))) (clock/seek! 30 229 -5) (is (= 0 (clock/frame 30 229))) (is (>= (.-currentTime a) 0) "and currentTime is never negative"))) (deftest rate-does-not-enter-the-frame-calculation ;; ½× and ¼× are playbackRate and nothing else. If rate appeared here as well ;; it would be applied twice — the picture would have one rate and the sound ;; another, which is precisely the desync the audio clock exists to prevent. (doseq [r [1.0 0.5 0.25]] (clock/attach! (fake :t 2.0 :rate r)) (is (= 60 (clock/frame 30 229)) (str "at " r "x, 2.0s is still frame 60")))) (deftest playing-follows-the-element (clock/attach! (fake :paused? true)) (is (not (clock/playing?))) (clock/attach! (fake :paused? false)) (is (clock/playing?)) (testing "and a finished file is not playing, whatever `paused` says" (clock/attach! (fake :paused? false :ended? true)) (is (not (clock/playing?))))) (deftest the-audio-length-is-reported-rather-than-assumed ;; A clip longer than its audio is a legitimate thing to be told about and not ;; a thing to silently truncate. (clock/attach! (fake :duration 7.601)) (is (= 229 (clock/duration-frames 30))) (is (= 92 (clock/duration-frames 12))) (testing "and an unloaded element has no opinion" (clock/attach! (fake :duration js/NaN)) (is (nil? (clock/duration-frames 30))))) (deftest exposure-is-applied-to-the-derived-frame ;; The transport shows which frame the grid holds the playhead back onto, so ;; that `exposure 2` is visibly doing something rather than only inside the ;; scene. (is (= [0 0 2 2 4 4] (mapv #(clock/exposed-frame % 2) (range 6)))) (is (= [0 1 2 3 4 5] (mapv #(clock/exposed-frame % 1) (range 6))))) (deftest with-no-element-attached-nothing-explodes ;; The loop starts before the :ref has fired, so every reader has to be safe ;; on a clock that has not been handed its element yet. (clock/attach! nil) (is (= 0 (clock/frame 30 229))) (is (not (clock/playing?))) (is (= 1.0 (clock/rate))) (is (nil? (clock/duration-frames 30))) (is (nil? (clock/seek! 30 229 5))) (is (nil? (clock/pause!))))