Files
odysseus/tests/selectedNotesContext.test.mjs
T
Alexandre TeixeiraandLéo f1a47f3812 refactor(notes): decompose Notes into feature-owned modules (#6713)
* refactor(notes): decompose Notes into feature-owned modules

* fix(notes): avoid HTML interpolation in reminder picker

* docs(notes): drop checkpoint archives, session logs and publication drafts

The N0-N6 tarballs and raw pytest/node logs are not read by any script or
test, and the logs carry absolute home-directory paths. Once squashed into
dev they would stay in history for good. notes-pr.md and notes-issue.md
were the drafts used to open #6712 and #6713, not documentation.

notes-validation.md keeps the counts, the checkpoint hashes and
implementation.sha256 as the record; local interpreter paths and the local
Docker image name are replaced with portable placeholders.

* fix(notes): keep the state store raw and stop masonry re-observing after close

- state: writes built from views (filter, spread, splice) stored nested
  read-only views in the store, so structuredClone on a stored note threw
  DataCloneError. Store writes now unwrap nested views.
- layout: a save that resolves after closePanel re-renders the leaving pane
  and re-created the masonry ResizeObserver that teardown had disposed.
  Skip creating it once Notes is closed.
- index.js: drop 30 imported or declared names that were never used and
  group the imports at the top. Import order is unchanged.
- Revert unrelated punctuation edits in .gitattributes and keep the header
  comment and docstring first in compare/vote.js and
  test_notes_dom_xss_helpers.py.

Both fixes have a node test that fails on the previous head.

* test(notes): make script rejection assertion case-insensitive

Address CodeQL js/bad-tag-filter alert #1150 in test-only assertion and refresh Notes evidence manifest checksum.

---------

Co-authored-by: Léo <leograndcontact@gmail.com>
2026-10-15 09:29:41 +01:00

82 lines
3.9 KiB
JavaScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const chatSource = readFileSync(new URL('../static/js/chat.js', import.meta.url), 'utf8');
const sessionSource = readFileSync(new URL('../static/js/sessions.js', import.meta.url), 'utf8');
const notesSource = readFileSync(new URL('../static/js/features/notes/application/selection.js', import.meta.url), 'utf8');
function setup() {
const start = chatSource.indexOf(" window.addEventListener('odysseus:notes-context-selected'");
const end = chatSource.indexOf(' function _fmtContextNumber(n)', start);
assert.ok(start !== -1 && end > start, 'production event handlers must exist');
const handlers = new Map();
const rendered = [];
let sessionId = null;
let pending = false;
let clear;
const win = { addEventListener: (name, fn) => handlers.set(name, fn) };
const doc = {
getElementById: () => ({ addEventListener: (name, fn) => { if (name === 'click') clear = fn; } }),
};
const sessionModule = {
getCurrentSessionId: () => sessionId,
hasPendingChat: () => pending,
};
vm.runInNewContext(
`const _selectedNoteIdsBySession = new Map(); let _pendingSelectedNoteIds = [];
${chatSource.slice(start, end)}`,
{ window: win, document: doc, sessionModule, console, _renderSelectedNotesIndicator: ids => rendered.push(Array.from(ids)) },
);
return {
setSession(id, isPending = false) { sessionId = id; pending = isPending; },
emit(name, detail) { handlers.get(name)({ detail }); },
selected(ids) { const detail = { noteIds: ids, accepted: false }; handlers.get('odysseus:notes-context-selected')({ detail }); return detail; },
clear() { clear(); },
lastRendered() { return rendered.at(-1); },
};
}
test('pending selections transfer only to the materialized chat', () => {
const ctx = setup();
ctx.setSession(null, true);
assert.equal(ctx.selected(['a', 'b']).accepted, true);
assert.deepEqual(ctx.lastRendered(), ['a', 'b']);
ctx.setSession('new-chat');
ctx.emit('odysseus:session-changed', { sessionId: 'new-chat', materialized: true });
assert.deepEqual(ctx.lastRendered(), ['a', 'b']);
ctx.setSession('other-chat');
ctx.emit('odysseus:session-changed', { sessionId: 'other-chat' });
assert.deepEqual(ctx.lastRendered(), []);
ctx.setSession('new-chat');
ctx.emit('odysseus:session-changed', { sessionId: 'new-chat' });
assert.deepEqual(ctx.lastRendered(), ['a', 'b']);
ctx.clear();
assert.deepEqual(ctx.lastRendered(), []);
});
test('new chat resets old indicator and invalid selection is rejected', () => {
const ctx = setup();
assert.equal(ctx.selected(['a']).accepted, false);
ctx.setSession('old-chat');
assert.equal(ctx.selected(['a']).accepted, true);
ctx.setSession(null, true);
ctx.emit('odysseus:session-changed', { sessionId: null });
assert.deepEqual(ctx.lastRendered(), []);
assert.equal(ctx.selected(['b']).accepted, true);
ctx.emit('odysseus:session-changed', { sessionId: null });
assert.deepEqual(ctx.lastRendered(), []);
ctx.setSession('fresh');
ctx.emit('odysseus:session-changed', { sessionId: 'fresh', materialized: true });
assert.deepEqual(ctx.lastRendered(), []);
});
test('producer events and rejection acknowledgement are wired in source', () => {
const create = sessionSource.slice(sessionSource.indexOf('export function createDirectChat'), sessionSource.indexOf('export async function materializePendingSession'));
const materialize = sessionSource.slice(sessionSource.indexOf('export async function materializePendingSession'), sessionSource.indexOf('export function preMaterializePendingSession'));
assert.match(create, /currentSessionId = null;[\s\S]{0,180}odysseus:session-changed/);
assert.match(materialize, /currentSessionId = payload.id;[\s\S]{0,180}odysseus:session-changed/);
assert.match(notesSource, /if \(!detail\.accepted\)/);
});