ekp/tests/ekp-region-tests.el
Kinneyzhang ede518f10f fix: editor-state integrity — saving, stickiness, modified flag, undo
Justification is a re-layout of the same logical text; it now behaves
that way toward the rest of the editor:

- saving a justified buffer writes the LOGICAL text: before-save-hook
  unjustifies (spans remembered as markers), after-save-hook restores
  the justified view; the deterministic pair stays off undo history.
  Previously soft newlines and break hyphens were silently persisted
  to disk and the original whitespace was unrecoverable after reopen.
- renderer marker properties (ekp-glue/soft-break/soft-hyphen/hidden/
  justified) are registered in text-property-default-nonsticky: text
  typed after a glue no longer inherits the marker and is no longer
  deleted as a synthesized space by the next re-flow.
- pure re-layout preserves buffer-modified-p when it was nil: no more
  lock files, auto-saves and "buffer modified" prompts from merely
  enabling ekp-auto-justify-mode.
- changes applied by undo are not re-dirtied (no timer war against
  the user's undo sequence).
- switching major modes tears justified state down cleanly
  (change-major-mode-hook), widening first so narrowing never leaves
  justified orphans.

5 new ERT tests (72 total).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 01:00:27 +08:00

396 lines
18 KiB
EmacsLisp

;;; ekp-region-tests.el --- Tests for ekp-region.el -*- lexical-binding: t; -*-
;;; Commentary:
;; Batch-safe ERT tests for the buffer-level justification layer.
;; Widths are always passed explicitly, so no window is required.
;;; Code:
(require 'ert)
(require 'ekp-region)
(defconst ekp-region-test--samples
(list "简单的中文段落测试内容,排版效果应当良好稳定。"
"The quick brown fox jumps over the lazy dog several times today."
"Mixed 中英文 paragraph with double spaces inside and a tail "
"para one\n\npara two 混排 content here\nthird para"
" leading indent 段落内容 preserved intact"
"\n\n\n多个空段落之间的内容")
"Logical texts covering CJK, Latin, mixed, blanks, indent, tails.")
(defconst ekp-region-test--widths '(30 80 200 400)
"Pixel widths from emergency-narrow to comfortable.")
(defmacro ekp-region-test--with-text (text &rest body)
"Run BODY in a temp buffer containing TEXT."
(declare (indent 1))
`(with-temp-buffer
(insert ,text)
,@body))
;;;; Roundtrip exactness
(ert-deftest ekp-region-test-roundtrip-exact ()
"justify + unjustify restores text and properties exactly."
(dolist (text ekp-region-test--samples)
(dolist (w ekp-region-test--widths)
(ekp-region-test--with-text text
(ekp-justify-region (point-min) (point-max) w)
(ekp-unjustify-region (point-min) (point-max))
(should (equal-including-properties (buffer-string) text))))))
(ert-deftest ekp-region-test-roundtrip-propertized ()
"Roundtrip preserves user text properties."
(let ((text (concat (propertize "加粗的中文开头内容" 'face 'bold)
" plain middle part "
(propertize "italic tail words" 'face 'italic))))
(dolist (w '(60 250))
(ekp-region-test--with-text text
(ekp-justify-region (point-min) (point-max) w)
(ekp-unjustify-region (point-min) (point-max))
(should (equal-including-properties (buffer-string) text))))))
(ert-deftest ekp-region-test-hard-newlines-preserved ()
"Hard newline count survives justification."
(ekp-region-test--with-text "a 段落 one\n\nb 段落 two\nc 段落 three"
(ekp-justify-region (point-min) (point-max) 100)
(let ((hard 0))
(goto-char (point-min))
(while (search-forward "\n" nil t)
(unless (get-text-property (match-beginning 0) 'ekp-soft-break)
(setq hard (1+ hard))))
(should (= hard 3)))))
;;;; Justified-state invariants
(ert-deftest ekp-region-test-justified-marked ()
"Justified region carries the ekp-justified width property."
(ekp-region-test--with-text "中文内容需要标记属性验证正确性"
(ekp-justify-region (point-min) (point-max) 120)
(should (eq (get-text-property (point-min) 'ekp-justified) 120))
(should-not (text-property-not-all (point-min) (point-max)
'ekp-justified 120))))
(ert-deftest ekp-region-test-rejustify-idempotent ()
"Justifying at a new width equals a fresh justification at that width."
(let ((text "The idempotence check 中英混排 must hold across widths."))
(let (fresh)
(ekp-region-test--with-text text
(ekp-justify-region (point-min) (point-max) 150)
(setq fresh (buffer-string)))
(ekp-region-test--with-text text
(ekp-justify-region (point-min) (point-max) 300)
(ekp-justify-region (point-min) (point-max) 150)
(should (equal-including-properties (buffer-string) fresh))))))
;;;; Edit robustness
(ert-deftest ekp-region-test-edit-then-unjustify ()
"Text typed into a justified buffer survives unjustification."
(ekp-region-test--with-text "abcdef ghijkl 中文内容 mnopqr stuvwx"
(ekp-justify-region (point-min) (point-max) 80)
;; Insert inside the first word: physical == logical there.
(goto-char (+ (point-min) 2))
(insert "XY")
(ekp-unjustify-region (point-min) (point-max))
(should (equal (buffer-string)
"abXYcdef ghijkl 中文内容 mnopqr stuvwx"))))
(ert-deftest ekp-region-test-point-stable ()
"Point returns to its logical position after a roundtrip."
(ekp-region-test--with-text "abcdef ghijkl mnopqr stuvwx yzabcd"
(goto-char (+ (point-min) 9)) ; inside "ghijkl"
(ekp-justify-region (point-min) (point-max) 60)
(ekp-unjustify-region (point-min) (point-max))
(should (= (point) (+ (point-min) 9)))))
;;;; Auto-justify mode
(defmacro ekp-region-test--with-mode (text width &rest body)
"Enable `ekp-auto-justify-mode' on TEXT at WIDTH, run BODY, disable."
(declare (indent 2))
`(ekp-region-test--with-text ,text
(cl-letf (((symbol-function 'ekp-region--window-pixel)
(lambda (&optional _) ,width)))
(ekp-auto-justify-mode 1)
(unwind-protect
(progn ,@body)
(ekp-auto-justify-mode -1)))))
(ert-deftest ekp-region-test-mode-roundtrip ()
"Enabling then disabling the mode restores the buffer exactly."
(let ((text "first paragraph 内容 aaa bbb ccc\nsecond paragraph 内容 ddd"))
(ekp-region-test--with-mode text 150
(should ekp-region--auto-width)
(should (get-text-property (point-min) 'ekp-justified)))
;; body ran; with-mode disabled the mode on exit — verify restore
(ekp-region-test--with-text text
(cl-letf (((symbol-function 'ekp-region--window-pixel)
(lambda (&optional _) 150)))
(ekp-auto-justify-mode 1)
(ekp-auto-justify-mode -1)
(should (equal-including-properties (buffer-string) text))))))
(ert-deftest ekp-region-test-mode-incremental-edit ()
"Edits re-justify only the touched paragraph, content stays correct."
(let ((text "aaa bbb ccc ddd eee fff\nggg hhh iii jjj kkk lll")
(calls nil))
(ekp-region-test--with-mode text 100
(let ((orig (symbol-function 'ekp-justify-region)))
(cl-letf (((symbol-function 'ekp-justify-region)
(lambda (b e &optional px)
(push (cons (marker-position (copy-marker b))
(marker-position (copy-marker e)))
calls)
(funcall orig b e px))))
;; Edit inside paragraph 1.
(goto-char (+ (point-min) 4))
(insert "zz")
(should ekp-region--dirty)
(ekp-region--flush-dirty (current-buffer))
;; Exactly one incremental call, confined before the hard \n.
(should (= (length calls) 1))
(let ((hard-nl (save-excursion
(goto-char (point-min))
(catch 'nl
(while (search-forward "\n" nil t)
(unless (get-text-property (match-beginning 0)
'ekp-soft-break)
(throw 'nl (match-beginning 0))))))))
(should (<= (cdar calls) hard-nl)))))
;; Logical text after disable = original with the edit applied.
(ekp-auto-justify-mode -1)
(should (equal (buffer-string)
"aaa zzbbb ccc ddd eee fff\nggg hhh iii jjj kkk lll"))
;; re-enable so with-mode's cleanup disable is a no-op state-wise
(ekp-auto-justify-mode 1))))
(ert-deftest ekp-region-test-verbatim-paragraph-skipped ()
"A code-block paragraph stays byte-identical; prose around it justifies."
(let* ((code (propertize "(defun foo (x) (list 1 2))"
'ekp-verbatim t 'face 'font-lock-keyword-face))
(text (concat "prose before with words enough to wrap lines\n"
code
"\nprose after also long enough to wrap lines")))
(ekp-region-test--with-text text
(ekp-justify-region (point-min) (point-max) 20)
;; the code line is still there, character-exact, spacing intact
(goto-char (point-min))
(should (search-forward "(defun foo (x) (list 1 2))" nil t))
;; prose got justified (soft breaks appeared)
(should (text-property-not-all (point-min) (point-max)
'ekp-soft-break nil))
(ekp-unjustify-region (point-min) (point-max))
(should (equal-including-properties (buffer-string) text)))))
(ert-deftest ekp-region-test-skip-faces ()
"Paragraphs wearing a skip face stay verbatim."
(let* ((ekp-region-skip-faces '(font-lock-comment-face))
(code (propertize ";; a comment line kept as-is"
'face 'font-lock-comment-face))
(text (concat "prose paragraph long enough to wrap\n" code)))
(ekp-region-test--with-text text
(ekp-justify-region (point-min) (point-max) 15)
(goto-char (point-min))
(should (search-forward ";; a comment line kept as-is" nil t))
(ekp-unjustify-region (point-min) (point-max))
(should (equal-including-properties (buffer-string) text)))))
(ert-deftest ekp-region-test-skip-predicate ()
"The paragraph predicate is the general escape hatch."
(let ((text "keepme raw spacing\nnormal prose that wraps around"))
(ekp-region-test--with-text text
(setq-local ekp-region-skip-predicate
(lambda (p) (string-prefix-p "keepme" p)))
(ekp-justify-region (point-min) (point-max) 12)
(goto-char (point-min))
(should (looking-at-p "keepme raw spacing$"))
(ekp-unjustify-region (point-min) (point-max))
(should (equal (buffer-string) text)))))
(ert-deftest ekp-region-test-indent-roundtrip ()
"First-line indent spacers vanish exactly on unjustify."
(let ((ekp-first-line-indent 6)
(text "首行缩进往返检查内容足够长断行几次"))
(ekp-region-test--with-text text
(ekp-justify-region (point-min) (point-max) 30)
(ekp-unjustify-region (point-min) (point-max))
(should (equal-including-properties (buffer-string) text)))))
(ert-deftest ekp-region-test-resize-hook-window-arg ()
"The resize hook handles its WINDOW argument and foreign current buffer.
Regression: buffer-local `window-size-change-functions' members get
the displaying WINDOW, with an arbitrary buffer current."
(let ((text "resize hook 检查 aaa bbb ccc ddd eee fff"))
(ekp-region-test--with-mode text 200
(let ((buf (current-buffer))
(win (selected-window)))
(set-window-buffer win buf)
(cl-letf (((symbol-function 'ekp-region--window-pixel)
(lambda (&optional _) 120)))
;; simulate redisplay: window argument, unrelated buffer current
(with-temp-buffer
(ekp-region--on-resize win)))
(with-current-buffer buf
(should (timerp ekp-region--resize-timer))
(cancel-timer ekp-region--resize-timer)
;; run what the timer would have run
(ekp-region--reflow buf 120)
(should (= ekp-region--auto-width 120)))))))
(ert-deftest ekp-region-test-mode-reflow-width ()
"Reflow to a new width matches a fresh justification at that width."
(let ((text "reflow 检查 aaa bbb ccc ddd eee fff ggg hhh")
fresh)
(ekp-region-test--with-text text
(ekp-justify-region (point-min) (point-max) 90)
(setq fresh (buffer-substring (point-min) (point-max))))
(ekp-region-test--with-mode text 200
(ekp-region--reflow (current-buffer) 90)
(should (= ekp-region--auto-width 90))
(let ((got (buffer-substring (point-min) (point-max))))
;; ekp-justified was written at two widths; ignore that prop
(remove-text-properties 0 (length got) '(ekp-justified nil) got)
(remove-text-properties 0 (length fresh) '(ekp-justified nil) fresh)
(should (equal-including-properties got fresh))))))
(ert-deftest ekp-region-test-lazy-reflow-equals-oneshot ()
"Visible-first chunked re-flow converges to the one-shot result."
(let ((ekp-auto-justify-lazy-threshold 100)
(ekp-auto-justify-chunk-size 3)
(text (mapconcat #'identity
(make-list 12 "段落内容 some words 足够长会换行的样子")
"\n")))
(ekp-region-test--with-text text
(cl-letf (((symbol-function 'ekp-region--window-pixel)
(lambda (&optional _) 60))
((symbol-function 'ekp-region--visible-span)
(lambda () (cons (point-min) (min (point-max) 80)))))
(ekp-auto-justify-mode 1)
(ekp-region--reflow (current-buffer) 50)
(should ekp-region--pending)
;; drain the background queue synchronously
(let ((guard 0))
(while (and ekp-region--pending (< guard 100))
(when (timerp ekp-region--chunk-timer)
(cancel-timer ekp-region--chunk-timer)
(setq ekp-region--chunk-timer nil))
(ekp-region--process-chunk (current-buffer))
(setq guard (1+ guard))))
(should-not ekp-region--pending)
(let ((lazy (buffer-string)))
(ekp-auto-justify-mode -1)
(ekp-justify-region (point-min) (point-max) 50)
(should (equal-including-properties (buffer-string) lazy)))))))
;;;; Editor-state integrity (save / modified / undo / stickiness)
(ert-deftest ekp-region-test-typed-char-inherits-no-marker ()
"Text typed right after a glue must not inherit renderer markers.
Regression: `self-insert-command' uses insert-and-inherit; a char
inheriting `ekp-glue' was deleted as a synthesized space by the next
unjustification."
(ekp-region-test--with-text "aaa bbb 中文 ccc"
(ekp-justify-region (point-min) (point-max) 200)
(let ((glue-pos (text-property-not-all (point-min) (point-max)
'ekp-glue nil)))
(should glue-pos)
(goto-char (1+ glue-pos))
(insert-and-inherit "X")
(let ((x (1+ glue-pos)))
(should-not (get-text-property x 'ekp-glue))
(should-not (get-text-property x 'display))
(should-not (get-text-property x 'ekp-soft-break))))
(ekp-unjustify-region (point-min) (point-max))
(should (= 1 (cl-count ?X (buffer-string))))))
(ert-deftest ekp-region-test-save-writes-logical-text ()
"Saving a justified file buffer writes the logical text to disk,
keeps the buffer justified, and leaves it unmodified."
(let* ((file (make-temp-file "ekp-save-test"))
(text "中文保存测试内容足够长会断行的样子,再加一句凑长度。")
(make-backup-files nil)
(create-lockfiles nil))
(unwind-protect
(with-current-buffer (find-file-noselect file)
(insert text)
(ekp-justify-region (point-min) (point-max) 20)
(should (> (cl-count ?\n (buffer-string)) 0))
(save-buffer)
;; Disk: logical text only, no layout newlines.
(should (equal (with-temp-buffer
(insert-file-contents file)
(buffer-string))
text))
;; Buffer: still justified, and not "modified" vs its file.
(should (get-text-property (point-min) 'ekp-justified))
(should-not (buffer-modified-p))
;; And a second save still works (state was reset).
(insert "x")
(goto-char (point-min))
(save-buffer)
(should (equal (with-temp-buffer
(insert-file-contents file)
(buffer-string))
(concat text "x")))
(let ((kill-buffer-query-functions nil))
(kill-buffer)))
(delete-file file))))
(ert-deftest ekp-region-test-justify-preserves-unmodified ()
"Pure re-layout must not flip `buffer-modified-p'."
(let* ((file (make-temp-file "ekp-mod-test"))
(make-backup-files nil)
(create-lockfiles nil))
(unwind-protect
(with-current-buffer (find-file-noselect file)
(insert "modified 标志保持检查内容足够长断行")
(save-buffer)
(should-not (buffer-modified-p))
(ekp-justify-region (point-min) (point-max) 30)
(should-not (buffer-modified-p))
(ekp-unjustify-region (point-min) (point-max))
(should-not (buffer-modified-p))
;; A real edit still marks the buffer modified.
(insert "y")
(should (buffer-modified-p))
(let ((kill-buffer-query-functions nil))
(set-buffer-modified-p nil)
(kill-buffer)))
(delete-file file))))
(ert-deftest ekp-region-test-undo-changes-not-redirtied ()
"Changes applied by undo must not schedule a re-flow."
(ekp-region-test--with-mode "undo guard 检查内容 aaa bbb ccc" 100
(setq ekp-region--dirty nil)
(let ((undo-in-progress t))
(ekp-region--after-change (point-min) (1+ (point-min)) 0))
(should-not ekp-region--dirty)
(let ((undo-in-progress nil))
(ekp-region--after-change (point-min) (1+ (point-min)) 0))
(should ekp-region--dirty)
(dolist (p ekp-region--dirty)
(set-marker (car p) nil)
(set-marker (cdr p) nil))
(setq ekp-region--dirty nil)
(when (timerp ekp-region--edit-timer)
(cancel-timer ekp-region--edit-timer))))
(ert-deftest ekp-region-test-major-mode-change-restores ()
"Switching major mode tears the justified state down cleanly."
(let ((text "major mode 切换检查 aaa bbb ccc ddd"))
(ekp-region-test--with-text text
(cl-letf (((symbol-function 'ekp-region--window-pixel)
(lambda (&optional _) 80)))
(ekp-auto-justify-mode 1)
(should (get-text-property (point-min) 'ekp-justified))
(fundamental-mode)
(should (equal (buffer-string) text))
(should-not ekp-auto-justify-mode)))))
(provide 'ekp-region-tests)
;;; ekp-region-tests.el ends here