diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml index fe2154f..9f645c1 100644 --- a/.github/workflows/e2e.yaml +++ b/.github/workflows/e2e.yaml @@ -24,23 +24,129 @@ jobs: with: persist-credentials: false - - name: Test Run - id: test-run + - name: Test with defaults + id: test-defaults uses: ./ with: - placeholder: "test_placeholder" + matrix: '["a","b","c"]' - - name: Assert placeholder + - name: Assert default output uses: nick-fields/assert-action@v2 with: - actual: ${{ steps.test-run.outputs.placeholder }} - expected: "test_placeholder" + actual: ${{ steps.test-defaults.outputs.matrix }} + expected: '["a b c"]' + + e2e-group-size: + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Test with group size + id: test-group-size + uses: ./ + with: + matrix: '["a","b","c","d","e"]' + target-group-size: 2 + + - name: Assert grouped output + uses: nick-fields/assert-action@v2 + with: + actual: ${{ steps.test-group-size.outputs.matrix }} + expected: '["a b","c d","e"]' + + e2e-prefix: + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Test with prefix + id: test-prefix + uses: ./ + with: + matrix: '["pkg-1","pkg-2","pkg-3"]' + target-group-size: 2 + result-item-prefix: "/data/" + + - name: Assert prefixed output + uses: nick-fields/assert-action@v2 + with: + actual: ${{ steps.test-prefix.outputs.matrix }} + expected: '["/data/pkg-1 /data/pkg-2","/data/pkg-3"]' + + e2e-separator: + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Test with custom separator + id: test-separator + uses: ./ + with: + matrix: '["a","b","c"]' + target-group-size: 2 + result-format-plain-separator: "," + + - name: Assert separator output + uses: nick-fields/assert-action@v2 + with: + actual: ${{ steps.test-separator.outputs.matrix }} + expected: '["a,b","c"]' + + e2e-empty: + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Test with empty array + id: test-empty + uses: ./ + with: + matrix: "[]" + + - name: Assert empty output + uses: nick-fields/assert-action@v2 + with: + actual: ${{ steps.test-empty.outputs.matrix }} + expected: "[]" + e2e: runs-on: ubuntu-latest if: always() && !cancelled() needs: - e2e-default + - e2e-group-size + - e2e-prefix + - e2e-separator + - e2e-empty steps: - name: Collect Results diff --git a/README.md b/README.md index 63d8d5c..70ad462 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![CI](https://github.com/ovsds/split-matrix-action/workflows/Check%20PR/badge.svg)](https://github.com/ovsds/split-matrix-action/actions?query=workflow%3A%22%22Check+PR%22%22) [![GitHub Marketplace](https://img.shields.io/badge/Marketplace-Split%20Matrix-blue.svg)](https://github.com/marketplace/actions/split-matrix) -Split Matrix Action +Splits a JSON array into groups for GitHub Actions matrix strategy. ## Usage @@ -16,26 +16,53 @@ jobs: - name: Split Matrix id: split-matrix uses: ovsds/split-matrix-action@v1 + with: + matrix: '["pkg-1", "pkg-2", "pkg-3", "pkg-4", "pkg-5"]' + target-group-size: 2 + result-item-prefix: "/data/" + + - name: Use matrix + run: echo '${{ steps.split-matrix.outputs.matrix }}' + # Output: ["/data/pkg-1 /data/pkg-2","/data/pkg-3 /data/pkg-4","/data/pkg-5"] ``` ### Action Inputs ```yaml inputs: - placeholder: + matrix: description: | - Placeholder input to be replaced by real inputs + JSON array of strings to split into groups required: true - default: "placeholder" + target-group-size: + description: | + Number of items per group + required: false + default: "10" + result-format: + description: | + Output format for groups. Supported: "plain" + required: false + default: "plain" + result-format-plain-separator: + description: | + Separator to join items within a group (used when result-format is "plain") + required: false + default: " " + result-item-prefix: + description: | + Prefix to prepend to each item + required: false + default: "" ``` ### Action Outputs ```yaml outputs: - placeholder: + matrix: description: | - Placeholder output to be replaced by real outputs + JSON array of grouped strings ``` ## Development diff --git a/action.yaml b/action.yaml index 552c270..78ecbc8 100644 --- a/action.yaml +++ b/action.yaml @@ -1,23 +1,42 @@ name: "Split Matrix" description: | - Split Matrix Action + Splits a JSON array into groups for GitHub Actions matrix strategy. # https://docs.github.com/en/actions/creating-actions/metadata-syntax-for-github-actions#branding branding: - icon: "message-square" + icon: "grid" color: "gray-dark" inputs: - placeholder: + matrix: description: | - Placeholder input to be replaced by real inputs + JSON array of strings to split into groups required: true - default: "placeholder" + target-group-size: + description: | + Number of items per group + required: false + default: "10" + result-format: + description: | + Output format for groups. Supported: "plain" + required: false + default: "plain" + result-format-plain-separator: + description: | + Separator to join items within a group (used when result-format is "plain") + required: false + default: " " + result-item-prefix: + description: | + Prefix to prepend to each item + required: false + default: "" outputs: - placeholder: + matrix: description: | - Placeholder output to be replaced by real outputs + JSON array of grouped strings runs: using: node24 diff --git a/dist/index.js b/dist/index.js index 6efb7f9..33c5d6c 100644 --- a/dist/index.js +++ b/dist/index.js @@ -17,6 +17,23 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge }; Object.defineProperty(exports, "__esModule", ({ value: true })); exports.Action = void 0; +exports.splitIntoGroups = splitIntoGroups; +function splitIntoGroups(list, targetGroupSize) { + if (list.length === 0) { + return []; + } + const groupCount = Math.ceil(list.length / targetGroupSize); + const baseSize = Math.floor(list.length / groupCount); + const remainder = list.length % groupCount; + const groups = []; + let offset = 0; + for (let i = 0; i < groupCount; i++) { + const size = baseSize + (i < remainder ? 1 : 0); + groups.push(list.slice(offset, offset + size)); + offset += size; + } + return groups; +} class Action { static fromOptions(actionOptions) { return new Action(actionOptions); @@ -26,10 +43,16 @@ class Action { } run() { return __awaiter(this, void 0, void 0, function* () { - this.options.logger(`Running with placeholder: ${this.options.placeholder}`); - return { - placeholder: this.options.placeholder, - }; + const { matrix, targetGroupSize, resultFormat, resultFormatPlainSeparator, resultItemPrefix, logger } = this.options; + logger(`Splitting ${matrix.length} items into groups of ${targetGroupSize}`); + logger(`Result format: ${resultFormat}, separator: "${resultFormatPlainSeparator}", prefix: "${resultItemPrefix}"`); + if (matrix.length === 0) { + return { matrix: [] }; + } + const groups = splitIntoGroups(matrix, targetGroupSize); + const result = groups.map((group) => group.map((item) => `${resultItemPrefix}${item}`).join(resultFormatPlainSeparator)); + logger(`Created ${result.length} groups`); + return { matrix: result }; }); } } @@ -48,7 +71,11 @@ exports.parseActionInput = parseActionInput; const parse_1 = __nccwpck_require__(789); function parseActionInput(raw) { return { - placeholder: (0, parse_1.parseNonEmptyString)(raw.placeholder), + matrix: (0, parse_1.parseJsonStringArray)(raw.matrix), + targetGroupSize: (0, parse_1.parsePositiveInteger)(raw.targetGroupSize), + resultFormat: (0, parse_1.parseResultFormat)(raw.resultFormat), + resultFormatPlainSeparator: raw.resultFormatPlainSeparator, + resultItemPrefix: raw.resultItemPrefix, }; } @@ -75,12 +102,16 @@ const action_1 = __nccwpck_require__(1536); const input_1 = __nccwpck_require__(2868); function getActionInput() { return (0, input_1.parseActionInput)({ - placeholder: (0, core_1.getInput)("placeholder"), + matrix: (0, core_1.getInput)("matrix"), + targetGroupSize: (0, core_1.getInput)("target-group-size"), + resultFormat: (0, core_1.getInput)("result-format"), + resultFormatPlainSeparator: (0, core_1.getInput)("result-format-plain-separator", { trimWhitespace: false }), + resultItemPrefix: (0, core_1.getInput)("result-item-prefix", { trimWhitespace: false }), }); } function setActionOutput(actionResult) { (0, core_1.info)(`Action result: ${JSON.stringify(actionResult)}`); - (0, core_1.setOutput)("placeholder", actionResult.placeholder); + (0, core_1.setOutput)("matrix", JSON.stringify(actionResult.matrix)); } function _main() { return __awaiter(this, void 0, void 0, function* () { @@ -93,7 +124,7 @@ function _main() { function main() { return __awaiter(this, void 0, void 0, function* () { try { - _main(); + yield _main(); } catch (error) { if (error instanceof Error) { @@ -116,7 +147,7 @@ main(); "use strict"; Object.defineProperty(exports, "__esModule", ({ value: true })); -exports.parseNonEmptyString = void 0; +exports.parseResultFormat = exports.parsePositiveInteger = exports.parseJsonStringArray = exports.parseNonEmptyString = void 0; const parseNonEmptyString = (value) => { if (!value) { throw new Error(`Invalid ${value}, must be a non-empty string`); @@ -127,6 +158,41 @@ const parseNonEmptyString = (value) => { return value; }; exports.parseNonEmptyString = parseNonEmptyString; +const parseJsonStringArray = (value) => { + let parsed; + try { + parsed = JSON.parse(value); + } + catch (_a) { + throw new Error(`Invalid JSON: ${value}`); + } + if (!Array.isArray(parsed)) { + throw new Error(`Expected a JSON array, got: ${typeof parsed}`); + } + for (const item of parsed) { + if (typeof item !== "string") { + throw new Error(`Expected all items to be strings, got: ${typeof item}`); + } + } + return parsed; +}; +exports.parseJsonStringArray = parseJsonStringArray; +const parsePositiveInteger = (value) => { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`Invalid ${value}, must be a positive integer`); + } + return parsed; +}; +exports.parsePositiveInteger = parsePositiveInteger; +const VALID_RESULT_FORMATS = ["plain"]; +const parseResultFormat = (value) => { + if (!VALID_RESULT_FORMATS.includes(value)) { + throw new Error(`Invalid result-format "${value}", must be one of: ${VALID_RESULT_FORMATS.join(", ")}`); + } + return value; +}; +exports.parseResultFormat = parseResultFormat; /***/ }), diff --git a/src/action.ts b/src/action.ts index b3ee8e8..88973bd 100644 --- a/src/action.ts +++ b/src/action.ts @@ -1,12 +1,39 @@ +import { ResultFormat } from "./utils/parse"; + export interface ActionResult { - placeholder: string; + matrix: string[]; } interface ActionOptions { - placeholder: string; + matrix: string[]; + targetGroupSize: number; + resultFormat: ResultFormat; + resultFormatPlainSeparator: string; + resultItemPrefix: string; logger: (message: string) => void; } +export function splitIntoGroups(list: string[], targetGroupSize: number): string[][] { + if (list.length === 0) { + return []; + } + + const groupCount = Math.ceil(list.length / targetGroupSize); + const baseSize = Math.floor(list.length / groupCount); + const remainder = list.length % groupCount; + + const groups: string[][] = []; + let offset = 0; + + for (let i = 0; i < groupCount; i++) { + const size = baseSize + (i < remainder ? 1 : 0); + groups.push(list.slice(offset, offset + size)); + offset += size; + } + + return groups; +} + export class Action { static fromOptions(actionOptions: ActionOptions): Action { return new Action(actionOptions); @@ -19,10 +46,23 @@ export class Action { } async run(): Promise { - this.options.logger(`Running with placeholder: ${this.options.placeholder}`); + const { matrix, targetGroupSize, resultFormat, resultFormatPlainSeparator, resultItemPrefix, logger } = + this.options; + + logger(`Splitting ${matrix.length} items into groups of ${targetGroupSize}`); + logger(`Result format: ${resultFormat}, separator: "${resultFormatPlainSeparator}", prefix: "${resultItemPrefix}"`); + + if (matrix.length === 0) { + return { matrix: [] }; + } + + const groups = splitIntoGroups(matrix, targetGroupSize); + const result = groups.map((group) => + group.map((item) => `${resultItemPrefix}${item}`).join(resultFormatPlainSeparator), + ); + + logger(`Created ${result.length} groups`); - return { - placeholder: this.options.placeholder, - }; + return { matrix: result }; } } diff --git a/src/input.ts b/src/input.ts index 09988c9..36e0399 100644 --- a/src/input.ts +++ b/src/input.ts @@ -1,15 +1,27 @@ -import { parseNonEmptyString } from "./utils/parse"; +import { ResultFormat, parseJsonStringArray, parsePositiveInteger, parseResultFormat } from "./utils/parse"; export interface RawActionInput { - placeholder: string; + matrix: string; + targetGroupSize: string; + resultFormat: string; + resultFormatPlainSeparator: string; + resultItemPrefix: string; } export interface ActionInput { - placeholder: string; + matrix: string[]; + targetGroupSize: number; + resultFormat: ResultFormat; + resultFormatPlainSeparator: string; + resultItemPrefix: string; } export function parseActionInput(raw: RawActionInput): ActionInput { return { - placeholder: parseNonEmptyString(raw.placeholder), + matrix: parseJsonStringArray(raw.matrix), + targetGroupSize: parsePositiveInteger(raw.targetGroupSize), + resultFormat: parseResultFormat(raw.resultFormat), + resultFormatPlainSeparator: raw.resultFormatPlainSeparator, + resultItemPrefix: raw.resultItemPrefix, }; } diff --git a/src/main.ts b/src/main.ts index 2d30a15..6390344 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,13 +5,17 @@ import { ActionInput, parseActionInput } from "./input"; function getActionInput(): ActionInput { return parseActionInput({ - placeholder: getInput("placeholder"), + matrix: getInput("matrix"), + targetGroupSize: getInput("target-group-size"), + resultFormat: getInput("result-format"), + resultFormatPlainSeparator: getInput("result-format-plain-separator", { trimWhitespace: false }), + resultItemPrefix: getInput("result-item-prefix", { trimWhitespace: false }), }); } function setActionOutput(actionResult: ActionResult): void { info(`Action result: ${JSON.stringify(actionResult)}`); - setOutput("placeholder", actionResult.placeholder); + setOutput("matrix", JSON.stringify(actionResult.matrix)); } async function _main(): Promise { @@ -26,7 +30,7 @@ async function _main(): Promise { async function main(): Promise { try { - _main(); + await _main(); } catch (error) { if (error instanceof Error) { setFailed(error.message); diff --git a/src/utils/parse.ts b/src/utils/parse.ts index 9664f93..391fccc 100644 --- a/src/utils/parse.ts +++ b/src/utils/parse.ts @@ -7,3 +7,45 @@ export const parseNonEmptyString = (value: string | undefined): string => { } return value; }; + +export const parseJsonStringArray = (value: string): string[] => { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + throw new Error(`Invalid JSON: ${value}`); + } + + if (!Array.isArray(parsed)) { + throw new Error(`Expected a JSON array, got: ${typeof parsed}`); + } + + for (const item of parsed) { + if (typeof item !== "string") { + throw new Error(`Expected all items to be strings, got: ${typeof item}`); + } + } + + return parsed as string[]; +}; + +export const parsePositiveInteger = (value: string): number => { + const parsed = Number(value); + + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`Invalid ${value}, must be a positive integer`); + } + + return parsed; +}; + +const VALID_RESULT_FORMATS = ["plain"] as const; +export type ResultFormat = (typeof VALID_RESULT_FORMATS)[number]; + +export const parseResultFormat = (value: string): ResultFormat => { + if (!VALID_RESULT_FORMATS.includes(value as ResultFormat)) { + throw new Error(`Invalid result-format "${value}", must be one of: ${VALID_RESULT_FORMATS.join(", ")}`); + } + + return value as ResultFormat; +}; diff --git a/tests/unit/action.test.ts b/tests/unit/action.test.ts new file mode 100644 index 0000000..3724bcb --- /dev/null +++ b/tests/unit/action.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, test, vi } from "vitest"; + +import { Action, splitIntoGroups } from "../../src/action"; + +const noopLogger = vi.fn(); + +describe("splitIntoGroups", () => { + test("splits evenly", () => { + expect(splitIntoGroups(["a", "b", "c", "d"], 2)).toEqual([ + ["a", "b"], + ["c", "d"], + ]); + }); + + test("distributes remainder evenly", () => { + // 5 items, target 2 → 3 groups: [2, 2, 1] + expect(splitIntoGroups(["a", "b", "c", "d", "e"], 2)).toEqual([["a", "b"], ["c", "d"], ["e"]]); + }); + + test("distributes approximately evenly instead of one small tail", () => { + // 12 items, target 10 → 2 groups of 6 (not [10, 2]) + expect(splitIntoGroups(["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"], 10)).toEqual([ + ["a", "b", "c", "d", "e", "f"], + ["g", "h", "i", "j", "k", "l"], + ]); + }); + + test("handles empty array", () => { + expect(splitIntoGroups([], 2)).toEqual([]); + }); + + test("handles group size larger than array", () => { + expect(splitIntoGroups(["a", "b"], 10)).toEqual([["a", "b"]]); + }); + + test("handles group size of 1", () => { + expect(splitIntoGroups(["a", "b", "c"], 1)).toEqual([["a"], ["b"], ["c"]]); + }); + + test("handles exact multiple", () => { + expect(splitIntoGroups(["a", "b", "c", "d", "e", "f"], 3)).toEqual([ + ["a", "b", "c"], + ["d", "e", "f"], + ]); + }); + + test("sizes differ by at most 1", () => { + // 7 items, target 3 → 3 groups: [3, 2, 2] + const result = splitIntoGroups(["a", "b", "c", "d", "e", "f", "g"], 3); + expect(result).toEqual([ + ["a", "b", "c"], + ["d", "e"], + ["f", "g"], + ]); + const sizes = result.map((g) => g.length); + expect(Math.max(...sizes) - Math.min(...sizes)).toBeLessThanOrEqual(1); + }); +}); + +describe("Action", () => { + test("splits items and joins with separator", async () => { + const action = Action.fromOptions({ + matrix: ["a", "b", "c", "d", "e"], + targetGroupSize: 2, + resultFormat: "plain", + resultFormatPlainSeparator: " ", + resultItemPrefix: "", + logger: noopLogger, + }); + + const result = await action.run(); + expect(result.matrix).toEqual(["a b", "c d", "e"]); + }); + + test("applies prefix to each item", async () => { + const action = Action.fromOptions({ + matrix: ["pkg-a", "pkg-b", "pkg-c"], + targetGroupSize: 2, + resultFormat: "plain", + resultFormatPlainSeparator: " ", + resultItemPrefix: "/data/", + logger: noopLogger, + }); + + const result = await action.run(); + expect(result.matrix).toEqual(["/data/pkg-a /data/pkg-b", "/data/pkg-c"]); + }); + + test("uses custom separator", async () => { + const action = Action.fromOptions({ + matrix: ["a", "b", "c"], + targetGroupSize: 2, + resultFormat: "plain", + resultFormatPlainSeparator: ",", + resultItemPrefix: "", + logger: noopLogger, + }); + + const result = await action.run(); + expect(result.matrix).toEqual(["a,b", "c"]); + }); + + test("returns empty array for empty input", async () => { + const action = Action.fromOptions({ + matrix: [], + targetGroupSize: 10, + resultFormat: "plain", + resultFormatPlainSeparator: " ", + resultItemPrefix: "", + logger: noopLogger, + }); + + const result = await action.run(); + expect(result.matrix).toEqual([]); + }); + + test("distributes evenly with prefix", async () => { + const packages = [ + "pkg-1", + "pkg-2", + "pkg-3", + "pkg-4", + "pkg-5", + "pkg-6", + "pkg-7", + "pkg-8", + "pkg-9", + "pkg-10", + "pkg-11", + "pkg-12", + ]; + const action = Action.fromOptions({ + matrix: packages, + targetGroupSize: 10, + resultFormat: "plain", + resultFormatPlainSeparator: " ", + resultItemPrefix: "/data/", + logger: noopLogger, + }); + + const result = await action.run(); + expect(result.matrix).toEqual([ + "/data/pkg-1 /data/pkg-2 /data/pkg-3 /data/pkg-4 /data/pkg-5 /data/pkg-6", + "/data/pkg-7 /data/pkg-8 /data/pkg-9 /data/pkg-10 /data/pkg-11 /data/pkg-12", + ]); + }); +}); diff --git a/tests/unit/input.test.ts b/tests/unit/input.test.ts index 3242217..40385a7 100644 --- a/tests/unit/input.test.ts +++ b/tests/unit/input.test.ts @@ -2,8 +2,12 @@ import { describe, expect, test } from "vitest"; import { RawActionInput, parseActionInput } from "../../src/input"; -const defaultRawInput = { - placeholder: "test_placeholder", +const defaultRawInput: RawActionInput = { + matrix: '["pkg-a","pkg-b","pkg-c"]', + targetGroupSize: "10", + resultFormat: "plain", + resultFormatPlainSeparator: " ", + resultItemPrefix: "", }; function createRawInput(overrides: Partial = {}): RawActionInput { @@ -16,11 +20,23 @@ function createRawInput(overrides: Partial = {}): RawActionInput describe("Input tests", () => { test("parses raw input correctly", () => { expect(parseActionInput(createRawInput())).toEqual({ - placeholder: "test_placeholder", + matrix: ["pkg-a", "pkg-b", "pkg-c"], + targetGroupSize: 10, + resultFormat: "plain", + resultFormatPlainSeparator: " ", + resultItemPrefix: "", }); }); - test("throws error when placeholder is empty", () => { - expect(() => parseActionInput(createRawInput({ placeholder: "" })).placeholder).toThrowError(); + test("throws error when matrix is invalid JSON", () => { + expect(() => parseActionInput(createRawInput({ matrix: "not json" }))).toThrowError(); + }); + + test("throws error when targetGroupSize is invalid", () => { + expect(() => parseActionInput(createRawInput({ targetGroupSize: "0" }))).toThrowError(); + }); + + test("throws error when resultFormat is invalid", () => { + expect(() => parseActionInput(createRawInput({ resultFormat: "xml" }))).toThrowError(); }); }); diff --git a/tests/unit/utils/parse.test.ts b/tests/unit/utils/parse.test.ts index 1b14d6a..4410725 100644 --- a/tests/unit/utils/parse.test.ts +++ b/tests/unit/utils/parse.test.ts @@ -1,14 +1,74 @@ import { describe, expect, test } from "vitest"; -import { parseNonEmptyString } from "../../../src/utils/parse"; +import { + parseNonEmptyString, + parseJsonStringArray, + parsePositiveInteger, + parseResultFormat, +} from "../../../src/utils/parse"; -describe("Parse utils tests", () => { - test("parseNonEmptyString parses non-empty string correctly", () => { +describe("parseNonEmptyString", () => { + test("parses non-empty string correctly", () => { expect(parseNonEmptyString("test")).toBe("test"); }); - test("parseNonEmptyString throws error when empty", () => { + test("throws error when empty", () => { expect(() => parseNonEmptyString("")).toThrowError(); expect(() => parseNonEmptyString(undefined)).toThrowError(); }); }); + +describe("parseJsonStringArray", () => { + test("parses valid JSON array of strings", () => { + expect(parseJsonStringArray('["a","b","c"]')).toEqual(["a", "b", "c"]); + }); + + test("parses empty array", () => { + expect(parseJsonStringArray("[]")).toEqual([]); + }); + + test("throws on invalid JSON", () => { + expect(() => parseJsonStringArray("not json")).toThrowError("Invalid JSON"); + }); + + test("throws on non-array JSON", () => { + expect(() => parseJsonStringArray('{"a": 1}')).toThrowError("Expected a JSON array"); + }); + + test("throws on array with non-string items", () => { + expect(() => parseJsonStringArray("[1, 2]")).toThrowError("Expected all items to be strings"); + }); +}); + +describe("parsePositiveInteger", () => { + test("parses valid positive integer", () => { + expect(parsePositiveInteger("10")).toBe(10); + expect(parsePositiveInteger("1")).toBe(1); + }); + + test("throws on zero", () => { + expect(() => parsePositiveInteger("0")).toThrowError("must be a positive integer"); + }); + + test("throws on negative", () => { + expect(() => parsePositiveInteger("-1")).toThrowError("must be a positive integer"); + }); + + test("throws on non-integer", () => { + expect(() => parsePositiveInteger("1.5")).toThrowError("must be a positive integer"); + }); + + test("throws on non-numeric", () => { + expect(() => parsePositiveInteger("abc")).toThrowError("must be a positive integer"); + }); +}); + +describe("parseResultFormat", () => { + test("parses 'plain'", () => { + expect(parseResultFormat("plain")).toBe("plain"); + }); + + test("throws on unsupported format", () => { + expect(() => parseResultFormat("xml")).toThrowError("Invalid result-format"); + }); +});