Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 3093a85

Browse files
author
Akos Kitta
committedFeb 9, 2024
fix: custom board options handling in data store
Signed-off-by: Akos Kitta <[email protected]>
1 parent 9351f5f commit 3093a85

File tree

6 files changed

+333
-36
lines changed

6 files changed

+333
-36
lines changed
 

‎arduino-ide-extension/src/browser/boards/boards-data-store.ts

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
Programmer,
2222
isBoardIdentifierChangeEvent,
2323
isProgrammer,
24+
sanitizeFqbn,
2425
} from '../../common/protocol';
2526
import { notEmpty } from '../../common/utils';
2627
import type {
@@ -130,7 +131,7 @@ export class BoardsDataStore
130131
if (!fqbn) {
131132
return undefined;
132133
} else {
133-
const data = await this.getData(fqbn);
134+
const data = await this.getData(sanitizeFqbn(fqbn));
134135
if (data === BoardsDataStore.Data.EMPTY) {
135136
return undefined;
136137
}
@@ -228,15 +229,19 @@ export class BoardsDataStore
228229
fqbn: string;
229230
selectedProgrammer: Programmer;
230231
}): Promise<boolean> {
231-
const storedData = deepClone(await this.getData(fqbn));
232+
const sanitizedFQBN = sanitizeFqbn(fqbn);
233+
const storedData = deepClone(await this.getData(sanitizedFQBN));
232234
const { programmers } = storedData;
233235
if (!programmers.find((p) => Programmer.equals(selectedProgrammer, p))) {
234236
return false;
235237
}
236238

237-
const data = { ...storedData, selectedProgrammer };
238-
await this.setData({ fqbn, data });
239-
this.fireChanged({ fqbn, data });
239+
const change: BoardsDataStoreChange = {
240+
fqbn: sanitizedFQBN,
241+
data: { ...storedData, selectedProgrammer },
242+
};
243+
await this.setData(change);
244+
this.fireChanged(change);
240245
return true;
241246
}
242247

@@ -246,24 +251,26 @@ export class BoardsDataStore
246251
return false;
247252
}
248253

249-
const mutableData = deepClone(await this.getData(fqbn));
254+
const sanitizedFQBN = sanitizeFqbn(fqbn);
255+
const mutableData = deepClone(await this.getData(sanitizedFQBN));
250256
let didChange = false;
251257

252258
for (const { option, selectedValue } of optionsToUpdate) {
253259
const { configOptions } = mutableData;
254260
const configOption = configOptions.find((c) => c.option === option);
255261
if (configOption) {
256-
let updated = false;
257-
for (const value of configOption.values) {
258-
const mutable: Mutable<ConfigValue> = value;
259-
if (mutable.value === selectedValue) {
260-
mutable.selected = true;
261-
updated = true;
262-
} else {
263-
mutable.selected = false;
264-
}
265-
}
266-
if (updated) {
262+
const configOptionValueIndex = configOption.values.findIndex(
263+
(configOptionValue) => configOptionValue.value === selectedValue
264+
);
265+
if (configOptionValueIndex >= 0) {
266+
// unselect all
267+
configOption.values
268+
.map((value) => value as Mutable<ConfigValue>)
269+
.forEach((value) => (value.selected = false));
270+
const mutableConfigValue: Mutable<ConfigValue> =
271+
configOption.values[configOptionValueIndex];
272+
// make the new value `selected`
273+
mutableConfigValue.selected = true;
267274
didChange = true;
268275
}
269276
}
@@ -273,8 +280,12 @@ export class BoardsDataStore
273280
return false;
274281
}
275282

276-
await this.setData({ fqbn, data: mutableData });
277-
this.fireChanged({ fqbn, data: mutableData });
283+
const change: BoardsDataStoreChange = {
284+
fqbn: sanitizedFQBN,
285+
data: mutableData,
286+
};
287+
await this.setData(change);
288+
this.fireChanged(change);
278289
return true;
279290
}
280291

‎arduino-ide-extension/src/browser/boards/boards-service-provider.ts

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { Emitter } from '@theia/core/lib/common/event';
1212
import { ILogger } from '@theia/core/lib/common/logger';
1313
import { MessageService } from '@theia/core/lib/common/message-service';
1414
import { nls } from '@theia/core/lib/common/nls';
15+
import { deepClone } from '@theia/core/lib/common/objects';
1516
import { Deferred } from '@theia/core/lib/common/promise-util';
1617
import type { Mutable } from '@theia/core/lib/common/types';
1718
import { inject, injectable, optional } from '@theia/core/shared/inversify';
@@ -21,31 +22,32 @@ import {
2122
} from '@theia/output/lib/browser/output-channel';
2223
import {
2324
BoardIdentifier,
24-
boardIdentifierEquals,
25+
BoardUserField,
26+
BoardWithPackage,
2527
BoardsConfig,
2628
BoardsConfigChangeEvent,
2729
BoardsPackage,
2830
BoardsService,
29-
BoardUserField,
30-
BoardWithPackage,
3131
DetectedPorts,
32+
Port,
33+
PortIdentifier,
34+
boardIdentifierEquals,
3235
emptyBoardsConfig,
3336
isBoardIdentifier,
3437
isBoardIdentifierChangeEvent,
3538
isPortIdentifier,
3639
isPortIdentifierChangeEvent,
37-
Port,
38-
PortIdentifier,
3940
portIdentifierEquals,
41+
sanitizeFqbn,
4042
serializePlatformIdentifier,
4143
} from '../../common/protocol';
4244
import {
4345
BoardList,
4446
BoardListHistory,
45-
createBoardList,
4647
EditBoardsConfigActionParams,
47-
isBoardListHistory,
4848
SelectBoardsConfigActionParams,
49+
createBoardList,
50+
isBoardListHistory,
4951
} from '../../common/protocol/board-list';
5052
import type { Defined } from '../../common/types';
5153
import type {
@@ -104,6 +106,21 @@ type BoardListHistoryUpdateResult =
104106
type BoardToSelect = BoardIdentifier | undefined | 'ignore-board';
105107
type PortToSelect = PortIdentifier | undefined | 'ignore-port';
106108

109+
function sanitizeBoardToSelectFQBN(board: BoardToSelect): BoardToSelect {
110+
if (isBoardIdentifier(board)) {
111+
return sanitizeBoardIdentifierFQBN(board);
112+
}
113+
return board;
114+
}
115+
function sanitizeBoardIdentifierFQBN(board: BoardIdentifier): BoardIdentifier {
116+
if (board.fqbn) {
117+
const copy: Mutable<BoardIdentifier> = deepClone(board);
118+
copy.fqbn = sanitizeFqbn(board.fqbn);
119+
return copy;
120+
}
121+
return board;
122+
}
123+
107124
interface UpdateBoardListHistoryParams {
108125
readonly portToSelect: PortToSelect;
109126
readonly boardToSelect: BoardToSelect;
@@ -356,7 +373,8 @@ export class BoardsServiceProvider
356373
portToSelect !== 'ignore-port' &&
357374
!portIdentifierEquals(portToSelect, previousSelectedPort);
358375
const boardDidChangeEvent = boardDidChange
359-
? { selectedBoard: boardToSelect, previousSelectedBoard }
376+
? // The change event must always contain any custom board options. Hence the board to select is not sanitized.
377+
{ selectedBoard: boardToSelect, previousSelectedBoard }
360378
: undefined;
361379
const portDidChangeEvent = portDidChange
362380
? { selectedPort: portToSelect, previousSelectedPort }
@@ -377,11 +395,22 @@ export class BoardsServiceProvider
377395
return false;
378396
}
379397

380-
this.maybeUpdateBoardListHistory({ portToSelect, boardToSelect });
381-
this.maybeUpdateBoardsData({ boardToSelect, reason });
398+
// unlike for the board change event, every persistent state must not contain custom board config options in the FQBN
399+
const sanitizedBoardToSelect = sanitizeBoardToSelectFQBN(boardToSelect);
400+
401+
this.maybeUpdateBoardListHistory({
402+
portToSelect,
403+
boardToSelect: sanitizedBoardToSelect,
404+
});
405+
this.maybeUpdateBoardsData({
406+
boardToSelect: sanitizedBoardToSelect,
407+
reason,
408+
});
382409

383410
if (isBoardIdentifierChangeEvent(event)) {
384-
this._boardsConfig.selectedBoard = event.selectedBoard;
411+
this._boardsConfig.selectedBoard = event.selectedBoard
412+
? sanitizeBoardIdentifierFQBN(event.selectedBoard)
413+
: event.selectedBoard;
385414
}
386415
if (isPortIdentifierChangeEvent(event)) {
387416
this._boardsConfig.selectedPort = event.selectedPort;

‎arduino-ide-extension/src/browser/contributions/ino-language.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import {
1010
BoardsService,
1111
ExecutableService,
1212
isBoardIdentifierChangeEvent,
13+
sanitizeFqbn,
1314
} from '../../common/protocol';
14-
import { FQBN } from 'fqbn';
1515
import {
1616
defaultAsyncWorkers,
1717
maxAsyncWorkers,
@@ -158,11 +158,11 @@ export class InoLanguage extends SketchContribution {
158158
this.notificationCenter.onDidReinitialize(() => forceRestart()),
159159
this.boardDataStore.onDidChange((event) => {
160160
if (this.languageServerFqbn) {
161-
const sanitizedFqbn = new FQBN(this.languageServerFqbn).sanitize();
161+
const sanitizedFQBN = sanitizeFqbn(this.languageServerFqbn);
162162
// The incoming FQBNs might contain custom boards configs, sanitize them before the comparison.
163163
// https://github.com/arduino/arduino-ide/pull/2113#pullrequestreview-1499998328
164-
const matchingChange = event.changes.find((change) =>
165-
new FQBN(change.fqbn).sanitize().equals(sanitizedFqbn)
164+
const matchingChange = event.changes.find(
165+
(change) => sanitizedFQBN === sanitizeFqbn(change.fqbn)
166166
);
167167
const { boardsConfig } = this.boardsServiceProvider;
168168
if (

‎arduino-ide-extension/src/common/protocol/boards-service.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -541,6 +541,15 @@ export namespace Board {
541541
}
542542
}
543543

544+
/**
545+
* Converts the `VENDOR:ARCHITECTURE:BOARD_ID[:MENU_ID=OPTION_ID[,MENU2_ID=OPTION_ID ...]]` FQBN to
546+
* `VENDOR:ARCHITECTURE:BOARD_ID` format.
547+
* See the details of the `{build.fqbn}` entry in the [specs](https://arduino.github.io/arduino-cli/latest/platform-specification/#global-predefined-properties).
548+
*/
549+
export function sanitizeFqbn(fqbn: string): string {
550+
return new FQBN(fqbn).sanitize().toString();
551+
}
552+
544553
export type PlatformIdentifier = Readonly<{ vendorId: string; arch: string }>;
545554
export function createPlatformIdentifier(
546555
board: BoardWithPackage

‎arduino-ide-extension/src/test/browser/board-service-provider.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,36 @@ describe('board-service-provider', () => {
170170
expect(events).deep.equals([expectedEvent]);
171171
});
172172

173+
it('should ignore custom board configs from the FQBN', () => {
174+
boardsServiceProvider['_boardsConfig'] = {
175+
selectedBoard: uno,
176+
selectedPort: unoSerialPort,
177+
};
178+
const events: BoardsConfigChangeEvent[] = [];
179+
toDisposeAfterEach.push(
180+
boardsServiceProvider.onBoardsConfigDidChange((event) =>
181+
events.push(event)
182+
)
183+
);
184+
const mkr1000WithCustomOptions = {
185+
...mkr1000,
186+
fqbn: `${mkr1000.fqbn}:c1=v1`,
187+
};
188+
const didUpdate = boardsServiceProvider.updateConfig(
189+
mkr1000WithCustomOptions
190+
);
191+
expect(didUpdate).to.be.true;
192+
const expectedEvent: BoardIdentifierChangeEvent = {
193+
previousSelectedBoard: uno,
194+
selectedBoard: mkr1000WithCustomOptions, // the even has the custom board options
195+
};
196+
expect(events).deep.equals([expectedEvent]);
197+
// the persisted state does not have the config options property
198+
expect(boardsServiceProvider.boardsConfig.selectedBoard?.fqbn).to.equal(
199+
mkr1000.fqbn
200+
);
201+
});
202+
173203
it('should not update the board if did not change (board identifier)', () => {
174204
boardsServiceProvider['_boardsConfig'] = {
175205
selectedBoard: uno,

‎arduino-ide-extension/src/test/browser/boards-data-store.test.ts

Lines changed: 220 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,14 @@ import {
1515
DisposableCollection,
1616
} from '@theia/core/lib/common/disposable';
1717
import { MessageService } from '@theia/core/lib/common/message-service';
18-
import { wait } from '@theia/core/lib/common/promise-util';
18+
import { wait, waitForEvent } from '@theia/core/lib/common/promise-util';
1919
import { Container, ContainerModule } from '@theia/core/shared/inversify';
2020
import { expect } from 'chai';
2121
import { BoardsDataStore } from '../../browser/boards/boards-data-store';
22-
import { BoardsServiceProvider } from '../../browser/boards/boards-service-provider';
22+
import {
23+
BoardsServiceProvider,
24+
UpdateBoardsConfigParams,
25+
} from '../../browser/boards/boards-service-provider';
2326
import { NotificationCenter } from '../../browser/notification-center';
2427
import {
2528
BoardDetails,
@@ -30,6 +33,7 @@ import {
3033
} from '../../common/protocol/boards-service';
3134
import { NotificationServiceServer } from '../../common/protocol/notification-service';
3235
import { bindBrowser } from './browser-test-bindings';
36+
import { unoSerialPort } from '../common/fixtures';
3337

3438
disableJSDOM();
3539

@@ -438,6 +442,220 @@ describe('boards-data-store', function () {
438442
});
439443
});
440444

445+
it('should select multiple config options', async () => {
446+
// reconfigure the board details mock for this test case to have multiple config options
447+
toDisposeAfterEach.push(
448+
mockBoardDetails([
449+
{
450+
fqbn,
451+
...baseDetails,
452+
configOptions: [configOption1, configOption2],
453+
},
454+
])
455+
);
456+
457+
let data = await boardsDataStore.getData(fqbn);
458+
expect(data).to.be.deep.equal({
459+
configOptions: [configOption1, configOption2],
460+
programmers: [edbg, jlink],
461+
});
462+
463+
let didChangeCounter = 0;
464+
toDisposeAfterEach.push(
465+
boardsDataStore.onDidChange(() => didChangeCounter++)
466+
);
467+
const result = await boardsDataStore.selectConfigOption({
468+
fqbn,
469+
optionsToUpdate: [
470+
{
471+
option: configOption1.option,
472+
selectedValue: configOption1.values[1].value,
473+
},
474+
{
475+
option: configOption2.option,
476+
selectedValue: configOption2.values[1].value,
477+
},
478+
],
479+
});
480+
expect(result).to.be.ok;
481+
expect(didChangeCounter).to.be.equal(1);
482+
483+
data = await boardsDataStore.getData(fqbn);
484+
expect(data).to.be.deep.equal({
485+
configOptions: [
486+
{
487+
...configOption1,
488+
values: [
489+
{ label: 'C1V1', selected: false, value: 'v1' },
490+
{ label: 'C1V2', selected: true, value: 'v2' },
491+
],
492+
},
493+
{
494+
...configOption2,
495+
values: [
496+
{ label: 'C2V1', selected: false, value: 'v1' },
497+
{ label: 'C2V2', selected: true, value: 'v2' },
498+
],
499+
},
500+
],
501+
programmers: [edbg, jlink],
502+
});
503+
});
504+
505+
it('should emit a did change event when updating with multiple config options and at least one of them is known (valid option + valid value)', async () => {
506+
// reconfigure the board details mock for this test case to have multiple config options
507+
toDisposeAfterEach.push(
508+
mockBoardDetails([
509+
{
510+
fqbn,
511+
...baseDetails,
512+
configOptions: [configOption1, configOption2],
513+
},
514+
])
515+
);
516+
517+
let data = await boardsDataStore.getData(fqbn);
518+
expect(data).to.be.deep.equal({
519+
configOptions: [configOption1, configOption2],
520+
programmers: [edbg, jlink],
521+
});
522+
523+
let didChangeCounter = 0;
524+
toDisposeAfterEach.push(
525+
boardsDataStore.onDidChange(() => didChangeCounter++)
526+
);
527+
const result = await boardsDataStore.selectConfigOption({
528+
fqbn,
529+
optionsToUpdate: [
530+
{
531+
option: 'an unknown option',
532+
selectedValue: configOption1.values[1].value,
533+
},
534+
{
535+
option: configOption1.option,
536+
selectedValue: configOption1.values[1].value,
537+
},
538+
{
539+
option: configOption2.option,
540+
selectedValue: 'an unknown value',
541+
},
542+
],
543+
});
544+
expect(result).to.be.ok;
545+
expect(didChangeCounter).to.be.equal(1);
546+
547+
data = await boardsDataStore.getData(fqbn);
548+
expect(data).to.be.deep.equal({
549+
configOptions: [
550+
{
551+
...configOption1,
552+
values: [
553+
{ label: 'C1V1', selected: false, value: 'v1' },
554+
{ label: 'C1V2', selected: true, value: 'v2' },
555+
],
556+
},
557+
configOption2,
558+
],
559+
programmers: [edbg, jlink],
560+
});
561+
});
562+
563+
it('should not emit a did change event when updating with multiple config options and all of the are unknown', async () => {
564+
let data = await boardsDataStore.getData(fqbn);
565+
expect(data).to.be.deep.equal({
566+
configOptions: [configOption1],
567+
programmers: [edbg, jlink],
568+
});
569+
570+
let didChangeCounter = 0;
571+
toDisposeAfterEach.push(
572+
boardsDataStore.onDidChange(() => didChangeCounter++)
573+
);
574+
const result = await boardsDataStore.selectConfigOption({
575+
fqbn,
576+
optionsToUpdate: [
577+
{
578+
option: 'an unknown option',
579+
selectedValue: configOption1.values[1].value,
580+
},
581+
{
582+
option: configOption1.option,
583+
selectedValue: 'an unknown value',
584+
},
585+
],
586+
});
587+
expect(result).to.be.not.ok;
588+
expect(didChangeCounter).to.be.equal(0);
589+
590+
data = await boardsDataStore.getData(fqbn);
591+
expect(data).to.be.deep.equal({
592+
configOptions: [configOption1],
593+
programmers: [edbg, jlink],
594+
});
595+
});
596+
597+
it("should automatically update the selected config options if the boards config change 'reason' is the 'toolbar' and the (CLI) detected FQBN has config options", async () => {
598+
// reconfigure the board details mock for this test case to have multiple config options
599+
toDisposeAfterEach.push(
600+
mockBoardDetails([
601+
{
602+
fqbn,
603+
...baseDetails,
604+
configOptions: [configOption1, configOption2],
605+
},
606+
])
607+
);
608+
609+
let data = await boardsDataStore.getData(fqbn);
610+
expect(data).to.be.deep.equal({
611+
configOptions: [configOption1, configOption2],
612+
programmers: [edbg, jlink],
613+
});
614+
615+
let didChangeCounter = 0;
616+
toDisposeAfterEach.push(
617+
boardsDataStore.onDidChange(() => didChangeCounter++)
618+
);
619+
620+
const boardsConfig = {
621+
selectedPort: unoSerialPort, // the port value does not matter here, but the change must come from a toolbar as a boards config: with port+board,
622+
selectedBoard: {
623+
fqbn: `${board.fqbn}:${configOption1.option}=${configOption1.values[1].value},${configOption2.option}=${configOption2.values[1].value}`,
624+
name: board.name,
625+
},
626+
};
627+
const params: UpdateBoardsConfigParams = {
628+
...boardsConfig,
629+
reason: 'toolbar',
630+
};
631+
const updated = boardsServiceProvider.updateConfig(params);
632+
expect(updated).to.be.ok;
633+
634+
await waitForEvent(boardsDataStore.onDidChange, 100);
635+
636+
expect(didChangeCounter).to.be.equal(1);
637+
data = await boardsDataStore.getData(fqbn);
638+
expect(data).to.be.deep.equal({
639+
configOptions: [
640+
{
641+
...configOption1,
642+
values: [
643+
{ label: 'C1V1', selected: false, value: 'v1' },
644+
{ label: 'C1V2', selected: true, value: 'v2' },
645+
],
646+
},
647+
{
648+
...configOption2,
649+
values: [
650+
{ label: 'C2V1', selected: false, value: 'v1' },
651+
{ label: 'C2V2', selected: true, value: 'v2' },
652+
],
653+
},
654+
],
655+
programmers: [edbg, jlink],
656+
});
657+
});
658+
441659
it('should not select a config option if the option is absent', async () => {
442660
const fqbn = 'a:b:c';
443661
let data = await boardsDataStore.getData(fqbn);

0 commit comments

Comments
 (0)
Please sign in to comment.