flan/emacs/test-flan-watch.el
Joseph Ferano d9404bb34a The client drops -dev- from its names, and starts the buffer you are in
`-dev-` was in every Emacs symbol this client owns and meant nothing to anyone
typing one: the daemon is `flan dev` at a shell, but from inside Emacs there is
no other kind of connection to distinguish it from. `M-x flan-dev` is now
`M-x flan`, `flan-dev-quit` is `flan-quit`, the private prefix `flan-dev--` is
`flan--`, and every defcustom follows — ninety-odd symbols, with the two files
renamed to emacs/flan.el and emacs/test-flan.el so the file names say the same
thing as the symbols in them.

No aliases. Renaming a defcustom breaks a config that names it and there is no
way around that; the repo has no precedent for softening one, and an alias left
behind is what keeps a rename from finishing. MANUAL.md says the old names are
gone and how to fix a config, which is the whole of the migration path.

Three strings are not symbols and keep their spelling: `.flan-dev.sock`, which
bin/main.ml writes and which a renamed variable searching for a renamed file
would simply never find; and the two buffer names `*flan-dev*` and ` *flan-dev*`,
which name the `flan dev` subcommand's own output rather than anything in elisp.
`flan dev` with a space is the CLI and is untouched everywhere.

The entry point also stops asking a question it already has the answer to. From
a buffer visiting a .flan file it starts that file; from anywhere else it reads
one from the minibuffer as before; `C-u` reads one either way, which is how you
start a second program without leaving the first. The current buffer is still
the only source of the default — the bug where a previous project won over the
buffer you were in was fixed by removing `flan--file` from that position, and
nothing here puts it back.

Four checks on the `interactive' form, evaluated on its own rather than by
calling the command, because calling it would build and launch a program and
the question is only which file the form arrives at and whether it had to ask.
A fifth asserts that nothing answers to the old names. test/test_emacs.ml loads
the test file by path and test/test_session.ml names the client file in a
comment, so the rename reaches those two lines; nothing else outside emacs/ and
the docs moved. Verified by byte-compiling every file
clean and by `dune test` and `@page`.
2026-09-18 23:20:26 +07:00

264 lines
12 KiB
EmacsLisp

;;; test-flan-watch.el --- Ghost text, from a buffer and a fixture table -*- lexical-binding: t; -*-
;; Loaded by test-flan-cider.el, which runs under `dune test', for the reason
;; test-flan-mode.el gives: `emacs/*.el' is already a dependency of that
;; stanza, so a file here needs no build change to be run.
;;
;; Nothing here needs a daemon. Ghost text is a function from *a table of rows*
;; and *the text in a buffer* to *overlays*, and both halves are fixtures: the
;; rows are what a watch reply carries, and the buffer is Flan source. The
;; interesting failures are all on that side — a call site inside a string taken
;; for code, a name the runtime clipped no longer matching the site that wrote
;; it, an overlay left behind from the last repaint.
;;
;; The one case that is not about text is the subscription: the watch buffer
;; used to *be* the subscription, and ghost text is the second consumer that
;; made that wrong. Closing one must leave the other running.
;;; Code:
(require 'flan-mode)
(require 'flan-watch)
(declare-function test-flan--check "test-flan-cider" (name ok))
(defun test-flan-watch--buffer (text)
"A live `flan-mode' buffer holding TEXT, shown in a window.
Shown because `flan-watch--ghost-paint' only scans displayed buffers, which is
what bounds its cost; a `with-temp-buffer' would be scanned by nothing."
(let ((buf (get-buffer-create "test-flan-watch.flan")))
(with-current-buffer buf
(let ((inhibit-read-only t))
(erase-buffer)
(insert text))
(flan-mode))
(set-window-buffer (selected-window) buf)
buf))
(defun test-flan-watch--starts ()
"Where the overlays sit, in buffer order."
(sort (mapcar #'overlay-start flan-watch--ghost-overlays) #'<))
(defun test-flan-watch--strings ()
"What the overlays say, in buffer order."
(mapcar (lambda (ov) (substring-no-properties (overlay-get ov 'after-string)))
(sort (copy-sequence flan-watch--ghost-overlays)
(lambda (a b) (< (overlay-start a) (overlay-start b))))))
;; --- Finding the call site ------------------------------------------------
;;
;; The anchor is searched for rather than reported, because the table carries a
;; name and no location. A call inside a string is the case that makes the
;; syntax check earn its place: it is text that looks exactly like code.
(let ((buf (test-flan-watch--buffer
(concat "(defn step [] i64\n"
" (watch-i64 \"ticks\" ticks)\n"
" (watch-f64 \"hp\" hp)\n"
" (println \"(watch-i64 \\\"fake\\\" x)\")\n"
" (watch \"hp\" hp))\n"))))
(with-current-buffer buf
(let ((sites (flan-watch--ghost-sites)))
(test-flan--check
"every watch call in the buffer is found, by the name it writes"
(equal (mapcar #'car sites) '("ticks" "hp" "hp")))
(test-flan--check
"and a call written inside a string is not one of them"
(not (member "fake" (mapcar #'car sites)))))))
;; --- Every head the default regexp claims to cover ------------------------
;;
;; `flan-watch-ghost-call-regexp' allows *any number* of hyphenated segments,
;; and the accumulator is why. With one optional segment — the `?\=' this
;; default used to have — `watch-num-i64' matched as far as `watch-num', then
;; required whitespace, found `-i64', and backtracked to failure. The failure
;; is silent and looks like nothing: the row is in the watch buffer as usual
;; and the call site simply never gets an inline value. So the two-segment
;; heads are pinned here beside the one-segment ones, or the next widening of
;; this default has nothing to fail against.
(let ((buf (test-flan-watch--buffer
(concat "(defn step [] i64\n"
" (watch \"bare\" x)\n"
" (watch-i64 \"ticks\" ticks)\n"
" (watch-f64 \"hp\" hp)\n"
" (watch-str \"label\" label)\n"
" (watch-num-i64 \"cell\" (at grid i))\n"
" (watch-num-f64 \"dt\" dt))\n"))))
(with-current-buffer buf
(let ((sites (flan-watch--ghost-sites)))
(test-flan--check
"a two-segment head is a site: the accumulator gets inline text too"
(equal (assoc "cell" sites)
(cons "cell" (save-excursion (goto-char (point-min))
(line-end-position 6)))))
(test-flan--check
"and every head the default documents is found, in buffer order"
(equal (mapcar #'car sites)
'("bare" "ticks" "hp" "label" "cell" "dt"))))))
;; --- Matching a row to a site ---------------------------------------------
;;
;; The runtime holds 31 bytes of a name, so a longer one in the source can
;; never match a row exactly. The prefix fallback is guarded on the row being
;; a full 31 bytes, or a short name would claim any site that starts with it.
(let ((long (make-string 40 ?a)))
(test-flan--check
"a name the runtime clipped at 31 bytes still matches the site that wrote it"
(equal (flan-watch--ghost-row long (list (cons (make-string 31 ?a) "9")))
(cons (make-string 31 ?a) "9")))
(test-flan--check
"and a row short enough not to have been clipped claims only its own site"
(null (flan-watch--ghost-row "hpx" '(("hp" . "1"))))))
;; --- What an overlay says -------------------------------------------------
(test-flan--check "a running value is just the value"
(equal (substring-no-properties
(flan-watch--ghost-text "42" 1 nil))
" => 42"))
(test-flan--check
"two sites of one name each say so, because the table has one slot"
(string-match-p "one slot, 2 sites"
(flan-watch--ghost-text "42" 2 nil)))
(test-flan--check
"and one site says nothing about sites"
(not (string-match-p "sites" (flan-watch--ghost-text "42" 1 nil))))
(test-flan--check
"a stopped program's value says it is the last frame's"
(string-match-p "last frame" (flan-watch--ghost-text "42" 1 t)))
(test-flan--check
"and is drawn in the face that is not the running one"
(eq (get-text-property 0 'face (flan-watch--ghost-text "42" 1 t))
'flan-watch-ghost-stale-face))
;; --- Painting -------------------------------------------------------------
(let ((buf (test-flan-watch--buffer
(concat "(watch-i64 \"ticks\" ticks)\n"
"(watch-i64 \"ticks\" ticks)\n"
"(watch-i64 \"gone\" g)\n")))
(flan-watch--consumers '(ghost))
(flan--stopped nil))
(flan-watch--ghost-paint '(("ticks" . "42")) nil)
(test-flan--check
"a site whose name is in the table gets an overlay, and one that is not does not"
(equal (test-flan-watch--strings)
'(" => 42 one slot, 2 sites" " => 42 one slot, 2 sites")))
(test-flan--check
"each one sits at the end of its own call's line"
(equal (test-flan-watch--starts)
(with-current-buffer buf
(save-excursion (goto-char (point-min))
(list (line-end-position 1) (line-end-position 2))))))
;; The one case worth annotating a site with no row: the value exists and was
;; dropped. The watch buffer's overflow line says the same thing without
;; being able to say which name it happened to.
(flan-watch--ghost-paint '(("ticks" . "42")) t)
(test-flan--check
"with the table full, the site that found no slot is told so rather than left blank"
(equal (nth 2 (test-flan-watch--strings))
" => no slot: the table is full"))
(setq flan--stopped "BoundsError")
(flan-watch--ghost-paint '(("ticks" . "42")) nil)
(test-flan--check
"stopping the program marks every overlay, not the buffer's modeline only"
(equal (test-flan-watch--strings)
'(" => 42 one slot, 2 sites last frame"
" => 42 one slot, 2 sites last frame")))
(setq flan--stopped nil)
;; The whole answer to invalidating an overlay whose line moved: no overlay
;; outlives a repaint, so there is nothing to invalidate.
(with-current-buffer buf
(let ((inhibit-read-only t))
(save-excursion (goto-char (point-min)) (insert "\n\n"))))
(flan-watch--ghost-paint '(("ticks" . "42")) nil)
(test-flan--check
"editing the buffer strands nothing: a repaint replaces every overlay"
(equal (test-flan-watch--starts)
(with-current-buffer buf
(save-excursion (goto-char (point-min))
(list (line-end-position 3) (line-end-position 4))))))
;; A refusal clears them. Inline there is nowhere to print an error, so a
;; stale number beside live code would be the whole picture and it would lie.
(flan-watch--absorb '(:status "error" :message "no"))
(test-flan--check "a refused reply takes the overlays with it"
(null flan-watch--ghost-overlays))
(test-flan--check "and leaves none behind in the buffer"
(with-current-buffer buf
(null (overlays-in (point-min) (point-max))))))
;; --- The subscription -----------------------------------------------------
;;
;; The buffer used to be the subscription, so killing it disarmed the table.
;; With two consumers that is wrong: closing one must leave the other armed,
;; and only the last one out turns the lights off.
(let ((flan-watch--consumers '(buffer ghost))
(torn nil))
(cl-letf (((symbol-function 'flan-watch-stop) (lambda () (setq torn t))))
(flan-watch--drop 'buffer)
(test-flan--check "closing the watch buffer leaves ghost text armed"
(and (equal flan-watch--consumers '(ghost)) (not torn)))
(flan-watch--drop 'ghost)
(test-flan--check "and the last consumer out disarms the table"
(and (null flan-watch--consumers) torn))))
;; --- The reset is guarded on the stop ------------------------------------
;;
;; `flan-watch--tick' asks for "since you last looked" by sending `:reset t'
;; beside the read. A stopped program takes no samples, so there is no window
;; for a reset to close and none for it to open — and the runtime's lazy clear
;; is what keeps a paused program showing the numbers from the moment you
;; paused it. So the tick must drop `:reset' while stopped and keep reading.
;;
;; Asserted at the level the rest of this file works at: no daemon, no socket.
;; The tick is a function from `flan--stopped' to the form it puts on the
;; wire, and that is the whole claim, so `flan--send' and `process-live-p'
;; are stubs. What this cannot reach is the daemon actually honouring the
;; absent field; `test/test_dev.ml' drives a real program for that.
(defun test-flan-watch--tick-form (stopped)
"The form `flan-watch--tick' sends with the program STOPPED or not."
(let ((sent nil)
(flan--stopped stopped)
(flan--connection 'a-process)
(flan--busy nil)
(flan-watch--pending nil)
;; Not `buffer': that consumer checks for a live watch buffer first and
;; would drop the subscription instead of ticking.
(flan-watch--consumers '(ghost)))
(cl-letf (((symbol-function 'process-live-p) (lambda (_) t))
((symbol-function 'flan--take-reply) (lambda (_) nil))
((symbol-function 'flan--send)
(lambda (_proc form) (setq sent form))))
(flan-watch--tick)
(list sent flan-watch--pending))))
(let ((running (test-flan-watch--tick-form nil))
(stopped (test-flan-watch--tick-form "BoundsError")))
(test-flan--check
"a tick while the program runs asks for the window it is closing"
(equal (nth 0 running) '(:op "watch" :reset t)))
(test-flan--check
"a tick while the program is stopped sends no reset"
(null (plist-get (nth 0 stopped) :reset)))
;; Skipping the tick outright would be worse than resetting: the watch would
;; freeze at whatever it held when the program stopped, and a break loop is
;; exactly when the numbers are being read.
(test-flan--check
"but it still reads the table"
(equal (plist-get (nth 0 stopped) :op) "watch"))
(test-flan--check
"and still has a reply in flight, so the cycle survives the pause"
(and (nth 1 running) (nth 1 stopped))))
(provide 'test-flan-watch)
;;; test-flan-watch.el ends here