From 9119a66bea5040fee8b73d76c0ba085e00f41769 Mon Sep 17 00:00:00 2001 From: Adrian Gruntkowski Date: Thu, 28 Nov 2024 13:44:16 +0100 Subject: [PATCH] Create a minimal test framework and comment code somewhat --- lib/jenot/web.ex | 7 +- priv/static/js/components.js | 3 +- priv/static/js/jenot-tests.js | 123 ++++++++++++++++++++++++++++++++++ priv/static/js/jenot.js | 46 +++++++++---- priv/static/test.html | 38 +++++++++++ 5 files changed, 197 insertions(+), 20 deletions(-) create mode 100644 priv/static/js/jenot-tests.js create mode 100644 priv/static/test.html diff --git a/lib/jenot/web.ex b/lib/jenot/web.ex index 34e9e32..d086aea 100644 --- a/lib/jenot/web.ex +++ b/lib/jenot/web.ex @@ -18,7 +18,7 @@ defmodule Jenot.Web do plug Plug.Static, at: "/", - only: ~w(img js index.html site.webmanifest style.css), + only: ~w(img js index.html test.html site.webmanifest style.css), from: {:jenot, "priv/static"} plug Plug.Parsers, @@ -30,11 +30,6 @@ defmodule Jenot.Web do plug :dispatch get "/" do - priv_dir = :code.priv_dir(:jenot) - index_path = Path.join([priv_dir, "static", "index.html"]) - - send_file(conn, 200, index_path) - conn |> resp(:found, "") |> put_resp_header("location", "/index.html") diff --git a/priv/static/js/components.js b/priv/static/js/components.js index b2867a2..0fb92e5 100644 --- a/priv/static/js/components.js +++ b/priv/static/js/components.js @@ -11,7 +11,8 @@ class EditableArea extends HTMLElement { connectedCallback() { const text = this.textContent; - this.displayElement = document.createElement("p", { class: "display" }); + this.displayElement = document.createElement("p"); + this.displayElement.classList.add("display"); this.inputElement = document.createElement("textarea"); this.inputElement.value = text; diff --git a/priv/static/js/jenot-tests.js b/priv/static/js/jenot-tests.js new file mode 100644 index 0000000..cdc6cb3 --- /dev/null +++ b/priv/static/js/jenot-tests.js @@ -0,0 +1,123 @@ +import "./components.js"; + +const URL_PARAMS = new URLSearchParams(window.location.search); + +const CONCRETE_TEST = URL_PARAMS.get("t"); + +let counter = 1; + +const body = document.querySelector("body"); +const log = document.querySelector("#test-log"); + +class AssertError extends Error { + constructor(message, options) { + super(message, options); + } +} + +const setup = (idx) => { + const container = document.createElement("div", { id: `test-${idx}` }); + body.appendChild(container); + return container; +}; + +async function assert(assertion, timeout) { + timeout = timeout || 100; + const interval = Math.max(timeout / 10, 10); + const start = performance.now(); + let now = performance.now(); + + while (true) { + let result; + let error; + + try { + result = assertion(); + } catch (error) { + result = false; + error = error; + } + + if (result === true) break; + + if (now - start >= timeout) { + const assertionStr = assertion.toString(); + const opts = error ? { cause: error } : {}; + throw new AssertError(`Assertion failed: ${assertionStr}`, opts); + } + + await new Promise((r) => setTimeout(r, interval)); + now = performance.now(); + } +} + +function test(label, testFun) { + if (CONCRETE_TEST && CONCRETE_TEST != counter) { + counter++; + return; + } + const container = setup(counter); + const logRow = document.createElement("li"); + logRow.textContent = `[RUNNING] ${label}`; + log.appendChild(logRow); + + try { + testFun(container); + const message = `[OK] ${label}`; + console.info(message); + logRow.textContent = message; + logRow.classList.add("success"); + } catch (error) { + const message = `[ERROR] ${label}`; + console.error(message, error); + logRow.textContent = message; + logRow.classList.add("failure"); + const errorOutput = document.createElement("pre"); + errorOutput.textContent = `${error.name}: ${error.message}`; + if (error.stack) { + errorOutput.textContent += `\n${error.stack}`; + } + logRow.appendChild(errorOutput); + } + container.remove(); + counter++; +} + +test("editable-area renders", (container) => { + container.innerHTML = `Test 123`; + + const textarea = container.querySelector("textarea"); + const display = container.querySelector("p"); + + assert(() => textarea.value === "Test 123"); + assert(() => display.classList.contains("display")); + assert(() => display.innerHTML === "Test 123
"); +}); + +test("editable-area updates on input", (container) => { + container.innerHTML = ``; + + const textarea = container.querySelector("textarea"); + const display = container.querySelector("p"); + + let eventCalled = false; + container.addEventListener("contentChange", () => { + eventCalled = true; + }); + + textarea.value = "Some new content\nwith a newline"; + textarea.dispatchEvent( + new Event("input", { bubbles: true, cancelable: true }), + ); + + assert(() => display.innerHTML === `Some new content
with a newline
`); + assert(() => eventCalled === true); +}); + +test("editable-area respects readonly attribute", (container) => { + container.innerHTML = `Some text`; + + const textarea = container.querySelector("textarea"); + + assert(() => textarea.disabled) +}) diff --git a/priv/static/js/jenot.js b/priv/static/js/jenot.js index 1fd3886..b5bed68 100644 --- a/priv/static/js/jenot.js +++ b/priv/static/js/jenot.js @@ -9,6 +9,28 @@ import { } from "./notifications.js"; import "./components.js"; +const URL_PARAMS = new URLSearchParams(window.location.search); + +// Notes storage configuration. +// Currently supports either simple, local storage based implementation +// and a more elaborate one, using a combination of IndexedDB + network sync. +const Notes = URL_PARAMS.has("localStorage") + ? new LocalNoteStore("jenot-app") + : new SyncedNoteStore("jenot-app", "notes", "/"); + +// Very rudimentary periodic sync. It will be refactored into a more real-time +// solution using either websocket of long-polling, so that server can notify about +// new data to sync. +const sync = async () => { + await Notes.sync(); + Notes.saveStorage(); +}; + +sync(); +setInterval(sync, 5000); + +// Notifications API test - to be reused for push notifications later on + const notificationsButton = document.querySelector("#enable-notifications"); const notificationsTestButton = document.querySelector("#test-notifications"); @@ -25,27 +47,20 @@ notificationsTestButton.addEventListener("click", () => { }, 8000); }); -const urlParams = new URLSearchParams(window.location.search); - -const Notes = urlParams.has("localStorage") - ? new LocalNoteStore("jenot-app") - : new SyncedNoteStore("jenot-app", "notes", "/"); - -const sync = async () => { - await Notes.sync(); - Notes.saveStorage(); -}; - -sync(); -setInterval(sync, 5000); +// There are two note-form component instances - one for +// composing new notes and another one for editing existing notes. const newNote = document.querySelector("#new-note"); const editNote = document.querySelector("#edit-note"); +// Each save event originating from storage triggers a re-render +// of notes list. Notes.addEventListener("save", render.bind(this)); +// Initial notes render. render(); +// note-form component specific event handlers newNote.addEventListener("addNote", async (e) => { await Notes.add(e.detail); Notes.saveStorage(); @@ -65,6 +80,11 @@ editNote.addEventListener("deleteNote", async (e) => { Notes.saveStorage(); }); +// All notes are currently re-rendered on each storage +// update. The routine will be optimized to replace only +// nodes that actually changed - most likely based on unique +// note IDs associated with block elements. + async function render() { const notes = await Notes.all(); const notesContainer = document.querySelector("#notes"); diff --git a/priv/static/test.html b/priv/static/test.html new file mode 100644 index 0000000..809549c --- /dev/null +++ b/priv/static/test.html @@ -0,0 +1,38 @@ + + + + + + Jenot Tests + + + + + + + +
+
    + +