Skip to content

Commit ce35607

Browse files
committed
chore(import): add functionality for import validation and parsing logic
1 parent 9b040e7 commit ce35607

File tree

4 files changed

+373
-63
lines changed

4 files changed

+373
-63
lines changed

src/app/app.component.spec.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,29 @@ import { AppComponent } from './app.component';
66
import { AppService } from './service/app.service';
77
import { LayoutService } from './service/layout.service';
88
import { SvgdefService } from './service/svgdef.service';
9+
import type { Layout, LoadLayout } from './model/types';
10+
11+
function b64(json: unknown): string {
12+
return Buffer.from(JSON.stringify(json)).toString('base64');
13+
}
14+
15+
const VALID_MAP = [[0, [[0, 0]]]];
16+
17+
function makeBoard(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
18+
return { id: 'test-id', name: 'Test Board', map: VALID_MAP, ...overrides };
19+
}
20+
21+
function makeMah(boards: unknown[] = [makeBoard()]): unknown {
22+
return { mah: '1.0', boards };
23+
}
24+
25+
const MOCK_LAYOUT: Layout = {
26+
id: 'test-id',
27+
name: 'Test Board',
28+
category: 'Classic',
29+
mapping: [],
30+
custom: true
31+
};
932

1033
describe('AppComponent', () => {
1134
beforeEach(async () => TestBed.configureTestingModule({
@@ -18,4 +41,75 @@ describe('AppComponent', () => {
1841
const app = fixture.debugElement.componentInstance;
1942
expect(app).toBeTruthy();
2043
});
44+
45+
describe('checkImport', () => {
46+
let app: AppComponent;
47+
let layoutService: LayoutService;
48+
49+
beforeEach(() => {
50+
const fixture = TestBed.createComponent(AppComponent);
51+
app = fixture.componentInstance;
52+
layoutService = app.layoutService;
53+
layoutService.layouts.items = [];
54+
jest.spyOn(layoutService, 'expandLayout').mockReturnValue(MOCK_LAYOUT);
55+
jest.spyOn(layoutService, 'storeCustomBoards').mockImplementation(() => undefined);
56+
});
57+
58+
const checkImport = (app: AppComponent, input: string | null): Promise<Array<string>> =>
59+
(app as unknown as Record<string, (s: string | null) => Promise<Array<string>>>)['checkImport'](input);
60+
61+
it('returns [] for null input', async () => {
62+
expect(await checkImport(app, null)).toEqual([]);
63+
expect(layoutService.storeCustomBoards).not.toHaveBeenCalled();
64+
});
65+
66+
it('imports a valid board and returns its id', async () => {
67+
const result = await checkImport(app, b64(makeMah()));
68+
expect(result).toEqual(['test-id']);
69+
expect(layoutService.storeCustomBoards).toHaveBeenCalledTimes(1);
70+
});
71+
72+
it('does not re-import a board already in layouts', async () => {
73+
layoutService.layouts.items = [MOCK_LAYOUT];
74+
const result = await checkImport(app, b64(makeMah()));
75+
expect(result).toEqual(['test-id']);
76+
expect(layoutService.storeCustomBoards).not.toHaveBeenCalled();
77+
});
78+
79+
it('imports multiple valid boards', async () => {
80+
const board2 = makeBoard({ id: 'id-2', name: 'Board 2' });
81+
const layout2: Layout = { ...MOCK_LAYOUT, id: 'id-2', name: 'Board 2' };
82+
(layoutService.expandLayout as jest.Mock)
83+
.mockReturnValueOnce(MOCK_LAYOUT)
84+
.mockReturnValueOnce(layout2);
85+
const result = await checkImport(app, b64(makeMah([makeBoard(), board2])));
86+
expect(result).toEqual(['test-id', 'id-2']);
87+
expect(layoutService.storeCustomBoards).toHaveBeenCalledTimes(1);
88+
});
89+
90+
it('does not store duplicate board ids within the same import', async () => {
91+
const result = await checkImport(app, b64(makeMah([makeBoard(), makeBoard()])));
92+
expect(result).toEqual(['test-id', 'test-id']);
93+
const storedBoards: Array<LoadLayout> = (layoutService.storeCustomBoards as jest.Mock).mock.calls[0][0];
94+
expect(storedBoards).toHaveLength(1);
95+
});
96+
97+
it('skips a board when expandLayout throws', async () => {
98+
(layoutService.expandLayout as jest.Mock).mockImplementationOnce(() => {
99+
throw new Error('expand error');
100+
});
101+
expect(await checkImport(app, b64(makeMah()))).toEqual([]);
102+
expect(layoutService.storeCustomBoards).not.toHaveBeenCalled();
103+
});
104+
105+
it('logs a warning when boards were parsed but none could be expanded', async () => {
106+
(layoutService.expandLayout as jest.Mock).mockImplementation(() => {
107+
throw new Error('expand error');
108+
});
109+
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);
110+
await checkImport(app, b64(makeMah()));
111+
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('no valid boards'));
112+
warnSpy.mockRestore();
113+
});
114+
});
21115
});

src/app/app.component.ts

Lines changed: 22 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@ import { environment } from '../environments/environment';
44
import { AppService } from './service/app.service';
55
import { LayoutService } from './service/layout.service';
66
import { log } from './model/log';
7-
import type { LoadLayout, MahFormat } from './model/types';
7+
import type { LoadLayout } from './model/types';
8+
import { parseImportString } from './model/import';
89
import { GameComponent } from './components/game/game-component.component';
910

1011
type onWindowBlur = (callback: () => void) => void;
@@ -115,72 +116,30 @@ export class AppComponent implements OnInit {
115116
}
116117

117118
private async checkImport(base64jsonString: string | null): Promise<Array<string>> {
118-
if (!base64jsonString) {
119-
return [];
120-
}
121-
try {
122-
let decoded: string;
123-
try {
124-
decoded = atob(base64jsonString);
125-
} catch (error) {
126-
log.warn('Import failed: Invalid base64 encoding', error);
127-
return [];
128-
}
129-
130-
let parsed: unknown;
119+
const boards = parseImportString(base64jsonString);
120+
const result: Array<string> = [];
121+
const imported: Array<LoadLayout> = [];
122+
for (const custom of boards) {
131123
try {
132-
parsed = JSON.parse(decoded);
133-
} catch (error) {
134-
log.warn('Import failed: Invalid JSON format', error);
135-
return [];
136-
}
137-
138-
const mah = parsed as MahFormat;
139-
if (!mah.mah || mah.mah !== '1.0') {
140-
log.warn('Import failed: Invalid or unsupported MAH format version');
141-
return [];
142-
}
143-
144-
if (!Array.isArray(mah.boards)) {
145-
log.warn('Import failed: Missing or invalid boards array');
146-
return [];
147-
}
148-
149-
if (mah.boards.length === 0) {
150-
log.warn('Import failed: No boards found in import data');
151-
return [];
152-
}
153-
154-
const result: Array<string> = [];
155-
const imported: Array<LoadLayout> = [];
156-
for (const custom of mah.boards) {
157-
try {
158-
const layout = this.layoutService.expandLayout(custom, true);
159-
result.push(layout.id);
160-
if (
161-
!this.layoutService.layouts.items.some(l => l.id === layout.id) &&
162-
!imported.some(l => l.id === layout.id)
163-
) {
164-
imported.push(LayoutService.layout2loadLayout(layout, custom.map));
165-
}
166-
} catch (error) {
167-
log.warn('Failed to import individual board:', error);
124+
const layout = this.layoutService.expandLayout(custom, true);
125+
result.push(layout.id);
126+
if (
127+
!this.layoutService.layouts.items.some(l => l.id === layout.id) &&
128+
!imported.some(l => l.id === layout.id)
129+
) {
130+
imported.push(LayoutService.layout2loadLayout(layout, custom.map));
168131
}
132+
} catch (error) {
133+
log.warn('Failed to import individual board:', error);
169134
}
170-
171-
if (imported.length > 0) {
172-
this.layoutService.storeCustomBoards(imported);
173-
}
174-
175-
if (result.length === 0) {
176-
log.warn('Import completed but no valid boards were imported');
177-
}
178-
179-
return result;
180-
} catch (error) {
181-
log.error('Unexpected error during import:', error);
182-
return [];
183135
}
136+
if (imported.length > 0) {
137+
this.layoutService.storeCustomBoards(imported);
138+
}
139+
if (boards.length > 0 && result.length === 0) {
140+
log.warn('Import completed but no valid boards were imported');
141+
}
142+
return result;
184143
}
185144

186145
private registerWindowListeners(): void {

src/app/model/import.spec.ts

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
import { isValidLoadLayout, MAX_IMPORT_BOARDS, parseImportString } from './import';
2+
3+
function b64(json: unknown): string {
4+
return Buffer.from(JSON.stringify(json)).toString('base64');
5+
}
6+
7+
const VALID_MAP = [[0, [[0, 0]]]];
8+
9+
function makeBoard(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
10+
return { id: 'test-id', name: 'Test Board', map: VALID_MAP, ...overrides };
11+
}
12+
13+
function makeMah(boards: unknown[] = [makeBoard()]): unknown {
14+
return { mah: '1.0', boards };
15+
}
16+
17+
describe('isValidLoadLayout', () => {
18+
it('returns false for null', () => {
19+
expect(isValidLoadLayout(null)).toBe(false);
20+
});
21+
22+
it('returns false for undefined', () => {
23+
expect(isValidLoadLayout(undefined)).toBe(false);
24+
});
25+
26+
it('returns false for a string', () => {
27+
expect(isValidLoadLayout('hello')).toBe(false);
28+
});
29+
30+
it('returns false for a number', () => {
31+
expect(isValidLoadLayout(42)).toBe(false);
32+
});
33+
34+
it('returns false for an array', () => {
35+
expect(isValidLoadLayout([])).toBe(false);
36+
});
37+
38+
it('returns false when name is missing', () => {
39+
expect(isValidLoadLayout({ map: VALID_MAP })).toBe(false);
40+
});
41+
42+
it('returns false when name is not a string', () => {
43+
expect(isValidLoadLayout({ name: 123, map: VALID_MAP })).toBe(false);
44+
});
45+
46+
it('returns false when name is empty string', () => {
47+
expect(isValidLoadLayout({ name: '', map: VALID_MAP })).toBe(false);
48+
});
49+
50+
it('returns false when name is whitespace only', () => {
51+
expect(isValidLoadLayout({ name: ' ', map: VALID_MAP })).toBe(false);
52+
});
53+
54+
it('returns false when name exceeds 200 characters', () => {
55+
expect(isValidLoadLayout({ name: 'a'.repeat(201), map: VALID_MAP })).toBe(false);
56+
});
57+
58+
it('returns false when map is missing', () => {
59+
expect(isValidLoadLayout({ name: 'Test' })).toBe(false);
60+
});
61+
62+
it('returns false when map is not an array', () => {
63+
expect(isValidLoadLayout({ name: 'Test', map: 'invalid' })).toBe(false);
64+
});
65+
66+
it('returns false when map is an object', () => {
67+
expect(isValidLoadLayout({ name: 'Test', map: {} })).toBe(false);
68+
});
69+
70+
it('returns false when id is not a string', () => {
71+
expect(isValidLoadLayout({ name: 'Test', map: VALID_MAP, id: 123 })).toBe(false);
72+
});
73+
74+
it('returns false when id exceeds 200 characters', () => {
75+
expect(isValidLoadLayout({ name: 'Test', map: VALID_MAP, id: 'a'.repeat(201) })).toBe(false);
76+
});
77+
78+
it('returns false when by is not a string', () => {
79+
expect(isValidLoadLayout({ name: 'Test', map: VALID_MAP, by: 99 })).toBe(false);
80+
});
81+
82+
it('returns false when by exceeds 200 characters', () => {
83+
expect(isValidLoadLayout({ name: 'Test', map: VALID_MAP, by: 'a'.repeat(201) })).toBe(false);
84+
});
85+
86+
it('returns false when cat is not a string', () => {
87+
expect(isValidLoadLayout({ name: 'Test', map: VALID_MAP, cat: true })).toBe(false);
88+
});
89+
90+
it('returns false when cat exceeds 200 characters', () => {
91+
expect(isValidLoadLayout({ name: 'Test', map: VALID_MAP, cat: 'a'.repeat(201) })).toBe(false);
92+
});
93+
94+
it('returns true for a valid minimal board (name + map)', () => {
95+
expect(isValidLoadLayout({ name: 'Test', map: VALID_MAP })).toBe(true);
96+
});
97+
98+
it('returns true when id is exactly 200 characters', () => {
99+
expect(isValidLoadLayout({ name: 'Test', map: VALID_MAP, id: 'a'.repeat(200) })).toBe(true);
100+
});
101+
102+
it('returns true for a fully specified valid board', () => {
103+
expect(isValidLoadLayout({ id: 'my-id', name: 'Test Board', map: VALID_MAP, by: 'Author', cat: 'Classic' })).toBe(true);
104+
});
105+
106+
it('returns true when optional fields are undefined', () => {
107+
expect(isValidLoadLayout({ name: 'Test', map: VALID_MAP, id: undefined, by: undefined, cat: undefined })).toBe(true);
108+
});
109+
});
110+
111+
describe('parseImportString', () => {
112+
it('returns [] for null input', () => {
113+
expect(parseImportString(null)).toEqual([]);
114+
});
115+
116+
it('returns [] for empty string input', () => {
117+
expect(parseImportString('')).toEqual([]);
118+
});
119+
120+
it('returns [] for invalid base64', () => {
121+
expect(parseImportString('!!!not-base64!!!')).toEqual([]);
122+
});
123+
124+
it('returns [] for valid base64 but invalid JSON', () => {
125+
const notJson = Buffer.from('not json at all').toString('base64');
126+
expect(parseImportString(notJson)).toEqual([]);
127+
});
128+
129+
it('returns [] when parsed JSON is null (triggers outer catch)', () => {
130+
expect(parseImportString(b64(null))).toEqual([]);
131+
});
132+
133+
it('returns [] when mah field is missing', () => {
134+
expect(parseImportString(b64({ boards: [] }))).toEqual([]);
135+
});
136+
137+
it('returns [] when mah version is wrong', () => {
138+
expect(parseImportString(b64({ mah: '2.0', boards: [] }))).toEqual([]);
139+
});
140+
141+
it('returns [] when boards is not an array', () => {
142+
expect(parseImportString(b64({ mah: '1.0', boards: 'oops' }))).toEqual([]);
143+
});
144+
145+
it('returns [] when boards array is empty', () => {
146+
expect(parseImportString(b64({ mah: '1.0', boards: [] }))).toEqual([]);
147+
});
148+
149+
it('returns [] when board count exceeds MAX_IMPORT_BOARDS', () => {
150+
const boards = Array.from({ length: MAX_IMPORT_BOARDS + 1 }, (_, i) => makeBoard({ id: `id-${i}` }));
151+
expect(parseImportString(b64({ mah: '1.0', boards }))).toEqual([]);
152+
});
153+
154+
it('skips boards with invalid structure', () => {
155+
const data = b64(makeMah([{ name: 123 }, null, 'string']));
156+
expect(parseImportString(data)).toEqual([]);
157+
});
158+
159+
it('returns only valid boards from a mixed list', () => {
160+
const data = b64(makeMah([{ name: 123 }, makeBoard()]));
161+
const result = parseImportString(data);
162+
expect(result).toHaveLength(1);
163+
expect(result[0].name).toBe('Test Board');
164+
});
165+
166+
it('returns all valid boards', () => {
167+
const boards = [makeBoard({ id: 'id-1', name: 'Board 1' }), makeBoard({ id: 'id-2', name: 'Board 2' })];
168+
const result = parseImportString(b64(makeMah(boards)));
169+
expect(result).toHaveLength(2);
170+
expect(result[0].id).toBe('id-1');
171+
expect(result[1].id).toBe('id-2');
172+
});
173+
});

0 commit comments

Comments
 (0)