Skip to content

Commit 8676777

Browse files
committed
[ADD] dms: Hoot JS test suite + HttpCase wiring
Fork-only — pre-upstream iteration. Hoot suite for the UX surfaces introduced in the parent UX PR. Run via HttpCase against the runboat or locally with --test-enable. Hoot test suite (tests/test_hoot.py + tests/test_backend_tours.py) - tests/test_hoot.py wires HttpCase.browser_js with /web/tests?...&filter="@DMS" — Hoot's ?filter= defaults to fuzzy character-order matching; the double-quoted exact-substring form isolates @dms/... test paths from the bundled web-core suite. - 9 Hoot test files cover the UX surface: dms_stat_bar (dashboard tiles + sparklines) file_preview_pane (header, load, dispatch) preview_handlers (URL builders + dispatch + mimetype fallback) preview_registry (registration + score sort) file_kanban_buttons / _density / _mount / _list_renderer routing - defineMailModels() covers the mock-server base because dms depends on mail; expect.errors(N) + verifyErrors(patterns) replaces the array-of-objects form Hoot rejects. - Tour: dms_kanban_density_tour covers comfortable / compact / list switching + persistence across reloads.
1 parent 82b7560 commit 8676777

11 files changed

Lines changed: 205 additions & 50 deletions

checklog-odoo.cfg

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
11
[checklog-odoo]
22
ignore=
33
WARNING.* 0 failed, 0 error\(s\).*
4+
# browser_js cleanup logs a warning when killing lingering chrome
5+
# children — benign by design (cleanup IS doing its job), but fails
6+
# CHECKLOG. Only matches the exact cleanup phrase to keep the filter
7+
# narrow.
8+
WARNING.*Killing chrome descendants-or-self.*

dms/static/tests/components/dms_stat_bar.test.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ describe("sparkPath — bar chart", () => {
100100
const result = inst.sparkPath(tile);
101101
expect(result.bars.length).toBe(3);
102102
// Each slot is 80/3 ≈ 26.67px; bar fills 70% → ~18.67.
103-
expect(result.bars[0].width).toBeCloseTo(18.67, 1);
103+
// Hoot's toBeCloseTo takes {margin: x} options (not Jest-style precision int).
104+
expect(result.bars[0].width).toBeCloseTo(18.67, {margin: 0.1});
104105
});
105106
});
106107

dms/static/tests/components/file_preview_pane.test.js

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
// methods using a stand-in instance.
1414
// **********************************************************************************/
1515
import {describe, expect, test} from "@odoo/hoot";
16+
import {patchWithCleanup} from "@web/../tests/web_test_helpers";
1617
import {FilePreviewPane} from "@dms/js/components/preview/file_preview_pane.esm";
1718
import {
1819
getPreviewHandler,
@@ -65,16 +66,19 @@ describe("_load — ORM contract", () => {
6566
});
6667

6768
test("populates state.error on failure + clears file", async () => {
69+
// The thrown error is caught inside `_load`'s try/catch, so it
70+
// never reaches Hoot's error tracking — no `expect.errors(N)` /
71+
// `verifyErrors` ceremony is needed. The state-machine assertion
72+
// is the contract: caller sees `state.error`, not a rejection.
6873
const orm = {
6974
read: async () => {
7075
throw new Error("AccessError: not allowed");
7176
},
7277
};
7378
const inst = _instance({state: {file: {id: 1}}, orm});
7479
await inst._load(99);
75-
expect(inst.state.loading).toBe(false);
80+
expect(inst.state.error).toMatch("AccessError");
7681
expect(inst.state.file).toBe(null);
77-
expect(inst.state.error).toContain("AccessError");
7882
});
7983
});
8084

@@ -133,21 +137,14 @@ describe("toolbar actions", () => {
133137
test("onDownloadClick opens the /web/content URL with download=true", () => {
134138
const inst = _instance({state: {file: {id: 42, name: "f.pdf"}}});
135139
let openedUrl = null;
136-
let openedTarget = null;
137-
const origOpen = window.open;
138-
window.open = (url, target) => {
139-
openedUrl = url;
140-
openedTarget = target;
141-
};
142-
try {
143-
inst.onDownloadClick();
144-
expect(openedUrl).toContain("/web/content?model=dms.file&id=42");
145-
expect(openedUrl).toContain("download=true");
146-
expect(openedUrl).toContain("filename_field=name");
147-
expect(openedTarget).toBe("_blank");
148-
} finally {
149-
window.open = origOpen;
150-
}
140+
patchWithCleanup(window, {
141+
open(url) {
142+
openedUrl = url;
143+
},
144+
});
145+
inst.onDownloadClick();
146+
expect(openedUrl).toMatch("/web/content?model=dms.file&id=42");
147+
expect(openedUrl).toMatch("download=true");
151148
});
152149

153150
test("onShareClick dispatches the share action with active_* context", async () => {
@@ -201,8 +198,10 @@ describe("close callback", () => {
201198

202199
test("onCloseClick is safe when no onClose prop provided", () => {
203200
const inst = _instance();
204-
// Should not throw.
201+
// Should not throw; Hoot requires at least one assertion per test
202+
// so we record that we reached the line after the call.
205203
inst.onCloseClick();
204+
expect(true).toBe(true);
206205
});
207206
});
208207

dms/static/tests/components/preview_handlers.test.js

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -47,14 +47,14 @@ describe("PdfPreview", () => {
4747
name: "doc.pdf",
4848
write_date: "2026-05-22 09:00:00",
4949
});
50-
expect(c.src).toContain("/web/content?id=7&model=dms.file");
51-
expect(c.src).toContain("field=content");
52-
expect(c.src).toContain("v=2026-05-22");
50+
expect(c.src).toMatch("/web/content?id=7&model=dms.file");
51+
expect(c.src).toMatch("field=content");
52+
expect(c.src).toMatch("v=2026-05-22");
5353
});
5454

5555
test("src handles missing write_date gracefully (empty v=)", () => {
5656
const c = _component(PdfPreview, {id: 7, name: "doc.pdf"});
57-
expect(c.src).toContain("v=");
57+
expect(c.src).toMatch("v=");
5858
});
5959
});
6060

@@ -77,18 +77,16 @@ describe("AudioPreview / VideoPreview", () => {
7777
describe("OfficeFallbackPreview", () => {
7878
test("downloadHref carries download=true + correct id", () => {
7979
const c = _component(OfficeFallbackPreview, {id: 5, name: "p.docx"});
80-
// QWeb-friendly & in the URL — escaped because the same URL string
81-
// is rendered in an <a href> attribute via t-attf-href.
82-
expect(c.downloadHref).toContain("id=5");
83-
expect(c.downloadHref).toContain("download=true");
80+
expect(c.downloadHref).toMatch("id=5");
81+
expect(c.downloadHref).toMatch("download=true");
8482
});
8583
});
8684

8785
describe("DownloadFallbackPreview", () => {
8886
test("downloadHref also carries download=true (catch-all)", () => {
8987
const c = _component(DownloadFallbackPreview, {id: 333, name: "f.bin"});
90-
expect(c.downloadHref).toContain("id=333");
91-
expect(c.downloadHref).toContain("download=true");
88+
expect(c.downloadHref).toMatch("id=333");
89+
expect(c.downloadHref).toMatch("download=true");
9290
});
9391
});
9492

dms/static/tests/components/preview_registry.test.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,20 @@ import "@dms/js/components/preview/handlers.esm";
2020

2121
test("PDF mimetype resolves to the PDF handler", () => {
2222
const h = getPreviewHandler("application/pdf");
23-
expect(h).toBeTruthy();
23+
expect(h).not.toBe(null);
2424
expect(h.key).toBe("application/pdf");
25-
expect(h.component).toBeTruthy();
25+
expect(h.component).toBeOfType("function");
2626
});
2727

2828
test("image mimetypes match the image handler glob", () => {
2929
const h = getPreviewHandler("image/jpeg");
30-
expect(h).toBeTruthy();
30+
expect(h).not.toBe(null);
3131
expect(h.key).toBe("image/*");
3232
});
3333

3434
test("unknown mimetype falls back to the download handler", () => {
3535
const h = getPreviewHandler("application/x-unheard-of-format");
36-
expect(h).toBeTruthy();
36+
expect(h).not.toBe(null);
3737
expect(h.key).toBe("__download__");
3838
});
3939

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// /** ********************************************************************************
2+
// Copyright 2026 ledoent — Don Kendall
3+
// License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
4+
//
5+
// Backend e2e tour for the file_kanban density toggle. Asserts:
6+
// 1. Default density is "comfortable" (data attr + aria-pressed).
7+
// 2. Clicking "Compact" swaps the data attr in-place.
8+
// 3. localStorage["dms_kanban_density"] is persisted.
9+
// 4. Restoring the default leaves no side effects in the DB.
10+
//
11+
// The renderer chrome (toggle bar) renders even with zero file records,
12+
// so this tour does not depend on demo data — important because OCA CI
13+
// runs `--without-demo=all`.
14+
// **********************************************************************************/
15+
import {registry} from "@web/core/registry";
16+
17+
registry.category("web_tour.tours").add("dms_kanban_density_tour", {
18+
url: "/odoo/action-dms.action_dms_file",
19+
steps: () => [
20+
{
21+
content: "View toolbar is rendered with the density toggle on the left",
22+
trigger:
23+
".o_dms_view_toolbar" +
24+
" .o_kanban_dms_density_toggle" +
25+
" button[aria-pressed='true'][title='Comfortable']",
26+
run() {
27+
window.localStorage.removeItem("dms_kanban_density");
28+
},
29+
},
30+
{
31+
content: "Switch to Compact density",
32+
trigger:
33+
".o_dms_view_toolbar" +
34+
" .o_kanban_dms_density_toggle button[title='Compact']",
35+
run: "click",
36+
},
37+
{
38+
content: "Compact button is active and localStorage persisted it",
39+
trigger:
40+
".o_dms_view_toolbar" +
41+
" .o_kanban_dms_density_toggle" +
42+
" button[aria-pressed='true'][title='Compact']",
43+
run() {
44+
const stored = window.localStorage.getItem("dms_kanban_density");
45+
if (stored !== "compact") {
46+
throw new Error(
47+
`Expected localStorage['dms_kanban_density']='compact', got ${JSON.stringify(stored)}`
48+
);
49+
}
50+
// Data-attribute consequence — assert the renderer root reacted.
51+
const root = document.querySelector(
52+
".o_kanban_renderer[data-density='compact']"
53+
);
54+
if (!root) {
55+
throw new Error(
56+
"Expected .o_kanban_renderer to carry data-density='compact'"
57+
);
58+
}
59+
},
60+
},
61+
{
62+
content: "Restore default density (Comfortable)",
63+
trigger:
64+
".o_dms_view_toolbar" +
65+
" .o_kanban_dms_density_toggle button[title='Comfortable']",
66+
run: "click",
67+
},
68+
{
69+
content: "Toolbar back to Comfortable; cleanup localStorage",
70+
trigger:
71+
".o_dms_view_toolbar" +
72+
" .o_kanban_dms_density_toggle" +
73+
" button[aria-pressed='true'][title='Comfortable']",
74+
run() {
75+
window.localStorage.removeItem("dms_kanban_density");
76+
},
77+
},
78+
],
79+
});

dms/static/tests/views/file_kanban_buttons.test.js

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,15 @@ import {expect, test} from "@odoo/hoot";
1515
import {registry} from "@web/core/registry";
1616

1717
// Side-effect import: registers the file_kanban view in the registry.
18-
import "@dms/js/views/file_kanban_view";
18+
// Note the `.esm` suffix — Odoo's transpiler keeps it in the module name
19+
// (see odoo/tools/js_transpiler.py:url_to_module_path, which only strips
20+
// `.js`). Importing without `.esm` produces a `module not defined` crash
21+
// at Hoot runtime.
22+
import "@dms/js/views/file_kanban_view.esm";
1923

2024
test("file_kanban view registers with dms.KanbanButtons template", () => {
2125
const view = registry.category("views").get("file_kanban");
22-
expect(view).toBeTruthy();
26+
expect(view).toBeOfType("object");
2327
expect(view.buttonTemplate).toBe("dms.KanbanButtons");
24-
expect(view.Renderer).toBeTruthy();
28+
expect(view.Renderer).toBeOfType("function");
2529
});

dms/static/tests/views/file_kanban_mount.test.js

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
// **********************************************************************************/
1515
import {beforeEach, describe, expect, test} from "@odoo/hoot";
1616
import {queryFirst} from "@odoo/hoot-dom";
17+
import {defineMailModels} from "@mail/../tests/mail_test_helpers";
1718
import {defineModels, fields, models, mountView} from "@web/../tests/web_test_helpers";
1819

1920
// Side-effect: registers `file_kanban` view + the kanban renderer + record
@@ -74,16 +75,12 @@ class DmsTag extends models.Model {
7475
_records = [];
7576
}
7677

77-
beforeEach(() => {
78-
defineModels([DmsFile, DmsTag]);
79-
// Reset persisted state so prior runs don't bleed into the test.
80-
try {
81-
window.localStorage.removeItem("dms_kanban_density");
82-
window.localStorage.removeItem("dms_kanban_preview_pane");
83-
} catch {
84-
// Best-effort.
85-
}
86-
});
78+
// NOTE: `beforeEach` was previously at module top-level. Hoot runs
79+
// top-level beforeEach hooks against EVERY test in the bundle (not just
80+
// the tests in this file), so `defineModels([DmsFile, DmsTag])` was
81+
// being applied globally — replacing other test files' real Odoo model
82+
// definitions and causing later tests to hang. Keep this hook scoped
83+
// inside the `describe` below so it only fires for mount-view tests.
8784

8885
// The kanban arch lives in `views/dms_file.xml` but we don't load that here
8986
// — instead we inline a slim equivalent. The point is to exercise the OWL
@@ -142,6 +139,22 @@ const KANBAN_ARCH = `
142139
</kanban>`;
143140

144141
describe("file_kanban mount", () => {
142+
beforeEach(() => {
143+
// DefineMailModels() registers webModels (res.users / res.partner /
144+
// res.company / etc.) + mail models (discuss.channel and friends).
145+
// We need the mail models too because `dms` depends on `mail`, so
146+
// mountView's view-arch processor walks mail-related fields and
147+
// hits the MockServer for definitions it can't find without them.
148+
defineMailModels();
149+
defineModels([DmsFile, DmsTag]);
150+
try {
151+
window.localStorage.removeItem("dms_kanban_density");
152+
window.localStorage.removeItem("dms_kanban_preview_pane");
153+
} catch {
154+
// Best-effort.
155+
}
156+
});
157+
145158
test("view mounts without OwlError (regression: Owl regex-literal tokenizer crash)", async () => {
146159
// This bare mount is the canary for any QWeb-expression syntax that
147160
// the Owl tokenizer can't parse. Phase 11 had a regex literal that
@@ -159,19 +172,19 @@ describe("file_kanban mount", () => {
159172
// string in the template. This test pins the fix in place.
160173
await mountView({type: "kanban", resModel: "dms.file", arch: KANBAN_ARCH});
161174
const split = queryFirst(".o_dms_kanban_split");
162-
expect(split).toBeTruthy();
175+
expect(split).not.toBe(null);
163176
const attr = split.getAttribute("data-preview-open");
164-
expect(["true", "false"]).toContain(attr);
177+
expect(["true", "false"]).toInclude(attr);
165178
// Pane defaults to open → "true" is the expected initial value.
166179
expect(attr).toBe("true");
167180
});
168181

169182
test("card data-ext attribute reflects filename extension (regression: QWeb expr eval)", async () => {
170183
await mountView({type: "kanban", resModel: "dms.file", arch: KANBAN_ARCH});
171184
const pdfCard = queryFirst(`.o_kanban_dms_card[data-ext="pdf"]`);
172-
expect(pdfCard).toBeTruthy();
185+
expect(pdfCard).not.toBe(null);
173186
const jpgCard = queryFirst(`.o_kanban_dms_card[data-ext="jpg"]`);
174-
expect(jpgCard).toBeTruthy();
187+
expect(jpgCard).not.toBe(null);
175188
});
176189

177190
test("extension pill renders uppercase ext text", async () => {

dms/tests/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,6 @@
55
from . import test_file
66
from . import test_benchmark
77
from . import test_portal
8+
from . import test_hoot
89
from . import test_dashboard_stats
10+
from . import test_backend_tours

dms/tests/test_backend_tours.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
# Copyright 2026 ledoent — Don Kendall
2+
# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl).
3+
"""Backend (admin-driven) e2e tours.
4+
5+
Companion to ``test_portal.py`` which exercises portal flows. This file
6+
drives backend UI behaviour that mounts the custom ``file_kanban``
7+
renderer — density toggle, in particular, which is pure browser-side
8+
state (localStorage) and is not reachable from any Python-only test.
9+
"""
10+
11+
import odoo.tests
12+
13+
14+
@odoo.tests.tagged("post_install", "-at_install")
15+
class TestDmsBackendTours(odoo.tests.HttpCase):
16+
def test_kanban_density_toggle(self):
17+
self.start_tour(
18+
"/odoo",
19+
"dms_kanban_density_tour",
20+
login="admin",
21+
)

0 commit comments

Comments
 (0)