diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index 2802a161..a0729fae 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -29,5 +29,13 @@ module.exports = {
                 "@typescript-eslint/no-var-requires": "off",
             },
         },
+        {
+            files: ["./spec/**/*"],
+            env: {
+                browser: false,
+            },
+            extends: ["plugin:jest/recommended"],
+            plugins: ["jest"],
+        },
     ],
 };
diff --git a/jest.config.js b/jest.config.js
index 3e74f815..7d3bde02 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -2,11 +2,15 @@
 //
 // SPDX-License-Identifier: MIT
 
-export default {
+/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
+const config = {
     preset: "ts-jest",
     globals: {
         "ts-jest": {
-            tsconfig: "<rootDir>/spec/tsconfig.spec.json",
+            tsconfig: "<rootDir>/spec/tsconfig.json",
         },
     },
+    setupFiles: ["<rootDir>/spec/setup.ts"],
 };
+
+export default config;
diff --git a/spec/item/type-specific-helpers/spell.spec.ts b/spec/item/type-specific-helpers/spell.spec.ts
new file mode 100644
index 00000000..9b520e5f
--- /dev/null
+++ b/spec/item/type-specific-helpers/spell.spec.ts
@@ -0,0 +1,231 @@
+// SPDX-FileCopyrightText: 2022 Johannes Loher
+//
+// SPDX-License-Identifier: MIT
+
+import { DS4SpellDataSourceData, TemporalUnit, UnitData } from "../../../src/item/item-data-source";
+import { calculateSpellPrice } from "../../../src/item/type-specific-helpers/spell";
+
+const defaultData: DS4SpellDataSourceData = {
+    description: "",
+    equipped: false,
+    spellType: "spellcasting",
+    bonus: "",
+    spellCategory: "unset",
+    maxDistance: {
+        value: "",
+        unit: "meter",
+    },
+    effectRadius: {
+        value: "",
+        unit: "meter",
+    },
+    duration: {
+        value: "",
+        unit: "custom",
+    },
+    cooldownDuration: {
+        value: "",
+        unit: "rounds",
+    },
+    minimumLevels: {
+        healer: null,
+        wizard: null,
+        sorcerer: null,
+    },
+};
+
+type TestCase = {
+    minimumLevel: number | null;
+    expected: number | null;
+};
+
+type CombinedTestCase = {
+    minimumLevels: DS4SpellDataSourceData["minimumLevels"];
+    expected: number | null;
+};
+
+const testCases: Record<keyof DS4SpellDataSourceData["minimumLevels"], TestCase[]> = {
+    healer: [
+        { minimumLevel: null, expected: null },
+        { minimumLevel: 1, expected: 10 },
+        { minimumLevel: 2, expected: 45 },
+        { minimumLevel: 3, expected: 80 },
+        { minimumLevel: 4, expected: 115 },
+        { minimumLevel: 5, expected: 150 },
+        { minimumLevel: 6, expected: 185 },
+        { minimumLevel: 7, expected: 220 },
+        { minimumLevel: 8, expected: 255 },
+        { minimumLevel: 9, expected: 290 },
+        { minimumLevel: 10, expected: 325 },
+        { minimumLevel: 11, expected: 360 },
+        { minimumLevel: 12, expected: 395 },
+        { minimumLevel: 13, expected: 430 },
+        { minimumLevel: 14, expected: 465 },
+        { minimumLevel: 15, expected: 500 },
+        { minimumLevel: 16, expected: 535 },
+        { minimumLevel: 17, expected: 570 },
+        { minimumLevel: 18, expected: 605 },
+        { minimumLevel: 19, expected: 640 },
+        { minimumLevel: 20, expected: 675 },
+    ],
+    sorcerer: [
+        { minimumLevel: null, expected: null },
+        { minimumLevel: 1, expected: 10 },
+        { minimumLevel: 2, expected: 75 },
+        { minimumLevel: 3, expected: 140 },
+        { minimumLevel: 4, expected: 205 },
+        { minimumLevel: 5, expected: 270 },
+        { minimumLevel: 6, expected: 335 },
+        { minimumLevel: 7, expected: 400 },
+        { minimumLevel: 8, expected: 465 },
+        { minimumLevel: 9, expected: 530 },
+        { minimumLevel: 10, expected: 595 },
+        { minimumLevel: 11, expected: 660 },
+        { minimumLevel: 12, expected: 725 },
+        { minimumLevel: 13, expected: 790 },
+        { minimumLevel: 14, expected: 855 },
+        { minimumLevel: 15, expected: 920 },
+        { minimumLevel: 16, expected: 985 },
+        { minimumLevel: 17, expected: 1050 },
+        { minimumLevel: 18, expected: 1115 },
+        { minimumLevel: 19, expected: 1180 },
+        { minimumLevel: 20, expected: 1245 },
+    ],
+    wizard: [
+        { minimumLevel: null, expected: null },
+        { minimumLevel: 1, expected: 10 },
+        { minimumLevel: 2, expected: 60 },
+        { minimumLevel: 3, expected: 110 },
+        { minimumLevel: 4, expected: 160 },
+        { minimumLevel: 5, expected: 210 },
+        { minimumLevel: 6, expected: 260 },
+        { minimumLevel: 7, expected: 310 },
+        { minimumLevel: 8, expected: 360 },
+        { minimumLevel: 9, expected: 410 },
+        { minimumLevel: 10, expected: 460 },
+        { minimumLevel: 11, expected: 510 },
+        { minimumLevel: 12, expected: 560 },
+        { minimumLevel: 13, expected: 610 },
+        { minimumLevel: 14, expected: 660 },
+        { minimumLevel: 15, expected: 710 },
+        { minimumLevel: 16, expected: 760 },
+        { minimumLevel: 17, expected: 810 },
+        { minimumLevel: 18, expected: 860 },
+        { minimumLevel: 19, expected: 910 },
+        { minimumLevel: 20, expected: 960 },
+    ],
+};
+
+function buildCombinedTestCases(): CombinedTestCase[] {
+    const combinedTestCases: CombinedTestCase[] = [];
+
+    // permutation test cases
+    const isRelevantPermutationTestCase = (t: TestCase) =>
+        ([null, 1, 10, 20] as (number | null)[]).includes(t.minimumLevel);
+
+    for (const healerTestCase of testCases.healer.filter(isRelevantPermutationTestCase)) {
+        for (const sorcererTestCase of testCases.sorcerer.filter(isRelevantPermutationTestCase)) {
+            for (const wizardTestCase of testCases.wizard.filter(isRelevantPermutationTestCase)) {
+                const expected =
+                    healerTestCase.expected !== null ||
+                    sorcererTestCase.expected !== null ||
+                    wizardTestCase.expected !== null
+                        ? Math.min(
+                              healerTestCase.expected ?? Infinity,
+                              sorcererTestCase.expected ?? Infinity,
+                              wizardTestCase.expected ?? Infinity,
+                          )
+                        : null;
+                combinedTestCases.push({
+                    minimumLevels: {
+                        healer: healerTestCase.minimumLevel,
+                        sorcerer: sorcererTestCase.minimumLevel,
+                        wizard: wizardTestCase.minimumLevel,
+                    },
+                    expected,
+                });
+            }
+        }
+    }
+
+    // single test cases
+    const isRelevantSingleTestCase = (t: TestCase) => t.minimumLevel !== null;
+
+    for (const spellCasterClass of ["healer", "sorcerer", "wizard"] as const) {
+        for (const testCase of testCases[spellCasterClass].filter(isRelevantSingleTestCase)) {
+            combinedTestCases.push({
+                minimumLevels: {
+                    ...defaultData.minimumLevels,
+                    [spellCasterClass]: testCase.minimumLevel,
+                },
+                expected: testCase.expected,
+            });
+        }
+    }
+
+    return combinedTestCases;
+}
+
+describe("calculateSpellPrice", () => {
+    const cooldownDurations: (UnitData<TemporalUnit> & { factor: number })[] = [
+        { value: "", unit: "rounds", factor: 1 },
+        { value: "foo", unit: "rounds", factor: 1 },
+        { value: "0", unit: "rounds", factor: 1 },
+        { value: "1", unit: "rounds", factor: 1 },
+        { value: "17279", unit: "rounds", factor: 1 },
+        { value: "17280", unit: "rounds", factor: 2 },
+        { value: "34559", unit: "rounds", factor: 2 },
+        { value: "34560", unit: "rounds", factor: 3 },
+
+        { value: "", unit: "minutes", factor: 1 },
+        { value: "foo", unit: "minutes", factor: 1 },
+        { value: "0", unit: "minutes", factor: 1 },
+        { value: "1", unit: "minutes", factor: 1 },
+        { value: "1439", unit: "minutes", factor: 1 },
+        { value: "1440", unit: "minutes", factor: 2 },
+        { value: "2879", unit: "minutes", factor: 2 },
+        { value: "2880", unit: "minutes", factor: 3 },
+
+        { value: "", unit: "hours", factor: 1 },
+        { value: "foo", unit: "hours", factor: 1 },
+        { value: "0", unit: "hours", factor: 1 },
+        { value: "1", unit: "hours", factor: 1 },
+        { value: "23", unit: "hours", factor: 1 },
+        { value: "24", unit: "hours", factor: 2 },
+        { value: "47", unit: "hours", factor: 2 },
+        { value: "48", unit: "hours", factor: 3 },
+
+        { value: "", unit: "days", factor: 3 },
+        { value: "foo", unit: "days", factor: 3 },
+        { value: "0", unit: "days", factor: 1 },
+        { value: "1", unit: "days", factor: 2 },
+        { value: "2", unit: "days", factor: 3 },
+    ];
+
+    describe.each(cooldownDurations)("with cooldown duration set to $value $unit", ({ value, unit, factor }) => {
+        const dataWithCooldownDuration = {
+            ...defaultData,
+            cooldownDuration: {
+                value,
+                unit,
+            },
+        };
+
+        it.each(buildCombinedTestCases())(
+            `returns ${factor} × $expected if the minimum leves are $minimumLevels`,
+            ({ minimumLevels, expected }) => {
+                // given
+                const data: DS4SpellDataSourceData = {
+                    ...dataWithCooldownDuration,
+                    minimumLevels,
+                };
+
+                // when
+                const spellPrice = calculateSpellPrice(data);
+
+                // then
+                expect(spellPrice).toBe(expected !== null ? expected * factor : expected);
+            },
+        );
+    });
+});
diff --git a/spec/localization/localization.spec.ts b/spec/localization/localization.spec.ts
index 4c62e45a..a050cf71 100644
--- a/spec/localization/localization.spec.ts
+++ b/spec/localization/localization.spec.ts
@@ -2,14 +2,10 @@
 //
 // SPDX-License-Identifier: MIT
 
-import * as fs from "fs-extra";
-import * as path from "path";
+import en from "../../lang/en.json";
+import de from "../../lang/de.json";
 
 describe("English and german localization files", () => {
-    const localizationPath = "./lang/";
-    const en: Record<string, unknown> = fs.readJSONSync(path.join(localizationPath, "en.json"));
-    const de: Record<string, unknown> = fs.readJSONSync(path.join(localizationPath, "de.json"));
-
     it("should have the same keys.", () => {
         const deKeys = Object.keys(de);
         const enKeys = Object.keys(en);
diff --git a/spec/rolls/check-evaluation.spec.ts b/spec/rolls/check-evaluation.spec.ts
index bde1ddbf..5e5e954c 100644
--- a/spec/rolls/check-evaluation.spec.ts
+++ b/spec/rolls/check-evaluation.spec.ts
@@ -5,39 +5,21 @@
 
 import evaluateCheck from "../../src/rolls/check-evaluation";
 
-class StubGame {
-    constructor() {
-        this.i18n = {
-            localize: (key: string) => key,
-        };
-    }
-    i18n: {
-        localize: (key: string) => string;
-    };
-}
-
-const game = new StubGame();
-
-Object.defineProperties(globalThis, {
-    game: { value: game },
-    Game: { value: StubGame },
-});
-
 describe("evaluateCheck with no dice", () => {
     it("should throw an error.", () => {
-        expect(() => evaluateCheck([], 10)).toThrow("DS4.ErrorInvalidNumberOfDice");
+        expect(() => evaluateCheck([], 10)).toThrow("Invalid number of dice.");
     });
 });
 
 describe("evaluateCheck with more dice than required by the checkTargetNumber", () => {
     it("should throw an error.", () => {
-        expect(() => evaluateCheck([10, 10], 10)).toThrow("DS4.ErrorInvalidNumberOfDice");
+        expect(() => evaluateCheck([10, 10], 10)).toThrow("Invalid number of dice.");
     });
 });
 
 describe("evaluateCheck with less dice than required by the checkTargetNumber", () => {
     it("should throw an error.", () => {
-        expect(() => evaluateCheck([10], 21)).toThrow("DS4.ErrorInvalidNumberOfDice");
+        expect(() => evaluateCheck([10], 21)).toThrow("Invalid number of dice.");
     });
 });
 
diff --git a/spec/setup.ts b/spec/setup.ts
new file mode 100644
index 00000000..d96ad725
--- /dev/null
+++ b/spec/setup.ts
@@ -0,0 +1,57 @@
+// SPDX-FileCopyrightText: 2022 Johannes Loher
+//
+// SPDX-License-Identifier: MIT
+
+import en from "../lang/en.json";
+
+function setupPrimitives() {
+    Object.defineProperties(Number, {
+        isNumeric: {
+            value: function (n: unknown) {
+                if (n instanceof Array) return false;
+                else if (([null, ""] as unknown[]).includes(n)) return false;
+                // @ts-expect-error Abusing JavaScript a bit here, but it's the implementation from foundry
+                return +n === +n;
+            },
+        },
+        fromString: {
+            value: function (str: unknown) {
+                if (typeof str !== "string" || !str.length) return NaN;
+                // Remove whitespace.
+                str = str.replace(/\s+/g, "");
+                return Number(str);
+            },
+        },
+    });
+
+    Object.defineProperties(Math, {
+        clamped: {
+            value: function (num: number, min: number, max: number) {
+                return Math.min(max, Math.max(num, min));
+            },
+        },
+    });
+}
+
+function setupStubs() {
+    class StubGame {
+        constructor() {
+            this.i18n = {
+                localize: (key: string) => (key in en ? en[key as keyof typeof en] : key),
+            };
+        }
+        i18n: {
+            localize: (key: string) => string;
+        };
+    }
+
+    const game = new StubGame();
+
+    Object.defineProperties(globalThis, {
+        game: { value: game },
+        Game: { value: StubGame },
+    });
+}
+
+setupPrimitives();
+setupStubs();
diff --git a/spec/tsconfig.spec.json b/spec/tsconfig.json
similarity index 100%
rename from spec/tsconfig.spec.json
rename to spec/tsconfig.json
diff --git a/spec/tsconfig.spec.json.license b/spec/tsconfig.json.license
similarity index 100%
rename from spec/tsconfig.spec.json.license
rename to spec/tsconfig.json.license
diff --git a/templates/sheets/item/components/properties/spell.hbs b/templates/sheets/item/components/properties/spell.hbs
index bfeba19e..ea788cd9 100644
--- a/templates/sheets/item/components/properties/spell.hbs
+++ b/templates/sheets/item/components/properties/spell.hbs
@@ -106,7 +106,6 @@ SPDX-License-Identifier: MIT
     </div>
     <div class="form-group">
         <label for="data.price-{{data._id}}">{{localize "DS4.SpellPrice"}}</label>
-        <input id="data.price-{{data._id}}" data-dtype="Number" type="number" min="0" step="0.01" name="data.price"
-            placeholder="0" value="{{data.data.price}}" />
+        <span id="data.price-{{data._id}}">{{data.data.price}}</span>
     </div>
 </div>
diff --git a/tsconfig.json b/tsconfig.json
index b8fbff11..7a7a51ce 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -7,7 +7,8 @@
         "moduleResolution": "node",
         "forceConsistentCasingInFileNames": true,
         "strict": true,
-        "noUncheckedIndexedAccess": true
+        "noUncheckedIndexedAccess": true,
+        "resolveJsonModule": true
     },
     "include": ["src"]
 }