diff --git a/packages/editor/browser/modem.spec.ts b/packages/editor/browser/modem.spec.ts new file mode 100644 index 0000000..4c72c50 --- /dev/null +++ b/packages/editor/browser/modem.spec.ts @@ -0,0 +1,111 @@ +// Two projects' networks joined by their modems (FILIUS_MODEM_PLAN K5), on a +// real server F, three people in three browsers, each in a copy of FILIUS' +// modem example. Mia's access point offers a connection and shows its code; +// Ben's accessing modem joins it with the code as typed off the board. Each +// modem says under it how it stands. Ben's notebook pings 192.168.0.11 — +// the computer behind Mia's access point, for the one behind Ben's own is cut +// off: his access point has no partner left on its port. Rita joins the same +// code and reads that it is taken, until Ben stops. + +import { expect, test, type Browser, type Page } from '@playwright/test'; +import { onDevice, serveEditor, showDesktop } from './editorPage.js'; +import { startServerF, type ServerF } from './serverF.js'; + +let editor: { readonly base: string; readonly close: () => Promise }; +let f: ServerF; + +test.beforeAll(async () => { + editor = await serveEditor(); + f = await startServerF(editor.base); +}); + +test.afterAll(async () => { + await f?.close(); + await editor?.close(); +}); + +const ACCESS_POINT = 'Modem (Zugangspunkt)'; +const ACCESSING = 'Modem (Zugreifend)'; + +/** A person in their own browser, signed in, in a fresh copy of the modem example. */ +async function person(browser: Browser, name: string): Promise<{ readonly page: Page; readonly node: (name: string) => string }> { + const project = await f.project('java-examples/modem_beispiel.fls'); + const context = await browser.newContext({ permissions: ['clipboard-read', 'clipboard-write'] }); + await f.signIn(context, project.id, { name }); + const page = await context.newPage(); + await page.goto(`${editor.base}?project=${project.id}&server=${encodeURIComponent(f.base)}`); + await page.waitForSelector('svg.filius-canvas'); + return { page, node: project.node }; +} + +const sheet = (page: Page) => page.getByRole('complementary', { name: 'Einstellungen' }); +const note = (page: Page, id: string) => page.locator(`g.device[data-node="${id}"] text[data-note]`); + +async function choose(page: Page, id: string): Promise { + const at = await onDevice(page, id); + await page.mouse.click(at.x, at.y); + await expect(sheet(page)).toBeVisible(); +} + +async function start(page: Page): Promise { + await page.getByRole('button', { name: 'Wechselt in den Aktionsmodus' }).click(); + await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'simulation'); +} + +test("joins two projects' networks through their modems: a code offered, a code typed, a ping across", async ({ browser }) => { + test.setTimeout(120_000); + const mia = await person(browser, 'Mia'); + const ben = await person(browser, 'Ben'); + const rita = await person(browser, 'Rita'); + + // Mia's access point offers: its sheet shows the code to pass on, and copies it. + await choose(mia.page, mia.node(ACCESS_POINT)); + await sheet(mia.page).getByRole('radio', { name: 'Mit einem anderen Projekt: Verbindung anbieten' }).check(); + const code = (await sheet(mia.page).locator('output.modem-code').textContent())!; + expect(code).toMatch(/^[A-HJ-NP-Z2-9]{3}-[A-HJ-NP-Z2-9]{3}$/); + await sheet(mia.page).getByRole('button', { name: 'Code kopieren' }).click(); + await expect(sheet(mia.page).getByRole('button', { name: 'Kopiert ✓' })).toBeVisible(); + await expect(mia.page.getByRole('alert')).toHaveCount(0); + expect(await mia.page.evaluate(() => navigator.clipboard.readText())).toBe(code); + await mia.page.screenshot({ path: 'test-results/modem-offer.png' }); + + // Ben's accessing modem joins, the code typed as one reads it off the board. + for (const person of [ben, rita]) { + await choose(person.page, person.node(ACCESSING)); + await sheet(person.page).getByRole('radio', { name: 'Mit einem anderen Projekt: mit Code verbinden' }).check(); + const field = sheet(person.page).locator('input[name="modemCode"]'); + await field.fill(code.toLowerCase().replace('-', ' ')); + await field.press('Enter'); + await expect(field).toHaveValue(code); + } + await ben.page.screenshot({ path: 'test-results/modem-join.png' }); + + await start(mia.page); + await expect(note(mia.page, mia.node(ACCESS_POINT))).toHaveText('wartet auf Gegenstelle'); + await expect(note(mia.page, mia.node(ACCESSING))).toHaveText('kein zweites Modem mit diesem Port'); + await start(ben.page); + await expect(note(ben.page, ben.node(ACCESSING))).toHaveText('verbunden'); + await expect(note(mia.page, mia.node(ACCESS_POINT))).toHaveText('verbunden'); + await expect(note(mia.page, mia.node(ACCESS_POINT))).toHaveAttribute('data-note', 'good'); + await mia.page.screenshot({ path: 'test-results/modem-connected.png' }); + + // Ben's notebook pings the computer behind Mia's access point. + const notebook = ben.node('Notebook 0.10'); + await showDesktop(ben.page, notebook); + const desktop = ben.page.locator(`section[data-desktop="${notebook}"]`); + await desktop.locator('[data-program="commandPrompt"]').click(); + const input = desktop.locator('[data-program-window="commandPrompt"] input[name="terminalInput"]'); + await input.fill('ping 192.168.0.11'); + await input.press('Enter'); + await expect(desktop.locator('.terminal-text')).toContainText('4 Paket(e) gesendet, 4 Paket(e) empfangen', { timeout: 30_000 }); + + // Rita wants the same code: taken while Ben has it, hers once he stops. + await start(rita.page); + await expect(note(rita.page, rita.node(ACCESSING))).toHaveText('Code schon belegt'); + await expect(note(rita.page, rita.node(ACCESSING))).toHaveAttribute('data-note', 'trouble'); + await ben.page.getByRole('button', { name: 'Wechselt in den Entwurfsmodus' }).click(); + await expect(note(rita.page, rita.node(ACCESSING))).toHaveText('verbunden'); + await expect(note(mia.page, mia.node(ACCESS_POINT))).toHaveText('verbunden'); + // In the design there is nothing to say. + await expect(ben.page.locator('text[data-note]')).toHaveCount(0); +}); diff --git a/packages/editor/browser/simulation.spec.ts b/packages/editor/browser/simulation.spec.ts index 2f43a2f..ffad28b 100644 --- a/packages/editor/browser/simulation.spec.ts +++ b/packages/editor/browser/simulation.spec.ts @@ -477,15 +477,15 @@ test("installs and removes programs as Java's installation does, and a program i expect((await desktopOf(page, id))!.programs).toEqual(['commandPrompt']); }); -test('says so when the simulation fails, and goes back to the design', async ({ page }) => { - // FILIUS' modem example: the core has no modems yet (remote links); a ping to the other side fails it. +test("runs FILIUS' modem example: both modems say they are connected, a ping goes through the pair", async ({ page }) => { await openEditor(page, server.base, 'modem_beispiel.fls'); await start(page); - const { input } = await commandLine(page, 'Notebook 0.10'); + for (const name of ['Modem (Zugreifend)', 'Modem (Zugangspunkt)']) + await expect(page.locator(`g.device[data-node="${await deviceNamed(page, name)}"] text[data-note]`)).toHaveText('verbunden'); + const { input, text } = await commandLine(page, 'Notebook 0.10'); await input.fill('ping 192.168.0.11'); await input.press('Enter'); - await expect(page.getByRole('alert')).toHaveText('Die Simulation ist auf einen Fehler gestoßen und wurde beendet.', { timeout: 20_000 }); - await expect(page.locator('svg.filius-canvas')).toHaveAttribute('data-mode', 'design'); + await expect(text).toContainText('4 Paket(e) gesendet, 4 Paket(e) empfangen', { timeout: 30_000 }); }); test('speaks English when asked', async ({ page }) => { diff --git a/packages/editor/src/Editor.tsx b/packages/editor/src/Editor.tsx index 95a3b1b..f5a1d83 100644 --- a/packages/editor/src/Editor.tsx +++ b/packages/editor/src/Editor.tsx @@ -31,6 +31,7 @@ // (moving.ts). import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; +import type { ModemStatus } from '@mdg-filius/protocol'; import type * as Y from 'yjs'; import { addDevice, @@ -51,7 +52,7 @@ import { type TopologyView, } from '@mdg-filius/doc'; import { DOCUMENTATION_NEW_SIZE, type DocumentationItemKind } from '@mdg-filius/core'; -import { Canvas, type CanvasHandle, type Selection, type Side } from './canvas/Canvas.js'; +import { Canvas, type CanvasHandle, type DeviceNote, type Selection, type Side } from './canvas/Canvas.js'; import type { Point } from './canvas/viewport.js'; import { ContextMenu, type MenuItem } from './design/ContextMenu.js'; import { DocumentationMenu } from './design/DocumentationMenu.js'; @@ -182,14 +183,32 @@ export interface EditorProps { readonly frozen?: boolean; /** The others in the project and how to tell them this editor's state (on F). */ readonly presence?: Presence; + /** The project on F, whose modems offer codes to other projects; none on the development page. */ + readonly project?: string; } -export function Editor({ doc, createSimHost, projectName, readOnly = false, frozen = false, presence }: EditorProps) { +/** How a modem's standing reads under it: good news, a wait, or trouble. */ +const MODEM_TONES: Readonly> = { + connected: 'good', + waiting: 'waiting', + taken: 'trouble', + unpaired: 'trouble', + ambiguous: 'trouble', + detached: 'trouble', + unavailable: 'trouble', +}; + +export function Editor({ doc, createSimHost, projectName, readOnly = false, frozen = false, presence, project }: EditorProps) { const t = useTranslate(); const language = useLanguage(); const view = useTopologyView(doc); const simulation = useSimulation(doc, createSimHost); const simulating = simulation.mode === 'simulation'; + // How each modem stands, under its label, while the simulation runs. + const modemNotes = useMemo( + () => new Map([...simulation.modems].map(([id, status]) => [id, { text: t(`modem.status.${status}`), tone: MODEM_TONES[status] }] as const)), + [simulation.modems, t], + ); const [documenting, setDocumenting] = useState(false); // The simulation comes first: started, it ends the documentation mode (changeMode). const editorMode: EditorMode = simulating ? 'simulation' : documenting ? 'documentation' : 'design'; @@ -516,6 +535,7 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz ); }} activeLinks={simulation.activeLinks} + notes={modemNotes} onMenu={(target, at) => { if (readOnly && !simulating) return; if (editorMode === 'documentation') { @@ -683,6 +703,7 @@ export function Editor({ doc, createSimHost, projectName, readOnly = false, froz onHighlight={setHighlightedLink} onClose={() => setSelection(null)} readOnly={readOnly} + {...(project === undefined ? {} : { project })} /> )} diff --git a/packages/editor/src/canvas/Canvas.tsx b/packages/editor/src/canvas/Canvas.tsx index d0aa583..3a5f964 100644 --- a/packages/editor/src/canvas/Canvas.tsx +++ b/packages/editor/src/canvas/Canvas.tsx @@ -107,9 +107,22 @@ export interface CanvasProps { readonly renderDocText?: (item: DocumentationView, frame: ScreenFrame) => React.ReactNode; /** The cables that carry a frame now, drawn as Java draws them then (JCablePanel.farbeBlinken). */ readonly activeLinks?: ReadonlySet; + /** A line under a device's label, by node (how a modem stands while the simulation runs). */ + readonly notes?: ReadonlyMap; } +/** A line under a device's label: its text, and whether it is good news, a wait or trouble. */ +export interface DeviceNote { + readonly text: string; + readonly tone: 'good' | 'waiting' | 'trouble'; +} + +const NOTE_COLORS: Readonly> = { good: 'var(--accent)', waiting: 'var(--text-secondary)', trouble: 'var(--danger)' }; +/** How far the note sits below the label's baseline. */ +const NOTE_LINE = 14; + const NO_LINKS: ReadonlySet = new Set(); +const NO_NOTES: ReadonlyMap = new Map(); const NO_CHOICES: ReadonlyMap = new Map(); const NO_COLORS: readonly string[] = []; @@ -195,6 +208,7 @@ function Device({ selected, pending, chosenBy, + note, }: { readonly node: NodeView; readonly box: DeviceBox; @@ -203,6 +217,7 @@ function Device({ readonly pending: boolean; /** The colours of the others who have chosen it, each a frame further out. */ readonly chosenBy: readonly string[]; + readonly note: DeviceNote | undefined; }) { return ( {/* The whole button takes the pointer, as Java's label does. */} @@ -249,6 +264,11 @@ function Device({ {node.label} + {note === undefined ? null : ( + + {note.text} + + )} ); } @@ -288,6 +308,7 @@ export const Canvas = forwardRef(function Canvas( locked = false, chosenBy = NO_CHOICES, activeLinks = NO_LINKS, + notes = NO_NOTES, onDocFrame, renderDocText, }, @@ -741,6 +762,7 @@ export const Canvas = forwardRef(function Canvas( selected={selection?.kind === 'device' && selection.id === node.id} chosenBy={chosenBy.get(node.id) ?? NO_COLORS} pending={cableFrom?.id === node.id} + note={notes.get(node.id)} /> ))} diff --git a/packages/editor/src/i18n/de.json b/packages/editor/src/i18n/de.json index f010100..d441a36 100644 --- a/packages/editor/src/i18n/de.json +++ b/packages/editor/src/i18n/de.json @@ -331,6 +331,25 @@ "mode.documentation": "Wechselt in den Dokumentationsmodus", "mode.simulation": "Wechselt in den Aktionsmodus", "mode.speed": "Simulationsgeschwindigkeit anpassen", + "modem.code": "Code dieses Modems", + "modem.connection": "Verbindung", + "modem.connection.inProject": "Mit einem Modem in diesem Projekt (gleicher Port)", + "modem.connection.join": "Mit einem anderen Projekt: mit Code verbinden", + "modem.connection.offer": "Mit einem anderen Projekt: Verbindung anbieten", + "modem.copied": "Kopiert ✓", + "modem.copy": "Code kopieren", + "modem.copyFailed": "Der Code ließ sich nicht kopieren.", + "modem.joinHint": "Den Code zeigt das Modem im anderen Projekt. Die Verbindung steht, sobald beide Simulationen laufen.", + "modem.offerHint": "Geben Sie den Code an die andere Gruppe weiter. Ihr Modem verbindet sich, sobald beide Simulationen laufen.", + "modem.partnerCode": "Code des anderen Modems", + "modem.serverOnly": "Verbindungen zu anderen Projekten gibt es nur in Projekten auf dem Server.", + "modem.status.ambiguous": "mehr als zwei Modems mit diesem Port", + "modem.status.connected": "verbunden", + "modem.status.detached": "Kabel fehlt", + "modem.status.taken": "Code schon belegt", + "modem.status.unavailable": "nur auf dem Server", + "modem.status.unpaired": "kein zweites Modem mit diesem Port", + "modem.status.waiting": "wartet auf Gegenstelle", "network.dnsServer": "DNS-Server:", "network.gateway": "Gateway:", "network.ipAddress": "IP-Adresse:", diff --git a/packages/editor/src/i18n/en.json b/packages/editor/src/i18n/en.json index cdde8cb..ddcc754 100644 --- a/packages/editor/src/i18n/en.json +++ b/packages/editor/src/i18n/en.json @@ -331,6 +331,25 @@ "mode.documentation": "Change to Documentation Mode", "mode.simulation": "Change to Simulation Mode", "mode.speed": "Adjust Simulation Velocity", + "modem.code": "Code of this modem", + "modem.connection": "Connection", + "modem.connection.inProject": "With a modem in this project (same port)", + "modem.connection.join": "With another project: connect with a code", + "modem.connection.offer": "With another project: offer a connection", + "modem.copied": "Copied ✓", + "modem.copy": "Copy code", + "modem.copyFailed": "The code could not be copied.", + "modem.joinHint": "The modem in the other project shows the code. The connection stands as soon as both simulations run.", + "modem.offerHint": "Pass the code on to the other group. Your modem connects as soon as both simulations run.", + "modem.partnerCode": "Code of the other modem", + "modem.serverOnly": "Connections to other projects exist only in projects on the server.", + "modem.status.ambiguous": "more than two modems with this port", + "modem.status.connected": "connected", + "modem.status.detached": "cable missing", + "modem.status.taken": "code already taken", + "modem.status.unavailable": "on the server only", + "modem.status.unpaired": "no second modem with this port", + "modem.status.waiting": "waiting for the other side", "network.dnsServer": "DNS server:", "network.gateway": "Gateway:", "network.ipAddress": "IP address:", diff --git a/packages/editor/src/remote/SharedProject.tsx b/packages/editor/src/remote/SharedProject.tsx index cb5e0a6..a592a53 100644 --- a/packages/editor/src/remote/SharedProject.tsx +++ b/packages/editor/src/remote/SharedProject.tsx @@ -26,11 +26,13 @@ type Ready = Extract; /** The editor on the ready document, with the others in the project. */ function ReadyEditor({ + project, state, createSimHost, onPeople, blobs, }: { + readonly project: string; readonly state: Ready; readonly createSimHost: SimHostFactory; readonly onPeople: ((people: readonly PresenceUser[]) => void) | undefined; @@ -52,7 +54,7 @@ function ReadyEditor({ const reach = useMemo(() => (files === null || writing ? files : { saveFile: files.saveFile }), [files, writing]); return ( - + ); } @@ -104,7 +106,7 @@ export function SharedProject({ server, project, onPeople }: SharedProjectProps) {t('remote.uploadFailed')}

) : null} - + ); } diff --git a/packages/editor/src/sheet/DeviceSheet.tsx b/packages/editor/src/sheet/DeviceSheet.tsx index da6343b..b8524ca 100644 --- a/packages/editor/src/sheet/DeviceSheet.tsx +++ b/packages/editor/src/sheet/DeviceSheet.tsx @@ -7,6 +7,7 @@ import { useEffect, useState } from 'react'; import type * as Y from 'yjs'; +import { modemCode, modemCodeText } from '@mdg-filius/core'; import { deviceSheet, setDhcpClient, @@ -14,6 +15,8 @@ import { setHostLabels, setHostWireless, setIpForwarding, + setModemCode, + setModemConnection, setModemPort, setSwitchCloud, setSwitchRetention, @@ -21,6 +24,7 @@ import { type CheckedField, type DeviceSheet as Sheet, type HostSheet, + type ModemConnection, type ModemSheet, type SwitchSheet, type WirelessError, @@ -63,6 +67,8 @@ export interface DeviceSheetProps { readonly onHighlight: (link: string | null) => void; /** Who only reads (on F): everything shown, nothing changed (SheetLock). */ readonly readOnly?: boolean; + /** The project on F (a modem's code is derived from it); none on the development page. */ + readonly project?: string; } /** A page of the sheet that Java opens as a dialog. */ @@ -70,7 +76,7 @@ export type SheetPage = 'dhcp' | 'forwarding' | 'firewall' | 'connections'; const PAGE_TITLES: Readonly> = { dhcp: 'sheet.dhcpServer', forwarding: 'sheet.portForwarding', firewall: 'sheet.firewall', connections: 'sheet.connections' }; -export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose, onHighlight, readOnly = false }: DeviceSheetProps) { +export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onClose, onHighlight, readOnly = false, project }: DeviceSheetProps) { const t = useTranslate(); const sheet = useDeviceSheet(doc, id); // The page open, if any; the editor makes a sheet per device, so another device starts at its fields. @@ -116,7 +122,7 @@ export function DeviceSheet({ doc, id, label, origin, onChanged, onMessage, onCl ) : sheet.kind === 'remoteLink' ? ( - + ) : sheet.kind === 'router' ? ( @@ -264,19 +270,103 @@ function SwitchFields({ sheet, change }: { readonly sheet: SwitchSheet; readonly ); } -function ModemFields({ sheet, change }: { readonly sheet: ModemSheet; readonly change: Change }) { +const MODEM_CONNECTIONS: readonly ModemConnection[] = ['inProject', 'offer', 'join']; +/** How long the copy button says the code is copied. */ +const COPIED_MILLISECONDS = 2_000; + +/** + * A modem (JModemKonfiguration): its name, and how it finds its partner — + * the other modem on its port in this project, as FILIUS' file pairs them; + * or a modem of another project, which takes the place of Java's IP address + * and port: this one offers a code to pass on, or joins the modem whose code + * is typed in (FILIUS_MODEM_PLAN). The code needs server F; on the + * development page there is none. + */ +function ModemFields({ + sheet, + change, + project, + onMessage, +}: { + readonly sheet: ModemSheet; + readonly change: Change; + readonly project: string | undefined; + readonly onMessage: (message: string) => void; +}) { const t = useTranslate(); + const code = project === undefined ? undefined : modemCodeText(modemCode(project, change.id)); + // The button says so for a moment once the code is copied; a failure goes to the editor's message. + const [copied, setCopied] = useState(false); + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), COPIED_MILLISECONDS); + return () => clearTimeout(timer); + }, [copied]); + const copy = () => { + if (code === undefined) return; + (navigator.clipboard?.writeText(code) ?? Promise.reject(new Error('no clipboard'))).then( + () => setCopied(true), + () => onMessage(t('modem.copyFailed')), + ); + }; return ( <> - change.run(() => setModemPort(change.doc, change.id, text, change.origin))} - /> +
+ {MODEM_CONNECTIONS.map((connection) => ( + + ))} +
+ {sheet.connection === 'inProject' ? ( + change.run(() => setModemPort(change.doc, change.id, text, change.origin))} + /> + ) : sheet.connection === 'offer' ? ( + code === undefined ? ( +

{t('modem.serverOnly')}

+ ) : ( + <> +
+ {t('modem.code')} + + {code} + +
+
+ +
+

{t('modem.offerHint')}

+ + ) + ) : ( + <> + change.run(() => setModemCode(change.doc, change.id, text, change.origin))} + /> +

{t(project === undefined ? 'modem.serverOnly' : 'modem.joinHint')}

+ + )} ); } diff --git a/packages/editor/src/sheet/sheet.css b/packages/editor/src/sheet/sheet.css index 93c86c9..0bcbdde 100644 --- a/packages/editor/src/sheet/sheet.css +++ b/packages/editor/src/sheet/sheet.css @@ -427,6 +427,15 @@ margin: 8px 0; } +/* A modem's code to pass on: read aloud or copied off the board, so large and spaced. */ +.modem-code { + font-family: ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; + font-size: 22px; + font-weight: 600; + letter-spacing: 0.12em; + color: var(--text-primary); +} + .sheet-connected, .sheet-note { margin: 8px 0; diff --git a/packages/editor/src/simulation/useSimulation.ts b/packages/editor/src/simulation/useSimulation.ts index 48a3a7e..68455d8 100644 --- a/packages/editor/src/simulation/useSimulation.ts +++ b/packages/editor/src/simulation/useSimulation.ts @@ -1,14 +1,14 @@ // The editor's side of the simulation room (plan §6): its mode and speed, // the cables that carry frames, a computer's network as it runs, the lines // and views of the programs' windows and the actions taken in them, the -// cards' frames for the data exchange window. The room +// cards' frames for the data exchange window, how each modem stands. The room // runs behind a SimHost — on F for everyone in the project (RemoteSimHost), // or in a worker of this page (LocalSimHost) on the development page; the // editor keeps no simulation of its own. import { useCallback, useEffect, useRef, useState } from 'react'; import type * as Y from 'yjs'; -import type { AppKind, DroppedUnit, NodeSettings, SimHost, SimMode, Topic, TraceFrame } from '@mdg-filius/protocol'; +import type { AppKind, DroppedUnit, ModemStatus, NodeSettings, SimHost, SimMode, Topic, TraceFrame } from '@mdg-filius/protocol'; import { LocalSimHost } from '@mdg-filius/sim'; import SimWorker from '@mdg-filius/sim/worker?worker'; import type { Language } from '../i18n/i18n.js'; @@ -52,6 +52,8 @@ export interface Simulation { readonly dropped: readonly DroppedUnit[]; /** Each watched card's frames as the data exchange window shows them, by its port. */ readonly traces: ReadonlyMap; + /** How each modem stands while the simulation runs, by node; empty in the design. */ + readonly modems: ReadonlyMap; /** Starts in the language the person runs FILIUS in: the routers' pages come in it, as Java's in its locale. */ start(language?: Language): void; stop(): void; @@ -71,6 +73,7 @@ export interface Simulation { } const NO_LINKS: ReadonlySet = new Set(); +const NO_MODEMS: ReadonlyMap = new Map(); export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimHost): Simulation { const host = useRef(null); @@ -89,6 +92,7 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH const [clock, setClock] = useState(0); const [dropped, setDropped] = useState([]); const [traces, setTraces] = useState>(new Map()); + const [modems, setModems] = useState>(NO_MODEMS); useEffect(() => { const made = createHost(doc); @@ -133,6 +137,9 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH case 'view': setViews((before) => new Map(before).set(`app:${message.node}/${message.app}`, message.view)); return; + case 'modems': + setModems(new Map(Object.entries(message.modems))); + return; case 'error': if (message.code === 'engineFailed') setFailed(true); if (message.code === 'readOnly' || message.code === 'frozen') { @@ -217,5 +224,5 @@ export function useSimulation(doc: Y.Doc, createHost: SimHostFactory = localSimH host.current?.send({ v: 1, kind: 'input', node, app, action, args, reqId: crypto.randomUUID() }); }, []); - return { mode, speed, activeLinks, nodes, apps, views, failed, startedBy, refusal, clock, dropped, traces, start, stop, restart, setSpeed, drop, clearCapture, watch, captures, act }; + return { mode, speed, activeLinks, nodes, apps, views, failed, startedBy, refusal, clock, dropped, traces, modems, start, stop, restart, setSpeed, drop, clearCapture, watch, captures, act }; } diff --git a/packages/fls/src/exportProject.ts b/packages/fls/src/exportProject.ts index 91d043c..c290b2a 100644 --- a/packages/fls/src/exportProject.ts +++ b/packages/fls/src/exportProject.ts @@ -232,12 +232,12 @@ function exportNode(node: TopologyNode, project: ProjectToExport, servingModems: }; } -/** The first node of each remote link pair: its modem serves. */ +/** The first node of each remote link pair in the project: its modem serves (a modem of another project serves by its role). */ function firstOfEachPair(nodes: readonly TopologyNode[], project: ProjectToExport): Set { const seen = new Set(); const serving = new Set(); for (const node of nodes) { - if (node.kind !== 'remoteLink') continue; + if (node.kind !== 'remoteLink' || project.remoteLinkConfigurations.get(node.id)?.relay !== undefined) continue; const pair = project.remoteLinkConfigurations.get(node.id)?.pairIdentifier ?? ''; if (seen.has(pair)) continue; seen.add(pair); @@ -255,14 +255,13 @@ function exportSystem(node: TopologyNode, project: ProjectToExport, servingModem const configuration = project.switchConfigurations.get(id) ?? defaultSwitchConfiguration(id); return { ssid: configuration.ssid, retentionTime: configuration.retentionTimeMilliseconds }; } - case 'remoteLink': - return { - modem: { - mode: servingModems.has(id) ? 1 : 2, - port: modemPort(project.remoteLinkConfigurations.get(id)?.pairIdentifier), - ipAddress: 'localhost', - }, - }; + case 'remoteLink': { + const configuration = project.remoteLinkConfigurations.get(id); + // A modem of another project: Java's server offers, its client joins; the code has no place in Java's file. + if (configuration?.relay !== undefined) warn('the connection to another project (its code) is not kept'); + const serves = configuration?.relay === undefined ? servingModems.has(id) : configuration.relay.role === 'offer'; + return { modem: { mode: serves ? 1 : 2, port: modemPort(configuration?.pairIdentifier), ipAddress: 'localhost' } }; + } case 'unsupported': return {}; default: diff --git a/packages/fls/test/exportProject.test.ts b/packages/fls/test/exportProject.test.ts index 0f86782..ef4f281 100644 --- a/packages/fls/test/exportProject.test.ts +++ b/packages/fls/test/exportProject.test.ts @@ -1029,6 +1029,28 @@ describe('exportProject rules', () => { ]); }); + it("writes a modem of another project as Java's server when it offers and as its client when it joins, and says the code is not kept", () => { + const modems = project([node('offer', 'remoteLink', ['o0']), node('join', 'remoteLink', ['j0']), node('a', 'remoteLink', ['a0']), node('b', 'remoteLink', ['b0'])], []); + const { topology, warnings } = exportProject({ + ...modems, + remoteLinkConfigurations: new Map([ + // The joining modem comes first among the modems on port 12345: by its role, not as the first of a pair. + ['join', { pairIdentifier: 'java-modem-port-12345', latencyMilliseconds: 20, isEnabled: true, relay: { role: 'join', code: 'K7F3QX' } }], + ['offer', { pairIdentifier: 'java-modem-port-12345', latencyMilliseconds: 20, isEnabled: true, relay: { role: 'offer' } }], + ['a', { pairIdentifier: 'java-modem-port-12345', latencyMilliseconds: 20, isEnabled: true }], + ['b', { pairIdentifier: 'java-modem-port-12345', latencyMilliseconds: 20, isEnabled: true }], + ]), + }); + expect(topology.nodes.map((each) => [each.name, each.system.modem?.mode])).toEqual([ + ['offer', 1], + ['join', 2], + // The pair in the project: its first serves, the relay modems on the same port do not count. + ['a', 1], + ['b', 2], + ]); + expect(warnings).toEqual(["node 'offer': the connection to another project (its code) is not kept", "node 'join': the connection to another project (its code) is not kept"]); + }); + it('joins wirelessly only a host with a wireless card and a switch', () => { const { topology } = exportProject( project(