| index 229caca..3372d1c 100644 |
| --- a/test/browser/chat.test.mjs |
| +++ b/test/browser/chat.test.mjs |
| @@ -1,15 +1,16 @@ |
| -// chat.test.mjs — the three pieces of chat.js that can be gated |
| -// without a browser. |
| +// chat.test.mjs — chat.js gated without a browser. |
| // |
| // node --test test/browser/chat.test.mjs |
| // |
| // Same trick as negotiation.test.mjs, and for the same reason: chat.js |
| -// is an IIFE that wants a document, so it cannot be imported. The three |
| -// functions under test are pure (or take their document as an argument) |
| -// and their source is lifted out of the shipped file by name and |
| -// evaluated here. What is gated is therefore the code that ships, not a |
| -// copy of it that can drift, and renaming or reshaping one of them |
| -// fails this file loudly instead of passing on a stale duplicate. |
| +// is an IIFE that wants a document, so it cannot be imported. Its |
| +// functions are lifted out of the shipped file by name and evaluated |
| +// here, `mount` included — which needs its module constants and its |
| +// three helpers handed to it, so the harness below rebuilds exactly |
| +// that much of the file and no more. What is gated is therefore the |
| +// code that ships, not a copy of it that can drift, and renaming or |
| +// reshaping any of it fails this file loudly instead of passing on a |
| +// stale duplicate. |
| // |
| // What it gates: |
| // |
| @@ -23,12 +24,17 @@ |
| // control's maxlength counts characters, so for anything |
| // outside ASCII the two disagree and this is the count that |
| // matches what the server will refuse. |
| +// mount the panel's whole contract with its host: one send per |
| +// submit, a local echo only on a `true` answer, a refusal |
| +// that keeps the draft, the host's enable gate, Enter |
| +// versus Shift+Enter versus an IME, and the two sentences. |
| import { test } from 'node:test'; |
| import assert from 'node:assert/strict'; |
| import { readFileSync } from 'node:fs'; |
| import { fileURLToPath } from 'node:url'; |
| |
| const chatJS = fileURLToPath(new URL('../../internal/meet/static/chat.js', import.meta.url)); |
| +const src = readFileSync(chatJS, 'utf8'); |
| |
| // lift returns the source of one `function name(...) {...}` by matching |
| // braces from its declaration. |
| @@ -47,12 +53,57 @@ function lift(source, name) { |
| assert.fail('unbalanced braces reading ' + name + ' out of chat.js'); |
| } |
| |
| -const src = readFileSync(chatJS, 'utf8'); |
| +// The limits and the sentences are read out of the shipped file rather |
| +// than retyped here, so this file cannot go on asserting 500 and 2048 |
| +// after someone has changed them — and then pinned to the values the |
| +// spec and the copy list name, so changing one is a deliberate act with |
| +// a failing test in front of it. |
| +function numberConst(name) { |
| + const m = new RegExp('const\\s+' + name + '\\s*=\\s*(\\d+)\\s*;').exec(src); |
| + assert.ok(m, name + ' is not a plain numeric const in chat.js any more'); |
| + return Number(m[1]); |
| +} |
| + |
| +function stringConst(name) { |
| + const m = new RegExp('const\\s+' + name + "\\s*=\\s*'([^']*)'\\s*;").exec(src); |
| + assert.ok(m, name + ' is not a plain single-quoted const in chat.js any more'); |
| + return m[1]; |
| +} |
| + |
| +const MAX_BYTES = numberConst('MAX_BYTES'); |
| +const MAX_LINES = numberConst('MAX_LINES'); |
| +const PINNED_PX = numberConst('PINNED_PX'); |
| +const SEND_FAILED = stringConst('SEND_FAILED'); |
| +const TOO_LONG = stringConst('TOO_LONG'); |
| + |
| +test('the shipped limits and sentences are the ones everything else was written against', () => { |
| + assert.equal(MAX_BYTES, 2048, "spec §4.4's 2 KB, and the control's maxlength in partials/chat.html"); |
| + assert.equal(MAX_LINES, 500); |
| + assert.equal(SEND_FAILED, 'Could not send. Try again.', 'catalogued in docs/superpowers/copy/2026-09-10-slice-2-strings.md'); |
| + assert.equal(TOO_LONG, 'Message is too long.', 'catalogued in docs/superpowers/copy/2026-09-10-slice-2-strings.md'); |
| +}); |
| + |
| const build = (name) => new Function(lift(src, name) + '; return ' + name + ';')(); |
| const chatLine = build('chatLine'); |
| const trimLog = build('trimLog'); |
| const chatBytes = build('chatBytes'); |
| |
| +// mount closes over the module's constants and its three helpers, so |
| +// the harness hands it exactly those and nothing else. `document` goes |
| +// in undefined on purpose: mount must reach its document through |
| +// root.ownerDocument, and a panel that falls back to a global is one |
| +// that cannot be mounted inside a frame or a template. |
| +const mount = new Function( |
| + 'MAX_BYTES', |
| + 'MAX_LINES', |
| + 'PINNED_PX', |
| + 'SEND_FAILED', |
| + 'TOO_LONG', |
| + 'document', |
| + [lift(src, 'chatBytes'), lift(src, 'chatLine'), lift(src, 'trimLog'), lift(src, 'mount')].join('\n') + |
| + '\nreturn mount;', |
| +)(MAX_BYTES, MAX_LINES, PINNED_PX, SEND_FAILED, TOO_LONG, undefined); |
| + |
| // --- the smallest document these functions can be driven against --- |
| // |
| // Deliberately missing innerHTML: an element here has textContent, |
| @@ -63,46 +114,132 @@ function textOf(node) { |
| return node.tag === '#text' ? node.text : node.textContent; |
| } |
| |
| -function elem(tag) { |
| - const node = { tag, className: '', attrs: {}, children: [], own: '' }; |
| - Object.defineProperty(node, 'textContent', { |
| - get() { |
| - return node.children.length ? node.children.map(textOf).join('') : node.own; |
| - }, |
| - set(value) { |
| - node.own = String(value); |
| - node.children = []; |
| +function fakeDoc() { |
| + const doc = { |
| + activeElement: null, |
| + createTextNode: (text) => ({ tag: '#text', text }), |
| + createElement(tag) { |
| + const node = { |
| + tag, |
| + ownerDocument: doc, |
| + className: '', |
| + attrs: {}, |
| + children: [], |
| + own: '', |
| + listeners: {}, |
| + disabled: false, |
| + value: '', |
| + scrollTop: 0, |
| + clientHeight: 0, |
| + // Every line is this tall, so scrollHeight follows the log's |
| + // length the way a real one does. Zero until a test says |
| + // otherwise, which makes every other test's panel "at the |
| + // bottom" and keeps the scroll out of their way. |
| + lineHeight: 0, |
| + }; |
| + Object.defineProperty(node, 'textContent', { |
| + get() { |
| + return node.children.length ? node.children.map(textOf).join('') : node.own; |
| + }, |
| + set(value) { |
| + node.own = String(value); |
| + node.children = []; |
| + }, |
| + }); |
| + Object.defineProperty(node, 'scrollHeight', { |
| + get() { |
| + return node.lineHeight * node.childElementCount; |
| + }, |
| + }); |
| + Object.defineProperty(node, 'childElementCount', { |
| + get() { |
| + return node.children.filter((c) => c.tag !== '#text').length; |
| + }, |
| + }); |
| + Object.defineProperty(node, 'firstElementChild', { |
| + get() { |
| + return node.children.find((c) => c.tag !== '#text') || null; |
| + }, |
| + }); |
| + Object.defineProperty(node, 'firstChild', { |
| + get() { |
| + return node.children[0] || null; |
| + }, |
| + }); |
| + node.setAttribute = (key, value) => { |
| + node.attrs[key] = String(value); |
| + }; |
| + node.appendChild = (child) => { |
| + node.children.push(child); |
| + return child; |
| + }; |
| + node.removeChild = (child) => { |
| + node.children.splice(node.children.indexOf(child), 1); |
| + return child; |
| + }; |
| + node.contains = (other) => { |
| + if (!other) return false; |
| + if (other === node) return true; |
| + return node.children.some((c) => c.contains && c.contains(other)); |
| + }; |
| + node.querySelector = (sel) => { |
| + const key = sel.replace(/^\[|\]$/g, ''); |
| + for (const child of node.children) { |
| + if (child.attrs && key in child.attrs) return child; |
| + const deeper = child.querySelector && child.querySelector(sel); |
| + if (deeper) return deeper; |
| + } |
| + return null; |
| + }; |
| + node.addEventListener = (type, fn) => { |
| + (node.listeners[type] = node.listeners[type] || []).push(fn); |
| + }; |
| + node.focus = () => { |
| + doc.activeElement = node; |
| + }; |
| + return node; |
| }, |
| - }); |
| - node.setAttribute = (key, value) => { |
| - node.attrs[key] = String(value); |
| - }; |
| - node.appendChild = (child) => { |
| - node.children.push(child); |
| - return child; |
| }; |
| - return node; |
| + return doc; |
| } |
| |
| -const doc = { |
| - createElement: elem, |
| - createTextNode: (text) => ({ tag: '#text', text }), |
| -}; |
| +// fire dispatches one event and hands the event object back, so a test |
| +// can ask whether the handler called preventDefault. |
| +function fire(node, type, extra) { |
| + const ev = Object.assign( |
| + { |
| + defaultPrevented: false, |
| + preventDefault() { |
| + ev.defaultPrevented = true; |
| + }, |
| + }, |
| + extra, |
| + ); |
| + for (const fn of node.listeners[type] || []) fn(ev); |
| + return ev; |
| +} |
| |
| +// settle drains the microtask queue, which is where mount's send chain |
| +// lives. |
| +const settle = () => new Promise((resolve) => setImmediate(resolve)); |
| + |
| +const bare = fakeDoc(); |
| const partOf = (li, cls) => li.children.find((n) => n.className === cls); |
| |
| +// --- chatLine: §6's DOM safety --- |
| + |
| test('a message is written as text, so an angle bracket stays an angle bracket', () => { |
| const nasty = '<img src=x onerror="alert(1)"> & <b>bold</b>'; |
| - const li = chatLine(doc, { name: 'Ann', text: nasty, at: new Date('2026-09-10T09:07:00Z') }); |
| + const li = chatLine(bare, { name: 'Ann', text: nasty, at: new Date('2026-09-10T09:07:00Z') }); |
| assert.equal(partOf(li, 'chat-text').textContent, nasty); |
| // The whole line reads as text too: nothing was smuggled in as markup |
| // through the name either. |
| - const line = chatLine(doc, { name: '<script>', text: 'hi' }); |
| + const line = chatLine(bare, { name: '<script>', text: 'hi' }); |
| assert.equal(partOf(line, 'chat-name').textContent, '<script>'); |
| }); |
| |
| test('a link is not linkified in version one', () => { |
| - const li = chatLine(doc, { name: 'Ann', text: 'see https://example.com/x' }); |
| + const li = chatLine(bare, { name: 'Ann', text: 'see https://example.com/x' }); |
| const text = partOf(li, 'chat-text'); |
| assert.equal(text.textContent, 'see https://example.com/x'); |
| assert.equal(text.children.length, 0, 'the text node was split into elements — something linkified it'); |
| @@ -119,7 +256,7 @@ test('chat.js parses no markup anywhere', () => { |
| |
| test('a line carries the name, a local clock time and the machine instant', () => { |
| const at = new Date(2026, 8, 10, 9, 7, 0); // local, like a reader's clock |
| - const li = chatLine(doc, { name: 'Ann', text: 'hello', at }); |
| + const li = chatLine(bare, { name: 'Ann', text: 'hello', at }); |
| assert.equal(li.tag, 'li'); |
| assert.equal(li.className, 'chat-line'); |
| assert.equal(partOf(li, 'chat-name').textContent, 'Ann'); |
| @@ -129,19 +266,27 @@ test('a line carries the name, a local clock time and the machine instant', () = |
| assert.equal(time.attrs.datetime, at.toISOString()); |
| }); |
| |
| +test('the whole date is in title, since the visible text is only a clock', () => { |
| + const at = new Date(2026, 8, 10, 9, 7, 0); |
| + const li = chatLine(bare, { name: 'Ann', text: 'hello', at }); |
| + const time = partOf(li, 'chat-time'); |
| + assert.equal(time.attrs.title, at.toLocaleString()); |
| + assert.notEqual(time.attrs.title, time.textContent, 'title must say more than the clock does'); |
| +}); |
| + |
| test('an unusable timestamp becomes now rather than an Invalid Date on the screen', () => { |
| - const li = chatLine(doc, { name: 'Ann', text: 'hello', at: 'not a date' }); |
| + const li = chatLine(bare, { name: 'Ann', text: 'hello', at: 'not a date' }); |
| assert.match(partOf(li, 'chat-time').textContent, /^\d\d:\d\d$/); |
| - assert.doesNotThrow(() => chatLine(doc, {})); |
| + assert.doesNotThrow(() => chatLine(bare, {})); |
| }); |
| |
| test("the sender's own line is marked, and the name is still the signal", () => { |
| - const li = chatLine(doc, { name: 'Ann', text: 'hello', self: true }); |
| + const li = chatLine(bare, { name: 'Ann', text: 'hello', self: true }); |
| assert.equal(li.className, 'chat-line chat-line-self'); |
| assert.equal(partOf(li, 'chat-name').textContent, 'Ann'); |
| }); |
| |
| -// --- the cap --- |
| +// --- trimLog: the cap --- |
| |
| function fakeLog(count) { |
| const kids = []; |
| @@ -161,22 +306,22 @@ function fakeLog(count) { |
| }; |
| } |
| |
| -test('the log is capped at 500 lines and the oldest go first', () => { |
| - const log = fakeLog(640); |
| - trimLog(log, 500); |
| - assert.equal(log.childElementCount, 500); |
| +test('the log is capped and the oldest go first', () => { |
| + const log = fakeLog(MAX_LINES + 140); |
| + trimLog(log, MAX_LINES); |
| + assert.equal(log.childElementCount, MAX_LINES); |
| assert.equal(log.kids[0].n, 140, 'the oldest lines are the ones dropped'); |
| - assert.equal(log.kids[499].n, 639, 'the newest line must survive'); |
| + assert.equal(log.kids[MAX_LINES - 1].n, MAX_LINES + 139, 'the newest line must survive'); |
| }); |
| |
| test('a log inside the cap is left alone', () => { |
| - const log = fakeLog(500); |
| - trimLog(log, 500); |
| - assert.equal(log.childElementCount, 500); |
| + const log = fakeLog(MAX_LINES); |
| + trimLog(log, MAX_LINES); |
| + assert.equal(log.childElementCount, MAX_LINES); |
| assert.equal(log.kids[0].n, 0); |
| }); |
| |
| -// --- the byte counter --- |
| +// --- chatBytes: the byte counter --- |
| |
| test('the byte counter counts UTF-8 bytes, not characters', () => { |
| assert.equal(chatBytes(''), 0); |
| @@ -188,17 +333,291 @@ test('the byte counter counts UTF-8 bytes, not characters', () => { |
| }); |
| |
| test('maxlength is not the limit: 2048 characters can be far more than 2048 bytes', () => { |
| - const MAX = 2048; |
| // Exactly at the cap in ASCII, and the control would allow it. |
| - assert.equal(chatBytes('a'.repeat(MAX)), MAX); |
| + assert.equal(chatBytes('a'.repeat(MAX_BYTES)), MAX_BYTES); |
| // The same number of characters, four times the bytes: this is what |
| // the server refuses with a 413 and what the panel must catch first. |
| - assert.ok(chatBytes('🙂'.repeat(512)) === MAX); |
| - assert.ok(chatBytes('🙂'.repeat(513)) > MAX, 'over the cap in bytes while well under it in characters'); |
| - assert.ok(chatBytes('é'.repeat(MAX)) > MAX); |
| + assert.equal(chatBytes('🙂'.repeat(MAX_BYTES / 4)), MAX_BYTES); |
| + assert.ok(chatBytes('🙂'.repeat(MAX_BYTES / 4 + 1)) > MAX_BYTES, 'over the cap in bytes while well under it in characters'); |
| + assert.ok(chatBytes('é'.repeat(MAX_BYTES)) > MAX_BYTES); |
| }); |
| |
| test('the byte counter survives what a textarea can hand it', () => { |
| assert.equal(chatBytes(null), 0); |
| assert.equal(chatBytes(undefined), 0); |
| }); |
| + |
| +// --- mount: the contract with the host --- |
| + |
| +function panel() { |
| + const doc = fakeDoc(); |
| + const root = doc.createElement('section'); |
| + const log = doc.createElement('ol'); |
| + const form = doc.createElement('form'); |
| + const input = doc.createElement('textarea'); |
| + const button = doc.createElement('button'); |
| + const status = doc.createElement('p'); |
| + log.attrs['data-chat-log'] = ''; |
| + form.attrs['data-chat-form'] = ''; |
| + input.attrs['data-chat-text'] = ''; |
| + button.attrs['data-chat-send'] = ''; |
| + status.attrs['data-chat-status'] = ''; |
| + form.appendChild(input); |
| + form.appendChild(button); |
| + form.appendChild(status); |
| + root.appendChild(log); |
| + root.appendChild(form); |
| + return { doc, root, log, form, input, button, status }; |
| +} |
| + |
| +// spy records what the host was asked to send and answers with what the |
| +// test tells it to. |
| +function spy(answer) { |
| + const calls = []; |
| + const send = (text) => { |
| + calls.push(text); |
| + return typeof answer === 'function' ? answer(text) : answer; |
| + }; |
| + return { calls, send }; |
| +} |
| + |
| +test('a send in flight swallows every further submit', async () => { |
| + const p = panel(); |
| + let release; |
| + const held = spy(() => new Promise((resolve) => { release = resolve; })); |
| + mount(p.root, { name: 'Ann', send: held.send }); |
| + p.input.value = 'hello'; |
| + fire(p.form, 'submit'); |
| + fire(p.form, 'submit'); |
| + fire(p.input, 'keydown', { key: 'Enter' }); |
| + await settle(); |
| + assert.equal(held.calls.length, 1, 'one message, one send'); |
| + release(true); |
| + await settle(); |
| + assert.equal(held.calls.length, 1); |
| + assert.equal(p.input.value, '', 'an accepted message leaves the box'); |
| +}); |
| + |
| +test('the local echo is appended only when send answers exactly true', async () => { |
| + const p = panel(); |
| + const ok = spy(true); |
| + const chat = mount(p.root, { name: 'Ann', send: ok.send }); |
| + p.input.value = ' hello '; |
| + fire(p.form, 'submit'); |
| + await settle(); |
| + assert.deepEqual(ok.calls, ['hello'], 'the text is trimmed on the way out'); |
| + assert.equal(p.log.childElementCount, 1); |
| + const li = p.log.firstElementChild; |
| + assert.equal(partOf(li, 'chat-name').textContent, 'Ann'); |
| + assert.equal(partOf(li, 'chat-text').textContent, 'hello'); |
| + assert.equal(li.className, 'chat-line chat-line-self'); |
| + assert.equal(p.status.textContent, ''); |
| + assert.equal(p.input.value, ''); |
| + assert.equal(typeof chat.append, 'function'); |
| +}); |
| + |
| +test('a truthy answer that is not true is a refusal, not an echo', async () => { |
| + // The obvious wrong send is `fetch(...)`: a Response object is truthy |
| + // whatever its status, so `!ok` would echo a message the server |
| + // refused with a 413 and nobody else will ever see. |
| + const p = panel(); |
| + const response = spy({ ok: false, status: 413 }); |
| + mount(p.root, { name: 'Ann', send: response.send }); |
| + p.input.value = 'hello'; |
| + fire(p.form, 'submit'); |
| + await settle(); |
| + assert.equal(p.log.childElementCount, 0, 'nothing was echoed'); |
| + assert.equal(p.status.textContent, SEND_FAILED); |
| + assert.equal(p.input.value, 'hello', 'the draft stays in the box'); |
| +}); |
| + |
| +test('a refused send keeps the draft and says so', async () => { |
| + for (const answer of [false, undefined, null, Promise.resolve(false)]) { |
| + const p = panel(); |
| + const refused = spy(answer); |
| + mount(p.root, { name: 'Ann', send: refused.send }); |
| + p.input.value = 'hello'; |
| + fire(p.form, 'submit'); |
| + await settle(); |
| + assert.equal(p.log.childElementCount, 0); |
| + assert.equal(p.status.textContent, SEND_FAILED); |
| + assert.equal(p.input.value, 'hello'); |
| + assert.equal(p.button.disabled, false, 'and Send is live again for the retry'); |
| + } |
| +}); |
| + |
| +test('a send that throws is a refusal like any other', async () => { |
| + const p = panel(); |
| + const thrown = spy(() => { |
| + throw new Error('offline'); |
| + }); |
| + mount(p.root, { name: 'Ann', send: thrown.send }); |
| + p.input.value = 'hello'; |
| + fire(p.form, 'submit'); |
| + await settle(); |
| + assert.equal(p.status.textContent, SEND_FAILED); |
| + assert.equal(p.input.value, 'hello'); |
| +}); |
| + |
| +test('editing the draft clears the refusal sentence', async () => { |
| + const p = panel(); |
| + const refused = spy(false); |
| + mount(p.root, { name: 'Ann', send: refused.send }); |
| + p.input.value = 'hello'; |
| + fire(p.form, 'submit'); |
| + await settle(); |
| + assert.equal(p.status.textContent, SEND_FAILED); |
| + p.input.value = 'hello again'; |
| + fire(p.input, 'input'); |
| + assert.equal(p.status.textContent, '', 'the sentence was about text that no longer exists'); |
| +}); |
| + |
| +test('the host gate stops a send and leaves the log readable', async () => { |
| + const p = panel(); |
| + const ok = spy(true); |
| + const chat = mount(p.root, { name: 'Ann', send: ok.send }); |
| + chat.append({ name: 'Bob', text: 'still here' }); |
| + chat.setEnabled(false); |
| + assert.equal(p.input.disabled, true); |
| + assert.equal(p.button.disabled, true); |
| + p.input.value = 'hello'; |
| + fire(p.form, 'submit'); |
| + fire(p.input, 'keydown', { key: 'Enter' }); |
| + await settle(); |
| + assert.equal(ok.calls.length, 0, 'not admitted, not sending'); |
| + assert.equal(p.log.childElementCount, 1, 'what arrived earlier is still readable'); |
| + chat.setEnabled(true); |
| + assert.equal(p.input.disabled, false); |
| + assert.equal(p.button.disabled, false); |
| + fire(p.form, 'submit'); |
| + await settle(); |
| + assert.deepEqual(ok.calls, ['hello']); |
| +}); |
| + |
| +test('Enter sends, Shift+Enter does not, and an IME is left alone', async () => { |
| + const p = panel(); |
| + const ok = spy(true); |
| + mount(p.root, { name: 'Ann', send: ok.send }); |
| + p.input.value = 'hello'; |
| + |
| + const shift = fire(p.input, 'keydown', { key: 'Enter', shiftKey: true }); |
| + assert.equal(shift.defaultPrevented, false, 'Shift+Enter must reach the textarea as a newline'); |
| + |
| + // Enter mid-composition is how an IME commits characters; taking it |
| + // would send half a word and eat the rest. |
| + const composing = fire(p.input, 'keydown', { key: 'Enter', isComposing: true }); |
| + const legacy = fire(p.input, 'keydown', { key: 'Enter', keyCode: 229 }); |
| + assert.equal(composing.defaultPrevented, false); |
| + assert.equal(legacy.defaultPrevented, false); |
| + |
| + const other = fire(p.input, 'keydown', { key: 'a' }); |
| + assert.equal(other.defaultPrevented, false); |
| + await settle(); |
| + assert.equal(ok.calls.length, 0, 'none of those was a send'); |
| + |
| + const enter = fire(p.input, 'keydown', { key: 'Enter' }); |
| + assert.equal(enter.defaultPrevented, true, 'a plain Enter must not also insert a newline'); |
| + await settle(); |
| + assert.deepEqual(ok.calls, ['hello']); |
| +}); |
| + |
| +test('an over-long draft says so and disables Send, in bytes not characters', async () => { |
| + const p = panel(); |
| + const ok = spy(true); |
| + mount(p.root, { name: 'Ann', send: ok.send }); |
| + |
| + // Well inside maxlength as characters, well past the cap as bytes. |
| + p.input.value = '🙂'.repeat(MAX_BYTES / 4 + 1); |
| + fire(p.input, 'input'); |
| + assert.equal(p.status.textContent, TOO_LONG); |
| + assert.equal(p.button.disabled, true); |
| + |
| + fire(p.form, 'submit'); |
| + fire(p.input, 'keydown', { key: 'Enter' }); |
| + await settle(); |
| + assert.equal(ok.calls.length, 0, 'neither path sends what the server would refuse'); |
| + |
| + // Exactly at the cap is inside it. |
| + p.input.value = '🙂'.repeat(MAX_BYTES / 4); |
| + fire(p.input, 'input'); |
| + assert.equal(p.status.textContent, '', 'the sentence clears itself when the draft comes back under'); |
| + assert.equal(p.button.disabled, false); |
| + fire(p.form, 'submit'); |
| + await settle(); |
| + assert.equal(ok.calls.length, 1); |
| +}); |
| + |
| +test('an empty box is nothing to send, the same nothing on both paths', async () => { |
| + const p = panel(); |
| + const ok = spy(true); |
| + mount(p.root, { name: 'Ann', send: ok.send }); |
| + for (const draft of ['', ' ', '\n\n']) { |
| + p.input.value = draft; |
| + fire(p.form, 'submit'); |
| + fire(p.input, 'keydown', { key: 'Enter' }); |
| + } |
| + await settle(); |
| + assert.equal(ok.calls.length, 0); |
| + assert.equal(p.status.textContent, '', 'and nothing is said about it'); |
| +}); |
| + |
| +test('append writes an arrived message and clear empties the log', () => { |
| + const p = panel(); |
| + const chat = mount(p.root, { name: 'Ann', send: () => true }); |
| + chat.append({ from: 'peer1', name: 'Bob', text: '<not markup>' }); |
| + chat.append({ from: 'peer1', name: 'Bob', text: 'second' }); |
| + assert.equal(p.log.childElementCount, 2); |
| + assert.equal(partOf(p.log.firstElementChild, 'chat-text').textContent, '<not markup>'); |
| + chat.clear(); |
| + assert.equal(p.log.childElementCount, 0); |
| + assert.equal(p.log.children.length, 0); |
| +}); |
| + |
| +test('the log stays at its cap through the panel too', () => { |
| + const p = panel(); |
| + const chat = mount(p.root, { name: 'Ann', send: () => true }); |
| + for (let i = 0; i < MAX_LINES + 3; i++) chat.append({ name: 'Bob', text: 'line ' + i }); |
| + assert.equal(p.log.childElementCount, MAX_LINES); |
| + assert.equal(partOf(p.log.firstElementChild, 'chat-text').textContent, 'line 3'); |
| +}); |
| + |
| +test('a reader at the bottom is kept at the bottom', () => { |
| + const p = panel(); |
| + p.log.lineHeight = 20; |
| + p.log.clientHeight = 100; |
| + const chat = mount(p.root, { name: 'Ann', send: () => true }); |
| + for (let i = 0; i < 20; i++) chat.append({ name: 'Bob', text: 'line ' + i }); |
| + assert.equal(p.log.scrollTop, p.log.scrollHeight, 'each arrival scrolls to the newest line'); |
| + assert.ok(PINNED_PX > 0, 'and there is a tolerance, so a pixel of rounding is not "scrolled up"'); |
| +}); |
| + |
| +test('a trimmed log does not slide the text a scrolled-up reader is looking at', () => { |
| + const p = panel(); |
| + p.log.lineHeight = 20; |
| + p.log.clientHeight = 100; |
| + const chat = mount(p.root, { name: 'Ann', send: () => true }); |
| + for (let i = 0; i < MAX_LINES; i++) chat.append({ name: 'Bob', text: 'line ' + i }); |
| + |
| + // The reader scrolls back up to something older. From here every |
| + // arrival trims one line off the top, and leaving scrollTop alone |
| + // would walk that text up and out of the box. |
| + p.log.scrollTop = 200; |
| + chat.append({ name: 'Bob', text: 'newest' }); |
| + assert.equal(p.log.childElementCount, MAX_LINES, 'still at the cap, so a line was removed'); |
| + assert.equal(p.log.scrollTop, 180, 'scrollTop moves up by the height of the line that went'); |
| + |
| + chat.append({ name: 'Bob', text: 'newer still' }); |
| + assert.equal(p.log.scrollTop, 160); |
| +}); |
| + |
| +test('a panel that is not there, or not complete, returns a working no-op', async () => { |
| + for (const root of [null, undefined, fakeDoc().createElement('section')]) { |
| + const ok = spy(true); |
| + const chat = mount(root, { name: 'Ann', send: ok.send }); |
| + assert.doesNotThrow(() => chat.append({ name: 'Bob', text: 'hi' })); |
| + assert.doesNotThrow(() => chat.setEnabled(false)); |
| + assert.doesNotThrow(() => chat.clear()); |
| + await settle(); |
| + assert.equal(ok.calls.length, 0); |
| + } |
| +}); |