@vscada/cli 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/bin/vscada.js +58 -0
  3. package/dist/commands/preview.js +53 -0
  4. package/dist/scene-path.js +22 -0
  5. package/dist/scene-plugin.js +35 -0
  6. package/dist/template-registry.js +42 -0
  7. package/package.json +34 -0
  8. package/preview-app/index.html +55 -0
  9. package/preview-app/main.ts +149 -0
  10. package/preview-app/tsconfig.json +11 -0
  11. package/preview-app/vite-env.d.ts +10 -0
  12. package/templates/boiler-turbine-generator/README.md +9 -0
  13. package/templates/boiler-turbine-generator/scene.test.ts +26 -0
  14. package/templates/boiler-turbine-generator/scene.ts +91 -0
  15. package/templates/conveyor-batching/README.md +9 -0
  16. package/templates/conveyor-batching/scene.test.ts +30 -0
  17. package/templates/conveyor-batching/scene.ts +68 -0
  18. package/templates/hello-tank/README.md +18 -0
  19. package/templates/hello-tank/scene.test.ts +24 -0
  20. package/templates/hello-tank/scene.ts +50 -0
  21. package/templates/substation-single-line/README.md +9 -0
  22. package/templates/substation-single-line/scene.test.ts +31 -0
  23. package/templates/substation-single-line/scene.ts +63 -0
  24. package/templates/template-test-utils.ts +49 -0
  25. package/templates/tsconfig.json +11 -0
  26. package/templates/vitest.config.ts +11 -0
  27. package/templates/waste-to-energy/README.md +13 -0
  28. package/templates/waste-to-energy/scene.test.ts +15 -0
  29. package/templates/waste-to-energy/scene.ts +250 -0
  30. package/templates/waste-to-energy/wte-checklist.test.ts +105 -0
  31. package/templates/waste-to-energy/wte-topology.test.ts +38 -0
@@ -0,0 +1 @@
1
+ {"root":["../src/scene-path.ts","../src/scene-plugin.ts","../src/template-registry.ts","../src/bin/vscada.ts","../src/commands/preview.ts"],"version":"5.9.3"}
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { printReady, runPreview } from '../commands/preview.js';
4
+ import { TEMPLATES } from '../template-registry.js';
5
+ const USAGE = [
6
+ 'Usage: vscada preview <scene-file> [--seed <n>] [--port <n>] [--theme <name>]',
7
+ ' or: vscada preview --template <name> [--seed <n>] [--port <n>] [--theme <name>]',
8
+ `Templates: ${TEMPLATES.map((t) => t.name).join(', ')}`,
9
+ ].join('\n');
10
+ async function main() {
11
+ const [command, ...rest] = process.argv.slice(2);
12
+ if (command !== 'preview') {
13
+ console.error(command ? `Unknown command: "${command}"` : 'Missing command.');
14
+ console.error(USAGE);
15
+ process.exitCode = 1;
16
+ return;
17
+ }
18
+ const { values, positionals } = parseArgs({
19
+ args: rest,
20
+ options: {
21
+ seed: { type: 'string' },
22
+ port: { type: 'string' },
23
+ theme: { type: 'string' },
24
+ template: { type: 'string' },
25
+ },
26
+ allowPositionals: true,
27
+ });
28
+ const [sceneFile] = positionals;
29
+ // Story 5.4 Task 1 (AC1) — `--template <name>` is an alternative to
30
+ // <scene-file>, not an addition to it: exactly one source of truth for
31
+ // which scene gets served.
32
+ if (sceneFile && values.template !== undefined) {
33
+ console.error('Pass either <scene-file> or --template, not both.');
34
+ console.error(USAGE);
35
+ process.exitCode = 1;
36
+ return;
37
+ }
38
+ if (!sceneFile && values.template === undefined) {
39
+ console.error('Missing <scene-file> argument or --template.');
40
+ console.error(USAGE);
41
+ process.exitCode = 1;
42
+ return;
43
+ }
44
+ const options = {
45
+ sceneFile,
46
+ template: values.template,
47
+ cwd: process.cwd(),
48
+ seed: values.seed !== undefined ? Number(values.seed) : undefined,
49
+ port: values.port !== undefined ? Number(values.port) : undefined,
50
+ theme: values.theme,
51
+ };
52
+ const result = await runPreview(options);
53
+ printReady(options, result);
54
+ }
55
+ main().catch((error) => {
56
+ console.error(error instanceof Error ? error.message : String(error));
57
+ process.exitCode = 1;
58
+ });
@@ -0,0 +1,53 @@
1
+ import { dirname, resolve } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { createServer } from 'vite';
4
+ import { resolveSceneFile } from '../scene-path.js';
5
+ import { sceneLoaderPlugin } from '../scene-plugin.js';
6
+ import { resolveTemplate } from '../template-registry.js';
7
+ const here = dirname(fileURLToPath(import.meta.url));
8
+ // From dist/commands/preview.js: up to dist/commands's parent (dist/), up to
9
+ // the package root (packages/cli/), into the shipped-as-source preview app
10
+ // (Task 2 Dev Notes — the preview page is a Vite ROOT, transformed on
11
+ // request, never pre-bundled by this package's own build; see
12
+ // package.json's "files").
13
+ const PREVIEW_APP_ROOT = resolve(here, '../../preview-app');
14
+ const DEFAULT_PORT = 5180;
15
+ /**
16
+ * Task 1-3 (AC1, AC2) — resolves + validates the scene file, starts a
17
+ * programmatic Vite dev server rooted at the internal preview app, and
18
+ * returns the ready-to-open URL. The scene file itself is never imported
19
+ * here — only the browser (via `sceneLoaderPlugin`'s virtual module) ever
20
+ * executes it, so `.ts`/`.js`/builder-vs-JSON duck-typing all happen
21
+ * client-side (Dev Notes).
22
+ */
23
+ export async function runPreview(options) {
24
+ const startedAt = Date.now();
25
+ const { absPath } = options.template !== undefined ? resolveTemplate(options.template) : resolveSceneFile(options.sceneFile, options.cwd);
26
+ const server = await createServer({
27
+ root: PREVIEW_APP_ROOT,
28
+ configFile: false,
29
+ logLevel: 'warn',
30
+ server: { port: options.port ?? DEFAULT_PORT },
31
+ plugins: [sceneLoaderPlugin(absPath)],
32
+ });
33
+ await server.listen();
34
+ const query = new URLSearchParams();
35
+ if (options.seed !== undefined)
36
+ query.set('seed', String(options.seed));
37
+ if (options.theme !== undefined)
38
+ query.set('theme', options.theme);
39
+ const resolvedUrls = server.resolvedUrls;
40
+ const base = resolvedUrls?.local[0] ?? `http://localhost:${options.port ?? DEFAULT_PORT}/`;
41
+ const url = query.size > 0 ? `${base}?${query.toString()}` : base;
42
+ return { server, url, elapsedMs: Date.now() - startedAt };
43
+ }
44
+ /** Task 3 (AC1) — the CLI's one-line usage hint, printed once the server is actually listening. */
45
+ export function printReady(options, result) {
46
+ // eslint-disable-next-line no-console
47
+ console.log(`\n vscada preview ready in ${result.elapsedMs}ms\n`);
48
+ // eslint-disable-next-line no-console
49
+ console.log(` ➜ Local: ${result.url}`);
50
+ const target = options.template !== undefined ? `templates/${options.template}/scene.ts` : options.sceneFile;
51
+ // eslint-disable-next-line no-console
52
+ console.log(`\n Edit ${target} and save — the preview hot-reloads automatically.\n`);
53
+ }
@@ -0,0 +1,22 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { extname, resolve } from 'node:path';
3
+ const SUPPORTED_EXTENSIONS = ['.ts', '.js', '.json'];
4
+ /**
5
+ * Task 1 (AC1) — validates the user's `<scene-file>` argument before a Vite
6
+ * server is even started, so a typo'd path fails fast with a clear message
7
+ * instead of a blank browser tab. Duck-typing (builder vs. raw scene JSON,
8
+ * `.build()` detection) happens client-side in the preview app, which is
9
+ * the thing that actually executes the module (Dev Notes: "internal
10
+ * preview page ... loads the user's scene module").
11
+ */
12
+ export function resolveSceneFile(inputPath, cwd) {
13
+ const absPath = resolve(cwd, inputPath);
14
+ if (!existsSync(absPath) || !statSync(absPath).isFile()) {
15
+ throw new Error(`Scene file not found: ${absPath}`);
16
+ }
17
+ const extension = extname(absPath);
18
+ if (!SUPPORTED_EXTENSIONS.includes(extension)) {
19
+ throw new Error(`Unsupported scene file type "${extension}" (expected .ts, .js, or .json): ${absPath}`);
20
+ }
21
+ return { absPath, extension: extension };
22
+ }
@@ -0,0 +1,35 @@
1
+ const VIRTUAL_MODULE_ID = 'virtual:vscada-scene';
2
+ const RESOLVED_VIRTUAL_MODULE_ID = `\0${VIRTUAL_MODULE_ID}`;
3
+ function toFsUrl(absPath) {
4
+ // Vite's `/@fs/` escape hatch serves any absolute path outside `root`
5
+ // (the user's scene file lives in their own project, never inside the
6
+ // preview app's own source tree) — always forward-slashed, including on
7
+ // Windows where `path.resolve` yields backslashes.
8
+ return `/@fs/${absPath.replace(/\\/g, '/')}`;
9
+ }
10
+ /**
11
+ * Task 2 (AC1, AC2) — bridges the user's own scene file into the preview
12
+ * app's Vite module graph as `import ... from 'virtual:vscada-scene'`. The
13
+ * re-export is a thin pass-through, but because Vite resolves the `from`
14
+ * specifier to the real file, that file becomes a real graph dependency of
15
+ * this virtual module: editing it on disk invalidates this module too, and
16
+ * the invalidation bubbles up to `main.ts`'s own
17
+ * `import.meta.hot.accept('virtual:vscada-scene', ...)` — real HMR, no
18
+ * process restart, no hand-rolled file watcher.
19
+ */
20
+ export function sceneLoaderPlugin(sceneAbsPath) {
21
+ return {
22
+ name: 'vscada:scene-loader',
23
+ resolveId(id) {
24
+ if (id === VIRTUAL_MODULE_ID)
25
+ return RESOLVED_VIRTUAL_MODULE_ID;
26
+ return undefined;
27
+ },
28
+ load(id) {
29
+ if (id === RESOLVED_VIRTUAL_MODULE_ID) {
30
+ return `export { default } from ${JSON.stringify(toFsUrl(sceneAbsPath))};`;
31
+ }
32
+ return undefined;
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,42 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ const here = dirname(fileURLToPath(import.meta.url));
5
+ // From dist/template-registry.js: one level up is the package root
6
+ // (packages/cli/), same relationship `commands/preview.ts` uses for
7
+ // PREVIEW_APP_ROOT — the shipped templates are source (.ts), never
8
+ // pre-bundled by this package's own `tsc -b` (they're outside `src/`, same
9
+ // reasoning as `test-fixtures/builder-scene.ts`).
10
+ const DEFAULT_TEMPLATES_ROOT = resolve(here, '../templates');
11
+ /**
12
+ * Story 5.4 Task 1 (AC1) — the five templates decided in
13
+ * spec-amendments.md §C2. Order here is authoring/discovery order, not
14
+ * significance; `waste-to-energy` is the flagship (AC2) but ships second
15
+ * because `hello-tank` is deliberately the very first thing a new dev
16
+ * copies (Dev Notes: "the 10-minute quickstart").
17
+ */
18
+ export const TEMPLATES = [
19
+ { name: 'hello-tank', title: 'Hello Tank — 10-minute quickstart' },
20
+ { name: 'waste-to-energy', title: 'Waste-to-Energy line (§16 reference scene)' },
21
+ { name: 'boiler-turbine-generator', title: 'Boiler → Turbine → Generator loop (power gen)' },
22
+ { name: 'substation-single-line', title: 'Substation single-line diagram (power gen)' },
23
+ { name: 'conveyor-batching', title: 'Conveyor / batching line (manufacturing)' },
24
+ ];
25
+ const TEMPLATE_NAMES = new Set(TEMPLATES.map((t) => t.name));
26
+ /**
27
+ * Resolves a `--template <name>` argument to its `scene.ts` on disk — the
28
+ * `--template` counterpart to `scene-path.ts#resolveSceneFile`. Unknown
29
+ * names fail fast with the full available list (discoverability, AC1)
30
+ * rather than a bare ENOENT.
31
+ */
32
+ export function resolveTemplate(name, templatesRoot = DEFAULT_TEMPLATES_ROOT) {
33
+ if (!TEMPLATE_NAMES.has(name)) {
34
+ const available = TEMPLATES.map((t) => t.name).join(', ');
35
+ throw new Error(`Unknown template "${name}". Available templates: ${available}`);
36
+ }
37
+ const absPath = resolve(templatesRoot, name, 'scene.ts');
38
+ if (!existsSync(absPath)) {
39
+ throw new Error(`Template "${name}" is registered but its scene file is missing: ${absPath}`);
40
+ }
41
+ return { absPath, extension: '.ts' };
42
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@vscada/cli",
3
+ "version": "0.0.1",
4
+ "description": "VScada preview CLI — `vscada preview <scene-file>`, hot-reloading scene authoring (Story 5.3)",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "license": "UNLICENSED",
8
+ "bin": {
9
+ "vscada": "./dist/bin/vscada.js"
10
+ },
11
+ "files": [
12
+ "dist",
13
+ "preview-app",
14
+ "templates"
15
+ ],
16
+ "dependencies": {
17
+ "vite": "~8.1.5",
18
+ "@vscada/builder": "0.0.1",
19
+ "@vscada/core": "0.0.1",
20
+ "@vscada/primitives": "0.0.1",
21
+ "@vscada/sim": "0.0.1",
22
+ "@vscada/themes": "0.0.1"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22.10.0",
26
+ "typescript": "~5.9.3",
27
+ "vitest": "~4.1.10"
28
+ },
29
+ "scripts": {
30
+ "build": "tsc -b && chmod +x dist/bin/vscada.js",
31
+ "typecheck": "tsc -b && tsc --noEmit -p preview-app && tsc --noEmit -p templates",
32
+ "test": "vitest run"
33
+ }
34
+ }
@@ -0,0 +1,55 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>vscada preview</title>
7
+ <style>
8
+ html,
9
+ body {
10
+ margin: 0;
11
+ height: 100%;
12
+ background: #1e1f20;
13
+ color: #e8eaec;
14
+ font-family: system-ui, sans-serif;
15
+ }
16
+ #app {
17
+ position: fixed;
18
+ inset: 0;
19
+ }
20
+ scada-view {
21
+ display: block;
22
+ width: 100%;
23
+ height: 100%;
24
+ }
25
+ #vscada-error-overlay {
26
+ position: fixed;
27
+ inset: 0;
28
+ z-index: 9999;
29
+ display: none;
30
+ background: rgba(20, 8, 8, 0.94);
31
+ color: #ffb4b4;
32
+ font-family: ui-monospace, 'SF Mono', Menlo, monospace;
33
+ font-size: 14px;
34
+ line-height: 1.6;
35
+ padding: 32px;
36
+ overflow: auto;
37
+ box-sizing: border-box;
38
+ }
39
+ #vscada-error-overlay h1 {
40
+ color: #ff6b6b;
41
+ font-size: 16px;
42
+ margin: 0 0 16px;
43
+ }
44
+ #vscada-error-overlay ul {
45
+ margin: 0;
46
+ padding-left: 20px;
47
+ }
48
+ </style>
49
+ </head>
50
+ <body>
51
+ <div id="app"></div>
52
+ <div id="vscada-error-overlay" role="alert"></div>
53
+ <script type="module" src="/main.ts"></script>
54
+ </body>
55
+ </html>
@@ -0,0 +1,149 @@
1
+ import {
2
+ ScadaView,
3
+ defaultTimeSource,
4
+ defineScadaView,
5
+ registerConnectionRenderer,
6
+ registerPrimitives,
7
+ registerThemes,
8
+ type TimeSource,
9
+ } from '@vscada/core';
10
+ import { validateScene, type SceneValidationIssue } from '@vscada/core/scene-authoring';
11
+ import { classicHmi } from '@vscada/themes';
12
+ import { annotation } from '@vscada/primitives/annotation';
13
+ import { conveyance, pipeConnectionRenderer } from '@vscada/primitives/conveyance';
14
+ import { controls } from '@vscada/primitives/controls';
15
+ import { electrical } from '@vscada/primitives/electrical';
16
+ import { filtration } from '@vscada/primitives/filtration';
17
+ import { heat } from '@vscada/primitives/heat';
18
+ import { instruments } from '@vscada/primitives/instruments';
19
+ import { readout } from '@vscada/primitives/readout';
20
+ import { rotating } from '@vscada/primitives/rotating';
21
+ import { valves } from '@vscada/primitives/valves';
22
+ import { vapor } from '@vscada/primitives/vapor';
23
+ import { vessels } from '@vscada/primitives/vessels';
24
+ import { createSimulator, type Simulator } from '@vscada/sim';
25
+ // The bridge to the user's own scene file — the CLI's Vite plugin resolves
26
+ // this specifier to `export { default } from '/@fs/<absolute path>'`
27
+ // (`../src/scene-plugin.ts`), so this import IS the "load the user's scene
28
+ // module" step (Dev Notes, Task 2). Editing the real file on disk drives
29
+ // this import through Vite's HMR graph into the `accept` call below.
30
+ import sceneEntry from 'virtual:vscada-scene';
31
+
32
+ declare global {
33
+ interface Window {
34
+ /**
35
+ * Story 5.4 Task 5 — same seam `apps/demo`'s visual-regression harness
36
+ * uses (Story 1.7 Task 3): Playwright's `page.addInitScript` injects
37
+ * this before this module runs, so a template baseline can tick the
38
+ * animation clock on demand instead of racing real time.
39
+ */
40
+ __VSCADA_TEST_TIME_SOURCE__?: TimeSource;
41
+ /**
42
+ * Only defined in test mode (i.e. when `__VSCADA_TEST_TIME_SOURCE__` is
43
+ * present) — advances `@vscada/sim` by `dtMs` and pushes the result,
44
+ * replacing `sim.start()`'s real-time `setInterval` loop so a baseline
45
+ * capture's pushed values are as deterministic as its animation frame.
46
+ */
47
+ __vscadaSimStep?: (dtMs: number) => void;
48
+ }
49
+ }
50
+
51
+ // Task 2 (AC1) — one full-kit registration, matching apps/demo/src/main.ts's
52
+ // precedent: the preview app is a generic authoring surface that must
53
+ // render ANY scene the user hands it, so (unlike a real product embed) it
54
+ // cannot know ahead of time which primitive groups a given scene needs.
55
+ defineScadaView();
56
+ registerThemes(classicHmi);
57
+ registerPrimitives(vessels, readout, conveyance, rotating, annotation, valves, instruments, heat, filtration, vapor, controls, electrical);
58
+ registerConnectionRenderer(pipeConnectionRenderer);
59
+
60
+ const params = new URLSearchParams(location.search);
61
+ const seedParam = params.get('seed');
62
+ const seed = seedParam !== null && seedParam !== '' ? Number(seedParam) : undefined;
63
+ const requestedTheme = params.get('theme') ?? undefined;
64
+
65
+ const testTimeSource = window.__VSCADA_TEST_TIME_SOURCE__;
66
+ const view = new ScadaView(testTimeSource ?? defaultTimeSource);
67
+ if (testTimeSource) view.stalenessTimeout = 3000; // apps/demo's own precedent — a watchable/testable timeline, not the 10s production default
68
+
69
+ document.querySelector<HTMLDivElement>('#app')!.appendChild(view);
70
+
71
+ const overlay = document.querySelector<HTMLDivElement>('#vscada-error-overlay')!;
72
+
73
+ function showError(issues: SceneValidationIssue[]): void {
74
+ overlay.replaceChildren();
75
+ const heading = document.createElement('h1');
76
+ heading.textContent = `Scene validation failed (${issues.length} issue${issues.length === 1 ? '' : 's'})`;
77
+ overlay.appendChild(heading);
78
+ const list = document.createElement('ul');
79
+ for (const issue of issues) {
80
+ const item = document.createElement('li');
81
+ item.textContent = issue.path ? `${issue.path}: ${issue.message}` : issue.message;
82
+ list.appendChild(item);
83
+ }
84
+ overlay.appendChild(list);
85
+ overlay.style.display = 'block';
86
+ }
87
+
88
+ function hideError(): void {
89
+ overlay.style.display = 'none';
90
+ }
91
+
92
+ function buildSceneRaw(candidate: unknown): unknown {
93
+ // Dev Notes' duck-typed scene-module contract: default export = scene
94
+ // JSON | builder chain — "call `.build()` if it quacks like a builder".
95
+ if (candidate && typeof candidate === 'object' && typeof (candidate as { build?: unknown }).build === 'function') {
96
+ return (candidate as { build: () => unknown }).build();
97
+ }
98
+ return candidate;
99
+ }
100
+
101
+ let sim: Simulator | undefined;
102
+
103
+ /**
104
+ * Task 2 (AC1, AC2) — the single entry point for both the first render and
105
+ * every hot-reload. Validation failures (bad shape, not JS/import errors —
106
+ * those are Vite's own overlay's job) render the full Zod diagnostic list
107
+ * IN the page and leave the previously-good scene/simulator running,
108
+ * exactly as Dev Notes specifies: "fixed on next save without restart".
109
+ */
110
+ function applyScene(candidate: unknown): void {
111
+ let raw: unknown;
112
+ try {
113
+ raw = buildSceneRaw(candidate);
114
+ } catch (error) {
115
+ showError([{ path: '', message: error instanceof Error ? error.message : String(error) }]);
116
+ return;
117
+ }
118
+
119
+ if (requestedTheme && raw && typeof raw === 'object') {
120
+ raw = { ...(raw as Record<string, unknown>), theme: requestedTheme };
121
+ }
122
+
123
+ const result = validateScene(raw);
124
+ if (!result.success) {
125
+ showError(result.issues);
126
+ return;
127
+ }
128
+
129
+ hideError();
130
+ sim?.stop();
131
+ view.scene = result.data;
132
+ sim = createSimulator(result.data, seed !== undefined ? { seed } : {});
133
+ if (testTimeSource) {
134
+ // Deterministic path (Task 5): the caller drives ticks via `__vscadaSimStep`
135
+ // instead of the simulator's own real-time interval.
136
+ window.__vscadaSimStep = (dtMs: number) => view.setValues(sim!.step(dtMs));
137
+ } else {
138
+ sim.start((values) => view.setValues(values));
139
+ }
140
+ }
141
+
142
+ applyScene(sceneEntry);
143
+
144
+ if (import.meta.hot) {
145
+ import.meta.hot.accept('virtual:vscada-scene', (mod) => {
146
+ if (!mod) return; // module removed from the graph — keep the last good render up
147
+ applyScene((mod as { default?: unknown }).default);
148
+ });
149
+ }
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "composite": false,
5
+ "declaration": false,
6
+ "declarationMap": false,
7
+ "emitDeclarationOnly": false,
8
+ "noEmit": true
9
+ },
10
+ "include": ["."]
11
+ }
@@ -0,0 +1,10 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ // `../src/scene-plugin.ts` resolves this specifier to
4
+ // `export { default } from '/@fs/<the user's absolute scene path>'` — the
5
+ // default export's real shape (scene JSON | builder chain) is unknown
6
+ // until runtime duck-typing in `main.ts`'s `buildSceneRaw`.
7
+ declare module 'virtual:vscada-scene' {
8
+ const sceneEntry: unknown;
9
+ export default sceneEntry;
10
+ }
@@ -0,0 +1,9 @@
1
+ # boiler-turbine-generator
2
+
3
+ A condensed power-generation loop: boiler → steam control valve → turbine → condenser → feedwater pump → back to the boiler (the same recycle-loop shape as `waste-to-energy`, exercising topology cycle detection again in a much smaller scene), plus an electrical tie showing the turbine's generator feeding a breaker and busbar.
4
+
5
+ Run it:
6
+
7
+ ```sh
8
+ vscada preview --template boiler-turbine-generator
9
+ ```
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { assertSchemaValid, assertSimCoversEveryBoundTag } from '../template-test-utils';
3
+ import sceneBuilder from './scene';
4
+
5
+ describe('boiler-turbine-generator template (Story 5.4 Task 4)', () => {
6
+ const scene = sceneBuilder.build();
7
+
8
+ it('builds a schema-valid scene', () => {
9
+ assertSchemaValid(scene);
10
+ });
11
+
12
+ it('has a steam loop and an electrical tie', () => {
13
+ const typesById = new Map(scene.elements.map((el) => [el.id, el.type]));
14
+ expect(typesById.get('BLR-01')).toBe('boiler');
15
+ expect(typesById.get('TRB-01')).toBe('turbine');
16
+ expect(typesById.get('GEN-01')).toBe('generator');
17
+ expect(typesById.get('BRK-01')).toBe('breaker');
18
+ expect(typesById.get('BUS-01')).toBe('busbar');
19
+ // The recycle loop closes back onto the boiler's own feedwater inlet.
20
+ expect(scene.connections.find((c) => c.id === 'C-FW')).toMatchObject({ from: 'PMP-01.discharge', to: 'BLR-01.feedwater-inlet' });
21
+ });
22
+
23
+ it('gives the simulator generator coverage for every bound tag', () => {
24
+ assertSimCoversEveryBoundTag(scene);
25
+ });
26
+ });
@@ -0,0 +1,91 @@
1
+ import { boiler, breaker, busbar, condenser, createScene, generator, pumpCentrifugal, turbine, valveControl } from '@vscada/builder';
2
+
3
+ /**
4
+ * boiler-turbine-generator — a condensed power-generation loop (Story 5.4
5
+ * Task 4, spec-amendments §C2 item 3): the same steam/water recycle loop as
6
+ * `waste-to-energy` (boiler → valve → turbine → condenser → feedwater pump
7
+ * → back to the drum), plus an electrical tie (generator → breaker →
8
+ * busbar) showing the turbine's output leaving the plant. Deliberately
9
+ * sized much smaller than `waste-to-energy` — no gas train, no gauges, no
10
+ * status bar — this template's whole point is the steam loop + electrical
11
+ * tie, nothing else.
12
+ */
13
+ const boilerDrum = boiler('BLR-01')
14
+ .at(40, 40)
15
+ .label('Boiler drum')
16
+ .bind(
17
+ { tag: 'BTG_BLR01.level', type: 'level', min: 0, max: 100, unit: '%' },
18
+ { tag: 'BTG_BLR01.pressure', type: 'readout', unit: 'bar', decimals: 1 },
19
+ );
20
+
21
+ const steamValve = valveControl('VLV-01').at(200, 80).label('Steam control valve').bind({ tag: 'BTG_VLV01.pos', type: 'position', min: 0, max: 100 });
22
+
23
+ const turbineEl = turbine('TRB-01')
24
+ .at(300, 60)
25
+ .label('Turbine')
26
+ .bind({ tag: 'BTG_TRB01.power', type: 'readout', unit: 'MW', decimals: 1 }, { tag: 'BTG_TRB01.rpm', type: 'rotate', min: 0, max: 3600 });
27
+
28
+ const condenserEl = condenser('CND-01').at(450, 70).label('Condenser');
29
+
30
+ const feedPump = pumpCentrifugal('PMP-01')
31
+ .at(620, 70)
32
+ .label('Feedwater pump')
33
+ .bind(
34
+ { tag: 'BTG_PMP01.state', type: 'state', map: { '0': 'stopped', '1': 'running' } },
35
+ { tag: 'BTG_PMP01.rpm', type: 'rotate', min: 0, max: 1800 },
36
+ );
37
+
38
+ const generatorEl = generator('GEN-01')
39
+ .at(300, 200)
40
+ .label('Generator')
41
+ .bind(
42
+ { tag: 'BTG_GEN01.power', type: 'readout', unit: 'MW', decimals: 1 },
43
+ { tag: 'BTG_GEN01.state', type: 'state', map: { '0': 'offline', '1': 'online' } },
44
+ );
45
+
46
+ const tieBreaker = breaker('BRK-01').at(420, 220).label('Generator breaker').bind({ tag: 'BTG_BRK01.state', type: 'state', map: { '0': 'open', '1': 'closed' } });
47
+
48
+ const bus = busbar('BUS-01').at(520, 235).label('Plant bus').bind({ tag: 'BTG_BUS01.state', type: 'state', map: { '0': 'de-energized', '1': 'energized' } });
49
+
50
+ export default createScene('boiler-turbine-generator', {
51
+ canvas: { width: 850, height: 320 },
52
+ title: 'Boiler → Turbine → Generator',
53
+ theme: 'classic-hmi',
54
+ })
55
+ .add(boilerDrum)
56
+ .add(steamValve)
57
+ .add(turbineEl)
58
+ .add(condenserEl)
59
+ .add(feedPump)
60
+ .add(generatorEl)
61
+ .add(tieBreaker)
62
+ .add(bus)
63
+ .connect(boilerDrum.port('steam-outlet'), steamValve.port('inlet'), {
64
+ id: 'C-STM1',
65
+ medium: 'steam',
66
+ bindings: [{ tag: 'BTG_STM01.flow', type: 'flow', min: 0, max: 100 }],
67
+ })
68
+ .connect(steamValve.port('outlet'), turbineEl.port('inlet'), {
69
+ id: 'C-STM2',
70
+ medium: 'steam',
71
+ bindings: [{ tag: 'BTG_STM02.flow', type: 'flow', min: 0, max: 100 }],
72
+ })
73
+ .connect(turbineEl.port('outlet'), condenserEl.port('inlet'), {
74
+ id: 'C-STM3',
75
+ medium: 'steam',
76
+ bindings: [{ tag: 'BTG_STM03.flow', type: 'flow', min: 0, max: 100 }],
77
+ })
78
+ .connect(condenserEl.port('outlet'), feedPump.port('suction'), {
79
+ id: 'C-COND',
80
+ medium: 'condensate',
81
+ bindings: [{ tag: 'BTG_COND01.flow', type: 'flow', min: 0, max: 100 }],
82
+ })
83
+ // Recycle loop — same cycle-detection exercise as waste-to-energy's own loop.
84
+ .connect(feedPump.port('discharge'), boilerDrum.port('feedwater-inlet'), {
85
+ id: 'C-FW',
86
+ medium: 'feedwater',
87
+ bindings: [{ tag: 'BTG_FW01.flow', type: 'flow', min: 0, max: 100 }],
88
+ })
89
+ .connect(generatorEl.port('output'), tieBreaker.port('line-in'), { id: 'C-ELEC1', medium: 'electricity' })
90
+ .connect(tieBreaker.port('line-out'), bus.port('line-in'), { id: 'C-ELEC2', medium: 'electricity' })
91
+ .inferTopology();
@@ -0,0 +1,9 @@
1
+ # conveyor-batching
2
+
3
+ The manufacturing vertical: feed hopper → screw feeder → conveyor → batching vessel, with a small selector-switch/pushbutton control panel. The lightest of the five templates.
4
+
5
+ Run it:
6
+
7
+ ```sh
8
+ vscada preview --template conveyor-batching
9
+ ```
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { assertSchemaValid, assertSimCoversEveryBoundTag } from '../template-test-utils';
3
+ import sceneBuilder from './scene';
4
+
5
+ describe('conveyor-batching template (Story 5.4 Task 4)', () => {
6
+ const scene = sceneBuilder.build();
7
+
8
+ it('builds a schema-valid scene', () => {
9
+ assertSchemaValid(scene);
10
+ });
11
+
12
+ it('runs hopper → screw feeder → conveyor → batching vessel, with a selector/pushbutton panel', () => {
13
+ const typesById = new Map(scene.elements.map((el) => [el.id, el.type]));
14
+ expect(typesById.get('HPR-01')).toBe('hopper');
15
+ expect(typesById.get('SCF-01')).toBe('screw-feeder');
16
+ expect(typesById.get('CNV-01')).toBe('conveyor');
17
+ expect(typesById.get('DRM-01')).toBe('drum');
18
+ expect(typesById.get('SEL-01')).toBe('selector-switch');
19
+ expect(typesById.get('PB-01')).toBe('pushbutton');
20
+ expect(scene.connections.map((c) => `${c.from}->${c.to}`)).toEqual([
21
+ 'HPR-01.outlet-bot->SCF-01.inlet-top',
22
+ 'SCF-01.outlet->CNV-01.infeed',
23
+ 'CNV-01.discharge->DRM-01.inlet-top',
24
+ ]);
25
+ });
26
+
27
+ it('gives the simulator generator coverage for every bound tag', () => {
28
+ assertSimCoversEveryBoundTag(scene);
29
+ });
30
+ });