diff options
Diffstat (limited to 'test/unit/data')
-rw-r--r-- | test/unit/data/things/cacheable-object.js | 270 | ||||
-rw-r--r-- | test/unit/data/things/track.js | 75 | ||||
-rw-r--r-- | test/unit/data/things/validators.js | 318 |
3 files changed, 663 insertions, 0 deletions
diff --git a/test/unit/data/things/cacheable-object.js b/test/unit/data/things/cacheable-object.js new file mode 100644 index 00000000..d7a88ce7 --- /dev/null +++ b/test/unit/data/things/cacheable-object.js @@ -0,0 +1,270 @@ +import t from 'tap'; + +import CacheableObject from '../../../../src/data/things/cacheable-object.js'; + +function newCacheableObject(PD) { + return new (class extends CacheableObject { + static propertyDescriptors = PD; + }); +} + +t.test(`CacheableObject simple separate update & expose`, t => { + const obj = newCacheableObject({ + number: { + flags: { + update: true + } + }, + + timesTwo: { + flags: { + expose: true + }, + + expose: { + dependencies: ['number'], + compute: ({ number }) => number * 2 + } + } + }); + + t.plan(1); + obj.number = 5; + t.equal(obj.timesTwo, 10); +}); + +t.test(`CacheableObject basic cache behavior`, t => { + let computeCount = 0; + + const obj = newCacheableObject({ + string: { + flags: { + update: true + } + }, + + karkat: { + flags: { + expose: true + }, + + expose: { + dependencies: ['string'], + compute: ({ string }) => { + computeCount++; + return string.toUpperCase(); + } + } + } + }); + + t.plan(8); + + t.equal(computeCount, 0); + + obj.string = 'hello world'; + t.equal(computeCount, 0); + + obj.karkat; + t.equal(computeCount, 1); + + obj.karkat; + t.equal(computeCount, 1); + + obj.string = 'testing once again'; + t.equal(computeCount, 1); + + obj.karkat; + t.equal(computeCount, 2); + + obj.string = 'testing once again'; + t.equal(computeCount, 2); + + obj.karkat; + t.equal(computeCount, 2); +}); + +t.test(`CacheableObject combined update & expose (no transform)`, t => { + const obj = newCacheableObject({ + directory: { + flags: { + update: true, + expose: true + } + } + }); + + t.plan(2); + + obj.directory = 'the-world-revolving'; + t.equal(obj.directory, 'the-world-revolving'); + + obj.directory = 'chaos-king'; + t.equal(obj.directory, 'chaos-king'); +}); + +t.test(`CacheableObject combined update & expose (basic transform)`, t => { + const obj = newCacheableObject({ + getsRepeated: { + flags: { + update: true, + expose: true + }, + + expose: { + transform: value => value.repeat(2) + } + } + }); + + t.plan(1); + + obj.getsRepeated = 'dog'; + t.equal(obj.getsRepeated, 'dogdog'); +}); + +t.test(`CacheableObject combined update & expose (transform with dependency)`, t => { + const obj = newCacheableObject({ + customRepeat: { + flags: { + update: true, + expose: true + }, + + expose: { + dependencies: ['times'], + transform: (value, { times }) => value.repeat(times) + } + }, + + times: { + flags: { + update: true + } + } + }); + + t.plan(3); + + obj.customRepeat = 'dog'; + obj.times = 1; + t.equal(obj.customRepeat, 'dog'); + + obj.times = 5; + t.equal(obj.customRepeat, 'dogdogdogdogdog'); + + obj.customRepeat = 'cat'; + t.equal(obj.customRepeat, 'catcatcatcatcat'); +}); + +t.test(`CacheableObject validate on update`, t => { + const mockError = new TypeError(`Expected a string, not ${typeof value}`); + + const obj = newCacheableObject({ + directory: { + flags: { + update: true, + expose: true + }, + + update: { + validate: value => { + if (typeof value !== 'string') { + throw mockError; + } + return true; + } + } + }, + + date: { + flags: { + update: true, + expose: true + }, + + update: { + validate: value => (value instanceof Date) + } + } + }); + + let thrownError; + t.plan(6); + + obj.directory = 'megalovania'; + t.equal(obj.directory, 'megalovania'); + + try { + obj.directory = 25; + } catch (err) { + thrownError = err; + } + + t.equal(thrownError, mockError); + t.equal(obj.directory, 'megalovania'); + + const date = new Date(`25 December 2009`); + + obj.date = date; + t.equal(obj.date, date); + + try { + obj.date = `TWELFTH PERIGEE'S EVE`; + } catch (err) { + thrownError = err; + } + + t.equal(thrownError?.constructor, TypeError); + t.equal(obj.date, date); +}); + +t.test(`CacheableObject default update property value`, t => { + const obj = newCacheableObject({ + fruit: { + flags: { + update: true, + expose: true + }, + + update: { + default: 'potassium' + } + } + }); + + t.plan(1); + t.equal(obj.fruit, 'potassium'); +}); + +t.test(`CacheableObject default property throws if invalid`, t => { + const mockError = new TypeError(`Expected a string, not ${typeof value}`); + + t.plan(1); + + let thrownError; + + try { + newCacheableObject({ + string: { + flags: { + update: true + }, + + update: { + default: 123, + validate: value => { + if (typeof value !== 'string') { + throw mockError; + } + return true; + } + } + } + }); + } catch (err) { + thrownError = err; + } + + t.equal(thrownError, mockError); +}); diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js new file mode 100644 index 00000000..0dad0e62 --- /dev/null +++ b/test/unit/data/things/track.js @@ -0,0 +1,75 @@ +import t from 'tap'; + +import thingConstructors from '../../../../src/data/things/index.js'; + +const { + Album, + Thing, + Track, + TrackGroup, +} = thingConstructors; + +function stubAlbum(tracks) { + const album = new Album(); + album.trackSections = [ + { + tracksByRef: tracks.map(t => Thing.getReference(t)), + }, + ]; + album.trackData = tracks; + return album; +} + +t.test(`Track.coverArtDate`, t => { + t.plan(5); + + // Priority order is as follows, with the last (trackCoverArtDate) being + // greatest priority. + const albumDate = new Date('2010-10-10'); + const albumTrackArtDate = new Date('2012-12-12'); + const trackDateFirstReleased = new Date('2008-08-08'); + const trackCoverArtDate = new Date('2009-09-09'); + + const track = new Track(); + track.directory = 'foo'; + + const album = stubAlbum([track]); + + track.albumData = [album]; + + // 1. coverArtDate defaults to null + + t.equal(track.coverArtDate, null); + + // 2. coverArtDate inherits album release date + + album.date = albumDate; + + // XXX clear cache so change in album's property is reflected + track.albumData = []; + track.albumData = [album]; + + t.equal(track.coverArtDate, albumDate); + + // 3. coverArtDate inherits album trackArtDate + + album.trackArtDate = albumTrackArtDate; + + // XXX clear cache again + track.albumData = []; + track.albumData = [album]; + + t.equal(track.coverArtDate, albumTrackArtDate); + + // 4. coverArtDate is overridden dateFirstReleased + + track.dateFirstReleased = trackDateFirstReleased; + + t.equal(track.coverArtDate, trackDateFirstReleased); + + // 5. coverArtDate is overridden coverArtDate + + track.coverArtDate = trackCoverArtDate; + + t.equal(track.coverArtDate, trackCoverArtDate); +}); diff --git a/test/unit/data/things/validators.js b/test/unit/data/things/validators.js new file mode 100644 index 00000000..53cba063 --- /dev/null +++ b/test/unit/data/things/validators.js @@ -0,0 +1,318 @@ +import t from 'tap'; +import { showAggregate } from '../../../../src/util/sugar.js'; + +import { + // Basic types + isBoolean, + isCountingNumber, + isDate, + isNumber, + isString, + isStringNonEmpty, + + // Complex types + isArray, + isObject, + validateArrayItems, + + // Wiki data + isColor, + isCommentary, + isContribution, + isContributionList, + isDimensions, + isDirectory, + isDuration, + isFileExtension, + isName, + isURL, + validateReference, + validateReferenceList, + + // Compositional utilities + oneOf, +} from '../../../../src/data/things/validators.js'; + +function test(t, msg, fn) { + t.test(msg, t => { + try { + fn(t); + } catch (error) { + if (error instanceof AggregateError) { + showAggregate(error); + } + throw error; + } + }); +} + +// Basic types + +test(t, 'isBoolean', t => { + t.plan(4); + t.ok(isBoolean(true)); + t.ok(isBoolean(false)); + t.throws(() => isBoolean(1), TypeError); + t.throws(() => isBoolean('yes'), TypeError); +}); + +test(t, 'isNumber', t => { + t.plan(6); + t.ok(isNumber(123)); + t.ok(isNumber(0.05)); + t.ok(isNumber(0)); + t.ok(isNumber(-10)); + t.throws(() => isNumber('413'), TypeError); + t.throws(() => isNumber(true), TypeError); +}); + +test(t, 'isCountingNumber', t => { + t.plan(6); + t.ok(isCountingNumber(3)); + t.ok(isCountingNumber(1)); + t.throws(() => isCountingNumber(1.75), TypeError); + t.throws(() => isCountingNumber(0), TypeError); + t.throws(() => isCountingNumber(-1), TypeError); + t.throws(() => isCountingNumber('612'), TypeError); +}); + +test(t, 'isString', t => { + t.plan(3); + t.ok(isString('hello!')); + t.ok(isString('')); + t.throws(() => isString(100), TypeError); +}); + +test(t, 'isStringNonEmpty', t => { + t.plan(4); + t.ok(isStringNonEmpty('hello!')); + t.throws(() => isStringNonEmpty(''), TypeError); + t.throws(() => isStringNonEmpty(' '), TypeError); + t.throws(() => isStringNonEmpty(100), TypeError); +}); + +// Complex types + +test(t, 'isArray', t => { + t.plan(3); + t.ok(isArray([])); + t.throws(() => isArray({}), TypeError); + t.throws(() => isArray('1, 2, 3'), TypeError); +}); + +test(t, 'isDate', t => { + t.plan(3); + t.ok(isDate(new Date('2023-03-27 09:24:15'))); + t.throws(() => isDate(new Date(Infinity)), TypeError); + t.throws(() => isDimensions('2023-03-27 09:24:15'), TypeError); +}); + +test(t, 'isObject', t => { + t.plan(3); + t.ok(isObject({})); + t.ok(isObject([])); + t.throws(() => isObject(null), TypeError); +}); + +test(t, 'validateArrayItems', t => { + t.plan(6); + + t.ok(validateArrayItems(isNumber)([3, 4, 5])); + t.ok(validateArrayItems(validateArrayItems(isNumber))([[3, 4], [4, 5], [6, 7]])); + + let caughtError = null; + try { + validateArrayItems(isNumber)([10, 20, 'one hundred million consorts', 30]); + } catch (err) { + caughtError = err; + } + + t.not(caughtError, null); + t.ok(caughtError instanceof AggregateError); + t.equal(caughtError.errors.length, 1); + t.ok(caughtError.errors[0] instanceof TypeError); +}); + +// Wiki data + +t.test('isColor', t => { + t.plan(9); + t.ok(isColor('#123')); + t.ok(isColor('#1234')); + t.ok(isColor('#112233')); + t.ok(isColor('#11223344')); + t.ok(isColor('#abcdef00')); + t.ok(isColor('#ABCDEF')); + t.throws(() => isColor('#ggg'), TypeError); + t.throws(() => isColor('red'), TypeError); + t.throws(() => isColor('hsl(150deg 30% 60%)'), TypeError); +}); + +t.test('isCommentary', t => { + t.plan(6); + t.ok(isCommentary(`<i>Toby Fox:</i>\ndogsong.mp3`)); + t.ok(isCommentary(`Technically, this works:</i>`)); + t.ok(isCommentary(`<i><b>Whodunnit:</b></i>`)); + t.throws(() => isCommentary(123), TypeError); + t.throws(() => isCommentary(``), TypeError); + t.throws(() => isCommentary(`<i><u>Toby Fox:</u></i>`)); +}); + +t.test('isContribution', t => { + t.plan(4); + t.ok(isContribution({who: 'artist:toby-fox', what: 'Music'})); + t.ok(isContribution({who: 'Toby Fox'})); + t.throws(() => isContribution(({who: 'group:umspaf', what: 'Organizing'})), + {errors: /who/}); + t.throws(() => isContribution(({who: 'artist:toby-fox', what: 123})), + {errors: /what/}); +}); + +t.test('isContributionList', t => { + t.plan(4); + t.ok(isContributionList([{who: 'Beavis'}, {who: 'Butthead', what: 'Wrangling'}])); + t.ok(isContributionList([])); + t.throws(() => isContributionList(2)); + t.throws(() => isContributionList(['Charlie', 'Woodstock'])); +}); + +test(t, 'isDimensions', t => { + t.plan(6); + t.ok(isDimensions([1, 1])); + t.ok(isDimensions([50, 50])); + t.ok(isDimensions([5000, 1])); + t.throws(() => isDimensions([1]), TypeError); + t.throws(() => isDimensions([413, 612, 1025]), TypeError); + t.throws(() => isDimensions('800x200'), TypeError); +}); + +test(t, 'isDirectory', t => { + t.plan(6); + t.ok(isDirectory('savior-of-the-waking-world')); + t.ok(isDirectory('MeGaLoVania')); + t.ok(isDirectory('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_')); + t.throws(() => isDirectory(123), TypeError); + t.throws(() => isDirectory(''), TypeError); + t.throws(() => isDirectory('troll saint nicholas and the quest for the holy pail'), TypeError); +}); + +test(t, 'isDuration', t => { + t.plan(5); + t.ok(isDuration(60)); + t.ok(isDuration(0.02)); + t.ok(isDuration(0)); + t.throws(() => isDuration(-1), TypeError); + t.throws(() => isDuration('10:25'), TypeError); +}); + +test(t, 'isFileExtension', t => { + t.plan(6); + t.ok(isFileExtension('png')); + t.ok(isFileExtension('jpg')); + t.ok(isFileExtension('sub_loc')); + t.throws(() => isFileExtension(''), TypeError); + t.throws(() => isFileExtension('.jpg'), TypeError); + t.throws(() => isFileExtension('just an image bro!!!!'), TypeError); +}); + +t.test('isName', t => { + t.plan(4); + t.ok(isName('Dogz 2.0')); + t.ok(isName('album:this-track-is-only-named-thusly-to-give-niklink-a-headache')); + t.ok(isName('')); + t.throws(() => isName(612)); +}); + +t.test('isURL', t => { + t.plan(4); + t.ok(isURL(`https://hsmusic.wiki/foo/bar/hi?baz=25#hash`)); + t.throws(() => isURL(`/the/dog/zone/`)); + t.throws(() => isURL(25)); + t.throws(() => isURL(new URL(`https://hsmusic.wiki/perfectly/reasonable/`))); +}); + +test(t, 'validateReference', t => { + t.plan(16); + + const typeless = validateReference(); + const track = validateReference('track'); + const album = validateReference('album'); + + t.ok(track('track:doctor')); + t.ok(track('track:MeGaLoVania')); + t.ok(track('Showtime (Imp Strife Mix)')); + t.throws(() => track('track:troll saint nic'), TypeError); + t.throws(() => track('track:'), TypeError); + t.throws(() => track('album:homestuck-vol-1'), TypeError); + + t.ok(album('album:sburb')); + t.ok(album('album:the-wanderers')); + t.ok(album('Homestuck Vol. 8')); + t.throws(() => album('album:Hiveswap Friendsim'), TypeError); + t.throws(() => album('album:'), TypeError); + t.throws(() => album('track:showtime-piano-refrain'), TypeError); + + t.ok(typeless('Hopes and Dreams')); + t.ok(typeless('track:snowdin-town')); + t.throws(() => typeless(''), TypeError); + t.throws(() => typeless('album:undertale-soundtrack')); +}); + +test(t, 'validateReferenceList', t => { + const track = validateReferenceList('track'); + const artist = validateReferenceList('artist'); + + t.plan(9); + + t.ok(track(['track:fallen-down', 'Once Upon a Time'])); + t.ok(artist(['artist:toby-fox', 'Mark Hadley'])); + t.ok(track(['track:amalgam'])); + t.ok(track([])); + + let caughtError = null; + try { + track(['Dog', 'album:vaporwave-2016', 'Cat', 'artist:john-madden']); + } catch (err) { + caughtError = err; + } + + t.not(caughtError, null); + t.ok(caughtError instanceof AggregateError); + t.equal(caughtError.errors.length, 2); + t.ok(caughtError.errors[0] instanceof TypeError); + t.ok(caughtError.errors[1] instanceof TypeError); +}); + +test(t, 'oneOf', t => { + t.plan(11); + + const isStringOrNumber = oneOf(isString, isNumber); + + t.ok(isStringOrNumber('hello world')); + t.ok(isStringOrNumber(42)); + t.throws(() => isStringOrNumber(false)); + + const mockError = new Error(); + const neverSucceeds = () => { + throw mockError; + }; + + const isStringOrGetRekt = oneOf(isString, neverSucceeds); + + t.ok(isStringOrGetRekt('phew!')); + + let caughtError = null; + try { + isStringOrGetRekt(0xdeadbeef); + } catch (err) { + caughtError = err; + } + + t.not(caughtError, null); + t.ok(caughtError instanceof AggregateError); + t.equal(caughtError.errors.length, 2); + t.ok(caughtError.errors[0] instanceof TypeError); + t.equal(caughtError.errors[0].check, isString); + t.equal(caughtError.errors[1], mockError); + t.equal(caughtError.errors[1].check, neverSucceeds); +}); |