use fancy matchers, like Shrimp

This commit is contained in:
Chris Wanstrath 2025-11-08 11:38:41 -08:00
parent 3e2e68b31f
commit 614f5c4f91
6 changed files with 443 additions and 201 deletions

2
bunfig.toml Normal file
View File

@ -0,0 +1,2 @@
[test]
preload = ["./tests/setup.ts"]

View File

@ -6,84 +6,84 @@ describe('bitwise operations', () => {
const bytecode = toBytecode([ const bytecode = toBytecode([
["PUSH", 5], ["PUSH", 3], ["BIT_AND"], ["HALT"] ["PUSH", 5], ["PUSH", 3], ["BIT_AND"], ["HALT"]
]) ])
expect(await run(bytecode)).toEqual({ type: 'number', value: 1 }) await expect(bytecode).toBeNumber(1)
}) })
test('BIT_AND with zero', async () => { test('BIT_AND with zero', async () => {
const bytecode = toBytecode([ const bytecode = toBytecode([
["PUSH", 5], ["PUSH", 0], ["BIT_AND"], ["HALT"] ["PUSH", 5], ["PUSH", 0], ["BIT_AND"], ["HALT"]
]) ])
expect(await run(bytecode)).toEqual({ type: 'number', value: 0 }) await expect(bytecode).toBeNumber(0)
}) })
test('BIT_OR', async () => { test('BIT_OR', async () => {
const bytecode = toBytecode([ const bytecode = toBytecode([
["PUSH", 5], ["PUSH", 3], ["BIT_OR"], ["HALT"] ["PUSH", 5], ["PUSH", 3], ["BIT_OR"], ["HALT"]
]) ])
expect(await run(bytecode)).toEqual({ type: 'number', value: 7 }) await expect(bytecode).toBeNumber(7)
}) })
test('BIT_OR with zero', async () => { test('BIT_OR with zero', async () => {
const bytecode = toBytecode([ const bytecode = toBytecode([
["PUSH", 5], ["PUSH", 0], ["BIT_OR"], ["HALT"] ["PUSH", 5], ["PUSH", 0], ["BIT_OR"], ["HALT"]
]) ])
expect(await run(bytecode)).toEqual({ type: 'number', value: 5 }) await expect(bytecode).toBeNumber(5)
}) })
test('BIT_XOR', async () => { test('BIT_XOR', async () => {
const bytecode = toBytecode([ const bytecode = toBytecode([
["PUSH", 5], ["PUSH", 3], ["BIT_XOR"], ["HALT"] ["PUSH", 5], ["PUSH", 3], ["BIT_XOR"], ["HALT"]
]) ])
expect(await run(bytecode)).toEqual({ type: 'number', value: 6 }) await expect(bytecode).toBeNumber(6)
}) })
test('BIT_XOR with itself returns zero', async () => { test('BIT_XOR with itself returns zero', async () => {
const bytecode = toBytecode([ const bytecode = toBytecode([
["PUSH", 5], ["PUSH", 5], ["BIT_XOR"], ["HALT"] ["PUSH", 5], ["PUSH", 5], ["BIT_XOR"], ["HALT"]
]) ])
expect(await run(bytecode)).toEqual({ type: 'number', value: 0 }) await expect(bytecode).toBeNumber(0)
}) })
test('BIT_SHL', async () => { test('BIT_SHL', async () => {
const bytecode = toBytecode([ const bytecode = toBytecode([
["PUSH", 5], ["PUSH", 2], ["BIT_SHL"], ["HALT"] ["PUSH", 5], ["PUSH", 2], ["BIT_SHL"], ["HALT"]
]) ])
expect(await run(bytecode)).toEqual({ type: 'number', value: 20 }) await expect(bytecode).toBeNumber(20)
}) })
test('BIT_SHL by zero', async () => { test('BIT_SHL by zero', async () => {
const bytecode = toBytecode([ const bytecode = toBytecode([
["PUSH", 5], ["PUSH", 0], ["BIT_SHL"], ["HALT"] ["PUSH", 5], ["PUSH", 0], ["BIT_SHL"], ["HALT"]
]) ])
expect(await run(bytecode)).toEqual({ type: 'number', value: 5 }) await expect(bytecode).toBeNumber(5)
}) })
test('BIT_SHR', async () => { test('BIT_SHR', async () => {
const bytecode = toBytecode([ const bytecode = toBytecode([
["PUSH", 20], ["PUSH", 2], ["BIT_SHR"], ["HALT"] ["PUSH", 20], ["PUSH", 2], ["BIT_SHR"], ["HALT"]
]) ])
expect(await run(bytecode)).toEqual({ type: 'number', value: 5 }) await expect(bytecode).toBeNumber(5)
}) })
test('BIT_SHR preserves sign for negative numbers', async () => { test('BIT_SHR preserves sign for negative numbers', async () => {
const bytecode = toBytecode([ const bytecode = toBytecode([
["PUSH", -20], ["PUSH", 2], ["BIT_SHR"], ["HALT"] ["PUSH", -20], ["PUSH", 2], ["BIT_SHR"], ["HALT"]
]) ])
expect(await run(bytecode)).toEqual({ type: 'number', value: -5 }) await expect(bytecode).toBeNumber(-5)
}) })
test('BIT_USHR', async () => { test('BIT_USHR', async () => {
const bytecode = toBytecode([ const bytecode = toBytecode([
["PUSH", -1], ["PUSH", 1], ["BIT_USHR"], ["HALT"] ["PUSH", -1], ["PUSH", 1], ["BIT_USHR"], ["HALT"]
]) ])
expect(await run(bytecode)).toEqual({ type: 'number', value: 2147483647 }) await expect(bytecode).toBeNumber(2147483647)
}) })
test('BIT_USHR does not preserve sign', async () => { test('BIT_USHR does not preserve sign', async () => {
const bytecode = toBytecode([ const bytecode = toBytecode([
["PUSH", -8], ["PUSH", 1], ["BIT_USHR"], ["HALT"] ["PUSH", -8], ["PUSH", 1], ["BIT_USHR"], ["HALT"]
]) ])
expect(await run(bytecode)).toEqual({ type: 'number', value: 2147483644 }) await expect(bytecode).toBeNumber(2147483644)
}) })
test('compound bitwise operations', async () => { test('compound bitwise operations', async () => {
@ -94,7 +94,7 @@ describe('bitwise operations', () => {
["BIT_OR"], // stack: [5] ["BIT_OR"], // stack: [5]
["HALT"] ["HALT"]
]) ])
expect(await run(bytecode)).toEqual({ type: 'number', value: 5 }) await expect(bytecode).toBeNumber(5)
}) })
test('shift with large shift amounts', async () => { test('shift with large shift amounts', async () => {
@ -102,6 +102,6 @@ describe('bitwise operations', () => {
["PUSH", 1], ["PUSH", 31], ["BIT_SHL"], ["HALT"] ["PUSH", 1], ["PUSH", 31], ["BIT_SHL"], ["HALT"]
]) ])
// 1 << 31 = -2147483648 (most significant bit set) // 1 << 31 = -2147483648 (most significant bit set)
expect(await run(bytecode)).toEqual({ type: 'number', value: -2147483648 }) await expect(bytecode).toBeNumber(-2147483648)
}) })
}) })

View File

@ -15,7 +15,7 @@ test("PUSH_TRY and POP_TRY - no exception thrown", async () => {
PUSH 999 PUSH 999
HALT HALT
` `
expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 52 }) await expect(str).toBeNumber(52)
}) })
test("THROW - catch exception with error value", async () => { test("THROW - catch exception with error value", async () => {
@ -29,7 +29,7 @@ test("THROW - catch exception with error value", async () => {
.catch: .catch:
HALT HALT
` `
expect(await run(toBytecode(str))).toEqual({ type: 'string', value: 'error occurred' }) await expect(str).toBeString('error occurred')
}) })
test("THROW - uncaught exception throws JS error", async () => { test("THROW - uncaught exception throws JS error", async () => {
@ -58,7 +58,7 @@ test("THROW - exception with nested try blocks", async () => {
PUSH "outer error" PUSH "outer error"
HALT HALT
` `
expect(await run(toBytecode(str))).toEqual({ type: 'string', value: 'inner error' }) await expect(str).toBeString('inner error')
}) })
test("THROW - exception skips outer handler", async () => { test("THROW - exception skips outer handler", async () => {
@ -75,7 +75,7 @@ test("THROW - exception skips outer handler", async () => {
.outer_catch: .outer_catch:
HALT HALT
` `
expect(await run(toBytecode(str))).toEqual({ type: 'string', value: 'error message' }) await expect(str).toBeString('error message')
}) })
test("THROW - exception unwinds call stack", async () => { test("THROW - exception unwinds call stack", async () => {
@ -150,7 +150,7 @@ test("PUSH_FINALLY - finally executes after successful try", async () => {
ADD ADD
HALT HALT
` `
expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 110 }) await expect(str).toBeNumber(110)
}) })
test("PUSH_FINALLY - finally executes after exception", async () => { test("PUSH_FINALLY - finally executes after exception", async () => {
@ -169,7 +169,7 @@ test("PUSH_FINALLY - finally executes after exception", async () => {
PUSH "finally ran" PUSH "finally ran"
HALT HALT
` `
expect(await run(toBytecode(str))).toEqual({ type: 'string', value: 'finally ran' }) await expect(str).toBeString('finally ran')
}) })
test("PUSH_FINALLY - finally without catch", async () => { test("PUSH_FINALLY - finally without catch", async () => {
@ -189,7 +189,7 @@ test("PUSH_FINALLY - finally without catch", async () => {
ADD ADD
HALT HALT
` `
expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 52 }) await expect(str).toBeNumber(52)
}) })
test("PUSH_FINALLY - nested try-finally blocks", async () => { test("PUSH_FINALLY - nested try-finally blocks", async () => {
@ -214,7 +214,7 @@ test("PUSH_FINALLY - nested try-finally blocks", async () => {
ADD ADD
HALT HALT
` `
expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 11 }) await expect(str).toBeNumber(11)
}) })
test("PUSH_FINALLY - error when no handler", async () => { test("PUSH_FINALLY - error when no handler", async () => {

File diff suppressed because it is too large Load Diff

View File

@ -159,14 +159,14 @@ describe("RegExp", () => {
PUSH /bar/ PUSH /bar/
NEQ NEQ
` `
expect(await run(toBytecode(str))).toEqual({ type: 'boolean', value: true }) await expect(str).toBeBoolean(true)
const str2 = ` const str2 = `
PUSH /test/i PUSH /test/i
PUSH /test/i PUSH /test/i
NEQ NEQ
` `
expect(await run(toBytecode(str2))).toEqual({ type: 'boolean', value: false }) await expect(str2).toBeBoolean(false)
}) })
test("is truthy", async () => { test("is truthy", async () => {
@ -177,7 +177,7 @@ describe("RegExp", () => {
PUSH 42 PUSH 42
.end: .end:
` `
expect(await run(toBytecode(str))).toEqual({ type: 'number', value: 42 }) await expect(str).toBeNumber(42)
}) })
test("NOT returns false (regex is truthy)", async () => { test("NOT returns false (regex is truthy)", async () => {
@ -185,7 +185,7 @@ describe("RegExp", () => {
PUSH /pattern/ PUSH /pattern/
NOT NOT
` `
expect(await run(toBytecode(str))).toEqual({ type: 'boolean', value: false }) await expect(str).toBeBoolean(false)
}) })
test("in arrays", async () => { test("in arrays", async () => {
@ -301,7 +301,7 @@ describe("RegExp", () => {
PUSH /bar/i PUSH /bar/i
STR_CONCAT #3 STR_CONCAT #3
` `
expect(await run(toBytecode(str))).toEqual({ type: 'string', value: '/foo/ and /bar/i' }) await expect(str).toBeString('/foo/ and /bar/i')
}) })
test("DUP with regex", async () => { test("DUP with regex", async () => {
@ -311,7 +311,7 @@ describe("RegExp", () => {
EQ EQ
` `
// Same regex duplicated should be equal // Same regex duplicated should be equal
expect(await run(toBytecode(str))).toEqual({ type: 'boolean', value: true }) await expect(str).toBeBoolean(true)
}) })
test("empty pattern", async () => { test("empty pattern", async () => {
@ -365,7 +365,7 @@ describe("RegExp", () => {
PUSH /xyz/ PUSH /xyz/
EQ EQ
` `
expect(await run(toBytecode(str1))).toEqual({ type: 'boolean', value: false }) await expect(str1).toBeBoolean(false)
// Same pattern, different flags // Same pattern, different flags
const str2 = ` const str2 = `
@ -373,7 +373,7 @@ describe("RegExp", () => {
PUSH /test/i PUSH /test/i
EQ EQ
` `
expect(await run(toBytecode(str2))).toEqual({ type: 'boolean', value: false }) await expect(str2).toBeBoolean(false)
// Different order of flags (should be equal) // Different order of flags (should be equal)
const str3 = ` const str3 = `
@ -381,7 +381,7 @@ describe("RegExp", () => {
PUSH /test/gi PUSH /test/gi
EQ EQ
` `
expect(await run(toBytecode(str3))).toEqual({ type: 'boolean', value: true }) await expect(str3).toBeBoolean(true)
}) })
test("with native functions", async () => { test("with native functions", async () => {

249
tests/setup.ts Normal file
View File

@ -0,0 +1,249 @@
import { expect } from "bun:test"
import { toValue, fromValue, type Value, toBytecode, run, type Bytecode } from "#reef"
import { isEqual } from "../src/value"
declare module "bun:test" {
interface Matchers<T> {
/**
* Run bytecode and assert that the result equals a JavaScript value after conversion via toValue()
* @example expect(bytecode).toEqualValue(42)
* @example expect("PUSH 5\nPUSH 3\nADD").toEqualValue(8)
* @example expect([["PUSH", 42]]).toEqualValue(42)
*/
toEqualValue(expected: any): Promise<void>
/**
* Run bytecode and assert that the result is null
* @example expect(bytecode).toBeNull()
* @example expect("PUSH null").toBeNull()
*/
toBeNull(): Promise<void>
/**
* Run bytecode and assert that the result is a boolean with the expected value
* @example expect(bytecode).toBeBoolean(true)
* @example expect("PUSH true").toBeBoolean(true)
*/
toBeBoolean(expected: boolean): Promise<void>
/**
* Run bytecode and assert that the result is a number with the expected value
* @example expect(bytecode).toBeNumber(42)
* @example expect("PUSH 42").toBeNumber(42)
*/
toBeNumber(expected: number): Promise<void>
/**
* Run bytecode and assert that the result is a string with the expected value
* @example expect(bytecode).toBeString("hello")
* @example expect("PUSH \"hello\"").toBeString("hello")
*/
toBeString(expected: string): Promise<void>
/**
* Run bytecode and assert that the result is an array with the expected values
* @example expect(bytecode).toBeArray([1, 2, 3])
*/
toBeArray(expected: any[]): Promise<void>
/**
* Run bytecode and assert that the result is a dict with the expected key-value pairs
* @example expect(bytecode).toBeDict({ x: 10, y: 20 })
*/
toBeDict(expected: Record<string, any>): Promise<void>
/**
* Run bytecode and assert that the result is a function (Reef or native)
* @example expect(bytecode).toBeFunction()
*/
toBeFunction(): Promise<void>
/**
* Run bytecode and assert that the result is truthy according to ReefVM semantics
* (only null and false are falsy)
* @example expect(bytecode).toBeTruthy()
*/
toBeTruthy(): Promise<void>
/**
* Run bytecode and assert that the result is falsy according to ReefVM semantics
* (only null and false are falsy)
* @example expect(bytecode).toBeFalsy()
*/
toBeFalsy(): Promise<void>
}
}
expect.extend({
async toEqualValue(received: string | any[] | Bytecode, expected: any) {
const bytecode = typeof received === "string" || Array.isArray(received) ? toBytecode(received) : received
const result = await run(bytecode)
const expectedValue = toValue(expected)
const pass = isEqual(result, expectedValue)
return {
pass,
message: () =>
pass
? `Expected value NOT to equal ${formatValue(expectedValue)}, but it did`
: `Expected value to equal ${formatValue(expectedValue)}, but received ${formatValue(result)}`,
}
},
async toBeNull(received: string | any[] | Bytecode) {
const bytecode = typeof received === "string" || Array.isArray(received) ? toBytecode(received) : received
const result = await run(bytecode)
const pass = result.type === "null"
return {
pass,
message: () =>
pass
? `Expected value NOT to be null, but it was`
: `Expected value to be null, but received ${formatValue(result)}`,
}
},
async toBeBoolean(received: string | any[] | Bytecode, expected: boolean) {
const bytecode = typeof received === "string" || Array.isArray(received) ? toBytecode(received) : received
const result = await run(bytecode)
const pass = result.type === "boolean" && result.value === expected
return {
pass,
message: () =>
pass
? `Expected value NOT to be boolean ${expected}, but it was`
: `Expected value to be boolean ${expected}, but received ${formatValue(result)}`,
}
},
async toBeNumber(received: string | any[] | Bytecode, expected: number) {
const bytecode = typeof received === "string" || Array.isArray(received) ? toBytecode(received) : received
const result = await run(bytecode)
const pass = result.type === "number" && result.value === expected
return {
pass,
message: () =>
pass
? `Expected value NOT to be number ${expected}, but it was`
: `Expected value to be number ${expected}, but received ${formatValue(result)}`,
}
},
async toBeString(received: string | any[] | Bytecode, expected: string) {
const bytecode = typeof received === "string" || Array.isArray(received) ? toBytecode(received) : received
const result = await run(bytecode)
const pass = result.type === "string" && result.value === expected
return {
pass,
message: () =>
pass
? `Expected value NOT to be string "${expected}", but it was`
: `Expected value to be string "${expected}", but received ${formatValue(result)}`,
}
},
async toBeArray(received: string | any[] | Bytecode, expected: any[]) {
const bytecode = typeof received === "string" || Array.isArray(received) ? toBytecode(received) : received
const result = await run(bytecode)
const expectedValue = toValue(expected)
const pass = result.type === "array" && isEqual(result, expectedValue)
return {
pass,
message: () =>
pass
? `Expected value NOT to be array ${formatValue(expectedValue)}, but it was`
: `Expected value to be array ${formatValue(expectedValue)}, but received ${formatValue(result)}`,
}
},
async toBeDict(received: string | any[] | Bytecode, expected: Record<string, any>) {
const bytecode = typeof received === "string" || Array.isArray(received) ? toBytecode(received) : received
const result = await run(bytecode)
const expectedValue = toValue(expected)
const pass = result.type === "dict" && isEqual(result, expectedValue)
return {
pass,
message: () =>
pass
? `Expected value NOT to be dict ${formatValue(expectedValue)}, but it was`
: `Expected value to be dict ${formatValue(expectedValue)}, but received ${formatValue(result)}`,
}
},
async toBeFunction(received: string | any[] | Bytecode) {
const bytecode = typeof received === "string" || Array.isArray(received) ? toBytecode(received) : received
const result = await run(bytecode)
const pass = result.type === "function" || result.type === "native"
return {
pass,
message: () =>
pass
? `Expected value NOT to be a function, but it was`
: `Expected value to be a function, but received ${formatValue(result)}`,
}
},
async toBeTruthy(received: string | any[] | Bytecode) {
const bytecode = typeof received === "string" || Array.isArray(received) ? toBytecode(received) : received
const result = await run(bytecode)
// ReefVM semantics: only null and false are falsy
const pass = !(result.type === "null" || (result.type === "boolean" && !result.value))
return {
pass,
message: () =>
pass
? `Expected value NOT to be truthy, but it was: ${formatValue(result)}`
: `Expected value to be truthy, but received ${formatValue(result)}`,
}
},
async toBeFalsy(received: string | any[] | Bytecode) {
const bytecode = typeof received === "string" || Array.isArray(received) ? toBytecode(received) : received
const result = await run(bytecode)
// ReefVM semantics: only null and false are falsy
const pass = result.type === "null" || (result.type === "boolean" && !result.value)
return {
pass,
message: () =>
pass
? `Expected value NOT to be falsy, but it was: ${formatValue(result)}`
: `Expected value to be falsy, but received ${formatValue(result)}`,
}
},
})
function formatValue(value: Value): string {
switch (value.type) {
case "null":
return "null"
case "boolean":
case "number":
return String(value.value)
case "string":
return `"${value.value}"`
case "array":
return `[${value.value.map(formatValue).join(", ")}]`
case "dict": {
const entries = Array.from(value.value.entries())
.map(([k, v]) => `${k}: ${formatValue(v)}`)
.join(", ")
return `{${entries}}`
}
case "regex":
return String(value.value)
case "function":
case "native":
return "<function>"
default:
return String(value)
}
}