validators.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441
  1. import t from 'tap';
  2. import {showAggregate} from '#aggregate';
  3. import {
  4. // Basic types
  5. isBoolean,
  6. isCountingNumber,
  7. isDate,
  8. isNumber,
  9. isString,
  10. isStringNonEmpty,
  11. // Complex types
  12. isArray,
  13. isObject,
  14. validateArrayItems,
  15. // Wiki data
  16. isColor,
  17. isCommentary,
  18. isContentString,
  19. isContribution,
  20. isContributionList,
  21. isDimensions,
  22. isDirectory,
  23. isDuration,
  24. isFileExtension,
  25. isName,
  26. isURL,
  27. validateReference,
  28. validateReferenceList,
  29. // Compositional utilities
  30. anyOf,
  31. } from '#validators';
  32. function test(t, msg, fn) {
  33. t.test(msg, t => {
  34. try {
  35. fn(t);
  36. } catch (error) {
  37. if (error instanceof AggregateError) {
  38. showAggregate(error);
  39. }
  40. throw error;
  41. }
  42. });
  43. }
  44. // Basic types
  45. test(t, 'isBoolean', t => {
  46. t.plan(4);
  47. t.ok(isBoolean(true));
  48. t.ok(isBoolean(false));
  49. t.throws(() => isBoolean(1), TypeError);
  50. t.throws(() => isBoolean('yes'), TypeError);
  51. });
  52. test(t, 'isNumber', t => {
  53. t.plan(6);
  54. t.ok(isNumber(123));
  55. t.ok(isNumber(0.05));
  56. t.ok(isNumber(0));
  57. t.ok(isNumber(-10));
  58. t.throws(() => isNumber('413'), TypeError);
  59. t.throws(() => isNumber(true), TypeError);
  60. });
  61. test(t, 'isCountingNumber', t => {
  62. t.plan(6);
  63. t.ok(isCountingNumber(3));
  64. t.ok(isCountingNumber(1));
  65. t.throws(() => isCountingNumber(1.75), TypeError);
  66. t.throws(() => isCountingNumber(0), TypeError);
  67. t.throws(() => isCountingNumber(-1), TypeError);
  68. t.throws(() => isCountingNumber('612'), TypeError);
  69. });
  70. test(t, 'isString', t => {
  71. t.plan(3);
  72. t.ok(isString('hello!'));
  73. t.ok(isString(''));
  74. t.throws(() => isString(100), TypeError);
  75. });
  76. test(t, 'isStringNonEmpty', t => {
  77. t.plan(4);
  78. t.ok(isStringNonEmpty('hello!'));
  79. t.throws(() => isStringNonEmpty(''), TypeError);
  80. t.throws(() => isStringNonEmpty(' '), TypeError);
  81. t.throws(() => isStringNonEmpty(100), TypeError);
  82. });
  83. // Complex types
  84. test(t, 'isArray', t => {
  85. t.plan(3);
  86. t.ok(isArray([]));
  87. t.throws(() => isArray({}), TypeError);
  88. t.throws(() => isArray('1, 2, 3'), TypeError);
  89. });
  90. test(t, 'isDate', t => {
  91. t.plan(3);
  92. t.ok(isDate(new Date('2023-03-27 09:24:15')));
  93. t.throws(() => isDate(new Date(Infinity)), TypeError);
  94. t.throws(() => isDimensions('2023-03-27 09:24:15'), TypeError);
  95. });
  96. test(t, 'isObject', t => {
  97. t.plan(3);
  98. t.ok(isObject({}));
  99. t.ok(isObject([]));
  100. t.throws(() => isObject(null), TypeError);
  101. });
  102. test(t, 'validateArrayItems', t => {
  103. t.plan(9);
  104. t.ok(validateArrayItems(isNumber)([3, 4, 5]));
  105. t.ok(validateArrayItems(validateArrayItems(isNumber))([[3, 4], [4, 5], [6, 7]]));
  106. let caughtError = null;
  107. try {
  108. validateArrayItems(isNumber)([10, 20, 'one hundred million consorts', 30]);
  109. } catch (err) {
  110. caughtError = err;
  111. }
  112. t.not(caughtError, null);
  113. t.ok(caughtError instanceof AggregateError);
  114. t.equal(caughtError.errors.length, 1);
  115. t.ok(caughtError.errors[0] instanceof Error);
  116. t.equal(caughtError.errors[0][Symbol.for('hsmusic.annotateError.indexInSourceArray')], 2);
  117. t.not(caughtError.errors[0].cause, null);
  118. t.ok(caughtError.errors[0].cause instanceof TypeError);
  119. });
  120. // Wiki data
  121. t.test('isColor', t => {
  122. t.plan(9);
  123. t.ok(isColor('#123'));
  124. t.ok(isColor('#1234'));
  125. t.ok(isColor('#112233'));
  126. t.ok(isColor('#11223344'));
  127. t.ok(isColor('#abcdef00'));
  128. t.ok(isColor('#ABCDEF'));
  129. t.throws(() => isColor('#ggg'), TypeError);
  130. t.throws(() => isColor('red'), TypeError);
  131. t.throws(() => isColor('hsl(150deg 30% 60%)'), TypeError);
  132. });
  133. t.test('isCommentary', t => {
  134. t.plan(9);
  135. // TODO: Test specific error messages.
  136. t.ok(isCommentary(`<i>Toby Fox:</i>\ndogsong.mp3`));
  137. t.ok(isCommentary(`<i>Toby Fox:</i> (music)\ndogsong.mp3`));
  138. t.throws(() => isCommentary(`dogsong.mp3\n<i>Toby Fox:</i>\ndogsong.mp3`));
  139. t.throws(() => isCommentary(`<i>Toby Fox:</i> dogsong.mp3`));
  140. t.throws(() => isCommentary(`<i>Toby Fox:</i> (music) dogsong.mp3`));
  141. t.throws(() => isCommentary(`<i>I Have Nothing To Say:</i>`));
  142. t.throws(() => isCommentary(123));
  143. t.throws(() => isCommentary(``));
  144. t.throws(() => isCommentary(`Technically, ah, er:</i>\nCorrect`));
  145. });
  146. t.test('isContentString', t => {
  147. t.plan(12);
  148. t.ok(isContentString(`Hello, world!`));
  149. t.ok(isContentString(`Hello...\nWorld!`));
  150. const quickThrows = (string, description) =>
  151. t.throws(() => isContentString(string), description);
  152. quickThrows(
  153. `Snooping\xa0as usual, I\xa0\xa0\xa0SEE.`,
  154. Object.assign(
  155. new AggregateError([
  156. new AggregateError([
  157. new TypeError(`Replace "\xa0" (non-breaking space) with " " (normal space) between "ing" and "as " (pos: 9)`),
  158. new TypeError(`Replace "\xa0\xa0\xa0" (non-breaking space) with " " (normal space) between ", I" and "SEE" (pos: 21)`),
  159. ], `Illegal characters found in content string`),
  160. ], `Errors validating content string`),
  161. {[Symbol.for(`hsmusic.aggregate.translucent`)]: 'single'}));
  162. quickThrows(
  163. `Oh\u200bdear,\n` +
  164. `Oh dear,\n` +
  165. `oh-dear-oh-dear-oh\u200bdear.`,
  166. new AggregateError([
  167. new AggregateError([
  168. new TypeError(`Delete "\u200b" (zero-width space) between "Oh" and "dea" (line: 1, col: 3)`),
  169. new TypeError(`Delete "\u200b" (zero-width space) between "-oh" and "dea" (line: 3, col: 19)`),
  170. ]),
  171. ]));
  172. quickThrows(
  173. `Well the days start comin'\xa0\xa0\xa0\xa0\u200b\u200b\xa0\xa0\xa0\u200b\u200b\u200band they don't stop comin'`,
  174. new AggregateError([
  175. new AggregateError([
  176. new TypeError(`Replace "\xa0\xa0\xa0\xa0" (non-breaking space) with " " (normal space) after "in'" (pos: 27)`),
  177. new TypeError(`Delete "\u200b\u200b" (zero-width space) (pos: 31)`),
  178. new TypeError(`Replace "\xa0\xa0\xa0" (non-breaking space) with " " (normal space) (pos: 33)`),
  179. new TypeError(`Delete "\u200b\u200b\u200b" (zero-width space) before "and" (pos: 36)`),
  180. ]),
  181. ]));
  182. quickThrows(
  183. `It's go-\u200bin',\n` +
  184. `\u200bIt's goin',\u200b\n` +
  185. `\u200b\u200bIt's going!`,
  186. new AggregateError([
  187. new AggregateError([
  188. new TypeError(`Delete "\u200b" (zero-width space) between "go-" and "in'" (line: 1, col: 9)`),
  189. new TypeError(`Delete "\u200b" (zero-width space) before "It'" (line: 2, col: 1)`),
  190. new TypeError(`Delete "\u200b" (zero-width space) after "n'," (line: 2, col: 13)`),
  191. new TypeError(`Delete "\u200b\u200b" (zero-width space) before "It'" (line: 3, col: 1)`),
  192. ]),
  193. ]));
  194. quickThrows(
  195. ` Room at the start.`,
  196. new AggregateError([
  197. new AggregateError([
  198. new TypeError(`Matched " " at start`),
  199. ], `Whitespace found at start or end`),
  200. ]));
  201. quickThrows(
  202. `Room at the end. `,
  203. new AggregateError([
  204. new AggregateError([
  205. new TypeError(`Matched " " at end`),
  206. ], `Whitespace found at start or end`),
  207. ]));
  208. quickThrows(
  209. ` Room on both sides. `,
  210. new AggregateError([
  211. new AggregateError([
  212. new TypeError(`Matched " " at start`),
  213. new TypeError(`Matched " " at end`),
  214. ], `Whitespace found at start or end`),
  215. ]));
  216. quickThrows(
  217. `We're going multiline! \n` +
  218. `That we are, aye. \n` +
  219. ` \n`,
  220. `Yessir.`,
  221. new AggregateError([
  222. new AggregateError([
  223. new TypeError(`Matched " " at end of line 1`),
  224. new TypeError(`Matched " " at end of line 2`),
  225. new TypeError(`Matched " " as all of line 3`),
  226. ], `Whitespace found at end of line`),
  227. ]));
  228. t.doesNotThrow(() =>
  229. isContentString(
  230. `It's cool.\n` +
  231. ` It's cool.\n` +
  232. ` It's cool.\n` +
  233. ` It's so cool.`));
  234. t.doesNotThrow(() =>
  235. isContentString(
  236. `\n` +
  237. `\n` +
  238. `It's okay for\n` +
  239. `blank lines\n` +
  240. `\n` +
  241. `just about anywhere.\n` +
  242. ``));
  243. });
  244. t.test('isContribution', t => {
  245. t.plan(4);
  246. t.ok(isContribution({artist: 'artist:toby-fox', annotation: 'Music'}));
  247. t.ok(isContribution({artist: 'Toby Fox'}));
  248. t.throws(() => isContribution(({artist: 'group:umspaf', annotation: 'Organizing'})),
  249. {errors: /artist/});
  250. t.throws(() => isContribution(({artist: 'artist:toby-fox', annotation: 123})),
  251. {errors: /annotation/});
  252. });
  253. t.test('isContributionList', t => {
  254. t.plan(4);
  255. t.ok(isContributionList([{artist: 'Beavis'}, {artist: 'Butthead', annotation: 'Wrangling'}]));
  256. t.ok(isContributionList([]));
  257. t.throws(() => isContributionList(2));
  258. t.throws(() => isContributionList(['Charlie', 'Woodstock']));
  259. });
  260. test(t, 'isDimensions', t => {
  261. t.plan(6);
  262. t.ok(isDimensions([1, 1]));
  263. t.ok(isDimensions([50, 50]));
  264. t.ok(isDimensions([5000, 1]));
  265. t.throws(() => isDimensions([1]), TypeError);
  266. t.throws(() => isDimensions([413, 612, 1025]), TypeError);
  267. t.throws(() => isDimensions('800x200'), TypeError);
  268. });
  269. test(t, 'isDirectory', t => {
  270. t.plan(6);
  271. t.ok(isDirectory('savior-of-the-waking-world'));
  272. t.ok(isDirectory('MeGaLoVania'));
  273. t.ok(isDirectory('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'));
  274. t.throws(() => isDirectory(123), TypeError);
  275. t.throws(() => isDirectory(''), TypeError);
  276. t.throws(() => isDirectory('troll saint nicholas and the quest for the holy pail'), TypeError);
  277. });
  278. test(t, 'isDuration', t => {
  279. t.plan(5);
  280. t.ok(isDuration(60));
  281. t.ok(isDuration(0.02));
  282. t.ok(isDuration(0));
  283. t.throws(() => isDuration(-1), TypeError);
  284. t.throws(() => isDuration('10:25'), TypeError);
  285. });
  286. test(t, 'isFileExtension', t => {
  287. t.plan(6);
  288. t.ok(isFileExtension('png'));
  289. t.ok(isFileExtension('jpg'));
  290. t.ok(isFileExtension('sub_loc'));
  291. t.throws(() => isFileExtension(''), TypeError);
  292. t.throws(() => isFileExtension('.jpg'), TypeError);
  293. t.throws(() => isFileExtension('just an image bro!!!!'), TypeError);
  294. });
  295. t.test('isName', t => {
  296. t.plan(4);
  297. t.ok(isName('Dogz 2.0'));
  298. t.ok(isName('album:this-track-is-only-named-thusly-to-give-niklink-a-headache'));
  299. t.throws(() => isName(''));
  300. t.throws(() => isName(612));
  301. });
  302. t.test('isURL', t => {
  303. t.plan(4);
  304. t.ok(isURL(`https://hsmusic.wiki/foo/bar/hi?baz=25#hash`));
  305. t.throws(() => isURL(`/the/dog/zone/`));
  306. t.throws(() => isURL(25));
  307. t.throws(() => isURL(new URL(`https://hsmusic.wiki/perfectly/reasonable/`)));
  308. });
  309. test(t, 'validateReference', t => {
  310. t.plan(16);
  311. const typeless = validateReference();
  312. const track = validateReference('track');
  313. const album = validateReference('album');
  314. t.ok(track('track:doctor'));
  315. t.ok(track('track:MeGaLoVania'));
  316. t.ok(track('Showtime (Imp Strife Mix)'));
  317. t.throws(() => track('track:troll saint nic'), TypeError);
  318. t.throws(() => track('track:'), TypeError);
  319. t.throws(() => track('album:homestuck-vol-1'), TypeError);
  320. t.ok(album('album:sburb'));
  321. t.ok(album('album:the-wanderers'));
  322. t.ok(album('Homestuck Vol. 8'));
  323. t.throws(() => album('album:Hiveswap Friendsim'), TypeError);
  324. t.throws(() => album('album:'), TypeError);
  325. t.throws(() => album('track:showtime-piano-refrain'), TypeError);
  326. t.ok(typeless('Hopes and Dreams'));
  327. t.ok(typeless('track:snowdin-town'));
  328. t.throws(() => typeless(''), TypeError);
  329. t.throws(() => typeless('album:undertale-soundtrack'));
  330. });
  331. test(t, 'validateReferenceList', t => {
  332. const track = validateReferenceList('track');
  333. const artist = validateReferenceList('artist');
  334. t.plan(11);
  335. t.ok(track(['track:fallen-down', 'Once Upon a Time']));
  336. t.ok(artist(['artist:toby-fox', 'Mark Hadley']));
  337. t.ok(track(['track:amalgam']));
  338. t.ok(track([]));
  339. let caughtError = null;
  340. try {
  341. track(['Dog', 'album:vaporwave-2016', 'Cat', 'artist:john-madden']);
  342. } catch (err) {
  343. caughtError = err;
  344. }
  345. t.not(caughtError, null);
  346. t.ok(caughtError instanceof AggregateError);
  347. t.equal(caughtError.errors.length, 2);
  348. t.ok(caughtError.errors[0] instanceof Error);
  349. t.ok(caughtError.errors[0].cause instanceof TypeError);
  350. t.ok(caughtError.errors[1] instanceof Error);
  351. t.ok(caughtError.errors[0].cause instanceof TypeError);
  352. });
  353. test(t, 'anyOf', t => {
  354. t.plan(11);
  355. const isStringOrNumber = anyOf(isString, isNumber);
  356. t.ok(isStringOrNumber('hello world'));
  357. t.ok(isStringOrNumber(42));
  358. t.throws(() => isStringOrNumber(false));
  359. const mockError = new Error();
  360. const neverSucceeds = () => {
  361. throw mockError;
  362. };
  363. const isStringOrGetRekt = anyOf(isString, neverSucceeds);
  364. t.ok(isStringOrGetRekt('phew!'));
  365. let caughtError = null;
  366. try {
  367. isStringOrGetRekt(0xdeadbeef);
  368. } catch (err) {
  369. caughtError = err;
  370. }
  371. t.not(caughtError, null);
  372. t.ok(caughtError instanceof AggregateError);
  373. t.equal(caughtError.errors.length, 2);
  374. t.ok(caughtError.errors[0] instanceof TypeError);
  375. t.equal(caughtError.errors[0].check, isString);
  376. t.equal(caughtError.errors[1], mockError);
  377. t.equal(caughtError.errors[1].check, neverSucceeds);
  378. });