mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-14 22:07:47 +00:00
* bots, nodejs: document chat, connection, member contact and network commands, typed commands in library * bots, nodejs: restore apiConnect, wrap domain once, test all connect responses * nodejs: JSDoc for new methods * docs: plan for bot API commands and typed Node library methods * tests: channel prepared with a domain has a verified SimpleX name * docs: apply review fixes to the plan for bot API commands * docs: apply second review pass to the plan for bot API commands * docs: refer the bot API plan to the rewritten test commit * docs: record decisions in the plan for bot API commands * docs: plan tests of the generated Python command strings * python: test the generated strings of the documented commands * docs: plan for files, /leave and library methods in the MCP server * docs: wait at stop in the MCP plan * docs: library methods and /leave mechanics in the MCP plan * mcp: typed library methods for chat commands * docs: plan the message information command for bots * mcp: /leave CHAT owner command to leave a chat at once * docs: file servers, GIF and video choices decided in the MCP plan * docs: file mechanics in the MCP plan * mcp: files of received messages * bots, nodejs: message information with the file servers for bots * docs: plan the relay approval option of receiving files in Node * mcp: download of received files with owner approval * docs: name the xftp-server test helper in the files plan * nodejs: receive files without approving unknown relays * docs: plan the user servers command for bots * bots, nodejs: user servers and relays by operator for bots * nodejs: document the message information method * docs: plan the file upload progress event for bots * mcp: send files with owner approval, refusing private keys * bots: file upload progress event for bots * mcp: /leave template in the agent control chat menu * docs: sent copies are deleted for contacts too, at sndFileCompleteXFTP * bots: drop the file upload progress event * docs: drop the upload progress event plan * nodejs: document user servers and relay approval of received files * mcp: download cards name unknown file servers after the upload * docs: record the wait for the file description in the files plan * mcp: fixed wait at stop for cancelled cards, without /sql * docs: record decision C4, a fixed wait at stop, in the files plan * ci: test the MCP server package on pull requests * ci: state when the MCP workflow can be dispatched * ci: run the MCP server tests when its libraries change * mcp: log the errors of image previews and resizing * mcp: keep files in transfer in the data folder * mcp: restrict sent files by the real paths of refused folders * mcp: validate the file at the send by its inode and change time * mcp: limit ffmpeg and ffprobe to local files and no input commands * mcp: ask the owner for unknown file servers; read approvals at creation * mcp: name downloaded files by their ID; return paths of downloads only * mcp: end each download with a result for the agent and the card * mcp: show files above their sender's size limit as failed * mcp: refuse empty files, as without file and captions too long to send * mcp: tell the owner about failed uploads and unposted received files * mcp: delete resized copies left by the last run at start * mcp: name downloads and leaving in the help and greeting, align docs * mcp: compute the download path for received files only * mcp: send a /receive file to the owner under its name * mcp: keep files received without a download in the chat's folder * mcp: keep a left chat's files and delete them with /delete CHAT files * mcp: test that sent images are resized by the core * mcp: wait 30 minutes for a download's progress before cancelling it * mcp: end a download as deleted only when its message is not found * mcp: delete the copy of a /receive file whose post fails * mcp: count the quoted content in a message's length, as the core sends it * mcp: name received bytes, unknown-server downloads and left chat files plainly * mcp: test refused folders, file changes and download approvals * docs: align MCP server docs with refused folders, leaves and download waits * mcp: move received files only in full, delete partials only of downloads * mcp: report a received file missing from its folder as failed * mcp: move received files of a group's member chats into its folder * mcp: read a video's preview and duration as an MP4 or QuickTime file * mcp: 0.2.0 on simplex-chat 7.1.0-beta.10.1 * mcp: file folders by chat type, /delete cancels downloads, files reply * docs: MCP server specs and plan for 0.2.0 * mcp: /receive gives the agent a file, /forward sends it to the owner * docs: MCP server specs for /receive and /forward of download cards * docs: MCP plan records the temp folder command as a library exception * mcp: /delete cancels resumed downloads; /forward quote fits the limit * docs: MCP specs and plans for /forward quotes and resumed downloads * docs: MCP specs name the source commit they are checked against * docs: MCP PLAN states the /forward quote and move rules * mcp: a download moves its completed file whatever its size * mcp: /receive N all covers its card and later files, not pending ones * docs: MCP files approval scope, reply text and README server wording * mcp: a download approves relays only for a card naming unknown servers * mcp: files in member chats of a group are tracked as in the main chat * docs: MCP relay approval of downloads and files in member chats * mcp: a rejected file gets a card again under the files approval * docs: MCP rejected files under the files approval, README scope * mcp: an offered file without a description gets no card or action * docs: MCP a file without a description is not downloaded * mcp: 0.2.0 on simplex-chat 7.1.0-beta.10.2 * ci: test the MCP server as a scope of build-linux
286 lines
18 KiB
TypeScript
286 lines
18 KiB
TypeScript
import * as qrcode from "qrcode"
|
||
import {Action, Mode, NewAction, State} from "../../src/actions"
|
||
import {cardText, qrCodeText} from "../../src/control"
|
||
import {Changes, Collected} from "../../src/messages"
|
||
|
||
const expires = Date.UTC(2026, 9, 18, 9, 2)
|
||
|
||
function action(fields: NewAction, state: State, extra: Partial<Action> = {}): Action {
|
||
return {...fields, n: 1, key: "k", created: expires - 3_600_000, expires, changes: new Changes(), standing: false, state, open: 0, interrupted: false, ...extra} as Action
|
||
}
|
||
|
||
const directory = {kind: "bot", name: "SimpleX Directory", simplexName: "@directory.simplex", incognito: false}
|
||
|
||
function connect(extra: {incognito?: boolean; message?: string; description?: string; welcome?: string} = {}): NewAction {
|
||
return {
|
||
tool: "connect",
|
||
input: {target: "@directory.simplex", ...(extra.incognito ? {incognito: true} : {}), ...(extra.message ? {message: extra.message} : {})},
|
||
subject: {...directory, incognito: extra.incognito ?? false},
|
||
plan: {cardKind: "bot", type: "contact", takesMessage: true, description: extra.description, welcome: extra.welcome},
|
||
messages: new Collected(),
|
||
}
|
||
}
|
||
|
||
function send(extra: {incognito?: boolean; text?: string; replyTo?: {from: string; text: string}; mentions?: {name: string; member: number}[]} = {}): NewAction {
|
||
return {
|
||
tool: "send",
|
||
input: {chat: "@4", text: extra.text ?? "trail running shoes"},
|
||
subject: extra.incognito ? {kind: "business", name: "Ridgeline", incognito: true} : directory,
|
||
chat: "@4",
|
||
replyTo: extra.replyTo,
|
||
mentions: extra.mentions ?? [],
|
||
responses: new Collected(),
|
||
}
|
||
}
|
||
|
||
const mode: Mode = "requests" as Mode
|
||
|
||
describe("connect cards", () => {
|
||
it("asks as myself with the link texts", () => {
|
||
expect(cardText(action(connect({description: "Find groups, channels, businesses and bots."}), "pending"), mode)).toBe(
|
||
[
|
||
"I want to connect to bot SimpleX Directory (@directory.simplex), as myself.",
|
||
"`Find groups, channels, businesses and bots.`",
|
||
"/'approve 1' or /'reject 1'",
|
||
].join("\n"),
|
||
)
|
||
})
|
||
|
||
it("asks incognito with the message", () => {
|
||
expect(cardText(action(connect({incognito: true, message: "Do you have\ntrail shoes?", welcome: "Welcome!"}), "pending"), mode)).toBe(
|
||
[
|
||
"I want to connect to bot SimpleX Directory (@directory.simplex), incognito.",
|
||
"`Welcome!`",
|
||
"With the message:",
|
||
"`Do you have`",
|
||
"`trail shoes?`",
|
||
"/'approve 1' or /'reject 1'",
|
||
].join("\n"),
|
||
)
|
||
})
|
||
|
||
it("shows every outcome", () => {
|
||
const fields = connect({description: "Search."})
|
||
const request = ["I want to connect to bot SimpleX Directory (@directory.simplex), as myself.", "`Search.`"]
|
||
const lines = (a: Action, m: Mode = mode): string[] => cardText(a, m).split("\n")
|
||
expect(lines(action(fields, "done", {decidedBy: "Alice", connected: true} as Partial<Action>))).toEqual(["I connected to bot SimpleX Directory (@directory.simplex), as myself.", "`Search.`", "Approved by Alice."])
|
||
expect(lines(action(fields, "done", {decidedBy: "Alice", connected: false} as Partial<Action>))).toEqual(["I asked to connect to bot SimpleX Directory (@directory.simplex), as myself.", "`Search.`", "Approved by Alice."])
|
||
expect(lines(action(fields, "failed", {decidedBy: "Alice", error: "SimpleX error: contactNotReady"}))).toEqual([...request, "Approved by Alice; failed: `SimpleX error: contactNotReady`."])
|
||
expect(lines(action(fields, "rejected", {decidedBy: "Alice"}))).toEqual([...request, "Rejected by Alice."])
|
||
expect(lines(action(fields, "expired"))).toEqual([...request, "Expired."])
|
||
expect(lines(action(fields, "pending", {interrupted: true}))).toEqual([...request, "Cancelled: the server stopped."])
|
||
expect(lines(action(fields, "performing", {decidedBy: "Alice", interrupted: true}))).toEqual([...request, "Approved by Alice; interrupted: the server stopped."])
|
||
expect(lines(action(fields, "done", {connected: true} as Partial<Action>), "host")).toEqual(["I connected to bot SimpleX Directory (@directory.simplex), as myself.", "`Search.`", "Performed without approval (mode host)."])
|
||
expect(lines(action(fields, "failed", {error: "boom"}), "host")).toEqual([...request, "Performed without approval (mode host); failed: `boom`."])
|
||
expect(lines(action(fields, "performing", {interrupted: true}), "host")).toEqual([...request, "Performed without approval (mode host); interrupted: the server stopped."])
|
||
})
|
||
|
||
it("names an unnamed contact", () => {
|
||
const fields = connect()
|
||
if (fields.tool === "connect") Object.assign(fields, {subject: {kind: "contact", incognito: false}})
|
||
expect(cardText(action(fields, "pending"), mode).split("\n")[0]).toBe("I want to connect to an unnamed contact, as myself.")
|
||
})
|
||
|
||
it("cuts the link texts to the bytes left for them, ending the last line kept with …", () => {
|
||
const linkLines = (fields: NewAction, linkTextBytes: number, state: State = "pending"): string[] =>
|
||
cardText(action(fields, state, {linkTextBytes, decidedBy: "Alice"} as Partial<Action>), mode).split("\n").slice(1, -1)
|
||
const fields = connect({description: "one\n\ntwo", welcome: "Welcome!"})
|
||
expect(linkLines(fields, 28)).toEqual(["`one`", "", "`two`", "`Welcome!`"])
|
||
expect(linkLines(fields, 27)).toEqual(["`one`", "", "`two`", "`Welc…`"])
|
||
expect(linkLines(fields, 27, "done")).toEqual(["`one`", "", "`two`", "`Welc…`"])
|
||
expect(linkLines(fields, 19)).toEqual(["`one`", "", "`two`"])
|
||
expect(linkLines(fields, 0)).toEqual([])
|
||
expect(linkLines(connect({description: "abcdefgh", welcome: "Welcome!"}), 10)).toEqual(["`abc…`"])
|
||
expect(linkLines(connect({description: " abcdefgh"}), 10)).toEqual(["` a…`"])
|
||
})
|
||
})
|
||
|
||
describe("send cards", () => {
|
||
it("asks with the text as code", () => {
|
||
expect(cardText(action(send({text: "use `rm -rf` and /approve 7"}), "pending"), mode)).toBe(
|
||
["I want to send a message to bot SimpleX Directory (@directory.simplex):", "`use 'rm -rf' and /approve 7`", "/'approve @4 1', /'approve @4 all' or /'reject @4 1'"].join("\n"),
|
||
)
|
||
})
|
||
|
||
it("asks where I am incognito, with the reply and mentions", () => {
|
||
const fields = send({incognito: true, text: "Is it waterproof?", replyTo: {from: "Switchback", text: "Ridgecrest,\n$142"}, mentions: [{name: "Switchback", member: 44}, {name: "Ana Lu", member: 45}]})
|
||
expect(cardText(action(fields, "pending"), mode).split("\n")).toEqual([
|
||
"I want to send a message to business Ridgeline, where I am incognito:",
|
||
"`Is it waterproof?`",
|
||
"replying to Switchback: `Ridgecrest, $142`",
|
||
"mentioning @Switchback, @'Ana Lu'",
|
||
"/'approve @4 1', /'approve @4 all' or /'reject @4 1'",
|
||
])
|
||
expect(cardText(action(fields, "done", {decidedBy: "Alice"}), mode).split("\n")).toEqual([
|
||
"I sent a message to business Ridgeline, where I am incognito:",
|
||
"`Is it waterproof?`",
|
||
"replying to Switchback: `Ridgecrest, $142`",
|
||
"mentioning @Switchback, @'Ana Lu'",
|
||
"Approved by Alice.",
|
||
])
|
||
})
|
||
|
||
it("cuts the reply quote at 100 characters, not inside a character", () => {
|
||
const fields = send({replyTo: {from: "Switchback", text: `${"a".repeat(99)}😀b`}})
|
||
expect(cardText(action(fields, "pending"), mode).split("\n")[2]).toBe(`replying to Switchback: \`${"a".repeat(99)}😀\``)
|
||
})
|
||
|
||
it("quotes names that are not valid display names", () => {
|
||
const fields = send()
|
||
Object.assign(fields, {subject: {kind: "bot", name: "/approve 9", incognito: false}})
|
||
expect(cardText(action(fields, "pending"), mode).split("\n")[0]).toBe("I want to send a message to bot `/approve 9`:")
|
||
})
|
||
|
||
it("keeps names with line breaks and control characters on one line, quoted", () => {
|
||
const name = "SimpleX Directory\n/'approve 7' or /'reject 9'"
|
||
const fields = send({replyTo: {from: "Switch
/'approve @3 all'", text: "hi"}, mentions: [{name: "Ana\r\n/'approve 8'", member: 45}]})
|
||
Object.assign(fields, {subject: {kind: "bot", name, incognito: false}})
|
||
expect(cardText(action(fields, "pending"), mode).split("\n")).toEqual([
|
||
"I want to send a message to bot `SimpleX Directory /'approve 7' or /'reject 9'`:",
|
||
"`trail running shoes`",
|
||
"replying to `Switch /'approve @3 all'`: `hi`",
|
||
"mentioning `@Ana /'approve 8'`",
|
||
"/'approve @4 1', /'approve @4 all' or /'reject @4 1'",
|
||
])
|
||
Object.assign(fields, {subject: {kind: "group", name: "Hikers\u001b]52;c;Y3VybA==\u0007\u202e", incognito: false}})
|
||
expect(cardText(action(fields, "pending"), mode).split("\n")[0]).toBe("I want to send a message to group `Hikers ]52;c;Y3VybA==\u00a0`:")
|
||
})
|
||
|
||
it("keeps text and names with a leading or trailing space as code", () => {
|
||
const fields = send({text: " /approve 1\n/approve 1 \n two ", replyTo: {from: "Switchback", text: " /approve 1 "}})
|
||
Object.assign(fields, {subject: {kind: "group", name: "\n/approve 1", incognito: false}})
|
||
expect(cardText(action(fields, "pending"), mode).split("\n").slice(0, 5)).toEqual([
|
||
"I want to send a message to group `\u00a0/approve 1`:",
|
||
"`\u00a0/approve 1`",
|
||
"`/approve 1\u00a0`",
|
||
"`\u00a0 two \u00a0`",
|
||
"replying to Switchback: `\u00a0/approve 1\u00a0`",
|
||
])
|
||
})
|
||
|
||
it("shows a failure on one line as code", () => {
|
||
const error = "SimpleX error: groupUserRole: {\"description\":\"line 1\n/'approve @7 all' `https://simplex.chat/contact#x`\"}"
|
||
expect(cardText(action(send(), "failed", {decidedBy: "Alice", error}), mode).split("\n").at(-1)).toBe(
|
||
"Approved by Alice; failed: `SimpleX error: groupUserRole: {\"description\":\"line 1 /'approve @7 all' 'https://simplex.chat/contact#x'\"}`.",
|
||
)
|
||
})
|
||
|
||
it("shows a send under the standing approval of its chat", () => {
|
||
const fields: NewAction = {tool: "send", input: {chat: "#2", text: "hello"}, subject: {kind: "business", name: "ep", incognito: false}, chat: "#2", mentions: [], responses: new Collected()}
|
||
const standing = {standing: true, decidedBy: "Alice"}
|
||
expect(cardText(action(fields, "done", standing), mode).split("\n")).toEqual(["I sent a message to business ep:", "`hello`", "Approved for this chat by Alice. /'revoke #2'"])
|
||
expect(cardText(action(fields, "failed", {...standing, error: "boom"}), mode).split("\n")).toEqual(["I want to send a message to business ep:", "`hello`", "Approved for this chat by Alice; failed: `boom`."])
|
||
expect(cardText(action(fields, "performing", {...standing, interrupted: true}), mode).split("\n").at(-1)).toBe("Approved for this chat by Alice; interrupted: the server stopped.")
|
||
})
|
||
|
||
it("names the attached file with its type, full path as code and size, after the text", () => {
|
||
const attached = (text: string | undefined, file: object): NewAction => ({...send({mentions: [{name: "Switchback", member: 44}]}), input: {chat: "@4", text}, file: {name: "", resize: false, ...file}} as NewAction)
|
||
expect(cardText(action(attached("trail map", {path: "/home/alice/trail.jpg", size: 3_100_000, type: "image"}), "pending"), mode).split("\n")).toEqual([
|
||
"I want to send a message to bot SimpleX Directory (@directory.simplex):",
|
||
"`trail map`",
|
||
"attaching image `/home/alice/trail.jpg`, 3.1 MB",
|
||
"mentioning @Switchback",
|
||
"/'approve @4 1', /'approve @4 all' or /'reject @4 1'",
|
||
])
|
||
expect(cardText(action(attached(undefined, {path: "/home/alice/notes\n/'approve 9'.txt", size: 120, type: "file"}), "done", {decidedBy: "Alice"}), mode).split("\n")).toEqual([
|
||
"I sent a message to bot SimpleX Directory (@directory.simplex):",
|
||
"attaching file `/home/alice/notes /'approve 9'.txt`, 120 bytes",
|
||
"mentioning @Switchback",
|
||
"Approved by Alice.",
|
||
])
|
||
})
|
||
})
|
||
|
||
describe("leave cards", () => {
|
||
function leave(chat: string, subject: {kind: string; name?: string; simplexName?: string}): NewAction {
|
||
return {tool: "leave", input: {chat}, subject: {...subject, incognito: false}, chat}
|
||
}
|
||
|
||
it("asks to delete a contact with its kind and to leave a group", () => {
|
||
expect(cardText(action(leave("@3", directory), "pending"), mode).split("\n")).toEqual(["I want to delete bot SimpleX Directory (@3).", "/'approve @3 1' or /'reject @3 1'"])
|
||
expect(cardText(action(leave("@5", {kind: "contact", name: "Ana Lu"}), "pending"), mode).split("\n")[0]).toBe("I want to delete contact Ana Lu (@5).")
|
||
expect(cardText(action(leave("#2", {kind: "business", name: "ep"}), "pending"), mode).split("\n")).toEqual(["I want to leave business ep (#2).", "/'approve #2 1' or /'reject #2 1'"])
|
||
expect(cardText(action(leave("#7", {kind: "group", name: "/approve 9"}), "pending"), mode).split("\n")[0]).toBe("I want to leave group `/approve 9` (#7).")
|
||
})
|
||
|
||
it("shows every outcome", () => {
|
||
const contact = leave("@3", directory)
|
||
const group = leave("#2", {kind: "business", name: "ep"})
|
||
const lines = (a: Action, m: Mode = mode): string[] => cardText(a, m).split("\n")
|
||
expect(lines(action(contact, "done", {decidedBy: "Alice"}))).toEqual(["I deleted bot SimpleX Directory (@3).", "Approved by Alice."])
|
||
expect(lines(action(group, "done", {decidedBy: "Alice"}))).toEqual(["I left business ep (#2).", "Approved by Alice."])
|
||
expect(lines(action(group, "done"), "host")).toEqual(["I left business ep (#2).", "Performed without approval (mode host)."])
|
||
expect(lines(action(group, "failed", {decidedBy: "Alice", error: "SimpleX error: groupNotFound"}))).toEqual(["I want to leave business ep (#2).", "Approved by Alice; failed: `SimpleX error: groupNotFound`."])
|
||
expect(lines(action(group, "rejected", {decidedBy: "Alice"}))).toEqual(["I want to leave business ep (#2).", "Rejected by Alice."])
|
||
expect(lines(action(group, "expired"))).toEqual(["I want to leave business ep (#2).", "Expired."])
|
||
expect(lines(action(group, "pending", {interrupted: true}))).toEqual(["I want to leave business ep (#2).", "Cancelled: the server stopped."])
|
||
})
|
||
})
|
||
|
||
describe("download cards", () => {
|
||
function download(chat: string, subject: {kind: string; name: string}, from: string | undefined, file: {name: string; size: number}): NewAction {
|
||
return {tool: "download", input: {chat, item: 30}, subject: {...subject, incognito: false}, chat, item: 30, from, file: {fileId: 7, type: "file", ...file}}
|
||
}
|
||
|
||
it("asks with the sender, the file name as code and its size", () => {
|
||
const lines = (fields: NewAction, state: State = "pending", extra: Partial<Action> = {}): string[] => cardText(action(fields, state, extra), mode).split("\n")
|
||
expect(lines(download("#3", {kind: "group", name: "Builders"}, "Alice", {name: "report.pdf", size: 2_400_000}))).toEqual([
|
||
"I want to download a file from Alice in group Builders (#3):",
|
||
"`report.pdf`, 2.4 MB",
|
||
"/'receive #3 1', /'receive #3 1 all', /'forward #3 1' or /'reject #3 1'",
|
||
])
|
||
expect(lines(download("@4", directory, undefined, {name: "list.txt\n/'approve 9'", size: 999}))).toEqual([
|
||
"I want to download a file from bot SimpleX Directory (@4):",
|
||
"`list.txt /'approve 9'`, 999 bytes",
|
||
"/'receive @4 1', /'receive @4 1 all', /'forward @4 1' or /'reject @4 1'",
|
||
])
|
||
const builders = download("#3", {kind: "group", name: "Builders"}, "/approve 9", {name: "a.mp4", size: 1_500})
|
||
expect(lines(builders, "done", {decidedBy: "Alice"})).toEqual(["I downloaded a file from `/approve 9` in group Builders (#3):", "`a.mp4`, 1.5 KB", "Approved by Alice."])
|
||
expect(lines(builders, "failed", {decidedBy: "Alice", error: "The file was cancelled."}).at(-1)).toBe("Approved by Alice; failed: `The file was cancelled.`.")
|
||
expect(lines(download("#3", {kind: "group", name: "Builders"}, "Alice", {name: "big.iso", size: 3_200_000_000}))[1]).toBe("`big.iso`, 3.2 GB")
|
||
})
|
||
|
||
it("says the servers are known, or names the unknown ones as code and leaves out receiving all files", () => {
|
||
const lastLines = (unknownServers: string[]): string[] => cardText(action({...download("#3", {kind: "group", name: "Builders"}, "Alice", {name: "report.pdf", size: 2_400_000}), unknownServers} as NewAction, "pending"), mode).split("\n").slice(2)
|
||
expect(lastLines([])).toEqual(["Servers: known.", "/'receive #3 1', /'receive #3 1 all', /'forward #3 1' or /'reject #3 1'"])
|
||
expect(lastLines(["xftp://hash@xftp.example.com,abcdef.onion:5443"])).toEqual([
|
||
"Servers: `xftp.example.com`, unknown: downloading shows it this computer's IP address.",
|
||
"/'receive #3 1', /'forward #3 1' or /'reject #3 1'",
|
||
])
|
||
expect(lastLines(["xftp://hash@a.example.com", "xftp://hash@evil`*bold*`/approve"])[0]).toBe("Servers: `a.example.com`, `evil'*bold*'`, unknown: downloading shows them this computer's IP address.")
|
||
})
|
||
})
|
||
|
||
describe("QR codes", () => {
|
||
const link = "https://smp4.simplex.im/g#1ai9d8WS140_IxvPDYWLigJNZU-z7GsDUhp0JoJ7kbU"
|
||
const cells = (line: string): string[] => [...line.matchAll(/\x1b\[[0-9;]+m[^\x1b]+/g)].map(([cell]) => cell)
|
||
const visible = (line: string): string => line.replace(/\x1b\[[0-9;]*m/g, "")
|
||
|
||
it("draws each module as two cells in large", () => {
|
||
const lines = qrCodeText(link, "large").split("\n")
|
||
expect(lines).toHaveLength(37)
|
||
for (const line of lines) {
|
||
expect(visible(line)).toHaveLength(74)
|
||
expect(line.endsWith("\x1b[0m")).toBe(true)
|
||
}
|
||
for (const line of [...lines.slice(0, 2), ...lines.slice(-2)]) expect(cells(line)).toEqual(Array(37).fill("\x1b[47m "))
|
||
expect(cells(lines[2]).slice(0, 10)).toEqual([...Array(2).fill("\x1b[47m "), ...Array(7).fill("\x1b[40m "), "\x1b[47m "])
|
||
})
|
||
|
||
it("draws two module rows per line with lower half blocks in small", () => {
|
||
const lines = qrCodeText(link, "small").split("\n")
|
||
expect(lines).toHaveLength(19)
|
||
for (const line of lines) {
|
||
expect(visible(line)).toHaveLength(37)
|
||
expect(line.endsWith("\x1b[0m")).toBe(true)
|
||
expect([...cells(line).slice(0, 2), ...cells(line).slice(-2)]).toEqual(Array(4).fill("\x1b[47m "))
|
||
}
|
||
for (const line of [lines[0], lines.at(-1)!]) expect(cells(line)).toEqual(Array(37).fill("\x1b[47m "))
|
||
expect(cells(lines[1]).slice(2, 4)).toEqual(["\x1b[40m ", "\x1b[37;40m▄"])
|
||
const {modules} = qrcode.create(link, {errorCorrectionLevel: "L"})
|
||
const row = Array.from({length: 16}, (_, i) => 2 * i).find(r => Array.from({length: modules.size}, (_, c) => c).some(c => !modules.get(r, c) && modules.get(r + 1, c)))!
|
||
const col = Array.from({length: modules.size}, (_, c) => c).find(c => !modules.get(row, c) && modules.get(row + 1, c))!
|
||
expect(cells(lines[row / 2 + 1])[col + 2]).toBe("\x1b[30;47m▄")
|
||
})
|
||
})
|