;;; 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))))) ;;;; Ecosystem compatibility (kill ring / isearch / fields / read-only) (ert-deftest ekp-region-test-kill-ring-gets-logical-text () "Copying justified text extracts the logical text. CJK justification injects real space characters between glyphs; they must not travel with a kill/yank." (let ((text "中文复制检查内容足够长会断行 with some latin")) (ekp-region-test--with-text text (ekp-justify-region (point-min) (point-max) 20) (should (local-variable-p 'filter-buffer-substring-function)) (should (equal (filter-buffer-substring (point-min) (point-max)) text))))) (ert-deftest ekp-region-test-isearch-sees-logical-text () "The isearch hooks expose the logical text, then restore the layout." (let ((text "跨行搜索的目标短语必须能找到 internationalization word")) (ekp-region-test--with-text text (ekp-justify-region (point-min) (point-max) 20) (let ((justified (buffer-string))) ;; Sanity: layout breaks the phrase apart. (should (> (cl-count ?\n justified) 0)) (ekp-region--isearch-begin) ;; Logical view: the full phrase and the long word are findable. (goto-char (point-min)) (should (search-forward "目标短语必须能找到" nil t)) (goto-char (point-min)) (should (search-forward "internationalization" nil t)) (ekp-region--isearch-end) ;; Layout restored byte-identically. (should (equal-including-properties (buffer-string) justified)))))) (ert-deftest ekp-region-test-field-paragraph-skipped () "Paragraphs containing field or read-only text stay verbatim." (let* ((prompt (propertize "shell> " 'field 'output)) (text (concat prompt "command output here\n" "prose paragraph long enough to wrap around"))) (ekp-region-test--with-text text (ekp-justify-region (point-min) (point-max) 15) (goto-char (point-min)) (should (search-forward "command output here" nil t))))) (ert-deftest ekp-region-test-read-only-command-barfs () "Interactive justify on a read-only buffer signals, not corrupts." (ekp-region-test--with-text "read only 检查内容" (set-mark (point-min)) (goto-char (point-max)) (activate-mark) (read-only-mode 1) (should-error (call-interactively #'ekp-justify-region) :type 'buffer-read-only))) ;;;; Commands and mode integration (ert-deftest ekp-region-test-justify-buffer-roundtrip () "ekp-justify-buffer / ekp-unjustify-buffer cover the whole buffer." (let ((text "第一段内容足够长断行\n\n第二段 also long enough to wrap")) (ekp-region-test--with-text text (ekp-justify-buffer 25) (should (get-text-property (point-min) 'ekp-justified)) (ekp-unjustify-buffer) (should (equal-including-properties (buffer-string) text))))) (ert-deftest ekp-region-test-justify-dwim-paragraph () "Without an active region, the commands act on the paragraph at point." (ekp-region-test--with-text "para one short\npara two 目标段落内容足够长会断行几次\npara three" (goto-char (point-min)) (search-forward "目标") (cl-letf (((symbol-function 'ekp-region--window-pixel) (lambda (&optional _) 20))) (call-interactively #'ekp-justify-region)) ;; Only paragraph two is justified. (goto-char (point-min)) (should-not (get-text-property (point) 'ekp-justified)) (search-forward "目标") (should (get-text-property (match-beginning 0) 'ekp-justified)) (goto-char (point-max)) (should-not (get-text-property (1- (point)) 'ekp-justified)) ;; And unjustify DWIM restores just as well. (goto-char (point-min)) (search-forward "目标") (call-interactively #'ekp-unjustify-region) (should (equal (buffer-string) "para one short\npara two 目标段落内容足够长会断行几次\npara three")))) (ert-deftest ekp-region-test-refill-paragraph () "`ekp-refill-paragraph' re-justifies the paragraph at point." (ekp-region-test--with-text "refill 检查内容足够长会断行几次的样子\nsecond para" (goto-char (point-min)) (cl-letf (((symbol-function 'ekp-region--window-pixel) (lambda (&optional _) 20))) (ekp-refill-paragraph)) (should (get-text-property (point-min) 'ekp-justified)) (goto-char (point-max)) (should-not (get-text-property (1- (point)) 'ekp-justified)))) (ert-deftest ekp-region-test-markdown-setup () "ekp-markdown-setup stops font-lock from managing `display'." (with-temp-buffer (setq-local font-lock-extra-managed-props '(display composition)) (ekp-markdown-setup) (should (equal font-lock-extra-managed-props '(composition))) (should (equal ekp-region-skip-faces ekp-region-markdown-skip-faces)))) (ert-deftest ekp-region-test-org-auto-preset () "Enabling the mode in an Org buffer applies the Org skip preset." (with-temp-buffer (org-mode) (insert "普通正文段落内容足够长断行几次的样子") (cl-letf (((symbol-function 'ekp-region--window-pixel) (lambda (&optional _) 100))) (ekp-auto-justify-mode 1) (unwind-protect (should (equal ekp-region-skip-faces ekp-region-org-skip-faces)) (ekp-auto-justify-mode -1))))) (provide 'ekp-region-tests) ;;; ekp-region-tests.el ends here