Browse Source

continued work on tests.

Tomi Cvetic 6 years ago
parent
commit
4b4d6f5397

+ 2 - 1
backend/.vscode/settings.json

@@ -1,5 +1,6 @@
 {
   "python.pythonPath": "./src/python_workers/venv/bin/python",
   "editor.tabSize": 2,
-  "sync.gist": "f2c23f606e2999daad6f9113382f4511"
+  "sync.gist": "f2c23f606e2999daad6f9113382f4511",
+  "gitlens.hovers.enabled": false
 }

+ 74 - 42
backend/__tests__/src/interfaces.spec.js

@@ -1,34 +1,65 @@
 jest.mock('fs')
-jest.mock('child_process')
 jest.mock('os')
+
+/**
+ * Asynchronous mock function that returns a promise witch
+ * resolves or rejects based on the arguments given.
+ * @param {object} unnamed - Either error or result
+ */
+function AsyncMock ({ result, error }) {
+  return new Promise((resolve, reject) => {
+    if (error) reject(error)
+    resolve(result)
+  })
+}
+
+const mockSpawn = jest.fn()
+const mockSend = jest.fn()
+
+const PythonWorker = require('../../src/pythonWorker')
+// Mock constructor for the PythonWorker
+jest.mock('../../src/pythonWorker', () => {
+  return function (args) {
+    this.spawn = mockSpawn
+    this.send = mockSend
+  }
+})
+
 const md5 = require('md5')
 const { resolvers, typeDefs, __test__ } = require('../../src/interfaces')
-const child_process = require('child_process')
-child_process.addCRPair({
-  input: 'options',
-  output: "[{'option1':'value1'},{'option2':'value2'}]"
-})
+// const child_process = require('child_process')
+// child_process.addCRPair({
+//   input: '{"type":"options"}\n',
+//   output: "[{'option1':'value1'},{'option2':'value2'}]"
+// })
 
 describe('interfaces module', () => {
   it('exports resolvers and typeDefs', () => {
     expect(resolvers).toBeDefined()
     expect(typeDefs).toBeDefined()
-  })
-  it('resolves queries and mutations', () => {
     expect(resolvers).toHaveProperty('Query'),
     expect(resolvers).toHaveProperty('Mutation')
   })
 
   it('finds workers', async () => {
+    // Check that all possible return values of fs.readdir are correctly handled.
     const { findWorkers } = __test__
-    await expect(findWorkers('/')).resolves.toEqual([])
+
+    // 1. Check use case
     await expect(findWorkers('python_workers')).resolves.toEqual([
       'test1_worker.py',
       'test2_worker.py'
     ])
+
+    // 2. Check empty directory
+    await expect(findWorkers('/')).resolves.toEqual([])
+
+    // 3. Check argument error
     await expect(findWorkers()).rejects.toThrow(
       'Directory argument must be a string.'
     )
+
+    // 4. Check non-existing path
     await expect(findWorkers('/DOESNTEXIST')).rejects.toThrow(
       `ENOENT: no such file or directory, scandir '/DOESNTEXIST'`
     )
@@ -37,62 +68,63 @@ describe('interfaces module', () => {
   it('finds options', async () => {
     const { getOptions } = __test__
     const options = [{ option1: 'value1' }]
-    const send = jest.fn()
-    const workerProcess = { send }
+    const workerProcess = new PythonWorker()
 
     // 1. Check the use case
-    send.mockReturnValueOnce(
-      new Promise((resolve, reject) => {
-        resolve({ data: options })
-      })
-    )
+    mockSend.mockReturnValueOnce(AsyncMock({ result: { data: options } }))
     await expect(getOptions(workerProcess)).resolves.toEqual(options)
+    expect(mockSend).toHaveBeenCalledWith({ type: 'options' })
 
     // 2. Check empty option case
-    expect(workerProcess.send).toHaveBeenCalledWith({ type: 'options' })
-    send.mockReturnValueOnce(
-      new Promise((resolve, reject) => {
-        resolve({ data: [] })
-      })
-    )
+    mockSend.mockReturnValueOnce(AsyncMock({ result: { data: [] } }))
     await expect(getOptions(workerProcess)).resolves.toEqual([])
+    expect(mockSend).toHaveBeenCalledWith({ type: 'options' })
 
     // 3. Check error case
-    expect(workerProcess.send).toHaveBeenCalledWith({ type: 'options' })
-    send.mockReturnValueOnce(
-      new Promise((resolve, reject) => {
-        resolve({ error: 'Timeout' })
-      })
-    )
+    mockSend.mockReturnValueOnce(AsyncMock({ result: { error: 'Timeout' } }))
     await expect(getOptions(workerProcess)).rejects.toThrow('Timeout')
 
     // 4. Throw error if workerProcess doesn't have a "send" property.
-    await expect(getOptions()).rejects.toThrow(
+    expect(getOptions()).rejects.toThrow(
       'workerProcess not configured properly.'
     )
   })
 
-  it('generates interface', async () => {
+  it('creates an interface', async () => {
     const { createInterface } = __test__
+    const testInterfaces = []
+    mockSpawn.mockReset()
+    mockSend.mockReset()
+
+    // 1. Check the use case
+    mockSpawn.mockReturnValueOnce(AsyncMock({ result: { data: 'ready!' } }))
+    mockSend.mockReturnValueOnce(AsyncMock({ result: { data: [] } }))
+    await expect(
+      createInterface('python_workers', 'test1_worker.py', [])
+    ).resolves.toMatchObject({
+      id: md5(`test1python_workers/test1_worker.py1980-03-18T03:15:00.000Z`),
+      interfaceName: 'test1',
+      options: [],
+      workerProcess: expect.objectContaining({
+        send: expect.anything(),
+        spawn: expect.anything()
+      })
+      // diini: 12
+    })
+    expect(mockSpawn).toHaveBeenCalled()
+    expect(mockSend).toHaveBeenCalledWith({ type: 'options' })
+
+    // 2. Check argument error case
     await expect(createInterface()).rejects.toThrow(
       'Directory argument must be a string.'
     )
+
+    // 3. Check missing file case
     await expect(
-      createInterface('python_workers', 'testXXX_worker.py')
+      createInterface('python_workers', 'testXXX_worker.py', [])
     ).rejects.toThrow(
       `ENOENT: no such file or directory, scandir 'python_workers/testXXX_worker.py'`
     )
-    await expect(
-      createInterface('python_workers', 'test1_worker.py')
-    ).resolves.toMatchObject({
-      id: md5(`test1python_workers/test1_worker.py1980-03-18T03:15:00.000Z`),
-      interfaceName: 'test1',
-      options: [],
-      workerProcess: {
-        error: []
-      },
-      dini: 12
-    })
   })
 
   const { Query, Mutation, Interface, Connection } = resolvers

+ 72 - 37
backend/src/interfaces.js

@@ -1,6 +1,20 @@
-/** @module interfaces */
+/**
+ * @fileOverview INTERFACE BACKEND
+ * @author <a href="tcve@u-blox.com">Tomi Cvetic</a>
+ *
+ * Communication with workers (for now only Python)
+ * The workers are implemented as REPL clients that
+ * communicate via JSON messages through STDIO.
+ *
+ * Convention:
+ *  - Interface: Communication interface to instruments or DUTs (e.g. serial)
+ *  - Worker: Python program that implements a REPL client (e.g. serial_worker.py)
+ *  - Port: Port of an interface (e.g. COM1)
+ *  - Connection: Process running the Python worker.
+ */
 
-let fs = require('fs')
+// Module imports
+const fs = require('fs')
 const os = require('os')
 const md5 = require('md5')
 const { promisify } = require('util')
@@ -9,28 +23,23 @@ const PythonWorker = require('./pythonWorker')
 const readdir = promisify(fs.readdir)
 const stat = promisify(fs.stat)
 
-/**
- * INTERFACE BACKEND
- *
- * Communication with workers (for now only Python)
- * The workers are implemented as REPL clients that
- * communicate with JSON.
- *
- * Names:
- *  * Worker: a Python file that implements a REPL client (e.g. serial)
- *  * Port:
- *  * Connection:
- */
-
-/** Directory with the Python worker scripts */
+/** @type {string} Directory with worker files */
 const WORKER_DIR = `${__dirname}/python_workers`
 
-/** Hostname is used to identify ports across machines */
+/** @type {string} Hostname is used to identify ports across machines */
 const HOST = os.hostname()
 
-/** Local state for the interface module */
+/**
+ * Local state for the interface module
+ * @typedef {object} State
+ * @property {Interface[]} interfaces - Array of found interfaces
+ * @property {Port[]} ports - Array of found ports
+ * @property {Connection[]} connections - Array of established connections
+ * @property {object} lastScan - Time of the last scan (per property)
+ */
+/** @type {State} Local state of the modules. */
 const state = {
-  interfaces: [],
+  interfaces: [], // @type {array}
   ports: [],
   connections: [],
   lastScan: {
@@ -40,7 +49,9 @@ const state = {
   }
 }
 
-/** GraphQL types for the interface module */
+async function setState (content) {}
+
+/** @type {string} GraphQL types for the interface module */
 const typeDefs = `
   type Interface {
     id: ID!
@@ -111,6 +122,7 @@ const typeDefs = `
     sendCommand(connectionId: ID!, command: ConnectionCommand!): String!
   }
 `
+
 /**
  * Find worker files in a directory based on filename convention
  * (ending in _worker.py).
@@ -127,11 +139,15 @@ async function findWorkers (directory) {
 
 /**
  * Fetch options from Python worker process.
- * @param {Object} workerProcess - Worker process to use for fetching the options
+ * @typedef {object} workerProcess
+ * @param {workerProcess} workerProcess - Worker process to use for fetching the options
+ * @return {array} Option array
  */
 async function getOptions (workerProcess) {
   // Check that workerProcess has a send method.
-  if (!workerProcess || !workerProcess.send) throw new Error('workerProcess not configured properly.')
+  if (!workerProcess || !workerProcess.send) {
+    throw new Error('workerProcess not configured properly.')
+  }
   const { data, error } = await workerProcess.send({ type: 'options' })
   if (error) throw new Error(error)
   return data
@@ -143,8 +159,10 @@ async function getOptions (workerProcess) {
  * process.
  * @param {string} directory - Python worker directory
  * @param {string} workerFile - Python worker file
+ * @param {Object} state - State with already connected interfaces
+ * @return {Object} Updated state
  */
-async function createInterface (directory, workerFile, state) {
+async function createInterface (directory, workerFile, interfaces) {
   // Assert that arguments are strings.
   if (!(typeof directory === 'string')) {
     throw new Error('Directory argument must be a string.')
@@ -158,16 +176,25 @@ async function createInterface (directory, workerFile, state) {
   const { mtime } = await stat(path)
   const workerScript = { path, mtime, updated: null }
   // 2. Check if the interface already exists
-  console.log('bugu', state)
-  const foundInterface = state.interfaces.find(
+  const ifaceIndex = interfaces.findIndex(
     iface => iface.interfaceName === interfaceName
   )
-  if (foundInterface) {
+  if (ifaceIndex >= 0) {
     // b. If it was modified, save the modification time.
-    if (foundInterface.workerScript.mtime < mtime) {
-      foundInterface.workerScript.updated = mtime
+    if (interfaces[ifaceIndex].workerScript.mtime < mtime) {
+      const iface = interfaces[ifaceIndex]
+      const newWorkerScript = { ...iface.workerScript, updated: mtime }
+      return [
+        ...interfaces.slice(0, ifaceIndex),
+        {
+          ...iface,
+          workerScript: newWorkerScript
+        },
+        ...interfaces.slice(ifaceIndex + 1)
+      ]
+    } else {
+      return interfaces
     }
-    return
   }
   // c. Spawn a new worker connection.
   const workerProcess = new PythonWorker(workerScript)
@@ -216,13 +243,21 @@ async function iface (parent, { id }, context, info) {
   if (!iface) {
     throw new Error(`Worker id=${id}, interfaceName=${interfaceName} not found`)
   }
-  return {
-    ...iface,
-    workerProcess
-  }
+  return iface
 }
 
-function workerProcess (parent, args, context, info) {
+/**
+ * @typedef {object} workerProcess
+ * @property {string} pid - OS process ID
+ *
+ * Serialize a worker process for GraphQL output
+ * @param {*} parent
+ * @param {*} args
+ * @param {*} context
+ * @param {*} info
+ * @return {workerProcess} Serialized worker process
+ */
+function serializeWorkerProcess (parent, args, context, info) {
   const {
     killed,
     exitCode,
@@ -399,10 +434,10 @@ const resolvers = {
   Interface: {
     ports,
     connections,
-    workerProcess
+    serializeWorkerProcess
   },
   Connection: {
-    workerProcess
+    serializeWorkerProcess
   }
 }
 
@@ -410,7 +445,7 @@ const __test__ = {
   findWorkers,
   createInterface,
   getOptions,
-  workerProcess,
+  serializeWorkerProcess,
   state
 }
 

+ 1 - 1
backend/src/pythonWorker.js

@@ -2,7 +2,7 @@ let { spawn } = require('child_process')
 const { Lock } = require('semaphore-async-await')
 const { serializeError } = require('./utils')
 
-function PythonWorker (workerScript, shellOptions) {
+function PythonWorker (workerScript) {
   // create a lock for incomming commands
   this.commandLock = new Lock()
   // create a lock for python communication

+ 0 - 16
frontend/__tests__/sample.test.js

@@ -1,16 +0,0 @@
-describe('sample test 101', () => {
-  it('works as expected', () => {
-    const age = 100
-    expect(1).toEqual(1)
-    expect(age).toEqual(100)
-  })
-
-  it('handles ranges just fine', () => {
-    const age = 200
-    expect(age).toBeGreaterThan(100)
-  })
-
-  fit('nothing else', () => {
-    expect(2).toEqual(2)
-  })
-})