diff --git a/src/container.test.ts b/src/container.test.ts new file mode 100644 index 0000000..9557468 --- /dev/null +++ b/src/container.test.ts @@ -0,0 +1,81 @@ +import test from "ava" +import { Client, TextChannel } from "discord.js" +import { nanoid } from "nanoid" +import { ReacordContainer } from "./container.js" +import { raise } from "./helpers/raise.js" +import { testBotToken, testChannelId } from "./test-environment.js" + +const client = new Client({ + intents: ["GUILDS"], +}) + +let channel: TextChannel + +test.before(async () => { + await client.login(testBotToken) + + const result = + client.channels.cache.get(testChannelId) ?? + (await client.channels.fetch(testChannelId)) ?? + raise("Channel not found") + + if (!(result instanceof TextChannel)) { + throw new TypeError("Channel must be a text channel") + } + + channel = result +}) + +test.after(() => { + client.destroy() +}) + +test("rendering text", async (t) => { + const container = new ReacordContainer(channel) + + const content = nanoid() + await container.render([content]) + + { + const messages = await channel.messages.fetch() + t.true(messages.some((m) => m.content === content)) + } + + const newContent = nanoid() + await container.render([newContent]) + + { + const messages = await channel.messages.fetch() + t.true(messages.some((m) => m.content === newContent)) + } + + await container.render([]) + + { + const messages = await channel.messages.fetch() + t.false(messages.some((m) => m.content === newContent)) + } +}) + +test("rapid updates", async (t) => { + const container = new ReacordContainer(channel) + + const content = nanoid() + const newContent = nanoid() + + void container.render([content]) + await container.render([newContent]) + + { + const messages = await channel.messages.fetch() + t.true(messages.some((m) => m.content === newContent)) + } + + void container.render([content]) + await container.render([]) + + { + const messages = await channel.messages.fetch() + t.false(messages.some((m) => m.content === newContent)) + } +}) diff --git a/src/container.ts b/src/container.ts index fcc8a85..400d21a 100644 --- a/src/container.ts +++ b/src/container.ts @@ -1,4 +1,5 @@ import type { Message, MessageOptions, TextBasedChannels } from "discord.js" +import { createDeferred } from "./helpers/deferred.js" type Action = | { type: "updateMessage"; options: MessageOptions } @@ -8,38 +9,42 @@ export class ReacordContainer { channel: TextBasedChannels message?: Message actions: Action[] = [] - runningActions = false + runningPromise?: PromiseLike constructor(channel: TextBasedChannels) { this.channel = channel } - render(instances: string[]) { + async render(instances: string[]) { const messageOptions: MessageOptions = { content: instances.join("") || undefined, // empty strings are not allowed } const hasContent = messageOptions.content !== undefined - if (hasContent) { - this.addAction({ type: "updateMessage", options: messageOptions }) - } else { - this.addAction({ type: "deleteMessage" }) - } + + await this.addAction( + hasContent + ? { type: "updateMessage", options: messageOptions } + : { type: "deleteMessage" }, + ) } - private addAction(action: Action) { + private async addAction(action: Action) { const lastAction = this.actions[this.actions.length - 1] if (lastAction?.type === action.type) { this.actions[this.actions.length - 1] = action } else { this.actions.push(action) } - void this.runActions() + await this.runActions() } - private runActions() { - if (this.runningActions) return - this.runningActions = true + private async runActions() { + if (this.runningPromise) { + return this.runningPromise + } + + const promise = (this.runningPromise = createDeferred()) queueMicrotask(async () => { let action: Action | undefined @@ -47,11 +52,9 @@ export class ReacordContainer { try { switch (action.type) { case "updateMessage": - if (this.message) { - await this.message.edit(action.options) - } else { - this.message = await this.channel.send(action.options) - } + this.message = await (this.message + ? this.message.edit(action.options) + : this.channel.send(action.options)) break case "deleteMessage": if (this.message) { @@ -66,7 +69,10 @@ export class ReacordContainer { } } - this.runningActions = false + promise.resolve() }) + + await promise + this.runningPromise = undefined } } diff --git a/src/render.test.ts b/src/render.test.ts deleted file mode 100644 index e85320d..0000000 --- a/src/render.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import test from "ava" -import { Client, TextChannel } from "discord.js" -import { nanoid } from "nanoid" -import { raise } from "./helpers/raise.js" -import { waitForWithTimeout } from "./helpers/wait-for-with-timeout.js" -import { render } from "./render.js" -import { testBotToken, testChannelId } from "./test-environment.js" - -const client = new Client({ - intents: ["GUILDS"], -}) - -let channel: TextChannel - -test.before(async () => { - await client.login(testBotToken) - - const result = - client.channels.cache.get(testChannelId) ?? - (await client.channels.fetch(testChannelId)) ?? - raise("Channel not found") - - if (!(result instanceof TextChannel)) { - throw new TypeError("Channel must be a text channel") - } - - channel = result -}) - -test.after(() => { - client.destroy() -}) - -test.serial("rendering text", async (t) => { - const content = nanoid() - const root = render(content, channel) - - await waitForWithTimeout( - async () => { - const messages = await channel.messages.fetch() - return messages.some((m) => m.content === content) - }, - 10_000, - "Message not found", - ) - - const newContent = nanoid() - root.rerender(newContent) - - await waitForWithTimeout( - async () => { - const messages = await channel.messages.fetch() - return messages.some((m) => m.content === newContent) - }, - 10_000, - "Message not found", - ) - - root.destroy() - - await waitForWithTimeout( - async () => { - const messages = await channel.messages.fetch() - return messages.size === 0 - }, - 10_000, - "Message was not deleted", - ) - - t.pass() -}) - -test.serial("rapid updates", async (t) => { - const content = nanoid() - const newContent = nanoid() - - const root = render(content, channel) - root.rerender(newContent) - - await waitForWithTimeout( - async () => { - const messages = await channel.messages.fetch() - return messages.some((m) => m.content === newContent) - }, - 10_000, - "Message not found", - ) - - root.rerender(content) - root.destroy() - - await waitForWithTimeout( - async () => { - const messages = await channel.messages.fetch() - return messages.size === 0 - }, - 10_000, - "Message was not deleted", - ) - - t.pass() -})