unit tests; improved iterator

This commit is contained in:
Rowan 2025-04-18 03:59:50 -05:00
parent 81a33e4a98
commit fa822ea3ac
17 changed files with 450 additions and 290 deletions

View file

@ -1,13 +1,11 @@
{
"compilerOptions": {
"strict": true,
"moduleResolution": "node",
"module": "es2020",
"target": "es6",
"lib": ["es2022", "dom"],
"checkJs": false,
"paths": {
"/*": ["./*"]
}
},
"exclude": [
"node_modules"

34
package-lock.json generated
View file

@ -24,7 +24,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -41,7 +40,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -58,7 +56,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -75,7 +72,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -92,7 +88,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -109,7 +104,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -126,7 +120,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -143,7 +136,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -160,7 +152,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -177,7 +168,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -194,7 +184,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -211,7 +200,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -228,7 +216,6 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -245,7 +232,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -262,7 +248,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -279,7 +264,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -296,7 +280,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -313,7 +296,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -330,7 +312,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -347,7 +328,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -364,7 +344,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -381,7 +360,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -398,7 +376,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -415,7 +392,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -432,7 +408,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@ -446,7 +421,6 @@
"version": "0.25.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz",
"integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
@ -485,20 +459,22 @@
},
"node_modules/folktest": {
"version": "1.0.0",
"resolved": "git+https://git.kitsu.cafe/rowan/folktest.git#708d44f1215be33fcceba426029f44b4f963dbe5",
"resolved": "git+https://git.kitsu.cafe/rowan/folktest.git#cbf48ff3b1334eb883f202a77a5bc89d24534520",
"dev": true,
"license": "GPL-3.0-or-later"
},
"node_modules/izuna": {
"version": "1.0.0",
"resolved": "git+https://git.kitsu.cafe/rowan/izuna.git#e11a0870c27eeb5ea1a4ae3fedccca008eda15c2",
"resolved": "git+https://git.kitsu.cafe/rowan/izuna.git#4ab7c265d83856f2dc527780a3ac87b3d54676f1",
"license": "GPL-3.0-or-later"
},
"node_modules/kojima": {
"version": "1.0.0",
"resolved": "git+https://git.kitsu.cafe/rowan/kojima.git#d6615248572d2e5c16661d8aab0650ae28aeb6c2",
"resolved": "git+https://git.kitsu.cafe/rowan/kojima.git#b997bca5e31323bb81b38fafe5f823cae6e574de",
"license": "GPL-3.0-or-later",
"dependencies": {
"esbuild": "^0.25.2",
"izuna": "git+https://git.kitsu.cafe/rowan/izuna.git",
"typescript": "^5.8.2"
}
},

83
src/byte.js Normal file
View file

@ -0,0 +1,83 @@
import { chain, curry } from 'izuna'
import { any, map, seq } from './combinator.js'
import { not } from './cond.js'
import { Digits, LowerAlpha, UpperAlpha } from './const.js'
import { clone, fail, join, mapStr, next, succeed } from './fn.js'
import { ok } from 'kojima'
/** @import { ParserState } from './state.js' */
export const char = curry(
/**
* @param {string} ch
* @param {ParserState} state
*/
(ch, state) => {
return next(state) === ch ? succeed(ch, state) : fail(`could not parse ${ch} `, state)
})
export const tag = curry(
/**
* @param {string} str
* @param {ParserState} state
*/
(str, state) => (
map(
join(''),
seq(...mapStr(char, str))
)(state)
))
export const charNoCase = curry((ch, state) => {
return any(
char(ch.toLowerCase()),
char(ch.toUpperCase())
)(state)
})
export const tagNoCase = curry(
(str, state) => (
map(
join(''),
seq(...mapStr(charNoCase, str))
)(state)
))
export const anyChar = state => {
const ch = next(state)
return !!ch ? succeed(ch, state) : fail('end of input', state)
}
export const take = curry(
(limit, state) => {
let res = ok(state)
while (res.isOk() && limit > 0) {
res = chain(anyChar, res)
limit -= 1
}
return res
}
)
export const oneOf = curry(
/**
* @param {string} str
* @param {ParserState} state
*/
(str, state) => (
any(...mapStr(char, str))(state)
))
export const noneOf = curry((str, state) => seq(not(any(oneOf(str), eof)), anyChar)(state))
export const digit = oneOf(Digits)
export const lowerAlpha = oneOf(LowerAlpha)
export const upperAlpha = oneOf(UpperAlpha)
export const alpha = any(lowerAlpha, upperAlpha)
export const alphanumeric = any(alpha, digit)
export const eof = state => {
return clone(state).next().done ? succeed([], state) : fail('not end of stream', state)
}

View file

@ -1,41 +0,0 @@
import { State } from './state.js'
import { any, anyOf, map } from './combinator.js'
import { Alpha, Alphanumeric, Digits, LowerAlpha, UpperAlpha } from './const.js'
import { fail, join, mapStr, next, succeed } from './fn.js'
import { seq } from './seq.js'
import { curry } from 'izuna'
/** @import { ParserState } from './state.js' */
export const char = curry(
/**
* @param {string} ch
* @param {ParserState} state
*/
(ch, state) => (
next(state) === ch ? succeed(ch, state) : fail(`could not parse ${ch} `, state)
))
export const str = curry(
/**
* @param {string} str
* @param {ParserState} state
*/
(str, state) => (
map(
join(''),
seq(...mapStr(char, str))
)(state)
))
export const anyChar = state => {
const ch = next(state)
return !!ch ? succeed(ch, state) : fail('end of input', state)
}
export const digit = anyOf(Digits)
export const lowerAlpha = anyOf(LowerAlpha)
export const upperAlpha = anyOf(UpperAlpha)
export const alpha = any(lowerAlpha, upperAlpha)
export const alphanumeric = any(alpha, digit)

11
src/clone.js Normal file
View file

@ -0,0 +1,11 @@
export const Clone = Symbol('clone')
export const clone = target => {
if (target[Clone]) {
return target[Clone]()
} else {
return structuredClone(target)
}
}

View file

@ -1,9 +1,28 @@
import { char } from './char.js'
import { clone, diff, fail, mapStr, succeed, Tuple } from './fn.js'
import { curry } from 'izuna'
import { chain, curry } from 'izuna'
import { ok } from 'kojima'
import { clone, diff, fail, succeed } from './fn.js'
import { state as State } from './state.js'
/** @import { ParserState } from './state.js' */
/**
* @param {...Parser} parsers
*/
export const seq = (...parsers) =>
/** @param {ParserState} state */
state => {
let acc = ok(state)
for (const parser of parsers) {
if (acc.isOk()) {
acc = chain(parser, acc)
} else {
break
}
}
return acc
}
/**
* @param {...any} parsers
*/
@ -13,6 +32,7 @@ export const any = (...parsers) =>
*/
state => {
for (const parser of parsers) {
//console.log('combinator.js::35/any:', state)
const result = parser(clone(state))
if (result.isOk()) {
return result
@ -22,15 +42,6 @@ export const any = (...parsers) =>
return fail('no matching parsers', state)
}
export const anyOf = curry(
/**
* @param {string} str
* @param {ParserState} state
*/
(str, state) => (
any(...mapStr(char, str))(state)
))
export const map = curry(
/**
* @param {(...args: any[]) => any} fn
@ -42,8 +53,7 @@ export const map = curry(
try {
/** @type {Result<ParserState, ParseError>} */
const parsed = fn(diff(state[0], result[0]))
const backtrack = Tuple(state[0], result[1])
const backtrack = State(result[1], state[0])
return succeed(parsed, backtrack)
} catch (e) {
@ -52,7 +62,3 @@ export const map = curry(
})
})
export const eof = state => {
return clone(state).next().done ? succeed([], state) : fail('not end of stream', state)
}

View file

@ -1,8 +1,8 @@
import { ParseError } from './state.js'
import { clone, fail, fork, succeed } from './fn.js'
import { anyChar } from './char.js'
import { clone, fail, succeed } from './fn.js'
import { anyChar } from './byte.js'
import { ok } from 'kojima'
import { curry, pipe } from 'izuna'
import { chain, curry, pipe } from 'izuna'
/** @import { Result } from '../vendor/kojima/src/index.js' */
/** @import { ParserState } from './state.js' */
@ -21,7 +21,7 @@ export const not = curry((parser, state) => {
const result = parser(clone(state))
if (result.isOk()) {
return fail(`'not' parser failed for ${parser.name}`, state)
return fail(`'not' parser failed`, state)
} else {
return succeed([], state)
}
@ -31,7 +31,6 @@ export const until = curry((parser, state) => {
let result = ok(state)
while (result.isOk()) {
console.log(parser.name, state)
result = result.chain(pipe(clone, parser))
if (result.isOk()) {
break
@ -43,6 +42,13 @@ export const until = curry((parser, state) => {
return result
})
export const verify = curry((parser, predicate, state) => {
const result = chain(parser, clone(state))
return chain(x => (
predicate(x) ? result : fail('verification failed', state)
), result)
})
export const skip = curry((parser, state) => {
})

View file

@ -1,64 +1,26 @@
import { ParseError } from './state.js'
import { Iter } from './iter.js'
import { err, ok } from 'kojima'
import { curry } from 'izuna'
import { concat, curry } from 'izuna'
import { ParseError, state } from './state.js'
import { IndexableIterator } from './iter.js'
/** @import { ParserState } from './state.js' */
/**
* @param {...any} values
*/
export const Tuple = (...values) => Object.freeze(values)
/**
* @template T
* @param {Iterator<T> | Iterable<T>} iterable
* @param {number} [n=2]
*/
function tee(iterable, n = 2) {
const iterator = Iter.from(iterable)
/**
* @param {{ next: any, value: T }} current
*/
function* gen(current) {
while (true) {
if (!current.next) {
const { done, value } = iterator.next()
if (done) return
current.next = { value }
}
current = current.next
yield current.value
}
}
return Array(n).fill({}).map(gen)
}
/**
* @param {import('./state.js').ParserState} state
*/
export const fork = ([tokens, state]) => {
const [a, b] = tee(state)
return Tuple(
Tuple(tokens.slice(), a),
Tuple(tokens.slice(), b),
)
}
export const iter = value => new IndexableIterator(value)
/**
* @param {ParserState} state
*/
export const clone = ([h, iter]) => [h, iter.clone()]
export const clone = (state) => state.clone()
/**
* @template T
* @param {T | T[]} v
* @param {ParserState} state
*/
export const succeed = (v, [x, y]) => ok(Tuple(x.concat(v), y))
export const succeed = (v, [x, y]) => {
return ok(state(y, concat(v, x)))
}
/**
* @param {string} msg
@ -77,7 +39,7 @@ export const nth = (n, iter) => iter[n]
/**
* @param {ParserState} state
*/
export const next = state => state[1].next().value
export const next = state => state.next().value
/**
* @template T
@ -99,7 +61,9 @@ export const mapStr = curry(
* @param {(...args: any[]) => any} fn
* @param {string} str
*/
(fn, str) => Array.from(str).map(v => fn(v))
(fn, str) => Array.from(str).flatMap(x => {
return fn(x)
})
)

View file

@ -1,6 +1,6 @@
export * from './char.js'
export * from './byte.js'
export * from './combinator.js'
export * from './cond.js'
export * from './seq.js'
export * from './multi.js'
export * from './state.js'

View file

@ -1,21 +1,50 @@
import { Clone } from './clone.js'
export class IndexableIterator extends Iterator {
_value
_index
constructor(value, index = 0) {
super()
this._value = value
this._index = index
}
clone() {
return this[Clone]()
}
done() {
return this._index >= this._value.length
}
next() {
if (this.done()) {
return { value: undefined, done: true }
} else {
return { value: this._value[this._index++], done: false }
}
}
[Clone]() {
return new IndexableIterator(
this._value,
this._index
)
}
[Symbol.iterator]() {
return this.clone()
}
}
/**
* @template T
* @implements Iterator<T>
* @implements Iterable<T>
*/
export class Iter {
_iterator
_source
/**
* @param {Iterator<T>} iterator
* @param {Iterabl<T>} [source]
*/
constructor(iterator, source) {
this._iterator = iterator
this._source = source
}
class Iter {
/**
* @template T
* @param {any} value
@ -34,6 +63,10 @@ export class Iter {
return value
}
if (Array.isArray(value) || typeof value === 'string') {
return new IndexableIterator(value)
}
if (Iter._isIterable(value)) {
const iterator = value[Symbol.iterator]()
@ -41,44 +74,12 @@ export class Iter {
return iterator
}
return new Iter(iterator, value)
return new this(iterator, value)
}
throw new TypeError('object is not an iterator')
}
clone() {
if (this._source) {
return Iter.from(this._source)
}
throw new Error('Cannot clone Iterator: not created from an iterable')
}
/**
* @param {any} [value]
*/
next(value) {
const n = this._iterator.next(value)
return n
}
/**
* @param {any} [value]
*/
return(value) {
// @ts-ignore
return this._iterator.return(value)
}
/**
* @param {any} err
*/
throw(err) {
// @ts-ignore
return this._iterator.throw(err)
}
/**
* @param {number} limit
*/

48
src/multi.js Normal file
View file

@ -0,0 +1,48 @@
import { ok } from 'kojima'
import { curry, } from 'izuna'
import { clone } from './fn.js'
import { verify } from './cond.js'
import { anyChar } from './byte.js'
/** @import { ParseError, ParserState } from './state.js' */
/** @import { Result } from '../vendor/kojima/src/index.js' */
export const takeWhile = curry((predicate, state) => {
let result = ok(state)
while (result.isOk()) {
result = verify(anyChar, predicate, state)
}
return result
})
export const takeUntil = curry((parser, state) => not(takeWhile(parser, state)))
export const skip = curry((parser, state) => {
const tokens = state[0]
const result = parser(state)
if (result.isOk()) {
return result.map(other => [tokens, other[1]])
} else {
return result
}
})
export const many = curry((parser, state) => {
let result = ok(state)
while (true) {
const res = parser(clone(state))
if (res.isOk()) {
result = res
} else {
break
}
}
return result
})
export const many1 = parser => seq(parser, many(parser))

View file

@ -1,71 +0,0 @@
import { clone, diff, fail, succeed, Tuple } from './fn.js'
import { anyChar } from './char.js'
import { ok } from 'kojima'
import { curry } from 'izuna'
/** @import { ParseError, ParserState } from './state.js' */
/** @import { Result } from '../vendor/kojima/src/index.js' */
/**
* @typedef {(value: any) => Result<ParserState, ParseError>} Parser
*/
export const take = n => state => {
let result = anyChar(state)
for (let i = n; i > 0; i--) {
if (result.isErr()) {
return result.chain(e => fail(`"take(${n})" failed`, state, e))
}
result = result.chain(anyChar)
}
return result
}
export const skip = parser => state => {
const tokens = state[0]
const result = parser(state)
if (result.isOk()) {
return result.map(other => [tokens, other[1]])
} else {
return result
}
}
/**
* @param {...Parser} parsers
*/
export const seq = (...parsers) =>
/** @param {ParserState} state */
state => {
let acc = ok(state)
for (const parser of parsers) {
if (acc.isOk()) {
acc = acc.chain(parser)
} else {
break
}
}
return acc
}
export const many = curry((parser, state) => {
let result = ok(state)
while (true) {
const res = parser(clone(state))
if (res.isOk()) {
result = res
} else {
break
}
}
return result
})
export const many1 = parser => seq(parser, many(parser))

View file

@ -1,8 +1,9 @@
import { Iter } from './iter.js'
import { seq } from './seq.js'
import { until } from './cond.js'
import { eof } from './combinator.js'
import { curry, pipe } from 'izuna'
import { ok } from 'kojima'
import { chain, compose, curry, pipe } from 'izuna'
import { eof } from './byte.js'
import { Tuple } from './tuple.js'
import { iter } from './fn.js'
import { Clone } from './clone.js'
/**
* @typedef {Readonly<[any[], Iterator<any>]>} ParserState
@ -20,18 +21,40 @@ export class ParseError extends Error {
}
}
/**
* @param {any} value
* @returns {ParserState}
*/
export const State = value => Object.freeze([[], Iter.from(value)])
export class State extends Tuple {
constructor(remaining, read = []) {
super([['read', read], ['remaining', remaining]])
}
export const parse = curry((parser, input) => parser(State(input)))
export const parseAll = curry((parser, input) => pipe(
State,
seq(
parser,
until(eof)
)
static from(values) {
return new State(iter(values))
}
next() {
return this.remaining.next()
}
toString() {
return `State(${this.read}, ${[...this.clone().remaining]})`
}
[Clone]() {
return Object.freeze(new State(this.remaining.clone(), this.read))
}
[Symbol.iterator]() {
return super[Symbol.iterator]()
}
}
export const state = (remaining, read) => new State(remaining, read)
export const parse = curry((parser, input) => pipe(
iter,
state,
ok,
chain(parser),
)(input))
export const parseAll = curry((parser, input) => compose(eof, parse(parser), input))

61
src/tuple.js Normal file
View file

@ -0,0 +1,61 @@
import { entries } from 'izuna'
import { Clone } from './clone.js'
import { IndexableIterator } from './iter.js'
/**
* @param {...any} values
*/
export class Tuple {
_values
_length
get length() {
return this._length
}
constructor(values) {
if (values.length > 0) {
this._length = values.length
values.forEach(this._setEntry.bind(this))
}
this._values = values
}
_setEntry([key, value], index) {
if (typeof key !== 'number') {
Object.defineProperty(this, key, {
get() {
return this[index]
}
})
}
this[index] = value
}
static from(values) {
return Object.freeze(new this(entries(values)))
}
toString() {
return `(${this._values.map(([_, v]) => v.toString())})`
}
clone() {
return this[Clone]()
}
[Clone]() {
return Object.freeze(new this.constructor(this._values))
}
[Symbol.iterator]() {
return new IndexableIterator(this._values.map(([_, v]) => v))
}
}
export const tuple = Tuple.from

81
tests/units/bytes.js Normal file
View file

@ -0,0 +1,81 @@
import { it, assert, assertEq } from 'folktest'
import { char, charNoCase, noneOf, oneOf, parse, tag, tagNoCase, take, takeWhile } from '../../src/index.js'
import { Alphanumeric } from '../../src/const.js'
import { chain, curry, map } from 'izuna'
const assertState = expected => state => {
assertEq(expected, state[0])
}
const parseEq = curry((parser, input, expected) =>
chain(assertState(expected), parse(parser, input))
)
const parseErr = curry((parser, input) =>
assert(parse(parser, input).isErr(), `expected an error but "${input}" parsed successfully`)
)
export const Byte = [
it('char', () => {
const parser = char('a')
parseEq(parser, 'abc', ['a'])
assert(parse(parser, ' abc').isErr())
assert(parse(parser, 'bc').isErr())
assert(parse(parser, '').isErr())
}),
it('oneOf', () => {
parse(oneOf('abc'), 'b').chain(assertState('b'))
assert(parse(oneOf('a'), 'bc').isErr())
assert(parse(oneOf('a'), '').isErr())
}),
it('noneOf', () => {
parse(noneOf('abc'), 'z').chain(assertState(['z']))
assert(parse(noneOf('ab'), 'a').isErr())
assert(parse(noneOf('ab'), '').isErr())
}),
it('tag', () => {
const parser = tag('Hello')
parse(parser, 'Hello, World!').chain(assertState(['Hello']))
assert(parse(parser, 'Something').isErr())
assert(parse(parser, '').isErr())
}),
it('charNoCase', () => {
const parser = charNoCase('a')
parse(parser, 'abc').chain(assertState(['a']))
parse(parser, 'Abc').chain(assertState(['A']))
assert(parse(parser, ' abc').isErr())
assert(parse(parser, 'bc').isErr())
assert(parse(parser, '').isErr())
}),
it('tagNoCase', () => {
const parser = tagNoCase('hello')
parse(parser, 'Hello, World!').chain(assertState(['Hello']))
parse(parser, 'hello, World!').chain(assertState(['hello']))
parse(parser, 'HeLlO, World!').chain(assertState(['HeLlO']))
assert(parse(parser, 'Something').isErr())
assert(parse(parser, '').isErr())
}),
it('take', () => {
const parser = take(6)
parse(parser, '1234567').chain(assertState('123456'.split('')))
parse(parser, 'things').chain(assertState('things'.split('')))
assert(parse(parser, 'short').isErr())
assert(parse(parser, '').isErr())
}),
it('takeWhile', () => {
const parser = takeWhile(x => Alphanumeric.includes(x))
parse(parser, 'latin123').chain(assertState(['latin']))
parse(parser, '123').chain(assertState([]))
parse(parser, 'latin').chain(assertState(['latin']))
parse(parser, '').chain(assertState([]))
})
]

View file

@ -1,10 +1,3 @@
import { it, assert } from 'folktest'
import { map, not, until, char, parse, State, seq, str } from '../../src/index.js'
import { mapStr, join } from '../../src/fn.js'
export const Tests = [
it('whatever', () => {
})
]
export * from './bytes.js'
export * from './iter.js'

21
tests/units/iter.js Normal file
View file

@ -0,0 +1,21 @@
import { it, assert, assertEq } from 'folktest'
import { IndexableIterator } from '../../src/iter.js'
import { state } from '../../src/state.js'
export const Iterator = [
it('should be cloneable', () => {
const hi = new IndexableIterator('hi :3')
const hihi = hi.clone()
assertEq(hi.next().value, 'h')
assertEq(hi.next().value, 'i')
assertEq(hihi.next().value, 'h')
}),
it('should have iterator helpers', () => {
const aaaaa = new IndexableIterator('awawawawa').filter(x => x !== 'w')
assertEq([...aaaaa].join(''), 'aaaaa')
})
]