mirror of
https://github.com/Kornstalx/5etools-mirror-2.github.io.git
synced 2025-10-28 20:45:35 -05:00
v1.198.1
This commit is contained in:
184
test/.eslintrc.cjs
Normal file
184
test/.eslintrc.cjs
Normal file
@@ -0,0 +1,184 @@
|
||||
module.exports = {
|
||||
"extends": "eslint:recommended",
|
||||
"env": {
|
||||
"browser": false,
|
||||
"es6": true,
|
||||
"jquery": true,
|
||||
},
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module",
|
||||
},
|
||||
"rules": {
|
||||
"accessor-pairs": "off",
|
||||
"arrow-spacing": ["error", {"before": true, "after": true}],
|
||||
"block-spacing": ["error", "always"],
|
||||
"brace-style": ["error", "1tbs", {"allowSingleLine": true}],
|
||||
"comma-dangle": ["error", {
|
||||
"arrays": "always-multiline",
|
||||
"objects": "always-multiline",
|
||||
"imports": "always-multiline",
|
||||
"exports": "always-multiline",
|
||||
"functions": "always-multiline",
|
||||
}],
|
||||
"comma-spacing": ["error", {"before": false, "after": true}],
|
||||
"comma-style": ["error", "last"],
|
||||
"constructor-super": "error",
|
||||
"curly": ["error", "multi-line"],
|
||||
"dot-location": ["error", "property"],
|
||||
"eqeqeq": ["error", "always", {"null": "ignore"}],
|
||||
"func-call-spacing": ["error", "never"],
|
||||
"generator-star-spacing": ["error", {"before": false, "after": true}],
|
||||
"handle-callback-err": ["error", "^(err|error)$"],
|
||||
"indent": [
|
||||
"error",
|
||||
"tab",
|
||||
{
|
||||
"SwitchCase": 1,
|
||||
},
|
||||
],
|
||||
"key-spacing": ["error", {"beforeColon": false, "afterColon": true}],
|
||||
"keyword-spacing": ["error", {"before": true, "after": true}],
|
||||
"new-cap": ["error", {"newIsCap": true, "capIsNew": false}],
|
||||
"new-parens": "error",
|
||||
"no-array-constructor": "error",
|
||||
"no-caller": "error",
|
||||
"no-class-assign": "error",
|
||||
"no-compare-neg-zero": "error",
|
||||
"no-cond-assign": "error",
|
||||
"no-const-assign": "error",
|
||||
"no-constant-condition": ["error", {"checkLoops": false}],
|
||||
"no-control-regex": "error",
|
||||
"no-debugger": "error",
|
||||
"no-delete-var": "error",
|
||||
"no-dupe-args": "error",
|
||||
"no-dupe-class-members": "error",
|
||||
"no-dupe-keys": "error",
|
||||
"no-duplicate-case": "error",
|
||||
"no-empty-character-class": "error",
|
||||
"no-empty-pattern": "error",
|
||||
"no-eval": "error",
|
||||
"no-ex-assign": "error",
|
||||
"no-extra-bind": "error",
|
||||
"no-extra-boolean-cast": "error",
|
||||
"no-extra-parens": ["error", "functions"],
|
||||
"no-fallthrough": "error",
|
||||
"no-floating-decimal": "error",
|
||||
"no-func-assign": "error",
|
||||
"no-global-assign": "error",
|
||||
"no-implied-eval": "error",
|
||||
"no-inner-declarations": ["error", "functions"],
|
||||
"no-invalid-regexp": "error",
|
||||
"no-irregular-whitespace": "error",
|
||||
"no-iterator": "error",
|
||||
"no-label-var": "error",
|
||||
"no-labels": ["error", {"allowLoop": true, "allowSwitch": false}],
|
||||
"no-lone-blocks": "error",
|
||||
"no-mixed-operators": ["error", {
|
||||
"groups": [
|
||||
["==", "!=", "===", "!==", ">", ">=", "<", "<="],
|
||||
["&&", "||"],
|
||||
["in", "instanceof"],
|
||||
],
|
||||
"allowSamePrecedence": true,
|
||||
}],
|
||||
"no-mixed-spaces-and-tabs": "error",
|
||||
"no-multi-spaces": "error",
|
||||
"no-multi-str": "error",
|
||||
"no-multiple-empty-lines": ["error", {"max": 1, "maxEOF": 0}],
|
||||
"no-negated-in-lhs": "error",
|
||||
"no-new": "error",
|
||||
"no-new-func": "error",
|
||||
"no-new-object": "error",
|
||||
"no-new-require": "error",
|
||||
"no-new-symbol": "error",
|
||||
"no-new-wrappers": "error",
|
||||
"no-obj-calls": "error",
|
||||
"no-octal": "error",
|
||||
"no-octal-escape": "error",
|
||||
"no-path-concat": "error",
|
||||
"no-proto": "error",
|
||||
"no-redeclare": "error",
|
||||
"no-regex-spaces": "error",
|
||||
"no-return-await": "error",
|
||||
"no-self-assign": "error",
|
||||
"no-self-compare": "error",
|
||||
"no-sequences": "error",
|
||||
"no-shadow-restricted-names": "error",
|
||||
"no-sparse-arrays": "error",
|
||||
"no-template-curly-in-string": "error",
|
||||
"no-this-before-super": "error",
|
||||
"no-throw-literal": "error",
|
||||
"no-trailing-spaces": "error",
|
||||
"no-undef": "off",
|
||||
"no-undef-init": "error",
|
||||
"no-unexpected-multiline": "error",
|
||||
"no-unmodified-loop-condition": "error",
|
||||
"no-unneeded-ternary": ["error", {"defaultAssignment": false}],
|
||||
"no-unreachable": "error",
|
||||
"no-unsafe-finally": "error",
|
||||
"no-unsafe-negation": "error",
|
||||
"no-unused-expressions": ["error", {
|
||||
"allowShortCircuit": true,
|
||||
"allowTernary": true,
|
||||
"allowTaggedTemplates": true,
|
||||
}],
|
||||
"no-unused-vars": "off",
|
||||
"no-use-before-define": ["error", {"functions": false, "classes": false, "variables": false}],
|
||||
"no-useless-call": "error",
|
||||
"no-useless-computed-key": "error",
|
||||
"no-useless-constructor": "error",
|
||||
"no-useless-escape": "error",
|
||||
"no-useless-rename": "error",
|
||||
"no-useless-return": "error",
|
||||
"no-whitespace-before-property": "error",
|
||||
"no-with": "error",
|
||||
"object-property-newline": ["error", {"allowMultiplePropertiesPerLine": true}],
|
||||
"one-var": ["error", {"initialized": "never"}],
|
||||
"operator-linebreak": ["error", "after", {
|
||||
"overrides": {
|
||||
"?": "before",
|
||||
":": "before",
|
||||
"+": "before",
|
||||
"-": "before",
|
||||
"*": "before",
|
||||
"/": "before",
|
||||
"||": "before",
|
||||
"&&": "before",
|
||||
},
|
||||
}],
|
||||
"padded-blocks": ["error", {"blocks": "never", "switches": "never", "classes": "never"}],
|
||||
"prefer-promise-reject-errors": "error",
|
||||
"rest-spread-spacing": ["error", "never"],
|
||||
"semi": ["warn", "always"],
|
||||
"semi-spacing": ["error", {"before": false, "after": true}],
|
||||
"space-before-blocks": ["error", "always"],
|
||||
"space-before-function-paren": ["error", "always"],
|
||||
"space-in-parens": ["error", "never"],
|
||||
"space-infix-ops": "error",
|
||||
"space-unary-ops": ["error", {"words": true, "nonwords": false}],
|
||||
"spaced-comment": ["error", "always", {
|
||||
"line": {"markers": ["*package", "!", "/", ",", "="]},
|
||||
"block": {
|
||||
"balanced": true,
|
||||
"markers": ["*package", "!", ",", ":", "::", "flow-include"],
|
||||
"exceptions": ["*"],
|
||||
},
|
||||
}],
|
||||
"symbol-description": "error",
|
||||
"template-curly-spacing": ["error", "never"],
|
||||
"template-tag-spacing": ["error", "never"],
|
||||
"unicode-bom": ["error", "never"],
|
||||
"use-isnan": "error",
|
||||
"valid-typeof": ["error", {"requireStringLiterals": true}],
|
||||
"wrap-iife": ["error", "any", {"functionPrototypeMethods": true}],
|
||||
"yield-star-spacing": ["error", "both"],
|
||||
"yoda": ["error", "never"],
|
||||
"no-prototype-builtins": "off",
|
||||
"require-atomic-updates": "off",
|
||||
"no-console": 0,
|
||||
"prefer-template": "error",
|
||||
"quotes": ["error", "double", {"allowTemplateLiterals": true}],
|
||||
"no-var": "error",
|
||||
},
|
||||
};
|
||||
1
test/.gitignore
vendored
Normal file
1
test/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
*.log
|
||||
31
test/test-adventure-book-contents.js
Normal file
31
test/test-adventure-book-contents.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import "../js/parser.js";
|
||||
import "../js/utils.js";
|
||||
import * as ut from "../node/util.js";
|
||||
|
||||
async function main () {
|
||||
console.log(`##### Validating adventure/book contents #####`);
|
||||
|
||||
const errors = [];
|
||||
|
||||
[
|
||||
{filename: "adventures.json", prop: "adventure", dir: "adventure"},
|
||||
{filename: "books.json", prop: "book", dir: "book"},
|
||||
].flatMap(({filename, prop, dir}) => ut.readJson(`./data/${filename}`)[prop]
|
||||
.map(({id, contents}) => ({filename: `./data/${dir}/${dir}-${id.toLowerCase()}.json`, contents})))
|
||||
.forEach(({filename, contents}) => {
|
||||
const json = ut.readJson(filename);
|
||||
|
||||
if (json.data.length === contents.length) return;
|
||||
|
||||
errors.push(`Contents length did not match data length in "${filename}"`);
|
||||
});
|
||||
|
||||
if (errors.length) {
|
||||
errors.forEach(err => console.error(err));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export default main();
|
||||
65
test/test-adventure-book-map-grids.js
Normal file
65
test/test-adventure-book-map-grids.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import "../js/parser.js";
|
||||
import "../js/utils.js";
|
||||
import * as ut from "../node/util.js";
|
||||
|
||||
const getClosestEntryId = stack => {
|
||||
const ent = [...stack].reverse().find(ent => ent.id);
|
||||
if (!ent) return null;
|
||||
return ent.id;
|
||||
};
|
||||
|
||||
async function main () {
|
||||
console.log(`##### Validating adventure/book map grids #####`);
|
||||
|
||||
const errors = [];
|
||||
const walker = MiscUtil.getWalker({isNoModification: true, keyBlocklist: MiscUtil.GENERIC_WALKER_ENTRIES_KEY_BLOCKLIST});
|
||||
const IMAGE_TYPES_MAP = new Set(["map", "mapPlayer"]);
|
||||
|
||||
[
|
||||
{filename: "adventures.json", prop: "adventure", dir: "adventure"},
|
||||
{filename: "books.json", prop: "book", dir: "book"},
|
||||
]
|
||||
.flatMap(({filename, prop, dir}) => ut.readJson(`./data/${filename}`)[prop]
|
||||
.map(({id}) => ({filename: `./data/${dir}/${dir}-${id.toLowerCase()}.json`})))
|
||||
.forEach(({filename}) => {
|
||||
const json = ut.readJson(filename);
|
||||
|
||||
const errorsFile = [];
|
||||
const stack = [];
|
||||
walker.walk(
|
||||
json,
|
||||
{
|
||||
object: (obj) => {
|
||||
if (obj.type !== "image" || !IMAGE_TYPES_MAP.has(obj.imageType)) return;
|
||||
|
||||
if (obj.grid !== undefined) return;
|
||||
|
||||
const closestEntryId = getClosestEntryId(stack);
|
||||
const ptsId = [
|
||||
obj.id ? `id "${obj.id}"` : "",
|
||||
obj.mapParent?.id ? `parent id "${obj.mapParent.id}"` : "",
|
||||
closestEntryId ? `closest entry id "${closestEntryId}"` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("; ");
|
||||
|
||||
errorsFile.push(`${obj.title ? `"${obj.title}"` : "[Untitled]"}${ptsId ? ` (${ptsId})` : ""}`);
|
||||
},
|
||||
},
|
||||
null,
|
||||
stack,
|
||||
);
|
||||
if (!errorsFile.length) return;
|
||||
|
||||
errors.push(`Found maps with no "grid" in "${filename}"\n${errorsFile.map(it => `\t${it}`).join("\n")}`);
|
||||
});
|
||||
|
||||
if (errors.length) {
|
||||
errors.forEach(err => console.error(err));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export default main();
|
||||
54
test/test-adventure-book-pages.js
Normal file
54
test/test-adventure-book-pages.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import "../js/parser.js";
|
||||
import "../js/utils.js";
|
||||
import * as ut from "../node/util.js";
|
||||
|
||||
async function main () {
|
||||
console.log(`##### Validating adventure/book page numbers #####`);
|
||||
|
||||
const walker = MiscUtil.getWalker({isNoModification: true});
|
||||
|
||||
const errors = [];
|
||||
|
||||
[
|
||||
{filename: "adventures.json", prop: "adventure", dir: "adventure"},
|
||||
{filename: "books.json", prop: "book", dir: "book"},
|
||||
].flatMap(({filename, prop, dir}) => ut.readJson(`./data/${filename}`)[prop]
|
||||
.map(({id}) => `./data/${dir}/${dir}-${id.toLowerCase()}.json`))
|
||||
.forEach(filename => {
|
||||
const stack = [];
|
||||
let pagePrev = -1;
|
||||
const errorsFile = [];
|
||||
|
||||
walker.walk(
|
||||
ut.readJson(filename),
|
||||
{
|
||||
object: (obj) => {
|
||||
if (obj.page == null || typeof obj.page !== "number") return obj;
|
||||
if (obj.page < pagePrev) {
|
||||
const id = obj.id || [...stack].reverse().find(it => it.id)?.id;
|
||||
const name = obj.name || [...stack].reverse().find(it => it.name)?.name;
|
||||
errorsFile.push(`Previous page ${pagePrev} > ${obj.page}${id || name ? ` (at or near ${[id ? `id "${id}"` : "", name ? `name "${name}"` : ""].filter(Boolean).join("; ")})` : ""}`);
|
||||
}
|
||||
pagePrev = obj.page;
|
||||
|
||||
return obj;
|
||||
},
|
||||
},
|
||||
null,
|
||||
stack,
|
||||
);
|
||||
|
||||
if (!errorsFile.length) return;
|
||||
|
||||
errors.push(`Page numbers were not monotonically increasing in "${filename}":\n${errorsFile.map(err => `\t${err}\n`).join("")}\n`);
|
||||
});
|
||||
|
||||
if (errors.length) {
|
||||
errors.forEach(err => console.error(err));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export default main();
|
||||
28
test/test-all.js
Normal file
28
test/test-all.js
Normal file
@@ -0,0 +1,28 @@
|
||||
"use strict";
|
||||
|
||||
function handleFail () {
|
||||
console.error("Tests failed!");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function main () {
|
||||
if (!(await (await import("./test-tags.js")).default)) handleFail();
|
||||
if (!(await (await import("./test-images.js")).default)) handleFail();
|
||||
if (!(await (await import("./test-image-paths.js")).default)) handleFail();
|
||||
await (await import("./test-pagenumbers.js")).default; // don't fail on missing page numbers
|
||||
if (!(await (await import("./test-json.js")).default)) handleFail();
|
||||
if (!(await (await import("./test-misc.js")).default)) handleFail();
|
||||
if (!(await (await import("./test-multisource.js")).default)) handleFail();
|
||||
if (!(await (await import("./test-language-fonts.js")).default)) handleFail();
|
||||
if (!(await (await import("./test-adventure-book-contents.js")).default)) handleFail();
|
||||
await (await import("./test-adventure-book-map-grids.js")).default; // don't fail on missing map grids
|
||||
if (!(await (await import("./test-foundry.js")).default)) handleFail();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main()
|
||||
.then(() => console.log("Tests complete."))
|
||||
.catch(e => {
|
||||
console.error(e);
|
||||
throw e;
|
||||
});
|
||||
165
test/test-foundry.js
Normal file
165
test/test-foundry.js
Normal file
@@ -0,0 +1,165 @@
|
||||
import * as fs from "fs";
|
||||
import * as ut from "../node/util.js";
|
||||
import "../js/parser.js";
|
||||
import "../js/utils.js";
|
||||
import "../js/render.js";
|
||||
import "../js/render-dice.js";
|
||||
|
||||
class TestFoundry {
|
||||
static async pLoadData (originalFilename, originalPath) {
|
||||
switch (originalFilename) {
|
||||
case "races.json": {
|
||||
ut.patchLoadJson();
|
||||
const out = await DataUtil.race.loadJSON({isAddBaseRaces: true});
|
||||
ut.unpatchLoadJson();
|
||||
return out;
|
||||
}
|
||||
|
||||
default: return ut.readJson(originalPath);
|
||||
}
|
||||
}
|
||||
|
||||
static testClasses ({errors}) {
|
||||
const classIndex = ut.readJson("./data/class/index.json");
|
||||
const classFiles = Object.values(classIndex)
|
||||
.map(file => ut.readJson(`./data/class/${file}`));
|
||||
|
||||
const uidsClass = new Set();
|
||||
const uidsClassFeature = new Set();
|
||||
const uidsSubclassFeature = new Set();
|
||||
|
||||
classFiles.forEach(data => {
|
||||
(data.class || []).forEach(cls => {
|
||||
const uid = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_CLASSES](cls);
|
||||
uidsClass.add(uid);
|
||||
});
|
||||
|
||||
(data.classFeature || []).forEach(cf => {
|
||||
const uid = UrlUtil.URL_TO_HASH_BUILDER["classFeature"](cf);
|
||||
uidsClassFeature.add(uid);
|
||||
});
|
||||
|
||||
(data.subclassFeature || []).forEach(scf => {
|
||||
const uid = UrlUtil.URL_TO_HASH_BUILDER["subclassFeature"](scf);
|
||||
uidsSubclassFeature.add(uid);
|
||||
});
|
||||
});
|
||||
|
||||
const foundryData = ut.readJson("./data/class/foundry.json");
|
||||
(foundryData.class || []).forEach(cls => {
|
||||
const uid = UrlUtil.URL_TO_HASH_BUILDER[UrlUtil.PG_CLASSES](cls);
|
||||
if (!uidsClass.has(uid)) errors.push(`\tClass "${uid}" not found!`);
|
||||
});
|
||||
(foundryData.classFeature || []).forEach(fcf => {
|
||||
const uid = UrlUtil.URL_TO_HASH_BUILDER["classFeature"](fcf);
|
||||
if (!uidsClassFeature.has(uid)) errors.push(`\tClass feature "${uid}" not found!`);
|
||||
});
|
||||
(foundryData.subclassFeature || []).forEach(fscf => {
|
||||
const uid = UrlUtil.URL_TO_HASH_BUILDER["subclassFeature"](fscf);
|
||||
if (!uidsSubclassFeature.has(uid)) errors.push(`\tSubclass feature "${uid}" not found!`);
|
||||
});
|
||||
}
|
||||
|
||||
static async pTestDir ({errors, dir}) {
|
||||
const FOUNDRY_FILE = "foundry.json";
|
||||
|
||||
const dirList = fs.readdirSync(`./data/${dir}`)
|
||||
.filter(it => !it.startsWith("fluff-") && it !== "sources.json" && it !== "index.json");
|
||||
|
||||
if (!dirList.includes(FOUNDRY_FILE)) throw new Error(`No "${FOUNDRY_FILE}" file found in dir "${dir}"!`);
|
||||
|
||||
const foundryPath = `./data/${dir}/${FOUNDRY_FILE}`;
|
||||
const foundryData = ut.readJson(foundryPath);
|
||||
const originalDatas = await dirList
|
||||
.filter(it => it !== FOUNDRY_FILE)
|
||||
.pSerialAwaitMap(it => this.pLoadData(it, `./data/${dir}/${it}`));
|
||||
|
||||
await this.pTestFile({errors, foundryData, foundryPath, originalDatas});
|
||||
}
|
||||
|
||||
static async pTestRoot ({errors}) {
|
||||
const dirList = fs.readdirSync(`./data/`);
|
||||
const foundryFiles = dirList.filter(it => it.startsWith("foundry-") && it.endsWith(".json"));
|
||||
|
||||
for (const foundryFile of foundryFiles) {
|
||||
const foundryPath = `./data/${foundryFile}`;
|
||||
const foundryData = ut.readJson(foundryPath);
|
||||
const originalFile = foundryFile.replace(/^foundry-/i, "");
|
||||
const originalData = await this.pLoadData(originalFile, `./data/${originalFile}`);
|
||||
|
||||
await this.pTestFile({errors, foundryData, foundryPath, originalDatas: [originalData]});
|
||||
}
|
||||
}
|
||||
|
||||
static testSpecialRaceFeatures ({foundryData, originalDatas, errors}) {
|
||||
const uidsRaceFeature = new Set();
|
||||
|
||||
const HASH_BUILDER = it => UrlUtil.encodeForHash([it.name, it.source, it.raceName, it.raceSource]);
|
||||
|
||||
originalDatas.forEach(originalData => {
|
||||
originalData.race.forEach(race => {
|
||||
DataUtil.generic.getVersions(race).forEach(ver => this._testSpecialRaceFeatures_addRace({HASH_BUILDER, uidsRaceFeature, race: ver}));
|
||||
this._testSpecialRaceFeatures_addRace({HASH_BUILDER, uidsRaceFeature, race});
|
||||
});
|
||||
});
|
||||
|
||||
foundryData.raceFeature.forEach(raceFeature => {
|
||||
const uid = HASH_BUILDER(raceFeature);
|
||||
if (!uidsRaceFeature.has(uid)) errors.push(`\tRace feature "${uid}" not found!`);
|
||||
});
|
||||
}
|
||||
|
||||
static _testSpecialRaceFeatures_addRace ({HASH_BUILDER, uidsRaceFeature, race}) {
|
||||
(race.entries || []).forEach(ent => {
|
||||
const uid = HASH_BUILDER({source: race.source, ...ent, raceName: race.name, raceSource: race.source});
|
||||
uidsRaceFeature.add(uid);
|
||||
});
|
||||
}
|
||||
|
||||
static async pTestSpecialMagicItemVariants ({foundryData, originalDatas, errors}) {
|
||||
const variants = await this.pLoadData("magicvariants.json", `./data/magicvariants.json`);
|
||||
const prop = "magicvariant";
|
||||
this.doCompareData({prop, foundryData, originalDatas: [variants], errors});
|
||||
}
|
||||
|
||||
static doCompareData ({prop, foundryData, originalDatas, errors}) {
|
||||
foundryData[prop].forEach(it => {
|
||||
const match = originalDatas.first(variants => variants[prop].find(og => og.name === it.name && (og?.inherits?.source ?? og.source) === it.source));
|
||||
if (!match) errors.push(`\t"${prop}" ${it.name} (${it.source}) not found!`);
|
||||
});
|
||||
}
|
||||
|
||||
static async pTestFile ({foundryPath, foundryData, originalDatas, errors}) {
|
||||
Object.entries(foundryData)
|
||||
.forEach(([prop, arr]) => {
|
||||
if (SPECIAL_PROPS[prop]) return SPECIAL_PROPS[prop]({foundryPath, foundryData, originalDatas, errors});
|
||||
|
||||
if (!(arr instanceof Array)) return;
|
||||
if (originalDatas.every(originalData => !originalData[prop] || !(originalData[prop] instanceof Array))) return console.warn(`\tUntested prop "${prop}" in file ${foundryPath}`);
|
||||
|
||||
this.doCompareData({prop, foundryData, originalDatas, errors});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const SPECIAL_PROPS = {
|
||||
"raceFeature": TestFoundry.testSpecialRaceFeatures.bind(TestFoundry),
|
||||
"magicvariant": TestFoundry.pTestSpecialMagicItemVariants.bind(TestFoundry),
|
||||
};
|
||||
|
||||
async function main () {
|
||||
const errors = [];
|
||||
|
||||
TestFoundry.testClasses({errors});
|
||||
await TestFoundry.pTestDir({dir: "spells", errors});
|
||||
await TestFoundry.pTestRoot({errors});
|
||||
|
||||
if (!errors.length) console.log("##### Foundry Tests Passed #####");
|
||||
else {
|
||||
console.error("Foundry data errors:");
|
||||
errors.forEach(err => console.error(err));
|
||||
}
|
||||
return !errors.length;
|
||||
}
|
||||
|
||||
export default main();
|
||||
82
test/test-image-paths.js
Normal file
82
test/test-image-paths.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import "../js/parser.js";
|
||||
import "../js/utils.js";
|
||||
import "../js/render.js";
|
||||
import * as ut from "../node/util.js";
|
||||
import * as fs from "fs";
|
||||
|
||||
const _IS_FIX = false;
|
||||
|
||||
async function main () {
|
||||
console.log(`##### Validating image paths #####`);
|
||||
|
||||
const walker = MiscUtil.getWalker({isNoModification: true});
|
||||
|
||||
const errors = [];
|
||||
|
||||
const doTestFile = (filePath, imgDirs) => {
|
||||
const errorsFile = [];
|
||||
|
||||
const data = ut.readJson(filePath);
|
||||
|
||||
walker.walk(
|
||||
data,
|
||||
{
|
||||
object: (obj) => {
|
||||
if (obj.type !== "image" || obj.href?.type !== "internal") return;
|
||||
const dir = obj.href.path.split("/")[0];
|
||||
if (imgDirs.includes(dir)) return;
|
||||
|
||||
if (!_IS_FIX) {
|
||||
errorsFile.push(`Path ${obj.href.path} had incorrect image path beginning "${dir}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pathOut = `${imgDirs[0]}/${obj.href.path.slice(dir.length + 1)}`;
|
||||
const pathInFull = `img/${obj.href.path}`;
|
||||
const pathOutFull = `img/${pathOut}`;
|
||||
|
||||
fs.mkdirSync(pathOutFull.split("/").slice(0, -1).join("/"), {recursive: true});
|
||||
fs.copyFileSync(pathInFull, pathOutFull);
|
||||
console.log(`Copied file: "${pathInFull}" -> "${pathOutFull}"`);
|
||||
|
||||
obj.href.path = pathOut;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!errorsFile.length) return data;
|
||||
|
||||
errors.push(`Cross-page image paths found in "${filePath}":\n${errorsFile.map(err => `\t${err}\n`).join("")}\n`);
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
[
|
||||
{index: "bestiary/fluff-index.json", imgDirs: ["bestiary"]},
|
||||
{index: "spells/fluff-index.json", imgDirs: ["spells"]},
|
||||
]
|
||||
.forEach(meta => {
|
||||
if (meta.index) {
|
||||
const dir = meta.index.split("/")[0];
|
||||
Object.values(ut.readJson(`data/${meta.index}`))
|
||||
.forEach(filename => {
|
||||
const dataPath = `data/${dir}/${filename}`;
|
||||
const data = doTestFile(dataPath, meta.imgDirs);
|
||||
if (_IS_FIX) fs.writeFileSync(dataPath, CleanUtil.getCleanJson(data, {isFast: false}), "utf-8");
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error("Unimplemented!");
|
||||
});
|
||||
|
||||
if (errors.length) {
|
||||
errors.forEach(err => console.error(err));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export default main();
|
||||
169
test/test-images.js
Normal file
169
test/test-images.js
Normal file
@@ -0,0 +1,169 @@
|
||||
import * as fs from "fs";
|
||||
import "../js/parser.js";
|
||||
import "../js/utils.js";
|
||||
import * as ut from "../node/util.js";
|
||||
|
||||
class _TestTokenImages {
|
||||
static _IS_CLEAN_MM_EXTRAS = false;
|
||||
|
||||
static _PATH_BASE = `./img/bestiary/tokens`;
|
||||
static _EXT = "webp";
|
||||
|
||||
static _IGNORED_PREFIXES = [
|
||||
".",
|
||||
"_",
|
||||
];
|
||||
|
||||
static _expected = new Set();
|
||||
static _expectedDirs = {};
|
||||
static _existing = new Set();
|
||||
static _expectedFromHashToken = {};
|
||||
|
||||
static _mmTokens = null;
|
||||
|
||||
static _isMmToken (filename) {
|
||||
if (!this._mmTokens) this._mmTokens = fs.readdirSync(`${this._PATH_BASE}/${Parser.sourceJsonToAbv(Parser.SRC_MM)}`).mergeMap(it => ({[it]: true}));
|
||||
return !!this._mmTokens[filename.split("/").last()];
|
||||
}
|
||||
|
||||
static _readBestiaryJson () {
|
||||
fs.readdirSync("./data/bestiary")
|
||||
.filter(file => file.startsWith("bestiary") && file.endsWith(".json"))
|
||||
.forEach(file => {
|
||||
ut.readJson(`./data/bestiary/${file}`).monster
|
||||
.forEach(m => {
|
||||
const source = Parser.sourceJsonToAbv(m.source);
|
||||
const implicitTokenPath = `${this._PATH_BASE}/${source}/${Parser.nameToTokenName(m.name)}.${this._EXT}`;
|
||||
|
||||
if (m.hasToken) this._expectedFromHashToken[implicitTokenPath] = true;
|
||||
|
||||
if (!fs.existsSync(`${this._PATH_BASE}/${source}`)) {
|
||||
this._expectedDirs[source] = true;
|
||||
return;
|
||||
}
|
||||
|
||||
this._expected.add(implicitTokenPath);
|
||||
|
||||
// add tokens specified as part of variants
|
||||
if (m.variant) {
|
||||
m.variant
|
||||
.filter(it => it.token)
|
||||
.forEach(entry => this._expected.add(`${this._PATH_BASE}/${Parser.sourceJsonToAbv(entry.token.source)}/${Parser.nameToTokenName(entry.token.name)}.${this._EXT}`));
|
||||
}
|
||||
|
||||
// add tokens specified as alt art
|
||||
if (m.altArt) {
|
||||
m.altArt
|
||||
.forEach(alt => this._expected.add(`${this._PATH_BASE}/${Parser.sourceJsonToAbv(alt.source)}/${Parser.nameToTokenName(alt.name)}.${this._EXT}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static _readImageDirs () {
|
||||
fs.readdirSync(this._PATH_BASE)
|
||||
.filter(file => !(this._IGNORED_PREFIXES.some(it => file.startsWith(it))))
|
||||
.forEach(dir => {
|
||||
fs.readdirSync(`${this._PATH_BASE}/${dir}`)
|
||||
.forEach(file => {
|
||||
this._existing.add(`${this._PATH_BASE}/${dir.replace("(", "").replace(")", "")}/${file}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static _getIsError () {
|
||||
let isError = false;
|
||||
const results = [];
|
||||
this._expected.forEach((img) => {
|
||||
if (!this._existing.has(img)) results.push(`[ MISSING] ${img}`);
|
||||
});
|
||||
this._existing.forEach((img) => {
|
||||
delete this._expectedFromHashToken[img];
|
||||
if (!this._expected.has(img)) {
|
||||
if (this._IS_CLEAN_MM_EXTRAS && this._isMmToken(img)) {
|
||||
fs.unlinkSync(img);
|
||||
results.push(`[ !DELETE] ${img}`);
|
||||
return;
|
||||
}
|
||||
results.push(`[ EXTRA] ${img}`);
|
||||
isError = true;
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(this._expectedDirs).forEach(k => results.push(`Directory ${k} doesn't exist!`));
|
||||
results
|
||||
.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))
|
||||
.forEach((img) => console.warn(img));
|
||||
|
||||
if (Object.keys(this._expectedFromHashToken).length) console.warn(`Declared in Bestiary data but not found:`);
|
||||
Object.keys(this._expectedFromHashToken).forEach(img => console.warn(`[MISMATCH] ${img}`));
|
||||
|
||||
if (!this._expected.size && !Object.keys(this._expectedFromHashToken).length) console.log("Tokens are as expected.");
|
||||
|
||||
return isError;
|
||||
}
|
||||
|
||||
static run () {
|
||||
console.log(`##### Reconciling the PNG tokens against the bestiary JSON #####`);
|
||||
|
||||
this._readBestiaryJson();
|
||||
this._readImageDirs();
|
||||
|
||||
return this._getIsError();
|
||||
}
|
||||
}
|
||||
|
||||
class _TestAdventureBookImages {
|
||||
static run () {
|
||||
const pathsMissing = [];
|
||||
|
||||
const walker = MiscUtil.getWalker({isNoModification: true});
|
||||
|
||||
const getHandler = (filename, out) => {
|
||||
const checkHref = (href) => {
|
||||
if (href?.type !== "internal") return;
|
||||
if (fs.existsSync(`./img/${href.path}`)) return;
|
||||
out.push(`${filename} :: ${href.path}`);
|
||||
};
|
||||
|
||||
return (obj) => {
|
||||
if (obj.type !== "image") return;
|
||||
checkHref(obj.href);
|
||||
checkHref(obj.hrefThumbnail);
|
||||
};
|
||||
};
|
||||
|
||||
[
|
||||
{filename: "adventures.json", prop: "adventure", dir: "adventure"},
|
||||
{filename: "books.json", prop: "book", dir: "book"},
|
||||
].flatMap(({filename, prop, dir}) => ut.readJson(`./data/${filename}`)[prop]
|
||||
.map(({id}) => `./data/${dir}/${dir}-${id.toLowerCase()}.json`))
|
||||
.forEach(filename => {
|
||||
walker.walk(
|
||||
ut.readJson(filename),
|
||||
{
|
||||
object: getHandler(filename, pathsMissing),
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (pathsMissing.length) {
|
||||
console.log(`Adventure/Book Errors:\n${pathsMissing.map(it => `\t${it}`).join("\n")}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(`##### Adventure/Book Image Tests Passed #####`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function main () {
|
||||
if (!fs.existsSync("./img")) return true;
|
||||
|
||||
if (_TestTokenImages.run()) return false;
|
||||
if (_TestAdventureBookImages.run()) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export default main();
|
||||
57
test/test-json.js
Normal file
57
test/test-json.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as fs from "fs";
|
||||
|
||||
import {Um, Uf, JsonTester} from "5etools-utils";
|
||||
|
||||
const LOG_TAG = "JSON";
|
||||
const _IS_FAIL_SLOW = !!process.env.FAIL_SLOW;
|
||||
|
||||
const _GENERATED_ALLOWLIST = new Set([
|
||||
"bookref-quick.json",
|
||||
"gendata-spell-source-lookup.json",
|
||||
]);
|
||||
|
||||
async function main () {
|
||||
const jsonTester = new JsonTester({
|
||||
tagLog: LOG_TAG,
|
||||
fnGetSchemaId: (filePath) => {
|
||||
const relativeFilePath = filePath.replace("data/", "");
|
||||
|
||||
if (relativeFilePath.startsWith("adventure/")) return "adventure/adventure.json";
|
||||
if (relativeFilePath.startsWith("book/")) return "book/book.json";
|
||||
|
||||
if (relativeFilePath.startsWith("bestiary/bestiary-")) return "bestiary/bestiary.json";
|
||||
if (relativeFilePath.startsWith("bestiary/fluff-bestiary-")) return "bestiary/fluff-bestiary.json";
|
||||
|
||||
if (relativeFilePath.startsWith("class/class-")) return "class/class.json";
|
||||
|
||||
if (relativeFilePath.startsWith("spells/spells-")) return "spells/spells.json";
|
||||
if (relativeFilePath.startsWith("spells/fluff-spells-")) return "spells/fluff-spells.json";
|
||||
|
||||
return relativeFilePath;
|
||||
},
|
||||
});
|
||||
|
||||
const fileList = Uf.listJsonFiles("data")
|
||||
.filter(filePath => {
|
||||
if (filePath.includes("data/generated")) return _GENERATED_ALLOWLIST.has(filePath.split("/").at(-1));
|
||||
return true;
|
||||
});
|
||||
|
||||
const results = await jsonTester.pGetErrorsOnDirsWorkers({
|
||||
isFailFast: !_IS_FAIL_SLOW,
|
||||
fileList,
|
||||
});
|
||||
|
||||
const {errors, errorsFull} = results;
|
||||
|
||||
if (errors.length) {
|
||||
if (!process.env.CI) fs.writeFileSync(`test/test-json.error.log`, errorsFull.join("\n\n=====\n\n"));
|
||||
console.error(`Schema test failed (${errors.length} failure${errors.length === 1 ? "" : "s"}).`);
|
||||
return false;
|
||||
}
|
||||
|
||||
Um.info(LOG_TAG, `All schema tests passed.`);
|
||||
return true;
|
||||
}
|
||||
|
||||
export default main();
|
||||
26
test/test-language-fonts.js
Normal file
26
test/test-language-fonts.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import * as fs from "fs";
|
||||
import * as ut from "../node/util.js";
|
||||
|
||||
async function main () {
|
||||
console.log(`##### Validating language fonts #####`);
|
||||
|
||||
const json = ut.readJson("./data/languages.json");
|
||||
const errors = [];
|
||||
|
||||
json.languageScript.forEach(lang => {
|
||||
if (!lang.fonts?.length) return;
|
||||
|
||||
lang.fonts
|
||||
.filter(path => !fs.existsSync(path))
|
||||
.forEach(path => errors.push(`languageScript "${lang.name}" font path "${path}" does not exist!`));
|
||||
});
|
||||
|
||||
if (errors.length) {
|
||||
errors.forEach(err => console.error(`\t${err}`));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export default main();
|
||||
27
test/test-misc.js
Normal file
27
test/test-misc.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import "../js/parser.js";
|
||||
import "../js/utils.js";
|
||||
|
||||
function testCatIds () {
|
||||
const errors = [];
|
||||
Object.keys(Parser.CAT_ID_TO_FULL).forEach(catId => {
|
||||
if (Parser.CAT_ID_TO_PROP[catId] === undefined) errors.push(`Missing property for ID: ${catId} (${Parser.CAT_ID_TO_FULL[catId]})`);
|
||||
if (UrlUtil.CAT_TO_PAGE[catId] === undefined) errors.push(`Missing page for ID: ${catId} (${Parser.CAT_ID_TO_FULL[catId]})`);
|
||||
});
|
||||
return errors;
|
||||
}
|
||||
|
||||
async function main () {
|
||||
let anyErrors = false;
|
||||
|
||||
const errorsCatIds = testCatIds();
|
||||
if (errorsCatIds.length) {
|
||||
anyErrors = true;
|
||||
console.error(`Category ID errors:`);
|
||||
errorsCatIds.forEach(it => console.error(`\t${it}`));
|
||||
}
|
||||
|
||||
if (!anyErrors) console.log("##### Misc Tests Passed #####");
|
||||
return !anyErrors; // invert the result as this is what the test runner expects
|
||||
}
|
||||
|
||||
export default main();
|
||||
49
test/test-multisource.js
Normal file
49
test/test-multisource.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import * as ut from "../node/util.js";
|
||||
import "../js/parser.js";
|
||||
import "../js/utils.js";
|
||||
|
||||
const _MULTISOURCE_DIRS = [
|
||||
"bestiary",
|
||||
"spells",
|
||||
];
|
||||
|
||||
function main () {
|
||||
console.log(`##### Testing multisource sources... #####`);
|
||||
|
||||
const sourceIncorrect = [];
|
||||
|
||||
_MULTISOURCE_DIRS.forEach(dir => {
|
||||
const indexPath = `./data/${dir}/index.json`;
|
||||
const indexPathFluff = `./data/${dir}/fluff-index.json`;
|
||||
|
||||
const indexes = [
|
||||
ut.readJson(indexPath),
|
||||
ut.readJson(indexPathFluff),
|
||||
];
|
||||
|
||||
indexes.forEach(index => {
|
||||
Object.entries(index)
|
||||
.forEach(([source, filename]) => {
|
||||
const json = ut.readJson(`./data/${dir}/${filename}`);
|
||||
Object.values(json)
|
||||
.forEach(arr => {
|
||||
if (!arr || !(arr instanceof Array)) return;
|
||||
|
||||
arr.forEach(ent => {
|
||||
if (!ent.source || source === ent.source) return;
|
||||
sourceIncorrect.push(`${filename} :: ${ent.name} :: ${ent.source} -> ${source}`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if (!sourceIncorrect.length) {
|
||||
console.log(`##### Multisource source test passed! #####`);
|
||||
return true;
|
||||
}
|
||||
|
||||
console.error(`##### Multisource source test failed! #####\n${sourceIncorrect.map(it => `\t${it}`).join("\n")}`);
|
||||
}
|
||||
|
||||
export default main();
|
||||
146
test/test-pagenumbers.js
Normal file
146
test/test-pagenumbers.js
Normal file
@@ -0,0 +1,146 @@
|
||||
import * as ut from "../node/util.js";
|
||||
import * as rl from "readline-sync";
|
||||
import * as fs from "fs";
|
||||
import "../js/parser.js";
|
||||
import "../js/utils.js";
|
||||
|
||||
const BLOCKLIST_FILE_PREFIXES = [
|
||||
...ut.FILE_PREFIX_BLOCKLIST,
|
||||
"fluff-",
|
||||
|
||||
// specific files
|
||||
"makebrew-creature.json",
|
||||
"makecards.json",
|
||||
"foundry.json",
|
||||
"characters.json",
|
||||
];
|
||||
|
||||
const BLOCKLIST_KEYS = new Set([
|
||||
"_meta",
|
||||
"data",
|
||||
"itemProperty",
|
||||
"itemEntry",
|
||||
"lifeClass",
|
||||
"lifeBackground",
|
||||
"lifeTrinket",
|
||||
"cr",
|
||||
"monsterfeatures",
|
||||
"adventure",
|
||||
"book",
|
||||
"itemTypeAdditionalEntries",
|
||||
"legendaryGroup",
|
||||
"languageScript",
|
||||
"dragonMundaneItems",
|
||||
]);
|
||||
|
||||
const BLOCKLIST_SOURCES = new Set([
|
||||
// region Sources which only exist in digital form
|
||||
Parser.SRC_DC,
|
||||
Parser.SRC_SLW,
|
||||
Parser.SRC_SDW,
|
||||
Parser.SRC_VD,
|
||||
Parser.SRC_HAT_TG,
|
||||
Parser.SRC_HAT_LMI,
|
||||
Parser.SRC_LK,
|
||||
Parser.SRC_AATM,
|
||||
Parser.SRC_HFStCM,
|
||||
|
||||
// N.b.: other MCV source creatures mysteriously have page numbers on Beyond
|
||||
Parser.SRC_MCV4EC,
|
||||
// endregion
|
||||
|
||||
// region Sources which are screens, and therefore "pageless"
|
||||
Parser.SRC_SCREEN,
|
||||
Parser.SRC_SCREEN_WILDERNESS_KIT,
|
||||
Parser.SRC_SCREEN_DUNGEON_KIT,
|
||||
Parser.SRC_SCREEN_SPELLJAMMER,
|
||||
// endregion
|
||||
]);
|
||||
|
||||
const SUB_KEYS = {};
|
||||
|
||||
function run ({isModificationMode = false} = {}) {
|
||||
console.log(`##### Checking for Missing Page Numbers #####`);
|
||||
const FILE_MAP = {};
|
||||
const files = ut.listFiles({dir: `./data`, blocklistFilePrefixes: BLOCKLIST_FILE_PREFIXES});
|
||||
files
|
||||
.forEach(file => {
|
||||
let mods = 0;
|
||||
|
||||
const json = ut.readJson(file);
|
||||
Object.keys(json)
|
||||
.filter(k => !BLOCKLIST_KEYS.has(k))
|
||||
.forEach(k => {
|
||||
const data = json[k];
|
||||
if (data instanceof Array) {
|
||||
const noPage = data
|
||||
.filter(it => !BLOCKLIST_SOURCES.has(SourceUtil.getEntitySource(it)))
|
||||
.filter(it => !(it.inherits ? it.inherits.page : it.page))
|
||||
.filter(it => !it._copy?._preserve?.page);
|
||||
|
||||
const subKeys = SUB_KEYS[k];
|
||||
if (subKeys) {
|
||||
subKeys.forEach(sk => {
|
||||
data
|
||||
.filter(it => it[sk] && it[sk] instanceof Array)
|
||||
.forEach(it => {
|
||||
const subArr = it[sk];
|
||||
subArr
|
||||
.forEach(subIt => subIt.source = subIt.source || it.source);
|
||||
noPage.push(...subArr
|
||||
// Skip un-named entries, as these are usually found on the page of their parent
|
||||
.filter(subIt => subIt.name)
|
||||
.filter(subIt => !BLOCKLIST_SOURCES.has(subIt.source))
|
||||
.filter(subIt => !subIt.page));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (noPage.length && isModificationMode) {
|
||||
console.log(`${file}:`);
|
||||
console.log(`\t${noPage.length} missing page number${noPage.length === 1 ? "" : "s"}`);
|
||||
}
|
||||
|
||||
noPage
|
||||
.forEach(it => {
|
||||
const ident = `${k.padEnd(20, " ")} ${SourceUtil.getEntitySource(it).padEnd(32, " ")} ${it.name}`;
|
||||
if (isModificationMode) {
|
||||
console.log(` ${ident}`);
|
||||
const page = rl.questionInt(" - Page = ");
|
||||
if (page) {
|
||||
it.page = page;
|
||||
mods++;
|
||||
}
|
||||
} else {
|
||||
const list = (FILE_MAP[file] = FILE_MAP[file] || []);
|
||||
list.push(ident);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (mods > 0) {
|
||||
let answer = "";
|
||||
while (!["y", "n", "quit"].includes(answer)) {
|
||||
answer = rl.question(`Save file with ${mods} modification${mods === 1 ? "" : "s"}? [y/n/quit]`);
|
||||
if (answer === "y") {
|
||||
console.log(`Saving ${file}...`);
|
||||
fs.writeFileSync(file, CleanUtil.getCleanJson(json), "utf-8");
|
||||
} else if (answer === "quit") {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const filesWithMissingPages = Object.keys(FILE_MAP);
|
||||
if (filesWithMissingPages.length) {
|
||||
console.warn(`##### Files with Missing Page Numbers #####`);
|
||||
filesWithMissingPages.forEach(f => {
|
||||
console.warn(`${f}:`);
|
||||
FILE_MAP[f].forEach(it => console.warn(`\t${it}`));
|
||||
});
|
||||
} else console.log(`Page numbers are as expected.`);
|
||||
}
|
||||
|
||||
run();
|
||||
1339
test/test-tags.js
Normal file
1339
test/test-tags.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user