@roster-lock/config-editor-cli 0.0.1
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/.claude-plugin/plugin.json +6 -0
- package/LICENSE.md +11 -0
- package/README.md +40 -0
- package/dist/commands/draft/from-lock.js +31 -0
- package/dist/commands/draft/from-lock.js.map +1 -0
- package/dist/commands/draft/index.js +22 -0
- package/dist/commands/draft/index.js.map +1 -0
- package/dist/commands/draft/init.js +25 -0
- package/dist/commands/draft/init.js.map +1 -0
- package/dist/commands/draft/promote.js +24 -0
- package/dist/commands/draft/promote.js.map +1 -0
- package/dist/commands/draft/publish.js +26 -0
- package/dist/commands/draft/publish.js.map +1 -0
- package/dist/commands/draft/validate.js +30 -0
- package/dist/commands/draft/validate.js.map +1 -0
- package/dist/commands/engine/add-asset.js +44 -0
- package/dist/commands/engine/add-asset.js.map +1 -0
- package/dist/commands/engine/add-piece-type.js +47 -0
- package/dist/commands/engine/add-piece-type.js.map +1 -0
- package/dist/commands/engine/edit-definition.js +49 -0
- package/dist/commands/engine/edit-definition.js.map +1 -0
- package/dist/commands/engine/index.js +24 -0
- package/dist/commands/engine/index.js.map +1 -0
- package/dist/commands/engine/remove-asset.js +27 -0
- package/dist/commands/engine/remove-asset.js.map +1 -0
- package/dist/commands/engine/remove-piece-type.js +28 -0
- package/dist/commands/engine/remove-piece-type.js.map +1 -0
- package/dist/commands/engine/set-info.js +22 -0
- package/dist/commands/engine/set-info.js.map +1 -0
- package/dist/commands/engine/show.js +23 -0
- package/dist/commands/engine/show.js.map +1 -0
- package/dist/commands/piece-meta/index.js +19 -0
- package/dist/commands/piece-meta/index.js.map +1 -0
- package/dist/commands/piece-meta/set.js +27 -0
- package/dist/commands/piece-meta/set.js.map +1 -0
- package/dist/commands/piece-meta/show.js +15 -0
- package/dist/commands/piece-meta/show.js.map +1 -0
- package/dist/commands/roster/add-piece.js +93 -0
- package/dist/commands/roster/add-piece.js.map +1 -0
- package/dist/commands/roster/edit-piece.js +92 -0
- package/dist/commands/roster/edit-piece.js.map +1 -0
- package/dist/commands/roster/import-piece.js +83 -0
- package/dist/commands/roster/import-piece.js.map +1 -0
- package/dist/commands/roster/index.js +24 -0
- package/dist/commands/roster/index.js.map +1 -0
- package/dist/commands/roster/list.js +24 -0
- package/dist/commands/roster/list.js.map +1 -0
- package/dist/commands/roster/remove-piece.js +30 -0
- package/dist/commands/roster/remove-piece.js.map +1 -0
- package/dist/commands/roster/rescan.js +55 -0
- package/dist/commands/roster/rescan.js.map +1 -0
- package/dist/commands/roster/test-download.js +72 -0
- package/dist/commands/roster/test-download.js.map +1 -0
- package/dist/commands/selection/add-script.js +43 -0
- package/dist/commands/selection/add-script.js.map +1 -0
- package/dist/commands/selection/index.js +21 -0
- package/dist/commands/selection/index.js.map +1 -0
- package/dist/commands/selection/run-script.js +27 -0
- package/dist/commands/selection/run-script.js.map +1 -0
- package/dist/commands/selection/set.js +41 -0
- package/dist/commands/selection/set.js.map +1 -0
- package/dist/commands/selection/show.js +15 -0
- package/dist/commands/selection/show.js.map +1 -0
- package/dist/commands/show.js +15 -0
- package/dist/commands/show.js.map +1 -0
- package/dist/commands/validate.js +31 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/index.js +55 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/cli-options.js +7 -0
- package/dist/lib/cli-options.js.map +1 -0
- package/dist/lib/draft-io.js +38 -0
- package/dist/lib/draft-io.js.map +1 -0
- package/dist/lib/errors.js +32 -0
- package/dist/lib/errors.js.map +1 -0
- package/dist/lib/fs-walk.js +28 -0
- package/dist/lib/fs-walk.js.map +1 -0
- package/dist/lib/json-input.js +16 -0
- package/dist/lib/json-input.js.map +1 -0
- package/dist/lib/lock-io.js +20 -0
- package/dist/lib/lock-io.js.map +1 -0
- package/dist/lib/parse.js +52 -0
- package/dist/lib/parse.js.map +1 -0
- package/dist/lib/piece-scan.js +12 -0
- package/dist/lib/piece-scan.js.map +1 -0
- package/dist/lib/schema-help.js +38 -0
- package/dist/lib/schema-help.js.map +1 -0
- package/dist/lib/schemas.js +71 -0
- package/dist/lib/schemas.js.map +1 -0
- package/dist/lib/semver-report.js +13 -0
- package/dist/lib/semver-report.js.map +1 -0
- package/dist/lib/slugify.js +13 -0
- package/dist/lib/slugify.js.map +1 -0
- package/dist/version.js +5 -0
- package/dist/version.js.map +1 -0
- package/package.json +49 -0
- package/scripts/bundle.js +15 -0
- package/scripts/generate-version.js +14 -0
- package/skills/rosterlock-config-editor/SKILL.md +151 -0
- package/src/commands/draft/from-lock.ts +34 -0
- package/src/commands/draft/index.ts +5 -0
- package/src/commands/draft/init.ts +21 -0
- package/src/commands/draft/promote.ts +24 -0
- package/src/commands/draft/publish.ts +26 -0
- package/src/commands/draft/validate.ts +30 -0
- package/src/commands/engine/add-asset.ts +46 -0
- package/src/commands/engine/add-piece-type.ts +56 -0
- package/src/commands/engine/edit-definition.ts +58 -0
- package/src/commands/engine/index.ts +7 -0
- package/src/commands/engine/remove-asset.ts +25 -0
- package/src/commands/engine/remove-piece-type.ts +27 -0
- package/src/commands/engine/set-info.ts +17 -0
- package/src/commands/engine/show.ts +20 -0
- package/src/commands/piece-meta/index.ts +2 -0
- package/src/commands/piece-meta/set.ts +29 -0
- package/src/commands/piece-meta/show.ts +12 -0
- package/src/commands/roster/add-piece.ts +109 -0
- package/src/commands/roster/edit-piece.ts +99 -0
- package/src/commands/roster/import-piece.ts +112 -0
- package/src/commands/roster/index.ts +7 -0
- package/src/commands/roster/list.ts +23 -0
- package/src/commands/roster/remove-piece.ts +28 -0
- package/src/commands/roster/rescan.ts +63 -0
- package/src/commands/roster/test-download.ts +76 -0
- package/src/commands/selection/add-script.ts +44 -0
- package/src/commands/selection/index.ts +4 -0
- package/src/commands/selection/run-script.ts +30 -0
- package/src/commands/selection/set.ts +54 -0
- package/src/commands/selection/show.ts +12 -0
- package/src/commands/show.ts +12 -0
- package/src/commands/validate.ts +26 -0
- package/src/index.ts +85 -0
- package/src/lib/cli-options.ts +8 -0
- package/src/lib/draft-io.ts +41 -0
- package/src/lib/errors.ts +30 -0
- package/src/lib/fs-walk.ts +31 -0
- package/src/lib/json-input.ts +21 -0
- package/src/lib/lock-io.ts +18 -0
- package/src/lib/parse.ts +42 -0
- package/src/lib/piece-scan.ts +19 -0
- package/src/lib/schema-help.ts +44 -0
- package/src/lib/schemas.ts +100 -0
- package/src/lib/semver-report.ts +11 -0
- package/src/lib/slugify.ts +9 -0
- package/test.rosterlock.draft.json +62 -0
- package/tests/commands/add-piece.spec.ts +92 -0
- package/tests/commands/draft-validate.spec.ts +85 -0
- package/tests/commands/edit-definition.spec.ts +94 -0
- package/tests/commands/edit-piece.spec.ts +153 -0
- package/tests/commands/from-lock.spec.ts +80 -0
- package/tests/commands/help-arguments.spec.ts +31 -0
- package/tests/commands/import-piece.spec.ts +228 -0
- package/tests/commands/piece-meta.spec.ts +123 -0
- package/tests/commands/rescan.spec.ts +137 -0
- package/tests/lib/draft-io.spec.ts +76 -0
- package/tests/lib/errors.spec.ts +48 -0
- package/tests/lib/fs-walk.spec.ts +35 -0
- package/tests/lib/json-input.spec.ts +63 -0
- package/tests/lib/parse.spec.ts +85 -0
- package/tests/lib/schema-help.spec.ts +42 -0
- package/tests/lib/schemas.spec.ts +82 -0
- package/tests/lib/slugify.spec.ts +28 -0
- package/tsconfig.json +19 -0
- package/tsconfig.test.json +10 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join as pathJoin } from "node:path";
|
|
5
|
+
import { EMPTY_ROSTER_LOCK } from "@roster-lock/shared";
|
|
6
|
+
import { cloneJSON } from "@roster-lock/utils";
|
|
7
|
+
import { RosterLockV1Config, RosterLockV1Draft } from "@roster-lock/types";
|
|
8
|
+
import { fromLockCommand } from "../../src/commands/draft/from-lock";
|
|
9
|
+
|
|
10
|
+
async function runFromLock(args: Array<string>){
|
|
11
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
12
|
+
process.exitCode = undefined;
|
|
13
|
+
|
|
14
|
+
await fromLockCommand.parseAsync(["node", "from-lock", ...args]);
|
|
15
|
+
|
|
16
|
+
const logs = logSpy.mock.calls.map((call) => call.join(" "));
|
|
17
|
+
logSpy.mockRestore();
|
|
18
|
+
return logs;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildLockWithRoster(): RosterLockV1Config {
|
|
22
|
+
const lock = cloneJSON(EMPTY_ROSTER_LOCK);
|
|
23
|
+
lock.title = "My Game";
|
|
24
|
+
lock.engine.pieceDefinitions["character"] = {
|
|
25
|
+
selectionStrategy: "mandatory",
|
|
26
|
+
requires: [],
|
|
27
|
+
pathVariables: [],
|
|
28
|
+
assets: [{ name: "sprite", classification: "media", count: 1, glob: ["*.png"] }],
|
|
29
|
+
};
|
|
30
|
+
lock.rosters["character"] = [{
|
|
31
|
+
id: "@author/blue",
|
|
32
|
+
version: { logic: "a".repeat(64), media: "b".repeat(64), docs: "c".repeat(64) },
|
|
33
|
+
humanInfo: { name: "Blue", author: "author", url: "https://example.com/blue" },
|
|
34
|
+
downloadSources: ["https://example.com/blue.tar"],
|
|
35
|
+
pathVariables: {},
|
|
36
|
+
requiredPieces: {},
|
|
37
|
+
}];
|
|
38
|
+
lock.selection.piece["character"] = {
|
|
39
|
+
type: "unselectable",
|
|
40
|
+
};
|
|
41
|
+
return lock;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("draft from-lock", () => {
|
|
45
|
+
let tempDir: string;
|
|
46
|
+
let lockPath: string;
|
|
47
|
+
let draftPath: string;
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
tempDir = mkdtempSync(pathJoin(tmpdir(), "rosterlock-from-lock-"));
|
|
51
|
+
lockPath = pathJoin(tempDir, "my-game-1.0.0.rosterlock.json");
|
|
52
|
+
draftPath = pathJoin(tempDir, "out.rosterlock.draft.json");
|
|
53
|
+
writeFileSync(lockPath, JSON.stringify(buildLockWithRoster()));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("copies the lock's rosters and selection into the draft by default", async () => {
|
|
61
|
+
await runFromLock([lockPath, draftPath]);
|
|
62
|
+
|
|
63
|
+
const draft: RosterLockV1Draft = JSON.parse(readFileSync(draftPath, "utf-8"));
|
|
64
|
+
expect(draft.stagedLock.rosters["character"]).toHaveLength(1);
|
|
65
|
+
expect(draft.stagedLock.selection.piece["character"]).toBeDefined();
|
|
66
|
+
expect(draft.previousLock.rosters["character"]).toHaveLength(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("--clear-rosters keeps the engine but empties rosters and per-type selection", async () => {
|
|
70
|
+
const logs = await runFromLock([lockPath, draftPath, "--clear-rosters"]);
|
|
71
|
+
|
|
72
|
+
const draft: RosterLockV1Draft = JSON.parse(readFileSync(draftPath, "utf-8"));
|
|
73
|
+
expect(draft.stagedLock.engine.pieceDefinitions["character"]).toBeDefined();
|
|
74
|
+
expect(draft.stagedLock.rosters["character"]).toEqual([]);
|
|
75
|
+
expect(draft.stagedLock.selection.piece).toEqual({});
|
|
76
|
+
// previousLock is left untouched so future diffs reflect the roster removal
|
|
77
|
+
expect(draft.previousLock.rosters["character"]).toHaveLength(1);
|
|
78
|
+
expect(logs.join("\n")).toMatch(/rosters cleared/);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import type { Command } from "commander";
|
|
3
|
+
import * as draftCommands from "../../src/commands/draft";
|
|
4
|
+
import * as engineCommands from "../../src/commands/engine";
|
|
5
|
+
import * as rosterCommands from "../../src/commands/roster";
|
|
6
|
+
import * as selectionCommands from "../../src/commands/selection";
|
|
7
|
+
import { validateCommand } from "../../src/commands/validate";
|
|
8
|
+
import { showCommand } from "../../src/commands/show";
|
|
9
|
+
|
|
10
|
+
// Commander only prints an "Arguments:" section in --help for arguments that have
|
|
11
|
+
// a description, so an argument without one is invisible to a user running --help,
|
|
12
|
+
// even though it's a required positional. This guards against adding a new one
|
|
13
|
+
// without documenting what it expects.
|
|
14
|
+
const allCommands: Array<Command> = [
|
|
15
|
+
...Object.values(draftCommands),
|
|
16
|
+
...Object.values(engineCommands),
|
|
17
|
+
...Object.values(rosterCommands),
|
|
18
|
+
...Object.values(selectionCommands),
|
|
19
|
+
validateCommand,
|
|
20
|
+
showCommand,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
describe("command --help documents every positional argument", () => {
|
|
24
|
+
for(const command of allCommands){
|
|
25
|
+
for(const arg of command.registeredArguments){
|
|
26
|
+
it(`"${command.name()}" argument "${arg.name()}" has a description`, () => {
|
|
27
|
+
expect(arg.description.length).toBeGreaterThan(0);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
});
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join as pathJoin } from "node:path";
|
|
5
|
+
import { EMPTY_ROSTER_DRAFT, EMPTY_ROSTER_LOCK } from "@roster-lock/shared";
|
|
6
|
+
import { cloneJSON } from "@roster-lock/utils";
|
|
7
|
+
import { RosterLockPiece, RosterLockV1Config, RosterLockV1Draft } from "@roster-lock/types";
|
|
8
|
+
|
|
9
|
+
async function runImportPiece(args: Array<string>){
|
|
10
|
+
vi.resetModules();
|
|
11
|
+
const { importPieceCommand } = await import("../../src/commands/roster/import-piece.js");
|
|
12
|
+
|
|
13
|
+
process.exitCode = undefined;
|
|
14
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
15
|
+
await importPieceCommand.parseAsync(["node", "import-piece", ...args]);
|
|
16
|
+
const logs = logSpy.mock.calls.map((call) => call.join(" "));
|
|
17
|
+
logSpy.mockRestore();
|
|
18
|
+
const exitCode = process.exitCode;
|
|
19
|
+
process.exitCode = undefined;
|
|
20
|
+
return { logs, exitCode };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const CHARACTER_DEFINITION = {
|
|
24
|
+
selectionStrategy: "mandatory" as const,
|
|
25
|
+
requires: [],
|
|
26
|
+
pathVariables: [],
|
|
27
|
+
assets: [{ name: "sprite", classification: "media" as const, count: 1, glob: ["*.png"] }],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const CHARACTER_WITH_WEAPON_DEFINITION = {
|
|
31
|
+
...CHARACTER_DEFINITION,
|
|
32
|
+
requires: ["weapon"],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const WEAPON_DEFINITION = {
|
|
36
|
+
selectionStrategy: "on demand" as const,
|
|
37
|
+
requires: [],
|
|
38
|
+
pathVariables: [],
|
|
39
|
+
assets: [{ name: "sprite", classification: "media" as const, count: 1, glob: ["*.png"] }],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const SOURCE_PIECE: RosterLockPiece = {
|
|
43
|
+
id: "@author/blue",
|
|
44
|
+
version: { logic: "a".repeat(64), media: "b".repeat(64), docs: "c".repeat(64) },
|
|
45
|
+
humanInfo: { name: "Blue", author: "author", url: "https://example.com/blue" },
|
|
46
|
+
downloadSources: ["https://example.com/blue.tar"],
|
|
47
|
+
pathVariables: {},
|
|
48
|
+
requiredPieces: {},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const SOURCE_WEAPON_PIECE: RosterLockPiece = {
|
|
52
|
+
id: "@author/sword",
|
|
53
|
+
version: { logic: "d".repeat(64), media: "e".repeat(64), docs: "f".repeat(64) },
|
|
54
|
+
humanInfo: { name: "Sword", author: "author", url: "https://example.com/sword" },
|
|
55
|
+
downloadSources: ["https://example.com/sword.tar"],
|
|
56
|
+
pathVariables: {},
|
|
57
|
+
requiredPieces: {},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const SOURCE_PIECE_WITH_REQUIRED: RosterLockPiece = {
|
|
61
|
+
...cloneJSON(SOURCE_PIECE),
|
|
62
|
+
requiredPieces: { weapon: { expected: ["@author/sword"], selectable: false } },
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
describe("roster import-piece", () => {
|
|
66
|
+
let tempDir: string;
|
|
67
|
+
let draftPath: string;
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
tempDir = mkdtempSync(pathJoin(tmpdir(), "rosterlock-import-piece-"));
|
|
71
|
+
draftPath = pathJoin(tempDir, "test.rosterlock.draft.json");
|
|
72
|
+
|
|
73
|
+
const draft: RosterLockV1Draft = cloneJSON(EMPTY_ROSTER_DRAFT);
|
|
74
|
+
draft.stagedLock.engine.pieceDefinitions["character"] = cloneJSON(CHARACTER_DEFINITION);
|
|
75
|
+
draft.stagedLock.rosters["character"] = [];
|
|
76
|
+
writeFileSync(draftPath, JSON.stringify(draft));
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
function readTargetPiece(){
|
|
84
|
+
const draft: RosterLockV1Draft = JSON.parse(readFileSync(draftPath, "utf-8"));
|
|
85
|
+
return draft.stagedLock.rosters["character"][0];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function writeSourceLock(){
|
|
89
|
+
const lock: RosterLockV1Config = cloneJSON(EMPTY_ROSTER_LOCK);
|
|
90
|
+
lock.engine.pieceDefinitions["character"] = cloneJSON(CHARACTER_DEFINITION);
|
|
91
|
+
lock.rosters["character"] = [cloneJSON(SOURCE_PIECE)];
|
|
92
|
+
const lockPath = pathJoin(tempDir, "source.rosterlock.json");
|
|
93
|
+
writeFileSync(lockPath, JSON.stringify(lock));
|
|
94
|
+
return lockPath;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function writeSourceDraft(){
|
|
98
|
+
const source: RosterLockV1Draft = cloneJSON(EMPTY_ROSTER_DRAFT);
|
|
99
|
+
source.stagedLock.engine.pieceDefinitions["character"] = cloneJSON(CHARACTER_DEFINITION);
|
|
100
|
+
source.stagedLock.rosters["character"] = [cloneJSON(SOURCE_PIECE)];
|
|
101
|
+
const sourcePath = pathJoin(tempDir, "source.rosterlock.draft.json");
|
|
102
|
+
writeFileSync(sourcePath, JSON.stringify(source));
|
|
103
|
+
return sourcePath;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
it("copies a piece from a published lock, trusting its existing hash as-is", async () => {
|
|
107
|
+
const lockPath = writeSourceLock();
|
|
108
|
+
|
|
109
|
+
const { exitCode } = await runImportPiece([lockPath, "character", "@author/blue", "--draft", draftPath]);
|
|
110
|
+
expect(exitCode).toBeUndefined();
|
|
111
|
+
|
|
112
|
+
const piece = readTargetPiece();
|
|
113
|
+
expect(piece).toEqual(SOURCE_PIECE);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("copies a piece from another draft's staged lock", async () => {
|
|
117
|
+
const sourcePath = writeSourceDraft();
|
|
118
|
+
|
|
119
|
+
const { exitCode } = await runImportPiece([sourcePath, "character", "@author/blue", "--draft", draftPath]);
|
|
120
|
+
expect(exitCode).toBeUndefined();
|
|
121
|
+
|
|
122
|
+
expect(readTargetPiece()).toEqual(SOURCE_PIECE);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("--as imports under a different piece id", async () => {
|
|
126
|
+
const lockPath = writeSourceLock();
|
|
127
|
+
|
|
128
|
+
await runImportPiece([lockPath, "character", "@author/blue", "--as", "@author/blue-renamed", "--draft", draftPath]);
|
|
129
|
+
|
|
130
|
+
const piece = readTargetPiece();
|
|
131
|
+
expect(piece.id).toBe("@author/blue-renamed");
|
|
132
|
+
expect(piece.version).toEqual(SOURCE_PIECE.version);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("fails for an unknown target piece type", async () => {
|
|
136
|
+
const lockPath = writeSourceLock();
|
|
137
|
+
|
|
138
|
+
const { exitCode } = await runImportPiece([lockPath, "vehicle", "@author/blue", "--draft", draftPath]);
|
|
139
|
+
expect(exitCode).toBe(1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("fails for an unknown piece id in the source", async () => {
|
|
143
|
+
const lockPath = writeSourceLock();
|
|
144
|
+
|
|
145
|
+
const { exitCode } = await runImportPiece([lockPath, "character", "@author/missing", "--draft", draftPath]);
|
|
146
|
+
expect(exitCode).toBe(1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("fails when a piece with the same id already exists, without --as", async () => {
|
|
150
|
+
const lockPath = writeSourceLock();
|
|
151
|
+
await runImportPiece([lockPath, "character", "@author/blue", "--draft", draftPath]);
|
|
152
|
+
|
|
153
|
+
const { exitCode } = await runImportPiece([lockPath, "character", "@author/blue", "--draft", draftPath]);
|
|
154
|
+
expect(exitCode).toBe(1);
|
|
155
|
+
|
|
156
|
+
const draft: RosterLockV1Draft = JSON.parse(readFileSync(draftPath, "utf-8"));
|
|
157
|
+
expect(draft.stagedLock.rosters["character"]).toHaveLength(1);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("required pieces", () => {
|
|
161
|
+
function setUpDraftAndSourceWithRequires(){
|
|
162
|
+
const draft: RosterLockV1Draft = JSON.parse(readFileSync(draftPath, "utf-8"));
|
|
163
|
+
draft.stagedLock.engine.pieceDefinitions["character"] = cloneJSON(CHARACTER_WITH_WEAPON_DEFINITION);
|
|
164
|
+
draft.stagedLock.engine.pieceDefinitions["weapon"] = cloneJSON(WEAPON_DEFINITION);
|
|
165
|
+
draft.stagedLock.rosters["weapon"] = [];
|
|
166
|
+
writeFileSync(draftPath, JSON.stringify(draft));
|
|
167
|
+
|
|
168
|
+
const lock: RosterLockV1Config = cloneJSON(EMPTY_ROSTER_LOCK);
|
|
169
|
+
lock.engine.pieceDefinitions["character"] = cloneJSON(CHARACTER_WITH_WEAPON_DEFINITION);
|
|
170
|
+
lock.engine.pieceDefinitions["weapon"] = cloneJSON(WEAPON_DEFINITION);
|
|
171
|
+
lock.rosters["character"] = [cloneJSON(SOURCE_PIECE_WITH_REQUIRED)];
|
|
172
|
+
lock.rosters["weapon"] = [cloneJSON(SOURCE_WEAPON_PIECE)];
|
|
173
|
+
const lockPath = pathJoin(tempDir, "source.rosterlock.json");
|
|
174
|
+
writeFileSync(lockPath, JSON.stringify(lock));
|
|
175
|
+
return lockPath;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
it("also imports a required piece that doesn't yet exist in the target", async () => {
|
|
179
|
+
const lockPath = setUpDraftAndSourceWithRequires();
|
|
180
|
+
|
|
181
|
+
const { exitCode } = await runImportPiece([lockPath, "character", "@author/blue", "--draft", draftPath]);
|
|
182
|
+
expect(exitCode).toBeUndefined();
|
|
183
|
+
|
|
184
|
+
const draft: RosterLockV1Draft = JSON.parse(readFileSync(draftPath, "utf-8"));
|
|
185
|
+
expect(draft.stagedLock.rosters["weapon"]).toEqual([SOURCE_WEAPON_PIECE]);
|
|
186
|
+
expect(draft.stagedLock.rosters["character"][0].requiredPieces).toEqual({
|
|
187
|
+
weapon: { expected: ["@author/sword"], selectable: false },
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("reuses an existing piece of matching version, rewriting the expected id to match it", async () => {
|
|
192
|
+
const lockPath = setUpDraftAndSourceWithRequires();
|
|
193
|
+
|
|
194
|
+
const draft: RosterLockV1Draft = JSON.parse(readFileSync(draftPath, "utf-8"));
|
|
195
|
+
const existingWeapon: RosterLockPiece = { ...cloneJSON(SOURCE_WEAPON_PIECE), id: "@other/existing-sword" };
|
|
196
|
+
draft.stagedLock.rosters["weapon"] = [existingWeapon];
|
|
197
|
+
writeFileSync(draftPath, JSON.stringify(draft));
|
|
198
|
+
|
|
199
|
+
const { exitCode } = await runImportPiece([lockPath, "character", "@author/blue", "--draft", draftPath]);
|
|
200
|
+
expect(exitCode).toBeUndefined();
|
|
201
|
+
|
|
202
|
+
const result: RosterLockV1Draft = JSON.parse(readFileSync(draftPath, "utf-8"));
|
|
203
|
+
expect(result.stagedLock.rosters["weapon"]).toEqual([existingWeapon]);
|
|
204
|
+
expect(result.stagedLock.rosters["character"][0].requiredPieces).toEqual({
|
|
205
|
+
weapon: { expected: ["@other/existing-sword"], selectable: false },
|
|
206
|
+
});
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("fails when a required piece id conflicts with a different version already in the target", async () => {
|
|
210
|
+
const lockPath = setUpDraftAndSourceWithRequires();
|
|
211
|
+
|
|
212
|
+
const draft: RosterLockV1Draft = JSON.parse(readFileSync(draftPath, "utf-8"));
|
|
213
|
+
const conflictingWeapon: RosterLockPiece = {
|
|
214
|
+
...cloneJSON(SOURCE_WEAPON_PIECE),
|
|
215
|
+
version: { logic: "1".repeat(64), media: "2".repeat(64), docs: "3".repeat(64) },
|
|
216
|
+
};
|
|
217
|
+
draft.stagedLock.rosters["weapon"] = [conflictingWeapon];
|
|
218
|
+
writeFileSync(draftPath, JSON.stringify(draft));
|
|
219
|
+
|
|
220
|
+
const { exitCode } = await runImportPiece([lockPath, "character", "@author/blue", "--draft", draftPath]);
|
|
221
|
+
expect(exitCode).toBe(1);
|
|
222
|
+
|
|
223
|
+
const result: RosterLockV1Draft = JSON.parse(readFileSync(draftPath, "utf-8"));
|
|
224
|
+
expect(result.stagedLock.rosters["character"]).toHaveLength(0);
|
|
225
|
+
expect(result.stagedLock.rosters["weapon"]).toEqual([conflictingWeapon]);
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join as pathJoin } from "node:path";
|
|
5
|
+
import { EMPTY_ROSTER_DRAFT } from "@roster-lock/shared";
|
|
6
|
+
import { cloneJSON } from "@roster-lock/utils";
|
|
7
|
+
import { RosterLockV1Draft } from "@roster-lock/types";
|
|
8
|
+
import { pieceMetaSetCommand } from "../../src/commands/piece-meta/set";
|
|
9
|
+
import { pieceMetaShowCommand } from "../../src/commands/piece-meta/show";
|
|
10
|
+
|
|
11
|
+
async function runPieceMetaSet(args: Array<string>){
|
|
12
|
+
process.exitCode = undefined;
|
|
13
|
+
let thrown: unknown;
|
|
14
|
+
try {
|
|
15
|
+
await pieceMetaSetCommand.parseAsync(["node", "set", ...args]);
|
|
16
|
+
} catch(e){
|
|
17
|
+
thrown = e;
|
|
18
|
+
}
|
|
19
|
+
return { thrown };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function runPieceMetaShow(args: Array<string>){
|
|
23
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
24
|
+
process.exitCode = undefined;
|
|
25
|
+
await pieceMetaShowCommand.parseAsync(["node", "show", ...args]);
|
|
26
|
+
const logs = logSpy.mock.calls.map((call) => call.join(" "));
|
|
27
|
+
logSpy.mockRestore();
|
|
28
|
+
return { logs };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("piece-meta set/show", () => {
|
|
32
|
+
let tempDir: string;
|
|
33
|
+
let draftPath: string;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
tempDir = mkdtempSync(pathJoin(tmpdir(), "rosterlock-piece-meta-"));
|
|
37
|
+
draftPath = pathJoin(tempDir, "test.rosterlock.draft.json");
|
|
38
|
+
|
|
39
|
+
const draft: RosterLockV1Draft = cloneJSON(EMPTY_ROSTER_DRAFT);
|
|
40
|
+
draft.stagedLock.engine.pieceDefinitions["character"] = {
|
|
41
|
+
selectionStrategy: "mandatory",
|
|
42
|
+
requires: [],
|
|
43
|
+
pathVariables: [],
|
|
44
|
+
assets: [],
|
|
45
|
+
};
|
|
46
|
+
draft.stagedLock.rosters["character"] = [{
|
|
47
|
+
id: "@author/blue",
|
|
48
|
+
version: { logic: "a".repeat(64), media: "b".repeat(64), docs: "c".repeat(64) },
|
|
49
|
+
humanInfo: { name: "Blue", author: "author", url: "https://example.com/blue" },
|
|
50
|
+
downloadSources: ["https://example.com/blue.tar"],
|
|
51
|
+
pathVariables: {},
|
|
52
|
+
requiredPieces: {},
|
|
53
|
+
}];
|
|
54
|
+
draft.stagedLock.selection.piece["character"] = { type: "unselectable" };
|
|
55
|
+
writeFileSync(draftPath, JSON.stringify(draft));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function readDraftFile(): RosterLockV1Draft {
|
|
63
|
+
return JSON.parse(readFileSync(draftPath, "utf-8"));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
it("sets the schema/defaultMeta/values for a piece type, independent of the selection config", async () => {
|
|
67
|
+
const jsonPath = pathJoin(tempDir, "meta.json");
|
|
68
|
+
writeFileSync(jsonPath, JSON.stringify({
|
|
69
|
+
schema: { power: "number" },
|
|
70
|
+
defaultMeta: { power: 1 },
|
|
71
|
+
values: { "@author/blue": { power: 5 } },
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
await runPieceMetaSet(["character", "--draft", draftPath, "--json", jsonPath]);
|
|
75
|
+
|
|
76
|
+
const meta = readDraftFile().stagedLock.pieceMeta["character"];
|
|
77
|
+
expect(meta.schema).toEqual({ power: "number" });
|
|
78
|
+
expect(meta.defaultMeta).toEqual({ power: 1 });
|
|
79
|
+
expect(meta.values).toEqual({ "@author/blue": { power: 5 } });
|
|
80
|
+
// Unrelated to the selection config for that piece type
|
|
81
|
+
expect(readDraftFile().stagedLock.selection.piece["character"]).toEqual({ type: "unselectable" });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("defaults to an empty schema/defaultMeta/values when no --json is given", async () => {
|
|
85
|
+
await runPieceMetaSet(["character", "--draft", draftPath]);
|
|
86
|
+
|
|
87
|
+
const meta = readDraftFile().stagedLock.pieceMeta["character"];
|
|
88
|
+
expect(meta).toEqual({ schema: {}, defaultMeta: {}, values: {} });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("rejects per-piece values referencing a piece not in the roster", async () => {
|
|
92
|
+
const jsonPath = pathJoin(tempDir, "meta.json");
|
|
93
|
+
writeFileSync(jsonPath, JSON.stringify({
|
|
94
|
+
schema: { power: "number" },
|
|
95
|
+
defaultMeta: { power: 1 },
|
|
96
|
+
values: { "@author/missing": { power: 2 } },
|
|
97
|
+
}));
|
|
98
|
+
|
|
99
|
+
const { thrown } = await runPieceMetaSet(["character", "--draft", draftPath, "--json", jsonPath]);
|
|
100
|
+
expect(thrown).toBeUndefined(); // withErrorHandling swallows and sets exitCode instead
|
|
101
|
+
expect(process.exitCode).toBe(1);
|
|
102
|
+
process.exitCode = undefined;
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("rejects --json with an unrecognized field", async () => {
|
|
106
|
+
const jsonPath = pathJoin(tempDir, "bad-meta.json");
|
|
107
|
+
writeFileSync(jsonPath, JSON.stringify({ notARealField: true }));
|
|
108
|
+
|
|
109
|
+
await runPieceMetaSet(["character", "--draft", draftPath, "--json", jsonPath]);
|
|
110
|
+
expect(process.exitCode).toBe(1);
|
|
111
|
+
process.exitCode = undefined;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("show prints the staged lock's piece meta", async () => {
|
|
115
|
+
const jsonPath = pathJoin(tempDir, "meta.json");
|
|
116
|
+
writeFileSync(jsonPath, JSON.stringify({ schema: { power: "number" }, defaultMeta: { power: 1 } }));
|
|
117
|
+
await runPieceMetaSet(["character", "--draft", draftPath, "--json", jsonPath]);
|
|
118
|
+
|
|
119
|
+
const { logs } = await runPieceMetaShow(["--draft", draftPath]);
|
|
120
|
+
const printed = JSON.parse(logs.join(""));
|
|
121
|
+
expect(printed["character"].schema).toEqual({ power: "number" });
|
|
122
|
+
});
|
|
123
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join as pathJoin } from "node:path";
|
|
5
|
+
import { EMPTY_ROSTER_DRAFT } from "@roster-lock/shared";
|
|
6
|
+
import { cloneJSON } from "@roster-lock/utils";
|
|
7
|
+
import { RosterLockV1Draft } from "@roster-lock/types";
|
|
8
|
+
|
|
9
|
+
async function runRescan(args: Array<string>){
|
|
10
|
+
vi.resetModules();
|
|
11
|
+
const { rescanCommand } = await import("../../src/commands/roster/rescan.js");
|
|
12
|
+
|
|
13
|
+
process.exitCode = undefined;
|
|
14
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
15
|
+
await rescanCommand.parseAsync(["node", "rescan", ...args]);
|
|
16
|
+
const logs = logSpy.mock.calls.map((call) => call.join(" "));
|
|
17
|
+
logSpy.mockRestore();
|
|
18
|
+
const exitCode = process.exitCode;
|
|
19
|
+
process.exitCode = undefined;
|
|
20
|
+
return { logs, exitCode };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("roster rescan", () => {
|
|
24
|
+
let tempDir: string;
|
|
25
|
+
let draftPath: string;
|
|
26
|
+
let pieceFolder: string;
|
|
27
|
+
let existingPiece: RosterLockV1Draft["stagedLock"]["rosters"][string][0];
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
tempDir = mkdtempSync(pathJoin(tmpdir(), "rosterlock-rescan-"));
|
|
31
|
+
draftPath = pathJoin(tempDir, "test.rosterlock.draft.json");
|
|
32
|
+
pieceFolder = pathJoin(tempDir, "blue-character");
|
|
33
|
+
mkdirSync(pieceFolder);
|
|
34
|
+
writeFileSync(pathJoin(pieceFolder, "sprite.png"), "fake-png-bytes-v1");
|
|
35
|
+
|
|
36
|
+
const draft: RosterLockV1Draft = cloneJSON(EMPTY_ROSTER_DRAFT);
|
|
37
|
+
draft.stagedLock.engine.pieceDefinitions["character"] = {
|
|
38
|
+
selectionStrategy: "mandatory",
|
|
39
|
+
requires: ["move"],
|
|
40
|
+
pathVariables: [],
|
|
41
|
+
assets: [{ name: "sprite", classification: "media", count: 1, glob: ["*.png"] }],
|
|
42
|
+
};
|
|
43
|
+
draft.stagedLock.engine.pieceDefinitions["move"] = {
|
|
44
|
+
selectionStrategy: "on demand",
|
|
45
|
+
requires: [],
|
|
46
|
+
pathVariables: [],
|
|
47
|
+
assets: [],
|
|
48
|
+
};
|
|
49
|
+
draft.stagedLock.rosters["move"] = [{
|
|
50
|
+
id: "move-basic",
|
|
51
|
+
version: { logic: "d".repeat(64), media: "e".repeat(64), docs: "f".repeat(64) },
|
|
52
|
+
humanInfo: { name: "Basic", author: "author", url: "https://example.com/basic" },
|
|
53
|
+
downloadSources: ["https://example.com/basic.tar"],
|
|
54
|
+
pathVariables: {},
|
|
55
|
+
requiredPieces: {},
|
|
56
|
+
}];
|
|
57
|
+
existingPiece = {
|
|
58
|
+
id: "character-blue",
|
|
59
|
+
version: { logic: "a".repeat(64), media: "b".repeat(64), docs: "c".repeat(64) },
|
|
60
|
+
humanInfo: { name: "Blue", author: "author", url: "https://example.com/blue" },
|
|
61
|
+
downloadSources: ["https://example.com/blue.tar"],
|
|
62
|
+
pathVariables: {},
|
|
63
|
+
requiredPieces: { move: { selectable: true, expected: ["move-basic"] } },
|
|
64
|
+
};
|
|
65
|
+
draft.stagedLock.rosters["character"] = [existingPiece];
|
|
66
|
+
draft.draft.rosterPieceInfo["character"] = {
|
|
67
|
+
"character-blue": {
|
|
68
|
+
referenceFolder: pieceFolder,
|
|
69
|
+
testedDownloadSources: [
|
|
70
|
+
{ source: "https://example.com/blue.tar", testedAt: Date.now(), version: existingPiece.version },
|
|
71
|
+
],
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
writeFileSync(draftPath, JSON.stringify(draft));
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
function readDraft(){
|
|
82
|
+
return JSON.parse(readFileSync(draftPath, "utf-8")) as RosterLockV1Draft;
|
|
83
|
+
}
|
|
84
|
+
function readPiece(){
|
|
85
|
+
return readDraft().stagedLock.rosters["character"][0];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
it("recomputes the version hash using the recorded reference folder, keeping id/humanInfo/requiredPieces", async () => {
|
|
89
|
+
writeFileSync(pathJoin(pieceFolder, "sprite.png"), "fake-png-bytes-v2");
|
|
90
|
+
|
|
91
|
+
const { exitCode } = await runRescan(["character", "character-blue", "--draft", draftPath]);
|
|
92
|
+
expect(exitCode).toBeUndefined();
|
|
93
|
+
|
|
94
|
+
const piece = readPiece();
|
|
95
|
+
expect(piece.id).toBe("character-blue");
|
|
96
|
+
expect(piece.humanInfo).toEqual(existingPiece.humanInfo);
|
|
97
|
+
expect(piece.downloadSources).toEqual(existingPiece.downloadSources);
|
|
98
|
+
expect(piece.requiredPieces).toEqual({ move: { selectable: true, expected: ["move-basic"] } });
|
|
99
|
+
expect(piece.version.media).not.toBe("b".repeat(64));
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("resets testedDownloadSources for the piece since the hash changed", async () => {
|
|
103
|
+
writeFileSync(pathJoin(pieceFolder, "sprite.png"), "fake-png-bytes-v2");
|
|
104
|
+
|
|
105
|
+
await runRescan(["character", "character-blue", "--draft", draftPath]);
|
|
106
|
+
|
|
107
|
+
const draft = readDraft();
|
|
108
|
+
expect(draft.draft.rosterPieceInfo["character"]["character-blue"].testedDownloadSources).toEqual([]);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("accepts an explicit folder argument overriding the recorded reference folder", async () => {
|
|
112
|
+
const otherFolder = pathJoin(tempDir, "other-folder");
|
|
113
|
+
mkdirSync(otherFolder);
|
|
114
|
+
writeFileSync(pathJoin(otherFolder, "sprite.png"), "different-bytes");
|
|
115
|
+
|
|
116
|
+
const { exitCode } = await runRescan(["character", "character-blue", otherFolder, "--draft", draftPath]);
|
|
117
|
+
expect(exitCode).toBeUndefined();
|
|
118
|
+
|
|
119
|
+
const draft = readDraft();
|
|
120
|
+
expect(draft.draft.rosterPieceInfo["character"]["character-blue"].referenceFolder).toBe(otherFolder);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("fails when the folder no longer matches the piece type's asset definitions", async () => {
|
|
124
|
+
rmSync(pathJoin(pieceFolder, "sprite.png"));
|
|
125
|
+
|
|
126
|
+
const { exitCode } = await runRescan(["character", "character-blue", "--draft", draftPath]);
|
|
127
|
+
expect(exitCode).toBe(1);
|
|
128
|
+
|
|
129
|
+
const piece = readPiece();
|
|
130
|
+
expect(piece.version.media).toBe("b".repeat(64));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("fails for an unknown piece id", async () => {
|
|
134
|
+
const { exitCode } = await runRescan(["character", "character-missing", "--draft", draftPath]);
|
|
135
|
+
expect(exitCode).toBe(1);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join as pathJoin } from "node:path";
|
|
5
|
+
import { EMPTY_ROSTER_DRAFT } from "@roster-lock/shared";
|
|
6
|
+
import { cloneJSON } from "@roster-lock/utils";
|
|
7
|
+
import {
|
|
8
|
+
DRAFT_SUFFIX, resolveDraftPath, defaultDraftPath, readDraft, writeDraft,
|
|
9
|
+
} from "../../src/lib/draft-io";
|
|
10
|
+
|
|
11
|
+
describe("draft-io", () => {
|
|
12
|
+
let tempDir: string;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
tempDir = mkdtempSync(pathJoin(tmpdir(), "rosterlock-draft-io-"));
|
|
16
|
+
vi.spyOn(process, "cwd").mockReturnValue(tempDir);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
vi.restoreAllMocks();
|
|
21
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("resolveDraftPath", () => {
|
|
25
|
+
it("resolves an explicit path against cwd", () => {
|
|
26
|
+
expect(resolveDraftPath("foo.rosterlock.draft.json")).toBe(pathJoin(tempDir, "foo.rosterlock.draft.json"));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("finds the sole draft file in cwd", () => {
|
|
30
|
+
writeFileSync(pathJoin(tempDir, `only${DRAFT_SUFFIX}`), "{}");
|
|
31
|
+
expect(resolveDraftPath()).toBe(pathJoin(tempDir, `only${DRAFT_SUFFIX}`));
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("throws when no draft file is found", () => {
|
|
35
|
+
expect(() => resolveDraftPath()).toThrow(/No draft file found/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("throws when multiple draft files are found", () => {
|
|
39
|
+
writeFileSync(pathJoin(tempDir, `a${DRAFT_SUFFIX}`), "{}");
|
|
40
|
+
writeFileSync(pathJoin(tempDir, `b${DRAFT_SUFFIX}`), "{}");
|
|
41
|
+
expect(() => resolveDraftPath()).toThrow(/Multiple draft files found/);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("defaultDraftPath", () => {
|
|
46
|
+
it("slugifies the title", () => {
|
|
47
|
+
expect(defaultDraftPath("My Game")).toBe(pathJoin(tempDir, `my-game${DRAFT_SUFFIX}`));
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("falls back to 'config' when no usable title is given", () => {
|
|
51
|
+
expect(defaultDraftPath()).toBe(pathJoin(tempDir, `config${DRAFT_SUFFIX}`));
|
|
52
|
+
expect(defaultDraftPath("!!!")).toBe(pathJoin(tempDir, `config${DRAFT_SUFFIX}`));
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("readDraft/writeDraft", () => {
|
|
57
|
+
it("round-trips a valid draft through cast + JSON", () => {
|
|
58
|
+
const draftPath = pathJoin(tempDir, `roundtrip${DRAFT_SUFFIX}`);
|
|
59
|
+
const draft = cloneJSON(EMPTY_ROSTER_DRAFT);
|
|
60
|
+
draft.stagedLock.title = "Test";
|
|
61
|
+
|
|
62
|
+
writeDraft(draftPath, draft);
|
|
63
|
+
const written = JSON.parse(readFileSync(draftPath, "utf-8"));
|
|
64
|
+
expect(written.stagedLock.title).toBe("Test");
|
|
65
|
+
|
|
66
|
+
const read = readDraft(draftPath);
|
|
67
|
+
expect(read.stagedLock.title).toBe("Test");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("throws when reading an invalid draft", () => {
|
|
71
|
+
const draftPath = pathJoin(tempDir, `bad${DRAFT_SUFFIX}`);
|
|
72
|
+
writeFileSync(draftPath, JSON.stringify({ not: "a draft" }));
|
|
73
|
+
expect(() => readDraft(draftPath)).toThrow();
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|