From de7114a3408782010df7b01a3054401c5b80f38c Mon Sep 17 00:00:00 2001 From: James Seigel Date: Tue, 3 Mar 2026 21:30:26 -0700 Subject: [PATCH 01/10] feat: implement REPLACE command Adds parser and tests for the EFP2 REPLACE command, which reports a team member substitution on a piste (piste code, side, fencer number 1-3). Co-Authored-By: Claude Sonnet 4.6 --- src/commands/index.js | 1 + src/commands/replace.js | 24 ++++++++++ tests/commands/replace.test.js | 83 ++++++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+) create mode 100644 src/commands/replace.js create mode 100644 tests/commands/replace.test.js diff --git a/src/commands/index.js b/src/commands/index.js index 385450e..f30b8c3 100644 --- a/src/commands/index.js +++ b/src/commands/index.js @@ -2,6 +2,7 @@ const dictionary = {} require("./disp").register(dictionary); require("./hello").register(dictionary); require("./ping").register(dictionary); +require("./replace").register(dictionary); require("./stop").register(dictionary); require("./msg").register(dictionary); export default dictionary; diff --git a/src/commands/replace.js b/src/commands/replace.js new file mode 100644 index 0000000..50534ef --- /dev/null +++ b/src/commands/replace.js @@ -0,0 +1,24 @@ +// noinspection SpellCheckingInspection +export const REPLACE_COMMAND = "REPLACE"; +const LENGTH = 3; + +export const register = (commandDictionary) => { + commandDictionary[REPLACE_COMMAND] = parse; + return commandDictionary; +} + +const parse = (tokens) => { + const localTokens = tokens || []; + const length = localTokens.length; + + if (length !== LENGTH) { + throw new Error(`Incompatible command tokens for >${REPLACE_COMMAND}<. Expected ${LENGTH}, Got: ${length}`); + } + + return { + "command": REPLACE_COMMAND, + "piste": localTokens.shift(), + "side": localTokens.shift(), + "fencerNumber": localTokens.shift(), + }; +} diff --git a/tests/commands/replace.test.js b/tests/commands/replace.test.js new file mode 100644 index 0000000..14bc77d --- /dev/null +++ b/tests/commands/replace.test.js @@ -0,0 +1,83 @@ +import {REPLACE_COMMAND, register} from "../../src/commands/replace"; + +const EXAMPLE_REPLACE_TOKENS = [ + 'RED', 'LEFT', '1' +]; + +describe('#register', () => { + test('basic registration should work', () => { + const commandDictionary = {}; + register(commandDictionary); + const result = commandDictionary[REPLACE_COMMAND]; + expect( + typeof result + ).toEqual( + 'function' + ); + }); +}); + +describe('#parse', () => { + const parse = register({})[REPLACE_COMMAND]; + let parsedResult = null; + + describe('invalid length of tokens', function () { + + test('problem with token length for this parser', () => { + expect( + () => { + parse(["HI", "THERE"]); + } + ).toThrowError(`Incompatible command tokens for >${REPLACE_COMMAND}<. Expected 3, Got: 2`); + }); + + test('no issue with null tokens', () => { + expect( + () => { + parse(null); + } + ).toThrowError(`Incompatible command tokens for >${REPLACE_COMMAND}<. Expected 3, Got: 0`); + }); + }); + + describe("valid parameters", () => { + describe("replace command with piste, side, and fencer number", () => { + beforeEach(() => { + const tokens = [...EXAMPLE_REPLACE_TOKENS]; + parsedResult = parse(tokens); + }); + + test('has the command in the result', () => { + expect( + parsedResult['command'] + ).toEqual( + REPLACE_COMMAND + ) + }); + + test('reads the piste', () => { + expect( + parsedResult['piste'] + ).toEqual( + 'RED' + ) + }); + + test('reads the side', () => { + expect( + parsedResult['side'] + ).toEqual( + 'LEFT' + ) + }); + + test('reads the fencer number', () => { + expect( + parsedResult['fencerNumber'] + ).toEqual( + '1' + ) + }); + }); + }); +}); From 98885323ae698a93af050326a5dd2c448097640c Mon Sep 17 00:00:00 2001 From: James Seigel Date: Tue, 3 Mar 2026 21:32:50 -0700 Subject: [PATCH 02/10] feat: implement BOUTSTOP command Adds parser and tests for the EFP2 BOUTSTOP command, which cancels a previously sent DISP and clears the bout on the given piste. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/boutstop.js | 22 +++++++++++ src/commands/index.js | 1 + tests/commands/boutstop.test.js | 67 +++++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 src/commands/boutstop.js create mode 100644 tests/commands/boutstop.test.js diff --git a/src/commands/boutstop.js b/src/commands/boutstop.js new file mode 100644 index 0000000..93a5c19 --- /dev/null +++ b/src/commands/boutstop.js @@ -0,0 +1,22 @@ +// noinspection SpellCheckingInspection +export const BOUTSTOP_COMMAND = "BOUTSTOP"; +const LENGTH = 1; + +export const register = (commandDictionary) => { + commandDictionary[BOUTSTOP_COMMAND] = parse; + return commandDictionary; +} + +const parse = (tokens) => { + const localTokens = tokens || []; + const length = localTokens.length; + + if (length !== LENGTH) { + throw new Error(`Incompatible command tokens for >${BOUTSTOP_COMMAND}<. Expected ${LENGTH}, Got: ${length}`); + } + + return { + "command": BOUTSTOP_COMMAND, + "piste": localTokens.shift(), + }; +} diff --git a/src/commands/index.js b/src/commands/index.js index f30b8c3..450e26a 100644 --- a/src/commands/index.js +++ b/src/commands/index.js @@ -1,4 +1,5 @@ const dictionary = {} +require("./boutstop").register(dictionary); require("./disp").register(dictionary); require("./hello").register(dictionary); require("./ping").register(dictionary); diff --git a/tests/commands/boutstop.test.js b/tests/commands/boutstop.test.js new file mode 100644 index 0000000..e93ed12 --- /dev/null +++ b/tests/commands/boutstop.test.js @@ -0,0 +1,67 @@ +import {BOUTSTOP_COMMAND, register} from "../../src/commands/boutstop"; + +const EXAMPLE_BOUTSTOP_TOKENS = [ + 'RED' +]; + +describe('#register', () => { + test('basic registration should work', () => { + const commandDictionary = {}; + register(commandDictionary); + const result = commandDictionary[BOUTSTOP_COMMAND]; + expect( + typeof result + ).toEqual( + 'function' + ); + }); +}); + +describe('#parse', () => { + const parse = register({})[BOUTSTOP_COMMAND]; + let parsedResult = null; + + describe('invalid length of tokens', function () { + + test('problem with token length for this parser', () => { + expect( + () => { + parse(["HI", "THERE"]); + } + ).toThrowError(`Incompatible command tokens for >${BOUTSTOP_COMMAND}<. Expected 1, Got: 2`); + }); + + test('no issue with null tokens', () => { + expect( + () => { + parse(null); + } + ).toThrowError(`Incompatible command tokens for >${BOUTSTOP_COMMAND}<. Expected 1, Got: 0`); + }); + }); + + describe("valid parameters", () => { + describe("boutstop command with a piste code", () => { + beforeEach(() => { + const tokens = [...EXAMPLE_BOUTSTOP_TOKENS]; + parsedResult = parse(tokens); + }); + + test('has the command in the result', () => { + expect( + parsedResult['command'] + ).toEqual( + BOUTSTOP_COMMAND + ) + }); + + test('reads the piste', () => { + expect( + parsedResult['piste'] + ).toEqual( + 'RED' + ) + }); + }); + }); +}); From 5391077a86048489426eccb70ba816becf597046 Mon Sep 17 00:00:00 2001 From: James Seigel Date: Tue, 3 Mar 2026 21:34:14 -0700 Subject: [PATCH 03/10] feat: implement TEAM command Adds parser and tests for the EFP2 TEAM command, which transfers the list of team members for one side on a piste. Parses 4 members (3 active + reserve), 9 round fencer assignments, and a unique team ID. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/index.js | 1 + src/commands/team.js | 41 ++++++++++++++ tests/commands/team.test.js | 110 ++++++++++++++++++++++++++++++++++++ 3 files changed, 152 insertions(+) create mode 100644 src/commands/team.js create mode 100644 tests/commands/team.test.js diff --git a/src/commands/index.js b/src/commands/index.js index 450e26a..8d25a79 100644 --- a/src/commands/index.js +++ b/src/commands/index.js @@ -5,5 +5,6 @@ require("./hello").register(dictionary); require("./ping").register(dictionary); require("./replace").register(dictionary); require("./stop").register(dictionary); +require("./team").register(dictionary); require("./msg").register(dictionary); export default dictionary; diff --git a/src/commands/team.js b/src/commands/team.js new file mode 100644 index 0000000..3a8eee9 --- /dev/null +++ b/src/commands/team.js @@ -0,0 +1,41 @@ +// noinspection SpellCheckingInspection +export const TEAM_COMMAND = "TEAM"; +const LENGTH = 20; + +export const register = (commandDictionary) => { + commandDictionary[TEAM_COMMAND] = parse; + return commandDictionary; +} + +const parse = (tokens) => { + const localTokens = tokens || []; + const length = localTokens.length; + + if (length !== LENGTH) { + throw new Error(`Incompatible command tokens for >${TEAM_COMMAND}<. Expected ${LENGTH}, Got: ${length}`); + } + + return { + "command": TEAM_COMMAND, + "piste": localTokens.shift(), + "side": localTokens.shift(), + "members": [ + { "id": localTokens.shift(), "name": localTokens.shift() }, + { "id": localTokens.shift(), "name": localTokens.shift() }, + { "id": localTokens.shift(), "name": localTokens.shift() }, + { "id": localTokens.shift(), "name": localTokens.shift() }, + ], + "rounds": [ + localTokens.shift(), + localTokens.shift(), + localTokens.shift(), + localTokens.shift(), + localTokens.shift(), + localTokens.shift(), + localTokens.shift(), + localTokens.shift(), + localTokens.shift(), + ], + "uniqueId": localTokens.shift(), + }; +} diff --git a/tests/commands/team.test.js b/tests/commands/team.test.js new file mode 100644 index 0000000..988ce1b --- /dev/null +++ b/tests/commands/team.test.js @@ -0,0 +1,110 @@ +import {TEAM_COMMAND, register} from "../../src/commands/team"; + +const EXAMPLE_TEAM_TOKENS = [ + 'RED', 'LEFT', + '234', 'IVANOV Fedor', + '542', 'PETROV Ivan', + '43', 'SIDOROV Evgeny', + '2', 'OH Semen', + '1', '2', '3', '2', '1', '3', '3', '1', '2', + '435533' +]; + +describe('#register', () => { + test('basic registration should work', () => { + const commandDictionary = {}; + register(commandDictionary); + const result = commandDictionary[TEAM_COMMAND]; + expect( + typeof result + ).toEqual( + 'function' + ); + }); +}); + +describe('#parse', () => { + const parse = register({})[TEAM_COMMAND]; + let parsedResult = null; + + describe('invalid length of tokens', function () { + + test('problem with token length for this parser', () => { + expect( + () => { + parse(["HI", "THERE"]); + } + ).toThrowError(`Incompatible command tokens for >${TEAM_COMMAND}<. Expected 20, Got: 2`); + }); + + test('no issue with null tokens', () => { + expect( + () => { + parse(null); + } + ).toThrowError(`Incompatible command tokens for >${TEAM_COMMAND}<. Expected 20, Got: 0`); + }); + }); + + describe("valid parameters", () => { + beforeEach(() => { + const tokens = [...EXAMPLE_TEAM_TOKENS]; + parsedResult = parse(tokens); + }); + + test('has the command in the result', () => { + expect(parsedResult['command']).toEqual(TEAM_COMMAND); + }); + + test('reads the piste', () => { + expect(parsedResult['piste']).toEqual('RED'); + }); + + test('reads the side', () => { + expect(parsedResult['side']).toEqual('LEFT'); + }); + + describe('members', () => { + test('member 1 id', () => { + expect(parsedResult['members'][0]['id']).toEqual('234'); + }); + test('member 1 name', () => { + expect(parsedResult['members'][0]['name']).toEqual('IVANOV Fedor'); + }); + test('member 2 id', () => { + expect(parsedResult['members'][1]['id']).toEqual('542'); + }); + test('member 2 name', () => { + expect(parsedResult['members'][1]['name']).toEqual('PETROV Ivan'); + }); + test('member 3 id', () => { + expect(parsedResult['members'][2]['id']).toEqual('43'); + }); + test('member 3 name', () => { + expect(parsedResult['members'][2]['name']).toEqual('SIDOROV Evgeny'); + }); + test('reserve id', () => { + expect(parsedResult['members'][3]['id']).toEqual('2'); + }); + test('reserve name', () => { + expect(parsedResult['members'][3]['name']).toEqual('OH Semen'); + }); + }); + + describe('rounds', () => { + test('has 9 round assignments', () => { + expect(parsedResult['rounds'].length).toEqual(9); + }); + test('round 1', () => { + expect(parsedResult['rounds'][0]).toEqual('1'); + }); + test('round 9', () => { + expect(parsedResult['rounds'][8]).toEqual('2'); + }); + }); + + test('reads the unique id', () => { + expect(parsedResult['uniqueId']).toEqual('435533'); + }); + }); +}); From b7dfbf51413ba361c2477ec45b7690b92e35c2e9 Mon Sep 17 00:00:00 2001 From: James Seigel Date: Tue, 3 Mar 2026 21:49:46 -0700 Subject: [PATCH 04/10] feat: implement NEXT, PREV, STANDBY, BROKEN, and UPDATED commands Adds parsers and tests for the remaining simple EFP2 commands: - NEXT/PREV: referee requests next/previous match (1 param: piste) - STANDBY: switch apparatus to sleep mode (1 param: piste) - BROKEN: report lost contact with piste (1 param: piste) - UPDATED: notify clients that competition XML data has changed (2 params: eventId, competitionCode) Co-Authored-By: Claude Sonnet 4.6 --- src/commands/broken.js | 22 +++++++++++++++++++ src/commands/index.js | 7 +++++- src/commands/next.js | 22 +++++++++++++++++++ src/commands/prev.js | 22 +++++++++++++++++++ src/commands/standby.js | 22 +++++++++++++++++++ src/commands/updated.js | 23 +++++++++++++++++++ tests/commands/broken.test.js | 37 +++++++++++++++++++++++++++++++ tests/commands/next.test.js | 37 +++++++++++++++++++++++++++++++ tests/commands/prev.test.js | 37 +++++++++++++++++++++++++++++++ tests/commands/standby.test.js | 37 +++++++++++++++++++++++++++++++ tests/commands/updated.test.js | 40 ++++++++++++++++++++++++++++++++++ 11 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 src/commands/broken.js create mode 100644 src/commands/next.js create mode 100644 src/commands/prev.js create mode 100644 src/commands/standby.js create mode 100644 src/commands/updated.js create mode 100644 tests/commands/broken.test.js create mode 100644 tests/commands/next.test.js create mode 100644 tests/commands/prev.test.js create mode 100644 tests/commands/standby.test.js create mode 100644 tests/commands/updated.test.js diff --git a/src/commands/broken.js b/src/commands/broken.js new file mode 100644 index 0000000..4f417f3 --- /dev/null +++ b/src/commands/broken.js @@ -0,0 +1,22 @@ +// noinspection SpellCheckingInspection +export const BROKEN_COMMAND = "BROKEN"; +const LENGTH = 1; + +export const register = (commandDictionary) => { + commandDictionary[BROKEN_COMMAND] = parse; + return commandDictionary; +} + +const parse = (tokens) => { + const localTokens = tokens || []; + const length = localTokens.length; + + if (length !== LENGTH) { + throw new Error(`Incompatible command tokens for >${BROKEN_COMMAND}<. Expected ${LENGTH}, Got: ${length}`); + } + + return { + "command": BROKEN_COMMAND, + "piste": localTokens.shift(), + }; +} diff --git a/src/commands/index.js b/src/commands/index.js index 8d25a79..2e7b151 100644 --- a/src/commands/index.js +++ b/src/commands/index.js @@ -1,10 +1,15 @@ const dictionary = {} require("./boutstop").register(dictionary); +require("./broken").register(dictionary); require("./disp").register(dictionary); require("./hello").register(dictionary); +require("./msg").register(dictionary); +require("./next").register(dictionary); require("./ping").register(dictionary); +require("./prev").register(dictionary); require("./replace").register(dictionary); +require("./standby").register(dictionary); require("./stop").register(dictionary); require("./team").register(dictionary); -require("./msg").register(dictionary); +require("./updated").register(dictionary); export default dictionary; diff --git a/src/commands/next.js b/src/commands/next.js new file mode 100644 index 0000000..a61474f --- /dev/null +++ b/src/commands/next.js @@ -0,0 +1,22 @@ +// noinspection SpellCheckingInspection +export const NEXT_COMMAND = "NEXT"; +const LENGTH = 1; + +export const register = (commandDictionary) => { + commandDictionary[NEXT_COMMAND] = parse; + return commandDictionary; +} + +const parse = (tokens) => { + const localTokens = tokens || []; + const length = localTokens.length; + + if (length !== LENGTH) { + throw new Error(`Incompatible command tokens for >${NEXT_COMMAND}<. Expected ${LENGTH}, Got: ${length}`); + } + + return { + "command": NEXT_COMMAND, + "piste": localTokens.shift(), + }; +} diff --git a/src/commands/prev.js b/src/commands/prev.js new file mode 100644 index 0000000..2759257 --- /dev/null +++ b/src/commands/prev.js @@ -0,0 +1,22 @@ +// noinspection SpellCheckingInspection +export const PREV_COMMAND = "PREV"; +const LENGTH = 1; + +export const register = (commandDictionary) => { + commandDictionary[PREV_COMMAND] = parse; + return commandDictionary; +} + +const parse = (tokens) => { + const localTokens = tokens || []; + const length = localTokens.length; + + if (length !== LENGTH) { + throw new Error(`Incompatible command tokens for >${PREV_COMMAND}<. Expected ${LENGTH}, Got: ${length}`); + } + + return { + "command": PREV_COMMAND, + "piste": localTokens.shift(), + }; +} diff --git a/src/commands/standby.js b/src/commands/standby.js new file mode 100644 index 0000000..71e4e9e --- /dev/null +++ b/src/commands/standby.js @@ -0,0 +1,22 @@ +// noinspection SpellCheckingInspection +export const STANDBY_COMMAND = "STANDBY"; +const LENGTH = 1; + +export const register = (commandDictionary) => { + commandDictionary[STANDBY_COMMAND] = parse; + return commandDictionary; +} + +const parse = (tokens) => { + const localTokens = tokens || []; + const length = localTokens.length; + + if (length !== LENGTH) { + throw new Error(`Incompatible command tokens for >${STANDBY_COMMAND}<. Expected ${LENGTH}, Got: ${length}`); + } + + return { + "command": STANDBY_COMMAND, + "piste": localTokens.shift(), + }; +} diff --git a/src/commands/updated.js b/src/commands/updated.js new file mode 100644 index 0000000..7024ed5 --- /dev/null +++ b/src/commands/updated.js @@ -0,0 +1,23 @@ +// noinspection SpellCheckingInspection +export const UPDATED_COMMAND = "UPDATED"; +const LENGTH = 2; + +export const register = (commandDictionary) => { + commandDictionary[UPDATED_COMMAND] = parse; + return commandDictionary; +} + +const parse = (tokens) => { + const localTokens = tokens || []; + const length = localTokens.length; + + if (length !== LENGTH) { + throw new Error(`Incompatible command tokens for >${UPDATED_COMMAND}<. Expected ${LENGTH}, Got: ${length}`); + } + + return { + "command": UPDATED_COMMAND, + "eventId": localTokens.shift(), + "competitionCode": localTokens.shift(), + }; +} diff --git a/tests/commands/broken.test.js b/tests/commands/broken.test.js new file mode 100644 index 0000000..2d8d4ef --- /dev/null +++ b/tests/commands/broken.test.js @@ -0,0 +1,37 @@ +import {BROKEN_COMMAND, register} from "../../src/commands/broken"; + +describe('#register', () => { + test('basic registration should work', () => { + const commandDictionary = {}; + register(commandDictionary); + expect(typeof commandDictionary[BROKEN_COMMAND]).toEqual('function'); + }); +}); + +describe('#parse', () => { + const parse = register({})[BROKEN_COMMAND]; + let parsedResult = null; + + describe('invalid length of tokens', function () { + test('problem with token length for this parser', () => { + expect(() => { parse(["HI", "THERE"]); }) + .toThrowError(`Incompatible command tokens for >${BROKEN_COMMAND}<. Expected 1, Got: 2`); + }); + test('no issue with null tokens', () => { + expect(() => { parse(null); }) + .toThrowError(`Incompatible command tokens for >${BROKEN_COMMAND}<. Expected 1, Got: 0`); + }); + }); + + describe("valid parameters", () => { + beforeEach(() => { + parsedResult = parse(['3']); + }); + test('has the command in the result', () => { + expect(parsedResult['command']).toEqual(BROKEN_COMMAND); + }); + test('reads the piste', () => { + expect(parsedResult['piste']).toEqual('3'); + }); + }); +}); diff --git a/tests/commands/next.test.js b/tests/commands/next.test.js new file mode 100644 index 0000000..4b761c4 --- /dev/null +++ b/tests/commands/next.test.js @@ -0,0 +1,37 @@ +import {NEXT_COMMAND, register} from "../../src/commands/next"; + +describe('#register', () => { + test('basic registration should work', () => { + const commandDictionary = {}; + register(commandDictionary); + expect(typeof commandDictionary[NEXT_COMMAND]).toEqual('function'); + }); +}); + +describe('#parse', () => { + const parse = register({})[NEXT_COMMAND]; + let parsedResult = null; + + describe('invalid length of tokens', function () { + test('problem with token length for this parser', () => { + expect(() => { parse(["HI", "THERE"]); }) + .toThrowError(`Incompatible command tokens for >${NEXT_COMMAND}<. Expected 1, Got: 2`); + }); + test('no issue with null tokens', () => { + expect(() => { parse(null); }) + .toThrowError(`Incompatible command tokens for >${NEXT_COMMAND}<. Expected 1, Got: 0`); + }); + }); + + describe("valid parameters", () => { + beforeEach(() => { + parsedResult = parse(['RED']); + }); + test('has the command in the result', () => { + expect(parsedResult['command']).toEqual(NEXT_COMMAND); + }); + test('reads the piste', () => { + expect(parsedResult['piste']).toEqual('RED'); + }); + }); +}); diff --git a/tests/commands/prev.test.js b/tests/commands/prev.test.js new file mode 100644 index 0000000..b7edb5e --- /dev/null +++ b/tests/commands/prev.test.js @@ -0,0 +1,37 @@ +import {PREV_COMMAND, register} from "../../src/commands/prev"; + +describe('#register', () => { + test('basic registration should work', () => { + const commandDictionary = {}; + register(commandDictionary); + expect(typeof commandDictionary[PREV_COMMAND]).toEqual('function'); + }); +}); + +describe('#parse', () => { + const parse = register({})[PREV_COMMAND]; + let parsedResult = null; + + describe('invalid length of tokens', function () { + test('problem with token length for this parser', () => { + expect(() => { parse(["HI", "THERE"]); }) + .toThrowError(`Incompatible command tokens for >${PREV_COMMAND}<. Expected 1, Got: 2`); + }); + test('no issue with null tokens', () => { + expect(() => { parse(null); }) + .toThrowError(`Incompatible command tokens for >${PREV_COMMAND}<. Expected 1, Got: 0`); + }); + }); + + describe("valid parameters", () => { + beforeEach(() => { + parsedResult = parse(['1']); + }); + test('has the command in the result', () => { + expect(parsedResult['command']).toEqual(PREV_COMMAND); + }); + test('reads the piste', () => { + expect(parsedResult['piste']).toEqual('1'); + }); + }); +}); diff --git a/tests/commands/standby.test.js b/tests/commands/standby.test.js new file mode 100644 index 0000000..9f3b539 --- /dev/null +++ b/tests/commands/standby.test.js @@ -0,0 +1,37 @@ +import {STANDBY_COMMAND, register} from "../../src/commands/standby"; + +describe('#register', () => { + test('basic registration should work', () => { + const commandDictionary = {}; + register(commandDictionary); + expect(typeof commandDictionary[STANDBY_COMMAND]).toEqual('function'); + }); +}); + +describe('#parse', () => { + const parse = register({})[STANDBY_COMMAND]; + let parsedResult = null; + + describe('invalid length of tokens', function () { + test('problem with token length for this parser', () => { + expect(() => { parse(["HI", "THERE"]); }) + .toThrowError(`Incompatible command tokens for >${STANDBY_COMMAND}<. Expected 1, Got: 2`); + }); + test('no issue with null tokens', () => { + expect(() => { parse(null); }) + .toThrowError(`Incompatible command tokens for >${STANDBY_COMMAND}<. Expected 1, Got: 0`); + }); + }); + + describe("valid parameters", () => { + beforeEach(() => { + parsedResult = parse(['FINAL']); + }); + test('has the command in the result', () => { + expect(parsedResult['command']).toEqual(STANDBY_COMMAND); + }); + test('reads the piste', () => { + expect(parsedResult['piste']).toEqual('FINAL'); + }); + }); +}); diff --git a/tests/commands/updated.test.js b/tests/commands/updated.test.js new file mode 100644 index 0000000..b63ea17 --- /dev/null +++ b/tests/commands/updated.test.js @@ -0,0 +1,40 @@ +import {UPDATED_COMMAND, register} from "../../src/commands/updated"; + +describe('#register', () => { + test('basic registration should work', () => { + const commandDictionary = {}; + register(commandDictionary); + expect(typeof commandDictionary[UPDATED_COMMAND]).toEqual('function'); + }); +}); + +describe('#parse', () => { + const parse = register({})[UPDATED_COMMAND]; + let parsedResult = null; + + describe('invalid length of tokens', function () { + test('problem with token length for this parser', () => { + expect(() => { parse(["HI"]); }) + .toThrowError(`Incompatible command tokens for >${UPDATED_COMMAND}<. Expected 2, Got: 1`); + }); + test('no issue with null tokens', () => { + expect(() => { parse(null); }) + .toThrowError(`Incompatible command tokens for >${UPDATED_COMMAND}<. Expected 2, Got: 0`); + }); + }); + + describe("valid parameters", () => { + beforeEach(() => { + parsedResult = parse(['23', 'EIM']); + }); + test('has the command in the result', () => { + expect(parsedResult['command']).toEqual(UPDATED_COMMAND); + }); + test('reads the event id', () => { + expect(parsedResult['eventId']).toEqual('23'); + }); + test('reads the competition code', () => { + expect(parsedResult['competitionCode']).toEqual('EIM'); + }); + }); +}); From 2429107abcefa53638d038e73ee02e6c7fe0e70b Mon Sep 17 00:00:00 2001 From: James Seigel Date: Tue, 3 Mar 2026 21:51:52 -0700 Subject: [PATCH 05/10] feat: implement INFO command Adds parser and tests for the EFP2 INFO command, the primary piste state message. Parses 42 tokens covering bout metadata (piste, event, phase, round, state, stopwatch), referee flags (remote control, priority, calls for technician/video/doctor/DT, reverse, standby), and full scoring data for both fencers (id, name, nation, score, cards, lamps, video reviews). Co-Authored-By: Claude Sonnet 4.6 --- src/commands/index.js | 1 + src/commands/info.js | 67 ++++++++++++++ tests/commands/info.test.js | 170 ++++++++++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+) create mode 100644 src/commands/info.js create mode 100644 tests/commands/info.test.js diff --git a/src/commands/index.js b/src/commands/index.js index 2e7b151..641f9be 100644 --- a/src/commands/index.js +++ b/src/commands/index.js @@ -3,6 +3,7 @@ require("./boutstop").register(dictionary); require("./broken").register(dictionary); require("./disp").register(dictionary); require("./hello").register(dictionary); +require("./info").register(dictionary); require("./msg").register(dictionary); require("./next").register(dictionary); require("./ping").register(dictionary); diff --git a/src/commands/info.js b/src/commands/info.js new file mode 100644 index 0000000..0e630f4 --- /dev/null +++ b/src/commands/info.js @@ -0,0 +1,67 @@ +// noinspection SpellCheckingInspection +export const INFO_COMMAND = "INFO"; +const LENGTH = 42; + +export const register = (commandDictionary) => { + commandDictionary[INFO_COMMAND] = parse; + return commandDictionary; +} + +const parse = (tokens) => { + const localTokens = tokens || []; + const length = localTokens.length; + + if (length !== LENGTH) { + throw new Error(`Incompatible command tokens for >${INFO_COMMAND}<. Expected ${LENGTH}, Got: ${length}`); + } + + return { + "command": INFO_COMMAND, + "piste": localTokens.shift(), + "eventId": localTokens.shift(), + "competitionCode": localTokens.shift(), + "competitionPhase": localTokens.shift(), + "boutOrderInPhase": localTokens.shift(), + "roundNumber": localTokens.shift(), + "boutId": localTokens.shift(), + "beginTime": localTokens.shift(), + "stopwatch": localTokens.shift(), + "state": localTokens.shift(), + "refereeRemoteControl": localTokens.shift(), + "priority": localTokens.shift(), + "callTechnician": localTokens.shift(), + "callVideoEngineer": localTokens.shift(), + "callDoctor": localTokens.shift(), + "callDT": localTokens.shift(), + "reverse": localTokens.shift(), + "standby": localTokens.shift(), + "rightFencer": { + "id": localTokens.shift(), + "name": localTokens.shift(), + "teamInfo": localTokens.shift(), + "teamMemberId": localTokens.shift(), + "teamMemberName": localTokens.shift(), + "score": localTokens.shift(), + "yellowCard": localTokens.shift(), + "redCards": localTokens.shift(), + "blackCard": localTokens.shift(), + "usedVideo": localTokens.shift(), + "lamp": localTokens.shift(), + "whiteLamp": localTokens.shift(), + }, + "leftFencer": { + "id": localTokens.shift(), + "name": localTokens.shift(), + "teamInfo": localTokens.shift(), + "teamMemberId": localTokens.shift(), + "teamMemberName": localTokens.shift(), + "score": localTokens.shift(), + "yellowCard": localTokens.shift(), + "redCards": localTokens.shift(), + "blackCard": localTokens.shift(), + "usedVideo": localTokens.shift(), + "lamp": localTokens.shift(), + "whiteLamp": localTokens.shift(), + }, + }; +} diff --git a/tests/commands/info.test.js b/tests/commands/info.test.js new file mode 100644 index 0000000..8b68edf --- /dev/null +++ b/tests/commands/info.test.js @@ -0,0 +1,170 @@ +import {INFO_COMMAND, register} from "../../src/commands/info"; + +const EXAMPLE_INFO_TOKENS = [ + 'RED', // piste + '24', // eventId + 'EIM', // competitionCode + 'T32', // competitionPhase + '1', // boutOrderInPhase + '1', // roundNumber + '32', // boutId + '14:45', // beginTime + '3:00', // stopwatch + 'F', // state (Fencing) + '0', // refereeRemoteControl + 'N', // priority + '0', // callTechnician + '0', // callVideoEngineer + '0', // callDoctor + '0', // callDT + '0', // reverse + '0', // standby + '33', // right id + 'IVANOV Sidor', // right name + 'RUS', // right teamInfo + '', // right teamMemberId + '', // right teamMemberName + '5', // right score + '0', // right yellowCard + '0', // right redCards + '0', // right blackCard + '0', // right usedVideo + '1', // right lamp + '0', // right whiteLamp + '531', // left id + 'LIMON Jua', // left name + 'FRA', // left teamInfo + '', // left teamMemberId + '', // left teamMemberName + '3', // left score + '1', // left yellowCard + '0', // left redCards + '0', // left blackCard + '1', // left usedVideo + '0', // left lamp + '0', // left whiteLamp +]; + +describe('#register', () => { + test('basic registration should work', () => { + const commandDictionary = {}; + register(commandDictionary); + expect(typeof commandDictionary[INFO_COMMAND]).toEqual('function'); + }); +}); + +describe('#parse', () => { + const parse = register({})[INFO_COMMAND]; + let parsedResult = null; + + describe('invalid length of tokens', function () { + test('problem with token length for this parser', () => { + expect(() => { parse(["HI", "THERE"]); }) + .toThrowError(`Incompatible command tokens for >${INFO_COMMAND}<. Expected 42, Got: 2`); + }); + test('no issue with null tokens', () => { + expect(() => { parse(null); }) + .toThrowError(`Incompatible command tokens for >${INFO_COMMAND}<. Expected 42, Got: 0`); + }); + }); + + describe("valid tokens", () => { + beforeEach(() => { + parsedResult = parse([...EXAMPLE_INFO_TOKENS]); + }); + + test('has the command in the result', () => { + expect(parsedResult['command']).toEqual(INFO_COMMAND); + }); + test('reads the piste', () => { + expect(parsedResult['piste']).toEqual('RED'); + }); + test('reads the event id', () => { + expect(parsedResult['eventId']).toEqual('24'); + }); + test('reads the competition code', () => { + expect(parsedResult['competitionCode']).toEqual('EIM'); + }); + test('reads the competition phase', () => { + expect(parsedResult['competitionPhase']).toEqual('T32'); + }); + test('reads the bout order in phase', () => { + expect(parsedResult['boutOrderInPhase']).toEqual('1'); + }); + test('reads the round number', () => { + expect(parsedResult['roundNumber']).toEqual('1'); + }); + test('reads the bout id', () => { + expect(parsedResult['boutId']).toEqual('32'); + }); + test('reads the begin time', () => { + expect(parsedResult['beginTime']).toEqual('14:45'); + }); + test('reads the stopwatch', () => { + expect(parsedResult['stopwatch']).toEqual('3:00'); + }); + test('reads the state', () => { + expect(parsedResult['state']).toEqual('F'); + }); + test('reads the referee remote control', () => { + expect(parsedResult['refereeRemoteControl']).toEqual('0'); + }); + test('reads the priority', () => { + expect(parsedResult['priority']).toEqual('N'); + }); + test('reads call technician', () => { + expect(parsedResult['callTechnician']).toEqual('0'); + }); + test('reads call video engineer', () => { + expect(parsedResult['callVideoEngineer']).toEqual('0'); + }); + test('reads call doctor', () => { + expect(parsedResult['callDoctor']).toEqual('0'); + }); + test('reads call DT', () => { + expect(parsedResult['callDT']).toEqual('0'); + }); + test('reads reverse', () => { + expect(parsedResult['reverse']).toEqual('0'); + }); + test('reads standby', () => { + expect(parsedResult['standby']).toEqual('0'); + }); + + describe('right fencer', () => { + let rightFencer = null; + beforeEach(() => { rightFencer = parsedResult['rightFencer']; }); + + test('id', () => { expect(rightFencer['id']).toEqual('33'); }); + test('name', () => { expect(rightFencer['name']).toEqual('IVANOV Sidor'); }); + test('team info', () => { expect(rightFencer['teamInfo']).toEqual('RUS'); }); + test('team member id', () => { expect(rightFencer['teamMemberId']).toEqual(''); }); + test('team member name', () => { expect(rightFencer['teamMemberName']).toEqual(''); }); + test('score', () => { expect(rightFencer['score']).toEqual('5'); }); + test('yellow card', () => { expect(rightFencer['yellowCard']).toEqual('0'); }); + test('red cards', () => { expect(rightFencer['redCards']).toEqual('0'); }); + test('black card', () => { expect(rightFencer['blackCard']).toEqual('0'); }); + test('used video', () => { expect(rightFencer['usedVideo']).toEqual('0'); }); + test('lamp', () => { expect(rightFencer['lamp']).toEqual('1'); }); + test('white lamp', () => { expect(rightFencer['whiteLamp']).toEqual('0'); }); + }); + + describe('left fencer', () => { + let leftFencer = null; + beforeEach(() => { leftFencer = parsedResult['leftFencer']; }); + + test('id', () => { expect(leftFencer['id']).toEqual('531'); }); + test('name', () => { expect(leftFencer['name']).toEqual('LIMON Jua'); }); + test('team info', () => { expect(leftFencer['teamInfo']).toEqual('FRA'); }); + test('team member id', () => { expect(leftFencer['teamMemberId']).toEqual(''); }); + test('team member name', () => { expect(leftFencer['teamMemberName']).toEqual(''); }); + test('score', () => { expect(leftFencer['score']).toEqual('3'); }); + test('yellow card', () => { expect(leftFencer['yellowCard']).toEqual('1'); }); + test('red cards', () => { expect(leftFencer['redCards']).toEqual('0'); }); + test('black card', () => { expect(leftFencer['blackCard']).toEqual('0'); }); + test('used video', () => { expect(leftFencer['usedVideo']).toEqual('1'); }); + test('lamp', () => { expect(leftFencer['lamp']).toEqual('0'); }); + test('white lamp', () => { expect(leftFencer['whiteLamp']).toEqual('0'); }); + }); + }); +}); From 1e9d98dc817324fb5651733773dcd8b2eca88d79 Mon Sep 17 00:00:00 2001 From: James Seigel Date: Tue, 3 Mar 2026 21:54:17 -0700 Subject: [PATCH 06/10] feat: implement ACK, NAK, GETTEAM, and DENY commands Completes the full EFP2 command set: - ACK/NAK: zero-param bout result acknowledgements from server to master - GETTEAM: request team list from Server of Results (piste, side) - DENY: rejection response with reason string Co-Authored-By: Claude Sonnet 4.6 --- src/commands/ack.js | 21 ++++++++++++++++++ src/commands/deny.js | 22 +++++++++++++++++++ src/commands/getteam.js | 23 +++++++++++++++++++ src/commands/index.js | 4 ++++ src/commands/nak.js | 21 ++++++++++++++++++ tests/commands/ack.test.js | 33 ++++++++++++++++++++++++++++ tests/commands/deny.test.js | 37 +++++++++++++++++++++++++++++++ tests/commands/getteam.test.js | 40 ++++++++++++++++++++++++++++++++++ tests/commands/nak.test.js | 33 ++++++++++++++++++++++++++++ 9 files changed, 234 insertions(+) create mode 100644 src/commands/ack.js create mode 100644 src/commands/deny.js create mode 100644 src/commands/getteam.js create mode 100644 src/commands/nak.js create mode 100644 tests/commands/ack.test.js create mode 100644 tests/commands/deny.test.js create mode 100644 tests/commands/getteam.test.js create mode 100644 tests/commands/nak.test.js diff --git a/src/commands/ack.js b/src/commands/ack.js new file mode 100644 index 0000000..fb0a6f1 --- /dev/null +++ b/src/commands/ack.js @@ -0,0 +1,21 @@ +// noinspection SpellCheckingInspection +export const ACK_COMMAND = "ACK"; +const LENGTH = 0; + +export const register = (commandDictionary) => { + commandDictionary[ACK_COMMAND] = parse; + return commandDictionary; +} + +const parse = (tokens) => { + const localTokens = tokens || []; + const length = localTokens.length; + + if (length !== LENGTH) { + throw new Error(`Incompatible command tokens for >${ACK_COMMAND}<. Expected ${LENGTH}, Got: ${length}`); + } + + return { + "command": ACK_COMMAND, + }; +} diff --git a/src/commands/deny.js b/src/commands/deny.js new file mode 100644 index 0000000..3841202 --- /dev/null +++ b/src/commands/deny.js @@ -0,0 +1,22 @@ +// noinspection SpellCheckingInspection +export const DENY_COMMAND = "DENY"; +const LENGTH = 1; + +export const register = (commandDictionary) => { + commandDictionary[DENY_COMMAND] = parse; + return commandDictionary; +} + +const parse = (tokens) => { + const localTokens = tokens || []; + const length = localTokens.length; + + if (length !== LENGTH) { + throw new Error(`Incompatible command tokens for >${DENY_COMMAND}<. Expected ${LENGTH}, Got: ${length}`); + } + + return { + "command": DENY_COMMAND, + "reason": localTokens.shift(), + }; +} diff --git a/src/commands/getteam.js b/src/commands/getteam.js new file mode 100644 index 0000000..4ced1b4 --- /dev/null +++ b/src/commands/getteam.js @@ -0,0 +1,23 @@ +// noinspection SpellCheckingInspection +export const GETTEAM_COMMAND = "GETTEAM"; +const LENGTH = 2; + +export const register = (commandDictionary) => { + commandDictionary[GETTEAM_COMMAND] = parse; + return commandDictionary; +} + +const parse = (tokens) => { + const localTokens = tokens || []; + const length = localTokens.length; + + if (length !== LENGTH) { + throw new Error(`Incompatible command tokens for >${GETTEAM_COMMAND}<. Expected ${LENGTH}, Got: ${length}`); + } + + return { + "command": GETTEAM_COMMAND, + "piste": localTokens.shift(), + "side": localTokens.shift(), + }; +} diff --git a/src/commands/index.js b/src/commands/index.js index 641f9be..c373807 100644 --- a/src/commands/index.js +++ b/src/commands/index.js @@ -1,10 +1,14 @@ const dictionary = {} +require("./ack").register(dictionary); require("./boutstop").register(dictionary); require("./broken").register(dictionary); +require("./deny").register(dictionary); require("./disp").register(dictionary); +require("./getteam").register(dictionary); require("./hello").register(dictionary); require("./info").register(dictionary); require("./msg").register(dictionary); +require("./nak").register(dictionary); require("./next").register(dictionary); require("./ping").register(dictionary); require("./prev").register(dictionary); diff --git a/src/commands/nak.js b/src/commands/nak.js new file mode 100644 index 0000000..978dbe2 --- /dev/null +++ b/src/commands/nak.js @@ -0,0 +1,21 @@ +// noinspection SpellCheckingInspection +export const NAK_COMMAND = "NAK"; +const LENGTH = 0; + +export const register = (commandDictionary) => { + commandDictionary[NAK_COMMAND] = parse; + return commandDictionary; +} + +const parse = (tokens) => { + const localTokens = tokens || []; + const length = localTokens.length; + + if (length !== LENGTH) { + throw new Error(`Incompatible command tokens for >${NAK_COMMAND}<. Expected ${LENGTH}, Got: ${length}`); + } + + return { + "command": NAK_COMMAND, + }; +} diff --git a/tests/commands/ack.test.js b/tests/commands/ack.test.js new file mode 100644 index 0000000..6775bb0 --- /dev/null +++ b/tests/commands/ack.test.js @@ -0,0 +1,33 @@ +import {ACK_COMMAND, register} from "../../src/commands/ack"; + +describe('#register', () => { + test('basic registration should work', () => { + const commandDictionary = {}; + register(commandDictionary); + expect(typeof commandDictionary[ACK_COMMAND]).toEqual('function'); + }); +}); + +describe('#parse', () => { + const parse = register({})[ACK_COMMAND]; + let parsedResult = null; + + describe('invalid length of tokens', function () { + test('problem with token length for this parser', () => { + expect(() => { parse(["HI"]); }) + .toThrowError(`Incompatible command tokens for >${ACK_COMMAND}<. Expected 0, Got: 1`); + }); + test('no issue with null tokens', () => { + expect(() => { parse(null); }).not.toThrow(); + }); + }); + + describe("valid parameters", () => { + beforeEach(() => { + parsedResult = parse([]); + }); + test('has the command in the result', () => { + expect(parsedResult['command']).toEqual(ACK_COMMAND); + }); + }); +}); diff --git a/tests/commands/deny.test.js b/tests/commands/deny.test.js new file mode 100644 index 0000000..a1321fe --- /dev/null +++ b/tests/commands/deny.test.js @@ -0,0 +1,37 @@ +import {DENY_COMMAND, register} from "../../src/commands/deny"; + +describe('#register', () => { + test('basic registration should work', () => { + const commandDictionary = {}; + register(commandDictionary); + expect(typeof commandDictionary[DENY_COMMAND]).toEqual('function'); + }); +}); + +describe('#parse', () => { + const parse = register({})[DENY_COMMAND]; + let parsedResult = null; + + describe('invalid length of tokens', function () { + test('problem with token length for this parser', () => { + expect(() => { parse(["HI", "THERE"]); }) + .toThrowError(`Incompatible command tokens for >${DENY_COMMAND}<. Expected 1, Got: 2`); + }); + test('no issue with null tokens', () => { + expect(() => { parse(null); }) + .toThrowError(`Incompatible command tokens for >${DENY_COMMAND}<. Expected 1, Got: 0`); + }); + }); + + describe("valid parameters", () => { + beforeEach(() => { + parsedResult = parse(['Piste does not exists']); + }); + test('has the command in the result', () => { + expect(parsedResult['command']).toEqual(DENY_COMMAND); + }); + test('reads the reason', () => { + expect(parsedResult['reason']).toEqual('Piste does not exists'); + }); + }); +}); diff --git a/tests/commands/getteam.test.js b/tests/commands/getteam.test.js new file mode 100644 index 0000000..3119646 --- /dev/null +++ b/tests/commands/getteam.test.js @@ -0,0 +1,40 @@ +import {GETTEAM_COMMAND, register} from "../../src/commands/getteam"; + +describe('#register', () => { + test('basic registration should work', () => { + const commandDictionary = {}; + register(commandDictionary); + expect(typeof commandDictionary[GETTEAM_COMMAND]).toEqual('function'); + }); +}); + +describe('#parse', () => { + const parse = register({})[GETTEAM_COMMAND]; + let parsedResult = null; + + describe('invalid length of tokens', function () { + test('problem with token length for this parser', () => { + expect(() => { parse(["HI"]); }) + .toThrowError(`Incompatible command tokens for >${GETTEAM_COMMAND}<. Expected 2, Got: 1`); + }); + test('no issue with null tokens', () => { + expect(() => { parse(null); }) + .toThrowError(`Incompatible command tokens for >${GETTEAM_COMMAND}<. Expected 2, Got: 0`); + }); + }); + + describe("valid parameters", () => { + beforeEach(() => { + parsedResult = parse(['RED', 'LEFT']); + }); + test('has the command in the result', () => { + expect(parsedResult['command']).toEqual(GETTEAM_COMMAND); + }); + test('reads the piste', () => { + expect(parsedResult['piste']).toEqual('RED'); + }); + test('reads the side', () => { + expect(parsedResult['side']).toEqual('LEFT'); + }); + }); +}); diff --git a/tests/commands/nak.test.js b/tests/commands/nak.test.js new file mode 100644 index 0000000..94b7e52 --- /dev/null +++ b/tests/commands/nak.test.js @@ -0,0 +1,33 @@ +import {NAK_COMMAND, register} from "../../src/commands/nak"; + +describe('#register', () => { + test('basic registration should work', () => { + const commandDictionary = {}; + register(commandDictionary); + expect(typeof commandDictionary[NAK_COMMAND]).toEqual('function'); + }); +}); + +describe('#parse', () => { + const parse = register({})[NAK_COMMAND]; + let parsedResult = null; + + describe('invalid length of tokens', function () { + test('problem with token length for this parser', () => { + expect(() => { parse(["HI"]); }) + .toThrowError(`Incompatible command tokens for >${NAK_COMMAND}<. Expected 0, Got: 1`); + }); + test('no issue with null tokens', () => { + expect(() => { parse(null); }).not.toThrow(); + }); + }); + + describe("valid parameters", () => { + beforeEach(() => { + parsedResult = parse([]); + }); + test('has the command in the result', () => { + expect(parsedResult['command']).toEqual(NAK_COMMAND); + }); + }); +}); From 73e4ab3d4acf19ca3592aa522b481813052cfebf Mon Sep 17 00:00:00 2001 From: James Seigel Date: Tue, 3 Mar 2026 21:56:54 -0700 Subject: [PATCH 07/10] test: expand cyrano protocol round-trip tests Adds round-trip tests for all 18 EFP2 commands through process(), an unknown command test, fixes three mislabelled test names ('ping' was used for STOP and MSG), and removes the stale skipped DISP test that referenced an outdated output shape. Co-Authored-By: Claude Sonnet 4.6 --- tests/protocol/cyrano.test.js | 151 ++++++++++++++++++++++------------ 1 file changed, 98 insertions(+), 53 deletions(-) diff --git a/tests/protocol/cyrano.test.js b/tests/protocol/cyrano.test.js index be81032..9f37eca 100644 --- a/tests/protocol/cyrano.test.js +++ b/tests/protocol/cyrano.test.js @@ -1,79 +1,124 @@ import {process} from "../../src/protocol/cyrano"; +import {ACK_COMMAND} from "../../src/commands/ack"; +import {BOUTSTOP_COMMAND} from "../../src/commands/boutstop"; +import {BROKEN_COMMAND} from "../../src/commands/broken"; +import {DENY_COMMAND} from "../../src/commands/deny"; import {DISP_COMMAND} from "../../src/commands/disp"; +import {GETTEAM_COMMAND} from "../../src/commands/getteam"; import {HELLO_COMMAND} from "../../src/commands/hello"; +import {INFO_COMMAND} from "../../src/commands/info"; +import {MSG_COMMAND} from "../../src/commands/msg"; +import {NAK_COMMAND} from "../../src/commands/nak"; +import {NEXT_COMMAND} from "../../src/commands/next"; import {PING_COMMAND} from "../../src/commands/ping"; +import {PREV_COMMAND} from "../../src/commands/prev"; +import {REPLACE_COMMAND} from "../../src/commands/replace"; +import {STANDBY_COMMAND} from "../../src/commands/standby"; import {STOP_COMMAND} from "../../src/commands/stop"; -import {MSG_COMMAND} from "../../src/commands/msg"; +import {TEAM_COMMAND} from "../../src/commands/team"; +import {UPDATED_COMMAND} from "../../src/commands/updated"; -const DISP_COMMAND_EXAMPLE = "|EFP2|DISP|RED|24|EIM|T32|1|32|14:45|3:00|33| IVANOV Sidor|CAN|||531|LIMON Jua|FRA|||"; -const HELLO_COMMAND_EXAMPLE = "|EFP2|HELLO|RED|"; -const PING_COMMAND_EXAMPLE = "|EFP2|PING|"; -const STOP_COMMAND_EXAMPLE = "|EFP2|STOP|"; -const MSG_COMMAND_EXAMPLE = "|EFP2|MSG|BLUE|Glove missing|"; +const ACK_EXAMPLE = "|EFP2|ACK|"; +const BOUTSTOP_EXAMPLE = "|EFP2|BOUTSTOP|RED|"; +const BROKEN_EXAMPLE = "|EFP2|BROKEN|3|"; +const DENY_EXAMPLE = "|EFP2|DENY|Piste does not exists|"; +const DISP_EXAMPLE = "|EFP2|DISP|RED|24|EIM|T32|1|32|14:45|3:00|33| IVANOV Sidor|CAN|||531|LIMON Jua|FRA|||"; +const GETTEAM_EXAMPLE = "|EFP2|GETTEAM|RED|LEFT|"; +const HELLO_EXAMPLE = "|EFP2|HELLO|RED|"; +const INFO_EXAMPLE = "|EFP2|INFO|RED|24|EIM|T32|1|1|32|14:45|3:00|F|0|N|0|0|0|0|0|0|33|IVANOV Sidor|RUS|||5|0|0|0|0|1|0|531|LIMON Jua|FRA|||3|1|0|0|1|0|0|"; +const MSG_EXAMPLE = "|EFP2|MSG|BLUE|Glove missing|"; +const NAK_EXAMPLE = "|EFP2|NAK|"; +const NEXT_EXAMPLE = "|EFP2|NEXT|RED|"; +const PING_EXAMPLE = "|EFP2|PING|"; +const PREV_EXAMPLE = "|EFP2|PREV|1|"; +const REPLACE_EXAMPLE = "|EFP2|REPLACE|RED|LEFT|1|"; +const STANDBY_EXAMPLE = "|EFP2|STANDBY|FINAL|"; +const STOP_EXAMPLE = "|EFP2|STOP|"; +const TEAM_EXAMPLE = "|EFP2|TEAM|RED|LEFT|234|IVANOV Fedor|542|PETROV Ivan|43|SIDOROV Evgeny|2|OH Semen|1|2|3|2|1|3|3|1|2|435533|"; +const UPDATED_EXAMPLE = "|EFP2|UPDATED|23|EIM|"; describe('#process', () => { test('unsupported protocol', () => { expect( - () => { - process("|JAMES|DISP|NOT RELEVANT"); - } + () => { process("|JAMES|DISP|NOT RELEVANT"); } ).toThrowError("Unsupported protocol >JAMES<"); }); - test('extract command name', () => { + test('unknown command throws', () => { expect( - process(DISP_COMMAND_EXAMPLE)["command"] - ).toEqual( - DISP_COMMAND - ); + () => { process("|EFP2|FOOBAR|"); } + ).toThrow(); }); - test('extract command name for hello', () => { - expect( - process(HELLO_COMMAND_EXAMPLE)["command"] - ).toEqual( - HELLO_COMMAND - ); + test('extract command name for ACK', () => { + expect(process(ACK_EXAMPLE)["command"]).toEqual(ACK_COMMAND); }); - test('extract command name for ping', () => { - expect( - process(PING_COMMAND_EXAMPLE)["command"] - ).toEqual( - PING_COMMAND - ); + test('extract command name for BOUTSTOP', () => { + expect(process(BOUTSTOP_EXAMPLE)["command"]).toEqual(BOUTSTOP_COMMAND); }); - test('extract command name for ping', () => { - expect( - process(STOP_COMMAND_EXAMPLE)["command"] - ).toEqual( - STOP_COMMAND - ); + test('extract command name for BROKEN', () => { + expect(process(BROKEN_EXAMPLE)["command"]).toEqual(BROKEN_COMMAND); }); - test('extract command name for ping', () => { - expect( - process(MSG_COMMAND_EXAMPLE)["command"] - ).toEqual( - MSG_COMMAND - ); + test('extract command name for DENY', () => { + expect(process(DENY_EXAMPLE)["command"]).toEqual(DENY_COMMAND); }); - test.skip('test display function', () => { - expect( - process(DISP_COMMAND_EXAMPLE) - ).toEqual( - { - "command": "DISP", - "piste": "RED", - "eventId": "24", - "competitionCode": "EIM", - "competitionPhase": "T32", - "red": { - "fencer": "bob" - } - } - ); + test('extract command name for DISP', () => { + expect(process(DISP_EXAMPLE)["command"]).toEqual(DISP_COMMAND); + }); + + test('extract command name for GETTEAM', () => { + expect(process(GETTEAM_EXAMPLE)["command"]).toEqual(GETTEAM_COMMAND); + }); + + test('extract command name for HELLO', () => { + expect(process(HELLO_EXAMPLE)["command"]).toEqual(HELLO_COMMAND); + }); + + test('extract command name for INFO', () => { + expect(process(INFO_EXAMPLE)["command"]).toEqual(INFO_COMMAND); + }); + + test('extract command name for MSG', () => { + expect(process(MSG_EXAMPLE)["command"]).toEqual(MSG_COMMAND); + }); + + test('extract command name for NAK', () => { + expect(process(NAK_EXAMPLE)["command"]).toEqual(NAK_COMMAND); + }); + + test('extract command name for NEXT', () => { + expect(process(NEXT_EXAMPLE)["command"]).toEqual(NEXT_COMMAND); + }); + + test('extract command name for PING', () => { + expect(process(PING_EXAMPLE)["command"]).toEqual(PING_COMMAND); + }); + + test('extract command name for PREV', () => { + expect(process(PREV_EXAMPLE)["command"]).toEqual(PREV_COMMAND); + }); + + test('extract command name for REPLACE', () => { + expect(process(REPLACE_EXAMPLE)["command"]).toEqual(REPLACE_COMMAND); + }); + + test('extract command name for STANDBY', () => { + expect(process(STANDBY_EXAMPLE)["command"]).toEqual(STANDBY_COMMAND); + }); + + test('extract command name for STOP', () => { + expect(process(STOP_EXAMPLE)["command"]).toEqual(STOP_COMMAND); + }); + + test('extract command name for TEAM', () => { + expect(process(TEAM_EXAMPLE)["command"]).toEqual(TEAM_COMMAND); + }); + + test('extract command name for UPDATED', () => { + expect(process(UPDATED_EXAMPLE)["command"]).toEqual(UPDATED_COMMAND); }); }); From 74f10f2e4483dea45841fc415c0c0933c6951a8c Mon Sep 17 00:00:00 2001 From: James Seigel Date: Wed, 4 Mar 2026 09:48:51 -0700 Subject: [PATCH 08/10] Add builder/compose pattern for EFP2 packet generation (HELLO proof-of-concept) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a symmetric build/compose architecture that mirrors the existing parse/process pipeline. `build()` returns a token array, `registerBuilder()` registers it per command, and `compose()` at the protocol layer assembles the full |EFP2|COMMAND|...| wire string. Round-trip tests verify parse→compose fidelity for HELLO with and without a piste code. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 95 +++++++++++++++++++++++++++++++++++ src/commands/hello.js | 16 ++++++ src/commands/index.js | 3 ++ src/protocol/cyrano.js | 13 ++++- tests/commands/hello.test.js | 28 ++++++++++- tests/protocol/cyrano.test.js | 20 +++++++- 6 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7714339 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,95 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Cyrano is a Node.js service implementing the **EFP2 (Ethernet Fencing Protocol 2)** — a UDP-based protocol for networked fencing scoring machines. It bridges scoring machines (Masters) with competition management software (Managers), results servers, and display clients. + +## Commands + +```bash +npm test # Run all tests (requires --experimental-vm-modules) +npm test -- tests/path/to/file.test.js # Run a single test file +npm test -- --testNamePattern="pattern" # Run tests matching a name +npm run coverage # Run tests with coverage report +npm run build # Bundle with esbuild → build/ +npm start # Run built app +npm run clean # Remove node_modules and reinstall +``` + +## Architecture + +**Entry point:** `index.js` — UDP server on port 50100 + +**Protocol pipeline:** `cylex.js` (tokenizer) → `cyrano.js` (processor) → `commands/` (handlers) + +- `src/protocol/cyranoTokens.js` — Protocol constants (`EFP2`, `|` separator) +- `src/protocol/cylex.js` — Splits raw messages by `|` into token arrays +- `src/protocol/cyrano.js` — Validates EFP2 header, dispatches to command handlers +- `src/commands/index.js` — Auto-loads all command handlers from the `commands/` directory +- `src/commands/*.js` — Individual command handlers (HELLO, PING, STOP, MSG, DISP) + +## EFP2 Protocol Reference + +**Message format:** `|EFP2|COMMAND|param1|param2|...|` (ASCII, fields delimited by `|`) + +### Network Topology + +| Node | IP | Ports | +|------|----|-------| +| Scoring machines Server | 172.20.0.1 | 50100 (from Masters), 50103 (from Managers) | +| Backup Scoring machines Server | 172.20.0.2 | same as primary | +| Master (piste x) | 172.20.x.1 (x=1..60) | 50100 (from Servers), 50101 (from Additional devices) | +| Additional devices (piste x) | 172.20.x.y (y=1..255) | any | +| Manager | 172.20.0.y (y=9..32) | any | +| Server of the Results | 172.20.0.8 | 50100 (from Servers), 50103 (from Managers), 50104 (from Clients) | +| Clients | 172.20.129–254.x | any | + +### All Commands + +| Command | Description | Params | +|---------|-------------|--------| +| `HELLO` | Node is online | 0–1 (optional piste code) | +| `PING` | Check node presence; recipient must reply `\|EFP2\|HELLO\|\|` | 0 | +| `STOP` | Disconnect / stop receiving | 0 | +| `DISP` | Set new bout on piste | 18 (see below) | +| `INFO` | Piste state (score, time, lights, cards, etc.) | ~40 fields | +| `ACK` | Bout result accepted | 0 | +| `NAK` | Bout result rejected | 0 | +| `NEXT` | Referee requests next match | 1 (piste code) | +| `PREV` | Referee requests previous match | 1 (piste code) | +| `TEAM` | Team member list for one side | 19 (piste, side, 3 members + reserve, 9 round assignments, unique ID) | +| `GETTEAM` | Request team list from Server of Results | 2 (piste code, side) | +| `REPLACE` | Team member substitution | 3 (piste code, side, fencer number 1–3) | +| `BOUTSTOP` | Cancel current DISP / clear piste | 1 (piste code) | +| `MSG` | Text message to display | 2 (piste code or `ALL`, message text ≤128 chars) | +| `STANDBY` | Switch apparatus to sleep mode | 1 (piste code) | +| `BROKEN` | Lost contact with piste | 1 (piste code) | +| `DENY` | Deny a request | reason string | +| `UPDATED` | XML competition data updated | 2 (event ID, competition code) | + +### TEAM structure (20 tokens) +`members` is an array of 4: indices 0–2 are the active fencers, index 3 is the reserve. +`rounds` is a 9-element array of fencer numbers (1, 2, or 3) indicating who fences each round. + +### DISP fields (fields 3–20) +PisteCode, EventID, CompetitionCode, Phase, Order, BoutID, TimeBegin, Stopwatch, +Right ID, Right Name, Right Nation, Right MemberID, Right MemberName, +Left ID, Left Name, Left Nation, Left MemberID, Left MemberName + +### Piste codes +Numeric `1`–`59` for standard pistes; named `BLUE`, `YELLOW`, `GREEN`, `RED`, `FINAL` for final area pistes. + +### Competition codes +`EIM`/`EIW`/`ETM`/`ETW` (épée), `FIM`/`FIW`/`FTM`/`FTW` (foil), `SIM`/`SIW`/`STM`/`STW` (sabre), `MIX` + +### Master state machine +States: `Standalone` → `Indefinite` → `Not active` → `Halt`/`Fencing`/`Pause`/`Waiting`/`Ending` +- Enters `Not active` only on receiving `DISP` +- Enters `Ending` on manual bout deactivation; sends `INFO` every second for up to 4s awaiting `ACK`/`NAK` +- `ACK` → `Not active`; `NAK` or timeout → `Waiting` + +## Testing + +Tests mirror the `src/` structure under `tests/`. Jest runs with ES module support (`--experimental-vm-modules`). No transpilation is needed for tests — the project uses native ES modules. diff --git a/src/commands/hello.js b/src/commands/hello.js index e1ea8d1..8fd2682 100644 --- a/src/commands/hello.js +++ b/src/commands/hello.js @@ -8,6 +8,22 @@ export const register = (commandDictionary) => { return commandDictionary; } +export const registerBuilder = (builderDictionary) => { + builderDictionary[HELLO_COMMAND] = build; + return builderDictionary; +} + +export const build = ({ pisteCode }) => { + const params = pisteCode ? [pisteCode] : []; + const length = params.length; + + if (length < MIN_LENGTH || length > MAX_LENGTH) { + throw new Error(`Incompatible build params for >${HELLO_COMMAND}<. Expected ${MIN_LENGTH} or ${MAX_LENGTH}, Got: ${length}`); + } + + return [HELLO_COMMAND, ...params]; +} + const parse = (tokens) => { const localTokens = tokens || []; const length = localTokens.length; diff --git a/src/commands/index.js b/src/commands/index.js index c373807..03484ff 100644 --- a/src/commands/index.js +++ b/src/commands/index.js @@ -1,4 +1,5 @@ const dictionary = {} +const builders = {} require("./ack").register(dictionary); require("./boutstop").register(dictionary); require("./broken").register(dictionary); @@ -6,6 +7,7 @@ require("./deny").register(dictionary); require("./disp").register(dictionary); require("./getteam").register(dictionary); require("./hello").register(dictionary); +require("./hello").registerBuilder(builders); require("./info").register(dictionary); require("./msg").register(dictionary); require("./nak").register(dictionary); @@ -18,3 +20,4 @@ require("./stop").register(dictionary); require("./team").register(dictionary); require("./updated").register(dictionary); export default dictionary; +export { builders }; diff --git a/src/protocol/cyrano.js b/src/protocol/cyrano.js index 3940bce..bcede34 100644 --- a/src/protocol/cyrano.js +++ b/src/protocol/cyrano.js @@ -1,7 +1,7 @@ // main class to take a packet of information and break it into import {tokenize} from "./cylex"; -import {PROTOCOL} from "./cyranoTokens"; -import commandDictionary from "../commands"; +import {PROTOCOL, PROTOCOL_SEPERATOR} from "./cyranoTokens"; +import commandDictionary, {builders as builderDictionary} from "../commands"; export const process = (rawMessage) => { const tokens = tokenize(rawMessage); @@ -16,3 +16,12 @@ export const process = (rawMessage) => { throw new Error(`Unsupported protocol >${protocolToken}<`); } } + +export const compose = (commandObject) => { + const build = builderDictionary[commandObject.command]; + if (!build) { + throw new Error(`No builder registered for command >${commandObject.command}<`); + } + const tokens = build(commandObject); + return PROTOCOL_SEPERATOR + [PROTOCOL, ...tokens].join(PROTOCOL_SEPERATOR) + PROTOCOL_SEPERATOR; +} diff --git a/tests/commands/hello.test.js b/tests/commands/hello.test.js index 13da9dc..28f6a4c 100644 --- a/tests/commands/hello.test.js +++ b/tests/commands/hello.test.js @@ -1,4 +1,4 @@ -import {HELLO_COMMAND, register} from "../../src/commands/hello"; +import {HELLO_COMMAND, register, registerBuilder, build} from "../../src/commands/hello"; const EXAMPLE_DISP_TOKENS = [ 'RED' @@ -88,3 +88,29 @@ describe('#parse', () => { }); }); }); + +describe('#registerBuilder', () => { + test('basic registration should work', () => { + const builderDictionary = {}; + registerBuilder(builderDictionary); + expect(typeof builderDictionary[HELLO_COMMAND]).toEqual('function'); + }); +}); + +describe('#build', () => { + describe('with piste code', () => { + test('returns token array with command and piste code', () => { + expect(build({ pisteCode: 'RED' })).toEqual(['HELLO', 'RED']); + }); + }); + + describe('without piste code', () => { + test('returns token array with command only', () => { + expect(build({ pisteCode: '' })).toEqual(['HELLO']); + }); + + test('returns token array with command only when pisteCode is undefined', () => { + expect(build({})).toEqual(['HELLO']); + }); + }); +}); diff --git a/tests/protocol/cyrano.test.js b/tests/protocol/cyrano.test.js index 9f37eca..6c1371f 100644 --- a/tests/protocol/cyrano.test.js +++ b/tests/protocol/cyrano.test.js @@ -1,4 +1,4 @@ -import {process} from "../../src/protocol/cyrano"; +import {process, compose} from "../../src/protocol/cyrano"; import {ACK_COMMAND} from "../../src/commands/ack"; import {BOUTSTOP_COMMAND} from "../../src/commands/boutstop"; import {BROKEN_COMMAND} from "../../src/commands/broken"; @@ -122,3 +122,21 @@ describe('#process', () => { expect(process(UPDATED_EXAMPLE)["command"]).toEqual(UPDATED_COMMAND); }); }); + +describe('#compose', () => { + test('throws for unknown command', () => { + expect(() => { compose({ command: 'FOOBAR' }); }) + .toThrowError("No builder registered for command >FOOBAR<"); + }); + + describe('HELLO round-trip', () => { + test('with piste code', () => { + expect(compose(process(HELLO_EXAMPLE))).toEqual(HELLO_EXAMPLE); + }); + + test('without piste code', () => { + const bare = '|EFP2|HELLO|'; + expect(compose(process(bare))).toEqual(bare); + }); + }); +}); From 64384ead279acfb6c328385733bb7774e6ce3222 Mon Sep 17 00:00:00 2001 From: James Seigel Date: Wed, 4 Mar 2026 10:06:57 -0700 Subject: [PATCH 09/10] Extend builder/compose pattern to all 18 EFP2 commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds build() and registerBuilder() to all remaining 17 commands, registers every builder in commands/index.js, and adds #registerBuilder + #build unit tests to each command file. Protocol-level round-trip tests (compose ∘ process) are added for all 18 commands in cyrano.test.js. Test count grows from 195 → 246 passing. Co-Authored-By: Claude Sonnet 4.6 --- src/commands/ack.js | 9 +++ src/commands/boutstop.js | 9 +++ src/commands/broken.js | 9 +++ src/commands/deny.js | 9 +++ src/commands/disp.js | 14 +++++ src/commands/getteam.js | 9 +++ src/commands/index.js | 17 ++++++ src/commands/info.js | 19 ++++++ src/commands/msg.js | 9 +++ src/commands/nak.js | 9 +++ src/commands/next.js | 9 +++ src/commands/ping.js | 9 +++ src/commands/prev.js | 9 +++ src/commands/replace.js | 9 +++ src/commands/standby.js | 9 +++ src/commands/stop.js | 9 +++ src/commands/team.js | 18 ++++++ src/commands/updated.js | 9 +++ tests/commands/ack.test.js | 16 ++++- tests/commands/boutstop.test.js | 16 ++++- tests/commands/broken.test.js | 16 ++++- tests/commands/deny.test.js | 16 ++++- tests/commands/disp.test.js | 87 ++++++++++----------------- tests/commands/getteam.test.js | 16 ++++- tests/commands/info.test.js | 25 +++++++- tests/commands/msg.test.js | 17 +++++- tests/commands/nak.test.js | 16 ++++- tests/commands/next.test.js | 16 ++++- tests/commands/ping.test.js | 50 +++++++--------- tests/commands/prev.test.js | 16 ++++- tests/commands/replace.test.js | 17 +++++- tests/commands/standby.test.js | 16 ++++- tests/commands/stop.test.js | 48 +++++++-------- tests/commands/team.test.js | 28 ++++++++- tests/commands/updated.test.js | 16 ++++- tests/protocol/cyrano.test.js | 102 ++++++++++++++++++++++++++++++++ 36 files changed, 602 insertions(+), 126 deletions(-) diff --git a/src/commands/ack.js b/src/commands/ack.js index fb0a6f1..ee12cc3 100644 --- a/src/commands/ack.js +++ b/src/commands/ack.js @@ -7,6 +7,15 @@ export const register = (commandDictionary) => { return commandDictionary; } +export const registerBuilder = (builderDictionary) => { + builderDictionary[ACK_COMMAND] = build; + return builderDictionary; +} + +export const build = ({}) => { + return [ACK_COMMAND]; +} + const parse = (tokens) => { const localTokens = tokens || []; const length = localTokens.length; diff --git a/src/commands/boutstop.js b/src/commands/boutstop.js index 93a5c19..7cf60bf 100644 --- a/src/commands/boutstop.js +++ b/src/commands/boutstop.js @@ -7,6 +7,15 @@ export const register = (commandDictionary) => { return commandDictionary; } +export const registerBuilder = (builderDictionary) => { + builderDictionary[BOUTSTOP_COMMAND] = build; + return builderDictionary; +} + +export const build = ({ piste }) => { + return [BOUTSTOP_COMMAND, piste]; +} + const parse = (tokens) => { const localTokens = tokens || []; const length = localTokens.length; diff --git a/src/commands/broken.js b/src/commands/broken.js index 4f417f3..2b3f037 100644 --- a/src/commands/broken.js +++ b/src/commands/broken.js @@ -7,6 +7,15 @@ export const register = (commandDictionary) => { return commandDictionary; } +export const registerBuilder = (builderDictionary) => { + builderDictionary[BROKEN_COMMAND] = build; + return builderDictionary; +} + +export const build = ({ piste }) => { + return [BROKEN_COMMAND, piste]; +} + const parse = (tokens) => { const localTokens = tokens || []; const length = localTokens.length; diff --git a/src/commands/deny.js b/src/commands/deny.js index 3841202..2e3a63b 100644 --- a/src/commands/deny.js +++ b/src/commands/deny.js @@ -7,6 +7,15 @@ export const register = (commandDictionary) => { return commandDictionary; } +export const registerBuilder = (builderDictionary) => { + builderDictionary[DENY_COMMAND] = build; + return builderDictionary; +} + +export const build = ({ reason }) => { + return [DENY_COMMAND, reason]; +} + const parse = (tokens) => { const localTokens = tokens || []; const length = localTokens.length; diff --git a/src/commands/disp.js b/src/commands/disp.js index 63b1df8..9bbaf0c 100644 --- a/src/commands/disp.js +++ b/src/commands/disp.js @@ -7,6 +7,20 @@ export const register = (commandDictionary) => { return commandDictionary; } +export const registerBuilder = (builderDictionary) => { + builderDictionary[DISP_COMMAND] = build; + return builderDictionary; +} + +export const build = ({ piste, eventId, competitionCode, competitionPhase, boutOrderInPhase, boutId, beginTime, stopwatch, rightFencer, leftFencer }) => { + return [ + DISP_COMMAND, + piste, eventId, competitionCode, competitionPhase, boutOrderInPhase, boutId, beginTime, stopwatch, + rightFencer.id, rightFencer.name, rightFencer.teamInfo, rightFencer.teamId, rightFencer.teamMemberName, + leftFencer.id, leftFencer.name, leftFencer.teamInfo, leftFencer.teamId, leftFencer.teamMemberName, + ]; +} + const parse = (tokens) => { const length = (tokens || []).length; if (length !== LENGTH) { diff --git a/src/commands/getteam.js b/src/commands/getteam.js index 4ced1b4..5759aa9 100644 --- a/src/commands/getteam.js +++ b/src/commands/getteam.js @@ -7,6 +7,15 @@ export const register = (commandDictionary) => { return commandDictionary; } +export const registerBuilder = (builderDictionary) => { + builderDictionary[GETTEAM_COMMAND] = build; + return builderDictionary; +} + +export const build = ({ piste, side }) => { + return [GETTEAM_COMMAND, piste, side]; +} + const parse = (tokens) => { const localTokens = tokens || []; const length = localTokens.length; diff --git a/src/commands/index.js b/src/commands/index.js index 03484ff..1c345c4 100644 --- a/src/commands/index.js +++ b/src/commands/index.js @@ -1,23 +1,40 @@ const dictionary = {} const builders = {} require("./ack").register(dictionary); +require("./ack").registerBuilder(builders); require("./boutstop").register(dictionary); +require("./boutstop").registerBuilder(builders); require("./broken").register(dictionary); +require("./broken").registerBuilder(builders); require("./deny").register(dictionary); +require("./deny").registerBuilder(builders); require("./disp").register(dictionary); +require("./disp").registerBuilder(builders); require("./getteam").register(dictionary); +require("./getteam").registerBuilder(builders); require("./hello").register(dictionary); require("./hello").registerBuilder(builders); require("./info").register(dictionary); +require("./info").registerBuilder(builders); require("./msg").register(dictionary); +require("./msg").registerBuilder(builders); require("./nak").register(dictionary); +require("./nak").registerBuilder(builders); require("./next").register(dictionary); +require("./next").registerBuilder(builders); require("./ping").register(dictionary); +require("./ping").registerBuilder(builders); require("./prev").register(dictionary); +require("./prev").registerBuilder(builders); require("./replace").register(dictionary); +require("./replace").registerBuilder(builders); require("./standby").register(dictionary); +require("./standby").registerBuilder(builders); require("./stop").register(dictionary); +require("./stop").registerBuilder(builders); require("./team").register(dictionary); +require("./team").registerBuilder(builders); require("./updated").register(dictionary); +require("./updated").registerBuilder(builders); export default dictionary; export { builders }; diff --git a/src/commands/info.js b/src/commands/info.js index 0e630f4..b40bd35 100644 --- a/src/commands/info.js +++ b/src/commands/info.js @@ -7,6 +7,25 @@ export const register = (commandDictionary) => { return commandDictionary; } +export const registerBuilder = (builderDictionary) => { + builderDictionary[INFO_COMMAND] = build; + return builderDictionary; +} + +export const build = ({ piste, eventId, competitionCode, competitionPhase, boutOrderInPhase, roundNumber, boutId, beginTime, stopwatch, state, refereeRemoteControl, priority, callTechnician, callVideoEngineer, callDoctor, callDT, reverse, standby, rightFencer, leftFencer }) => { + return [ + INFO_COMMAND, + piste, eventId, competitionCode, competitionPhase, boutOrderInPhase, roundNumber, boutId, beginTime, stopwatch, + state, refereeRemoteControl, priority, callTechnician, callVideoEngineer, callDoctor, callDT, reverse, standby, + rightFencer.id, rightFencer.name, rightFencer.teamInfo, rightFencer.teamMemberId, rightFencer.teamMemberName, + rightFencer.score, rightFencer.yellowCard, rightFencer.redCards, rightFencer.blackCard, rightFencer.usedVideo, + rightFencer.lamp, rightFencer.whiteLamp, + leftFencer.id, leftFencer.name, leftFencer.teamInfo, leftFencer.teamMemberId, leftFencer.teamMemberName, + leftFencer.score, leftFencer.yellowCard, leftFencer.redCards, leftFencer.blackCard, leftFencer.usedVideo, + leftFencer.lamp, leftFencer.whiteLamp, + ]; +} + const parse = (tokens) => { const localTokens = tokens || []; const length = localTokens.length; diff --git a/src/commands/msg.js b/src/commands/msg.js index 7362bcb..9418ba3 100644 --- a/src/commands/msg.js +++ b/src/commands/msg.js @@ -7,6 +7,15 @@ export const register = (commandDictionary) => { return commandDictionary; } +export const registerBuilder = (builderDictionary) => { + builderDictionary[MSG_COMMAND] = build; + return builderDictionary; +} + +export const build = ({ piste, message }) => { + return [MSG_COMMAND, piste, message]; +} + const parse = (tokens) => { const localTokens = tokens || []; const length = localTokens.length; diff --git a/src/commands/nak.js b/src/commands/nak.js index 978dbe2..fe47e97 100644 --- a/src/commands/nak.js +++ b/src/commands/nak.js @@ -7,6 +7,15 @@ export const register = (commandDictionary) => { return commandDictionary; } +export const registerBuilder = (builderDictionary) => { + builderDictionary[NAK_COMMAND] = build; + return builderDictionary; +} + +export const build = ({}) => { + return [NAK_COMMAND]; +} + const parse = (tokens) => { const localTokens = tokens || []; const length = localTokens.length; diff --git a/src/commands/next.js b/src/commands/next.js index a61474f..b592b46 100644 --- a/src/commands/next.js +++ b/src/commands/next.js @@ -7,6 +7,15 @@ export const register = (commandDictionary) => { return commandDictionary; } +export const registerBuilder = (builderDictionary) => { + builderDictionary[NEXT_COMMAND] = build; + return builderDictionary; +} + +export const build = ({ piste }) => { + return [NEXT_COMMAND, piste]; +} + const parse = (tokens) => { const localTokens = tokens || []; const length = localTokens.length; diff --git a/src/commands/ping.js b/src/commands/ping.js index 3fed469..480b71a 100644 --- a/src/commands/ping.js +++ b/src/commands/ping.js @@ -7,6 +7,15 @@ export const register = (commandDictionary) => { return commandDictionary; } +export const registerBuilder = (builderDictionary) => { + builderDictionary[PING_COMMAND] = build; + return builderDictionary; +} + +export const build = ({}) => { + return [PING_COMMAND]; +} + const parse = (tokens) => { const localTokens = tokens || []; const length = localTokens.length; diff --git a/src/commands/prev.js b/src/commands/prev.js index 2759257..09ef041 100644 --- a/src/commands/prev.js +++ b/src/commands/prev.js @@ -7,6 +7,15 @@ export const register = (commandDictionary) => { return commandDictionary; } +export const registerBuilder = (builderDictionary) => { + builderDictionary[PREV_COMMAND] = build; + return builderDictionary; +} + +export const build = ({ piste }) => { + return [PREV_COMMAND, piste]; +} + const parse = (tokens) => { const localTokens = tokens || []; const length = localTokens.length; diff --git a/src/commands/replace.js b/src/commands/replace.js index 50534ef..ce70c9c 100644 --- a/src/commands/replace.js +++ b/src/commands/replace.js @@ -7,6 +7,15 @@ export const register = (commandDictionary) => { return commandDictionary; } +export const registerBuilder = (builderDictionary) => { + builderDictionary[REPLACE_COMMAND] = build; + return builderDictionary; +} + +export const build = ({ piste, side, fencerNumber }) => { + return [REPLACE_COMMAND, piste, side, fencerNumber]; +} + const parse = (tokens) => { const localTokens = tokens || []; const length = localTokens.length; diff --git a/src/commands/standby.js b/src/commands/standby.js index 71e4e9e..f0fe70c 100644 --- a/src/commands/standby.js +++ b/src/commands/standby.js @@ -7,6 +7,15 @@ export const register = (commandDictionary) => { return commandDictionary; } +export const registerBuilder = (builderDictionary) => { + builderDictionary[STANDBY_COMMAND] = build; + return builderDictionary; +} + +export const build = ({ piste }) => { + return [STANDBY_COMMAND, piste]; +} + const parse = (tokens) => { const localTokens = tokens || []; const length = localTokens.length; diff --git a/src/commands/stop.js b/src/commands/stop.js index 1e62ab3..741c26f 100644 --- a/src/commands/stop.js +++ b/src/commands/stop.js @@ -7,6 +7,15 @@ export const register = (commandDictionary) => { return commandDictionary; } +export const registerBuilder = (builderDictionary) => { + builderDictionary[STOP_COMMAND] = build; + return builderDictionary; +} + +export const build = ({}) => { + return [STOP_COMMAND]; +} + const parse = (tokens) => { const localTokens = tokens || []; const length = localTokens.length; diff --git a/src/commands/team.js b/src/commands/team.js index 3a8eee9..74ade66 100644 --- a/src/commands/team.js +++ b/src/commands/team.js @@ -7,6 +7,24 @@ export const register = (commandDictionary) => { return commandDictionary; } +export const registerBuilder = (builderDictionary) => { + builderDictionary[TEAM_COMMAND] = build; + return builderDictionary; +} + +export const build = ({ piste, side, members, rounds, uniqueId }) => { + return [ + TEAM_COMMAND, + piste, side, + members[0].id, members[0].name, + members[1].id, members[1].name, + members[2].id, members[2].name, + members[3].id, members[3].name, + ...rounds, + uniqueId, + ]; +} + const parse = (tokens) => { const localTokens = tokens || []; const length = localTokens.length; diff --git a/src/commands/updated.js b/src/commands/updated.js index 7024ed5..8da8b13 100644 --- a/src/commands/updated.js +++ b/src/commands/updated.js @@ -7,6 +7,15 @@ export const register = (commandDictionary) => { return commandDictionary; } +export const registerBuilder = (builderDictionary) => { + builderDictionary[UPDATED_COMMAND] = build; + return builderDictionary; +} + +export const build = ({ eventId, competitionCode }) => { + return [UPDATED_COMMAND, eventId, competitionCode]; +} + const parse = (tokens) => { const localTokens = tokens || []; const length = localTokens.length; diff --git a/tests/commands/ack.test.js b/tests/commands/ack.test.js index 6775bb0..9c28f7a 100644 --- a/tests/commands/ack.test.js +++ b/tests/commands/ack.test.js @@ -1,4 +1,4 @@ -import {ACK_COMMAND, register} from "../../src/commands/ack"; +import {ACK_COMMAND, register, registerBuilder, build} from "../../src/commands/ack"; describe('#register', () => { test('basic registration should work', () => { @@ -31,3 +31,17 @@ describe('#parse', () => { }); }); }); + +describe('#registerBuilder', () => { + test('basic registration should work', () => { + const builderDictionary = {}; + registerBuilder(builderDictionary); + expect(typeof builderDictionary[ACK_COMMAND]).toEqual('function'); + }); +}); + +describe('#build', () => { + test('returns token array with command only', () => { + expect(build({})).toEqual(['ACK']); + }); +}); diff --git a/tests/commands/boutstop.test.js b/tests/commands/boutstop.test.js index e93ed12..60cf428 100644 --- a/tests/commands/boutstop.test.js +++ b/tests/commands/boutstop.test.js @@ -1,4 +1,4 @@ -import {BOUTSTOP_COMMAND, register} from "../../src/commands/boutstop"; +import {BOUTSTOP_COMMAND, register, registerBuilder, build} from "../../src/commands/boutstop"; const EXAMPLE_BOUTSTOP_TOKENS = [ 'RED' @@ -65,3 +65,17 @@ describe('#parse', () => { }); }); }); + +describe('#registerBuilder', () => { + test('basic registration should work', () => { + const builderDictionary = {}; + registerBuilder(builderDictionary); + expect(typeof builderDictionary[BOUTSTOP_COMMAND]).toEqual('function'); + }); +}); + +describe('#build', () => { + test('returns token array with command and piste', () => { + expect(build({ piste: 'RED' })).toEqual(['BOUTSTOP', 'RED']); + }); +}); diff --git a/tests/commands/broken.test.js b/tests/commands/broken.test.js index 2d8d4ef..04ad052 100644 --- a/tests/commands/broken.test.js +++ b/tests/commands/broken.test.js @@ -1,4 +1,4 @@ -import {BROKEN_COMMAND, register} from "../../src/commands/broken"; +import {BROKEN_COMMAND, register, registerBuilder, build} from "../../src/commands/broken"; describe('#register', () => { test('basic registration should work', () => { @@ -35,3 +35,17 @@ describe('#parse', () => { }); }); }); + +describe('#registerBuilder', () => { + test('basic registration should work', () => { + const builderDictionary = {}; + registerBuilder(builderDictionary); + expect(typeof builderDictionary[BROKEN_COMMAND]).toEqual('function'); + }); +}); + +describe('#build', () => { + test('returns token array with command and piste', () => { + expect(build({ piste: '3' })).toEqual(['BROKEN', '3']); + }); +}); diff --git a/tests/commands/deny.test.js b/tests/commands/deny.test.js index a1321fe..aec9a09 100644 --- a/tests/commands/deny.test.js +++ b/tests/commands/deny.test.js @@ -1,4 +1,4 @@ -import {DENY_COMMAND, register} from "../../src/commands/deny"; +import {DENY_COMMAND, register, registerBuilder, build} from "../../src/commands/deny"; describe('#register', () => { test('basic registration should work', () => { @@ -35,3 +35,17 @@ describe('#parse', () => { }); }); }); + +describe('#registerBuilder', () => { + test('basic registration should work', () => { + const builderDictionary = {}; + registerBuilder(builderDictionary); + expect(typeof builderDictionary[DENY_COMMAND]).toEqual('function'); + }); +}); + +describe('#build', () => { + test('returns token array with command and reason', () => { + expect(build({ reason: 'Piste does not exists' })).toEqual(['DENY', 'Piste does not exists']); + }); +}); diff --git a/tests/commands/disp.test.js b/tests/commands/disp.test.js index 8d08952..1d1321d 100644 --- a/tests/commands/disp.test.js +++ b/tests/commands/disp.test.js @@ -1,4 +1,4 @@ -import {DISP_COMMAND, register} from "../../src/commands/disp"; +import {DISP_COMMAND, register, registerBuilder, build} from "../../src/commands/disp"; const EXAMPLE_DISP_TOKENS = [ 'RED', '24', 'EIM', 'T32', '1', '32', "14:45", "3:00", @@ -121,47 +121,24 @@ describe('#parse', () => { describe('right fencer', () => { let rightFencer = null; - // "33", " IVANOV Sidor", 'CAN', '', '', beforeEach(() => { rightFencer = parsedResult['rightFencer']; }); test('right fencer id', () => { - expect( - rightFencer["id"] - ).toEqual( - "33" - ) + expect(rightFencer["id"]).toEqual("33") }); test('name', () => { - expect( - rightFencer["name"] - ).toEqual( - " IVANOV Sidor" - ) + expect(rightFencer["name"]).toEqual(" IVANOV Sidor") }); test('team info', () => { - expect( - rightFencer["teamInfo"] - ).toEqual( - "CAN" - ) + expect(rightFencer["teamInfo"]).toEqual("CAN") }); - test('team id', () => { - expect( - rightFencer["teamId"] - ).toEqual( - "rightTeamId" - ) + expect(rightFencer["teamId"]).toEqual("rightTeamId") }); - test('team Member Name', () => { - expect( - rightFencer["teamMemberName"] - ).toEqual( - "rightTeamMemberName" - ) + expect(rightFencer["teamMemberName"]).toEqual("rightTeamMemberName") }); }); describe('left fencer', () => { @@ -171,42 +148,40 @@ describe('#parse', () => { }); test('fencer id', () => { - expect( - leftFencer["id"] - ).toEqual( - "531" - ) + expect(leftFencer["id"]).toEqual("531") }); test('name', () => { - expect( - leftFencer["name"] - ).toEqual( - "LIMON Jua" - ) + expect(leftFencer["name"]).toEqual("LIMON Jua") }); test('team info', () => { - expect( - leftFencer["teamInfo"] - ).toEqual( - "FRA" - ) + expect(leftFencer["teamInfo"]).toEqual("FRA") }); - test('team id', () => { - expect( - leftFencer["teamId"] - ).toEqual( - "leftTeamId" - ) + expect(leftFencer["teamId"]).toEqual("leftTeamId") }); - test('team Member Name', () => { - expect( - leftFencer["teamMemberName"] - ).toEqual( - "leftTeamMemberName" - ) + expect(leftFencer["teamMemberName"]).toEqual("leftTeamMemberName") }); }); }); }) + +describe('#registerBuilder', () => { + test('basic registration should work', () => { + const builderDictionary = {}; + registerBuilder(builderDictionary); + expect(typeof builderDictionary[DISP_COMMAND]).toEqual('function'); + }); +}); + +describe('#build', () => { + test('returns token array with all 19 elements (command + 18 params)', () => { + const result = build({ + piste: 'RED', eventId: '24', competitionCode: 'EIM', competitionPhase: 'T32', + boutOrderInPhase: '1', boutId: '32', beginTime: '14:45', stopwatch: '3:00', + rightFencer: { id: '33', name: ' IVANOV Sidor', teamInfo: 'CAN', teamId: 'rightTeamId', teamMemberName: 'rightTeamMemberName' }, + leftFencer: { id: '531', name: 'LIMON Jua', teamInfo: 'FRA', teamId: 'leftTeamId', teamMemberName: 'leftTeamMemberName' }, + }); + expect(result).toEqual([DISP_COMMAND, ...EXAMPLE_DISP_TOKENS]); + }); +}); diff --git a/tests/commands/getteam.test.js b/tests/commands/getteam.test.js index 3119646..60208ee 100644 --- a/tests/commands/getteam.test.js +++ b/tests/commands/getteam.test.js @@ -1,4 +1,4 @@ -import {GETTEAM_COMMAND, register} from "../../src/commands/getteam"; +import {GETTEAM_COMMAND, register, registerBuilder, build} from "../../src/commands/getteam"; describe('#register', () => { test('basic registration should work', () => { @@ -38,3 +38,17 @@ describe('#parse', () => { }); }); }); + +describe('#registerBuilder', () => { + test('basic registration should work', () => { + const builderDictionary = {}; + registerBuilder(builderDictionary); + expect(typeof builderDictionary[GETTEAM_COMMAND]).toEqual('function'); + }); +}); + +describe('#build', () => { + test('returns token array with command, piste and side', () => { + expect(build({ piste: 'RED', side: 'LEFT' })).toEqual(['GETTEAM', 'RED', 'LEFT']); + }); +}); diff --git a/tests/commands/info.test.js b/tests/commands/info.test.js index 8b68edf..4648380 100644 --- a/tests/commands/info.test.js +++ b/tests/commands/info.test.js @@ -1,4 +1,4 @@ -import {INFO_COMMAND, register} from "../../src/commands/info"; +import {INFO_COMMAND, register, registerBuilder, build} from "../../src/commands/info"; const EXAMPLE_INFO_TOKENS = [ 'RED', // piste @@ -168,3 +168,26 @@ describe('#parse', () => { }); }); }); + +describe('#registerBuilder', () => { + test('basic registration should work', () => { + const builderDictionary = {}; + registerBuilder(builderDictionary); + expect(typeof builderDictionary[INFO_COMMAND]).toEqual('function'); + }); +}); + +describe('#build', () => { + test('returns token array with all 43 elements (command + 42 params)', () => { + const result = build({ + piste: 'RED', eventId: '24', competitionCode: 'EIM', competitionPhase: 'T32', + boutOrderInPhase: '1', roundNumber: '1', boutId: '32', beginTime: '14:45', stopwatch: '3:00', + state: 'F', refereeRemoteControl: '0', priority: 'N', + callTechnician: '0', callVideoEngineer: '0', callDoctor: '0', callDT: '0', + reverse: '0', standby: '0', + rightFencer: { id: '33', name: 'IVANOV Sidor', teamInfo: 'RUS', teamMemberId: '', teamMemberName: '', score: '5', yellowCard: '0', redCards: '0', blackCard: '0', usedVideo: '0', lamp: '1', whiteLamp: '0' }, + leftFencer: { id: '531', name: 'LIMON Jua', teamInfo: 'FRA', teamMemberId: '', teamMemberName: '', score: '3', yellowCard: '1', redCards: '0', blackCard: '0', usedVideo: '1', lamp: '0', whiteLamp: '0' }, + }); + expect(result).toEqual([INFO_COMMAND, ...EXAMPLE_INFO_TOKENS]); + }); +}); diff --git a/tests/commands/msg.test.js b/tests/commands/msg.test.js index 6076fa7..f197c73 100644 --- a/tests/commands/msg.test.js +++ b/tests/commands/msg.test.js @@ -1,5 +1,4 @@ -import {MSG_COMMAND, register} from "../../src/commands/msg"; -import {DISP_COMMAND} from "../../src/commands/disp"; +import {MSG_COMMAND, register, registerBuilder, build} from "../../src/commands/msg"; const EXAMPLE_MSG_TOKENS = [ 'RED', 'Red table come home' @@ -73,3 +72,17 @@ describe('#parse', () => { }); }); }); + +describe('#registerBuilder', () => { + test('basic registration should work', () => { + const builderDictionary = {}; + registerBuilder(builderDictionary); + expect(typeof builderDictionary[MSG_COMMAND]).toEqual('function'); + }); +}); + +describe('#build', () => { + test('returns token array with command, piste and message', () => { + expect(build({ piste: 'BLUE', message: 'Glove missing' })).toEqual(['MSG', 'BLUE', 'Glove missing']); + }); +}); diff --git a/tests/commands/nak.test.js b/tests/commands/nak.test.js index 94b7e52..f53f1db 100644 --- a/tests/commands/nak.test.js +++ b/tests/commands/nak.test.js @@ -1,4 +1,4 @@ -import {NAK_COMMAND, register} from "../../src/commands/nak"; +import {NAK_COMMAND, register, registerBuilder, build} from "../../src/commands/nak"; describe('#register', () => { test('basic registration should work', () => { @@ -31,3 +31,17 @@ describe('#parse', () => { }); }); }); + +describe('#registerBuilder', () => { + test('basic registration should work', () => { + const builderDictionary = {}; + registerBuilder(builderDictionary); + expect(typeof builderDictionary[NAK_COMMAND]).toEqual('function'); + }); +}); + +describe('#build', () => { + test('returns token array with command only', () => { + expect(build({})).toEqual(['NAK']); + }); +}); diff --git a/tests/commands/next.test.js b/tests/commands/next.test.js index 4b761c4..76a9f53 100644 --- a/tests/commands/next.test.js +++ b/tests/commands/next.test.js @@ -1,4 +1,4 @@ -import {NEXT_COMMAND, register} from "../../src/commands/next"; +import {NEXT_COMMAND, register, registerBuilder, build} from "../../src/commands/next"; describe('#register', () => { test('basic registration should work', () => { @@ -35,3 +35,17 @@ describe('#parse', () => { }); }); }); + +describe('#registerBuilder', () => { + test('basic registration should work', () => { + const builderDictionary = {}; + registerBuilder(builderDictionary); + expect(typeof builderDictionary[NEXT_COMMAND]).toEqual('function'); + }); +}); + +describe('#build', () => { + test('returns token array with command and piste', () => { + expect(build({ piste: 'RED' })).toEqual(['NEXT', 'RED']); + }); +}); diff --git a/tests/commands/ping.test.js b/tests/commands/ping.test.js index c802964..3dfc245 100644 --- a/tests/commands/ping.test.js +++ b/tests/commands/ping.test.js @@ -1,15 +1,10 @@ -import {PING_COMMAND, register} from "../../src/commands/ping"; +import {PING_COMMAND, register, registerBuilder, build} from "../../src/commands/ping"; describe('#register', () => { test('basic registration should work', () => { const commandDictionary = {}; register(commandDictionary); - const result = commandDictionary[PING_COMMAND]; - expect( - typeof result - ).toEqual( - 'function' - ); + expect(typeof commandDictionary[PING_COMMAND]).toEqual('function'); }); }); @@ -18,38 +13,37 @@ describe('#parse', () => { let parsedResult = null; describe('invalid length of tokens', function () { - test('problem with token length for this parser', () => { - expect( - () => { - parse(["HI", "THERE", "MONKEY"]); - } - ).toThrowError(`Incompatible command tokens for >${PING_COMMAND}<. Expected 0, Got: 3`); + expect(() => { parse(["HI", "THERE", "MONKEY"]); }) + .toThrowError(`Incompatible command tokens for >${PING_COMMAND}<. Expected 0, Got: 3`); }); - - test('problem with token length for this parser', () => { - expect( - () => { - parse(null); - } - ).not.toThrowError("Anything"); + test('no issue with null tokens', () => { + expect(() => { parse(null); }).not.toThrowError("Anything"); }); }); describe("valid parameters", () => { describe("ping command with no parameter", () => { beforeEach(() => { - const tokens = []; - parsedResult = parse(tokens); + parsedResult = parse([]); }); - test('has the command in the result', () => { - expect( - parsedResult['command'] - ).toEqual( - PING_COMMAND - ) + expect(parsedResult['command']).toEqual(PING_COMMAND); }); }); }); }); + +describe('#registerBuilder', () => { + test('basic registration should work', () => { + const builderDictionary = {}; + registerBuilder(builderDictionary); + expect(typeof builderDictionary[PING_COMMAND]).toEqual('function'); + }); +}); + +describe('#build', () => { + test('returns token array with command only', () => { + expect(build({})).toEqual(['PING']); + }); +}); diff --git a/tests/commands/prev.test.js b/tests/commands/prev.test.js index b7edb5e..0b8ecf5 100644 --- a/tests/commands/prev.test.js +++ b/tests/commands/prev.test.js @@ -1,4 +1,4 @@ -import {PREV_COMMAND, register} from "../../src/commands/prev"; +import {PREV_COMMAND, register, registerBuilder, build} from "../../src/commands/prev"; describe('#register', () => { test('basic registration should work', () => { @@ -35,3 +35,17 @@ describe('#parse', () => { }); }); }); + +describe('#registerBuilder', () => { + test('basic registration should work', () => { + const builderDictionary = {}; + registerBuilder(builderDictionary); + expect(typeof builderDictionary[PREV_COMMAND]).toEqual('function'); + }); +}); + +describe('#build', () => { + test('returns token array with command and piste', () => { + expect(build({ piste: '1' })).toEqual(['PREV', '1']); + }); +}); diff --git a/tests/commands/replace.test.js b/tests/commands/replace.test.js index 14bc77d..bc33e09 100644 --- a/tests/commands/replace.test.js +++ b/tests/commands/replace.test.js @@ -1,4 +1,4 @@ -import {REPLACE_COMMAND, register} from "../../src/commands/replace"; +import {REPLACE_COMMAND, register, registerBuilder, build} from "../../src/commands/replace"; const EXAMPLE_REPLACE_TOKENS = [ 'RED', 'LEFT', '1' @@ -81,3 +81,18 @@ describe('#parse', () => { }); }); }); + +describe('#registerBuilder', () => { + test('basic registration should work', () => { + const builderDictionary = {}; + registerBuilder(builderDictionary); + expect(typeof builderDictionary[REPLACE_COMMAND]).toEqual('function'); + }); +}); + +describe('#build', () => { + test('returns token array with command, piste, side and fencerNumber', () => { + expect(build({ piste: 'RED', side: 'LEFT', fencerNumber: '1' })) + .toEqual(['REPLACE', 'RED', 'LEFT', '1']); + }); +}); diff --git a/tests/commands/standby.test.js b/tests/commands/standby.test.js index 9f3b539..7baadf1 100644 --- a/tests/commands/standby.test.js +++ b/tests/commands/standby.test.js @@ -1,4 +1,4 @@ -import {STANDBY_COMMAND, register} from "../../src/commands/standby"; +import {STANDBY_COMMAND, register, registerBuilder, build} from "../../src/commands/standby"; describe('#register', () => { test('basic registration should work', () => { @@ -35,3 +35,17 @@ describe('#parse', () => { }); }); }); + +describe('#registerBuilder', () => { + test('basic registration should work', () => { + const builderDictionary = {}; + registerBuilder(builderDictionary); + expect(typeof builderDictionary[STANDBY_COMMAND]).toEqual('function'); + }); +}); + +describe('#build', () => { + test('returns token array with command and piste', () => { + expect(build({ piste: 'FINAL' })).toEqual(['STANDBY', 'FINAL']); + }); +}); diff --git a/tests/commands/stop.test.js b/tests/commands/stop.test.js index 4cb5e3d..d67bfe1 100644 --- a/tests/commands/stop.test.js +++ b/tests/commands/stop.test.js @@ -1,15 +1,10 @@ -import {STOP_COMMAND, register} from "../../src/commands/stop"; +import {STOP_COMMAND, register, registerBuilder, build} from "../../src/commands/stop"; describe('#register', () => { test('basic registration should work', () => { const commandDictionary = {}; register(commandDictionary); - const result = commandDictionary[STOP_COMMAND]; - expect( - typeof result - ).toEqual( - 'function' - ); + expect(typeof commandDictionary[STOP_COMMAND]).toEqual('function'); }); }); @@ -18,38 +13,37 @@ describe('#parse', () => { let parsedResult = null; describe('invalid length of tokens', function () { - test('problem with token length for this parser', () => { - expect( - () => { - parse(["HI", "THERE", "MONKEY"]); - } - ).toThrowError(`Incompatible command tokens for >${STOP_COMMAND}<. Expected 0, Got: 3`); + expect(() => { parse(["HI", "THERE", "MONKEY"]); }) + .toThrowError(`Incompatible command tokens for >${STOP_COMMAND}<. Expected 0, Got: 3`); }); - test('no issue with token length for this parser', () => { - expect( - () => { - parse(null); - } - ).not.toThrowError("Anything"); + expect(() => { parse(null); }).not.toThrowError("Anything"); }); }); describe("valid parameters", () => { describe("stop command with no parameter", () => { beforeEach(() => { - const tokens = []; - parsedResult = parse(tokens); + parsedResult = parse([]); }); - test('has the command in the result', () => { - expect( - parsedResult['command'] - ).toEqual( - STOP_COMMAND - ) + expect(parsedResult['command']).toEqual(STOP_COMMAND); }); }); }); }); + +describe('#registerBuilder', () => { + test('basic registration should work', () => { + const builderDictionary = {}; + registerBuilder(builderDictionary); + expect(typeof builderDictionary[STOP_COMMAND]).toEqual('function'); + }); +}); + +describe('#build', () => { + test('returns token array with command only', () => { + expect(build({})).toEqual(['STOP']); + }); +}); diff --git a/tests/commands/team.test.js b/tests/commands/team.test.js index 988ce1b..832b760 100644 --- a/tests/commands/team.test.js +++ b/tests/commands/team.test.js @@ -1,4 +1,4 @@ -import {TEAM_COMMAND, register} from "../../src/commands/team"; +import {TEAM_COMMAND, register, registerBuilder, build} from "../../src/commands/team"; const EXAMPLE_TEAM_TOKENS = [ 'RED', 'LEFT', @@ -108,3 +108,29 @@ describe('#parse', () => { }); }); }); + +describe('#registerBuilder', () => { + test('basic registration should work', () => { + const builderDictionary = {}; + registerBuilder(builderDictionary); + expect(typeof builderDictionary[TEAM_COMMAND]).toEqual('function'); + }); +}); + +describe('#build', () => { + test('returns token array with all 21 elements (command + 20 params)', () => { + const result = build({ + piste: 'RED', + side: 'LEFT', + members: [ + { id: '234', name: 'IVANOV Fedor' }, + { id: '542', name: 'PETROV Ivan' }, + { id: '43', name: 'SIDOROV Evgeny' }, + { id: '2', name: 'OH Semen' }, + ], + rounds: ['1', '2', '3', '2', '1', '3', '3', '1', '2'], + uniqueId: '435533', + }); + expect(result).toEqual([TEAM_COMMAND, ...EXAMPLE_TEAM_TOKENS]); + }); +}); diff --git a/tests/commands/updated.test.js b/tests/commands/updated.test.js index b63ea17..0644316 100644 --- a/tests/commands/updated.test.js +++ b/tests/commands/updated.test.js @@ -1,4 +1,4 @@ -import {UPDATED_COMMAND, register} from "../../src/commands/updated"; +import {UPDATED_COMMAND, register, registerBuilder, build} from "../../src/commands/updated"; describe('#register', () => { test('basic registration should work', () => { @@ -38,3 +38,17 @@ describe('#parse', () => { }); }); }); + +describe('#registerBuilder', () => { + test('basic registration should work', () => { + const builderDictionary = {}; + registerBuilder(builderDictionary); + expect(typeof builderDictionary[UPDATED_COMMAND]).toEqual('function'); + }); +}); + +describe('#build', () => { + test('returns token array with command, eventId and competitionCode', () => { + expect(build({ eventId: '23', competitionCode: 'EIM' })).toEqual(['UPDATED', '23', 'EIM']); + }); +}); diff --git a/tests/protocol/cyrano.test.js b/tests/protocol/cyrano.test.js index 6c1371f..95d984d 100644 --- a/tests/protocol/cyrano.test.js +++ b/tests/protocol/cyrano.test.js @@ -129,6 +129,42 @@ describe('#compose', () => { .toThrowError("No builder registered for command >FOOBAR<"); }); + describe('ACK round-trip', () => { + test('round-trips correctly', () => { + expect(compose(process(ACK_EXAMPLE))).toEqual(ACK_EXAMPLE); + }); + }); + + describe('BOUTSTOP round-trip', () => { + test('round-trips correctly', () => { + expect(compose(process(BOUTSTOP_EXAMPLE))).toEqual(BOUTSTOP_EXAMPLE); + }); + }); + + describe('BROKEN round-trip', () => { + test('round-trips correctly', () => { + expect(compose(process(BROKEN_EXAMPLE))).toEqual(BROKEN_EXAMPLE); + }); + }); + + describe('DENY round-trip', () => { + test('round-trips correctly', () => { + expect(compose(process(DENY_EXAMPLE))).toEqual(DENY_EXAMPLE); + }); + }); + + describe('DISP round-trip', () => { + test('round-trips correctly', () => { + expect(compose(process(DISP_EXAMPLE))).toEqual(DISP_EXAMPLE); + }); + }); + + describe('GETTEAM round-trip', () => { + test('round-trips correctly', () => { + expect(compose(process(GETTEAM_EXAMPLE))).toEqual(GETTEAM_EXAMPLE); + }); + }); + describe('HELLO round-trip', () => { test('with piste code', () => { expect(compose(process(HELLO_EXAMPLE))).toEqual(HELLO_EXAMPLE); @@ -139,4 +175,70 @@ describe('#compose', () => { expect(compose(process(bare))).toEqual(bare); }); }); + + describe('INFO round-trip', () => { + test('round-trips correctly', () => { + expect(compose(process(INFO_EXAMPLE))).toEqual(INFO_EXAMPLE); + }); + }); + + describe('MSG round-trip', () => { + test('round-trips correctly', () => { + expect(compose(process(MSG_EXAMPLE))).toEqual(MSG_EXAMPLE); + }); + }); + + describe('NAK round-trip', () => { + test('round-trips correctly', () => { + expect(compose(process(NAK_EXAMPLE))).toEqual(NAK_EXAMPLE); + }); + }); + + describe('NEXT round-trip', () => { + test('round-trips correctly', () => { + expect(compose(process(NEXT_EXAMPLE))).toEqual(NEXT_EXAMPLE); + }); + }); + + describe('PING round-trip', () => { + test('round-trips correctly', () => { + expect(compose(process(PING_EXAMPLE))).toEqual(PING_EXAMPLE); + }); + }); + + describe('PREV round-trip', () => { + test('round-trips correctly', () => { + expect(compose(process(PREV_EXAMPLE))).toEqual(PREV_EXAMPLE); + }); + }); + + describe('REPLACE round-trip', () => { + test('round-trips correctly', () => { + expect(compose(process(REPLACE_EXAMPLE))).toEqual(REPLACE_EXAMPLE); + }); + }); + + describe('STANDBY round-trip', () => { + test('round-trips correctly', () => { + expect(compose(process(STANDBY_EXAMPLE))).toEqual(STANDBY_EXAMPLE); + }); + }); + + describe('STOP round-trip', () => { + test('round-trips correctly', () => { + expect(compose(process(STOP_EXAMPLE))).toEqual(STOP_EXAMPLE); + }); + }); + + describe('TEAM round-trip', () => { + test('round-trips correctly', () => { + expect(compose(process(TEAM_EXAMPLE))).toEqual(TEAM_EXAMPLE); + }); + }); + + describe('UPDATED round-trip', () => { + test('round-trips correctly', () => { + expect(compose(process(UPDATED_EXAMPLE))).toEqual(UPDATED_EXAMPLE); + }); + }); }); From be6d90f2bc3362132c8b19ecc84916f37bc1628c Mon Sep 17 00:00:00 2001 From: James Seigel Date: Wed, 4 Mar 2026 10:37:15 -0700 Subject: [PATCH 10/10] Add UDP demo showing full EFP2 bout lifecycle (npm run demo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two UDP actors on loopback — Server (port 50100) and Master/piste RED (port 50101) — walk through the complete lifecycle specified in the EFP2 protocol: HELLO → DISP → INFO(Not active) → INFO(Fencing) ×8 → INFO(Ending) → ACK → INFO(Not active). The wire bytes logged at each step are produced by compose() and consumed by process(). Co-Authored-By: Claude Sonnet 4.6 --- demo/run.js | 332 +++++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + 2 files changed, 333 insertions(+) create mode 100644 demo/run.js diff --git a/demo/run.js b/demo/run.js new file mode 100644 index 0000000..1654b48 --- /dev/null +++ b/demo/run.js @@ -0,0 +1,332 @@ +/** + * EFP2 Demo — Bout Lifecycle + * + * Simulates a Server (competition software) and a Master (scoring machine) + * communicating over UDP on loopback, following the EFP2 protocol spec. + * + * Network topology (loopback): + * Server 127.0.0.1:50100 — listens for Masters, manages bouts + * Master 127.0.0.1:50101 — piste RED scoring machine + * + * Lifecycle demonstrated: + * 1. Master → HELLO RED (comes online) + * 2. Server → DISP RED … (assigns a bout) + * 3. Master → INFO … state=A (Not active, waiting for referee) + * 4. Master → INFO … state=F (Fencing, score advances each tick) + * 5. Master → INFO … state=E ×n (Ending, sends every 1 s for up to 4 s) + * 6. Server → ACK (accepts the result) + * 7. Master → INFO … state=A (returns to Not active) + * + * State codes used in the INFO 'state' field: + * 'A' — Not active (DISP received, bout not yet started) + * 'F' — Fencing (bout in progress) + * 'E' — Ending (referee deactivated, awaiting ACK/NAK) + */ + +import dgram from 'dgram'; +import { compose, process } from '../src/protocol/cyrano.js'; + +const SERVER_PORT = 50100; +const MASTER_PORT = 50101; +const HOST = '127.0.0.1'; +const PISTE = 'RED'; + +// ── Colour helpers ──────────────────────────────────────────────────────────── +const reset = s => `\x1b[0m${s}\x1b[0m`; +const dim = s => `\x1b[2m${s}\x1b[0m`; +const bold = s => `\x1b[1m${s}\x1b[0m`; +const cyan = s => `\x1b[36m${s}\x1b[0m`; +const yellow = s => `\x1b[33m${s}\x1b[0m`; +const green = s => `\x1b[32m${s}\x1b[0m`; +const red = s => `\x1b[31m${s}\x1b[0m`; + +function log(actor, dir, summary, wire = null) { + const label = actor === 'SERVER' ? cyan(bold('SERVER')) : yellow(bold('MASTER')); + const arrow = dir === 'tx' ? '→' : '←'; + const detail = wire ? ` ${dim(wire.trim())}` : ''; + console.log(` ${label} ${arrow} ${summary}${detail}`); +} + +function delay(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +// ── Send a command object as a UDP datagram ─────────────────────────────────── +function send(socket, commandObj, toPort, toHost = HOST) { + const wire = compose(commandObj); + const buf = Buffer.from(wire, 'ascii'); + return new Promise((resolve, reject) => + socket.send(buf, toPort, toHost, err => err ? reject(err) : resolve(wire)) + ); +} + +// ── Fixture data (matches the cyrano.test.js examples) ─────────────────────── +const RIGHT_FENCER = { + id: '33', + name: 'IVANOV Sidor', + teamInfo: 'RUS', + teamMemberId: '', + teamMemberName: '', +}; +const LEFT_FENCER = { + id: '531', + name: 'LIMON Jua', + teamInfo: 'FRA', + teamMemberId: '', + teamMemberName: '', +}; + +const BOUT_HEADER = { + piste: PISTE, + eventId: '24', + competitionCode: 'EIM', + competitionPhase: 'T32', + boutOrderInPhase: '1', + roundNumber: '1', + boutId: '32', + beginTime: '14:45', + stopwatch: '3:00', + refereeRemoteControl: '0', + priority: 'N', + callTechnician: '0', + callVideoEngineer:'0', + callDoctor: '0', + callDT: '0', + reverse: '0', + standby: '0', +}; + +/** Build an INFO command object for the given state and scores. */ +function makeInfo(state, rightScore, leftScore, rightLamp = '0', leftLamp = '0') { + return { + command: 'INFO', + ...BOUT_HEADER, + state, + rightFencer: { + ...RIGHT_FENCER, + score: String(rightScore), + yellowCard: '0', + redCards: '0', + blackCard: '0', + usedVideo: '0', + lamp: rightLamp, + whiteLamp: '0', + }, + leftFencer: { + ...LEFT_FENCER, + score: String(leftScore), + yellowCard: '0', + redCards: '0', + blackCard: '0', + usedVideo: '0', + lamp: leftLamp, + whiteLamp: '0', + }, + }; +} + +/** Build a DISP command object for the bout assignment. */ +function makeDisp() { + return { + command: 'DISP', + piste: PISTE, + eventId: '24', + competitionCode: 'EIM', + competitionPhase: 'T32', + boutOrderInPhase: '1', + boutId: '32', + beginTime: '14:45', + stopwatch: '3:00', + rightFencer: { + id: RIGHT_FENCER.id, + name: RIGHT_FENCER.name, + teamInfo: RIGHT_FENCER.teamInfo, + teamId: '', + teamMemberName: '', + }, + leftFencer: { + id: LEFT_FENCER.id, + name: LEFT_FENCER.name, + teamInfo: LEFT_FENCER.teamInfo, + teamId: '', + teamMemberName: '', + }, + }; +} + +// ── SERVER ──────────────────────────────────────────────────────────────────── +async function startServer() { + const socket = dgram.createSocket('udp4'); + + socket.on('message', async (raw) => { + const wire = raw.toString('ascii'); + const obj = process(wire); + + switch (obj.command) { + + case 'HELLO': { + log('SERVER', 'rx', `${bold('HELLO')} piste=${obj.pisteCode}`, wire); + // Assign a bout to this piste + await delay(400); + const disp = makeDisp(); + const sent = await send(socket, disp, MASTER_PORT); + log('SERVER', 'tx', + `${bold('DISP')} ${obj.pisteCode} — ` + + `${RIGHT_FENCER.name} (${RIGHT_FENCER.teamInfo}) ` + + `vs ${LEFT_FENCER.name} (${LEFT_FENCER.teamInfo})`, sent); + break; + } + + case 'INFO': { + const rs = obj.rightFencer.score; + const ls = obj.leftFencer.score; + const st = obj.state; + + const stateLabel = { A: 'Not active', F: 'Fencing', E: 'Ending' }[st] ?? st; + log('SERVER', 'rx', + `${bold('INFO')} state=${bold(stateLabel)} ` + + `${cyan(RIGHT_FENCER.name + ' ' + rs)} ${dim('–')} ${yellow(LEFT_FENCER.name + ' ' + ls)}`, + wire); + + if (st === 'E') { + // Accept the bout result + await delay(700); + const sent = await send(socket, { command: 'ACK' }, MASTER_PORT); + log('SERVER', 'tx', `${bold('ACK')} ${green('result accepted')}`, sent); + } + break; + } + } + }); + + await new Promise(resolve => socket.bind(SERVER_PORT, HOST, resolve)); + console.log(cyan(` Server bound ${HOST}:${SERVER_PORT}`)); + return socket; +} + +// ── MASTER ──────────────────────────────────────────────────────────────────── +async function startMaster(serverSocket) { + const socket = dgram.createSocket('udp4'); + let endingTimer = null; + + socket.on('message', async (raw) => { + const wire = raw.toString('ascii'); + const obj = process(wire); + + switch (obj.command) { + + case 'DISP': { + log('MASTER', 'rx', `${bold('DISP')} bout assigned`, wire); + + // Transition to Not active — send initial INFO + const infoA = makeInfo('A', 0, 0); + const sentA = await send(socket, infoA, SERVER_PORT); + log('MASTER', 'tx', `${bold('INFO')} state=Not active score=0–0`, sentA); + + // Short pause then referee starts the bout + await delay(500); + simulateFencing(socket); + break; + } + + case 'ACK': { + log('MASTER', 'rx', `${bold('ACK')}`, wire); + clearInterval(endingTimer); + endingTimer = null; + + // Return to Not active with final score + const infoFinal = makeInfo('A', 5, 3); + const sentFinal = await send(socket, infoFinal, SERVER_PORT); + log('MASTER', 'tx', + `${bold('INFO')} state=Not active score=5–3 ${green('(bout complete)')}`, + sentFinal); + + await delay(300); + console.log('\n' + green(bold(' ✓ Bout lifecycle complete')) + '\n'); + socket.close(); + serverSocket.close(); + break; + } + + case 'NAK': { + log('MASTER', 'rx', `${bold('NAK')} ${red('result rejected — entering Waiting')}`, wire); + clearInterval(endingTimer); + socket.close(); + serverSocket.close(); + break; + } + } + }); + + /** Score progression for a 5–3 bout (right wins). + * Each step: [rightScore, leftScore, rightLamp, leftLamp] */ + const SCORE_STEPS = [ + [1, 0, '1', '0'], + [1, 1, '0', '1'], + [2, 1, '1', '0'], + [3, 1, '1', '0'], + [3, 2, '0', '1'], + [4, 2, '1', '0'], + [4, 3, '0', '1'], + [5, 3, '1', '0'], // final touch — right wins + ]; + + async function simulateFencing(socket) { + console.log(dim('\n ── Fencing ──────────────────────────────')); + for (const [rs, ls, rl, ll] of SCORE_STEPS) { + await delay(600); + const info = makeInfo('F', rs, ls, rl, ll); + const sent = await send(socket, info, SERVER_PORT); + log('MASTER', 'tx', + `${bold('INFO')} state=Fencing score=${cyan(rs)}–${yellow(ls)}`, + sent); + } + + // Referee deactivates bout — enter Ending state + console.log(dim('\n ── Ending (awaiting ACK/NAK) ────────────')); + let endingCount = 0; + endingTimer = setInterval(async () => { + endingCount++; + const info = makeInfo('E', 5, 3); + const sent = await send(socket, info, SERVER_PORT); + log('MASTER', 'tx', + `${bold('INFO')} state=Ending score=5–3 ${dim(`(${endingCount}/4)`)}`, + sent); + + if (endingCount >= 4) { + // Timeout — no ACK received, transition to Waiting + clearInterval(endingTimer); + log('MASTER', 'tx', + `${red(bold('timeout'))} no ACK after 4 s — would enter Waiting state`); + socket.close(); + serverSocket.close(); + } + }, 1000); + } + + await new Promise(resolve => socket.bind(MASTER_PORT, HOST, resolve)); + console.log(yellow(` Master bound ${HOST}:${MASTER_PORT}`) + + dim(` (piste ${PISTE})`)); + return socket; +} + +// ── Entry point ─────────────────────────────────────────────────────────────── +async function main() { + console.log(bold('\n ══════════════════════════════════════')); + console.log(bold(' EFP2 Demo — Bout Lifecycle ')); + console.log(bold(' ══════════════════════════════════════\n')); + + const serverSocket = await startServer(); + const masterSocket = await startMaster(serverSocket); + + // Master initiates contact + console.log(dim('\n ── Handshake ─────────────────────────')); + await delay(100); + const helloWire = await send(masterSocket, { command: 'HELLO', pisteCode: PISTE }, SERVER_PORT); + log('MASTER', 'tx', `${bold('HELLO')} pisteCode=${PISTE}`, helloWire); +} + +main().catch(err => { + console.error(red(bold('Fatal: ')) + err.message); + process.exit(1); +}); diff --git a/package.json b/package.json index 389399d..812d245 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "clean": "rm -rf ./node_modules && npm i", "build": "./node_modules/.bin/esbuild --platform=node --minify --bundle index.js --outdir=build", "start": "node ./build/index.js", + "demo": "./node_modules/.bin/esbuild --platform=node --bundle demo/run.js --outfile=build/demo.js && node build/demo.js", "test": "node --experimental-vm-modules --input-type=module node_modules/jest/bin/jest.js", "coverage": "jest --collect-coverage" },