aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-04-28 18:10:17 -0500
committerCraig Jennings <c@cjennings.net>2026-04-28 19:09:22 -0500
commitdc0db0f0e12d8af6d1d54a5dde1cd16cf890a33d (patch)
tree9d174e136d62981c1bded06fe423d08bddc91e04 /tests
parent3a846506399dc12ab219bfa8047947c122dd1d04 (diff)
downloadgloss-dc0db0f0e12d8af6d1d54a5dde1cd16cf890a33d.tar.gz
gloss-dc0db0f0e12d8af6d1d54a5dde1cd16cf890a33d.zip
test: add gloss-fetch test suite (red phase)
Eight test files cover the network layer's public and internal contract. The boundary mock is `url-retrieve-synchronously', wrapped by a small `testutil-gloss-fetch' helper that builds response buffers in the shape the url library returns. Tests cover the 200 happy paths (anaphora and SBIR fixtures), 404 to :no-defs, 5xx and 4xx-other and malformed JSON to :server-error, 429 to :rate-limited, nil-from-url to :unreachable, the libxml availability probe (one-shot, signals user-error when absent), the registry walker ordering, and the pure HTML strip helper across N/B/E. Tests fail on missing `gloss-fetch--*' functions, as expected for red phase.
Diffstat (limited to 'tests')
-rw-r--r--tests/test-gloss-fetch--definitions-200-returns-ok.el62
-rw-r--r--tests/test-gloss-fetch--definitions-404-returns-no-defs.el48
-rw-r--r--tests/test-gloss-fetch--definitions-429-returns-rate-limited.el43
-rw-r--r--tests/test-gloss-fetch--definitions-500-returns-server-error.el55
-rw-r--r--tests/test-gloss-fetch--definitions-timeout-returns-unreachable.el44
-rw-r--r--tests/test-gloss-fetch--libxml-probe.el62
-rw-r--r--tests/test-gloss-fetch--multi-source-walks-registry.el88
-rw-r--r--tests/test-gloss-fetch--strip-html.el59
-rw-r--r--tests/testutil-gloss-fetch.el48
9 files changed, 509 insertions, 0 deletions
diff --git a/tests/test-gloss-fetch--definitions-200-returns-ok.el b/tests/test-gloss-fetch--definitions-200-returns-ok.el
new file mode 100644
index 0000000..fee997b
--- /dev/null
+++ b/tests/test-gloss-fetch--definitions-200-returns-ok.el
@@ -0,0 +1,62 @@
+;;; test-gloss-fetch--definitions-200-returns-ok.el --- 200 path tests -*- lexical-binding: t -*-
+
+;; SPDX-License-Identifier: GPL-3.0-or-later
+
+;;; Commentary:
+;; Normal/Boundary cases: a 200 response with valid JSON returns
+;; (:ok DEFS) and each def is a plist with :source and :text. Uses the
+;; captured Wiktionary fixtures replayed through a mocked
+;; `url-retrieve-synchronously'.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'gloss-fetch)
+(require 'testutil-gloss)
+(require 'testutil-gloss-fetch)
+
+(ert-deftest test-gloss-fetch-definitions-200-anaphora-returns-ok ()
+ "Normal: anaphora fixture (single English sense) returns (:ok DEFS)."
+ (let ((body (gloss-test--load-wiktionary-fixture "anaphora")))
+ (gloss-fetch-test--with-mocked-url
+ (lambda (_url) (gloss-fetch-test--ok-response body))
+ (let* ((result (gloss-fetch-definitions "anaphora"))
+ (defs (plist-get result :ok)))
+ (should (eq (car result) :ok))
+ (should (consp defs))
+ (should (>= (length defs) 1))
+ (let ((first (car defs)))
+ (should (eq (plist-get first :source) 'wiktionary))
+ (should (stringp (plist-get first :text)))
+ (should (> (length (plist-get first :text)) 0))
+ ;; HTML stripped — no angle brackets in the text.
+ (should-not (string-match-p "<" (plist-get first :text))))))))
+
+(ert-deftest test-gloss-fetch-definitions-200-sbir-returns-multiple-senses ()
+ "Boundary: SBIR fixture has multiple senses; all returned as separate plists."
+ (let ((body (gloss-test--load-wiktionary-fixture "SBIR")))
+ (gloss-fetch-test--with-mocked-url
+ (lambda (_url) (gloss-fetch-test--ok-response body))
+ (let* ((result (gloss-fetch-definitions "SBIR"))
+ (defs (plist-get result :ok)))
+ (should (eq (car result) :ok))
+ (should (>= (length defs) 1))
+ (dolist (d defs)
+ (should (eq (plist-get d :source) 'wiktionary))
+ (should (stringp (plist-get d :text))))))))
+
+(ert-deftest test-gloss-fetch-definitions-200-encodes-spaces-in-url ()
+ "Boundary: a multi-word term URL-encodes the space."
+ (let ((seen-url nil)
+ (body "{}"))
+ (gloss-fetch-test--with-mocked-url
+ (lambda (url)
+ (setq seen-url url)
+ (gloss-fetch-test--ok-response body))
+ (gloss-fetch-definitions "hapax legomenon"))
+ (should seen-url)
+ (should (string-match-p "hapax%20legomenon\\|hapax_legomenon" seen-url))))
+
+(provide 'test-gloss-fetch--definitions-200-returns-ok)
+;;; test-gloss-fetch--definitions-200-returns-ok.el ends here
diff --git a/tests/test-gloss-fetch--definitions-404-returns-no-defs.el b/tests/test-gloss-fetch--definitions-404-returns-no-defs.el
new file mode 100644
index 0000000..d8cd257
--- /dev/null
+++ b/tests/test-gloss-fetch--definitions-404-returns-no-defs.el
@@ -0,0 +1,48 @@
+;;; test-gloss-fetch--definitions-404-returns-no-defs.el --- 404 path -*- lexical-binding: t -*-
+
+;; SPDX-License-Identifier: GPL-3.0-or-later
+
+;;; Commentary:
+;; HTTP 404 from a source maps to :no-defs. When every source returns
+;; :no-defs, the user-facing rollup is :empty with the source listed
+;; under :no-defs and nothing under :failed.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'gloss-fetch)
+(require 'testutil-gloss-fetch)
+
+(ert-deftest test-gloss-fetch-definitions-404-rolls-up-to-empty-no-defs ()
+ "Normal: a 404 from the only source rolls up to (:empty :no-defs (wiktionary) :failed nil)."
+ (gloss-fetch-test--with-mocked-url
+ (lambda (_url)
+ (gloss-fetch-test--status-response "HTTP/1.1 404 Not Found"
+ "{\"detail\":\"Page not found\"}"))
+ (let ((result (gloss-fetch-definitions "asdf-not-a-word")))
+ (should (eq (car result) :empty))
+ (should (member 'wiktionary (plist-get result :no-defs)))
+ (should-not (plist-get result :failed)))))
+
+(ert-deftest test-gloss-fetch-definitions-200-empty-rolls-up-to-empty-no-defs ()
+ "Boundary: a 200 with an empty JSON object also maps to :no-defs."
+ (gloss-fetch-test--with-mocked-url
+ (lambda (_url) (gloss-fetch-test--ok-response "{}"))
+ (let ((result (gloss-fetch-definitions "term")))
+ (should (eq (car result) :empty))
+ (should (member 'wiktionary (plist-get result :no-defs)))
+ (should-not (plist-get result :failed)))))
+
+(ert-deftest test-gloss-fetch-definitions-200-no-english-rolls-up-to-no-defs ()
+ "Boundary: a 200 response with only non-English keys maps to :no-defs."
+ ;; v1 ignores everything but the en key per the design doc.
+ (let ((body "{\"fr\":[{\"partOfSpeech\":\"Noun\",\"language\":\"French\",\"definitions\":[{\"definition\":\"Un mot.\"}]}]}"))
+ (gloss-fetch-test--with-mocked-url
+ (lambda (_url) (gloss-fetch-test--ok-response body))
+ (let ((result (gloss-fetch-definitions "term")))
+ (should (eq (car result) :empty))
+ (should (member 'wiktionary (plist-get result :no-defs)))))))
+
+(provide 'test-gloss-fetch--definitions-404-returns-no-defs)
+;;; test-gloss-fetch--definitions-404-returns-no-defs.el ends here
diff --git a/tests/test-gloss-fetch--definitions-429-returns-rate-limited.el b/tests/test-gloss-fetch--definitions-429-returns-rate-limited.el
new file mode 100644
index 0000000..a5fb719
--- /dev/null
+++ b/tests/test-gloss-fetch--definitions-429-returns-rate-limited.el
@@ -0,0 +1,43 @@
+;;; test-gloss-fetch--definitions-429-returns-rate-limited.el --- 429 path -*- lexical-binding: t -*-
+
+;; SPDX-License-Identifier: GPL-3.0-or-later
+
+;;; Commentary:
+;; HTTP 429 is its own per-source status (`:rate-limited'), separated
+;; from `:server-error' so the v2 user-facing wording can call it out
+;; distinctly. At the rollup it joins :failed.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'gloss-fetch)
+(require 'testutil-gloss-fetch)
+
+(ert-deftest test-gloss-fetch-definitions-429-rolls-up-to-failed ()
+ "Normal: HTTP 429 maps the source to :rate-limited, which joins :failed."
+ (gloss-fetch-test--with-mocked-url
+ (lambda (_url)
+ (gloss-fetch-test--status-response "HTTP/1.1 429 Too Many Requests" ""))
+ (let ((result (gloss-fetch-definitions "anaphora")))
+ (should (eq (car result) :empty))
+ (should (member 'wiktionary (plist-get result :failed)))
+ (should-not (plist-get result :no-defs)))))
+
+(ert-deftest test-gloss-fetch-definitions-429-tracked-separately-internally ()
+ "Boundary: per-source status taxonomy distinguishes :rate-limited from :server-error.
+
+Verifies the internal walker exposes the per-source result so the
+debug log can carry the right tag. Calls
+`gloss-fetch--collect' (the internal entry point that returns the
+per-source result list) and inspects the :status field."
+ (gloss-fetch-test--with-mocked-url
+ (lambda (_url)
+ (gloss-fetch-test--status-response "HTTP/1.1 429 Too Many Requests" ""))
+ (let* ((per-source (gloss-fetch--collect "anaphora"))
+ (entry (car per-source)))
+ (should (eq (plist-get entry :source) 'wiktionary))
+ (should (eq (plist-get entry :status) :rate-limited)))))
+
+(provide 'test-gloss-fetch--definitions-429-returns-rate-limited)
+;;; test-gloss-fetch--definitions-429-returns-rate-limited.el ends here
diff --git a/tests/test-gloss-fetch--definitions-500-returns-server-error.el b/tests/test-gloss-fetch--definitions-500-returns-server-error.el
new file mode 100644
index 0000000..e81efe2
--- /dev/null
+++ b/tests/test-gloss-fetch--definitions-500-returns-server-error.el
@@ -0,0 +1,55 @@
+;;; test-gloss-fetch--definitions-500-returns-server-error.el --- 5xx path -*- lexical-binding: t -*-
+
+;; SPDX-License-Identifier: GPL-3.0-or-later
+
+;;; Commentary:
+;; HTTP 5xx, malformed JSON, schema mismatch, and 4xx other than 404/429
+;; all roll up to :server-error. When every source fails this way, the
+;; user-facing rollup is :empty with the source listed under :failed.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'gloss-fetch)
+(require 'testutil-gloss-fetch)
+
+(ert-deftest test-gloss-fetch-definitions-500-rolls-up-to-failed ()
+ "Normal: HTTP 500 maps the source to :server-error (in :failed)."
+ (gloss-fetch-test--with-mocked-url
+ (lambda (_url)
+ (gloss-fetch-test--status-response "HTTP/1.1 500 Internal Server Error"
+ "Server is sad."))
+ (let ((result (gloss-fetch-definitions "anaphora")))
+ (should (eq (car result) :empty))
+ (should (member 'wiktionary (plist-get result :failed)))
+ (should-not (plist-get result :no-defs)))))
+
+(ert-deftest test-gloss-fetch-definitions-503-rolls-up-to-failed ()
+ "Normal: HTTP 503 maps the source to :server-error (in :failed)."
+ (gloss-fetch-test--with-mocked-url
+ (lambda (_url)
+ (gloss-fetch-test--status-response "HTTP/1.1 503 Service Unavailable" ""))
+ (let ((result (gloss-fetch-definitions "anaphora")))
+ (should (eq (car result) :empty))
+ (should (member 'wiktionary (plist-get result :failed))))))
+
+(ert-deftest test-gloss-fetch-definitions-malformed-json-rolls-up-to-failed ()
+ "Boundary: a 200 with non-JSON body also maps to :server-error."
+ (gloss-fetch-test--with-mocked-url
+ (lambda (_url) (gloss-fetch-test--ok-response "<html>not json</html>"))
+ (let ((result (gloss-fetch-definitions "anaphora")))
+ (should (eq (car result) :empty))
+ (should (member 'wiktionary (plist-get result :failed))))))
+
+(ert-deftest test-gloss-fetch-definitions-400-rolls-up-to-failed ()
+ "Error: HTTP 400 (4xx other than 404/429) maps to :server-error (in :failed)."
+ (gloss-fetch-test--with-mocked-url
+ (lambda (_url)
+ (gloss-fetch-test--status-response "HTTP/1.1 400 Bad Request" ""))
+ (let ((result (gloss-fetch-definitions "anaphora")))
+ (should (eq (car result) :empty))
+ (should (member 'wiktionary (plist-get result :failed))))))
+
+(provide 'test-gloss-fetch--definitions-500-returns-server-error)
+;;; test-gloss-fetch--definitions-500-returns-server-error.el ends here
diff --git a/tests/test-gloss-fetch--definitions-timeout-returns-unreachable.el b/tests/test-gloss-fetch--definitions-timeout-returns-unreachable.el
new file mode 100644
index 0000000..881b783
--- /dev/null
+++ b/tests/test-gloss-fetch--definitions-timeout-returns-unreachable.el
@@ -0,0 +1,44 @@
+;;; test-gloss-fetch--definitions-timeout-returns-unreachable.el --- timeout path -*- lexical-binding: t -*-
+
+;; SPDX-License-Identifier: GPL-3.0-or-later
+
+;;; Commentary:
+;; A timeout (or any other transport-level failure) makes
+;; `url-retrieve-synchronously' return nil; the source maps to
+;; :unreachable, which joins :failed at the rollup.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'gloss-fetch)
+(require 'testutil-gloss-fetch)
+
+(ert-deftest test-gloss-fetch-definitions-timeout-rolls-up-to-failed ()
+ "Normal: nil from url-retrieve-synchronously rolls up to :failed."
+ (gloss-fetch-test--with-mocked-url
+ (lambda (_url) nil)
+ (let ((result (gloss-fetch-definitions "anaphora")))
+ (should (eq (car result) :empty))
+ (should (member 'wiktionary (plist-get result :failed)))
+ (should-not (plist-get result :no-defs)))))
+
+(ert-deftest test-gloss-fetch-definitions-timeout-marks-source-unreachable ()
+ "Boundary: per-source status is :unreachable, distinct from :server-error."
+ (gloss-fetch-test--with-mocked-url
+ (lambda (_url) nil)
+ (let* ((per-source (gloss-fetch--collect "anaphora"))
+ (entry (car per-source)))
+ (should (eq (plist-get entry :source) 'wiktionary))
+ (should (eq (plist-get entry :status) :unreachable)))))
+
+(ert-deftest test-gloss-fetch-definitions-error-signal-marks-source-unreachable ()
+ "Error: a signaled error inside url-retrieve-synchronously also yields :unreachable."
+ (gloss-fetch-test--with-mocked-url
+ (lambda (_url) (error "Connection refused"))
+ (let* ((per-source (gloss-fetch--collect "anaphora"))
+ (entry (car per-source)))
+ (should (eq (plist-get entry :status) :unreachable)))))
+
+(provide 'test-gloss-fetch--definitions-timeout-returns-unreachable)
+;;; test-gloss-fetch--definitions-timeout-returns-unreachable.el ends here
diff --git a/tests/test-gloss-fetch--libxml-probe.el b/tests/test-gloss-fetch--libxml-probe.el
new file mode 100644
index 0000000..830a278
--- /dev/null
+++ b/tests/test-gloss-fetch--libxml-probe.el
@@ -0,0 +1,62 @@
+;;; test-gloss-fetch--libxml-probe.el --- libxml availability probe tests -*- lexical-binding: t -*-
+
+;; SPDX-License-Identifier: GPL-3.0-or-later
+
+;;; Commentary:
+;; libxml is a precondition for online fetch. First call probes once;
+;; absent libxml triggers a one-shot `user-error' and disables online
+;; fetch package-wide for the session. Subsequent attempts in the same
+;; session also signal `user-error'.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'gloss-fetch)
+(require 'testutil-gloss-fetch)
+
+(ert-deftest test-gloss-fetch-libxml-absent-signals-user-error ()
+ "Error: with libxml unavailable, the first fetch signals user-error and disables online."
+ (let ((gloss-fetch--libxml-disabled nil))
+ (cl-letf (((symbol-function 'gloss-fetch--libxml-available-p)
+ (lambda () nil)))
+ (should-error (gloss-fetch-definitions "anything") :type 'user-error)
+ ;; Subsequent attempts also signal — no auto-recovery in the same session.
+ (should-error (gloss-fetch-definitions "again") :type 'user-error))))
+
+(ert-deftest test-gloss-fetch-libxml-error-mentions-libxml2 ()
+ "Error: the user-error message names libxml2 so the user can act."
+ (let ((gloss-fetch--libxml-disabled nil))
+ (cl-letf (((symbol-function 'gloss-fetch--libxml-available-p)
+ (lambda () nil)))
+ (let ((err (should-error (gloss-fetch-definitions "x") :type 'user-error)))
+ (should (string-match-p "libxml2" (error-message-string err)))))))
+
+(ert-deftest test-gloss-fetch-libxml-probe-runs-only-once ()
+ "Boundary: the libxml availability probe is invoked at most once per session."
+ (let ((probe-calls 0)
+ (gloss-fetch--libxml-disabled nil)
+ (gloss-fetch--libxml-checked nil))
+ (cl-letf (((symbol-function 'gloss-fetch--libxml-available-p)
+ (lambda () (cl-incf probe-calls) t)))
+ (gloss-fetch-test--with-mocked-url
+ (lambda (_url) (gloss-fetch-test--ok-response "{}"))
+ (gloss-fetch-definitions "first")
+ (gloss-fetch-definitions "second")
+ (gloss-fetch-definitions "third"))
+ (should (= 1 probe-calls)))))
+
+(ert-deftest test-gloss-fetch-libxml-present-allows-fetch ()
+ "Normal: when libxml is available, fetch proceeds normally."
+ (let ((gloss-fetch--libxml-disabled nil)
+ (gloss-fetch--libxml-checked nil))
+ (cl-letf (((symbol-function 'gloss-fetch--libxml-available-p)
+ (lambda () t)))
+ (gloss-fetch-test--with-mocked-url
+ (lambda (_url) (gloss-fetch-test--ok-response "{}"))
+ (let ((result (gloss-fetch-definitions "term")))
+ (should (eq (car result) :empty))
+ (should (member 'wiktionary (plist-get result :no-defs))))))))
+
+(provide 'test-gloss-fetch--libxml-probe)
+;;; test-gloss-fetch--libxml-probe.el ends here
diff --git a/tests/test-gloss-fetch--multi-source-walks-registry.el b/tests/test-gloss-fetch--multi-source-walks-registry.el
new file mode 100644
index 0000000..fe04e02
--- /dev/null
+++ b/tests/test-gloss-fetch--multi-source-walks-registry.el
@@ -0,0 +1,88 @@
+;;; test-gloss-fetch--multi-source-walks-registry.el --- Registry walk tests -*- lexical-binding: t -*-
+
+;; SPDX-License-Identifier: GPL-3.0-or-later
+
+;;; Commentary:
+;; `gloss-fetch--sources' is an alist (source-symbol → fetcher fn).
+;; `gloss-fetch-sources' is the user-facing defcustom that orders the
+;; walk. Each fetcher returns a per-source result plist. The walker
+;; aggregates results across all configured sources.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+(require 'gloss-fetch)
+
+(ert-deftest test-gloss-fetch-collect-walks-registry-in-defcustom-order ()
+ "Normal: walker visits each source in `gloss-fetch-sources' order, gathers a result per source."
+ (let* ((calls nil)
+ (gloss-fetch--sources
+ `((alpha . ,(lambda (term)
+ (push 'alpha calls)
+ (list :source 'alpha :status :ok :defs
+ (list (list :source 'alpha :text (concat "A:" term))))))
+ (beta . ,(lambda (term)
+ (push 'beta calls)
+ (list :source 'beta :status :no-defs
+ :reason (concat "no entry for " term))))))
+ (gloss-fetch-sources '(alpha beta))
+ (per-source (gloss-fetch--collect "x")))
+ (should (equal (reverse calls) '(alpha beta)))
+ (should (= 2 (length per-source)))
+ (should (eq 'alpha (plist-get (nth 0 per-source) :source)))
+ (should (eq 'beta (plist-get (nth 1 per-source) :source)))))
+
+(ert-deftest test-gloss-fetch-rollup-mixes-no-defs-and-failed ()
+ "Boundary: a mix of :no-defs and :server-error rolls up with both lists populated."
+ (let* ((gloss-fetch--sources
+ `((alpha . ,(lambda (_term)
+ (list :source 'alpha :status :no-defs :reason "404")))
+ (beta . ,(lambda (_term)
+ (list :source 'beta :status :server-error :reason "HTTP 503")))))
+ (gloss-fetch-sources '(alpha beta))
+ (result (gloss-fetch-definitions "x")))
+ (should (eq (car result) :empty))
+ (should (member 'alpha (plist-get result :no-defs)))
+ (should (member 'beta (plist-get result :failed)))))
+
+(ert-deftest test-gloss-fetch-rollup-any-ok-yields-ok ()
+ "Boundary: if any source returns :ok with defs, the rollup is (:ok DEFS)."
+ (let* ((gloss-fetch--sources
+ `((alpha . ,(lambda (_term)
+ (list :source 'alpha :status :no-defs :reason "404")))
+ (beta . ,(lambda (_term)
+ (list :source 'beta :status :ok :defs
+ (list (list :source 'beta :text "got it")))))))
+ (gloss-fetch-sources '(alpha beta))
+ (result (gloss-fetch-definitions "x")))
+ (should (eq (car result) :ok))
+ (should (= 1 (length (plist-get result :ok))))
+ (should (equal "got it" (plist-get (car (plist-get result :ok)) :text)))))
+
+(ert-deftest test-gloss-fetch-collect-skips-source-not-in-defcustom ()
+ "Error: a source registered in `gloss-fetch--sources' but not in `gloss-fetch-sources' is skipped."
+ (let* ((called nil)
+ (gloss-fetch--sources
+ `((alpha . ,(lambda (_term)
+ (setq called t)
+ (list :source 'alpha :status :ok :defs
+ (list (list :source 'alpha :text "x")))))))
+ (gloss-fetch-sources '()) ; empty — skip everything
+ (per-source (gloss-fetch--collect "x")))
+ (should-not called)
+ (should (null per-source))))
+
+(ert-deftest test-gloss-fetch-collect-skips-unknown-source-symbol ()
+ "Error: a symbol in `gloss-fetch-sources' with no registered fetcher is silently skipped."
+ (let* ((gloss-fetch--sources
+ `((alpha . ,(lambda (_term)
+ (list :source 'alpha :status :ok :defs
+ (list (list :source 'alpha :text "x")))))))
+ (gloss-fetch-sources '(zeta alpha))
+ (per-source (gloss-fetch--collect "x")))
+ (should (= 1 (length per-source)))
+ (should (eq 'alpha (plist-get (car per-source) :source)))))
+
+(provide 'test-gloss-fetch--multi-source-walks-registry)
+;;; test-gloss-fetch--multi-source-walks-registry.el ends here
diff --git a/tests/test-gloss-fetch--strip-html.el b/tests/test-gloss-fetch--strip-html.el
new file mode 100644
index 0000000..61f7e70
--- /dev/null
+++ b/tests/test-gloss-fetch--strip-html.el
@@ -0,0 +1,59 @@
+;;; test-gloss-fetch--strip-html.el --- HTML strip helper tests -*- lexical-binding: t -*-
+
+;; SPDX-License-Identifier: GPL-3.0-or-later
+
+;;; Commentary:
+;; `gloss-fetch--strip-html' converts a fragment of HTML to plain text
+;; using `libxml-parse-html-region' (no italic/bold preservation).
+;; Returns nil when libxml fails on the fragment so the caller can drop
+;; that sense.
+
+;;; Code:
+
+(require 'ert)
+(require 'gloss-fetch)
+
+(ert-deftest test-gloss-fetch-strip-html-plain-text-roundtrips ()
+ "Normal: a string with no markup comes back unchanged (modulo whitespace)."
+ (skip-unless (fboundp 'libxml-parse-html-region))
+ (should (equal (gloss-fetch--strip-html "Just plain text.")
+ "Just plain text.")))
+
+(ert-deftest test-gloss-fetch-strip-html-removes-tags ()
+ "Normal: anchor and span tags are stripped, leaving inner text."
+ (skip-unless (fboundp 'libxml-parse-html-region))
+ (let ((stripped (gloss-fetch--strip-html
+ "The <a href=\"/foo\">repetition</a> of a <i>phrase</i>.")))
+ (should (string-match-p "repetition" stripped))
+ (should (string-match-p "phrase" stripped))
+ (should-not (string-match-p "<" stripped))
+ (should-not (string-match-p "href" stripped))))
+
+(ert-deftest test-gloss-fetch-strip-html-empty-string-returns-empty ()
+ "Boundary: empty input returns an empty string (or nil)."
+ (skip-unless (fboundp 'libxml-parse-html-region))
+ (let ((result (gloss-fetch--strip-html "")))
+ (should (or (null result) (equal result "")))))
+
+(ert-deftest test-gloss-fetch-strip-html-entities-decoded ()
+ "Boundary: HTML entities decode to their characters."
+ (skip-unless (fboundp 'libxml-parse-html-region))
+ (let ((stripped (gloss-fetch--strip-html "Smith &amp; Wesson")))
+ (should (string-match-p "&" stripped))
+ (should-not (string-match-p "&amp;" stripped))))
+
+(ert-deftest test-gloss-fetch-strip-html-collapses-whitespace ()
+ "Boundary: runs of internal whitespace collapse to single spaces; result is trimmed."
+ (skip-unless (fboundp 'libxml-parse-html-region))
+ (let ((stripped (gloss-fetch--strip-html " hello <b>world</b> ")))
+ (should (equal stripped "hello world"))))
+
+(ert-deftest test-gloss-fetch-strip-html-failure-returns-nil ()
+ "Error: when libxml-parse-html-region raises, return nil so the caller can drop the sense."
+ (skip-unless (fboundp 'libxml-parse-html-region))
+ (cl-letf (((symbol-function 'libxml-parse-html-region)
+ (lambda (&rest _) (error "libxml exploded"))))
+ (should-not (gloss-fetch--strip-html "any input"))))
+
+(provide 'test-gloss-fetch--strip-html)
+;;; test-gloss-fetch--strip-html.el ends here
diff --git a/tests/testutil-gloss-fetch.el b/tests/testutil-gloss-fetch.el
new file mode 100644
index 0000000..a144d91
--- /dev/null
+++ b/tests/testutil-gloss-fetch.el
@@ -0,0 +1,48 @@
+;;; testutil-gloss-fetch.el --- Test helpers for gloss-fetch -*- lexical-binding: t -*-
+
+;; SPDX-License-Identifier: GPL-3.0-or-later
+
+;;; Commentary:
+
+;; Helpers for tests that exercise `gloss-fetch'. The boundary mock is
+;; `url-retrieve-synchronously', so these helpers build response buffers
+;; in the shape Emacs's url library returns: status line, blank line,
+;; body. The body comes from a JSON fixture loaded via
+;; `gloss-test--load-wiktionary-fixture' (provided by testutil-gloss.el
+;; on a parallel branch).
+
+;;; Code:
+
+(defun gloss-fetch-test--make-response-buffer (status-line body)
+ "Return a fresh buffer containing STATUS-LINE, a blank line, and BODY.
+STATUS-LINE is an HTTP status line such as \"HTTP/1.1 200 OK\". BODY
+is the response body as a string. The buffer is unibyte so that
+multibyte handling is exercised end-to-end."
+ (let ((buf (generate-new-buffer " *gloss-fetch-test-response*")))
+ (with-current-buffer buf
+ (set-buffer-multibyte nil)
+ (insert status-line "\n")
+ (insert "Content-Type: application/json\n")
+ (insert "\n")
+ (insert (encode-coding-string (or body "") 'utf-8)))
+ buf))
+
+(defmacro gloss-fetch-test--with-mocked-url (response-fn &rest body)
+ "Run BODY with `url-retrieve-synchronously' replaced by RESPONSE-FN.
+RESPONSE-FN takes the URL string and returns either a buffer (the
+response) or nil (to simulate timeout / unreachable)."
+ (declare (indent 1) (debug t))
+ `(cl-letf (((symbol-function 'url-retrieve-synchronously)
+ (lambda (url &rest _args) (funcall ,response-fn url))))
+ ,@body))
+
+(defun gloss-fetch-test--ok-response (body)
+ "Return a 200 OK response buffer with BODY."
+ (gloss-fetch-test--make-response-buffer "HTTP/1.1 200 OK" body))
+
+(defun gloss-fetch-test--status-response (status-line &optional body)
+ "Return a response buffer with STATUS-LINE and optional BODY (default empty)."
+ (gloss-fetch-test--make-response-buffer status-line (or body "")))
+
+(provide 'testutil-gloss-fetch)
+;;; testutil-gloss-fetch.el ends here