diff --git a/ai-code-agile.el b/ai-code-agile.el index 5b11a2af..6072f748 100644 --- a/ai-code-agile.el +++ b/ai-code-agile.el @@ -28,6 +28,7 @@ (declare-function ai-code--scope-context-for-region "ai-code-utils" (beg end)) (declare-function ai-code--format-scope-context "ai-code-utils" (context)) (declare-function ai-code--git-root "ai-code-utils" (&optional dir)) +(declare-function ai-code-derive-topic-unit-tests "ai-code-doc" ()) (declare-function dired-current-directory "dired" ()) (declare-function dired-get-filename "dired" (&optional localp no-error-if-not-filep)) (declare-function dired-get-marked-files "dired" @@ -1001,6 +1002,7 @@ Works with both source code and test files that have been added to ai-code." (if (derived-mode-p 'magit-mode) (ai-code-magit-prompt 'tests) ;; DONE: use-write-test-stage should also support selected region. If there is selected region, we can use it as the context for writing a test. If there is no selected region, we can use the current function name as the context for writing a test. + ;; DONE: ai-code-derive-topic-unit-tests should be added as the last option of C-c a t, purpose is to derive the test code as tutorial and learning material for the user for given topic. (let* ((region-active (region-active-p)) (scope-context (if region-active @@ -1033,7 +1035,8 @@ Works with both source code and test files that have been added to ai-code." "2. Green (Make test pass)" "3. Blue (Refactor, improve code quality)" "4. Red + Green (One prompt)" - "5. Red + Green + Blue (One prompt)") + "5. Red + Green + Blue (One prompt)" + "6. Learning tests (Derive unit tests as a tutorial for a topic)") nil t)) (stage-num (string-to-number (substring cycle-stage 0 1)))) (cond @@ -1051,7 +1054,9 @@ Works with both source code and test files that have been added to ai-code." ;; Red + Green combined in one prompt ((= stage-num 4) (ai-code--tdd-red-green-stage function-name)) ;; Red + Green + Blue combined in one prompt - ((= stage-num 5) (ai-code--tdd-red-green-blue-stage function-name)))))) + ((= stage-num 5) (ai-code--tdd-red-green-blue-stage function-name)) + ;; Learning tests - derive unit tests as a tutorial for a topic + ((= stage-num 6) (ai-code-derive-topic-unit-tests)))))) (provide 'ai-code-agile) diff --git a/ai-code-doc.el b/ai-code-doc.el index bc44e37d..94584adb 100644 --- a/ai-code-doc.el +++ b/ai-code-doc.el @@ -71,12 +71,31 @@ Default value is English." "Prompt for the language and append it to BASE-PROMPT." (concat base-prompt (format "\nGenerate the document in %s." (ai-code--read-document-language)))) +(defun ai-code--topic-at-point () + "Return the function or class at point as a topic, or nil. +Only `prog-mode' buffers offer one, formatted as \"Service.run +\(src/Service.java)\" with the file relative to the repository root. +The file part is omitted when the buffer visits no file." + (when (derived-mode-p 'prog-mode) + (when-let* ((scope (ai-code--current-qualified-scope-name))) + (if buffer-file-name + (format "%s (%s)" scope + (file-relative-name buffer-file-name (ai-code--git-root))) + scope)))) + (defun ai-code--read-document-topic () "Ask which topic the document should cover. -Return nil for an empty answer, which means the whole repository." - (let ((topic (string-trim - (read-string "Document topic (empty for whole repo): ")))) - (unless (string-empty-p topic) topic))) +Return nil for an empty answer, which means the whole repository. +When `ai-code--topic-at-point' offers a topic, first ask whether to scope +the document to it: declining means the whole repository, and accepting +pre-fills the topic for editing." + (let ((at-point (ai-code--topic-at-point))) + (when (or (null at-point) + (y-or-n-p (format "Scope document to \"%s\"? " at-point))) + (let ((topic (string-trim + (read-string "Document topic (empty for whole repo): " + at-point)))) + (unless (string-empty-p topic) topic))))) (defun ai-code--topic-file-name (file-name topic) "Return FILE-NAME with a slug and stable digest of TOPIC in its base name. @@ -390,8 +409,10 @@ selects how code references are linked." (defun ai-code--read-unit-test-topic () "Read the topic the derived unit tests must explain. An empty topic leaves the backend nothing to teach, so it is rejected -instead of falling back to the whole repository." - (let ((topic (string-trim (read-string "Unit test topic: ")))) +instead of falling back to the whole repository. The topic is pre-filled +from `ai-code--topic-at-point'." + (let ((topic (string-trim (read-string "Unit test topic: " + (ai-code--topic-at-point))))) (if (string-empty-p topic) (user-error "A topic is required to derive unit tests") topic))) @@ -589,6 +610,7 @@ not already exist, so the backend has a concrete document to create or update." Unlike the other derivation commands this one produces test code beside the repository's own tests, so a topic is required and no Org document is created." + ;; DONE: If the current buffer is a prog-mode derived buffer (eg. java-mode, or java-ts-mode), we could pre-fill the topic with context under cursor, eg. a function, or a class. (interactive) (let* ((git-root (or (ai-code--git-root) (user-error "Not inside a Git repository"))) diff --git a/test/test_ai-code-agile.el b/test/test_ai-code-agile.el index c1d5530d..7c50caa0 100644 --- a/test/test_ai-code-agile.el +++ b/test/test_ai-code-agile.el @@ -164,6 +164,23 @@ (ai-code-tdd-cycle) (should (equal called-function-name "my-function")))))) +(ert-deftest ai-code-test-tdd-cycle-last-choice-derives-learning-tests () + "Verify the last TDD stage derives learning unit tests for a topic." + (with-temp-buffer + (emacs-lisp-mode) + (let (captured-choices called) + (cl-letf (((symbol-function 'ai-code--tdd-source-function-context-p) (lambda (_) nil)) + ((symbol-function 'completing-read) + (lambda (_prompt collection &rest _) + (setq captured-choices collection) + (car (last collection)))) + ((symbol-function 'ai-code-derive-topic-unit-tests) + (lambda () (setq called t)))) + (ai-code-tdd-cycle) + (should (equal (car (last captured-choices)) + "6. Learning tests (Derive unit tests as a tutorial for a topic)")) + (should called))))) + (ert-deftest ai-code-test-run-test-always-delegates-to-ai-assisted-runner () "Verify `ai-code-run-test' always delegates to the AI-assisted test runner." (dolist (mode-setup '(emacs-lisp-mode diff --git a/test/test_ai-code-doc.el b/test/test_ai-code-doc.el index f3f2fea8..80c14cd3 100644 --- a/test/test_ai-code-doc.el +++ b/test/test_ai-code-doc.el @@ -741,6 +741,69 @@ corrupted." (lambda (&optional _dir) nil))) (should-error (ai-code-derive-topic-unit-tests) :type 'user-error))) +(ert-deftest ai-code-test-read-document-topic-offers-scope-at-point () + "A `prog-mode' buffer offers the scope at point with its repo-relative file. +Accepting pre-fills the editable topic; declining means the whole +repository without asking for a topic." + (ai-code-file-with-test-env + (with-temp-buffer + (prog-mode) + (setq buffer-file-name (expand-file-name "src/Service.java" default-directory)) + (let ((answer t) + asked + initial-input) + (cl-letf (((symbol-function 'ai-code--git-root) + (lambda (&optional _dir) default-directory)) + ((symbol-function 'ai-code--current-qualified-scope-name) + (lambda () "Service.run")) + ((symbol-function 'y-or-n-p) + (lambda (prompt) (setq asked prompt) answer)) + ((symbol-function 'read-string) + (lambda (_prompt &optional initial &rest _) + (setq initial-input initial) + (concat initial " retry")))) + (should (equal (ai-code--read-document-topic) + "Service.run (src/Service.java) retry")) + (should (string-match-p (regexp-quote "\"Service.run (src/Service.java)\"") + asked)) + (should (equal initial-input "Service.run (src/Service.java)")) + (setq answer nil + initial-input 'unread) + (should-not (ai-code--read-document-topic)) + (should (eq initial-input 'unread))))))) + +(ert-deftest ai-code-test-read-document-topic-offers-nothing-outside-prog-mode () + "Outside `prog-mode' the topic is asked blank, without a y/n question." + (with-temp-buffer + (let ((initial-input 'unread)) + (cl-letf (((symbol-function 'ai-code--current-qualified-scope-name) + (lambda () "Service.run")) + ((symbol-function 'y-or-n-p) + (lambda (&rest _) (error "Unexpected y-or-n-p"))) + ((symbol-function 'read-string) + (lambda (_prompt &optional initial &rest _) + (setq initial-input initial) + ""))) + (should-not (ai-code--read-document-topic)) + (should-not initial-input))))) + +(ert-deftest ai-code-test-read-unit-test-topic-prefills-scope-at-point () + "The required unit test topic is pre-filled without a y/n question. +A buffer that visits no file offers the scope name alone." + (with-temp-buffer + (prog-mode) + (let (initial-input) + (cl-letf (((symbol-function 'ai-code--current-qualified-scope-name) + (lambda () "Service")) + ((symbol-function 'y-or-n-p) + (lambda (&rest _) (error "Unexpected y-or-n-p"))) + ((symbol-function 'read-string) + (lambda (_prompt &optional initial &rest _) + (setq initial-input initial) + initial))) + (should (equal (ai-code--read-unit-test-topic) "Service")) + (should (equal initial-input "Service")))))) + (provide 'test_ai-code-doc) ;;; test_ai-code-doc.el ends here ;;; test_ai-code-doc.el ends here