Files
2026-07-29 11:55:31 +02:00

314 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/mobile/screens/trip/tabs/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);
expect(OWN_BUBBLE_RADIUS).toBe('16px 16px 4px 16px');
expect(OTHER_BUBBLE_RADIUS).toBe('4px 16px 16px 16px');
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');
});
});