254 lines
9.1 KiB
TypeScript
254 lines
9.1 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { ChatChannelDetailVM, ChatFeatureMessageVM, ChatMessageVM, ChatPinnedMessagesVM, ChatThreadVM } from "@hivegrid/view-models";
|
|
|
|
import {
|
|
beginChatFeatureMutation,
|
|
chatFeatureErrorsByMessageId,
|
|
failedChatFeatureMutationForMessage,
|
|
overlayChatChannelFeatureMutations,
|
|
overlayChatPinnedFeatureMutations,
|
|
overlayChatThreadFeatureMutations,
|
|
reconcileChatChannelFeatureMutations,
|
|
reconcileChatPinnedFeatureMutations,
|
|
retryChatFeatureMutation,
|
|
settleChatFeatureMutation,
|
|
type ChatOptimisticFeatureMutationInput,
|
|
} from "./ChatFeatureOptimistic";
|
|
|
|
const reactionMutation: ChatOptimisticFeatureMutationInput = {
|
|
kind: "reaction",
|
|
requestId: "request_reaction",
|
|
messageId: "message_1",
|
|
emoji: "👍",
|
|
active: true,
|
|
actorLabel: "Raffael",
|
|
startedAtIso: "2026-07-16T09:00:00.000Z",
|
|
};
|
|
|
|
const pinMutation: ChatOptimisticFeatureMutationInput = {
|
|
kind: "pin",
|
|
requestId: "request_pin",
|
|
messageId: "message_1",
|
|
pinned: false,
|
|
actorLabel: "Raffael",
|
|
startedAtIso: "2026-07-16T09:00:00.000Z",
|
|
};
|
|
|
|
describe("chat feature optimistic overlays", () => {
|
|
it("sets a reaction immediately and never doubles the count when the authoritative echo arrives", () => {
|
|
const pending = beginChatFeatureMutation([], reactionMutation);
|
|
const optimistic = overlayChatChannelFeatureMutations(channelVM(message()), pending);
|
|
expect(readyChannelMessage(optimistic).reactions).toEqual([
|
|
{ emoji: "👍", count: 1, reactedByViewer: true, reactorLabels: ["Raffael"] },
|
|
]);
|
|
|
|
const confirmed = settleChatFeatureMutation(pending, reactionMutation.requestId, { ok: true, errorKey: null });
|
|
const echoed = channelVM(
|
|
message({ reactions: [{ emoji: "👍", count: 1, reactedByViewer: true, reactorLabels: ["Raffael"] }] })
|
|
);
|
|
expect(readyChannelMessage(overlayChatChannelFeatureMutations(echoed, confirmed)).reactions[0]?.count).toBe(1);
|
|
expect(reconcileChatChannelFeatureMutations(confirmed, echoed)).toEqual([]);
|
|
});
|
|
|
|
it("rolls a rejected reaction back, keeps an element-local error, and retries the same request id", () => {
|
|
const pending = beginChatFeatureMutation([], reactionMutation);
|
|
const failed = settleChatFeatureMutation(pending, reactionMutation.requestId, {
|
|
ok: false,
|
|
errorKey: "chat.errors.invalidInput",
|
|
});
|
|
|
|
expect(readyChannelMessage(overlayChatChannelFeatureMutations(channelVM(message()), failed)).reactions).toEqual([]);
|
|
expect(chatFeatureErrorsByMessageId(failed)).toEqual({ message_1: "chat.errors.invalidInput" });
|
|
expect(failedChatFeatureMutationForMessage(failed, "message_1")?.requestId).toBe("request_reaction");
|
|
expect(retryChatFeatureMutation(failed, "request_reaction")).toMatchObject([
|
|
{ requestId: "request_reaction", status: "pending", errorKey: null },
|
|
]);
|
|
});
|
|
|
|
it("removes a pin optimistically and restores it after an error", () => {
|
|
const canonical = channelVM(message({ pin: { pinnedAtIso: "2026-07-16T08:00:00.000Z", pinnedByLabel: "Ada", note: null } }));
|
|
const pending = beginChatFeatureMutation([], pinMutation);
|
|
expect(readyChannelMessage(overlayChatChannelFeatureMutations(canonical, pending)).pin).toBeNull();
|
|
|
|
const failed = settleChatFeatureMutation(pending, pinMutation.requestId, { ok: false, errorKey: "chat.errors.pinLimitReached" });
|
|
expect(readyChannelMessage(overlayChatChannelFeatureMutations(canonical, failed)).pin).not.toBeNull();
|
|
});
|
|
|
|
it("hides a pinned row until the loader echo confirms it without duplicating state", () => {
|
|
const canonical = pinnedVM([featureMessage({ pinned: true })]);
|
|
const pending = beginChatFeatureMutation([], pinMutation);
|
|
expect(readyPinnedRows(overlayChatPinnedFeatureMutations(canonical, pending))).toHaveLength(0);
|
|
|
|
const failed = settleChatFeatureMutation(pending, pinMutation.requestId, { ok: false, errorKey: "chat.errors.invalidInput" });
|
|
expect(readyPinnedRows(overlayChatPinnedFeatureMutations(canonical, failed))).toHaveLength(1);
|
|
|
|
const confirmed = settleChatFeatureMutation(pending, pinMutation.requestId, { ok: true, errorKey: null });
|
|
const authoritative = pinnedVM([]);
|
|
expect(reconcileChatPinnedFeatureMutations(confirmed, authoritative)).toEqual([]);
|
|
});
|
|
|
|
it("applies the same set-semantics reaction overlay in a thread", () => {
|
|
const pending = beginChatFeatureMutation([], reactionMutation);
|
|
const overlaid = overlayChatThreadFeatureMutations(threadVM(featureMessage()), pending);
|
|
expect(overlaid.status === "ready" ? overlaid.data.parent.reactions : []).toEqual([
|
|
{ emoji: "👍", count: 1, reactedByViewer: true },
|
|
]);
|
|
});
|
|
});
|
|
|
|
function message(overrides: Partial<ChatMessageVM> = {}): ChatMessageVM {
|
|
return {
|
|
id: "message_1",
|
|
clientMessageId: "client_1",
|
|
source: "persisted",
|
|
deliveryState: "sent",
|
|
deliveryStateKey: "chat.message.delivery.sent",
|
|
failureReasonKey: null,
|
|
retryable: false,
|
|
author: {
|
|
userId: "user_1",
|
|
displayName: "Raffael",
|
|
avatar: { imageUrl: null, fallbackLabel: "R" },
|
|
isCurrentUser: true,
|
|
},
|
|
bodyMarkdown: "Hello",
|
|
bodyFormat: "markdown",
|
|
systemEvent: null,
|
|
createdAtIso: "2026-07-16T08:00:00.000Z",
|
|
updatedAtIso: "2026-07-16T08:00:00.000Z",
|
|
editedAtIso: null,
|
|
deletedAtIso: null,
|
|
parentMessageId: null,
|
|
mentions: [],
|
|
reactions: [],
|
|
thread: null,
|
|
attachments: [],
|
|
linkPreviews: [],
|
|
readAggregate: { readCount: 0, recipientCount: 0, readerUserIds: [], readerLabels: [], labelKey: "chat.message.read.none" },
|
|
pin: null,
|
|
actions: { canReact: true, canReply: true, canEdit: false, canDelete: false, canPin: true, canMarkAsTask: false },
|
|
tombstoneKey: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function channelVM(item: ChatMessageVM): ChatChannelDetailVM {
|
|
return {
|
|
status: "ready",
|
|
data: {
|
|
header: {
|
|
id: "chat_1",
|
|
kind: "group",
|
|
title: "Team",
|
|
subtitle: null,
|
|
avatar: { imageUrl: null, fallbackLabel: "T" },
|
|
project: null,
|
|
memberCount: 2,
|
|
isPrivate: false,
|
|
isArchived: false,
|
|
isAuditMode: false,
|
|
dmAuditBannerKey: null,
|
|
canManage: false,
|
|
settingsHref: null,
|
|
pinnedHref: "/demo/chat/chat_1/pinned",
|
|
searchHref: "/demo/chat/search",
|
|
},
|
|
messages: [item],
|
|
messageGroups: [{ dateIso: "2026-07-16", messages: [item] }],
|
|
presence: [],
|
|
typingLabels: [],
|
|
pinnedMessages: item.pin
|
|
? [{ messageId: item.id, authorLabel: "Raffael", snippet: "Hello", pinnedAtIso: item.pin.pinnedAtIso, pinnedByLabel: item.pin.pinnedByLabel, href: "#message-message_1" }]
|
|
: [],
|
|
history: { olderCursor: null, hasOlder: false },
|
|
composer: {
|
|
enabled: true,
|
|
disabledReasonKey: null,
|
|
canSend: true,
|
|
canAttachFiles: false,
|
|
canRecordVoice: false,
|
|
canMention: false,
|
|
canMarkAsTask: false,
|
|
draftStorageKey: "draft",
|
|
maxBodyLength: 10_000,
|
|
placeholderKey: "chat.composer.placeholder",
|
|
},
|
|
connection: { state: "online", stateLabelKey: "chat.connection.online", reconnectAttempt: 0, retryAtIso: null, canRetry: false },
|
|
emptyHeadlineKey: "chat.empty.headline",
|
|
emptyBodyKey: "chat.empty.body",
|
|
loadOlderLabelKey: "chat.message.loadOlder",
|
|
newMessagesLabelKey: "chat.message.newMessages",
|
|
},
|
|
};
|
|
}
|
|
|
|
function featureMessage(overrides: Partial<ChatFeatureMessageVM> = {}): ChatFeatureMessageVM {
|
|
return {
|
|
id: "message_1",
|
|
authorLabel: "Raffael",
|
|
authorAvatarUrl: null,
|
|
bodyMarkdown: "Hello",
|
|
createdAtIso: "2026-07-16T08:00:00.000Z",
|
|
deleted: false,
|
|
mentions: [],
|
|
attachments: [],
|
|
reactions: [],
|
|
pinned: false,
|
|
canReact: true,
|
|
canPin: true,
|
|
canEdit: true,
|
|
canDelete: true,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function threadVM(parent: ChatFeatureMessageVM): ChatThreadVM {
|
|
return {
|
|
status: "ready",
|
|
data: {
|
|
chatId: "chat_1",
|
|
threadId: parent.id,
|
|
channelTitle: "Team",
|
|
backHref: "/demo/chat/chat_1",
|
|
parent,
|
|
replies: [],
|
|
replyCount: 0,
|
|
canReply: true,
|
|
composerPlaceholderKey: "chat.thread.composerPlaceholder",
|
|
emptyRepliesHeadlineKey: "chat.thread.empty.headline",
|
|
emptyRepliesBodyKey: "chat.thread.empty.body",
|
|
},
|
|
};
|
|
}
|
|
|
|
function pinnedVM(messages: readonly ChatFeatureMessageVM[]): ChatPinnedMessagesVM {
|
|
return {
|
|
status: "ready",
|
|
data: {
|
|
chatId: "chat_1",
|
|
channelTitle: "Team",
|
|
backHref: "/demo/chat/chat_1",
|
|
rows: messages.map((item) => ({
|
|
message: item,
|
|
pinnedAtIso: "2026-07-16T08:00:00.000Z",
|
|
pinnedByLabel: "Raffael",
|
|
note: null,
|
|
messageHref: "/demo/chat/chat_1#message-message_1",
|
|
canUnpin: true,
|
|
})),
|
|
emptyHeadlineKey: "chat.pinned.empty.headline",
|
|
emptyBodyKey: "chat.pinned.empty.body",
|
|
},
|
|
};
|
|
}
|
|
|
|
function readyChannelMessage(vm: ChatChannelDetailVM): ChatMessageVM {
|
|
if (vm.status !== "ready") throw new Error("expected ready channel VM");
|
|
return vm.data.messages[0]!;
|
|
}
|
|
|
|
function readyPinnedRows(vm: ChatPinnedMessagesVM) {
|
|
if (vm.status !== "ready") throw new Error("expected ready pinned VM");
|
|
return vm.data.rows;
|
|
}
|