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 type * as Discord from "discord.js"
import { raise } from "../../../helpers/raise" import { raise } from "../../helpers/raise"
import { toUpper } from "../../../helpers/to-upper" import { toUpper } from "../../helpers/to-upper"
import type { Channel } from "../../internal/channel" import type { ComponentInteraction } from "../internal/interaction"
import type { import type { Message, MessageOptions } from "../internal/message"
CommandInteraction, import type { ReacordConfig, ReacordInstance } from "./reacord"
ComponentInteraction, import { Reacord } from "./reacord"
} from "../../internal/interaction"
import type { Message, MessageOptions } from "../../internal/message"
import type { Adapter } from "./adapter"
type DiscordJsAdapterGenerics = { export class ReacordDiscordJs extends Reacord {
commandReplyInit: Discord.CommandInteraction constructor(client: Discord.Client, config: ReacordConfig = {}) {
channelInit: Discord.TextBasedChannel super(config)
}
export class DiscordJsAdapter implements Adapter<DiscordJsAdapterGenerics> { client.on("interactionCreate", (interaction) => {
constructor(private client: Discord.Client) {}
/**
* @internal
*/
addComponentInteractionListener(
listener: (interaction: ComponentInteraction) => void,
) {
this.client.on("interactionCreate", (interaction) => {
if (interaction.isMessageComponent()) { if (interaction.isMessageComponent()) {
listener(createReacordComponentInteraction(interaction)) this.handleComponentInteraction(
createReacordComponentInteraction(interaction),
)
} }
}) })
} }
/** override send(channel: Discord.TextBasedChannel): ReacordInstance {
* @internal return this.createChannelRendererInstance({
*/ send: async (options) => {
// eslint-disable-next-line class-methods-use-this const message = await channel.send(getDiscordMessageOptions(options))
createCommandInteraction( return createReacordMessage(message)
interaction: Discord.CommandInteraction, },
): CommandInteraction { })
return { }
override reply(interaction: Discord.CommandInteraction): ReacordInstance {
return this.createCommandReplyRendererInstance({
type: "command", type: "command",
id: interaction.id, id: interaction.id,
channelId: interaction.channelId, channelId: interaction.channelId,
@@ -55,20 +47,7 @@ export class DiscordJsAdapter implements Adapter<DiscordJsAdapterGenerics> {
}) })
return createReacordMessage(message as Discord.Message) 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}`) raise(`Unsupported component interaction type: ${interaction.type}`)
} }
function createReacordMessage(message: Discord.Message): Message { // TODO: this could be a part of the core library,
return { // and also handle some edge cases, e.g. empty messages
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()
},
}
}
function getDiscordMessageOptions( function getDiscordMessageOptions(
options: MessageOptions, options: MessageOptions,
): Discord.MessageOptions { ): Discord.MessageOptions {
return { return {
content: options.content, // eslint-disable-next-line unicorn/no-null
content: options.content || null,
embeds: options.embeds, embeds: options.embeds,
components: options.actionRows.map((row) => ({ components: options.actionRows.map((row) => ({
type: "ACTION_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 { ReactNode } from "react"
import type { Channel } from "../internal/channel"
import { ChannelMessageRenderer } from "../internal/channel-message-renderer" import { ChannelMessageRenderer } from "../internal/channel-message-renderer"
import { CommandReplyRenderer } from "../internal/command-reply-renderer.js" import { CommandReplyRenderer } from "../internal/command-reply-renderer.js"
import type {
CommandInteraction,
ComponentInteraction,
} from "../internal/interaction"
import { reconciler } from "../internal/reconciler.js" import { reconciler } from "../internal/reconciler.js"
import type { Renderer } from "../internal/renderer" 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. * The max number of active instances.
* When this limit is exceeded, the oldest instances will be disabled. * When this limit is exceeded, the oldest instances will be disabled.
@@ -21,33 +23,37 @@ export type ReacordInstance = {
destroy: () => void destroy: () => void
} }
export class Reacord<Generics extends AdapterGenerics> { export type ComponentInteractionListener = (
interaction: ComponentInteraction,
) => void
export abstract class Reacord {
private renderers: Renderer[] = [] private renderers: Renderer[] = []
constructor(private readonly config: ReacordConfig<Generics>) { constructor(private readonly config: ReacordConfig = {}) {}
config.adapter.addComponentInteractionListener((interaction) => {
abstract send(channel: unknown): ReacordInstance
abstract reply(commandInteraction: unknown): ReacordInstance
protected handleComponentInteraction(interaction: ComponentInteraction) {
for (const renderer of this.renderers) { for (const renderer of this.renderers) {
if (renderer.handleComponentInteraction(interaction)) return if (renderer.handleComponentInteraction(interaction)) return
} }
})
} }
private get maxInstances() { private get maxInstances() {
return this.config.maxInstances ?? 50 return this.config.maxInstances ?? 50
} }
send(init: Generics["channelInit"]): ReacordInstance { protected createChannelRendererInstance(channel: Channel) {
return this.createInstance( return this.createInstance(new ChannelMessageRenderer(channel))
new ChannelMessageRenderer(this.config.adapter.createChannel(init)),
)
} }
reply(init: Generics["commandReplyInit"]): ReacordInstance { protected createCommandReplyRendererInstance(
return this.createInstance( commandInteraction: CommandInteraction,
new CommandReplyRenderer( ): ReacordInstance {
this.config.adapter.createCommandInteraction(init), return this.createInstance(new CommandReplyRenderer(commandInteraction))
),
)
} }
private createInstance(renderer: Renderer) { 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/action-row"
export * from "./core/components/button" export * from "./core/components/button"
export * from "./core/components/embed" export * from "./core/components/embed"
@@ -13,3 +11,5 @@ export * from "./core/components/link"
export * from "./core/components/option" export * from "./core/components/option"
export * from "./core/components/select" export * from "./core/components/select"
export * from "./core/reacord" 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", "lodash-es": "^4.17.21",
"nodemon": "^2.0.15", "nodemon": "^2.0.15",
"prettier": "^2.5.1", "prettier": "^2.5.1",
"pretty-ms": "^7.0.1",
"react": "^17.0.2", "react": "^17.0.2",
"tsup": "^5.11.9", "tsup": "^5.11.9",
"type-fest": "^2.8.0", "type-fest": "^2.8.0",

View File

@@ -1,7 +1,7 @@
import { Client } from "discord.js" import { Client } from "discord.js"
import "dotenv/config" import "dotenv/config"
import React from "react" import React from "react"
import { DiscordJsAdapter, Reacord } from "../library/main" import { ReacordDiscordJs } from "../library/main"
import { createCommandHandler } from "./command-handler" import { createCommandHandler } from "./command-handler"
import { Counter } from "./counter" import { Counter } from "./counter"
import { FruitSelect } from "./fruit-select" import { FruitSelect } from "./fruit-select"
@@ -10,10 +10,38 @@ const client = new Client({
intents: ["GUILDS"], intents: ["GUILDS"],
}) })
const reacord = new Reacord({ const reacord = new ReacordDiscordJs(client)
adapter: new DiscordJsAdapter(client),
maxInstances: 2, // 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, [ createCommandHandler(client, [
{ {

14
pnpm-lock.yaml generated
View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,9 @@
import { jest } from "@jest/globals" import { jest } from "@jest/globals"
import React, { useState } from "react" import React, { useState } from "react"
import { Button, Option, Select } from "../library/main" import { Button, Option, ReacordTester, Select } from "../library/main"
import { setupReacordTesting } from "./setup-testing"
const { adapter, reply, assertRender, assertMessages } = setupReacordTesting()
test("single select", async () => { test("single select", async () => {
const tester = new ReacordTester()
const onSelect = jest.fn() const onSelect = jest.fn()
function TestSelect() { function TestSelect() {
@@ -30,7 +28,7 @@ test("single select", async () => {
} }
async function assertSelect(values: string[], disabled = false) { async function assertSelect(values: string[], disabled = false) {
await assertMessages([ await tester.assertMessages([
{ {
content: "", content: "",
embeds: [], embeds: [],
@@ -54,23 +52,26 @@ test("single select", async () => {
]) ])
} }
const reply = tester.reply()
reply.render(<TestSelect />) reply.render(<TestSelect />)
await assertSelect([]) await assertSelect([])
expect(onSelect).toHaveBeenCalledTimes(0) expect(onSelect).toHaveBeenCalledTimes(0)
adapter.findSelectByPlaceholder("choose one").select("2") tester.findSelectByPlaceholder("choose one").select("2")
await assertSelect(["2"]) await assertSelect(["2"])
expect(onSelect).toHaveBeenCalledWith({ values: ["2"] }) expect(onSelect).toHaveBeenCalledWith({ values: ["2"] })
adapter.findButtonByLabel("disable").click() tester.findButtonByLabel("disable").click()
await assertSelect(["2"], true) await assertSelect(["2"], true)
adapter.findSelectByPlaceholder("choose one").select("1") tester.findSelectByPlaceholder("choose one").select("1")
await assertSelect(["2"], true) await assertSelect(["2"], true)
expect(onSelect).toHaveBeenCalledTimes(1) expect(onSelect).toHaveBeenCalledTimes(1)
}) })
test("multiple select", async () => { test("multiple select", async () => {
const tester = new ReacordTester()
const onSelect = jest.fn() const onSelect = jest.fn()
function TestSelect() { function TestSelect() {
@@ -91,7 +92,7 @@ test("multiple select", async () => {
} }
async function assertSelect(values: string[]) { async function assertSelect(values: string[]) {
await assertMessages([ await tester.assertMessages([
{ {
content: "", content: "",
embeds: [], embeds: [],
@@ -115,31 +116,34 @@ test("multiple select", async () => {
]) ])
} }
const reply = tester.reply()
reply.render(<TestSelect />) reply.render(<TestSelect />)
await assertSelect([]) await assertSelect([])
expect(onSelect).toHaveBeenCalledTimes(0) expect(onSelect).toHaveBeenCalledTimes(0)
adapter.findSelectByPlaceholder("select").select("1", "3") tester.findSelectByPlaceholder("select").select("1", "3")
await assertSelect(expect.arrayContaining(["1", "3"])) await assertSelect(expect.arrayContaining(["1", "3"]))
expect(onSelect).toHaveBeenCalledWith({ expect(onSelect).toHaveBeenCalledWith({
values: expect.arrayContaining(["1", "3"]), values: expect.arrayContaining(["1", "3"]),
}) })
adapter.findSelectByPlaceholder("select").select("2") tester.findSelectByPlaceholder("select").select("2")
await assertSelect(expect.arrayContaining(["2"])) await assertSelect(expect.arrayContaining(["2"]))
expect(onSelect).toHaveBeenCalledWith({ expect(onSelect).toHaveBeenCalledWith({
values: expect.arrayContaining(["2"]), values: expect.arrayContaining(["2"]),
}) })
adapter.findSelectByPlaceholder("select").select() tester.findSelectByPlaceholder("select").select()
await assertSelect([]) await assertSelect([])
expect(onSelect).toHaveBeenCalledWith({ values: [] }) expect(onSelect).toHaveBeenCalledWith({ values: [] })
}) })
test("optional onSelect + unknown value", async () => { test("optional onSelect + unknown value", async () => {
reply.render(<Select placeholder="select" />) const tester = new ReacordTester()
adapter.findSelectByPlaceholder("select").select("something") tester.reply().render(<Select placeholder="select" />)
await assertMessages([ tester.findSelectByPlaceholder("select").select("something")
await tester.assertMessages([
{ {
content: "", content: "",
embeds: [], 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 # core features
- [ ] render to channel - [x] render to channel
- [x] render to interaction - [x] render to interaction
- [ ] ephemeral messages - [ ] ephemeral messages
- [x] message content - [x] message content
@@ -43,5 +43,6 @@
- [ ] max instance count per guild - [ ] max instance count per guild
- [ ] max instance count per channel - [ ] max instance count per channel
- [ ] uncontrolled select - [ ] uncontrolled select
- [ ] single class/helper function for testing `ReacordTester` - [x] single class/helper function for testing `ReacordTester`
- [ ] some failsafes and fallbacks in DJS adapter - [ ] 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