Files
TREK/client/tests/unit/mobile/trip/collabModel.test.ts
T
Maurice 7298285a43 refactor(collab): share the chat logic between desktop and phone
collabModel moves from the phone tabs into components/Collab so both
shells read it, and the desktop chat drops its own copies of the time,
date separator and emoji only helpers for the model's.

useCollabChatData holds the messages, paging, live updates, the draft with
its images, and sending, deleting and reacting. The desktop hook keeps its
emoji picker, hover and delete animation, the phone tab its long press
popover; where they differed (older page errors, scrolling after a remote
delete, upload progress, the edit permission on send) the hook takes an
option.
2026-10-09 15:19:43 +02:00

316 lines
12 KiB
TypeScript

import { describe, it, expect, afterEach, vi } from 'vitest';
import {
NOTE_COLORS,
OTHER_BUBBLE_RADIUS,
OWN_BUBBLE_RADIUS,
QUICK_REACTIONS,
buildCategoryColorMap,
formatChatClockTime,
formatChatDateSeparator,
formatPollCountdown,
getCategoryColor,
hasUserVoted,
isEmojiOnlyText,
isPollActive,
isPollExpired,
isSameSender,
noteCategoriesList,
parseUtcDate,
pollMaxVoteCount,
shouldShowChatDateSeparator,
sortNotes,
splitPolls,
totalPollVotes,
type ChatMessage,
type CollabNoteData,
type CollabPollData,
type PollVoter,
} from '../../../../src/components/Collab/collabModel';
// FE-MOB-CLBM-001 to FE-MOB-CLBM-023
/** SQLite-style timestamp (UTC, no 'Z') for a given local wall-clock time, so
* assertions on getHours() hold in any test-runner timezone. */
function utcStampForLocal(y: number, monthIndex: number, d: number, h: number, min = 0): string {
return new Date(y, monthIndex, d, h, min, 0, 0).toISOString().slice(0, -1);
}
function msg(overrides: Partial<ChatMessage> = {}): ChatMessage {
return {
id: 1,
trip_id: 1,
user_id: 1,
text: 'hi',
reply_to: null,
username: 'ada',
avatar: null,
avatar_url: null,
created_at: '2026-07-15T10:00:00',
reactions: [],
...overrides,
};
}
function note(overrides: Partial<CollabNoteData> = {}): CollabNoteData {
return {
id: 1,
trip_id: 1,
user_id: 1,
title: 'Note',
content: null,
category: null,
color: null,
website: null,
pinned: 0,
username: 'ada',
avatar: null,
avatar_url: null,
created_at: '2026-07-01T10:00:00.000Z',
attachments: [],
...overrides,
};
}
function voter(user_id: number): PollVoter {
return { id: user_id * 10, user_id, username: `u${user_id}`, avatar: null, avatar_url: null };
}
function poll(overrides: Partial<CollabPollData> = {}): CollabPollData {
return {
id: 1,
trip_id: 1,
user_id: 1,
question: 'Where to?',
options: [],
multiple_choice: false,
is_closed: false,
deadline: null,
username: 'ada',
avatar: null,
avatar_url: null,
created_at: '2026-07-01T10:00:00.000Z',
...overrides,
};
}
const t = (key: string, params?: Record<string, string | number>) =>
params ? `${key}:${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')}` : key;
afterEach(() => {
vi.useRealTimers();
});
describe('collabModel — constants', () => {
it('FE-MOB-CLBM-001: exposes the quick reaction set, bubble radii and note palette', () => {
expect(QUICK_REACTIONS).toHaveLength(8);
expect(QUICK_REACTIONS).toContain('❤️');
expect(new Set(QUICK_REACTIONS).size).toBe(QUICK_REACTIONS.length);
// Logical corners: in a left-to-right language the same as '16px 16px 4px 16px'
// and '4px 16px 16px 16px', and the tail follows the row in right to left.
expect(OWN_BUBBLE_RADIUS).toEqual({ borderStartStartRadius: 16, borderStartEndRadius: 16, borderEndEndRadius: 4, borderEndStartRadius: 16 });
expect(OTHER_BUBBLE_RADIUS).toEqual({ borderStartStartRadius: 4, borderStartEndRadius: 16, borderEndEndRadius: 16, borderEndStartRadius: 16 });
expect(NOTE_COLORS).toHaveLength(6);
});
});
describe('collabModel — chat', () => {
it('FE-MOB-CLBM-002: parseUtcDate reads a suffix-less SQLite timestamp as UTC', () => {
expect(parseUtcDate('2026-07-15T10:30:00').getTime()).toBe(Date.UTC(2026, 6, 15, 10, 30, 0));
expect(parseUtcDate('2026-07-15T10:30:00Z').getTime()).toBe(Date.UTC(2026, 6, 15, 10, 30, 0));
});
it('FE-MOB-CLBM-003: isEmojiOnlyText accepts up to three emoji and nothing else', () => {
expect(isEmojiOnlyText('👍')).toBe(true);
expect(isEmojiOnlyText(' ❤️ ')).toBe(true);
expect(isEmojiOnlyText('🔥🎉👏')).toBe(true);
expect(isEmojiOnlyText('👨‍👩‍👧')).toBe(true);
});
it('FE-MOB-CLBM-004: isEmojiOnlyText rejects text and long emoji runs', () => {
expect(isEmojiOnlyText('nice 👍')).toBe(false);
expect(isEmojiOnlyText('🔥🎉👏😮')).toBe(false);
expect(isEmojiOnlyText('')).toBe(false);
});
it('FE-MOB-CLBM-005: formatChatClockTime renders zero-padded 24h time', () => {
expect(formatChatClockTime(utcStampForLocal(2026, 6, 15, 9, 5), false)).toBe('09:05');
expect(formatChatClockTime(utcStampForLocal(2026, 6, 15, 23, 59), false)).toBe('23:59');
});
it('FE-MOB-CLBM-006: formatChatClockTime maps midnight and noon correctly in 12h mode', () => {
expect(formatChatClockTime(utcStampForLocal(2026, 6, 15, 0, 5), true)).toBe('12:05 AM');
expect(formatChatClockTime(utcStampForLocal(2026, 6, 15, 12, 0), true)).toBe('12:00 PM');
expect(formatChatClockTime(utcStampForLocal(2026, 6, 15, 15, 30), true)).toBe('3:30 PM');
expect(formatChatClockTime(utcStampForLocal(2026, 6, 15, 7, 30), true)).toBe('7:30 AM');
});
it('FE-MOB-CLBM-007: formatChatDateSeparator labels today and yesterday', () => {
const now = new Date();
expect(formatChatDateSeparator(now.toISOString(), t, 'en-US')).toBe('collab.chat.today');
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
expect(formatChatDateSeparator(yesterday.toISOString(), t, 'en-US')).toBe('collab.chat.yesterday');
});
it('FE-MOB-CLBM-008: formatChatDateSeparator falls back to a localized date', () => {
expect(formatChatDateSeparator(utcStampForLocal(2020, 2, 5, 12), t, 'en-US')).toBe('Mar 5, 2020');
});
it('FE-MOB-CLBM-009: shouldShowChatDateSeparator triggers on the first message and on day changes', () => {
const a = msg({ created_at: utcStampForLocal(2026, 6, 15, 20) });
const b = msg({ id: 2, created_at: utcStampForLocal(2026, 6, 15, 22) });
const c = msg({ id: 3, created_at: utcStampForLocal(2026, 6, 16, 9) });
expect(shouldShowChatDateSeparator(a, undefined)).toBe(true);
expect(shouldShowChatDateSeparator(b, a)).toBe(false);
expect(shouldShowChatDateSeparator(c, b)).toBe(true);
});
it('FE-MOB-CLBM-010: isSameSender compares user ids loosely and needs both messages', () => {
const a = msg({ user_id: 7 });
const b = msg({ id: 2, user_id: 7 });
const other = msg({ id: 3, user_id: 8 });
expect(isSameSender(a, b)).toBe(true);
expect(isSameSender(a, other)).toBe(false);
expect(isSameSender(a, undefined)).toBe(false);
expect(isSameSender(undefined, b)).toBe(false);
expect(isSameSender(a, { ...b, user_id: '7' } as unknown as ChatMessage)).toBe(true);
});
});
describe('collabModel — notes', () => {
it('FE-MOB-CLBM-011: buildCategoryColorMap keeps the first colour seen per category', () => {
const notes = [
note({ id: 1, category: 'Ideas', color: '#ef4444' }),
note({ id: 2, category: 'Ideas', color: '#10b981' }),
note({ id: 3, category: 'Food', color: null }),
note({ id: 4, category: null, color: '#3b82f6' }),
];
expect(buildCategoryColorMap(notes)).toEqual({ Ideas: '#ef4444' });
});
it('FE-MOB-CLBM-012: getCategoryColor round-robins fresh categories onto the palette', () => {
expect(getCategoryColor(null, {})).toBe(NOTE_COLORS[0]);
expect(getCategoryColor(undefined, {})).toBe(NOTE_COLORS[0]);
expect(getCategoryColor('Ideas', { Ideas: '#123456' })).toBe('#123456');
expect(getCategoryColor('New', { A: 'x', B: 'y' })).toBe(NOTE_COLORS[2]);
// wraps around once every palette slot is taken
const full = Object.fromEntries(NOTE_COLORS.map((c, i) => [`c${i}`, c]));
expect(getCategoryColor('New', full)).toBe(NOTE_COLORS[0]);
});
it('FE-MOB-CLBM-013: noteCategoriesList returns distinct categories in first-appearance order', () => {
const notes = [
note({ id: 1, category: 'Ideas' }),
note({ id: 2, category: null }),
note({ id: 3, category: 'Food' }),
note({ id: 4, category: 'Ideas' }),
];
expect(noteCategoriesList(notes)).toEqual(['Ideas', 'Food']);
expect(noteCategoriesList([])).toEqual([]);
});
it('FE-MOB-CLBM-014: sortNotes puts pinned notes first, then most recently touched', () => {
const old = note({ id: 1, created_at: '2026-07-01T10:00:00.000Z' });
const fresh = note({ id: 2, created_at: '2026-07-05T10:00:00.000Z' });
const edited = note({ id: 3, created_at: '2026-06-01T10:00:00.000Z', updated_at: '2026-07-09T10:00:00.000Z' });
const pinned = note({ id: 4, created_at: '2026-01-01T10:00:00.000Z', pinned: 1 });
expect(sortNotes([old, fresh, edited, pinned], null).map(n => n.id)).toEqual([4, 3, 2, 1]);
// pinned wins no matter which side of the comparison it lands on
expect(sortNotes([pinned, fresh], null).map(n => n.id)).toEqual([4, 2]);
});
it('FE-MOB-CLBM-015: sortNotes filters by the active category and leaves the input untouched', () => {
const input = [
note({ id: 1, category: 'Food', created_at: '2026-07-01T10:00:00.000Z' }),
note({ id: 2, category: 'Ideas', created_at: '2026-07-02T10:00:00.000Z' }),
note({ id: 3, category: 'Food', created_at: '2026-07-03T10:00:00.000Z', pinned: true }),
];
const snapshot = input.map(n => n.id);
expect(sortNotes(input, 'Food').map(n => n.id)).toEqual([3, 1]);
expect(sortNotes(input, 'Nope')).toEqual([]);
expect(input.map(n => n.id)).toEqual(snapshot);
});
});
describe('collabModel — polls', () => {
const optA = { text: 'Rome', label: 'A', voters: [voter(1), voter(2)] };
const optB = { text: 'Oslo', label: 'B', voters: [voter(3)] };
it('FE-MOB-CLBM-016: totalPollVotes and pollMaxVoteCount count across options', () => {
const p = poll({ options: [optA, optB] });
expect(totalPollVotes(p)).toBe(3);
expect(pollMaxVoteCount(p)).toBe(2);
expect(totalPollVotes(poll())).toBe(0);
expect(pollMaxVoteCount(poll())).toBe(0);
});
it('FE-MOB-CLBM-017: vote counters tolerate missing option/voter arrays', () => {
const broken = poll({
options: [{ text: 'Rome', label: 'A' }] as unknown as CollabPollData['options'],
});
expect(totalPollVotes(broken)).toBe(0);
expect(pollMaxVoteCount(broken)).toBe(0);
expect(hasUserVoted(broken, 1)).toBe(false);
const noOptions = { ...poll(), options: undefined } as unknown as CollabPollData;
expect(totalPollVotes(noOptions)).toBe(0);
expect(pollMaxVoteCount(noOptions)).toBe(0);
expect(hasUserVoted(noOptions, 1)).toBe(false);
});
it('FE-MOB-CLBM-018: hasUserVoted matches ids across string/number storage', () => {
const p = poll({ options: [optA, optB] });
expect(hasUserVoted(p, 2)).toBe(true);
expect(hasUserVoted(p, 9)).toBe(false);
expect(hasUserVoted(p, '3' as unknown as number)).toBe(true);
});
it('FE-MOB-CLBM-019: isPollExpired only fires on a deadline in the past', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-15T12:00:00Z'));
expect(isPollExpired(null)).toBe(false);
expect(isPollExpired('2026-07-15T11:59:00Z')).toBe(true);
expect(isPollExpired('2026-07-15T12:00:00Z')).toBe(true);
expect(isPollExpired('2026-07-15T12:01:00Z')).toBe(false);
});
it('FE-MOB-CLBM-020: isPollActive requires an open poll with an unexpired deadline', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-15T12:00:00Z'));
expect(isPollActive(poll())).toBe(true);
expect(isPollActive(poll({ is_closed: true }))).toBe(false);
expect(isPollActive(poll({ deadline: '2026-07-14T12:00:00Z' }))).toBe(false);
expect(isPollActive(poll({ deadline: '2026-07-16T12:00:00Z' }))).toBe(true);
});
it('FE-MOB-CLBM-021: splitPolls separates active from closed/expired', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-15T12:00:00Z'));
const open = poll({ id: 1 });
const closed = poll({ id: 2, is_closed: true });
const expired = poll({ id: 3, deadline: '2026-07-01T12:00:00Z' });
const { active, closed: done } = splitPolls([open, closed, expired]);
expect(active.map(p => p.id)).toEqual([1]);
expect(done.map(p => p.id)).toEqual([2, 3]);
expect(splitPolls([])).toEqual({ active: [], closed: [] });
});
it('FE-MOB-CLBM-022: formatPollCountdown picks the days/hours/minutes tier', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-15T12:00:00Z'));
expect(formatPollCountdown(null, t)).toBeNull();
expect(formatPollCountdown('2026-07-15T11:00:00Z', t)).toBeNull();
expect(formatPollCountdown('2026-07-17T16:00:00Z', t)).toBe('collab.polls.countdownDaysHours:d=2,h=4');
expect(formatPollCountdown('2026-07-15T15:45:00Z', t)).toBe('collab.polls.countdownHoursMinutes:h=3,m=45');
expect(formatPollCountdown('2026-07-15T12:12:00Z', t)).toBe('collab.polls.countdownMinutes:m=12');
});
});