rendering to channel + simplified adapter interface

This commit is contained in:
MapleLeaf
2021-12-27 20:57:04 -06:00
parent 3682f67bfe
commit ef26b66cb8
17 changed files with 408 additions and 425 deletions

View File

@@ -1,31 +0,0 @@
import type { Channel } from "../../internal/channel"
import type {
CommandInteraction,
ComponentInteraction,
} from "../../internal/interaction"
export type AdapterGenerics = {
commandReplyInit: unknown
channelInit: unknown
}
export type Adapter<Generics extends AdapterGenerics> = {
/**
* @internal
*/
addComponentInteractionListener(
listener: (interaction: ComponentInteraction) => void,
): void
/**
* @internal
*/
createCommandInteraction(
init: Generics["commandReplyInit"],
): CommandInteraction
/**
* @internal
*/
createChannel(init: Generics["channelInit"]): Channel
}

View File

@@ -1,43 +1,35 @@
import type * as Discord from "discord.js"
import { raise } from "../../../helpers/raise"
import { toUpper } from "../../../helpers/to-upper"
import type { Channel } from "../../internal/channel"
import type {
CommandInteraction,
ComponentInteraction,
} from "../../internal/interaction"
import type { Message, MessageOptions } from "../../internal/message"
import type { Adapter } from "./adapter"
import { raise } from "../../helpers/raise"
import { toUpper } from "../../helpers/to-upper"
import type { ComponentInteraction } from "../internal/interaction"
import type { Message, MessageOptions } from "../internal/message"
import type { ReacordConfig, ReacordInstance } from "./reacord"
import { Reacord } from "./reacord"
type DiscordJsAdapterGenerics = {
commandReplyInit: Discord.CommandInteraction
channelInit: Discord.TextBasedChannel
}
export class ReacordDiscordJs extends Reacord {
constructor(client: Discord.Client, config: ReacordConfig = {}) {
super(config)
export class DiscordJsAdapter implements Adapter<DiscordJsAdapterGenerics> {
constructor(private client: Discord.Client) {}
/**
* @internal
*/
addComponentInteractionListener(
listener: (interaction: ComponentInteraction) => void,
) {
this.client.on("interactionCreate", (interaction) => {
client.on("interactionCreate", (interaction) => {
if (interaction.isMessageComponent()) {
listener(createReacordComponentInteraction(interaction))
this.handleComponentInteraction(
createReacordComponentInteraction(interaction),
)
}
})
}
/**
* @internal
*/
// eslint-disable-next-line class-methods-use-this
createCommandInteraction(
interaction: Discord.CommandInteraction,
): CommandInteraction {
return {
override send(channel: Discord.TextBasedChannel): ReacordInstance {
return this.createChannelRendererInstance({
send: async (options) => {
const message = await channel.send(getDiscordMessageOptions(options))
return createReacordMessage(message)
},
})
}
override reply(interaction: Discord.CommandInteraction): ReacordInstance {
return this.createCommandReplyRendererInstance({
type: "command",
id: interaction.id,
channelId: interaction.channelId,
@@ -55,20 +47,7 @@ export class DiscordJsAdapter implements Adapter<DiscordJsAdapterGenerics> {
})
return createReacordMessage(message as Discord.Message)
},
}
}
/**
* @internal
*/
// eslint-disable-next-line class-methods-use-this
createChannel(channel: Discord.TextBasedChannel): Channel {
return {
send: async (options) => {
const message = await channel.send(getDiscordMessageOptions(options))
return createReacordMessage(message)
},
}
})
}
}
@@ -103,33 +82,14 @@ function createReacordComponentInteraction(
raise(`Unsupported component interaction type: ${interaction.type}`)
}
function createReacordMessage(message: Discord.Message): Message {
return {
edit: async (options) => {
await message.edit(getDiscordMessageOptions(options))
},
disableComponents: async () => {
for (const actionRow of message.components) {
for (const component of actionRow.components) {
component.setDisabled(true)
}
}
await message.edit({
components: message.components,
})
},
delete: async () => {
await message.delete()
},
}
}
// TODO: this could be a part of the core library,
// and also handle some edge cases, e.g. empty messages
function getDiscordMessageOptions(
options: MessageOptions,
): Discord.MessageOptions {
return {
content: options.content,
// eslint-disable-next-line unicorn/no-null
content: options.content || null,
embeds: options.embeds,
components: options.actionRows.map((row) => ({
type: "ACTION_ROW",
@@ -163,3 +123,25 @@ function getDiscordMessageOptions(
})),
}
}
function createReacordMessage(message: Discord.Message): Message {
return {
edit: async (options) => {
await message.edit(getDiscordMessageOptions(options))
},
disableComponents: async () => {
for (const actionRow of message.components) {
for (const component of actionRow.components) {
component.setDisabled(true)
}
}
await message.edit({
components: message.components,
})
},
delete: async () => {
await message.delete()
},
}
}

View File

@@ -0,0 +1,214 @@
/* eslint-disable class-methods-use-this */
/* eslint-disable require-await */
import { nanoid } from "nanoid"
import { nextTick } from "node:process"
import { promisify } from "node:util"
import type { ReactNode } from "react"
import { logPretty } from "../../helpers/log-pretty"
import { omit } from "../../helpers/omit"
import { raise } from "../../helpers/raise"
import type { Channel } from "../internal/channel"
import { Container } from "../internal/container"
import type {
ButtonInteraction,
CommandInteraction,
SelectInteraction,
} from "../internal/interaction"
import type {
Message,
MessageButtonOptions,
MessageOptions,
MessageSelectOptions,
} from "../internal/message"
import type { ReacordInstance } from "./reacord"
import { Reacord } from "./reacord"
const nextTickPromise = promisify(nextTick)
export class ReacordTester extends Reacord {
private messageContainer = new Container<TestMessage>()
constructor() {
super({ maxInstances: 2 })
}
get messages(): readonly TestMessage[] {
return [...this.messageContainer]
}
send(): ReacordInstance {
return this.createChannelRendererInstance(
new TestChannel(this.messageContainer),
)
}
reply(): ReacordInstance {
return this.createCommandReplyRendererInstance(
new TestCommandInteraction(this.messageContainer),
)
}
async assertMessages(expected: ReturnType<this["sampleMessages"]>) {
await nextTickPromise()
expect(this.sampleMessages()).toEqual(expected)
}
async assertRender(
content: ReactNode,
expected: ReturnType<this["sampleMessages"]>,
) {
const instance = this.reply()
instance.render(content)
await this.assertMessages(expected)
instance.destroy()
}
logMessages() {
logPretty(this.sampleMessages())
}
sampleMessages() {
return this.messages.map((message) => ({
...message.options,
actionRows: message.options.actionRows.map((row) =>
row.map((component) =>
omit(component, ["customId", "onClick", "onSelect", "onSelectValue"]),
),
),
}))
}
findButtonByLabel(label: string) {
for (const message of this.messageContainer) {
for (const component of message.options.actionRows.flat()) {
if (component.type === "button" && component.label === label) {
return this.createButtonActions(component, message)
}
}
}
raise(`Couldn't find button with label "${label}"`)
}
findSelectByPlaceholder(placeholder: string) {
for (const message of this.messageContainer) {
for (const component of message.options.actionRows.flat()) {
if (
component.type === "select" &&
component.placeholder === placeholder
) {
return this.createSelectActions(component, message)
}
}
}
raise(`Couldn't find select with placeholder "${placeholder}"`)
}
private createButtonActions(
button: MessageButtonOptions,
message: TestMessage,
) {
return {
click: () => {
this.handleComponentInteraction(
new TestButtonInteraction(button.customId, message),
)
},
}
}
private createSelectActions(
component: MessageSelectOptions,
message: TestMessage,
) {
return {
select: (...values: string[]) => {
this.handleComponentInteraction(
new TestSelectInteraction(component.customId, message, values),
)
},
}
}
}
class TestMessage implements Message {
constructor(
public options: MessageOptions,
private container: Container<TestMessage>,
) {
container.add(this)
}
async edit(options: MessageOptions): Promise<void> {
this.options = options
}
async disableComponents(): Promise<void> {
for (const row of this.options.actionRows) {
for (const action of row) {
if (action.type === "button") {
action.disabled = true
}
}
}
}
async delete(): Promise<void> {
this.container.remove(this)
}
}
class TestCommandInteraction implements CommandInteraction {
readonly type = "command"
readonly id = "test-command-interaction"
readonly channelId = "test-channel-id"
constructor(private messageContainer: Container<TestMessage>) {}
reply(messageOptions: MessageOptions): Promise<Message> {
return Promise.resolve(
new TestMessage(messageOptions, this.messageContainer),
)
}
followUp(messageOptions: MessageOptions): Promise<Message> {
return Promise.resolve(
new TestMessage(messageOptions, this.messageContainer),
)
}
}
class TestButtonInteraction implements ButtonInteraction {
readonly type = "button"
readonly id = nanoid()
readonly channelId = "test-channel-id"
constructor(readonly customId: string, readonly message: TestMessage) {}
async update(options: MessageOptions): Promise<void> {
this.message.options = options
}
}
class TestSelectInteraction implements SelectInteraction {
readonly type = "select"
readonly id = nanoid()
readonly channelId = "test-channel-id"
constructor(
readonly customId: string,
readonly message: TestMessage,
readonly values: string[],
) {}
async update(options: MessageOptions): Promise<void> {
this.message.options = options
}
}
class TestChannel implements Channel {
constructor(private messageContainer: Container<TestMessage>) {}
async send(messageOptions: MessageOptions): Promise<Message> {
return new TestMessage(messageOptions, this.messageContainer)
}
}

View File

@@ -1,13 +1,15 @@
import type { ReactNode } from "react"
import type { Channel } from "../internal/channel"
import { ChannelMessageRenderer } from "../internal/channel-message-renderer"
import { CommandReplyRenderer } from "../internal/command-reply-renderer.js"
import type {
CommandInteraction,
ComponentInteraction,
} from "../internal/interaction"
import { reconciler } from "../internal/reconciler.js"
import type { Renderer } from "../internal/renderer"
import type { Adapter, AdapterGenerics } from "./adapters/adapter"
export type ReacordConfig<Generics extends AdapterGenerics> = {
adapter: Adapter<Generics>
export type ReacordConfig = {
/**
* The max number of active instances.
* When this limit is exceeded, the oldest instances will be disabled.
@@ -21,33 +23,37 @@ export type ReacordInstance = {
destroy: () => void
}
export class Reacord<Generics extends AdapterGenerics> {
export type ComponentInteractionListener = (
interaction: ComponentInteraction,
) => void
export abstract class Reacord {
private renderers: Renderer[] = []
constructor(private readonly config: ReacordConfig<Generics>) {
config.adapter.addComponentInteractionListener((interaction) => {
for (const renderer of this.renderers) {
if (renderer.handleComponentInteraction(interaction)) return
}
})
constructor(private readonly config: ReacordConfig = {}) {}
abstract send(channel: unknown): ReacordInstance
abstract reply(commandInteraction: unknown): ReacordInstance
protected handleComponentInteraction(interaction: ComponentInteraction) {
for (const renderer of this.renderers) {
if (renderer.handleComponentInteraction(interaction)) return
}
}
private get maxInstances() {
return this.config.maxInstances ?? 50
}
send(init: Generics["channelInit"]): ReacordInstance {
return this.createInstance(
new ChannelMessageRenderer(this.config.adapter.createChannel(init)),
)
protected createChannelRendererInstance(channel: Channel) {
return this.createInstance(new ChannelMessageRenderer(channel))
}
reply(init: Generics["commandReplyInit"]): ReacordInstance {
return this.createInstance(
new CommandReplyRenderer(
this.config.adapter.createCommandInteraction(init),
),
)
protected createCommandReplyRendererInstance(
commandInteraction: CommandInteraction,
): ReacordInstance {
return this.createInstance(new CommandReplyRenderer(commandInteraction))
}
private createInstance(renderer: Renderer) {

View File

@@ -1,5 +1,3 @@
export * from "./core/adapters/adapter"
export * from "./core/adapters/discord-js-adapter"
export * from "./core/components/action-row"
export * from "./core/components/button"
export * from "./core/components/embed"
@@ -13,3 +11,5 @@ export * from "./core/components/link"
export * from "./core/components/option"
export * from "./core/components/select"
export * from "./core/reacord"
export * from "./core/reacord-discord-js"
export * from "./core/reacord-tester"

View File

@@ -1,190 +0,0 @@
/* eslint-disable class-methods-use-this */
/* eslint-disable require-await */
import { nanoid } from "nanoid"
import { raise } from "../helpers/raise"
import type { Adapter } from "./core/adapters/adapter"
import type { Channel } from "./internal/channel"
import type {
ButtonInteraction,
CommandInteraction,
ComponentInteraction,
SelectInteraction,
} from "./internal/interaction"
import type {
Message,
MessageButtonOptions,
MessageOptions,
MessageSelectOptions,
} from "./internal/message"
type TestAdapterGenerics = {
commandReplyInit: TestCommandInteraction
channelInit: TestChannel
}
export class TestAdapter implements Adapter<TestAdapterGenerics> {
messages: TestMessage[] = []
private constructor() {}
static create(): Adapter<TestAdapterGenerics> & TestAdapter {
return new TestAdapter()
}
private componentInteractionListener: (
interaction: ComponentInteraction,
) => void = () => {}
addComponentInteractionListener(
listener: (interaction: ComponentInteraction) => void,
): void {
this.componentInteractionListener = listener
}
createCommandInteraction(
interaction: CommandInteraction,
): CommandInteraction {
return interaction
}
createChannel(channel: TestChannel): Channel {
return channel
}
findButtonByLabel(label: string) {
for (const message of this.messages) {
for (const component of message.options.actionRows.flat()) {
if (component.type === "button" && component.label === label) {
return this.createButtonActions(component, message)
}
}
}
raise(`Couldn't find button with label "${label}"`)
}
findSelectByPlaceholder(placeholder: string) {
for (const message of this.messages) {
for (const component of message.options.actionRows.flat()) {
if (
component.type === "select" &&
component.placeholder === placeholder
) {
return this.createSelectActions(component, message)
}
}
}
raise(`Couldn't find select with placeholder "${placeholder}"`)
}
removeMessage(message: TestMessage) {
this.messages = this.messages.filter((m) => m !== message)
}
private createButtonActions(
button: MessageButtonOptions,
message: TestMessage,
) {
return {
click: () => {
this.componentInteractionListener(
new TestButtonInteraction(button.customId, message),
)
},
}
}
private createSelectActions(
component: MessageSelectOptions,
message: TestMessage,
) {
return {
select: (...values: string[]) => {
this.componentInteractionListener(
new TestSelectInteraction(component.customId, message, values),
)
},
}
}
}
export class TestMessage implements Message {
constructor(public options: MessageOptions, private adapter: TestAdapter) {}
async edit(options: MessageOptions): Promise<void> {
this.options = options
}
async disableComponents(): Promise<void> {
for (const row of this.options.actionRows) {
for (const action of row) {
if (action.type === "button") {
action.disabled = true
}
}
}
}
async delete(): Promise<void> {
this.adapter.removeMessage(this)
}
}
export class TestCommandInteraction implements CommandInteraction {
readonly type = "command"
readonly id = "test-command-interaction"
readonly channelId = "test-channel-id"
constructor(private adapter: TestAdapter) {}
private createMesssage(messageOptions: MessageOptions): Message {
const message = new TestMessage(messageOptions, this.adapter)
this.adapter.messages.push(message)
return message
}
reply(messageOptions: MessageOptions): Promise<Message> {
return Promise.resolve(this.createMesssage(messageOptions))
}
followUp(messageOptions: MessageOptions): Promise<Message> {
return Promise.resolve(this.createMesssage(messageOptions))
}
}
export class TestButtonInteraction implements ButtonInteraction {
readonly type = "button"
readonly id = nanoid()
readonly channelId = "test-channel-id"
constructor(readonly customId: string, readonly message: TestMessage) {}
async update(options: MessageOptions): Promise<void> {
this.message.options = options
}
}
export class TestSelectInteraction implements SelectInteraction {
readonly type = "select"
readonly id = nanoid()
readonly channelId = "test-channel-id"
constructor(
readonly customId: string,
readonly message: TestMessage,
readonly values: string[],
) {}
async update(options: MessageOptions): Promise<void> {
this.message.options = options
}
}
export class TestChannel implements Channel {
constructor(private adapter: TestAdapter) {}
async send(messageOptions: MessageOptions): Promise<Message> {
const message = new TestMessage(messageOptions, this.adapter)
this.adapter.messages.push(message)
return message
}
}

View File

@@ -66,6 +66,7 @@
"lodash-es": "^4.17.21",
"nodemon": "^2.0.15",
"prettier": "^2.5.1",
"pretty-ms": "^7.0.1",
"react": "^17.0.2",
"tsup": "^5.11.9",
"type-fest": "^2.8.0",

View File

@@ -1,7 +1,7 @@
import { Client } from "discord.js"
import "dotenv/config"
import React from "react"
import { DiscordJsAdapter, Reacord } from "../library/main"
import { ReacordDiscordJs } from "../library/main"
import { createCommandHandler } from "./command-handler"
import { Counter } from "./counter"
import { FruitSelect } from "./fruit-select"
@@ -10,10 +10,38 @@ const client = new Client({
intents: ["GUILDS"],
})
const reacord = new Reacord({
adapter: new DiscordJsAdapter(client),
maxInstances: 2,
})
const reacord = new ReacordDiscordJs(client)
// client.on("ready", async () => {
// const now = new Date()
// function UptimeCounter() {
// const [uptime, setUptime] = React.useState(0)
// React.useEffect(() => {
// const interval = setInterval(() => {
// setUptime(Date.now() - now.getTime())
// }, 5000)
// return () => clearInterval(interval)
// }, [])
// return (
// <Embed>this bot has been running for {prettyMilliseconds(uptime)}</Embed>
// )
// }
// const channelId = "671787605624487941"
// const channel =
// client.channels.cache.get(channelId) ||
// (await client.channels.fetch(channelId))
// if (!channel?.isText()) {
// throw new Error("channel is not text")
// }
// reacord.send(channel).render(<UptimeCounter />)
// })
createCommandHandler(client, [
{

14
pnpm-lock.yaml generated
View File

@@ -35,6 +35,7 @@ importers:
nanoid: ^3.1.30
nodemon: ^2.0.15
prettier: ^2.5.1
pretty-ms: ^7.0.1
react: ^17.0.2
react-reconciler: ^0.26.2
rxjs: ^7.5.0
@@ -74,6 +75,7 @@ importers:
lodash-es: 4.17.21
nodemon: 2.0.15
prettier: 2.5.1
pretty-ms: 7.0.1
react: 17.0.2
tsup: 5.11.9_typescript@4.5.4
type-fest: 2.8.0
@@ -5534,6 +5536,11 @@ packages:
lines-and-columns: 1.2.4
dev: true
/parse-ms/2.1.0:
resolution: {integrity: sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==}
engines: {node: '>=6'}
dev: true
/parse5/6.0.1:
resolution: {integrity: sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==}
dev: true
@@ -5686,6 +5693,13 @@ packages:
react-is: 17.0.2
dev: true
/pretty-ms/7.0.1:
resolution: {integrity: sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==}
engines: {node: '>=10'}
dependencies:
parse-ms: 2.1.0
dev: true
/progress/2.0.3:
resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==}
engines: {node: '>=0.4.0'}

View File

@@ -1,11 +1,11 @@
import React from "react"
import { ReacordTester } from "../library/core/reacord-tester"
import { ActionRow, Button, Select } from "../library/main"
import { setupReacordTesting } from "./setup-testing"
const { assertRender } = setupReacordTesting()
const testing = new ReacordTester()
test("action row", async () => {
await assertRender(
await testing.assertRender(
<>
<Button label="outside button" onClick={() => {}} />
<ActionRow>

2
test/discord-js.test.tsx Normal file
View File

@@ -0,0 +1,2 @@
test.todo("discord js integration")
export {}

View File

@@ -1,4 +1,5 @@
import React from "react"
import { ReacordTester } from "../library/core/reacord-tester"
import {
Embed,
EmbedAuthor,
@@ -8,14 +9,13 @@ import {
EmbedThumbnail,
EmbedTitle,
} from "../library/main"
import { setupReacordTesting } from "./setup-testing"
const { assertRender } = setupReacordTesting()
const testing = new ReacordTester()
test("kitchen sink", async () => {
const now = new Date()
await assertRender(
await testing.assertRender(
<>
<Embed color={0xfe_ee_ef}>
<EmbedAuthor name="author" iconUrl="https://example.com/author.png" />
@@ -75,7 +75,7 @@ test("kitchen sink", async () => {
})
test("author variants", async () => {
await assertRender(
await testing.assertRender(
<>
<Embed>
<EmbedAuthor iconUrl="https://example.com/author.png">
@@ -110,7 +110,7 @@ test("author variants", async () => {
})
test("field variants", async () => {
await assertRender(
await testing.assertRender(
<>
<Embed>
<EmbedField name="field name" value="field value" />
@@ -157,7 +157,7 @@ test("field variants", async () => {
test("footer variants", async () => {
const now = new Date()
await assertRender(
await testing.assertRender(
<>
<Embed>
<EmbedFooter text="footer text" />
@@ -213,7 +213,7 @@ test("footer variants", async () => {
test("embed props", async () => {
const now = new Date()
await assertRender(
await testing.assertRender(
<Embed
title="title text"
description="description text"

View File

@@ -1,11 +1,11 @@
import React from "react"
import { ReacordTester } from "../library/core/reacord-tester"
import { Link } from "../library/main"
import { setupReacordTesting } from "./setup-testing"
const { assertRender } = setupReacordTesting()
const tester = new ReacordTester()
test("link", async () => {
await assertRender(
await tester.assertRender(
<>
<Link url="https://example.com/">link text</Link>
<Link label="link text" url="https://example.com/" />

View File

@@ -1,15 +1,19 @@
import * as React from "react"
import { Button, Embed, EmbedField, EmbedTitle } from "../library/main"
import { TestCommandInteraction } from "../library/testing"
import { setupReacordTesting } from "./setup-testing"
import {
Button,
Embed,
EmbedField,
EmbedTitle,
ReacordTester,
} from "../library/main"
test("rendering behavior", async () => {
const { reacord, adapter, assertMessages } = setupReacordTesting()
const tester = new ReacordTester()
const reply = reacord.reply(new TestCommandInteraction(adapter))
const reply = tester.reply()
reply.render(<KitchenSinkCounter onDeactivate={() => reply.deactivate()} />)
await assertMessages([
await tester.assertMessages([
{
content: "count: 0",
embeds: [],
@@ -35,8 +39,8 @@ test("rendering behavior", async () => {
},
])
adapter.findButtonByLabel("show embed").click()
await assertMessages([
tester.findButtonByLabel("show embed").click()
await tester.assertMessages([
{
content: "count: 0",
embeds: [{ title: "the counter" }],
@@ -62,8 +66,8 @@ test("rendering behavior", async () => {
},
])
adapter.findButtonByLabel("clicc").click()
await assertMessages([
tester.findButtonByLabel("clicc").click()
await tester.assertMessages([
{
content: "count: 1",
embeds: [
@@ -94,8 +98,8 @@ test("rendering behavior", async () => {
},
])
adapter.findButtonByLabel("clicc").click()
await assertMessages([
tester.findButtonByLabel("clicc").click()
await tester.assertMessages([
{
content: "count: 2",
embeds: [
@@ -126,8 +130,8 @@ test("rendering behavior", async () => {
},
])
adapter.findButtonByLabel("hide embed").click()
await assertMessages([
tester.findButtonByLabel("hide embed").click()
await tester.assertMessages([
{
content: "count: 2",
embeds: [],
@@ -153,8 +157,8 @@ test("rendering behavior", async () => {
},
])
adapter.findButtonByLabel("clicc").click()
await assertMessages([
tester.findButtonByLabel("clicc").click()
await tester.assertMessages([
{
content: "count: 3",
embeds: [],
@@ -180,8 +184,8 @@ test("rendering behavior", async () => {
},
])
adapter.findButtonByLabel("deactivate").click()
await assertMessages([
tester.findButtonByLabel("deactivate").click()
await tester.assertMessages([
{
content: "count: 3",
embeds: [],
@@ -210,8 +214,8 @@ test("rendering behavior", async () => {
},
])
adapter.findButtonByLabel("clicc").click()
await assertMessages([
tester.findButtonByLabel("clicc").click()
await tester.assertMessages([
{
content: "count: 3",
embeds: [],
@@ -242,9 +246,9 @@ test("rendering behavior", async () => {
})
test("delete", async () => {
const { reacord, adapter, assertMessages } = setupReacordTesting()
const tester = new ReacordTester()
const reply = reacord.reply(new TestCommandInteraction(adapter))
const reply = tester.reply()
reply.render(
<>
some text
@@ -253,7 +257,7 @@ test("delete", async () => {
</>,
)
await assertMessages([
await tester.assertMessages([
{
content: "some text",
embeds: [{ description: "some embed" }],
@@ -264,7 +268,7 @@ test("delete", async () => {
])
reply.destroy()
await assertMessages([])
await tester.assertMessages([])
})
// test multiple instances that can be updated independently,

View File

@@ -1,11 +1,9 @@
import { jest } from "@jest/globals"
import React, { useState } from "react"
import { Button, Option, Select } from "../library/main"
import { setupReacordTesting } from "./setup-testing"
const { adapter, reply, assertRender, assertMessages } = setupReacordTesting()
import { Button, Option, ReacordTester, Select } from "../library/main"
test("single select", async () => {
const tester = new ReacordTester()
const onSelect = jest.fn()
function TestSelect() {
@@ -30,7 +28,7 @@ test("single select", async () => {
}
async function assertSelect(values: string[], disabled = false) {
await assertMessages([
await tester.assertMessages([
{
content: "",
embeds: [],
@@ -54,23 +52,26 @@ test("single select", async () => {
])
}
const reply = tester.reply()
reply.render(<TestSelect />)
await assertSelect([])
expect(onSelect).toHaveBeenCalledTimes(0)
adapter.findSelectByPlaceholder("choose one").select("2")
tester.findSelectByPlaceholder("choose one").select("2")
await assertSelect(["2"])
expect(onSelect).toHaveBeenCalledWith({ values: ["2"] })
adapter.findButtonByLabel("disable").click()
tester.findButtonByLabel("disable").click()
await assertSelect(["2"], true)
adapter.findSelectByPlaceholder("choose one").select("1")
tester.findSelectByPlaceholder("choose one").select("1")
await assertSelect(["2"], true)
expect(onSelect).toHaveBeenCalledTimes(1)
})
test("multiple select", async () => {
const tester = new ReacordTester()
const onSelect = jest.fn()
function TestSelect() {
@@ -91,7 +92,7 @@ test("multiple select", async () => {
}
async function assertSelect(values: string[]) {
await assertMessages([
await tester.assertMessages([
{
content: "",
embeds: [],
@@ -115,31 +116,34 @@ test("multiple select", async () => {
])
}
const reply = tester.reply()
reply.render(<TestSelect />)
await assertSelect([])
expect(onSelect).toHaveBeenCalledTimes(0)
adapter.findSelectByPlaceholder("select").select("1", "3")
tester.findSelectByPlaceholder("select").select("1", "3")
await assertSelect(expect.arrayContaining(["1", "3"]))
expect(onSelect).toHaveBeenCalledWith({
values: expect.arrayContaining(["1", "3"]),
})
adapter.findSelectByPlaceholder("select").select("2")
tester.findSelectByPlaceholder("select").select("2")
await assertSelect(expect.arrayContaining(["2"]))
expect(onSelect).toHaveBeenCalledWith({
values: expect.arrayContaining(["2"]),
})
adapter.findSelectByPlaceholder("select").select()
tester.findSelectByPlaceholder("select").select()
await assertSelect([])
expect(onSelect).toHaveBeenCalledWith({ values: [] })
})
test("optional onSelect + unknown value", async () => {
reply.render(<Select placeholder="select" />)
adapter.findSelectByPlaceholder("select").select("something")
await assertMessages([
const tester = new ReacordTester()
tester.reply().render(<Select placeholder="select" />)
tester.findSelectByPlaceholder("select").select("something")
await tester.assertMessages([
{
content: "",
embeds: [],

View File

@@ -1,52 +0,0 @@
import { nextTick } from "node:process"
import { promisify } from "node:util"
import type { ReactNode } from "react"
import { logPretty } from "../helpers/log-pretty"
import { omit } from "../helpers/omit"
import { Reacord } from "../library/main"
import { TestAdapter, TestCommandInteraction } from "../library/testing"
const nextTickPromise = promisify(nextTick)
export function setupReacordTesting() {
const adapter = TestAdapter.create()
const reacord = new Reacord({ adapter })
const reply = reacord.reply(new TestCommandInteraction(adapter))
async function assertMessages(expected: ReturnType<typeof sampleMessages>) {
await nextTickPromise() // wait for the render to complete
expect(sampleMessages(adapter)).toEqual(expected)
}
async function assertRender(
content: ReactNode,
expected: ReturnType<typeof sampleMessages>,
) {
reply.render(content)
await assertMessages(expected)
}
function logMessages() {
logPretty(sampleMessages(adapter))
}
return {
reacord,
adapter,
reply,
assertMessages,
assertRender,
logMessages,
}
}
function sampleMessages(adapter: TestAdapter) {
return adapter.messages.map((message) => ({
...message.options,
actionRows: message.options.actionRows.map((row) =>
row.map((component) =>
omit(component, ["customId", "onClick", "onSelect", "onSelectValue"]),
),
),
}))
}

View File

@@ -1,6 +1,6 @@
# core features
- [ ] render to channel
- [x] render to channel
- [x] render to interaction
- [ ] ephemeral messages
- [x] message content
@@ -43,5 +43,6 @@
- [ ] max instance count per guild
- [ ] max instance count per channel
- [ ] uncontrolled select
- [ ] single class/helper function for testing `ReacordTester`
- [ ] some failsafes and fallbacks in DJS adapter
- [x] single class/helper function for testing `ReacordTester`
- [ ] handle deletion outside of reacord
- [ ] for more easily writing adapters, address discord API nuances at the reacord level instead of the adapter level. the goal being that adapters can just take the objects and send them to discord