Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/modules/chat/client/ChatFeatureOptimistic.test.ts
T

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;
}