feat: chat — boutons discrets sous l'image, image en onglet d'app, point de non-lus, notification OS, suppression annulable BUG-110
CI / lint (push) Successful in 2m50s
CI / security (push) Successful in 1m36s
CI / test (push) Successful in 4m28s
CI / build (push) Successful in 1m31s
CI / e2e (push) Successful in 17m30s

This commit is contained in:
2026-10-09 11:53:26 -04:00
parent 54caa48d1f
commit 4fb7c43e06
17 changed files with 420 additions and 46 deletions
+97 -2
View File
@@ -46,8 +46,10 @@ const LOCALES_DIR = path.resolve(__dirname, "..", "..", "frontend", "locales");
// Route fetch: locales from disk, auth 401, API chat endpoints from a stub map.
const apiStubs = new Map();
globalThis.fetch = (url) => {
const fetchCalls = []; // #110 — journal des appels {url, method}
globalThis.fetch = (url, opts = {}) => {
const u = String(url);
fetchCalls.push({ url: u, method: (opts.method || "GET").toUpperCase() });
if (u.includes("/static/locales/")) {
const name = u.split("/").pop();
try {
@@ -77,7 +79,7 @@ const mod = await import("../../frontend/js/filechat.js");
const {
renderChatMessages, onChatMessage, openFileChat, closeFileChat, toggleFileChat,
followFileChat, openSidebarChat, filterChatMessages, refreshUnreadBadge,
onChatDeleted, onChatRead,
onChatDeleted, onChatRead, _deleteMessage,
} = mod;
let pass = 0;
@@ -338,6 +340,99 @@ test("#192 — saisie en textarea auto-agrandie, Enter envoie", async () => {
closeFileChat();
});
// --- #110 : boutons discrets, image en onglet, point non-lu, delete différé -
test("#110 — bouton supprimer visible (pas opacity:0)", () => {
const list = document.createElement("div");
renderChatMessages(list, [{ id: "d1", user: "bruno", text: "x", ts: 50 }], "bruno", "/api/chat");
const del = list.querySelector(".file-chat-del");
assert.ok(del, "bouton suppression présent");
assert.strictEqual(del.style.opacity, "", "pas de opacity:0 — bouton visible");
});
test("#110 — image : boutons icônes discrets sous l'image, à droite", () => {
const list = document.createElement("div");
renderChatMessages(list, [{
id: "img1", user: "alice", text: "", ts: 100,
attachment: { kind: "image", url: "/api/chat/attachment/photo.png", name: "photo.png", mime: "image/png" },
}], "bruno");
const container = list.querySelector(".file-chat-image-container");
assert.ok(container, "conteneur image présent");
const actions = container.querySelector(".file-chat-image-actions");
assert.ok(actions, "rangée de boutons sous l'image");
assert.ok(actions.querySelector(".file-chat-image-download"), "bouton téléchargement présent");
assert.ok(actions.querySelector(".file-chat-open"), "bouton visualisation présent");
actions.querySelectorAll("button").forEach((b) => {
assert.strictEqual(b.textContent.trim(), "", "bouton icône seule (sans libellé texte)");
assert.ok(b.getAttribute("aria-label"), "aria-label présent");
});
});
test("#110 — clic sur l'image : ouverture dans un onglet de l'app", async () => {
const ui = await import(pathToFileURL(path.join(JS_DIR, "ui.js")).href);
const seen = [];
const orig = ui.TabManager.openChatImage;
ui.TabManager.openChatImage = (att) => seen.push(att);
try {
const list = document.createElement("div");
const att = { kind: "image", url: "/api/chat/attachment/p2.png", name: "p2.png" };
renderChatMessages(list, [{ id: "img2", user: "alice", text: "", ts: 101, attachment: att }], "bruno");
list.querySelector(".file-chat-image").click();
assert.strictEqual(seen.length, 1, "openChatImage appelé au clic sur l'image");
assert.strictEqual(seen[0].url, att.url, "même pièce jointe transmise");
list.querySelector(".file-chat-open").click();
assert.strictEqual(seen.length, 2, "le bouton discret ouvre aussi l'onglet");
} finally {
ui.TabManager.openChatImage = orig;
}
});
test("#110 — point de couleur sur l'icône chat tant qu'il reste des non-lus", () => {
const tab = document.getElementById("sidebar-tab-chat");
assert.ok(tab, "onglet chat présent");
localStorage.setItem("obsigate-chat-unread", JSON.stringify({ general: 3 }));
refreshUnreadBadge();
assert.ok(tab.classList.contains("chat-has-unread"), "classe chat-has-unread posée");
localStorage.setItem("obsigate-chat-unread", JSON.stringify({}));
refreshUnreadBadge();
assert.ok(!tab.classList.contains("chat-has-unread"), "classe retirée quand tout est lu");
});
test("#110 — suppression différée : Annuler remet le message, aucun DELETE", () => {
const list = document.createElement("div");
renderChatMessages(list, [{ id: "del9", user: "bruno", text: "mon message", ts: 200 }], "bruno", "/api/chat");
const before = fetchCalls.length;
list.querySelector(".file-chat-del").click();
assert.ok(list.querySelector(".file-chat-deleted"), "ligne remplacée par « supprimé »");
assert.ok(list.querySelector(".file-chat-undo"), "bouton Annuler présent");
assert.ok(!fetchCalls.slice(before).some((c) => c.method === "DELETE"), "aucun DELETE parti pendant la fenêtre");
list.querySelector(".file-chat-undo").click();
assert.ok(list.querySelector("[data-id='del9']"), "message d'origine restauré");
assert.strictEqual(list.querySelector(".file-chat-deleted"), null, "ligne « supprimé » retirée");
assert.ok(!fetchCalls.slice(before).some((c) => c.method === "DELETE"), "toujours aucun DELETE");
});
test("#110 — notification OS quand l'app est en arrière-plan", () => {
const fired = [];
class FakeNotification {
constructor(title, opts) { fired.push({ title, opts }); }
close() {}
}
FakeNotification.permission = "granted";
window.Notification = FakeNotification;
Object.defineProperty(document, "hidden", { value: true, configurable: true });
try {
const panel = document.getElementById("sidebar-panel-chat");
panel.classList.remove("active");
onChatMessage({ vault: "__global__", path: "general", message: { id: "n-1", user: "bob", text: "coucou", ts: 300 } });
assert.strictEqual(fired.length, 1, "notification affichée");
assert.ok(String(fired[0].opts.body).includes("coucou"), "aperçu du message dans la notification");
} finally {
Object.defineProperty(document, "hidden", { value: false, configurable: true });
window.Notification = { permission: "denied" };
}
});
// Sequential execution: each case depends on panel state left by the previous.
for (const [name, fn] of cases) {
try {