From 8cbb1d921c7e7dacac09fbf8b824e554e7208867 Mon Sep 17 00:00:00 2001
From: taichunmin <taichunmin@gmail.com>
Date: Tue, 11 Jun 2024 00:31:40 +0800
Subject: [PATCH] add test

---
 package.json                |  2 +-
 src/ChameleonUltra.ts       |  7 ++--
 src/ResponseDecoder.test.ts | 76 +++++++++++++++++++++++++++++++++++++
 src/ResponseDecoder.ts      |  2 +-
 src/helper.test.ts          | 56 +++++++++++++--------------
 src/helper.ts               | 55 +--------------------------
 src/plugin/Debug.test.ts    | 45 ++++++++++++++++++++++
 src/plugin/Debug.ts         | 71 +++++++++++++++++++++++++++++++++-
 typedoc.json                |  3 +-
 9 files changed, 228 insertions(+), 89 deletions(-)
 create mode 100644 src/ResponseDecoder.test.ts
 create mode 100644 src/plugin/Debug.test.ts

diff --git a/package.json b/package.json
index 424b3b7..ad1a9bb 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
   "module": "./dist/index.mjs",
   "name": "chameleon-ultra.js",
   "type": "commonjs",
-  "version": "0.3.8",
+  "version": "0.3.9",
   "bugs": {
     "url": "https://github.com/taichunmin/chameleon-ultra.js/issues"
   },
diff --git a/src/ChameleonUltra.ts b/src/ChameleonUltra.ts
index 97eb04a..1f94f50 100644
--- a/src/ChameleonUltra.ts
+++ b/src/ChameleonUltra.ts
@@ -1,6 +1,6 @@
 import _ from 'lodash'
 import { Buffer } from '@taichunmin/buffer'
-import { errToJson, middlewareCompose, sleep, type MiddlewareComposeFn, versionCompare } from './helper'
+import { middlewareCompose, sleep, type MiddlewareComposeFn, versionCompare } from './helper'
 import { EventAsyncGenerator } from './EventAsyncGenerator'
 import { EventEmitter } from './EventEmitter'
 import { type ReadableStream, type UnderlyingSink, type WritableStreamDefaultController, WritableStream } from 'node:stream/web'
@@ -212,7 +212,6 @@ export class ChameleonUltra {
       } catch (err) {
         err.message = `Failed to connect: ${err.message}`
         this.emitter.emit('error', err)
-        this.#debug('error', `${err.message}\n${err.stack}`)
         if (this.isConnected()) await this.disconnect(err)
         throw err
       }
@@ -227,7 +226,7 @@ export class ChameleonUltra {
     try {
       if (this.#isDisconnecting) return
       this.#isDisconnecting = true // 避免重複執行
-      this.#debug('core', '%s %O', err.message, errToJson(err))
+      this.emitter.emit('error', err)
       this.#debug('core', 'disconnecting...')
       await this.invokeHook('disconnect', { err }, async (ctx, next) => {
         try {
@@ -345,7 +344,7 @@ export class ChameleonUltra {
         return resp
       }
     } catch (err) {
-      this.#debug('error', `${err.message}\n${err.stack}`)
+      this.emitter.emit('error', err)
       throw err
     }
   }
diff --git a/src/ResponseDecoder.test.ts b/src/ResponseDecoder.test.ts
new file mode 100644
index 0000000..49dc476
--- /dev/null
+++ b/src/ResponseDecoder.test.ts
@@ -0,0 +1,76 @@
+import * as sut from './ResponseDecoder'
+import { Buffer } from '@taichunmin/buffer'
+
+describe('is not buffer', () => {
+  test.each([
+    { name: 'SlotInfo.fromCmd1019', fn: sut.SlotInfo.fromCmd1019 },
+    { name: 'SlotFreqIsEnable.fromCmd1023', fn: sut.SlotFreqIsEnable.fromCmd1023 },
+    { name: 'BatteryInfo.fromCmd1025', fn: sut.BatteryInfo.fromCmd1025 },
+    { name: 'DeviceSettings.fromCmd1034', fn: sut.DeviceSettings.fromCmd1034 },
+    { name: 'Hf14aAntiColl.fromCmd2000', fn: sut.Hf14aAntiColl.fromCmd2000 },
+    { name: 'Mf1AcquireStaticNestedRes.fromCmd2003', fn: sut.Mf1AcquireStaticNestedRes.fromCmd2003 },
+    { name: 'Mf1DarksideRes.fromCmd2004', fn: sut.Mf1DarksideRes.fromCmd2004 },
+    { name: 'Mf1NtDistanceRes.fromCmd2005', fn: sut.Mf1NtDistanceRes.fromCmd2005 },
+    { name: 'Mf1NestedRes.fromCmd2006', fn: sut.Mf1NestedRes.fromCmd2006 },
+    { name: 'Mf1CheckKeysOfSectorsRes.fromCmd2012', fn: sut.Mf1CheckKeysOfSectorsRes.fromCmd2012 },
+    { name: 'Mf1DetectionLog.fromBuffer', fn: sut.Mf1DetectionLog.fromBuffer },
+    { name: 'Mf1DetectionLog.fromCmd4006', fn: sut.Mf1DetectionLog.fromCmd4006 },
+    { name: 'Mf1EmuSettings.fromCmd4009', fn: sut.Mf1EmuSettings.fromCmd4009 },
+  ])('$name should throw error', ({ name, fn }) => {
+    expect.hasAssertions()
+    try {
+      fn('not a buffer' as any)
+    } catch (err) {
+      expect(err).toBeInstanceOf(TypeError)
+      expect(err.message).toMatch(/should be a Buffer/)
+    }
+  })
+
+  test.each([
+    { name: 'SlotInfo.fromCmd1019', fn: sut.SlotInfo.fromCmd1019 },
+    { name: 'SlotFreqIsEnable.fromCmd1023', fn: sut.SlotFreqIsEnable.fromCmd1023 },
+    { name: 'BatteryInfo.fromCmd1025', fn: sut.BatteryInfo.fromCmd1025 },
+    { name: 'DeviceSettings.fromCmd1034', fn: sut.DeviceSettings.fromCmd1034 },
+    { name: 'Mf1DarksideRes.fromCmd2004', fn: sut.Mf1DarksideRes.fromCmd2004 },
+    { name: 'Mf1NtDistanceRes.fromCmd2005', fn: sut.Mf1NtDistanceRes.fromCmd2005 },
+    { name: 'Mf1CheckKeysOfSectorsRes.fromCmd2012', fn: sut.Mf1CheckKeysOfSectorsRes.fromCmd2012 },
+    { name: 'Mf1DetectionLog.fromBuffer', fn: sut.Mf1DetectionLog.fromBuffer },
+  ])('$name should throw error', ({ name, fn }) => {
+    expect.hasAssertions()
+    try {
+      fn(new Buffer(0))
+    } catch (err) {
+      expect(err).toBeInstanceOf(TypeError)
+      expect(err.message).toMatch(/should be a Buffer/)
+    }
+  })
+})
+
+describe('Hf14aAntiColl', () => {
+  test('fromBuffer should throw invalid length error', () => {
+    expect.hasAssertions()
+    try {
+      sut.Hf14aAntiColl.fromBuffer(Buffer.from('01', 'hex'))
+    } catch (err) {
+      expect(err).toBeInstanceOf(Error)
+      expect(err.message).toMatch(/invalid length of/)
+    }
+  })
+
+  test('fromBuffer should throw invalid length error', () => {
+    expect.hasAssertions()
+    try {
+      sut.Hf14aAntiColl.fromBuffer(Buffer.from('040102030404000801', 'hex'))
+    } catch (err) {
+      expect(err).toBeInstanceOf(Error)
+      expect(err.message).toMatch(/invalid length of/)
+    }
+  })
+})
+
+describe('Mf1DarksideRes', () => {
+  test('fromBuffer should throw invalid length error', () => {
+    const actual = sut.Mf1DarksideRes.fromCmd2004(Buffer.from('01', 'hex'))
+    expect(actual).toMatchObject({ status: 1 })
+  })
+})
diff --git a/src/ResponseDecoder.ts b/src/ResponseDecoder.ts
index 0c7212b..f571885 100644
--- a/src/ResponseDecoder.ts
+++ b/src/ResponseDecoder.ts
@@ -108,7 +108,7 @@ export class Hf14aAntiColl {
     const uidLen = buf[0]
     if (buf.length < uidLen + 4) throw new Error('invalid length of uid')
     const atsLen = buf[uidLen + 4]
-    if (buf.length < uidLen + atsLen + 5) throw new Error('invalid invalid length of ats')
+    if (buf.length < uidLen + atsLen + 5) throw new Error('invalid length of ats')
     return bufUnpackToClass(buf, `!${uidLen + 1}p2ss${atsLen + 1}p`, Hf14aAntiColl)
   }
 
diff --git a/src/helper.test.ts b/src/helper.test.ts
index 1902fdc..8844057 100644
--- a/src/helper.test.ts
+++ b/src/helper.test.ts
@@ -1,4 +1,3 @@
-import _ from 'lodash'
 import * as sut from './helper'
 
 test('sleep', async () => {
@@ -9,33 +8,6 @@ test('sleep', async () => {
   expect(end - start).toBeGreaterThanOrEqual(90)
 })
 
-test('errToJson', async () => {
-  const err = _.merge(new Error('test'), { originalError: new Error('test') })
-  const actual = sut.errToJson(err)
-  expect(actual).toMatchObject({
-    name: 'Error',
-    message: 'test',
-    originalError: {
-      name: 'Error',
-      message: 'test',
-    },
-  })
-})
-
-test('jsonStringify', async () => {
-  const circular = { b: 1 }
-  const obj = {
-    circular1: circular,
-    circular2: circular,
-    map: new Map([['a', 1]]),
-    number: 1,
-    set: new Set([1, 2, 3]),
-    string: 'abc',
-  }
-  const actual = sut.jsonStringify(obj)
-  expect(actual).toBe('{"circular1":{"b":1},"circular2":"[Circular]","map":{"a":1},"number":1,"set":[1,2,3],"string":"abc"}')
-})
-
 describe('middlewareCompose', () => {
   test('should have correct order', async () => {
     const actual: number[] = []
@@ -326,6 +298,18 @@ describe('middlewareCompose', () => {
 
     expect(actual).toBe(1)
   })
+
+  test('should set default ctx', async () => {
+    const actual: number[] = []
+    await sut.middlewareCompose([
+      async (ctx, next) => {
+        actual.push(1)
+        await next()
+      },
+    ])()
+
+    expect(actual).toEqual([1])
+  })
 })
 
 describe('versionCompare', () => {
@@ -353,4 +337,20 @@ describe('versionCompare', () => {
   ])('versionCompare(\'$str1\', \'$str2\') = $expected', ({ str1, str2, expected }) => {
     expect(sut.versionCompare(str1, str2)).toBe(expected)
   })
+
+  test('should throw error when version is invalid', () => {
+    expect.hasAssertions()
+    try {
+      sut.versionCompare('test', '1.0.0')
+    } catch (err) {
+      expect(err).toBeInstanceOf(Error)
+      expect(err.message).toMatch(/invalid version/)
+    }
+    try {
+      sut.versionCompare('1.0.0', 'test')
+    } catch (err) {
+      expect(err).toBeInstanceOf(Error)
+      expect(err.message).toMatch(/invalid version/)
+    }
+  })
 })
diff --git a/src/helper.ts b/src/helper.ts
index 38f1d6d..365e5b2 100644
--- a/src/helper.ts
+++ b/src/helper.ts
@@ -9,7 +9,7 @@ enum MiddlewareStatus {
   ERROR,
 }
 
-export function middlewareCompose (middlewares: MiddlewareComposeFn[]): (ctx: Record<string, any>, next?: MiddlewareComposeFn) => Promise<unknown> {
+export function middlewareCompose (middlewares: MiddlewareComposeFn[]): (ctx?: Record<string, any>, next?: MiddlewareComposeFn) => Promise<unknown> {
   // 型態檢查
   if (!_.isArray(middlewares)) throw new TypeError('Middleware stack must be an array!')
   if (_.some(middlewares, fn => !_.isFunction(fn))) throw new TypeError('Middleware must be composed of functions!')
@@ -44,60 +44,9 @@ export async function sleep (ms: number): Promise<void> {
   return new Promise(resolve => setTimeout(resolve, ms))
 }
 
-const ERROR_KEYS = [
-  'address',
-  'args',
-  'code',
-  'data',
-  'dest',
-  'errno',
-  'info',
-  'message',
-  'name',
-  'path',
-  'port',
-  'positions',
-  'reason',
-  'response.data',
-  'response.headers',
-  'response.status',
-  'source',
-  'stack',
-  'status',
-  'statusCode',
-  'statusMessage',
-  'syscall',
-] as const
-
-export function errToJson<T extends Error & { originalError?: any, stack?: any }> (err: T): Partial<T> {
-  const tmp: any = {
-    ..._.pick(err, ERROR_KEYS),
-    ...(_.isNil(err.originalError) ? {} : { originalError: errToJson(err.originalError) }),
-  }
-  return tmp
-}
-
-export function jsonStringify (obj: object, space?: number): string {
-  try {
-    const preventCircular = new Set()
-    return JSON.stringify(obj, function (this: any, key: string, value: any) {
-      if (Buffer.isBuffer(this[key])) return { type: 'Buffer', hex: this[key].toString('hex') }
-      if (value instanceof Map) return _.fromPairs([...value.entries()])
-      if (value instanceof Set) return [...value.values()]
-      if (_.isObject(value) && !_.isEmpty(value)) {
-        if (preventCircular.has(value)) return '[Circular]'
-        preventCircular.add(value)
-      }
-      return value
-    }, space)
-  } catch (err) {
-    return `[UnexpectedJSONParseError]: ${err.message as string}`
-  }
-}
-
 export function versionCompare (str1: string, str2: string): number {
   const tmp = _.map([str1, str2], (str, idx) => {
-    const matched = _.trim(str).match(/^(?:(\d+)[.]?)?(?:(\d+)[.]?)?(?:(\d+)[.]?)?/)
+    const matched = _.trim(str).match(/^(?:(\d+)[.]?)(?:(\d+)[.]?)?(?:(\d+)[.]?)?/)
     if (_.isNil(matched)) throw new Error(`invalid version: str${idx + 1} = ${str}`)
     return _.map(matched.slice(1), v => _.isNil(v) ? 0 : _.toSafeInteger(v))
   })
diff --git a/src/plugin/Debug.test.ts b/src/plugin/Debug.test.ts
new file mode 100644
index 0000000..9c779cb
--- /dev/null
+++ b/src/plugin/Debug.test.ts
@@ -0,0 +1,45 @@
+import _ from 'lodash'
+import * as sut from './Debug'
+
+test('errToJson', async () => {
+  const err = _.merge(new Error('test'), { originalError: new Error('test') })
+  const actual = sut.errToJson(err)
+  expect(actual).toMatchObject({
+    name: 'Error',
+    message: 'test',
+    originalError: {
+      name: 'Error',
+      message: 'test',
+    },
+  })
+})
+
+test('jsonStringify', async () => {
+  const circular = { b: 1 }
+  const obj = {
+    _censored: ['censored', 'censored1'],
+    bigint: 1n,
+    censored: 'test',
+    circular1: circular,
+    circular2: circular,
+    date: new Date('2000-01-01T00:00:00Z'),
+    error: new Error('test'),
+    map: new Map([['a', 1]]),
+    number: 1,
+    set: new Set([1, 2, 3]),
+    string: 'abc',
+  }
+  const actual = JSON.parse(sut.jsonStringify(obj))
+  expect(actual).toMatchObject({
+    bigint: '1',
+    censored: '[Censored]',
+    circular1: { b: 1 },
+    circular2: '[Circular]',
+    date: '2000-01-01T00:00:00.000Z',
+    error: { message: 'test', name: 'Error' },
+    map: { a: 1 },
+    number: 1,
+    set: [1, 2, 3],
+    string: 'abc',
+  })
+})
diff --git a/src/plugin/Debug.ts b/src/plugin/Debug.ts
index 90e256c..922c7ff 100644
--- a/src/plugin/Debug.ts
+++ b/src/plugin/Debug.ts
@@ -1,5 +1,6 @@
-import createDebugger, { type Debugger } from 'debug'
+import _ from 'lodash'
 import { type ChameleonPlugin, type PluginInstallContext } from '../ChameleonUltra'
+import createDebugger, { type Debugger } from 'debug'
 
 export default class ChameleonDebug implements ChameleonPlugin {
   filter?: ChameleonDebugFilter
@@ -8,6 +9,11 @@ export default class ChameleonDebug implements ChameleonPlugin {
 
   async install (context: PluginInstallContext): Promise<this> {
     const { ultra } = context
+    ultra.emitter.on('error', (err: Error) => {
+      const errJson = errToJson(err)
+      ultra.emitter.emit('debug', 'error', jsonStringify(errJson))
+      console.error(errJson)
+    })
     ultra.emitter.on('debug', (namespace: string, formatter: any, ...args: [] | any[]) => {
       if (!(this.filter?.(namespace, formatter, ...args) ?? true)) return
       const debug = this.debugers.get(namespace) ?? createDebugger(`ultra:${namespace}`)
@@ -21,3 +27,66 @@ export default class ChameleonDebug implements ChameleonPlugin {
 ;((globalThis as any ?? {}).ChameleonUltraJS ?? {}).ChameleonDebug = ChameleonDebug // eslint-disable-line @typescript-eslint/prefer-optional-chain
 
 type ChameleonDebugFilter = (namespace: string, formatter: any, ...args: [] | any[]) => boolean
+
+const ERROR_KEYS = [
+  'address',
+  'args',
+  'code',
+  'data',
+  'dest',
+  'errno',
+  'info',
+  'message',
+  'name',
+  'path',
+  'port',
+  'positions',
+  'reason',
+  'response.data',
+  'response.headers',
+  'response.status',
+  'source',
+  'stack',
+  'status',
+  'statusCode',
+  'statusMessage',
+  'syscall',
+] as const
+
+export function errToJson<T extends Error & { originalError?: any, stack?: any }> (err: T): Partial<T> {
+  const tmp: any = {
+    ..._.pick(err, ERROR_KEYS),
+    ...(_.isNil(err.originalError) ? {} : { originalError: errToJson(err.originalError) }),
+  }
+  return tmp
+}
+
+export function stringifyClone (obj: any): any {
+  const preventCircular = new Set()
+  return _.cloneDeepWith(obj, val1 => {
+    if (_.isObject(val1) && !_.isEmpty(val1)) {
+      if (preventCircular.has(val1)) return '[Circular]'
+      preventCircular.add(val1)
+    }
+    if (typeof val1 === 'bigint') return val1.toString()
+    if (val1 instanceof Error) return errToJson(val1)
+    if (val1 instanceof Map) return _.fromPairs([...val1.entries()])
+    if (val1 instanceof Set) return [...val1.values()]
+    if (val1 instanceof Date) return val1.toISOString()
+  })
+}
+
+export function stringifyReplacer (this: any, key: any, val: any): any {
+  if (key.length > 1 && key[0] === '_') return undefined
+  const censored = this?._censored ?? []
+  for (const key1 of censored) {
+    if (!_.hasIn(this, key1)) continue
+    _.set(this, key1, '[Censored]')
+  }
+  delete this?._censored
+  return this[key]
+}
+
+export function jsonStringify (obj: object, space?: number): string {
+  return JSON.stringify(stringifyClone(obj), stringifyReplacer, space)
+}
diff --git a/typedoc.json b/typedoc.json
index b5a06e4..0929ba3 100644
--- a/typedoc.json
+++ b/typedoc.json
@@ -8,8 +8,9 @@
   "plugin": ["typedoc-plugin-mdn-links", "typedoc-plugin-missing-exports", "typedoc-plugin-rename-defaults"],
   "navigationLinks": {
     "Demos": "https://github.com/taichunmin/chameleon-ultra.js/blob/master/pages/demos.md",
+    "GitHub": "https://github.com/taichunmin/chameleon-ultra.js",
     "NPM": "https://www.npmjs.com/package/chameleon-ultra.js",
-    "GitHub": "https://github.com/taichunmin/chameleon-ultra.js"
+    "jsDelivr": "https://www.jsdelivr.com/package/npm/chameleon-ultra.js"
   },
   "entryPoints": [
     "src/index.ts",