@zthun/romulator-web 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/dist/assets/index-CtJiKvl7.js +52645 -0
- package/dist/favicon.ico +0 -0
- package/dist/index.html +12 -0
- package/dist/png/romulator-256x256.png +0 -0
- package/dist/systems/uk/megadrive-256x256.png +0 -0
- package/dist/systems/us/megadrive-256x256.png +0 -0
- package/dist/systems/us/nes-256x256.png +0 -0
- package/dist/systems/us/snes-256x256.png +0 -0
- package/index.html +12 -0
- package/package.json +48 -0
- package/public/favicon.ico +0 -0
- package/public/png/romulator-256x256.png +0 -0
- package/public/systems/uk/megadrive-256x256.png +0 -0
- package/public/systems/us/megadrive-256x256.png +0 -0
- package/public/systems/us/nes-256x256.png +0 -0
- package/public/systems/us/snes-256x256.png +0 -0
- package/src/app/app-avatar.tsx +8 -0
- package/src/app/app-title.tsx +10 -0
- package/src/app/app.tsx +41 -0
- package/src/environment/environment-service.mts +19 -0
- package/src/environment/environment.mts +29 -0
- package/src/index.tsx +14 -0
- package/src/menu/menu.cm.mts +60 -0
- package/src/menu/menu.spec.tsx +115 -0
- package/src/menu/menu.tsx +130 -0
- package/src/settings/setting-page.cm.mts +22 -0
- package/src/settings/setting-page.spec.tsx +154 -0
- package/src/settings/setting-page.tsx +83 -0
- package/src/settings/settings-page.cm.mts +18 -0
- package/src/settings/settings-page.spec.tsx +99 -0
- package/src/settings/settings-page.tsx +82 -0
- package/src/settings/settings-service.mts +38 -0
- package/src/systems/system-avatar-card.cm.mts +17 -0
- package/src/systems/system-avatar-card.tsx +41 -0
- package/src/systems/system-page.cm.mts +33 -0
- package/src/systems/system-page.spec.tsx +127 -0
- package/src/systems/system-page.tsx +48 -0
- package/src/systems/systems-page.cm.mts +39 -0
- package/src/systems/systems-page.spec.tsx +81 -0
- package/src/systems/systems-page.tsx +48 -0
- package/src/systems/systems-service.mts +65 -0
- package/src/systems/systems-service.spec.ts +144 -0
- package/tsconfig.json +10 -0
- package/vite.config.mts +9 -0
- package/vitest.config.mts +9 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ZCircusBy,
|
|
3
|
+
type IZCircusDriver,
|
|
4
|
+
type IZCircusSetup,
|
|
5
|
+
} from "@zthun/cirque";
|
|
6
|
+
import { ZCircusSetupRenderer } from "@zthun/cirque-du-react";
|
|
7
|
+
import { ZRoute, ZRouteMap, ZTestRouter } from "@zthun/fashion-boutique";
|
|
8
|
+
import {
|
|
9
|
+
ZRomulatorConfigBuilder,
|
|
10
|
+
ZRomulatorConfigGamesBuilder,
|
|
11
|
+
ZRomulatorConfigGamesMetadata,
|
|
12
|
+
ZRomulatorConfigId,
|
|
13
|
+
} from "@zthun/romulator-client";
|
|
14
|
+
import { createMemoryHistory, type MemoryHistory } from "history";
|
|
15
|
+
import type { Mocked } from "vitest";
|
|
16
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
17
|
+
import { mock } from "vitest-mock-extended";
|
|
18
|
+
import { ZRomulatorSettingPageComponentModel } from "./setting-page.cm.mjs";
|
|
19
|
+
import { ZRomulatorSettingPage } from "./setting-page.js";
|
|
20
|
+
import {
|
|
21
|
+
ZRomulatorSettingsContext,
|
|
22
|
+
type IZRomulatorSettingsService,
|
|
23
|
+
} from "./settings-service.mjs";
|
|
24
|
+
|
|
25
|
+
describe("ZRomulatorSettingPage", () => {
|
|
26
|
+
const _gamesContent = new ZRomulatorConfigGamesBuilder()
|
|
27
|
+
.gamesFolder("/path/to/games")
|
|
28
|
+
.build();
|
|
29
|
+
const _gamesFolder = ZRomulatorConfigGamesMetadata.gamesFolder();
|
|
30
|
+
const _games = new ZRomulatorConfigBuilder()
|
|
31
|
+
.id(ZRomulatorConfigId.Games)
|
|
32
|
+
.contents(_gamesContent)
|
|
33
|
+
.name("Games")
|
|
34
|
+
.description("Games config")
|
|
35
|
+
.metadata(_gamesFolder)
|
|
36
|
+
.build();
|
|
37
|
+
|
|
38
|
+
let _history: MemoryHistory;
|
|
39
|
+
let _settings: Mocked<IZRomulatorSettingsService>;
|
|
40
|
+
let _renderer: IZCircusSetup;
|
|
41
|
+
let _driver: IZCircusDriver;
|
|
42
|
+
|
|
43
|
+
const createTestTarget = async () => {
|
|
44
|
+
const element = (
|
|
45
|
+
<ZRomulatorSettingsContext value={_settings}>
|
|
46
|
+
<ZTestRouter location={_history.location} navigator={_history}>
|
|
47
|
+
<ZRouteMap>
|
|
48
|
+
<ZRoute path="/settings/:id" element={<ZRomulatorSettingPage />} />
|
|
49
|
+
</ZRouteMap>
|
|
50
|
+
</ZTestRouter>
|
|
51
|
+
</ZRomulatorSettingsContext>
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
_renderer = new ZCircusSetupRenderer(element);
|
|
55
|
+
_driver = await _renderer.setup();
|
|
56
|
+
|
|
57
|
+
return ZCircusBy.first(_driver, ZRomulatorSettingPageComponentModel);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const loadTestTarget = async () => {
|
|
61
|
+
const target = await createTestTarget();
|
|
62
|
+
const suspense = await target.suspense();
|
|
63
|
+
await suspense.load();
|
|
64
|
+
return target;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
_settings = mock<IZRomulatorSettingsService>();
|
|
69
|
+
_settings.get.mockResolvedValue(_games);
|
|
70
|
+
|
|
71
|
+
_history = createMemoryHistory({
|
|
72
|
+
initialEntries: [`/settings/${_games.id}`],
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterEach(async () => {
|
|
77
|
+
await _driver?.destroy?.call(_driver);
|
|
78
|
+
await _renderer?.destroy?.call(_renderer);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("Header", () => {
|
|
82
|
+
it("should set the page title to the name of the config", async () => {
|
|
83
|
+
// Arrange.
|
|
84
|
+
const target = await loadTestTarget();
|
|
85
|
+
const card = await target.card();
|
|
86
|
+
const title = await card.title();
|
|
87
|
+
|
|
88
|
+
// Act.
|
|
89
|
+
const heading = await title.heading();
|
|
90
|
+
const actual = await heading?.text();
|
|
91
|
+
|
|
92
|
+
// Assert.
|
|
93
|
+
expect(actual).toEqual(_games.name);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("should set the subheading to the description of the config", async () => {
|
|
97
|
+
// Arrange.
|
|
98
|
+
const target = await loadTestTarget();
|
|
99
|
+
const card = await target.card();
|
|
100
|
+
const title = await card.title();
|
|
101
|
+
|
|
102
|
+
// Act.
|
|
103
|
+
const subHeading = await title.subHeading();
|
|
104
|
+
const actual = await subHeading?.text();
|
|
105
|
+
|
|
106
|
+
// Assert.
|
|
107
|
+
expect(actual).toEqual(_games.description);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("Reset", () => {
|
|
112
|
+
it("should reset the form meta when the reset button is clicked", async () => {
|
|
113
|
+
// Arrange.
|
|
114
|
+
const target = await loadTestTarget();
|
|
115
|
+
const form = await target.form();
|
|
116
|
+
const field = await form.field(_gamesFolder.id);
|
|
117
|
+
|
|
118
|
+
// Act.
|
|
119
|
+
const folder = await field.text();
|
|
120
|
+
await folder?.keyboard("games");
|
|
121
|
+
const reset = await form.button("reset");
|
|
122
|
+
const btn = await reset.underlying();
|
|
123
|
+
await btn.click();
|
|
124
|
+
const actual = await folder?.value();
|
|
125
|
+
|
|
126
|
+
// Assert.
|
|
127
|
+
expect(actual).toEqual(_gamesContent.gamesFolder);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("Save", () => {
|
|
132
|
+
it("should save the form when the save button is clicked", async () => {
|
|
133
|
+
// Arrange.
|
|
134
|
+
const target = await loadTestTarget();
|
|
135
|
+
const form = await target.form();
|
|
136
|
+
const field = await form.field(_gamesFolder.id);
|
|
137
|
+
const gamesFolder = "games";
|
|
138
|
+
const expected = {
|
|
139
|
+
contents: expect.objectContaining({ gamesFolder }),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Act.
|
|
143
|
+
const folder = await field.text();
|
|
144
|
+
await folder?.clear();
|
|
145
|
+
await folder?.keyboard(gamesFolder);
|
|
146
|
+
const save = await form.button("submit");
|
|
147
|
+
const btn = await save.underlying();
|
|
148
|
+
await btn.click();
|
|
149
|
+
|
|
150
|
+
// Assert.
|
|
151
|
+
expect(_settings.update).toHaveBeenCalledWith(_games.id, expected);
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useParams,
|
|
3
|
+
ZBreadcrumbsLocation,
|
|
4
|
+
ZCard,
|
|
5
|
+
ZForm,
|
|
6
|
+
ZFormButton,
|
|
7
|
+
ZFormField,
|
|
8
|
+
ZIconFontAwesome,
|
|
9
|
+
ZStack,
|
|
10
|
+
ZSuspenseProgress,
|
|
11
|
+
} from "@zthun/fashion-boutique";
|
|
12
|
+
import { ZSizeFixed } from "@zthun/fashion-tailor";
|
|
13
|
+
import { firstDefined, ZOrientation } from "@zthun/helpful-fn";
|
|
14
|
+
import type { IZMetadata } from "@zthun/helpful-query";
|
|
15
|
+
import { asStateData, isStateLoading } from "@zthun/helpful-react";
|
|
16
|
+
import type { ZRomulatorConfigId } from "@zthun/romulator-client";
|
|
17
|
+
import { useCallback } from "react";
|
|
18
|
+
import { useSetting, useSettingsService } from "./settings-service.mjs";
|
|
19
|
+
|
|
20
|
+
export function ZRomulatorSettingPage() {
|
|
21
|
+
const { id } = useParams();
|
|
22
|
+
const [setting, setSetting] = useSetting(id as ZRomulatorConfigId);
|
|
23
|
+
const _setting = asStateData(setting);
|
|
24
|
+
const metadata = firstDefined([], _setting?.metadata);
|
|
25
|
+
const service = useSettingsService();
|
|
26
|
+
|
|
27
|
+
const renderField = useCallback((meta: IZMetadata) => {
|
|
28
|
+
return <ZFormField key={meta.id} metadata={meta} />;
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
const handleUpdateConfig = async (contents: any) => {
|
|
32
|
+
try {
|
|
33
|
+
const updated = await service.update(_setting!.id, { contents });
|
|
34
|
+
setSetting(updated);
|
|
35
|
+
} catch {
|
|
36
|
+
// TODO: Error Handling
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<ZStack className="ZRomulatorSettingPage-root" gap={ZSizeFixed.Medium}>
|
|
42
|
+
<ZBreadcrumbsLocation />
|
|
43
|
+
<ZForm value={_setting?.contents} onValueChange={handleUpdateConfig}>
|
|
44
|
+
<ZCard
|
|
45
|
+
TitleProps={{
|
|
46
|
+
avatar: (
|
|
47
|
+
<ZIconFontAwesome
|
|
48
|
+
name={_setting?.avatar}
|
|
49
|
+
width={ZSizeFixed.Medium}
|
|
50
|
+
/>
|
|
51
|
+
),
|
|
52
|
+
heading: _setting?.name,
|
|
53
|
+
subHeading: _setting?.description,
|
|
54
|
+
suffix: (
|
|
55
|
+
<ZStack
|
|
56
|
+
orientation={ZOrientation.Horizontal}
|
|
57
|
+
gap={ZSizeFixed.Medium}
|
|
58
|
+
>
|
|
59
|
+
<ZFormButton
|
|
60
|
+
type="reset"
|
|
61
|
+
ButtonProps={{
|
|
62
|
+
label: (
|
|
63
|
+
<ZIconFontAwesome name="rotate-left" tooltip="Reset" />
|
|
64
|
+
),
|
|
65
|
+
}}
|
|
66
|
+
/>
|
|
67
|
+
<ZFormButton
|
|
68
|
+
type="submit"
|
|
69
|
+
ButtonProps={{
|
|
70
|
+
label: <ZIconFontAwesome name="save" tooltip="Save" />,
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
</ZStack>
|
|
74
|
+
),
|
|
75
|
+
}}
|
|
76
|
+
>
|
|
77
|
+
<ZSuspenseProgress disabled={!isStateLoading(setting)} />
|
|
78
|
+
{metadata.map(renderField)}
|
|
79
|
+
</ZCard>
|
|
80
|
+
</ZForm>
|
|
81
|
+
</ZStack>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { ZCircusBy, ZCircusComponentModel } from "@zthun/cirque";
|
|
2
|
+
import {
|
|
3
|
+
ZBoxComponentModel,
|
|
4
|
+
ZGridViewComponentModel,
|
|
5
|
+
} from "@zthun/fashion-boutique";
|
|
6
|
+
import type { ZRomulatorConfigId } from "@zthun/romulator-client";
|
|
7
|
+
|
|
8
|
+
export class ZRomulatorSettingsPageComponentModel extends ZCircusComponentModel {
|
|
9
|
+
public static readonly Selector = ".ZRomulatorSettingsPage-root";
|
|
10
|
+
|
|
11
|
+
public async grid(): Promise<ZGridViewComponentModel> {
|
|
12
|
+
return Promise.resolve(new ZGridViewComponentModel(this.driver));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
public async config(id: ZRomulatorConfigId): Promise<ZBoxComponentModel> {
|
|
16
|
+
return ZCircusBy.first(this.driver, ZBoxComponentModel, id);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { IZCircusDriver, IZCircusSetup } from "@zthun/cirque";
|
|
2
|
+
import { ZCircusBy } from "@zthun/cirque";
|
|
3
|
+
import { ZCircusSetupRenderer } from "@zthun/cirque-du-react";
|
|
4
|
+
import { ZTestRouter } from "@zthun/fashion-boutique";
|
|
5
|
+
import { ZDataSourceStatic } from "@zthun/helpful-query";
|
|
6
|
+
import {
|
|
7
|
+
ZRomulatorConfigBuilder,
|
|
8
|
+
ZRomulatorConfigId,
|
|
9
|
+
} from "@zthun/romulator-client";
|
|
10
|
+
import { createMemoryHistory, type MemoryHistory } from "history";
|
|
11
|
+
import type { Mocked } from "vitest";
|
|
12
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
13
|
+
import { mock } from "vitest-mock-extended";
|
|
14
|
+
import { ZRomulatorSettingsPageComponentModel } from "./settings-page.cm.mjs";
|
|
15
|
+
import { ZRomulatorSettingsPage } from "./settings-page.js";
|
|
16
|
+
import type { IZRomulatorSettingsService } from "./settings-service.mjs";
|
|
17
|
+
import { ZRomulatorSettingsContext } from "./settings-service.mjs";
|
|
18
|
+
|
|
19
|
+
describe("ZRomulatorSettingsPage", () => {
|
|
20
|
+
const _games = new ZRomulatorConfigBuilder()
|
|
21
|
+
.id(ZRomulatorConfigId.Games)
|
|
22
|
+
.name("Games")
|
|
23
|
+
.build();
|
|
24
|
+
const _emulators = new ZRomulatorConfigBuilder()
|
|
25
|
+
.id(ZRomulatorConfigId.Media)
|
|
26
|
+
.name("Media")
|
|
27
|
+
.build();
|
|
28
|
+
|
|
29
|
+
let _renderer: IZCircusSetup | undefined;
|
|
30
|
+
let _driver: IZCircusDriver | undefined;
|
|
31
|
+
let _settings: Mocked<IZRomulatorSettingsService>;
|
|
32
|
+
let _history: MemoryHistory;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
_history = createMemoryHistory();
|
|
36
|
+
|
|
37
|
+
const source = new ZDataSourceStatic([_games, _emulators]);
|
|
38
|
+
|
|
39
|
+
_settings = mock<IZRomulatorSettingsService>();
|
|
40
|
+
_settings.retrieve.mockImplementation((r) => source.retrieve(r));
|
|
41
|
+
_settings.count.mockImplementation((r) => source.count(r));
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterEach(async () => {
|
|
45
|
+
await _driver?.destroy?.call(_driver);
|
|
46
|
+
await _renderer?.destroy?.call(_renderer);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const createTestTarget = async () => {
|
|
50
|
+
const element = (
|
|
51
|
+
<ZTestRouter location={_history.location} navigator={_history}>
|
|
52
|
+
<ZRomulatorSettingsContext value={_settings}>
|
|
53
|
+
<ZRomulatorSettingsPage />
|
|
54
|
+
</ZRomulatorSettingsContext>
|
|
55
|
+
</ZTestRouter>
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
_renderer = new ZCircusSetupRenderer(element);
|
|
59
|
+
_driver = await _renderer.setup();
|
|
60
|
+
|
|
61
|
+
const target = await ZCircusBy.first(
|
|
62
|
+
_driver,
|
|
63
|
+
ZRomulatorSettingsPageComponentModel,
|
|
64
|
+
);
|
|
65
|
+
const grid = await target.grid();
|
|
66
|
+
const suspense = await grid.suspense();
|
|
67
|
+
await suspense.load();
|
|
68
|
+
|
|
69
|
+
return target;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
describe("Tiles", () => {
|
|
73
|
+
it("should have a tile for each config returned from the settings service", async () => {
|
|
74
|
+
// Arrange.
|
|
75
|
+
const target = await createTestTarget();
|
|
76
|
+
|
|
77
|
+
// Act.
|
|
78
|
+
const games = await target.config(ZRomulatorConfigId.Games);
|
|
79
|
+
const emulators = await target.config(ZRomulatorConfigId.Media);
|
|
80
|
+
|
|
81
|
+
// Assert.
|
|
82
|
+
expect(games).toBeTruthy();
|
|
83
|
+
expect(emulators).toBeTruthy();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("should navigate to an individual config page when a tile is clicked", async () => {
|
|
87
|
+
// Arrange.
|
|
88
|
+
const target = await createTestTarget();
|
|
89
|
+
const expected = `/${ZRomulatorConfigId.Games}`;
|
|
90
|
+
|
|
91
|
+
// Act.
|
|
92
|
+
const games = await target.config(ZRomulatorConfigId.Games);
|
|
93
|
+
await games.click();
|
|
94
|
+
|
|
95
|
+
// Assert.
|
|
96
|
+
expect(_history.location.pathname).toEqual(expected);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useFashionTheme,
|
|
3
|
+
useNavigate,
|
|
4
|
+
ZBox,
|
|
5
|
+
ZBreadcrumbsLocation,
|
|
6
|
+
ZCaption,
|
|
7
|
+
ZCard,
|
|
8
|
+
ZContentTitle,
|
|
9
|
+
ZGridView,
|
|
10
|
+
ZH3,
|
|
11
|
+
ZIconFontAwesome,
|
|
12
|
+
ZStack,
|
|
13
|
+
} from "@zthun/fashion-boutique";
|
|
14
|
+
import { ZSizeFixed } from "@zthun/fashion-tailor";
|
|
15
|
+
import { ZDataRequestBuilder } from "@zthun/helpful-query";
|
|
16
|
+
import type { IZRomulatorConfig } from "@zthun/romulator-client";
|
|
17
|
+
import { useState } from "react";
|
|
18
|
+
import { useSettingsService } from "./settings-service.mjs";
|
|
19
|
+
|
|
20
|
+
export function ZRomulatorSettingsPage() {
|
|
21
|
+
const { body } = useFashionTheme();
|
|
22
|
+
const navigate = useNavigate();
|
|
23
|
+
const settings = useSettingsService();
|
|
24
|
+
const [request] = useState(new ZDataRequestBuilder().build());
|
|
25
|
+
|
|
26
|
+
const renderTile = (config: IZRomulatorConfig) => {
|
|
27
|
+
return (
|
|
28
|
+
<ZBox
|
|
29
|
+
className="ZRomulatorSettingsPage-tile"
|
|
30
|
+
fashion={body}
|
|
31
|
+
interactive
|
|
32
|
+
key={config.id}
|
|
33
|
+
cursor="pointer"
|
|
34
|
+
padding={ZSizeFixed.Medium}
|
|
35
|
+
data-name={config.id}
|
|
36
|
+
onClick={() => navigate(config.id)}
|
|
37
|
+
>
|
|
38
|
+
<ZStack gap={ZSizeFixed.Medium}>
|
|
39
|
+
<ZContentTitle
|
|
40
|
+
avatar={
|
|
41
|
+
<ZIconFontAwesome
|
|
42
|
+
name={config.avatar}
|
|
43
|
+
width={ZSizeFixed.Medium}
|
|
44
|
+
/>
|
|
45
|
+
}
|
|
46
|
+
heading={<ZH3 compact>{config.name}</ZH3>}
|
|
47
|
+
subHeading={<ZCaption>{config.description}</ZCaption>}
|
|
48
|
+
/>
|
|
49
|
+
</ZStack>
|
|
50
|
+
</ZBox>
|
|
51
|
+
);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<ZStack gap={ZSizeFixed.Medium}>
|
|
56
|
+
<ZBreadcrumbsLocation />
|
|
57
|
+
<ZCard
|
|
58
|
+
TitleProps={{
|
|
59
|
+
heading: <ZH3 compact>Settings</ZH3>,
|
|
60
|
+
subHeading: <ZCaption compact>Modify configs and options</ZCaption>,
|
|
61
|
+
avatar: <ZIconFontAwesome name="gear" width={ZSizeFixed.Medium} />,
|
|
62
|
+
}}
|
|
63
|
+
>
|
|
64
|
+
<ZGridView
|
|
65
|
+
className="ZRomulatorSettingsPage-root"
|
|
66
|
+
GridProps={{
|
|
67
|
+
columns: {
|
|
68
|
+
xl: "1fr 1fr 1fr",
|
|
69
|
+
lg: "1fr 1fr",
|
|
70
|
+
sm: "1fr",
|
|
71
|
+
},
|
|
72
|
+
gap: ZSizeFixed.Medium,
|
|
73
|
+
}}
|
|
74
|
+
SearchProps={false}
|
|
75
|
+
dataSource={settings}
|
|
76
|
+
value={request}
|
|
77
|
+
renderItem={renderTile}
|
|
78
|
+
/>
|
|
79
|
+
</ZCard>
|
|
80
|
+
</ZStack>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { IZDataSource } from "@zthun/helpful-query";
|
|
2
|
+
import { useAsyncState } from "@zthun/helpful-react";
|
|
3
|
+
import type {
|
|
4
|
+
IZRomulatorConfig,
|
|
5
|
+
ZRomulatorConfigId,
|
|
6
|
+
} from "@zthun/romulator-client";
|
|
7
|
+
import { ZHttpService } from "@zthun/webigail-http";
|
|
8
|
+
import {
|
|
9
|
+
ZRestfulService,
|
|
10
|
+
type IZRestfulGet,
|
|
11
|
+
type IZRestfulUpdate,
|
|
12
|
+
} from "@zthun/webigail-rest";
|
|
13
|
+
import { createContext, useContext } from "react";
|
|
14
|
+
import { ZRomulatorEnvironmentBuilder } from "../environment/environment.mjs";
|
|
15
|
+
|
|
16
|
+
export interface IZRomulatorSettingsService
|
|
17
|
+
extends IZDataSource<IZRomulatorConfig>,
|
|
18
|
+
IZRestfulGet<IZRomulatorConfig>,
|
|
19
|
+
IZDataSource<IZRomulatorConfig>,
|
|
20
|
+
IZRestfulUpdate<IZRomulatorConfig> {}
|
|
21
|
+
|
|
22
|
+
export function createDefaultSettingsService(): IZRomulatorSettingsService {
|
|
23
|
+
const env = new ZRomulatorEnvironmentBuilder().build();
|
|
24
|
+
const http = new ZHttpService();
|
|
25
|
+
const endpoint = `${env.api}/configs`;
|
|
26
|
+
return new ZRestfulService(http, endpoint);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const ZRomulatorSettingsContext = createContext(
|
|
30
|
+
createDefaultSettingsService(),
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export const useSettingsService = () => useContext(ZRomulatorSettingsContext);
|
|
34
|
+
|
|
35
|
+
export const useSetting = (id: ZRomulatorConfigId) => {
|
|
36
|
+
const service = useSettingsService();
|
|
37
|
+
return useAsyncState(() => service.get(id), [id]);
|
|
38
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ZCircusComponentModel } from "@zthun/cirque";
|
|
2
|
+
import { ZCardComponentModel } from "@zthun/fashion-boutique";
|
|
3
|
+
import { firstDefined } from "@zthun/helpful-fn";
|
|
4
|
+
|
|
5
|
+
export class ZRomulatorSystemAvatarCardComponentModel extends ZCircusComponentModel {
|
|
6
|
+
public static readonly Selector = ".ZRomulatorSystemCard-root";
|
|
7
|
+
|
|
8
|
+
public card(): Promise<ZCardComponentModel> {
|
|
9
|
+
return Promise.resolve(new ZCardComponentModel(this.driver));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
public async id(): Promise<string> {
|
|
13
|
+
const card = await this.card();
|
|
14
|
+
const name = await card.driver.attribute("data-name");
|
|
15
|
+
return firstDefined("", name);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { IZCard } from "@zthun/fashion-boutique";
|
|
2
|
+
import {
|
|
3
|
+
ZCard,
|
|
4
|
+
ZIconFontAwesome,
|
|
5
|
+
ZImageSource,
|
|
6
|
+
ZStack,
|
|
7
|
+
} from "@zthun/fashion-boutique";
|
|
8
|
+
import { ZSizeFixed } from "@zthun/fashion-tailor";
|
|
9
|
+
import { ZOrientation } from "@zthun/helpful-fn";
|
|
10
|
+
|
|
11
|
+
export interface IZRomulatorSystemAvatarCard {
|
|
12
|
+
system: any;
|
|
13
|
+
|
|
14
|
+
CardProps?: Pick<IZCard, "footer">;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ZRomulatorSystemAvatarCard(props: IZRomulatorSystemAvatarCard) {
|
|
18
|
+
const { system, CardProps } = props;
|
|
19
|
+
// TODO: Add support for other regions
|
|
20
|
+
const src = `/systems/us/${system.id}-256x256.png`;
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<ZCard
|
|
24
|
+
className="ZRomulatorSystemCard-root"
|
|
25
|
+
TitleProps={{
|
|
26
|
+
avatar: <ZIconFontAwesome name="gamepad" width={ZSizeFixed.Small} />,
|
|
27
|
+
heading: system.short,
|
|
28
|
+
subHeading: system.name,
|
|
29
|
+
}}
|
|
30
|
+
name={system.id}
|
|
31
|
+
{...CardProps}
|
|
32
|
+
>
|
|
33
|
+
<ZStack
|
|
34
|
+
orientation={ZOrientation.Horizontal}
|
|
35
|
+
justify={{ content: "center" }}
|
|
36
|
+
>
|
|
37
|
+
<ZImageSource src={src} width={ZSizeFixed.ExtraLarge} />
|
|
38
|
+
</ZStack>
|
|
39
|
+
</ZCard>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ZCircusBy, ZCircusComponentModel } from "@zthun/cirque";
|
|
2
|
+
import {
|
|
3
|
+
ZAlertComponentModel,
|
|
4
|
+
ZSuspenseComponentModel,
|
|
5
|
+
} from "@zthun/fashion-boutique";
|
|
6
|
+
import { ZRomulatorSystemAvatarCardComponentModel } from "./system-avatar-card.cm.mjs";
|
|
7
|
+
|
|
8
|
+
export class ZRomulatorSystemPageComponentModel extends ZCircusComponentModel {
|
|
9
|
+
public static readonly Selector = ".ZRomulatorSystemPage-root";
|
|
10
|
+
|
|
11
|
+
public async loader(): Promise<ZSuspenseComponentModel | null> {
|
|
12
|
+
return ZCircusBy.optional(this.driver, ZSuspenseComponentModel);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
public async loading(): Promise<boolean> {
|
|
16
|
+
return (await this.loader()) != null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public load(): Promise<void> {
|
|
20
|
+
return this.driver.wait(() => this.loading().then((l) => !l));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
public error(): Promise<ZAlertComponentModel | null> {
|
|
24
|
+
return ZCircusBy.optional(this.driver, ZAlertComponentModel);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public system(): Promise<ZRomulatorSystemAvatarCardComponentModel | null> {
|
|
28
|
+
return ZCircusBy.optional(
|
|
29
|
+
this.driver,
|
|
30
|
+
ZRomulatorSystemAvatarCardComponentModel,
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { IZCircusDriver, IZCircusSetup } from "@zthun/cirque";
|
|
2
|
+
import { ZCircusBy } from "@zthun/cirque";
|
|
3
|
+
import { ZCircusSetupRenderer } from "@zthun/cirque-du-react";
|
|
4
|
+
import {
|
|
5
|
+
ZNotFound,
|
|
6
|
+
ZRoute,
|
|
7
|
+
ZRouteMap,
|
|
8
|
+
ZTestRouter,
|
|
9
|
+
} from "@zthun/fashion-boutique";
|
|
10
|
+
import { required } from "@zthun/helpful-fn";
|
|
11
|
+
import {
|
|
12
|
+
ZDataRequestBuilder,
|
|
13
|
+
ZDataSourceStatic,
|
|
14
|
+
ZFilterBinaryBuilder,
|
|
15
|
+
} from "@zthun/helpful-query";
|
|
16
|
+
import { ZRomulatorSystemBuilder } from "@zthun/romulator-client";
|
|
17
|
+
import type { History } from "history";
|
|
18
|
+
import { createMemoryHistory } from "history";
|
|
19
|
+
import { noop } from "lodash-es";
|
|
20
|
+
import type { Mocked } from "vitest";
|
|
21
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
22
|
+
import { mock } from "vitest-mock-extended";
|
|
23
|
+
import { ZRomulatorSystemPageComponentModel } from "./system-page.cm.mjs";
|
|
24
|
+
import { ZRomulatorSystemPage } from "./system-page.js";
|
|
25
|
+
import type { IZRomulatorSystemsService } from "./systems-service.mjs";
|
|
26
|
+
import { ZRomulatorSystemsServiceContext } from "./systems-service.mjs";
|
|
27
|
+
|
|
28
|
+
interface ZRomulatorSystemPageProps {
|
|
29
|
+
history?: History;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("SystemPage", () => {
|
|
33
|
+
const nes = new ZRomulatorSystemBuilder().id("nes").build();
|
|
34
|
+
|
|
35
|
+
let _driver: IZCircusDriver;
|
|
36
|
+
let _renderer: IZCircusSetup;
|
|
37
|
+
let _systems: Mocked<IZRomulatorSystemsService>;
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
const source = new ZDataSourceStatic([nes]);
|
|
41
|
+
|
|
42
|
+
_systems = mock<IZRomulatorSystemsService>();
|
|
43
|
+
_systems.get.mockImplementation(async (id) => {
|
|
44
|
+
const byId = new ZFilterBinaryBuilder()
|
|
45
|
+
.subject("id")
|
|
46
|
+
.equal()
|
|
47
|
+
.value(id)
|
|
48
|
+
.build();
|
|
49
|
+
const request = new ZDataRequestBuilder().filter(byId).build();
|
|
50
|
+
const [item] = await source.retrieve(request);
|
|
51
|
+
|
|
52
|
+
return required(item);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(async () => {
|
|
57
|
+
await _driver?.destroy?.call(_driver);
|
|
58
|
+
await _renderer?.destroy?.call(_renderer);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
async function createTestTarget(props: ZRomulatorSystemPageProps = {}) {
|
|
62
|
+
const {
|
|
63
|
+
history = createMemoryHistory({ initialEntries: [`/systems/${nes.id}`] }),
|
|
64
|
+
} = props;
|
|
65
|
+
|
|
66
|
+
const element = (
|
|
67
|
+
<ZRomulatorSystemsServiceContext value={_systems}>
|
|
68
|
+
<ZTestRouter navigator={history} location={history.location}>
|
|
69
|
+
<ZRouteMap>
|
|
70
|
+
<ZRoute path="/systems/:id" element={<ZRomulatorSystemPage />} />
|
|
71
|
+
<ZRoute path="*" element={<ZNotFound />} />
|
|
72
|
+
</ZRouteMap>
|
|
73
|
+
</ZTestRouter>
|
|
74
|
+
</ZRomulatorSystemsServiceContext>
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
_renderer = new ZCircusSetupRenderer(element);
|
|
78
|
+
_driver = await _renderer.setup();
|
|
79
|
+
return ZCircusBy.first(_driver, ZRomulatorSystemPageComponentModel);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe("Loading", () => {
|
|
83
|
+
it("should show a loading indicator while the system is loading", async () => {
|
|
84
|
+
// Arrange.
|
|
85
|
+
_systems.get.mockReturnValue(new Promise(noop));
|
|
86
|
+
const target = await createTestTarget();
|
|
87
|
+
|
|
88
|
+
// Act.
|
|
89
|
+
const actual = await target.loading();
|
|
90
|
+
|
|
91
|
+
// Assert.
|
|
92
|
+
expect(actual).toBeTruthy();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("Error", () => {
|
|
97
|
+
it("should show an error alert if the system cannot be found", async () => {
|
|
98
|
+
// Arrange.
|
|
99
|
+
const history = createMemoryHistory({
|
|
100
|
+
initialEntries: ["/systems/does-not-exist"],
|
|
101
|
+
});
|
|
102
|
+
const target = await createTestTarget({ history });
|
|
103
|
+
await target.load();
|
|
104
|
+
|
|
105
|
+
// Act.
|
|
106
|
+
const actual = await target.error();
|
|
107
|
+
|
|
108
|
+
// Assert.
|
|
109
|
+
expect(actual).toBeTruthy();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("System", () => {
|
|
114
|
+
it("should render the system information card", async () => {
|
|
115
|
+
// Arrange.
|
|
116
|
+
const target = await createTestTarget();
|
|
117
|
+
await target.load();
|
|
118
|
+
|
|
119
|
+
// Act.
|
|
120
|
+
const system = await target.system();
|
|
121
|
+
const actual = await system?.id();
|
|
122
|
+
|
|
123
|
+
// Assert.
|
|
124
|
+
expect(actual).toEqual(nes.id);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|