135 lines
7 KiB
TypeScript
135 lines
7 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { test } from 'node:test';
|
|
import type { Draft } from '../src/core/types.ts';
|
|
import { insertText, matchesContext, pickerPosition, preview, rankDrafts } from '../src/shell/draftPickerModel.ts';
|
|
import { MAX_REPORT_CHARS, extractTweets, installKeywordAlerts, matchKeyword, normalizeKeywords, reportSnippet, type KeywordHit } from '../src/inject/alerts.ts';
|
|
|
|
const draft = (patch: Partial<Draft>): Draft => ({
|
|
id: 'd', accountId: 'unassigned-x', kind: 'post', title: '', text: 'hello', contextUrl: '', revision: 1,
|
|
status: 'active', createdAt: '2026-09-01T00:00:00Z', updatedAt: '2026-09-01T00:00:00Z', source: 'local', ...patch,
|
|
});
|
|
|
|
test('rankDrafts puts this page first, then the composer kind, then recency', () => {
|
|
const drafts = [
|
|
draft({ id: 'old-post', kind: 'post', updatedAt: '2026-09-01T00:00:00Z' }),
|
|
draft({ id: 'new-post', kind: 'post', updatedAt: '2026-09-05T00:00:00Z' }),
|
|
draft({ id: 'reply', kind: 'reply', updatedAt: '2026-09-02T00:00:00Z' }),
|
|
draft({ id: 'here', kind: 'post', contextUrl: 'https://x.com/jack/status/20', updatedAt: '2026-08-01T00:00:00Z' }),
|
|
draft({ id: 'archived', status: 'archived', updatedAt: '2026-09-09T00:00:00Z' }),
|
|
draft({ id: 'blank', text: ' ', updatedAt: '2026-09-09T00:00:00Z' }),
|
|
];
|
|
const ranked = rankDrafts(drafts, { kind: 'reply', contextUrl: 'https://x.com/jack/status/20/' });
|
|
assert.deepEqual(ranked.map(d => d.id), ['here', 'reply', 'new-post', 'old-post']);
|
|
});
|
|
|
|
test('rankDrafts filters by query on title and text', () => {
|
|
const drafts = [draft({ id: 'a', title: 'Launch', text: 'x' }), draft({ id: 'b', text: 'about launch day' }), draft({ id: 'c', text: 'nope' })];
|
|
assert.deepEqual(rankDrafts(drafts, { contextUrl: '' }, 'LAUNCH').map(d => d.id).sort(), ['a', 'b']);
|
|
});
|
|
|
|
test('matchesContext ignores query, hash and trailing slash', () => {
|
|
assert.ok(matchesContext(draft({ contextUrl: 'https://x.com/a/status/1' }), 'https://x.com/a/status/1/?s=20#x'));
|
|
assert.ok(!matchesContext(draft({ contextUrl: '' }), 'https://x.com/home'));
|
|
});
|
|
|
|
test('insertText is the draft text, unchanged', () => {
|
|
assert.equal(insertText(draft({ text: 'line 1\nline 2' })), 'line 1\nline 2');
|
|
});
|
|
|
|
test('pickerPosition anchors under the button and stays inside the pane', () => {
|
|
const pane = { left: 100, top: 94, width: 800, height: 600 };
|
|
const size = { width: 380, height: 420 };
|
|
assert.deepEqual(pickerPosition(pane, { x: 200, y: 150 }, size), { left: 300, top: 250 });
|
|
// Near the right/bottom edge it is pushed back inside.
|
|
assert.deepEqual(pickerPosition(pane, { x: 790, y: 590 }, size), { left: 512, top: 266 });
|
|
// Without a point: centered near the top.
|
|
assert.deepEqual(pickerPosition(pane, { x: null, y: null }, size), { left: 310, top: 118 });
|
|
});
|
|
|
|
test('preview flattens whitespace and truncates', () => {
|
|
assert.equal(preview('a\n\n b', 10), 'a b');
|
|
assert.equal(preview('abcdefghijkl', 5), 'abcd…');
|
|
});
|
|
|
|
const timeline = {
|
|
data: { home: { home_timeline_urt: { instructions: [{ entries: [
|
|
{ content: { itemContent: { tweet_results: { result: {
|
|
__typename: 'Tweet', rest_id: '111',
|
|
core: { user_results: { result: { core: { screen_name: 'jack' } } } },
|
|
legacy: { full_text: 'Shipping Tauri apps today' },
|
|
} } } } },
|
|
{ content: { itemContent: { tweet_results: { result: {
|
|
__typename: 'Tweet', rest_id: '222',
|
|
core: { user_results: { result: { legacy: { screen_name: 'bad name!' } } } },
|
|
legacy: { full_text: 'short' },
|
|
note_tweet: { note_tweet_results: { result: { text: 'A long note about Better Twttr' } } },
|
|
} } } } },
|
|
{ content: { itemContent: { user_results: { result: { rest_id: '999', legacy: { description: 'tauri fan' } } } } } },
|
|
] }] } } },
|
|
};
|
|
|
|
test('extractTweets finds tweets with text, prefers note tweets, rejects odd screen names', () => {
|
|
const tweets = extractTweets(timeline);
|
|
assert.deepEqual(tweets.map(t => t.tweetId).sort(), ['111', '222']);
|
|
const note = tweets.find(t => t.tweetId === '222')!;
|
|
assert.equal(note.text, 'A long note about Better Twttr');
|
|
assert.equal(note.author, '');
|
|
assert.equal(tweets.find(t => t.tweetId === '111')!.author, 'jack');
|
|
});
|
|
|
|
test('keywords are normalized and matched case-insensitively across whitespace', () => {
|
|
assert.deepEqual(normalizeKeywords([' Better Twttr ', 'x', 42, 'tauri', 'TAURI']), ['better twttr', 'tauri']);
|
|
assert.deepEqual(normalizeKeywords('tauri'), []);
|
|
assert.equal(matchKeyword('A long note about Better Twttr', ['tauri', 'better twttr']), 'better twttr');
|
|
assert.equal(matchKeyword('nothing', ['tauri']), null);
|
|
});
|
|
|
|
type FakeWindow = { fetch: typeof fetch };
|
|
|
|
test('keyword alerts tap only while keywords exist and report each tweet once', async () => {
|
|
const body = JSON.stringify(timeline);
|
|
const original: typeof fetch = async () => new Response(body, { status: 200, headers: { 'content-type': 'application/json' } });
|
|
const win: FakeWindow = { fetch: original };
|
|
const hits: KeywordHit[] = [];
|
|
const alerts = installKeywordAlerts(win, async (_command, args) => { hits.push(args.hit); });
|
|
assert.equal(win.fetch, original, 'no tap without keywords');
|
|
alerts.set(['tauri']);
|
|
assert.notEqual(win.fetch, original);
|
|
const url = 'https://x.com/i/api/graphql/abc/HomeTimeline?variables=%7B%7D';
|
|
const response = await win.fetch(url);
|
|
assert.equal(await response.text(), body, 'X still receives the original body');
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
await win.fetch(url);
|
|
await new Promise(resolve => setTimeout(resolve, 20));
|
|
assert.equal(hits.length, 1);
|
|
assert.deepEqual(hits[0], { keyword: 'tauri', tweetId: '111', author: 'jack', text: 'Shipping Tauri apps today' });
|
|
// Non-timeline requests are never read.
|
|
await win.fetch('https://x.com/i/api/1.1/account/settings.json');
|
|
// The alert off: the keywords stay (Notifications+ highlights them), nothing is read or reported.
|
|
alerts.enable(false);
|
|
assert.equal(win.fetch, original, 'no tap while the alert is off');
|
|
alerts.ingest(JSON.parse(body));
|
|
assert.equal(hits.length, 1);
|
|
alerts.enable(true);
|
|
assert.notEqual(win.fetch, original);
|
|
alerts.set([]);
|
|
assert.equal(win.fetch, original, 'tap removed when keywords are cleared');
|
|
alerts.dispose();
|
|
});
|
|
|
|
test('keyword alerts without IPC stay silent', () => {
|
|
const alerts = installKeywordAlerts({ fetch: (async () => new Response('{}')) as typeof fetch }, undefined);
|
|
alerts.set(['tauri']);
|
|
assert.doesNotThrow(() => alerts.ingest(timeline));
|
|
alerts.dispose();
|
|
});
|
|
|
|
test('report snippet keeps a late keyword and never splits surrogate pairs', () => {
|
|
const long = `${'😀'.repeat(900)} late Tauri mention ${'b'.repeat(900)}`;
|
|
const snippet = reportSnippet(long, 'tauri');
|
|
assert.ok(Array.from(snippet).length <= MAX_REPORT_CHARS);
|
|
assert.ok(snippet.toLowerCase().includes('tauri'));
|
|
assert.ok(!/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/.test(snippet));
|
|
assert.ok(new TextEncoder().encode(snippet).length <= 280 * 8, 'fits the native size limit');
|
|
assert.equal(reportSnippet('short\n text', 'text'), 'short text');
|
|
});
|