@amxts/menu-core 0.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/LICENSE +21 -0
- package/PAWN.md +33 -0
- package/PAWN.ru.md +33 -0
- package/README.md +382 -0
- package/README.ru.md +382 -0
- package/include/menu_core.inc +213 -0
- package/package.json +72 -0
- package/prebuilt/linux/menu-core.aot +0 -0
- package/prebuilt/manifest.json +703 -0
- package/prebuilt/windows/menu-core.aot +0 -0
- package/scripts/docs/src/index.ts +298 -0
- package/scripts/docs/src/types.ts +266 -0
- package/scripts/menu-colors.ts +97 -0
- package/src/index.ts +1615 -0
- package/src/internal.ts +328 -0
- package/src/menu-file.ts +686 -0
- package/src/natives.ts +411 -0
- package/src/types.ts +158 -0
- package/testing/index.ts +375 -0
package/testing/index.ts
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
// Menu Core's test kit: the fake server's side of menus - what show_menu
|
|
2
|
+
// puts on a player's screen, the keys he presses, and Pawn plugins that Menu
|
|
3
|
+
// Core calls back through callfunc. The fake server installs it before Menu
|
|
4
|
+
// Core's plugin loads (package.json, "amxts".testing); a test reaches it with
|
|
5
|
+
// menusOf(server):
|
|
6
|
+
//
|
|
7
|
+
// import { setup } from "@amxts/core/test-utils";
|
|
8
|
+
// import { menusOf } from "@amxts/menu-core/testing";
|
|
9
|
+
//
|
|
10
|
+
// const server = await setup();
|
|
11
|
+
// const menus = menusOf(server);
|
|
12
|
+
// const admin = menus.pawnPlugin("admin.amxx", {
|
|
13
|
+
// IsAdmin: (id: number) => id === 1,
|
|
14
|
+
// OnKick: (id: number, target: number) => kicked.push(target),
|
|
15
|
+
// });
|
|
16
|
+
// admin.native("mc_register_action", "KICK", "OnKick");
|
|
17
|
+
// const alice = server.join("Alice");
|
|
18
|
+
// admin.native("mc_show_menu", alice.id, "ADMIN_MENU");
|
|
19
|
+
// menus.screen(alice)?.text; // what he sees
|
|
20
|
+
// menus.press(alice, 1); // key 1
|
|
21
|
+
//
|
|
22
|
+
// callfunc is played the way the amxts module and AMX Mod X play it: the host
|
|
23
|
+
// plugin's heap is a map of cells here; a public of the host (a TypeScript
|
|
24
|
+
// plugin's publicFor) gets its strings and arrays as addresses on it, and the
|
|
25
|
+
// module's callfunc_buffer copies an array back into the plugin after
|
|
26
|
+
// callfunc_end.
|
|
27
|
+
import type { ArgValue, FakePlayer, FakeServer, Memory, Native, NativeCall, NativeResult, PluginInstance } from "@amxts/core/test-utils";
|
|
28
|
+
import { defineTestKit } from "@amxts/core/test-utils";
|
|
29
|
+
|
|
30
|
+
/** The host plugin's id: what caller() is for a TypeScript plugin calling a native. */
|
|
31
|
+
export const HOST_PLUGIN = 0;
|
|
32
|
+
|
|
33
|
+
/** What a player's screen shows. */
|
|
34
|
+
export interface MenuScreen {
|
|
35
|
+
text: string;
|
|
36
|
+
/** Keys that answer: 1-9 and 0. */
|
|
37
|
+
keys: number[];
|
|
38
|
+
/** The title show_menu was given - the menu's name. */
|
|
39
|
+
title: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** An array a Pawn public was handed: its cells, and as text. */
|
|
43
|
+
export class PawnArray {
|
|
44
|
+
constructor(public cells: number[]) {}
|
|
45
|
+
|
|
46
|
+
get text(): string {
|
|
47
|
+
const end = this.cells.indexOf(0);
|
|
48
|
+
return new TextDecoder().decode(Uint8Array.from(this.cells.slice(0, end < 0 ? this.cells.length : end).map(c => c & 0xFF)));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Fills it as formatex(value, charsmax(value), ...) would. */
|
|
52
|
+
set(text: string): void {
|
|
53
|
+
const bytes = [...new TextEncoder().encode(text)].slice(0, this.cells.length - 1);
|
|
54
|
+
for (const [i, b] of bytes.entries()) this.cells[i] = b;
|
|
55
|
+
this.cells[bytes.length] = 0;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A test's Pawn public takes numbers, strings and PawnArrays, whatever it declares.
|
|
60
|
+
type PawnPublic = (...args: any[]) => number | boolean | void;
|
|
61
|
+
|
|
62
|
+
/** A Pawn plugin in a test: its publics, and natives called on its behalf. */
|
|
63
|
+
export class FakePawnPlugin {
|
|
64
|
+
constructor(readonly kit: MenuKit, readonly id: number, readonly file: string, readonly publics: Record<string, PawnPublic>) {}
|
|
65
|
+
|
|
66
|
+
/** An exported native, called as this plugin: caller() is its id. */
|
|
67
|
+
native(name: string, ...args: ArgValue[]): NativeResult {
|
|
68
|
+
return this.kit.as(this.id, () => this.kit.server.native(name, ...args));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface IntParam { kind: "int"; value: number }
|
|
73
|
+
interface CellsParam { kind: "string" | "array"; cells: number[]; from: number; copyback: boolean; at: number; into?: { memory: Memory; pointer: number } }
|
|
74
|
+
type Param = IntParam | CellsParam;
|
|
75
|
+
|
|
76
|
+
interface Pending {
|
|
77
|
+
plugin: number;
|
|
78
|
+
func: number;
|
|
79
|
+
params: Param[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Where the host heap starts: an address like any other in the AMX's data. */
|
|
83
|
+
const HEAP_BASE = 0x4000;
|
|
84
|
+
/** Function ids of host publics (__amxts_cb<n>) - distinct from a fake plugin's. */
|
|
85
|
+
const HOST_FUNCS = 100000;
|
|
86
|
+
|
|
87
|
+
const kits = new WeakMap<FakeServer, MenuKit>();
|
|
88
|
+
|
|
89
|
+
export class MenuKit {
|
|
90
|
+
readonly plugins: FakePawnPlugin[] = [];
|
|
91
|
+
/** Who is calling a native right now; the host unless a FakePawnPlugin is. */
|
|
92
|
+
caller = HOST_PLUGIN;
|
|
93
|
+
|
|
94
|
+
readonly heap = new Map<number, number>();
|
|
95
|
+
hea = HEAP_BASE;
|
|
96
|
+
pending: Pending | null = null;
|
|
97
|
+
|
|
98
|
+
readonly menuIds = new Map<string, number>();
|
|
99
|
+
readonly menuCommands = new Map<number, string[]>();
|
|
100
|
+
readonly screens = new Map<number, MenuScreen>();
|
|
101
|
+
/** ShowMenu messages marked "more", waiting for the rest. */
|
|
102
|
+
readonly chunks = new Map<number, string>();
|
|
103
|
+
|
|
104
|
+
constructor(readonly server: FakeServer) {}
|
|
105
|
+
|
|
106
|
+
/** A Pawn plugin whose publics menu_core may call. */
|
|
107
|
+
pawnPlugin(file: string, publics: Record<string, PawnPublic>): FakePawnPlugin {
|
|
108
|
+
const plugin = new FakePawnPlugin(this, this.plugins.length + 1, file, publics);
|
|
109
|
+
this.plugins.push(plugin);
|
|
110
|
+
return plugin;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Translations in English, as a dictionary file writes them: the server's own dictionary. */
|
|
114
|
+
translate(entries: Record<string, string>): void {
|
|
115
|
+
this.server.translate(entries);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** What the player sees; null when no menu is up. */
|
|
119
|
+
screen(player: FakePlayer): MenuScreen | null {
|
|
120
|
+
const shown = this.screens.get(player.id);
|
|
121
|
+
return shown && shown.keys.length > 0 ? shown : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Presses a key, 1-9 or 0. False when the menu does not take it - it is not on the screen. */
|
|
125
|
+
press(player: FakePlayer, key: number): boolean {
|
|
126
|
+
const shown = this.screens.get(player.id);
|
|
127
|
+
if (!shown || !shown.keys.includes(key)) return false;
|
|
128
|
+
const index = key === 0 ? 9 : key - 1;
|
|
129
|
+
for (const name of this.menuCommands.get(this.menuIds.get(shown.title) ?? -1) ?? []) {
|
|
130
|
+
const slot = this.server.slotByPublic(name);
|
|
131
|
+
this.server.withCallArgs([player.id, index], () => this.server.call(slot, [player.id, index], slot.fallback));
|
|
132
|
+
}
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `body` with caller() answering `id`. */
|
|
137
|
+
as<T>(id: number, body: () => T): T {
|
|
138
|
+
const previous = this.caller;
|
|
139
|
+
this.caller = id;
|
|
140
|
+
try {
|
|
141
|
+
return body();
|
|
142
|
+
} finally {
|
|
143
|
+
this.caller = previous;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** An item for a list data source's Array:, as a Pawn plugin fills one: 289 cells. */
|
|
148
|
+
pushRow(handle: number, row: { target: number; action?: string; text: string; restriction?: string; message?: string }): void {
|
|
149
|
+
const cells = [row.target, ...field(row.action ?? "", 64), ...field(row.text, 64), ...field(row.restriction ?? "", 32), ...field(row.message ?? "", 128)];
|
|
150
|
+
this.server.cellArrays.get(handle)?.items.push(cells);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
heapCells(at: number, count: number): number[] {
|
|
154
|
+
return Array.from({ length: count }, (_, i) => this.heap.get(at + i * 4) ?? 0);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
writeHeap(at: number, cells: number[]): void {
|
|
158
|
+
cells.forEach((cell, i) => this.heap.set(at + i * 4, cell));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function field(text: string, cells: number): number[] {
|
|
163
|
+
const bytes = [...new TextEncoder().encode(text)].slice(0, cells - 1);
|
|
164
|
+
return [...bytes, ...Array.from<number>({ length: cells - bytes.length }).fill(0)];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A Pawn string as the module copies it: up to 511 cells, then the zero. */
|
|
168
|
+
function stringCells(memory: Memory, at: number): number[] {
|
|
169
|
+
if (!at) return [0];
|
|
170
|
+
|
|
171
|
+
const cells: number[] = [];
|
|
172
|
+
for (let i = 0; i < 511; i++) {
|
|
173
|
+
const cell = memory.cell(at + i * 4);
|
|
174
|
+
if (cell === 0) break;
|
|
175
|
+
cells.push(cell);
|
|
176
|
+
}
|
|
177
|
+
return [...cells, 0];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function textOf(cells: number[]): string {
|
|
181
|
+
return new PawnArray(cells).text;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The kit of the server a native runs on. */
|
|
185
|
+
function kitOf(call: NativeCall): MenuKit {
|
|
186
|
+
return menusOf(call.server);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** callfunc's push: onto the host heap for this native's length, and remembered. */
|
|
190
|
+
function pushCells(call: NativeCall, kind: "string" | "array", pointer: number, size: number, copyback: number): number {
|
|
191
|
+
const kit = kitOf(call);
|
|
192
|
+
if (!kit.pending) return 0;
|
|
193
|
+
const at = kit.hea;
|
|
194
|
+
const copied = stringCells(call.memory, pointer);
|
|
195
|
+
kit.writeHeap(at, [...copied, 0]);
|
|
196
|
+
const cells = kind === "array" ? kit.heapCells(at, size) : copied;
|
|
197
|
+
kit.pending.params.push({ kind, cells, from: at, copyback: copyback !== 0, at: 0 });
|
|
198
|
+
return 1;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** callfunc_end on a public of the host plugin: its arrays go onto the host heap, and it runs as a callback. */
|
|
202
|
+
function endOnHost(kit: MenuKit, pending: Pending): number {
|
|
203
|
+
const slot = kit.server.slots[pending.func - HOST_FUNCS];
|
|
204
|
+
if (!slot) return 0;
|
|
205
|
+
const mark = kit.hea;
|
|
206
|
+
for (const param of [...pending.params].reverse()) {
|
|
207
|
+
if (param.kind === "int") continue;
|
|
208
|
+
param.at = kit.hea;
|
|
209
|
+
kit.writeHeap(param.at, param.cells);
|
|
210
|
+
kit.hea += param.cells.length * 4;
|
|
211
|
+
}
|
|
212
|
+
const args = pending.params.map(p => p.kind === "int" ? p.value : p.at);
|
|
213
|
+
try {
|
|
214
|
+
return kit.server.withCallArgs(args, () => kit.server.call(slot, args.slice(0, 4), slot.fallback));
|
|
215
|
+
} finally {
|
|
216
|
+
for (const param of pending.params) {
|
|
217
|
+
if (param.kind === "int" || !param.copyback) continue;
|
|
218
|
+
copyBack(kit, param, kit.heapCells(param.at, param.cells.length));
|
|
219
|
+
}
|
|
220
|
+
kit.hea = mark;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** What the public left in a copied-back argument, back where the caller passed it from. */
|
|
225
|
+
function copyBack(kit: MenuKit, param: CellsParam, cells: number[]) {
|
|
226
|
+
if (param.into) cells.forEach((cell, i) => param.into!.memory.setCell(param.into!.pointer + i * 4, cell));
|
|
227
|
+
else kit.writeHeap(param.from, cells);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** callfunc_end: the call the pushes made, on the host or on a fake Pawn plugin; 0 when none was begun. */
|
|
231
|
+
function finish(kit: MenuKit): number {
|
|
232
|
+
const pending = kit.pending;
|
|
233
|
+
kit.pending = null;
|
|
234
|
+
if (!pending) return 0;
|
|
235
|
+
return pending.plugin === HOST_PLUGIN ? endOnHost(kit, pending) : endOnPlugin(kit, pending);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** callfunc_end on a fake Pawn plugin's public. */
|
|
239
|
+
function endOnPlugin(kit: MenuKit, pending: Pending): number {
|
|
240
|
+
const plugin = kit.plugins.find(p => p.id === pending.plugin);
|
|
241
|
+
const name = plugin ? Object.keys(plugin.publics)[pending.func] : undefined;
|
|
242
|
+
if (!plugin || name === undefined) return 0;
|
|
243
|
+
|
|
244
|
+
const args = pending.params.map(p => p.kind === "int" ? p.value : p.kind === "string" ? textOf(p.cells) : new PawnArray([...p.cells]));
|
|
245
|
+
const result = kit.as(plugin.id, () => plugin.publics[name](...args));
|
|
246
|
+
pending.params.forEach((param, i) => {
|
|
247
|
+
if (param.kind !== "array" || !param.copyback) return;
|
|
248
|
+
copyBack(kit, param, (args[i] as PawnArray).cells);
|
|
249
|
+
});
|
|
250
|
+
return typeof result === "boolean" ? +result : typeof result === "number" ? result | 0 : 0;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const MENU_NATIVES: Record<string, Native> = {
|
|
254
|
+
register_menuid: (c, [name]) => {
|
|
255
|
+
const kit = kitOf(c);
|
|
256
|
+
const title = c.memory.text(name);
|
|
257
|
+
if (!kit.menuIds.has(title)) kit.menuIds.set(title, kit.menuIds.size + 1);
|
|
258
|
+
return kit.menuIds.get(title);
|
|
259
|
+
},
|
|
260
|
+
register_menucmd: (c, [menuId, _keys, fn]) => {
|
|
261
|
+
const kit = kitOf(c);
|
|
262
|
+
const list = kit.menuCommands.get(menuId) ?? [];
|
|
263
|
+
list.push(c.memory.text(fn));
|
|
264
|
+
kit.menuCommands.set(menuId, list);
|
|
265
|
+
return 1;
|
|
266
|
+
},
|
|
267
|
+
show_menu: (c, [id, keys, text, _time, title]) => {
|
|
268
|
+
const kit = kitOf(c);
|
|
269
|
+
const head = kit.chunks.get(id) ?? "";
|
|
270
|
+
kit.chunks.delete(id);
|
|
271
|
+
const pressable = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0].filter(key => keys & (1 << (key === 0 ? 9 : key - 1)));
|
|
272
|
+
kit.screens.set(id, { text: head + c.memory.text(text), keys: pressable, title: c.memory.text(title) });
|
|
273
|
+
return 1;
|
|
274
|
+
},
|
|
275
|
+
|
|
276
|
+
get_func_id: (c, [name, pluginId]) => {
|
|
277
|
+
const kit = kitOf(c);
|
|
278
|
+
const text = c.memory.text(name);
|
|
279
|
+
if (pluginId === -1 || pluginId === HOST_PLUGIN) {
|
|
280
|
+
const match = text.match(/^__amxts_cb(\d+)$/);
|
|
281
|
+
return match && kit.server.slots[Number(match[1])] ? HOST_FUNCS + Number(match[1]) : -1;
|
|
282
|
+
}
|
|
283
|
+
const plugin = kit.plugins.find(p => p.id === pluginId);
|
|
284
|
+
return plugin ? Object.keys(plugin.publics).indexOf(text) : -1;
|
|
285
|
+
},
|
|
286
|
+
callfunc_begin_i: (c, [func, plugin]) => {
|
|
287
|
+
const kit = kitOf(c);
|
|
288
|
+
const target = plugin === -1 ? HOST_PLUGIN : plugin;
|
|
289
|
+
if (func < 0) return -2;
|
|
290
|
+
kit.pending = { plugin: target, func, params: [] };
|
|
291
|
+
return 1;
|
|
292
|
+
},
|
|
293
|
+
callfunc_push_int: (c, [value]) => {
|
|
294
|
+
kitOf(c).pending?.params.push({ kind: "int", value });
|
|
295
|
+
return 1;
|
|
296
|
+
},
|
|
297
|
+
callfunc_push_str: (c, [text, copyback]) => pushCells(c, "string", text, 0, copyback),
|
|
298
|
+
callfunc_push_array: (c, [array, size, copyback]) => pushCells(c, "array", array, size, copyback),
|
|
299
|
+
callfunc_end: c => finish(kitOf(c)),
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Menus and callfunc on this server: menu natives, fake Pawn plugins, a
|
|
304
|
+
* dictionary. Before the plugins load - the fake server does it when Menu
|
|
305
|
+
* Core's plugin loads, and setup() for every server; once per server.
|
|
306
|
+
*/
|
|
307
|
+
export function installMenus(server: FakeServer): MenuKit {
|
|
308
|
+
const known = kits.get(server);
|
|
309
|
+
if (known) return known;
|
|
310
|
+
const kit = new MenuKit(server);
|
|
311
|
+
kits.set(server, kit);
|
|
312
|
+
|
|
313
|
+
// A ShowMenu message is a menu's text in pieces, the last one shown with show_menu.
|
|
314
|
+
server.messageListeners.push((message) => {
|
|
315
|
+
if (message.name !== "ShowMenu") return;
|
|
316
|
+
const text = message.args.filter((arg): arg is string => typeof arg === "string").join("");
|
|
317
|
+
kit.chunks.set(message.player, (kit.chunks.get(message.player) ?? "") + text);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
for (const [name, native] of Object.entries(MENU_NATIVES)) server.defineNative(name, native);
|
|
321
|
+
|
|
322
|
+
const bridge = server.bridge as Record<string, (...args: any[]) => any>;
|
|
323
|
+
const argArray = bridge.arg_array;
|
|
324
|
+
const argText = bridge.arg_text;
|
|
325
|
+
const setArgText = bridge.set_arg_text;
|
|
326
|
+
|
|
327
|
+
// A cell that is a number where an array is read is an address on the
|
|
328
|
+
// host heap: a host public called through callfunc is handed those.
|
|
329
|
+
bridge.arg_array = function (this: FakeServer, plugin: PluginInstance, index: number, out: number, count: number) {
|
|
330
|
+
const value = this.callArgs?.[index];
|
|
331
|
+
if (typeof value !== "number") return argArray.call(this, plugin, index, out, count);
|
|
332
|
+
kit.heapCells(value, count).forEach((cell, i) => plugin.memory.setCell(out + i * 4, cell));
|
|
333
|
+
return count;
|
|
334
|
+
};
|
|
335
|
+
bridge.arg_text = function (this: FakeServer, plugin: PluginInstance, index: number, out: number, max: number) {
|
|
336
|
+
const value = this.callArgs?.[index];
|
|
337
|
+
if (typeof value !== "number" || value < HEAP_BASE) return argText.call(this, plugin, index, out, max);
|
|
338
|
+
return plugin.memory.setBytes(out, max, textOf(kit.heapCells(value, 512)));
|
|
339
|
+
};
|
|
340
|
+
bridge.set_arg_text = function (this: FakeServer, plugin: PluginInstance, index: number, text: number, max: number) {
|
|
341
|
+
const value = this.callArgs?.[index];
|
|
342
|
+
if (typeof value !== "number" || value < HEAP_BASE) return setArgText.call(this, plugin, index, text, max);
|
|
343
|
+
const cells = field(plugin.memory.string(text), max + 1);
|
|
344
|
+
kit.writeHeap(value, cells);
|
|
345
|
+
return cells.indexOf(0);
|
|
346
|
+
};
|
|
347
|
+
bridge.caller = () => kit.caller;
|
|
348
|
+
|
|
349
|
+
// The module's callfunc_text / callfunc_buffer / callfunc_finish: arguments
|
|
350
|
+
// that stay until callfunc_end, an array copied back into the plugin.
|
|
351
|
+
bridge.callfunc_text = function (this: FakeServer, plugin: PluginInstance, text: number) {
|
|
352
|
+
if (!kit.pending) return 0;
|
|
353
|
+
const cells = [...new TextEncoder().encode(plugin.memory.string(text)), 0];
|
|
354
|
+
kit.pending.params.push({ kind: "string", cells, from: 0, copyback: false, at: 0 });
|
|
355
|
+
return 1;
|
|
356
|
+
};
|
|
357
|
+
bridge.callfunc_buffer = function (this: FakeServer, plugin: PluginInstance, pointer: number, count: number) {
|
|
358
|
+
if (!kit.pending) return 0;
|
|
359
|
+
const cells = Array.from({ length: count }, (_, i) => plugin.memory.cell(pointer + i * 4));
|
|
360
|
+
kit.pending.params.push({ kind: "array", cells, from: 0, copyback: true, at: 0, into: { memory: plugin.memory, pointer } });
|
|
361
|
+
return 1;
|
|
362
|
+
};
|
|
363
|
+
bridge.callfunc_finish = () => finish(kit);
|
|
364
|
+
|
|
365
|
+
return kit;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** The menus of a server Menu Core runs on - its test kit, installed before its plugin loaded. */
|
|
369
|
+
export function menusOf(server: FakeServer): MenuKit {
|
|
370
|
+
const kit = kits.get(server);
|
|
371
|
+
if (!kit) throw new Error("Menu Core's test kit is not on this server: load @amxts/menu-core (or call setup()) before asking for its menus");
|
|
372
|
+
return kit;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export default defineTestKit({ install: installMenus });
|