import assert from 'node:assert/strict'; import test from 'node:test'; import { EventType } from '../types/stats'; import { buildSessionChartEvents, collectPendingSessionEventNoteIds, extractSessionEventNoteInfo, getSessionEventCardRequest, mergeSessionEventNoteInfos, projectSessionMarkerLeftPx, resolveActiveSessionMarkerKey, togglePinnedSessionMarkerKey, } from './session-events'; test('buildSessionChartEvents produces typed hover markers with parsed payload metadata', () => { const chartEvents = buildSessionChartEvents([ { eventType: EventType.PAUSE_START, tsMs: 2_000, payload: null }, { eventType: EventType.SEEK_FORWARD, tsMs: 3_000, payload: '{"fromMs":1000,"toMs":5500}', }, { eventType: EventType.PAUSE_END, tsMs: 5_000, payload: null }, { eventType: EventType.CARD_MINED, tsMs: 6_000, payload: '{"cardsMined":2,"noteIds":[11,22]}', }, { eventType: EventType.YOMITAN_LOOKUP, tsMs: 7_000, payload: null }, ]); // Seek events are intentionally dropped — too noisy on the session chart. assert.deepEqual( chartEvents.markers.map((marker) => marker.kind), ['pause', 'card'], ); const pauseMarker = chartEvents.markers[0]!; assert.equal(pauseMarker.kind, 'pause'); assert.equal(pauseMarker.startMs, 2_000); assert.equal(pauseMarker.endMs, 5_000); assert.equal(pauseMarker.durationMs, 3_000); assert.equal(pauseMarker.anchorTsMs, 3_500); const cardMarker = chartEvents.markers[1]!; assert.equal(cardMarker.kind, 'card'); assert.deepEqual(cardMarker.noteIds, [11, 22]); assert.equal(cardMarker.cardsDelta, 2); assert.deepEqual( chartEvents.yomitanLookupEvents.map((event) => event.tsMs), [7_000], ); }); test('projectSessionMarkerLeftPx respects chart plot offsets instead of full-width percentages', () => { assert.equal( projectSessionMarkerLeftPx({ anchorTsMs: 1_000, tsMin: 1_000, tsMax: 11_000, plotLeftPx: 5, plotWidthPx: 958, }), 5, ); assert.equal( projectSessionMarkerLeftPx({ anchorTsMs: 6_000, tsMin: 1_000, tsMax: 11_000, plotLeftPx: 5, plotWidthPx: 958, }), 484, ); assert.equal( projectSessionMarkerLeftPx({ anchorTsMs: 11_000, tsMin: 1_000, tsMax: 11_000, plotLeftPx: 5, plotWidthPx: 958, }), 963, ); }); test('extractSessionEventNoteInfo prefers expression-like fields and strips html', () => { const info = extractSessionEventNoteInfo({ noteId: 91, fields: { Sentence: { value: '
この呪いの剣は危険だ
' }, Vocabulary: { value: '呪いの剣' }, Meaning: { value: '
cursed sword
' }, }, }); assert.deepEqual(info, { noteId: 91, expression: '呪いの剣', context: 'この呪いの剣は危険だ', meaning: 'cursed sword', }); }); test('extractSessionEventNoteInfo prefers explicit preview payload over field-name guessing', () => { const info = extractSessionEventNoteInfo({ noteId: 92, preview: { word: '連れる', sentence: 'このまま 連れてって', translation: 'to take along', }, fields: { UnexpectedWordField: { value: 'should not win' }, UnexpectedSentenceField: { value: 'should not win either' }, }, }); assert.deepEqual(info, { noteId: 92, expression: '連れる', context: 'このまま 連れてって', meaning: 'to take along', }); }); test('extractSessionEventNoteInfo ignores malformed notes without a numeric note id', () => { assert.equal( extractSessionEventNoteInfo({ noteId: Number.NaN, fields: { Vocabulary: { value: '呪い' }, }, }), null, ); }); test('mergeSessionEventNoteInfos keys previews by both requested and returned note ids', () => { const noteInfos = mergeSessionEventNoteInfos( [111], [ { noteId: 222, fields: { Expression: { value: '呪い' }, Sentence: { value: 'この剣は呪いだ' }, }, }, ], ); assert.deepEqual(noteInfos.get(111), { noteId: 222, expression: '呪い', context: 'この剣は呪いだ', meaning: null, }); assert.deepEqual(noteInfos.get(222), { noteId: 222, expression: '呪い', context: 'この剣は呪いだ', meaning: null, }); }); test('collectPendingSessionEventNoteIds supports strict-mode cleanup and refetch', () => { const noteInfos = new Map(); const pendingNoteIds = new Set(); assert.deepEqual(collectPendingSessionEventNoteIds([177], noteInfos, pendingNoteIds), [177]); pendingNoteIds.add(177); assert.deepEqual(collectPendingSessionEventNoteIds([177], noteInfos, pendingNoteIds), []); pendingNoteIds.delete(177); assert.deepEqual(collectPendingSessionEventNoteIds([177], noteInfos, pendingNoteIds), [177]); noteInfos.set(177, { noteId: 177, expression: '対抗', context: 'ダクネス 無理して 対抗 するな', meaning: null, }); assert.deepEqual(collectPendingSessionEventNoteIds([177], noteInfos, pendingNoteIds), []); }); test('getSessionEventCardRequest stays stable across rebuilt marker objects', () => { const events = [ { eventType: EventType.CARD_MINED, tsMs: 6_000, payload: '{"cardsMined":1,"noteIds":[1773808840964]}', }, ]; const firstMarker = buildSessionChartEvents(events).markers[0]!; const secondMarker = buildSessionChartEvents(events).markers[0]!; assert.notEqual(firstMarker, secondMarker); assert.deepEqual(getSessionEventCardRequest(firstMarker), { noteIds: [1773808840964], requestKey: 'card-6000:1773808840964', }); assert.deepEqual(getSessionEventCardRequest(secondMarker), { noteIds: [1773808840964], requestKey: 'card-6000:1773808840964', }); }); test('session marker pin helpers prefer pinned markers and toggle on repeat clicks', () => { assert.equal(resolveActiveSessionMarkerKey('card-1', 'seek-2'), 'seek-2'); assert.equal(resolveActiveSessionMarkerKey('card-1', null), 'card-1'); assert.equal(togglePinnedSessionMarkerKey(null, 'card-1'), 'card-1'); assert.equal(togglePinnedSessionMarkerKey('card-1', 'card-1'), null); assert.equal(togglePinnedSessionMarkerKey('card-1', 'seek-2'), 'seek-2'); });