Initial commit

This commit is contained in:
Stanislav Nikolov 2022-08-16 16:22:20 +03:00
commit a86b36af9b
56 changed files with 8898 additions and 0 deletions

9
.editorconfig Normal file
View File

@ -0,0 +1,9 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

7
.eslintignore Normal file
View File

@ -0,0 +1,7 @@
/dist
/src-capacitor
/src-cordova
/.quasar
/node_modules
.eslintrc.js
/src-ssr

88
.eslintrc.js Normal file
View File

@ -0,0 +1,88 @@
module.exports = {
// https://eslint.org/docs/user-guide/configuring#configuration-cascading-and-hierarchy
// This option interrupts the configuration hierarchy at this file
// Remove this if you have an higher level ESLint config file (it usually happens into a monorepos)
root: true,
// https://eslint.vuejs.org/user-guide/#how-to-use-a-custom-parser
// Must use parserOptions instead of "parser" to allow vue-eslint-parser to keep working
// `parser: 'vue-eslint-parser'` is already included with any 'plugin:vue/**' config and should be omitted
parserOptions: {
parser: require.resolve('@typescript-eslint/parser'),
extraFileExtensions: ['.vue'],
},
env: {
browser: true,
es2021: true,
node: true,
'vue/setup-compiler-macros': true,
},
// Rules order is important, please avoid shuffling them
extends: [
// Base ESLint recommended rules
// 'eslint:recommended',
// https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin#usage
// ESLint typescript rules
'plugin:@typescript-eslint/recommended',
// Uncomment any of the lines below to choose desired strictness,
// but leave only one uncommented!
// See https://eslint.vuejs.org/rules/#available-rules
'plugin:vue/vue3-essential', // Priority A: Essential (Error Prevention)
// 'plugin:vue/vue3-strongly-recommended', // Priority B: Strongly Recommended (Improving Readability)
// 'plugin:vue/vue3-recommended', // Priority C: Recommended (Minimizing Arbitrary Choices and Cognitive Overhead)
// https://github.com/prettier/eslint-config-prettier#installation
// usage with Prettier, provided by 'eslint-config-prettier'.
'prettier',
],
plugins: [
// required to apply rules which need type information
'@typescript-eslint',
// https://eslint.vuejs.org/user-guide/#why-doesn-t-it-work-on-vue-files
// required to lint *.vue files
'vue',
// https://github.com/typescript-eslint/typescript-eslint/issues/389#issuecomment-509292674
// Prettier has not been included as plugin to avoid performance impact
// add it as an extension for your IDE
],
globals: {
ga: 'readonly', // Google Analytics
cordova: 'readonly',
__statics: 'readonly',
__QUASAR_SSR__: 'readonly',
__QUASAR_SSR_SERVER__: 'readonly',
__QUASAR_SSR_CLIENT__: 'readonly',
__QUASAR_SSR_PWA__: 'readonly',
process: 'readonly',
Capacitor: 'readonly',
chrome: 'readonly',
},
// add your custom rules here
rules: {
'prefer-promise-reject-errors': 'off',
quotes: ['warn', 'single', { avoidEscape: true }],
// this rule, if on, would require explicit return type on the `render` function
'@typescript-eslint/explicit-function-return-type': 'off',
// in plain CommonJS modules, you can't use `import foo = require('foo')` to pass this rule, so it has to be disabled
'@typescript-eslint/no-var-requires': 'off',
// The core 'no-unused-vars' rules (in the eslint:recommended ruleset)
// does not work with type definitions
'no-unused-vars': 'off',
// allow debugger during development only
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
},
};

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
.DS_Store
.thumbs.db
node_modules
# Quasar core related directories
.quasar
/dist
# Cordova related directories and files
/src-cordova/node_modules
/src-cordova/platforms
/src-cordova/plugins
/src-cordova/www
# Capacitor related directories and files
/src-capacitor/www
/src-capacitor/node_modules
# BEX related directories and files
/src-bex/www
/src-bex/js/core
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
.nyc_output
coverage/

3
.nycrc Normal file
View File

@ -0,0 +1,3 @@
{
"extends": "@quasar/quasar-app-extension-testing-e2e-cypress/nyc-config-preset"
}

4
.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"semi": true
}

15
.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,15 @@
{
"recommendations": [
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"editorconfig.editorconfig",
"vue.volar",
"wayou.vscode-todo-highlight"
],
"unwantedRecommendations": [
"octref.vetur",
"hookyqr.beautify",
"dbaeumer.jshint",
"ms-vscode.vscode-typescript-tslint-plugin"
]
}

18
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,18 @@
{
"editor.bracketPairColorization.enabled": true,
"editor.guides.bracketPairs": true,
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.codeActionsOnSave": ["source.fixAll.eslint"],
"eslint.validate": ["javascript", "javascriptreact", "typescript", "vue"],
"typescript.tsdk": "node_modules/typescript/lib",
"[vue]": {
"editor.defaultFormatter": "Vue.volar"
},
"json.schemas": [
{
"fileMatch": ["cypress.json"],
"url": "https://on.cypress.io/cypress.schema.json"
}
]
}

49
README.md Normal file
View File

@ -0,0 +1,49 @@
# The Biscuit Machine
This repository contains a Biscuit Machine implementation, as defined in the [enclosed PDF](public/The_biscuit_machine_task.pdf). The implementation is based on the [Quasar framework](https://quasar.dev), and thereby supports various deployment targets. A published live SSR version is available on [https://biscuit-machine.snikolov.me](http://biscuit-machine.snikolov.me).
Each component's logic is implemented as a composable function within `src/composables`. In particular, the main control system is in `src/composables/biscuitMachine.ts`.
Additionally, a visualisation of the system has been provided, which allows users to interact with the machine and monitor its operation.
Unit tests are available in the `test/vitest/__tests__` directory and can be run with `yarn test:unit:ci`.
## Install the dependencies
```bash
yarn
# or
npm install
```
### Start the app in development mode (hot-code reloading, error reporting, etc.)
```bash
quasar dev
```
### Lint the files
```bash
yarn lint
# or
npm run lint
```
### Format the files
```bash
yarn format
# or
npm run format
```
### Build the app for production
```bash
quasar build
```
### Customize the configuration
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).

44
index.html Normal file
View File

@ -0,0 +1,44 @@
<!DOCTYPE html>
<html>
<head>
<title><%= productName %></title>
<meta charset="utf-8" />
<meta name="description" content="<%= productDescription %>" />
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<meta
name="viewport"
content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>"
/>
<link
rel="icon"
type="image/png"
sizes="128x128"
href="icons/favicon-128x128.png"
/>
<link
rel="icon"
type="image/png"
sizes="96x96"
href="icons/favicon-96x96.png"
/>
<link
rel="icon"
type="image/png"
sizes="32x32"
href="icons/favicon-32x32.png"
/>
<link
rel="icon"
type="image/png"
sizes="16x16"
href="icons/favicon-16x16.png"
/>
<link rel="icon" type="image/ico" href="favicon.ico" />
</head>
<body>
<!-- quasar:entry-point -->
</body>
</html>

58
package.json Normal file
View File

@ -0,0 +1,58 @@
{
"name": "the-buscuit-machine",
"version": "0.0.1",
"description": "A biscuit machine implementation.",
"productName": "The Buscuit Machine",
"author": "Stanislav Nikolov <hello@snikolov.me>",
"private": true,
"scripts": {
"lint": "eslint --ext .js,.ts,.vue ./",
"format": "prettier --write \"**/*.{js,ts,vue,scss,html,md,json}\" --ignore-path .gitignore",
"test": "echo \"See package.json => scripts for available tests.\" && exit 0",
"test:unit:ui": "vitest --ui",
"test:unit": "vitest",
"test:unit:ci": "vitest run",
"test:unit:coverage": "jest --coverage",
"test:unit:watch": "jest --watch",
"test:unit:watchAll": "jest --watchAll",
"serve:test:coverage": "quasar serve test/jest/coverage/lcov-report/ --port 8788",
"concurrently:dev:jest": "concurrently \"quasar dev\" \"jest --watch\"",
"test:e2e": "cross-env NODE_ENV=test start-test \"quasar dev\" http-get://localhost:9000 \"cypress open\"",
"test:e2e:ci": "cross-env NODE_ENV=test start-test \"quasar dev\" http-get://localhost:9000 \"cypress run\"",
"test:component": "cross-env NODE_ENV=test cypress open-ct",
"test:component:ci": "cross-env NODE_ENV=test cypress run-ct"
},
"dependencies": {
"@quasar/extras": "^1.0.0",
"pinia": "^2.0.18",
"quasar": "^2.6.0",
"vue": "^3.0.0",
"vue-router": "^4.0.0"
},
"devDependencies": {
"@quasar/app-vite": "^1.0.0",
"@quasar/quasar-app-extension-testing": "^2.0.4",
"@quasar/quasar-app-extension-testing-unit-jest": "^3.0.0-alpha.10",
"@quasar/quasar-app-extension-testing-unit-vitest": "^0.1.2",
"@types/node": "^12.20.21",
"@typescript-eslint/eslint-plugin": "^5.10.0",
"@typescript-eslint/parser": "^5.10.0",
"@vitest/ui": "^0.15.0",
"autoprefixer": "^10.4.2",
"eslint": "^8.10.0",
"eslint-config-prettier": "^8.1.0",
"eslint-plugin-cypress": "^2.11.3",
"eslint-plugin-jest": "^25.2.2",
"eslint-plugin-vue": "^9.0.0",
"majestic": "^1.7.0",
"prettier": "^2.5.1",
"typescript": "^4.5.4",
"vitest": "^0.15.0",
"@vue/test-utils": "^2.0.0"
},
"engines": {
"node": "^18 || ^16 || ^14.19",
"npm": ">= 6.13.4",
"yarn": ">= 1.21.1"
}
}

27
postcss.config.js Normal file
View File

@ -0,0 +1,27 @@
/* eslint-disable */
// https://github.com/michael-ciniawsky/postcss-load-config
module.exports = {
plugins: [
// https://github.com/postcss/autoprefixer
require('autoprefixer')({
overrideBrowserslist: [
'last 4 Chrome versions',
'last 4 Firefox versions',
'last 4 Edge versions',
'last 4 Safari versions',
'last 4 Android versions',
'last 4 ChromeAndroid versions',
'last 4 FirefoxAndroid versions',
'last 4 iOS versions',
],
}),
// https://github.com/elchininet/postcss-rtlcss
// If you want to support RTL css, then
// 1. yarn/npm install postcss-rtlcss
// 2. optionally set quasar.config.js > framework > lang to an RTL language
// 3. uncomment the following line:
// require('postcss-rtlcss')
],
};

Binary file not shown.

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

203
quasar.config.js Normal file
View File

@ -0,0 +1,203 @@
/* eslint-env node */
/*
* This file runs in a Node context (it's NOT transpiled by Babel), so use only
* the ES6 features that are supported by your Node version. https://node.green/
*/
// Configuration for your app
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
const { configure } = require('quasar/wrappers');
module.exports = configure(function (/* ctx */) {
return {
eslint: {
// fix: true,
// include = [],
// exclude = [],
// rawOptions = {},
warnings: true,
errors: true,
},
// https://v2.quasar.dev/quasar-cli-vite/prefetch-feature
// preFetch: true,
// app boot file (/src/boot)
// --> boot files are part of "main.js"
// https://v2.quasar.dev/quasar-cli-vite/boot-files
boot: [],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css
css: ['app.scss'],
// https://github.com/quasarframework/quasar/tree/dev/extras
extras: [
// 'ionicons-v4',
// 'mdi-v5',
// 'fontawesome-v6',
// 'eva-icons',
// 'themify',
// 'line-awesome',
// 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
'roboto-font', // optional, you are not bound to it
'material-icons', // optional, you are not bound to it
],
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build
build: {
target: {
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
node: 'node16',
},
vueRouterMode: 'hash', // available values: 'hash', 'history'
// vueRouterBase,
// vueDevtools,
// vueOptionsAPI: false,
// rebuildCache: true, // rebuilds Vite/linter/etc cache on startup
// publicPath: '/',
// analyze: true,
// env: {},
// rawDefine: {}
// ignorePublicFolder: true,
// minify: false,
// polyfillModulePreload: true,
// distDir
// extendViteConf (viteConf) {},
// viteVuePluginOptions: {},
viteVuePluginOptions: { reactivityTransform: true },
// vitePlugins: [
// [ 'package-name', { ..options.. } ]
// ]
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#devServer
devServer: {
// https: true
open: true, // opens browser window automatically
},
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework
framework: {
config: {},
// iconSet: 'material-icons', // Quasar icon set
// lang: 'en-US', // Quasar language pack
// For special cases outside of where the auto-import strategy can have an impact
// (like functional components as one of the examples),
// you can manually specify Quasar components/directives to be available everywhere:
//
// components: [],
// directives: [],
// Quasar plugins
plugins: [],
},
// animations: 'all', // --- includes all animations
// https://v2.quasar.dev/options/animations
animations: [],
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#sourcefiles
// sourceFiles: {
// rootComponent: 'src/App.vue',
// router: 'src/router/index',
// store: 'src/store/index',
// registerServiceWorker: 'src-pwa/register-service-worker',
// serviceWorker: 'src-pwa/custom-service-worker',
// pwaManifestFile: 'src-pwa/manifest.json',
// electronMain: 'src-electron/electron-main',
// electronPreload: 'src-electron/electron-preload'
// },
sourceFiles: {
store: undefined,
},
// https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr
ssr: {
// ssrPwaHtmlFilename: 'offline.html', // do NOT use index.html as name!
// will mess up SSR
// extendSSRWebserverConf (esbuildConf) {},
// extendPackageJson (json) {},
pwa: false,
// manualStoreHydration: true,
// manualPostHydrationTrigger: true,
prodPort: 3000, // The default port that the production server should use
// (gets superseded if process.env.PORT is specified at runtime)
middlewares: [
'render', // keep this as last one
],
},
// https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa
pwa: {
workboxMode: 'generateSW', // or 'injectManifest'
injectPwaMetaTags: true,
swFilename: 'sw.js',
manifestFilename: 'manifest.json',
useCredentialsForManifestTag: false,
// extendGenerateSWOptions (cfg) {}
// extendInjectManifestOptions (cfg) {},
// extendManifestJson (json) {}
// extendPWACustomSWConf (esbuildConf) {}
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-cordova-apps/configuring-cordova
cordova: {
// noIosLegacyBuildFlag: true, // uncomment only if you know what you are doing
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-capacitor-apps/configuring-capacitor
capacitor: {
hideSplashscreen: true,
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/configuring-electron
electron: {
// extendElectronMainConf (esbuildConf)
// extendElectronPreloadConf (esbuildConf)
inspectPort: 5858,
bundler: 'packager', // 'packager' or 'builder'
packager: {
// https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
// OS X / Mac App Store
// appBundleId: '',
// appCategoryType: '',
// osxSign: '',
// protocol: 'myapp://path',
// Windows only
// win32metadata: { ... }
},
builder: {
// https://www.electron.build/configuration/configuration
appId: 'the-buscuit-machine',
},
},
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-browser-extensions/configuring-bex
bex: {
contentScripts: ['my-content-script'],
// extendBexScriptsConf (esbuildConf) {}
// extendBexManifestJson (json) {}
},
};
});

9
quasar.extensions.json Normal file
View File

@ -0,0 +1,9 @@
{
"@quasar/testing-unit-vitest": {
"options": [
"scripts",
"typescript",
"ui"
]
}
}

14
quasar.testing.json Normal file
View File

@ -0,0 +1,14 @@
{
"unit-jest": {
"runnerCommand": "jest --ci"
},
"e2e-cypress": {
"runnerCommand": "cross-env NODE_ENV=test start-test \"quasar dev\" http-get://localhost:9000 \"cypress run\""
},
"component-cypress": {
"runnerCommand": "cross-env NODE_ENV=test cypress run-ct"
},
"unit-vitest": {
"runnerCommand": "vitest run"
}
}

View File

@ -0,0 +1,56 @@
import { RenderError } from '@quasar/app-vite';
import { ssrMiddleware } from 'quasar/wrappers';
// This middleware should execute as last one
// since it captures everything and tries to
// render the page with Vue
export default ssrMiddleware(({ app, resolve, render, serve }) => {
// we capture any other Express route and hand it
// over to Vue and Vue Router to render our page
app.get(resolve.urlPath('*'), (req, res) => {
res.setHeader('Content-Type', 'text/html');
render(/* the ssrContext: */ { req, res })
.then((html) => {
// now let's send the rendered html to the client
res.send(html);
})
.catch((err: RenderError) => {
// oops, we had an error while rendering the page
// we were told to redirect to another URL
if (err.url) {
if (err.code) {
res.redirect(err.code, err.url);
} else {
res.redirect(err.url);
}
} else if (err.code === 404) {
// hmm, Vue Router could not find the requested route
// Should reach here only if no "catch-all" route
// is defined in /src/routes
res.status(404).send('404 | Page Not Found');
} else if (process.env.DEV) {
// well, we treat any other code as error;
// if we're in dev mode, then we can use Quasar CLI
// to display a nice error page that contains the stack
// and other useful information
// serve.error is available on dev only
serve.error({ err, req, res });
} else {
// we're in production, so we should have another method
// to display something to the client when we encounter an error
// (for security reasons, it's not ok to display the same wealth
// of information as we do in development)
// Render Error Page on production or
// create a route (/src/routes) for an error page and redirect to it
res.status(500).send('500 | Internal Server Error');
// console.error(err.stack)
}
});
});
});

134
src-ssr/server.ts Normal file
View File

@ -0,0 +1,134 @@
/**
* More info about this file:
* https://v2.quasar.dev/quasar-cli-vite/developing-ssr/ssr-webserver
*
* Runs in Node context.
*/
/**
* Make sure to yarn add / npm install (in your project root)
* anything you import here (except for express and compression).
*/
import express from 'express';
import compression from 'compression';
import {
ssrClose,
ssrCreate,
ssrListen,
ssrRenderPreloadTag,
ssrServeStaticContent,
} from 'quasar/wrappers';
/**
* Create your webserver and return its instance.
* If needed, prepare your webserver to receive
* connect-like middlewares.
*
* Should NOT be async!
*/
export const create = ssrCreate((/* { ... } */) => {
const app = express();
// attackers can use this header to detect apps running Express
// and then launch specifically-targeted attacks
app.disable('x-powered-by');
// place here any middlewares that
// absolutely need to run before anything else
if (process.env.PROD) {
app.use(compression());
}
return app;
});
/**
* You need to make the server listen to the indicated port
* and return the listening instance or whatever you need to
* close the server with.
*
* The "listenResult" param for the "close()" definition below
* is what you return here.
*
* For production, you can instead export your
* handler for serverless use or whatever else fits your needs.
*/
export const listen = ssrListen(async ({ app, port, isReady }) => {
await isReady();
return app.listen(port, () => {
if (process.env.PROD) {
console.log('Server listening at port ' + port);
}
});
});
/**
* Should close the server and free up any resources.
* Will be used on development only when the server needs
* to be rebooted.
*
* Should you need the result of the "listen()" call above,
* you can use the "listenResult" param.
*
* Can be async.
*/
export const close = ssrClose(({ listenResult }) => {
return listenResult.close();
});
const maxAge = process.env.DEV ? 0 : 1000 * 60 * 60 * 24 * 30;
/**
* Should return middleware that serves the indicated path
* with static content.
*/
export const serveStaticContent = ssrServeStaticContent((path, opts) => {
return express.static(path, {
maxAge,
...opts,
});
});
const jsRE = /\.js$/;
const cssRE = /\.css$/;
const woffRE = /\.woff$/;
const woff2RE = /\.woff2$/;
const gifRE = /\.gif$/;
const jpgRE = /\.jpe?g$/;
const pngRE = /\.png$/;
/**
* Should return a String with HTML output
* (if any) for preloading indicated file
*/
export const renderPreloadTag = ssrRenderPreloadTag((file) => {
if (jsRE.test(file) === true) {
return `<link rel="modulepreload" href="${file}" crossorigin>`;
}
if (cssRE.test(file) === true) {
return `<link rel="stylesheet" href="${file}">`;
}
if (woffRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="font" type="font/woff" crossorigin>`;
}
if (woff2RE.test(file) === true) {
return `<link rel="preload" href="${file}" as="font" type="font/woff2" crossorigin>`;
}
if (gifRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="image" type="image/gif">`;
}
if (jpgRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="image" type="image/jpeg">`;
}
if (pngRE.test(file) === true) {
return `<link rel="preload" href="${file}" as="image" type="image/png">`;
}
return '';
});

10
src-ssr/ssr-flag.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/* eslint-disable */
// THIS FEATURE-FLAG FILE IS AUTOGENERATED,
// REMOVAL OR CHANGES WILL CAUSE RELATED TYPES TO STOP WORKING
import "quasar/dist/types/feature-flag";
declare module "quasar/dist/types/feature-flag" {
interface QuasarFeatureFlags {
ssr: true;
}
}

3
src/App.vue Normal file
View File

@ -0,0 +1,3 @@
<template>
<router-view />
</template>

View File

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 356 360">
<path
d="M43.4 303.4c0 3.8-2.3 6.3-7.1 6.3h-15v-22h14.4c4.3 0 6.2 2.2 6.2 5.2 0 2.6-1.5 4.4-3.4 5 2.8.4 4.9 2.5 4.9 5.5zm-8-13H24.1v6.9H35c2.1 0 4-1.3 4-3.8 0-2.2-1.3-3.1-3.7-3.1zm5.1 12.6c0-2.3-1.8-3.7-4-3.7H24.2v7.7h11.7c3.4 0 4.6-1.8 4.6-4zm36.3 4v2.7H56v-22h20.6v2.7H58.9v6.8h14.6v2.3H58.9v7.5h17.9zm23-5.8v8.5H97v-8.5l-11-13.4h3.4l8.9 11 8.8-11h3.4l-10.8 13.4zm19.1-1.8V298c0-7.9 5.2-10.7 12.7-10.7 7.5 0 13 2.8 13 10.7v1.4c0 7.9-5.5 10.8-13 10.8s-12.7-3-12.7-10.8zm22.7 0V298c0-5.7-3.9-8-10-8-6 0-9.8 2.3-9.8 8v1.4c0 5.8 3.8 8.1 9.8 8.1 6 0 10-2.3 10-8.1zm37.2-11.6v21.9h-2.9l-15.8-17.9v17.9h-2.8v-22h3l15.6 18v-18h2.9zm37.9 10.2v1.3c0 7.8-5.2 10.4-12.4 10.4H193v-22h11.2c7.2 0 12.4 2.8 12.4 10.3zm-3 0c0-5.3-3.3-7.6-9.4-7.6h-8.4V307h8.4c6 0 9.5-2 9.5-7.7V298zm50.8-7.6h-9.7v19.3h-3v-19.3h-9.7v-2.6h22.4v2.6zm34.4-2.6v21.9h-3v-10.1h-16.8v10h-2.8v-21.8h2.8v9.2H296v-9.2h2.9zm34.9 19.2v2.7h-20.7v-22h20.6v2.7H316v6.8h14.5v2.3H316v7.5h17.8zM24 340.2v7.3h13.9v2.4h-14v9.6H21v-22h20v2.7H24zm41.5 11.4h-9.8v7.9H53v-22h13.3c5.1 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6H66c3.1 0 5.3-1.5 5.3-4.7 0-3.3-2.2-4.1-5.3-4.1H55.7v8.8zm47.9 6.2H89l-2 4.3h-3.2l10.7-22.2H98l10.7 22.2h-3.2l-2-4.3zm-1-2.3l-6.3-13-6 13h12.2zm46.3-15.3v21.9H146v-17.2L135.7 358h-2.1l-10.2-15.6v17h-2.8v-21.8h3l11 16.9 11.3-17h3zm35 19.3v2.6h-20.7v-22h20.6v2.7H166v6.8h14.5v2.3H166v7.6h17.8zm47-19.3l-8.3 22h-3l-7.1-18.6-7 18.6h-3l-8.2-22h3.3L204 356l6.8-18.5h3.4L221 356l6.6-18.5h3.3zm10 11.6v-1.4c0-7.8 5.2-10.7 12.7-10.7 7.6 0 13 2.9 13 10.7v1.4c0 7.9-5.4 10.8-13 10.8-7.5 0-12.7-3-12.7-10.8zm22.8 0v-1.4c0-5.7-4-8-10-8s-9.9 2.3-9.9 8v1.4c0 5.8 3.8 8.2 9.8 8.2 6.1 0 10-2.4 10-8.2zm28.3 2.4h-9.8v7.9h-2.8v-22h13.2c5.2 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6h10.2c3 0 5.2-1.5 5.2-4.7 0-3.3-2.1-4.1-5.2-4.1h-10.2v8.8zm40.3-1.5l-6.8 5.6v6.4h-2.9v-22h2.9v12.3l15.2-12.2h3.7l-9.9 8.1 10.3 13.8h-3.6l-8.9-12z" />
<path fill="#050A14"
d="M188.4 71.7a10.4 10.4 0 01-20.8 0 10.4 10.4 0 1120.8 0zM224.2 45c-2.2-3.9-5-7.5-8.2-10.7l-12 7c-3.7-3.2-8-5.7-12.6-7.3a49.4 49.4 0 00-9.7 13.9 59 59 0 0140.1 14l7.6-4.4a57 57 0 00-5.2-12.5zM178 125.1c4.5 0 9-.6 13.4-1.7v-14a40 40 0 0012.5-7.2 47.7 47.7 0 00-7.1-15.3 59 59 0 01-32.2 27.7v8.7c4.4 1.2 8.9 1.8 13.4 1.8zM131.8 45c-2.3 4-4 8.1-5.2 12.5l12 7a40 40 0 000 14.4c5.7 1.5 11.3 2 16.9 1.5a59 59 0 01-8-41.7l-7.5-4.3c-3.2 3.2-6 6.7-8.2 10.6z" />
<path fill="#00B4FF"
d="M224.2 98.4c2.3-3.9 4-8 5.2-12.4l-12-7a40 40 0 000-14.5c-5.7-1.5-11.3-2-16.9-1.5a59 59 0 018 41.7l7.5 4.4c3.2-3.2 6-6.8 8.2-10.7zm-92.4 0c2.2 4 5 7.5 8.2 10.7l12-7a40 40 0 0012.6 7.3c4-4.1 7.3-8.8 9.7-13.8a59 59 0 01-40-14l-7.7 4.4c1.2 4.3 3 8.5 5.2 12.4zm46.2-80c-4.5 0-9 .5-13.4 1.7V34a40 40 0 00-12.5 7.2c1.5 5.7 4 10.8 7.1 15.4a59 59 0 0132.2-27.7V20a53.3 53.3 0 00-13.4-1.8z" />
<path fill="#00B4FF"
d="M178 9.2a62.6 62.6 0 11-.1 125.2A62.6 62.6 0 01178 9.2m0-9.2a71.7 71.7 0 100 143.5A71.7 71.7 0 00178 0z" />
<path fill="#050A14"
d="M96.6 212v4.3c-9.2-.8-15.4-5.8-15.4-17.8V180h4.6v18.4c0 8.6 4 12.6 10.8 13.5zm16-31.9v18.4c0 8.9-4.3 12.8-10.9 13.5v4.4c9.2-.7 15.5-5.6 15.5-18v-18.3h-4.7zM62.2 199v-2.2c0-12.7-8.8-17.4-21-17.4-12.1 0-20.7 4.7-20.7 17.4v2.2c0 12.8 8.6 17.6 20.7 17.6 1.5 0 3-.1 4.4-.3l11.8 6.2 2-3.3-8.2-4-6.4-3.1a32 32 0 01-3.6.2c-9.8 0-16-3.9-16-13.3v-2.2c0-9.3 6.2-13.1 16-13.1 9.9 0 16.3 3.8 16.3 13.1v2.2c0 5.3-2.1 8.7-5.6 10.8l4.8 2.4c3.4-2.8 5.5-7 5.5-13.2zM168 215.6h5.1L156 179.7h-4.8l17 36zM143 205l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.8-3.7H143zm133.7 10.7h5.2l-17.3-35.9h-4.8l17 36zm-25-10.7l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.7-3.7h-14.8zm73.8-2.5c6-1.2 9-5.4 9-11.4 0-8-4.5-10.9-12.9-10.9h-21.4v35.5h4.6v-31.3h16.5c5 0 8.5 1.4 8.5 6.7 0 5.2-3.5 7.7-8.5 7.7h-11.4v4.1h10.7l9.3 12.8h5.5l-9.9-13.2zm-117.4 9.9c-9.7 0-14.7-2.5-18.6-6.3l-2.2 3.8c5.1 5 11 6.7 21 6.7 1.6 0 3.1-.1 4.6-.3l-1.9-4h-3zm18.4-7c0-6.4-4.7-8.6-13.8-9.4l-10.1-1c-6.7-.7-9.3-2.2-9.3-5.6 0-2.5 1.4-4 4.6-5l-1.8-3.8c-4.7 1.4-7.5 4.2-7.5 8.9 0 5.2 3.4 8.7 13 9.6l11.3 1.2c6.4.6 8.9 2 8.9 5.4 0 2.7-2.1 4.7-6 5.8l1.8 3.9c5.3-1.6 8.9-4.7 8.9-10zm-20.3-21.9c7.9 0 13.3 1.8 18.1 5.7l1.8-3.9a30 30 0 00-19.6-5.9c-2 0-4 .1-5.7.3l1.9 4 3.5-.2z" />
<path fill="#00B4FF"
d="M.5 251.9c29.6-.5 59.2-.8 88.8-1l88.7-.3 88.7.3 44.4.4 44.4.6-44.4.6-44.4.4-88.7.3-88.7-.3a7981 7981 0 01-88.8-1z" />
<path fill="none" d="M-565.2 324H-252v15.8h-313.2z" />
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

0
src/boot/.gitkeep Normal file
View File

View File

@ -0,0 +1,58 @@
<template>
<div>
<MainSwitch @position-changed="(p) => (mainSwitchPosition = p)" />
<BiscuitMachineVisualization :is-extruding="isExtruding" :is-stamping="isStamping" :is-heater-on-in="isHeaterOnIn"
:target-temperature="targetTemperature" :current-temperature-in="currentTemperature" :motor-on="motorOn"
:motor-pulse-out="motorPulseOut" :in-target-zone="inTargetZoneOut" :machine-state="machineState" />
</div>
</template>
<script setup lang="ts">
import { ref, watchEffect } from 'vue';
import MainSwitch, { BiscuitSwitchState } from './BiscuitSwitch.vue';
import { useTimeSource } from 'src/composables/timeSource';
import { useBiscuitOven } from 'src/composables/biscuitOven';
import { useMotor } from 'src/composables/motor';
import { useThermostat } from 'src/composables/thermostat';
import { useBiscuitMachine } from 'src/composables/biscuitMachine';
import { usePulseActivatedTool } from 'src/composables/pulseActivatedTool';
import BiscuitMachineVisualization from './BiscuitMachineVisualization.vue';
// inputs
const inTargetZoneIn = ref(false);
const motorPulseIn = ref(false);
const mainSwitchPosition = ref<BiscuitSwitchState>('off');
const {
motorOn,
extruderPulseOut,
stamperPulseOut,
targetTemperature,
machineState,
} = useBiscuitMachine(mainSwitchPosition, inTargetZoneIn, motorPulseIn);
// Time source
const { tickIsOn } = useTimeSource(1000);
// Motor
const { pulse: motorPulseOut } = useMotor(tickIsOn, motorOn);
// Oven
const isHeaterOnIn = ref(false);
const { currentTemperature } = useBiscuitOven(tickIsOn, isHeaterOnIn);
// Heating Manager
const { inTargetZoneOut, isHeaterOnOut } = useThermostat(
targetTemperature,
currentTemperature
);
// Extruder
const { isActive: isExtruding } = usePulseActivatedTool(extruderPulseOut, 700);
// Stamper
const { isActive: isStamping } = usePulseActivatedTool(stamperPulseOut, 300);
// Connect signals that can't be straightforwardly connected due to circular dependencies
watchEffect(() => {
motorPulseIn.value = motorPulseOut.value;
inTargetZoneIn.value = inTargetZoneOut.value;
isHeaterOnIn.value = isHeaterOnOut.value;
});
</script>

View File

@ -0,0 +1,281 @@
<template>
<h6>Visualization</h6>
<div class="row flex-center q-pa-md items-stretch">
<q-card class="col-5 col-sm-2 q-ma-md">
<q-card-section>
<q-item-label header>Machine State</q-item-label>
</q-card-section>
<q-separator />
<q-item>
<q-item-section>
<q-item-label>{{ props.machineState.toUpperCase() }}</q-item-label>
</q-item-section>
</q-item>
</q-card>
<q-card class="col-5 col-sm-2 q-ma-md">
<q-card-section>
<q-item-label header>Thermostat</q-item-label>
</q-card-section>
<q-separator />
<q-item>
<q-item-section>
<q-item-label>Target</q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label caption>{{
targetTemperature ?? 'not set'
}}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label>Within Range</q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label caption>{{
!targetTemperature ? 'N/A' : inTargetZone ? 'YES' : 'NO'
}}</q-item-label>
</q-item-section>
</q-item>
</q-card>
<q-card class="col-5 col-sm-2 q-ma-md">
<q-card-section>
<q-item-label header>Motor</q-item-label>
</q-card-section>
<q-separator />
<q-item>
<q-item-section>
<q-item-label>Power</q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label caption>{{ motorOn ? 'ON' : 'OFF' }}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label>Pulse Out</q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label caption>{{
motorPulseOutUi ? 'YES' : ''
}}</q-item-label>
</q-item-section>
</q-item>
</q-card>
<q-card class="col-5 col-sm-2 q-ma-md">
<q-card-section>
<q-item-label header>Biscuit Container</q-item-label>
</q-card-section>
<q-separator />
<q-item>
<q-item-section>
<q-item-label>Biscuits</q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label caption>{{ numReadyBiscuits }}</q-item-label>
</q-item-section>
</q-item>
</q-card>
</div>
<q-markup-table class="q-pa-md" separator="cell">
<tbody>
<tr>
<template v-for="(_, i) in BELT_SIZE - 1" :key="i">
<td v-if="i == EXTRUDER_POSITION">
<q-card>
<q-card-section>
<q-item-label header>Extruder</q-item-label>
</q-card-section>
<q-separator />
<q-item>
<q-item-section>
<q-item-label>{{
props.isExtruding ? 'Extruding...' : 'Idle'
}}</q-item-label>
</q-item-section>
</q-item>
</q-card>
</td>
<td v-else-if="i == STAMPER_POSITION">
<q-card>
<q-card-section>
<q-item-label header>Stamper</q-item-label>
</q-card-section>
<q-separator />
<q-item>
<q-item-section>
<q-item-label>{{
props.isStamping ? 'Stamping...' : 'Idle'
}}</q-item-label>
</q-item-section>
</q-item>
</q-card>
</td>
<td v-else-if="i == OVEN_POSITION" colspan="2">
<q-card>
<q-card-section>
<q-item-label header>Oven</q-item-label>
</q-card-section>
<q-separator />
<q-item>
<q-item-section>
<q-item-label>Heater</q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label caption>{{
isHeaterOnIn ? 'ON' : 'OFF'
}}</q-item-label>
</q-item-section>
</q-item>
<q-item>
<q-item-section>
<q-item-label>Temperature</q-item-label>
</q-item-section>
<q-item-section side>
<q-item-label caption>{{ currentTemperatureIn }}&#176;C</q-item-label>
</q-item-section>
</q-item>
</q-card>
</td>
<td v-else></td>
</template>
</tr>
<tr>
<td v-for="(item, index) in items" :key="index">
<span v-if="item == 'empty'"></span>
<q-icon v-else-if="item == 'dough'" :name="matFilterDrama" size="xl" color="deep-orange-2" />
<q-icon v-else-if="item == 'raw biscuit'" :name="outlinedCookie" size="xl" color="deep-orange-2" />
<q-icon v-else-if="item == 'almost biscuit'" :name="outlinedCookie" size="xl" color="deep-orange-4" />
<q-icon v-else-if="item == 'biscuit'" :name="outlinedCookie" size="xl" color="brown-10" />
<span v-else>
{{ item }}
</span>
</td>
</tr>
<tr>
<td v-for="(item, index) in conveyorItems" :key="index">
<template v-for="i in 3" v-bind:key="i">
<q-icon v-if="item == '>'" name="arrow_circle_right" size="lg" color="primary" />
<q-icon v-else name="do_not_disturb_on" size="lg" color="primary" />
</template>
</td>
</tr>
</tbody>
</q-markup-table>
</template>
<script setup lang="ts">
import { QMarkupTable } from 'quasar';
import { matFilterDrama } from '@quasar/extras/material-icons';
import { outlinedCookie } from '@quasar/extras/material-icons-outlined';
import { ref, toRefs, watch } from 'vue';
import { usePulseActivatedTool } from 'src/composables/pulseActivatedTool';
import {
BELT_SIZE,
STAMPER_POSITION,
EXTRUDER_POSITION,
OVEN_POSITION,
MachineState,
} from 'src/composables/biscuitMachine';
const props = defineProps<{
isExtruding: boolean;
isStamping: boolean;
isHeaterOnIn: boolean;
motorOn: boolean;
currentTemperatureIn: number;
targetTemperature: number | 'none';
motorPulseOut: boolean;
inTargetZone: boolean;
machineState: MachineState;
}>();
type BeltItem = 'empty' | 'dough' | 'raw biscuit' | 'almost biscuit' | 'biscuit';
const numReadyBiscuits = ref(0);
const items = ref(Array(BELT_SIZE).fill('empty') as BeltItem[]);
const conveyorItems = ref(
Array(BELT_SIZE)
.fill('')
.map((_, i) => (i % 2 == 0 ? '>' : '-'))
);
const { isExtruding, isStamping, motorPulseOut } = toRefs(props);
const { isActive: motorPulseOutUi } = usePulseActivatedTool(motorPulseOut, 400)
// Move belt
watch(motorPulseOut, (p) => {
if (!p) return;
const lastConveyorItem = conveyorItems.value.pop() ?? '-';
conveyorItems.value = [lastConveyorItem, ...conveyorItems.value];
const lastItem = items.value.pop();
items.value = ['empty' as BeltItem].concat(items.value);
if (lastItem == 'biscuit') {
numReadyBiscuits.value++;
}
});
// Extrude dough
watch(isExtruding, (e) => {
if (e) return;
items.value[EXTRUDER_POSITION] = 'dough';
});
// Stamp dough
watch(isStamping, (s) => {
if (s) return;
if (items.value[STAMPER_POSITION] == 'dough') {
items.value[STAMPER_POSITION] = 'raw biscuit';
}
});
// Bake cookies
watch(motorPulseOut, (p) => {
if (p) return;
setTimeout(() => {
if (props.inTargetZone) {
for (let pos of [OVEN_POSITION, OVEN_POSITION + 1]) {
switch (items.value[pos]) {
case 'raw biscuit':
items.value[pos] = 'almost biscuit'
break
case 'almost biscuit':
items.value[pos] = 'biscuit'
break
}
}
}
}, 500)
});
</script>

View File

@ -0,0 +1,26 @@
<template>
<div>
<h6>Main Switch</h6>
<q-btn-toggle v-model="state" toggle-color="primary" :options="options" />
</div>
</template>
<script setup lang="ts">
import { ref, watchEffect } from 'vue';
export type BiscuitSwitchState = 'on' | 'off' | 'paused';
const emit = defineEmits<{
(event: 'positionChanged', value: BiscuitSwitchState): void;
}>();
const options: { label: string; value: BiscuitSwitchState }[] = [
{ label: 'Off', value: 'off' },
{ label: 'Paused', value: 'paused' },
{ label: 'On', value: 'on' },
];
const state = ref<BiscuitSwitchState>('off');
watchEffect(() => emit('positionChanged', state.value));
</script>

View File

@ -0,0 +1,165 @@
import { BiscuitSwitchState } from 'src/components/BiscuitSwitch.vue';
import { nextTick, ref, Ref, watch, watchEffect } from 'vue';
export type MachineState = 'off' | 'heating' | 'on' | 'paused' | 'turning off';
export const OPTIMAL_COOKIES_TARGET_TEMPERATURE = 230;
export const BELT_SIZE = 6;
export const EXTRUDER_POSITION = 0;
export const STAMPER_POSITION = 1;
export const OVEN_POSITION = 3;
interface BiscuitMachineOutputs {
/** A signal for controlling motor power */
motorOn: Ref<boolean>;
/** A signal for the desired target oven temperature in degrees Celsius, or 'none' if not set.*/
targetTemperature: Ref<number | 'none'>;
/** A pulse that controls the extruder operation */
extruderPulseOut: Ref<boolean>;
/** A pulse that controls the stamper operation */
stamperPulseOut: Ref<boolean>;
/** The current machines state */
machineState: Ref<MachineState>;
}
/**
* Create a Biscuit Machine's core logical structure, connecting it to the I/O ports it needs.
*
* @param mainSwitchPosition A reference to the main switch position.
* @param inTargetZoneIn A reference to a signal indicating if the oven target temperature has been reached.
* @param motorPulseIn A reference to the motor pulse, generated by a running motor
* @returns The output ports of the machine.
*/
export function useBiscuitMachine(
mainSwitchPosition: Ref<BiscuitSwitchState>,
inTargetZoneIn: Ref<boolean>,
motorPulseIn: Ref<boolean>
): BiscuitMachineOutputs {
// External outputs
const motorOn = ref(false);
const targetTemperature = ref<number | 'none'>('none');
const machineState = ref<MachineState>('off');
const stamperPulseOut = ref(false);
const extruderPulseOut = ref(false);
// Internal state
const pulsesToShutdown = ref(0);
const pulsesToStamperShutdown = ref(0);
// monitor the main switch position
watchEffect(() => {
switch (mainSwitchPosition.value) {
case 'off':
if (machineState.value != 'off') {
machineState.value = 'turning off';
}
break;
case 'on':
machineState.value = inTargetZoneIn.value ? 'on' : 'heating';
break;
case 'paused':
machineState.value = 'paused';
break;
}
});
// monitor the heating process
watchEffect(() => {
if (inTargetZoneIn.value && machineState.value == 'heating') {
machineState.value = 'on';
}
});
// transfer incoming motor pulses to the extruder & stamper
watch(motorPulseIn, (newMotorPulse) => {
if (!newMotorPulse) return;
switch (machineState.value) {
case 'on':
// while on, always transfer the incoming pulse to both appendages
stamperPulseOut.value = true;
extruderPulseOut.value = true;
nextTick(() => {
stamperPulseOut.value = false;
extruderPulseOut.value = false;
});
break;
case 'turning off':
// during the turning off procedure, the extruder never runs
// the stamper only runs long enough to stamp out the remaining dough
if (pulsesToStamperShutdown.value > 0) {
stamperPulseOut.value = true;
nextTick(() => (stamperPulseOut.value = false));
}
break;
default:
// in any other case, ignore a spurious motor pulse
break;
}
});
// decrement internal shutdown counter on incoming motor pulses
watch(
motorPulseIn,
(newMotorPulse) => {
if (!newMotorPulse) return;
if (pulsesToStamperShutdown.value > 0) {
pulsesToStamperShutdown.value--;
}
if (pulsesToShutdown.value > 0) {
pulsesToShutdown.value--;
}
if (pulsesToShutdown.value == 0 && machineState.value == 'turning off') {
machineState.value = 'off';
}
},
{ flush: 'pre' }
);
// React on changing machine state to configure the stable outputs
watchEffect(() => {
switch (machineState.value) {
case 'off':
motorOn.value = false;
targetTemperature.value = 'none';
break;
case 'heating':
motorOn.value = false;
targetTemperature.value = OPTIMAL_COOKIES_TARGET_TEMPERATURE;
break;
case 'on':
motorOn.value = true;
targetTemperature.value = OPTIMAL_COOKIES_TARGET_TEMPERATURE;
break;
case 'paused':
motorOn.value = false;
targetTemperature.value = targetTemperature.value;
break;
case 'turning off':
motorOn.value = true;
targetTemperature.value = targetTemperature.value;
// turn off the stamper after it has stamped the last blob of dough
pulsesToStamperShutdown.value = STAMPER_POSITION - EXTRUDER_POSITION;
// turn off the machine after a full belt-worth of motor pulses
pulsesToShutdown.value = BELT_SIZE;
break;
}
});
return {
// stable outputs
motorOn,
targetTemperature,
// pulsing outputs
stamperPulseOut,
extruderPulseOut,
// debugging outputs
machineState,
};
}

View File

@ -0,0 +1,38 @@
import { ref, Ref, watch } from 'vue';
export const ROOM_TEMPERATURE = 20;
export const HEATER_PLUS_DEGREES_PER_TICK = 10;
export const OVEN_MINUS_DEGREES_PER_TICK = 2;
export interface BiscuitOvenOutputs {
/** The currently reached temperature, in degrees Celsius */
currentTemperature: Ref<number>;
}
/**
* A biscuit oven implementation.
* @param tick The input tick source, used for calculating the current temperature.
* @param isHeaterOn The input signal indicating if the heater should be on.
* @returns The oven output signals.
*/
export function useBiscuitOven(
tick: Ref<boolean>,
isHeaterOn: Ref<boolean>
): BiscuitOvenOutputs {
const currentTemperature = ref(ROOM_TEMPERATURE);
watch(tick, (newTick) => {
if (newTick) return;
if (isHeaterOn.value) {
currentTemperature.value += HEATER_PLUS_DEGREES_PER_TICK;
} else {
currentTemperature.value -= OVEN_MINUS_DEGREES_PER_TICK;
if (currentTemperature.value < ROOM_TEMPERATURE) {
currentTemperature.value = ROOM_TEMPERATURE;
}
}
});
return { currentTemperature };
}

32
src/composables/motor.ts Normal file
View File

@ -0,0 +1,32 @@
import { nextTick, ref, Ref, watch } from 'vue';
export const TICKS_PER_REVOLUTION = 3;
export interface MotorOutputs {
/** An output pulse, indicating the completion of a full motor revolution. It stays on for a single VueJS tick. */
pulse: Ref<boolean>;
}
/**
* A generic motor implementation.
* @param tick The input tick source, used for calculating number of revolutions.
* @param isOn The input signal indicating if the motor power is on.
* @returns The motor output signals.
*/
export function useMotor(tick: Ref<boolean>, isOn: Ref<boolean>): MotorOutputs {
const totalTicks = ref(0);
const pulse = ref(false);
watch(tick, (newTick) => {
if (!newTick || !isOn.value) return;
totalTicks.value += 1;
if (totalTicks.value % TICKS_PER_REVOLUTION == 0) {
pulse.value = true;
nextTick(() => (pulse.value = false));
}
});
return { pulse };
}

View File

@ -0,0 +1,28 @@
import { ref, Ref, watch } from 'vue';
export interface PulseActivatedToolsOutputs {
/** Indicates whether the tool is doing its work, as a result of an incoming pulse */
isActive: Ref<boolean>;
}
/**
* A generic implementation of a tool, whose work is triggered by an incoming pulse,
* and it takes a fixed amount of time to complete it.
*
* @param pulse The incoming pulse, which kicks the tool into performing its activity.
* @param workDuration The duration in milliseconds that the tool completes its assign work for.
* @retursn The tool's activity status.
*/
export function usePulseActivatedTool(
pulse: Ref<boolean>,
workDuration: number
): PulseActivatedToolsOutputs {
const isActive = ref(false);
watch(pulse, (newPulse) => {
if (!newPulse) return;
isActive.value = true;
setTimeout(() => (isActive.value = false), workDuration);
});
return { isActive };
}

View File

@ -0,0 +1,50 @@
import { ref, Ref, watchEffect } from 'vue';
export const TEMPERATURE_TOLERANCE = 10;
export interface ThermostatOutputs {
/** An output signal indicating whether the current temperature is within
* the desired tolerance level of the target. */
inTargetZoneOut: Ref<boolean>;
/** An output signal indicating whether the target device's heater needs to be on. */
isHeaterOnOut: Ref<boolean>;
}
/**
* A simplistic thermostat implementation, which tries to keep the controlled
* devices's temperature within a given range of a target set point.
*
* @param targetTemperature The given target temperature in degrees Celsius, or 'none' if not set.
* @param currentTemperature The currently measured temperature in degrees Celsius.
* @returns The thermostat outputs.
*/
export function useThermostat(
targetTemperature: Ref<number | 'none'>,
currentTemperature: Ref<number>
): ThermostatOutputs {
const inTargetZoneOut = ref(false);
const isHeaterOnOut = ref(false);
watchEffect(() => {
let heaterOn = false;
if (targetTemperature.value != 'none') {
const lowerBound = targetTemperature.value - TEMPERATURE_TOLERANCE;
const upperBound = targetTemperature.value + TEMPERATURE_TOLERANCE;
if (isHeaterOnOut.value) {
heaterOn = currentTemperature.value < upperBound;
} else {
heaterOn = currentTemperature.value <= lowerBound;
}
isHeaterOnOut.value = heaterOn;
inTargetZoneOut.value =
Math.abs(targetTemperature.value - currentTemperature.value) <=
TEMPERATURE_TOLERANCE;
} else {
isHeaterOnOut.value = false;
}
});
return { inTargetZoneOut, isHeaterOnOut };
}

View File

@ -0,0 +1,22 @@
import { Ref, ref } from 'vue';
export interface TimeSourceOutputs {
/** A signal indicating the state of the time source. The signal keeps a
* 50% duty cycle with the requested cycle duration. */
tickIsOn: Ref<boolean>;
}
/**
* A generic time source, providing a on/off pulse of a given duration.
* @param tickDuration The desired duration of the pulse, in milliseconds.
*/
export function useTimeSource(tickDuration: number): TimeSourceOutputs {
const tickIsOn = ref(false);
setInterval(() => {
tickIsOn.value = true;
setTimeout(() => (tickIsOn.value = false), tickDuration / 2);
}, tickDuration);
return { tickIsOn };
}

1
src/css/app.scss Normal file
View File

@ -0,0 +1 @@
// app global css in SCSS form

View File

@ -0,0 +1,25 @@
// Quasar SCSS (& Sass) Variables
// --------------------------------------------------
// To customize the look and feel of this app, you can override
// the Sass/SCSS variables found in Quasar's source Sass/SCSS files.
// Check documentation for full list of Quasar variables
// Your own variables (that are declared here) and Quasar's own
// ones will be available out of the box in your .vue/.scss/.sass files
// It's highly recommended to change the default colors
// to match your app's branding.
// Tip: Use the "Theme Builder" on Quasar's documentation website.
$primary: #ffa60b;
$secondary: #26a69a;
$accent: #9c27b0;
$dark: #1d1d1d;
$dark-page: #121212;
$positive: #21ba45;
$negative: #c10015;
$info: #31ccec;
$warning: #f2c037;

9
src/env.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
/* eslint-disable */
declare namespace NodeJS {
interface ProcessEnv {
NODE_ENV: string;
VUE_ROUTER_MODE: 'hash' | 'history' | 'abstract' | undefined;
VUE_ROUTER_BASE: string | undefined;
}
}

View File

@ -0,0 +1,13 @@
<template>
<q-layout view="lHh Lpr lFf">
<q-header elevated>
<q-toolbar>
<q-toolbar-title> The Biscuit Machine </q-toolbar-title>
</q-toolbar>
</q-header>
<q-page-container>
<router-view />
</q-page-container>
</q-layout>
</template>

View File

@ -0,0 +1,23 @@
<template>
<div
class="fullscreen bg-blue text-white text-center q-pa-md flex flex-center"
>
<div>
<div style="font-size: 30vh">404</div>
<div class="text-h2" style="opacity: 0.4">Oops. Nothing here...</div>
<q-btn
class="q-mt-xl"
color="white"
text-color="blue"
unelevated
to="/"
label="Go Home"
no-caps
/>
</div>
</div>
</template>
<script setup lang="ts"></script>

9
src/pages/IndexPage.vue Normal file
View File

@ -0,0 +1,9 @@
<template>
<q-page padding>
<BiscuitMachine />
</q-page>
</template>
<script setup lang="ts">
import BiscuitMachine from 'src/components/BiscuitMachine.vue';
</script>

9
src/quasar.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
/* eslint-disable */
// Forces TS to apply `@quasar/app-vite` augmentations of `quasar` package
// Removing this would break `quasar/wrappers` imports as those typings are declared
// into `@quasar/app-vite`
// As a side effect, since `@quasar/app-vite` reference `quasar` to augment it,
// this declaration also apply `quasar` own
// augmentations (eg. adds `$q` into Vue component context)
/// <reference types="@quasar/app-vite" />

38
src/router/index.ts Normal file
View File

@ -0,0 +1,38 @@
import { route } from 'quasar/wrappers';
import {
createMemoryHistory,
createRouter,
createWebHashHistory,
createWebHistory,
} from 'vue-router';
import routes from './routes';
/*
* If not building with SSR mode, you can
* directly export the Router instantiation;
*
* The function below can be async too; either use
* async/await or return a Promise which resolves
* with the Router instance.
*/
export default route(function (/* { store, ssrContext } */) {
const createHistory = process.env.SERVER
? createMemoryHistory
: process.env.VUE_ROUTER_MODE === 'history'
? createWebHistory
: createWebHashHistory;
const Router = createRouter({
scrollBehavior: () => ({ left: 0, top: 0 }),
routes,
// Leave this as is and make changes in quasar.conf.js instead!
// quasar.conf.js -> build -> vueRouterMode
// quasar.conf.js -> build -> publicPath
history: createHistory(process.env.VUE_ROUTER_BASE),
});
return Router;
});

18
src/router/routes.ts Normal file
View File

@ -0,0 +1,18 @@
import { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('layouts/MainLayout.vue'),
children: [{ path: '', component: () => import('pages/IndexPage.vue') }],
},
// Always leave this as last one,
// but you can also remove it
{
path: '/:catchAll(.*)*',
component: () => import('pages/ErrorNotFound.vue'),
},
];
export default routes;

10
src/shims-vue.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
/* eslint-disable */
/// <reference types="vite/client" />
// Mocks all files ending in `.vue` showing them as plain Vue instances
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}

View File

@ -0,0 +1,124 @@
import { BiscuitSwitchState } from 'src/components/BiscuitSwitch.vue';
import {
useBiscuitMachine,
OPTIMAL_COOKIES_TARGET_TEMPERATURE,
BELT_SIZE,
STAMPER_POSITION,
EXTRUDER_POSITION,
} from 'src/composables/biscuitMachine';
import { expect, test } from 'vitest';
import { nextTick, ref } from 'vue';
import { toggleTickAsync } from '../utils';
test('start in the expected state', async () => {
const mainSwitchPosition = ref<BiscuitSwitchState>('off');
const inTargetZoneIn = ref(false);
const motorPulseIn = ref(false);
const {
motorOn,
stamperPulseOut,
extruderPulseOut,
targetTemperature,
machineState,
} = useBiscuitMachine(mainSwitchPosition, inTargetZoneIn, motorPulseIn);
expect(machineState.value).toBe('off');
expect(stamperPulseOut.value).toBe(false);
expect(extruderPulseOut.value).toBe(false);
expect(targetTemperature.value).toBe('none');
expect(motorOn.value).toBe(false);
});
test('switches motor on only after target temperature reached', async () => {
const mainSwitchPosition = ref<BiscuitSwitchState>('on');
const inTargetZoneIn = ref(false);
const motorPulseIn = ref(false);
const { motorOn, targetTemperature, machineState } = useBiscuitMachine(
mainSwitchPosition,
inTargetZoneIn,
motorPulseIn
);
expect(machineState.value).toBe('heating');
expect(motorOn.value).toBe(false);
expect(targetTemperature.value).toBe(OPTIMAL_COOKIES_TARGET_TEMPERATURE);
inTargetZoneIn.value = true;
await nextTick();
expect(machineState.value).toBe('on');
expect(motorOn.value).toBe(true);
expect(targetTemperature.value).toBe(OPTIMAL_COOKIES_TARGET_TEMPERATURE);
});
test('pausing the machine stops the motor & keeps the temperature', async () => {
const mainSwitchPosition = ref<BiscuitSwitchState>('on');
const inTargetZoneIn = ref(true);
const motorPulseIn = ref(false);
const { motorOn, targetTemperature, machineState } = useBiscuitMachine(
mainSwitchPosition,
inTargetZoneIn,
motorPulseIn
);
expect(machineState.value).toBe('on');
expect(motorOn.value).toBe(true);
expect(targetTemperature.value).toBe(OPTIMAL_COOKIES_TARGET_TEMPERATURE);
mainSwitchPosition.value = 'paused';
await nextTick();
expect(machineState.value).toBe('paused');
expect(motorOn.value).toBe(false);
expect(targetTemperature.value).toBe(OPTIMAL_COOKIES_TARGET_TEMPERATURE);
});
test('turning off the machine waits for produce to get carted off', async () => {
const mainSwitchPosition = ref<BiscuitSwitchState>('on');
const inTargetZoneIn = ref(true);
const motorPulseIn = ref(false);
const {
motorOn,
targetTemperature,
machineState,
extruderPulseOut,
stamperPulseOut,
} = useBiscuitMachine(mainSwitchPosition, inTargetZoneIn, motorPulseIn);
expect(machineState.value).toBe('on');
expect(motorOn.value).toBe(true);
expect(targetTemperature.value).toBe(OPTIMAL_COOKIES_TARGET_TEMPERATURE);
mainSwitchPosition.value = 'off';
await nextTick();
expect(machineState.value).toBe('turning off');
expect(motorOn.value).toBe(true);
expect(targetTemperature.value).toBe(OPTIMAL_COOKIES_TARGET_TEMPERATURE);
for (let i = 0; i < BELT_SIZE - 1; i++) {
motorPulseIn.value = true;
await nextTick();
expect(machineState.value).toBe('turning off');
expect(motorOn.value).toBe(true);
expect(targetTemperature.value).toBe(OPTIMAL_COOKIES_TARGET_TEMPERATURE);
expect(extruderPulseOut.value).toBe(false);
expect(stamperPulseOut.value).toBe(
i < STAMPER_POSITION - EXTRUDER_POSITION
// i <= 3
);
motorPulseIn.value = false;
await nextTick();
}
await toggleTickAsync(motorPulseIn);
expect(machineState.value).toBe('off');
expect(motorOn.value).toBe(false);
expect(targetTemperature.value).toBe('none');
});

View File

@ -0,0 +1,62 @@
import {
HEATER_PLUS_DEGREES_PER_TICK,
ROOM_TEMPERATURE,
useBiscuitOven,
} from 'src/composables/biscuitOven';
import { test, expect } from 'vitest';
import { ref } from 'vue';
import { toggleTickAsync } from '../utils';
test('starts off at room temperature', () => {
const tick = ref(false);
const isOn = ref(false);
const { currentTemperature } = useBiscuitOven(tick, isOn);
expect(currentTemperature.value).toBe(ROOM_TEMPERATURE);
});
test('does not heat up when off', async () => {
const tick = ref(false);
const isOn = ref(false);
const { currentTemperature } = useBiscuitOven(tick, isOn);
await toggleTickAsync(tick, 10);
expect(currentTemperature.value).toBe(ROOM_TEMPERATURE);
});
test('heats up when on', async () => {
const tick = ref(false);
const isOn = ref(true);
const { currentTemperature } = useBiscuitOven(tick, isOn);
const heatingUpTime = 10;
await toggleTickAsync(tick, heatingUpTime);
expect(currentTemperature.value).toBe(
ROOM_TEMPERATURE + heatingUpTime * HEATER_PLUS_DEGREES_PER_TICK
);
});
test('cools down to room temperature when turned off', async () => {
const tick = ref(false);
const isOn = ref(true);
const { currentTemperature } = useBiscuitOven(tick, isOn);
const heatingUpTime = 10;
await toggleTickAsync(tick, heatingUpTime);
expect(currentTemperature.value).toBe(
ROOM_TEMPERATURE + heatingUpTime * HEATER_PLUS_DEGREES_PER_TICK
);
isOn.value = false;
const cooldownTime = 1000;
await toggleTickAsync(tick, cooldownTime);
expect(currentTemperature.value).toBe(ROOM_TEMPERATURE);
});

View File

@ -0,0 +1,70 @@
import { TICKS_PER_REVOLUTION, useMotor } from 'src/composables/motor';
import { describe, expect, test } from 'vitest';
import { nextTick, ref } from 'vue';
import { toggleTickAsync, usePulseCount } from '../utils';
describe('motor', () => {
test('starts in the off state', () => {
const isOn = ref(false);
const tick = ref(false);
const { pulse } = useMotor(tick, isOn);
const { pulseCount } = usePulseCount(pulse);
expect(pulseCount.value).toBe(0);
});
test('does not pulse when off', async () => {
const isOn = ref(false);
const tick = ref(false);
const { pulse } = useMotor(tick, isOn);
const { pulseCount } = usePulseCount(pulse);
for (let i = 0; i < 3 * TICKS_PER_REVOLUTION; i++) {
await toggleTickAsync(tick);
}
expect(pulseCount.value).toBe(0);
});
test('does pulse when on', async () => {
const isOn = ref(true);
const tick = ref(false);
const { pulse } = useMotor(tick, isOn);
const { pulseCount } = usePulseCount(pulse);
for (let i = 0; i < 3 * TICKS_PER_REVOLUTION; i++) {
await toggleTickAsync(tick);
const expectedValue = Math.floor((i + 1) / TICKS_PER_REVOLUTION);
expect(pulseCount.value).toBe(expectedValue);
}
});
test('stops pulsing when turned off', async () => {
const tick = ref(false);
const isOn = ref(true);
const { pulse } = useMotor(tick, isOn);
const { pulseCount } = usePulseCount(pulse);
for (let i = 0; i < 3 * TICKS_PER_REVOLUTION; i++) {
await toggleTickAsync(tick);
const expectedValue = Math.floor((i + 1) / TICKS_PER_REVOLUTION);
expect(pulseCount.value).toBe(expectedValue);
}
const pulseValueAtStop = pulseCount.value;
isOn.value = false;
await nextTick();
for (let i = 0; i < 3 * TICKS_PER_REVOLUTION; i++) {
toggleTickAsync(tick);
}
expect(pulseCount.value).toBe(pulseValueAtStop);
});
});

View File

@ -0,0 +1,30 @@
import { usePulseActivatedTool } from 'src/composables/pulseActivatedTool';
import { expect, test } from 'vitest';
import { nextTick, ref } from 'vue';
test('starts in the off state', () => {
const pulse = ref(false);
const { isActive } = usePulseActivatedTool(pulse, 10);
expect(isActive.value).toBe(false);
});
test('is resoping to the input pulse', async () => {
const pulse = ref(false);
const { isActive } = usePulseActivatedTool(pulse, 10);
expect(isActive.value).toBe(false);
pulse.value = true;
await nextTick();
expect(isActive.value).toBe(true);
pulse.value = false;
await nextTick();
expect(isActive.value).toBe(true);
await await new Promise((p) => setTimeout(p, 15));
expect(isActive.value).toBe(false);
});

View File

@ -0,0 +1,95 @@
import { ROOM_TEMPERATURE, useBiscuitOven } from 'src/composables/biscuitOven';
import {
TEMPERATURE_TOLERANCE,
useThermostat,
} from 'src/composables/thermostat';
import { expect, test } from 'vitest';
import { nextTick, ref, watchEffect } from 'vue';
import { toggleTickAsync } from '../utils';
test('starts in the off state', () => {
const targetTemperature = ref<number | 'none'>('none');
const currentTemperature = ref(ROOM_TEMPERATURE);
const { inTargetZoneOut, isHeaterOnOut } = useThermostat(
targetTemperature,
currentTemperature
);
expect(inTargetZoneOut.value).toBe(false);
expect(isHeaterOnOut.value).toBe(false);
});
test('requests heats up when needed', async () => {
const targetTemperature = ref(200);
const currentTemperature = ref(ROOM_TEMPERATURE);
const { inTargetZoneOut, isHeaterOnOut } = useThermostat(
targetTemperature,
currentTemperature
);
expect(inTargetZoneOut.value).toBe(false);
expect(isHeaterOnOut.value).toBe(true);
});
test('requests cooldown when needed', async () => {
const targetTemperature = ref(200);
const currentTemperature = ref(220);
const { inTargetZoneOut, isHeaterOnOut } = useThermostat(
targetTemperature,
currentTemperature
);
expect(inTargetZoneOut.value).toBe(false);
expect(isHeaterOnOut.value).toBe(false);
});
test('target zone report is correct', async () => {
const TARGET_TEMPERATURE = 200;
const targetTemperature = ref(TARGET_TEMPERATURE);
const currentTemperature = ref(0);
const { inTargetZoneOut } = useThermostat(
targetTemperature,
currentTemperature
);
currentTemperature.value = TARGET_TEMPERATURE + TEMPERATURE_TOLERANCE;
await nextTick();
expect(inTargetZoneOut.value).toBe(true);
currentTemperature.value = TARGET_TEMPERATURE - TEMPERATURE_TOLERANCE;
await nextTick();
expect(inTargetZoneOut.value).toBe(true);
currentTemperature.value = TARGET_TEMPERATURE - TEMPERATURE_TOLERANCE - 0.1;
await nextTick();
expect(inTargetZoneOut.value).toBe(false);
currentTemperature.value = TARGET_TEMPERATURE + TEMPERATURE_TOLERANCE + 0.1;
await nextTick();
expect(inTargetZoneOut.value).toBe(false);
});
test('keeps stable target temperature when connected to an oven', async () => {
const TARGET_TEMPERATURE = 200;
const targetTemperature = ref(TARGET_TEMPERATURE);
const isHeaterOnIn = ref(false);
const tick = ref(false);
const { currentTemperature } = useBiscuitOven(tick, isHeaterOnIn);
const { inTargetZoneOut, isHeaterOnOut } = useThermostat(
targetTemperature,
currentTemperature
);
watchEffect(() => (isHeaterOnIn.value = isHeaterOnOut.value));
await toggleTickAsync(tick, 1000);
expect(inTargetZoneOut.value).toBe(true);
});

View File

@ -0,0 +1 @@
// This file will be run before each test file

18
test/vitest/utils.ts Normal file
View File

@ -0,0 +1,18 @@
import { Ref, nextTick, ref, watch } from 'vue';
export async function toggleTickAsync(tick: Ref<boolean>, numTimes = 1) {
for (let i = 0; i < numTimes; i++) {
tick.value = true;
await nextTick();
tick.value = false;
await nextTick();
}
}
export function usePulseCount(pulse: Ref<boolean>) {
const pulseCount = ref(0);
watch(pulse, (newPulse) => {
if (newPulse) pulseCount.value++;
});
return { pulseCount };
}

6
tsconfig.json Normal file
View File

@ -0,0 +1,6 @@
{
"extends": "@quasar/app-vite/tsconfig-preset",
"compilerOptions": {
"baseUrl": "."
}
}

27
vitest.config.ts Normal file
View File

@ -0,0 +1,27 @@
import { defineConfig } from 'vitest/config';
import vue from '@vitejs/plugin-vue';
import { quasar, transformAssetUrls } from '@quasar/vite-plugin';
import tsconfigPaths from 'vite-tsconfig-paths';
// https://vitejs.dev/config/
export default defineConfig({
test: {
environment: 'happy-dom',
setupFiles: 'test/vitest/setup-file.ts',
include: [
// Matches vitest tests in any subfolder of 'src' or into 'test/vitest/__tests__'
// Matches all files with extension 'js', 'jsx', 'ts' and 'tsx'
'src/**/*.vitest.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
'test/vitest/__tests__/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}',
],
},
plugins: [
vue({
template: { transformAssetUrls },
}),
quasar({
sassVariables: 'src/quasar-variables.scss',
}),
tsconfigPaths(),
],
});

6808
yarn.lock Normal file

File diff suppressed because it is too large Load Diff