diff options
-rw-r--r-- | src/content/dependencies/generateAlbumInfoPage.js | 2 | ||||
-rw-r--r-- | src/content/dependencies/generateAlbumTrackListItem.js | 20 | ||||
-rw-r--r-- | src/content/dependencies/generateTrackInfoPage.js | 7 | ||||
-rw-r--r-- | src/content/dependencies/generateTrackList.js | 59 | ||||
-rw-r--r-- | src/data/things/album.js | 15 | ||||
-rw-r--r-- | src/data/things/art-tag.js | 4 | ||||
-rw-r--r-- | src/data/things/artist.js | 30 | ||||
-rw-r--r-- | src/data/things/cacheable-object.js | 35 | ||||
-rw-r--r-- | src/data/things/flash.js | 14 | ||||
-rw-r--r-- | src/data/things/group.js | 13 | ||||
-rw-r--r-- | src/data/things/thing.js | 1265 | ||||
-rw-r--r-- | src/data/things/track.js | 796 | ||||
-rw-r--r-- | src/data/things/wiki-info.js | 6 | ||||
-rw-r--r-- | src/data/yaml.js | 26 | ||||
-rw-r--r-- | src/util/sugar.js | 24 | ||||
-rw-r--r-- | test/lib/index.js | 1 | ||||
-rw-r--r-- | test/lib/wiki-data.js | 24 | ||||
-rw-r--r-- | test/unit/data/things/track.js | 515 |
18 files changed, 2330 insertions, 526 deletions
diff --git a/src/content/dependencies/generateAlbumInfoPage.js b/src/content/dependencies/generateAlbumInfoPage.js index ce17ab21..51ea5927 100644 --- a/src/content/dependencies/generateAlbumInfoPage.js +++ b/src/content/dependencies/generateAlbumInfoPage.js @@ -44,7 +44,7 @@ export default { relations.coverArtistChronologyContributions = getChronologyRelations(album, { - contributions: album.coverArtistContribs, + contributions: album.coverArtistContribs ?? [], linkArtist: artist => relation('linkArtist', artist), diff --git a/src/content/dependencies/generateAlbumTrackListItem.js b/src/content/dependencies/generateAlbumTrackListItem.js index f65b47c9..f92712f9 100644 --- a/src/content/dependencies/generateAlbumTrackListItem.js +++ b/src/content/dependencies/generateAlbumTrackListItem.js @@ -1,4 +1,4 @@ -import {compareArrays} from '#sugar'; +import {compareArrays, empty} from '#sugar'; export default { contentDependencies: [ @@ -11,9 +11,11 @@ export default { relations(relation, track) { const relations = {}; - relations.contributionLinks = - track.artistContribs - .map(contrib => relation('linkContribution', contrib)); + if (!empty(track.artistContribs)) { + relations.contributionLinks = + track.artistContribs + .map(contrib => relation('linkContribution', contrib)); + } relations.trackLink = relation('linkTrack', track); @@ -31,10 +33,12 @@ export default { } data.showArtists = - !compareArrays( - track.artistContribs.map(c => c.who), - album.artistContribs.map(c => c.who), - {checkOrder: false}); + !empty(track.artistContribs) && + (empty(album.artistContribs) || + !compareArrays( + track.artistContribs.map(c => c.who), + album.artistContribs.map(c => c.who), + {checkOrder: false})); return data; }, diff --git a/src/content/dependencies/generateTrackInfoPage.js b/src/content/dependencies/generateTrackInfoPage.js index 334c5422..7002204c 100644 --- a/src/content/dependencies/generateTrackInfoPage.js +++ b/src/content/dependencies/generateTrackInfoPage.js @@ -51,7 +51,10 @@ export default { relations.artistChronologyContributions = getChronologyRelations(track, { - contributions: [...track.artistContribs, ...track.contributorContribs], + contributions: [ + ...track.artistContribs ?? [], + ...track.contributorContribs ?? [], + ], linkArtist: artist => relation('linkArtist', artist), linkThing: track => relation('linkTrack', track), @@ -65,7 +68,7 @@ export default { relations.coverArtistChronologyContributions = getChronologyRelations(track, { - contributions: track.coverArtistContribs, + contributions: track.coverArtistContribs ?? [], linkArtist: artist => relation('linkArtist', artist), diff --git a/src/content/dependencies/generateTrackList.js b/src/content/dependencies/generateTrackList.js index f001c3b3..65f5552b 100644 --- a/src/content/dependencies/generateTrackList.js +++ b/src/content/dependencies/generateTrackList.js @@ -1,4 +1,4 @@ -import {empty} from '#sugar'; +import {empty, stitchArrays} from '#sugar'; export default { contentDependencies: ['linkTrack', 'linkContribution'], @@ -11,14 +11,17 @@ export default { } return { - items: tracks.map(track => ({ - trackLink: - relation('linkTrack', track), + trackLinks: + tracks + .map(track => relation('linkTrack', track)), - contributionLinks: - track.artistContribs - .map(contrib => relation('linkContribution', contrib)), - })), + contributionLinks: + tracks + .map(track => + (empty(track.artistContribs) + ? null + : track.artistContribs + .map(contrib => relation('linkContribution', contrib)))), }; }, @@ -28,22 +31,28 @@ export default { }, generate(relations, slots, {html, language}) { - return html.tag('ul', - relations.items.map(({trackLink, contributionLinks}) => - html.tag('li', - language.$('trackList.item.withArtists', { - track: trackLink, - by: - html.tag('span', {class: 'by'}, - language.$('trackList.item.withArtists.by', { - artists: - language.formatConjunctionList( - contributionLinks.map(link => - link.slots({ - showContribution: slots.showContribution, - showIcons: slots.showIcons, - }))), - })), - })))); + return ( + html.tag('ul', + stitchArrays({ + trackLink: relations.trackLinks, + contributionLinks: relations.contributionLinks, + }).map(({trackLink, contributionLinks}) => + html.tag('li', + (empty(contributionLinks) + ? trackLink + : language.$('trackList.item.withArtists', { + track: trackLink, + by: + html.tag('span', {class: 'by'}, + language.$('trackList.item.withArtists.by', { + artists: + language.formatConjunctionList( + contributionLinks.map(link => + link.slots({ + showContribution: slots.showContribution, + showIcons: slots.showIcons, + }))), + })), + })))))); }, }; diff --git a/src/data/things/album.js b/src/data/things/album.js index c012c243..06982903 100644 --- a/src/data/things/album.js +++ b/src/data/things/album.js @@ -128,6 +128,9 @@ export class Album extends Thing { commentatorArtists: Thing.common.commentatorArtists(), + groups: Thing.common.dynamicThingsFromReferenceList('groupsByRef', 'groupData', find.group), + artTags: Thing.common.dynamicThingsFromReferenceList('artTagsByRef', 'artTagData', find.artTag), + hasCoverArt: Thing.common.contribsPresent('coverArtistContribsByRef'), hasWallpaperArt: Thing.common.contribsPresent('wallpaperArtistContribsByRef'), hasBannerArt: Thing.common.contribsPresent('bannerArtistContribsByRef'), @@ -146,18 +149,6 @@ export class Album extends Thing { : [], }, }, - - groups: Thing.common.dynamicThingsFromReferenceList( - 'groupsByRef', - 'groupData', - find.group - ), - - artTags: Thing.common.dynamicThingsFromReferenceList( - 'artTagsByRef', - 'artTagData', - find.artTag - ), }); static [Thing.getSerializeDescriptors] = ({ diff --git a/src/data/things/art-tag.js b/src/data/things/art-tag.js index c103c4d5..bb36e09e 100644 --- a/src/data/things/art-tag.js +++ b/src/data/things/art-tag.js @@ -37,8 +37,8 @@ export class ArtTag extends Thing { flags: {expose: true}, expose: { - dependencies: ['albumData', 'trackData'], - compute: ({albumData, trackData, [ArtTag.instance]: artTag}) => + dependencies: ['this', 'albumData', 'trackData'], + compute: ({this: artTag, albumData, trackData}) => sortAlbumsTracksChronologically( [...albumData, ...trackData] .filter(({artTags}) => artTags.includes(artTag)), diff --git a/src/data/things/artist.js b/src/data/things/artist.js index 522ca5f9..b2383057 100644 --- a/src/data/things/artist.js +++ b/src/data/things/artist.js @@ -66,14 +66,14 @@ export class Artist extends Thing { flags: {expose: true}, expose: { - dependencies: ['trackData'], + dependencies: ['this', 'trackData'], - compute: ({trackData, [Artist.instance]: artist}) => + compute: ({this: artist, trackData}) => trackData?.filter((track) => [ - ...track.artistContribs, - ...track.contributorContribs, - ...track.coverArtistContribs, + ...track.artistContribs ?? [], + ...track.contributorContribs ?? [], + ...track.coverArtistContribs ?? [], ].some(({who}) => who === artist)) ?? [], }, }, @@ -82,9 +82,9 @@ export class Artist extends Thing { flags: {expose: true}, expose: { - dependencies: ['trackData'], + dependencies: ['this', 'trackData'], - compute: ({trackData, [Artist.instance]: artist}) => + compute: ({this: artist, trackData}) => trackData?.filter(({commentatorArtists}) => commentatorArtists.includes(artist)) ?? [], }, @@ -103,18 +103,16 @@ export class Artist extends Thing { flags: {expose: true}, expose: { - dependencies: ['albumData'], + dependencies: [this, 'albumData'], - compute: ({albumData, [Artist.instance]: artist}) => + compute: ({this: artist, albumData}) => albumData?.filter(({commentatorArtists}) => commentatorArtists.includes(artist)) ?? [], }, }, - flashesAsContributor: Artist.filterByContrib( - 'flashData', - 'contributorContribs' - ), + flashesAsContributor: + Artist.filterByContrib('flashData', 'contributorContribs'), }); static [Thing.getSerializeDescriptors] = ({ @@ -148,15 +146,15 @@ export class Artist extends Thing { flags: {expose: true}, expose: { - dependencies: [thingDataProperty], + dependencies: ['this', thingDataProperty], compute: ({ + this: artist, [thingDataProperty]: thingData, - [Artist.instance]: artist }) => thingData?.filter(thing => thing[contribsProperty] - .some(contrib => contrib.who === artist)) ?? [], + ?.some(contrib => contrib.who === artist)) ?? [], }, }); } diff --git a/src/data/things/cacheable-object.js b/src/data/things/cacheable-object.js index ea705a61..62c23d13 100644 --- a/src/data/things/cacheable-object.js +++ b/src/data/things/cacheable-object.js @@ -83,8 +83,6 @@ function inspect(value) { } export default class CacheableObject { - static instance = Symbol('CacheableObject `this` instance'); - #propertyUpdateValues = Object.create(null); #propertyUpdateCacheInvalidators = Object.create(null); @@ -143,7 +141,7 @@ export default class CacheableObject { const definition = { configurable: false, - enumerable: true, + enumerable: flags.expose, }; if (flags.update) { @@ -250,20 +248,27 @@ export default class CacheableObject { let getAllDependencies; - const dependencyKeys = expose.dependencies; - if (dependencyKeys?.length > 0) { - const reflectionEntry = [this.constructor.instance, this]; - const dependencyGetters = dependencyKeys - .map(key => () => [key, this.#propertyUpdateValues[key]]); + if (expose.dependencies?.length > 0) { + const dependencyKeys = expose.dependencies.slice(); + const shouldReflect = dependencyKeys.includes('this'); + + getAllDependencies = () => { + const dependencies = Object.create(null); + + for (const key of dependencyKeys) { + dependencies[key] = this.#propertyUpdateValues[key]; + } - getAllDependencies = () => - Object.fromEntries(dependencyGetters - .map(f => f()) - .concat([reflectionEntry])); + if (shouldReflect) { + dependencies.this = this; + } + + return dependencies; + }; } else { - const allDependencies = {[this.constructor.instance]: this}; - Object.freeze(allDependencies); - getAllDependencies = () => allDependencies; + const dependencies = Object.create(null); + Object.freeze(dependencies); + getAllDependencies = () => dependencies; } if (flags.update) { diff --git a/src/data/things/flash.js b/src/data/things/flash.js index 6eb5234f..3f870c51 100644 --- a/src/data/things/flash.js +++ b/src/data/things/flash.js @@ -77,9 +77,9 @@ export class Flash extends Thing { flags: {expose: true}, expose: { - dependencies: ['flashActData'], + dependencies: ['this', 'flashActData'], - compute: ({flashActData, [Flash.instance]: flash}) => + compute: ({this: flash, flashActData}) => flashActData.find((act) => act.flashes.includes(flash)) ?? null, }, }, @@ -88,9 +88,9 @@ export class Flash extends Thing { flags: {expose: true}, expose: { - dependencies: ['flashActData'], + dependencies: ['this', 'flashActData'], - compute: ({flashActData, [Flash.instance]: flash}) => + compute: ({this: flash, flashActData}) => flashActData.find((act) => act.flashes.includes(flash))?.color ?? null, }, }, @@ -141,10 +141,6 @@ export class FlashAct extends Thing { // Expose only - flashes: Thing.common.dynamicThingsFromReferenceList( - 'flashesByRef', - 'flashData', - find.flash - ), + flashes: Thing.common.dynamicThingsFromReferenceList('flashesByRef', 'flashData', find.flash), }) } diff --git a/src/data/things/group.js b/src/data/things/group.js index ba339b3e..f552b8f3 100644 --- a/src/data/things/group.js +++ b/src/data/things/group.js @@ -41,8 +41,8 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['albumData'], - compute: ({albumData, [Group.instance]: group}) => + dependencies: ['this', 'albumData'], + compute: ({this: group, albumData}) => albumData?.filter((album) => album.groups.includes(group)) ?? [], }, }, @@ -51,9 +51,8 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['groupCategoryData'], - - compute: ({groupCategoryData, [Group.instance]: group}) => + dependencies: ['this', 'groupCategoryData'], + compute: ({this: group, groupCategoryData}) => groupCategoryData.find((category) => category.groups.includes(group)) ?.color, }, @@ -63,8 +62,8 @@ export class Group extends Thing { flags: {expose: true}, expose: { - dependencies: ['groupCategoryData'], - compute: ({groupCategoryData, [Group.instance]: group}) => + dependencies: ['this', 'groupCategoryData'], + compute: ({this: group, groupCategoryData}) => groupCategoryData.find((category) => category.groups.includes(group)) ?? null, }, diff --git a/src/data/things/thing.js b/src/data/things/thing.js index c2876f56..19f5fb53 100644 --- a/src/data/things/thing.js +++ b/src/data/things/thing.js @@ -5,7 +5,7 @@ import {inspect} from 'node:util'; import {color} from '#cli'; import find from '#find'; -import {empty} from '#sugar'; +import {empty, filterProperties, openAggregate} from '#sugar'; import {getKebabCase} from '#wiki-data'; import { @@ -192,26 +192,29 @@ export default class Thing extends CacheableObject { // Corresponding dynamic property to referenceList, which takes the values // in the provided property and searches the specified wiki data for // matching actual Thing-subclass objects. - dynamicThingsFromReferenceList: ( - referenceListProperty, - thingDataProperty, - findFn - ) => ({ - flags: {expose: true}, + dynamicThingsFromReferenceList( + refs, + data, + findFunction + ) { + return Thing.composite.from(`Thing.common.dynamicThingsFromReferenceList`, [ + Thing.composite.earlyExitWithoutDependency(refs, {value: []}), + Thing.composite.earlyExitWithoutDependency(data, {value: []}), - expose: { - dependencies: [referenceListProperty, thingDataProperty], - compute: ({ - [referenceListProperty]: refs, - [thingDataProperty]: thingData, - }) => - refs && thingData - ? refs - .map((ref) => findFn(ref, thingData, {mode: 'quiet'})) - .filter(Boolean) - : [], - }, - }), + { + flags: {expose: true}, + expose: { + mapDependencies: {refs, data}, + options: {findFunction}, + + compute: ({refs, data, '#options': {findFunction}}) => + refs + .map(ref => findFunction(ref, data, {mode: 'quiet'})) + .filter(Boolean), + }, + }, + ]); + }, // Corresponding function for a single reference. dynamicThingFromSingleReference: ( @@ -250,14 +253,7 @@ export default class Thing extends CacheableObject { expose: { dependencies: ['artistData', contribsByRefProperty], compute: ({artistData, [contribsByRefProperty]: contribsByRef}) => - contribsByRef && artistData - ? contribsByRef - .map(({who: ref, what}) => ({ - who: find.artist(ref, artistData), - what, - })) - .filter(({who}) => who) - : [], + Thing.findArtistsFromContribs(contribsByRef, artistData), }, }), @@ -285,6 +281,7 @@ export default class Thing extends CacheableObject { flags: {expose: true}, expose: { dependencies: [ + 'this', contribsByRefProperty, thingDataProperty, nullerProperty, @@ -292,7 +289,7 @@ export default class Thing extends CacheableObject { ].filter(Boolean), compute({ - [Thing.instance]: thing, + this: thing, [nullerProperty]: nuller, [contribsByRefProperty]: contribsByRef, [thingDataProperty]: thingData, @@ -333,16 +330,15 @@ export default class Thing extends CacheableObject { // you would use this to compute a corresponding "referenced *by* tracks" // property. Naturally, the passed ref list property is of the things in the // wiki data provided, not the requesting Thing itself. - reverseReferenceList: (thingDataProperty, referencerRefListProperty) => ({ - flags: {expose: true}, - - expose: { - dependencies: [thingDataProperty], - - compute: ({[thingDataProperty]: thingData, [Thing.instance]: thing}) => - thingData?.filter(t => t[referencerRefListProperty].includes(thing)) ?? [], - }, - }), + reverseReferenceList({ + data, + refList, + }) { + return Thing.composite.from(`Thing.common.reverseReferenceList`, [ + Thing.composite.withReverseReferenceList({data, refList}), + Thing.composite.exposeDependency('#reverseReferenceList'), + ]); + }, // Corresponding function for single references. Note that the return value // is still a list - this is for matching all the objects whose single @@ -351,9 +347,9 @@ export default class Thing extends CacheableObject { flags: {expose: true}, expose: { - dependencies: [thingDataProperty], + dependencies: ['this', thingDataProperty], - compute: ({[thingDataProperty]: thingData, [Thing.instance]: thing}) => + compute: ({this: thing, [thingDataProperty]: thingData}) => thingData?.filter((t) => t[referencerRefListProperty] === thing) ?? [], }, }), @@ -418,4 +414,1191 @@ export default class Thing extends CacheableObject { return `${thing.constructor[Thing.referenceType]}:${thing.directory}`; } + + static findArtistsFromContribs(contribsByRef, artistData) { + if (empty(contribsByRef)) return null; + + return ( + contribsByRef + .map(({who, what}) => ({ + who: find.artist(who, artistData, {mode: 'quiet'}), + what, + })) + .filter(({who}) => who)); + } + + static composite = { + // Composes multiple compositional "steps" and a "base" to form a property + // descriptor out of modular building blocks. This is an extension to the + // more general-purpose CacheableObject property descriptor syntax, and + // aims to make modular data processing - which lends to declarativity - + // much easier, without fundamentally altering much of the typical syntax + // or terminology, nor building on it to an excessive degree. + // + // Think of a composition as being a chain of steps which lead into a final + // base property, which is usually responsible for returning the value that + // will actually get exposed when the property being described is accessed. + // + // == The compositional base: == + // + // The final item in a compositional list is its base, and it identifies + // the essential qualities of the property descriptor. The compositional + // steps preceding it may exit early, in which case the expose function + // defined on the base won't be called; or they will provide dependencies + // that the base may use to compute the final value that gets exposed for + // this property. + // + // The base indicates the capabilities of the composition as a whole. + // It should be {expose: true}, since that's the only area that preceding + // compositional steps (currently) can actually influence. If it's also + // {update: true}, then the composition as a whole accepts an update value + // just like normal update-flag property descriptors - meaning it can be + // set with `thing.someProperty = value` and that value will be paseed + // into each (implementing) step's transform() function, as well as the + // base. Bases usually aren't {compose: true}, but can be - check out the + // section on "nesting compositions" for details about that. + // + // Every composition always has exactly one compositional base, and it's + // always the last item in the composition list. All items preceding it + // are compositional steps, described below. + // + // == Compositional steps: == + // + // Compositional steps are, in essence, typical property descriptors with + // the extra flag {compose: true}. They operate on existing dependencies, + // and are typically dynamically constructed by "utility" functions (but + // can also be manually declared within the step list of a composition). + // Compositional steps serve two purposes: + // + // 1. exit early, if some condition is matched, returning and exposing + // some value directly from that step instead of continuing further + // down the step list; + // + // 2. and/or provide new, dynamically created "private" dependencies which + // can be accessed by further steps down the list, or at the base at + // the bottom, modularly supplying information that will contribute to + // the final value exposed for this property. + // + // Usually it's just one of those two, but it's fine for a step to perform + // both jobs if the situation benefits. + // + // Compositional steps are the real "modular" or "compositional" part of + // this data processing style - they're designed to be combined together + // in dynamic, versatile ways, as each property demands it. You usually + // define a compositional step to be returned by some ordinary static + // property-descriptor-returning function (customarily namespaced under + // the relevant Thing class's static `composite` field) - that lets you + // reuse it in multiple compositions later on. + // + // Compositional steps are implemented with "continuation passing style", + // meaning the connection to the next link on the chain is passed right to + // each step's compute (or transform) function, and the implementation gets + // to decide whether to continue on that chain or exit early by returning + // some other value. + // + // Every step along the chain, apart from the base at the bottom, has to + // have the {compose: true} step. That means its compute() or transform() + // function will be passed an extra argument at the end, `continuation`. + // To provide new dependencies to items further down the chain, just pass + // them directly to this continuation() function, customarily with a hash + // ('#') prefixing each name - for example: + // + // compute({..some dependencies..}, continuation) { + // return continuation({ + // '#excitingProperty': (..a value made from dependencies..), + // }); + // } + // + // Performing an early exit is as simple as returning some other value, + // instead of the continuation. You may also use `continuation.exit(value)` + // to perform the exact same kind of early exit - it's just a different + // syntax that might fit in better in certain longer compositions. + // + // It may be fine to simply provide new dependencies under a hard-coded + // name, such as '#excitingProperty' above, but if you're writing a utility + // that dynamically returns the compositional step and you suspect you + // might want to use this step multiple times in a single composition, + // it's customary to accept a name for the result. + // + // Here's a detailed example showing off early exit, dynamically operating + // on a provided dependency name, and then providing a result in another + // also-provided dependency name: + // + // static Thing.composite.withResolvedContribs = ({ + // from: contribsByRefDependency, + // to: outputDependency, + // }) => ({ + // flags: {expose: true, compose: true}, + // expose: { + // dependencies: [contribsByRefDependency, 'artistData'], + // compute({ + // [contribsByRefDependency]: contribsByRef, + // artistData, + // }, continuation) { + // if (!artistData) return null; /* early exit! */ + // return continuation({ + // [outputDependency]: /* this is the important part */ + // (..resolve contributions one way or another..), + // }); + // }, + // }, + // }); + // + // And how you might work that into a composition: + // + // static Track[Thing.getPropertyDescriptors].coverArtists = + // Thing.composite.from([ + // Track.composite.doSomethingWhichMightEarlyExit(), + // Thing.composite.withResolvedContribs({ + // from: 'coverArtistContribsByRef', + // to: '#coverArtistContribs', + // }), + // + // { + // flags: {expose: true}, + // expose: { + // dependencies: ['#coverArtistContribs'], + // compute({'#coverArtistContribs': coverArtistContribs}) { + // return coverArtistContribs.map(({who}) => who); + // }, + // }, + // }, + // ]); + // + // One last note! A super common code pattern when creating more complex + // compositions is to have several steps which *only* expose and compose. + // As a syntax shortcut, you can skip the outer section. It's basically + // like writing out just the {expose: {...}} part. Remember that this + // indicates that the step you're defining is compositional, so you have + // to specify the flags manually for the base, even if this property isn't + // going to get an {update: true} flag. + // + // == Cache-safe dependency names: == + // + // [Disclosure: The caching engine hasn't actually been implemented yet. + // As such, this section is subject to change, and simply provides sound + // forward-facing advice and interfaces.] + // + // It's a good idea to write individual compositional steps in such a way + // that they're "cache-safe" - meaning the same input (dependency) values + // will always result in the same output (continuation or early exit). + // + // In order to facilitate this, compositional step descriptors may specify + // unique `mapDependencies`, `mapContinuation`, and `options` values. + // + // Consider the `withResolvedContribs` example adjusted to make use of + // two of these options below: + // + // static Thing.composite.withResolvedContribs = ({ + // from: contribsByRefDependency, + // to: outputDependency, + // }) => ({ + // flags: {expose: true, compose: true}, + // expose: { + // dependencies: ['artistData'], + // mapDependencies: {contribsByRef: contribsByRefDependency}, + // mapContinuation: {outputDependency}, + // compute({ + // contribsByRef, /* no longer in square brackets */ + // artistData, + // }, continuation) { + // if (!artistData) return null; + // return continuation({ + // outputDependency: /* no longer in square brackets */ + // (..resolve contributions one way or another..), + // }); + // }, + // }, + // }); + // + // With a little destructuring and restructuring JavaScript sugar, the + // above can be simplified some more: + // + // static Thing.composite.withResolvedContribs = ({from, to}) => ({ + // flags: {expose: true, compose: true}, + // expose: { + // dependencies: ['artistData'], + // mapDependencies: {from}, + // mapContinuation: {to}, + // compute({artistData, from: contribsByRef}, continuation) { + // if (!artistData) return null; + // return continuation({ + // to: (..resolve contributions one way or another..), + // }); + // }, + // }, + // }); + // + // These two properties let you separate the name-mapping behavior (for + // dependencies and the continuation) from the main body of the compute + // function. That means the compute function will *always* get inputs in + // the same form (dependencies 'artistData' and 'from' above), and will + // *always* provide its output in the same form (early return or 'to'). + // + // Thanks to that, this `compute` function is cache-safe! Its outputs can + // be cached corresponding to each set of mapped inputs. So it won't matter + // whether the `from` dependency is named `coverArtistContribsByRef` or + // `contributorContribsByRef` or something else - the compute function + // doesn't care, and only expects that value to be provided via its `from` + // argument. Likewise, it doesn't matter if the output should be sent to + // '#coverArtistContribs` or `#contributorContribs` or some other name; + // the mapping is handled automatically outside, and compute will always + // output its value to the continuation's `to`. + // + // Note that `mapDependencies` and `mapContinuation` should be objects of + // the same "shape" each run - that is, the values will change depending on + // outside context, but the keys are always the same. You shouldn't use + // `mapDependencies` to dynamically select more or fewer dependencies. + // If you need to dynamically select a range of dependencies, just specify + // them in the `dependencies` array like usual. The caching engine will + // understand that differently named `dependencies` indicate separate + // input-output caches should be used. + // + // The 'options' property makes it possible to specify external arguments + // that fundamentally change the behavior of the `compute` function, while + // still remaining cache-safe. It indicates that the caching engine should + // use a completely different input-to-output cache for each permutation + // of the 'options' values. This way, those functions are still cacheable + // at all; they'll just be cached separately for each set of option values. + // Values on the 'options' property will always be provided in compute's + // dependencies under '#options' (to avoid name conflicts with other + // dependencies). + // + // == To compute or to transform: == + // + // A compositional step can work directly on a property's stored update + // value, transforming it in place and either early exiting with it or + // passing it on (via continuation) to the next item(s) in the + // compositional step list. (If needed, these can provide dependencies + // the same way as compute functions too - just pass that object after + // the updated (or same) transform value in your call to continuation().) + // + // But in order to make them more versatile, compositional steps have an + // extra trick up their sleeve. If a compositional step implements compute + // and *not* transform, it can still be used in a composition targeting a + // property which updates! These retain their full dependency-providing and + // early exit functionality - they just won't be provided the update value. + // If a compute-implementing step returns its continuation, then whichever + // later step (or the base) next implements transform() will receive the + // update value that had so far been running - as well as any dependencies + // the compute() step returned, of course! + // + // Please note that a compositional step which transforms *should not* + // specify, in its flags, {update: true}. Just provide the transform() + // function in its expose descriptor; it will be automatically detected + // and used when appropriate. + // + // It's actually possible for a step to specify both transform and compute, + // in which case the transform() implementation will only be selected if + // the composition's base is {update: true}. It's not exactly known why you + // would want to specify unique-but-related transform and compute behavior, + // but the basic possibility was too cool to skip out on. + // + // == Nesting compositions: == + // + // Compositional steps are so convenient that you just might want to bundle + // them together, and form a whole new step-shaped unit of its own! + // + // In order to allow for this while helping to ensure internal dependencies + // remain neatly isolated from the composition which nests your bundle, + // the Thing.composite.from() function will accept and adapt to a base that + // specifies the {compose: true} flag, just like the steps preceding it. + // + // The continuation function that gets provided to the base will be mildly + // special - after all, nothing follows the base within the composition's + // own list! Instead of appending dependencies alongside any previously + // provided ones to be available to the next step, the base's continuation + // function should be used to define "exports" of the composition as a + // whole. It's similar to the usual behavior of the continuation, just + // expanded to the scope of the composition instead of following steps. + // + // For example, suppose your composition (which you expect to include in + // other compositions) brings about several private, hash-prefixed + // dependencies to contribute to its own results. Those dependencies won't + // end up "bleeding" into the dependency list of whichever composition is + // nesting this one - they will totally disappear once all the steps in + // the nested composition have finished up. + // + // To "export" the results of processing all those dependencies (provided + // that's something you want to do and this composition isn't used purely + // for a conditional early-exit), you'll want to define them in the + // continuation passed to the base. (Customarily, those should start with + // a hash just like the exports from any other compositional step; they're + // still dynamically provided dependencies!) + // + // Another way to "export" dependencies is by using calling *any* step's + // `continuation.raise()` function. This is sort of like early exiting, + // but instead of quitting out the whole entire property, it will just + // break out of the current, nested composition's list of steps, acting + // as though the composition had finished naturally. The dependencies + // passed to `raise` will be the ones which get exported. + // + // Since `raise` is another way to export dependencies, if you're using + // dynamic export names, you should specify `mapContinuation` on the step + // calling `continuation.raise` as well. + // + // An important note on `mapDependencies` here: A nested composition gets + // free access to all the ordinary properties defined on the thing it's + // working on, but if you want it to depend on *private* dependencies - + // ones prefixed with '#' - which were provided by some other compositional + // step preceding wherever this one gets nested, then you *have* to use + // `mapDependencies` to gain access. Check out the section on "cache-safe + // dependency names" for information on this syntax! + // + // Also - on rare occasion - you might want to make a reusable composition + // that itself causes the composition *it's* nested in to raise. If that's + // the case, give `composition.raiseAbove()` a go! This effectively means + // kicking out of *two* layers of nested composition - the one including + // the step with the `raiseAbove` call, and the composition which that one + // is nested within. You don't need to use `raiseAbove` if the reusable + // utility function just returns a single compositional step, but if you + // want to make use of other compositional steps, it gives you access to + // the same conditional-raise capabilities. + // + // Have some syntax sugar! Since nested compositions are defined by having + // the base be {compose: true}, the composition will infer as much if you + // don't specifying the base's flags at all. Simply use the same shorthand + // syntax as for other compositional steps, and it'll work out cleanly! + // + from(firstArg, secondArg) { + const debug = fn => { + if (Thing.composite.from.debug === true) { + const label = + (annotation + ? color.dim(`[composite: ${annotation}]`) + : color.dim(`[composite]`)); + const result = fn(); + if (Array.isArray(result)) { + console.log(label, ...result.map(value => + (typeof value === 'object' + ? inspect(value, {depth: 0, colors: true, compact: true, breakLength: Infinity}) + : value))); + } else { + console.log(label, result); + } + } + }; + + let annotation, composition; + if (typeof firstArg === 'string') { + [annotation, composition] = [firstArg, secondArg]; + } else { + [annotation, composition] = [null, firstArg]; + } + + const base = composition.at(-1); + const steps = composition.slice(); + + const aggregate = openAggregate({ + message: + `Errors preparing Thing.composite.from() composition` + + (annotation ? ` (${annotation})` : ''), + }); + + const baseExposes = + (base.flags + ? base.flags.expose + : true); + + const baseUpdates = + (base.flags + ? base.flags.update + : false); + + const baseComposes = + (base.flags + ? base.flags.compose + : true); + + if (!baseExposes) { + aggregate.push(new TypeError(`All steps, including base, must expose`)); + } + + const exposeDependencies = new Set(); + + let anyStepsCompute = false; + let anyStepsTransform = false; + + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + const isBase = i === steps.length - 1; + const message = + `Errors in step #${i + 1}` + + (isBase ? ` (base)` : ``) + + (step.annotation ? ` (${step.annotation})` : ``); + + aggregate.nest({message}, ({push}) => { + if (step.flags) { + let flagsErrored = false; + + if (!step.flags.compose && !isBase) { + push(new TypeError(`All steps but base must compose`)); + flagsErrored = true; + } + + if (!step.flags.expose) { + push(new TypeError(`All steps must expose`)); + flagsErrored = true; + } + + if (flagsErrored) { + return; + } + } + + const expose = + (step.flags + ? step.expose + : step); + + const stepComputes = !!expose.compute; + const stepTransforms = !!expose.transform; + + if (!stepComputes && !stepTransforms) { + push(new TypeError(`Steps must provide compute or transform (or both)`)); + return; + } + + if ( + stepTransforms && !stepComputes && + !baseUpdates && !baseComposes + ) { + push(new TypeError(`Steps which only transform can't be composed with a non-updating base`)); + return; + } + + if (stepComputes) { + anyStepsCompute = true; + } + + if (stepTransforms) { + anyStepsTransform = true; + } + + // Unmapped dependencies are exposed on the final composition only if + // they're "public", i.e. pointing to update values of other properties + // on the CacheableObject. + for (const dependency of expose.dependencies ?? []) { + if (typeof dependency === 'string' && dependency.startsWith('#')) { + continue; + } + + exposeDependencies.add(dependency); + } + + // Mapped dependencies are always exposed on the final composition. + // These are explicitly for reading values which are named outside of + // the current compositional step. + for (const dependency of Object.values(expose.mapDependencies ?? {})) { + exposeDependencies.add(dependency); + } + }); + } + + if (!baseComposes) { + if (baseUpdates) { + if (!anyStepsTransform) { + push(new TypeError(`Expected at least one step to transform`)); + } + } else { + if (!anyStepsCompute) { + push(new TypeError(`Expected at least one step to compute`)); + } + } + } + + aggregate.close(); + + const constructedDescriptor = {}; + + if (annotation) { + constructedDescriptor.annotation = annotation; + } + + constructedDescriptor.flags = { + update: baseUpdates, + expose: baseExposes, + compose: baseComposes, + }; + + if (baseUpdates) { + constructedDescriptor.update = base.update; + } + + if (baseExposes) { + const expose = constructedDescriptor.expose = {}; + expose.dependencies = Array.from(exposeDependencies); + + const continuationSymbol = Symbol('continuation symbol'); + const noTransformSymbol = Symbol('no-transform symbol'); + + function _filterDependencies(availableDependencies, { + dependencies, + mapDependencies, + options, + }) { + const filteredDependencies = + (dependencies + ? filterProperties(availableDependencies, dependencies) + : {}); + + if (mapDependencies) { + for (const [to, from] of Object.entries(mapDependencies)) { + filteredDependencies[to] = availableDependencies[from] ?? null; + } + } + + if (options) { + filteredDependencies['#options'] = options; + } + + return filteredDependencies; + } + + function _assignDependencies(continuationAssignment, {mapContinuation}) { + if (!mapContinuation) { + return continuationAssignment; + } + + const assignDependencies = {}; + + for (const [from, to] of Object.entries(mapContinuation)) { + assignDependencies[to] = continuationAssignment[from] ?? null; + } + + return assignDependencies; + } + + function _prepareContinuation(callingTransformForThisStep) { + const continuationStorage = { + returnedWith: null, + providedDependencies: undefined, + providedValue: undefined, + }; + + const continuation = + (callingTransformForThisStep + ? (providedValue, providedDependencies = null) => { + continuationStorage.returnedWith = 'continuation'; + continuationStorage.providedDependencies = providedDependencies; + continuationStorage.providedValue = providedValue; + return continuationSymbol; + } + : (providedDependencies = null) => { + continuationStorage.returnedWith = 'continuation'; + continuationStorage.providedDependencies = providedDependencies; + return continuationSymbol; + }); + + continuation.exit = (providedValue) => { + continuationStorage.returnedWith = 'exit'; + continuationStorage.providedValue = providedValue; + return continuationSymbol; + }; + + if (baseComposes) { + const makeRaiseLike = returnWith => + (callingTransformForThisStep + ? (providedValue, providedDependencies = null) => { + continuationStorage.returnedWith = returnWith; + continuationStorage.providedDependencies = providedDependencies; + continuationStorage.providedValue = providedValue; + return continuationSymbol; + } + : (providedDependencies = null) => { + continuationStorage.returnedWith = returnWith; + continuationStorage.providedDependencies = providedDependencies; + return continuationSymbol; + }); + + continuation.raise = makeRaiseLike('raise'); + continuation.raiseAbove = makeRaiseLike('raiseAbove'); + } + + return {continuation, continuationStorage}; + } + + function _computeOrTransform(initialValue, initialDependencies, continuationIfApplicable) { + const expectingTransform = initialValue !== noTransformSymbol; + + let valueSoFar = + (expectingTransform + ? initialValue + : undefined); + + const availableDependencies = {...initialDependencies}; + + if (expectingTransform) { + debug(() => [color.bright(`begin composition - transforming from:`), initialValue]); + } else { + debug(() => color.bright(`begin composition - not transforming`)); + } + + for (let i = 0; i < steps.length; i++) { + const step = steps[i]; + const isBase = i === steps.length - 1; + + debug(() => [ + `step #${i+1}` + + (isBase + ? ` (base):` + : ` of ${steps.length}:`), + step]); + + const expose = + (step.flags + ? step.expose + : step); + + const callingTransformForThisStep = + expectingTransform && expose.transform; + + const filteredDependencies = _filterDependencies(availableDependencies, expose); + const {continuation, continuationStorage} = _prepareContinuation(callingTransformForThisStep); + + debug(() => [ + `step #${i+1} - ${callingTransformForThisStep ? 'transform' : 'compute'}`, + `with dependencies:`, filteredDependencies]); + + const result = + (callingTransformForThisStep + ? expose.transform(valueSoFar, filteredDependencies, continuation) + : expose.compute(filteredDependencies, continuation)); + + if (result !== continuationSymbol) { + debug(() => [`step #${i+1} - result: exit (inferred) ->`, result]); + + if (baseComposes) { + throw new TypeError(`Inferred early-exit is disallowed in nested compositions`); + } + + debug(() => color.bright(`end composition - exit (inferred)`)); + + return result; + } + + const {returnedWith} = continuationStorage; + + if (returnedWith === 'exit') { + const {providedValue} = continuationStorage; + + debug(() => [`step #${i+1} - result: exit (explicit) ->`, providedValue]); + debug(() => color.bright(`end composition - exit (explicit)`)); + + if (baseComposes) { + return continuationIfApplicable.exit(providedValue); + } else { + return providedValue; + } + } + + const {providedValue, providedDependencies} = continuationStorage; + + const continuingWithValue = + (expectingTransform + ? (callingTransformForThisStep + ? providedValue ?? null + : valueSoFar ?? null) + : undefined); + + const continuingWithDependencies = + (providedDependencies + ? _assignDependencies(providedDependencies, expose) + : null); + + const continuationArgs = []; + if (continuingWithValue !== undefined) continuationArgs.push(continuingWithValue); + if (continuingWithDependencies !== null) continuationArgs.push(continuingWithDependencies); + + debug(() => { + const base = `step #${i+1} - result: ` + returnedWith; + const parts = []; + + if (callingTransformForThisStep) { + if (continuingWithValue === undefined) { + parts.push(`(no value)`); + } else { + parts.push(`value:`, providedValue); + } + } + + if (continuingWithDependencies !== null) { + parts.push(`deps:`, continuingWithDependencies); + } else { + parts.push(`(no deps)`); + } + + if (empty(parts)) { + return base; + } else { + return [base + ' ->', ...parts]; + } + }); + + switch (returnedWith) { + case 'raise': + debug(() => + (isBase + ? color.bright(`end composition - raise (base: explicit)`) + : color.bright(`end composition - raise`))); + return continuationIfApplicable(...continuationArgs); + + case 'raiseAbove': + debug(() => color.bright(`end composition - raiseAbove`)); + return continuationIfApplicable.raise(...continuationArgs); + + case 'continuation': + if (isBase) { + debug(() => color.bright(`end composition - raise (inferred)`)); + return continuationIfApplicable(...continuationArgs); + } else { + Object.assign(availableDependencies, continuingWithDependencies); + break; + } + } + } + } + + const transformFn = + (value, initialDependencies, continuationIfApplicable) => + _computeOrTransform(value, initialDependencies, continuationIfApplicable); + + const computeFn = + (initialDependencies, continuationIfApplicable) => + _computeOrTransform(noTransformSymbol, initialDependencies, continuationIfApplicable); + + if (baseComposes) { + if (anyStepsTransform) expose.transform = transformFn; + if (anyStepsCompute) expose.compute = computeFn; + } else if (baseUpdates) { + expose.transform = transformFn; + } else { + expose.compute = computeFn; + } + } + + return constructedDescriptor; + }, + + // Evaluates a function with composite debugging enabled, turns debugging + // off again, and returns the result of the function. This is mostly syntax + // sugar, but also helps avoid unit tests avoid accidentally printing debug + // info for a bunch of unrelated composites (due to property enumeration + // when displaying an unexpected result). Use as so: + // + // Without debugging: + // t.same(thing.someProp, value) + // + // With debugging: + // t.same(Thing.composite.debug(() => thing.someProp), value) + // + debug(fn) { + Thing.composite.from.debug = true; + const value = fn(); + Thing.composite.from.debug = false; + return value; + }, + + // -- Compositional steps for compositions to nest -- + + // Provides dependencies exactly as they are (or null if not defined) to the + // continuation. Although this can *technically* be used to alias existing + // dependencies to some other name within the middle of a composition, it's + // intended to be used only as a composition's base - doing so makes the + // composition as a whole suitable as a step in some other composition, + // providing the listed (internal) dependencies to later steps just like + // other compositional steps. + export(mapping) { + const mappingEntries = Object.entries(mapping); + + return { + annotation: `Thing.composite.export`, + flags: {expose: true, compose: true}, + + expose: { + options: {mappingEntries}, + dependencies: Object.values(mapping), + + compute({'#options': {mappingEntries}, ...dependencies}, continuation) { + const exports = {}; + + // Note: This is slightly different behavior from filterProperties, + // as defined in sugar.js, which doesn't fall back to null for + // properties which don't exist on the original object. + for (const [exportKey, dependencyKey] of mappingEntries) { + exports[exportKey] = + (Object.hasOwn(dependencies, dependencyKey) + ? dependencies[dependencyKey] + : null); + } + + return continuation.raise(exports); + } + }, + }; + }, + + // -- Compositional steps for top-level property descriptors -- + + // Exposes a dependency exactly as it is; this is typically the base of a + // composition which was created to serve as one property's descriptor. + // Since this serves as a base, specify a value for {update} to indicate + // that the property as a whole updates (and some previous compositional + // step works with that update value). Set {update: true} to only enable + // the update flag, or set update to an object to specify a descriptor + // (e.g. for custom value validation). + // + // Please note that this *doesn't* verify that the dependency exists, so + // if you provide the wrong name or it hasn't been set by a previous + // compositional step, the property will be exposed as undefined instead + // of null. + // + exposeDependency(dependency, { + update = false, + } = {}) { + return { + annotation: `Thing.composite.exposeDependency`, + flags: {expose: true, update: !!update}, + + expose: { + mapDependencies: {dependency}, + compute: ({dependency}) => dependency, + }, + + update: + (typeof update === 'object' + ? update + : null), + }; + }, + + // Exposes a constant value exactly as it is; like exposeDependency, this + // is typically the base of a composition serving as a particular property + // descriptor. It generally follows steps which will conditionally early + // exit with some other value, with the exposeConstant base serving as the + // fallback default value. Like exposeDependency, set {update} to true or + // an object to indicate that the property as a whole updates. + exposeConstant(value, { + update = false, + } = {}) { + return { + annotation: `Thing.composite.exposeConstant`, + flags: {expose: true, update: !!update}, + + expose: { + options: {value}, + compute: ({'#options': {value}}) => value, + }, + + update: + (typeof update === 'object' + ? update + : null), + }; + }, + + // Checks the availability of a dependency or the update value and provides + // the result to later steps under '#availability' (by default). This is + // mainly intended for use by the more specific utilities, which you should + // consider using instead. Customize {mode} to select one of these modes, + // or leave unset and default to 'null': + // + // * 'null': Check that the value isn't null. + // * 'empty': Check that the value is neither null nor an empty array. + // * 'falsy': Check that the value isn't false when treated as a boolean + // (nor an empty array). Keep in mind this will also be false + // for values like zero and the empty string! + // + withResultOfAvailabilityCheck({ + fromUpdateValue, + fromDependency, + mode = 'null', + to = '#availability', + }) { + if (!['null', 'empty', 'falsy'].includes(mode)) { + throw new TypeError(`Expected mode to be null, empty, or falsy`); + } + + if (fromUpdateValue && fromDependency) { + throw new TypeError(`Don't provide both fromUpdateValue and fromDependency`); + } + + if (!fromUpdateValue && !fromDependency) { + throw new TypeError(`Missing dependency name (or fromUpdateValue)`); + } + + const checkAvailability = (value, mode) => { + switch (mode) { + case 'null': return value !== null; + case 'empty': return !empty(value); + case 'falsy': return !!value && (!Array.isArray(value) || !empty(value)); + default: return false; + } + }; + + if (fromDependency) { + return { + annotation: `Thing.composite.withResultOfAvailabilityCheck.fromDependency`, + flags: {expose: true, compose: true}, + expose: { + mapDependencies: {from: fromDependency}, + mapContinuation: {to}, + options: {mode}, + compute: ({from, '#options': {mode}}, continuation) => + continuation({to: checkAvailability(from, mode)}), + }, + }; + } else { + return { + annotation: `Thing.composite.withResultOfAvailabilityCheck.fromUpdateValue`, + flags: {expose: true, compose: true}, + expose: { + mapContinuation: {to}, + options: {mode}, + transform: (value, {'#options': {mode}}, continuation) => + continuation(value, {to: checkAvailability(value, mode)}), + }, + }; + } + }, + + // Exposes a dependency as it is, or continues if it's unavailable. + // See withResultOfAvailabilityCheck for {mode} options! + exposeDependencyOrContinue(dependency, { + mode = 'null', + } = {}) { + return Thing.composite.from(`Thing.composite.exposeDependencyOrContinue`, [ + Thing.composite.withResultOfAvailabilityCheck({ + fromDependency: dependency, + mode, + }), + + { + dependencies: ['#availability'], + compute: ({'#availability': availability}, continuation) => + (availability + ? continuation() + : continuation.raise()), + }, + + { + mapDependencies: {dependency}, + compute: ({dependency}, continuation) => + continuation.exit(dependency), + }, + ]); + }, + + // Exposes the update value of an {update: true} property as it is, + // or continues if it's unavailable. See withResultOfAvailabilityCheck + // for {mode} options! + exposeUpdateValueOrContinue({ + mode = 'null', + } = {}) { + return Thing.composite.from(`Thing.composite.exposeUpdateValueOrContinue`, [ + Thing.composite.withResultOfAvailabilityCheck({ + fromUpdateValue: true, + mode, + }), + + { + dependencies: ['#availability'], + compute: ({'#availability': availability}, continuation) => + (availability + ? continuation() + : continuation.raise()), + }, + + { + transform: (value, {}, continuation) => + continuation.exit(value), + }, + ]); + }, + + // Early exits if an availability check fails. + // This is for internal use only - use `earlyExitWithoutDependency` or + // `earlyExitWIthoutUpdateValue` instead. + earlyExitIfAvailabilityCheckFailed({ + availability = '#availability', + value = null, + }) { + return Thing.composite.from(`Thing.composite.earlyExitIfAvailabilityCheckFailed`, [ + { + mapDependencies: {availability}, + compute: ({availability}, continuation) => + (availability + ? continuation.raise() + : continuation()), + }, + + { + options: {value}, + compute: ({'#options': {value}}, continuation) => + continuation.exit(value), + }, + ]); + }, + + // Early exits if a dependency isn't available. + // See withResultOfAvailabilityCheck for {mode} options! + earlyExitWithoutDependency(dependency, { + mode = 'null', + value = null, + } = {}) { + return Thing.composite.from(`Thing.composite.earlyExitWithoutDependency`, [ + Thing.composite.withResultOfAvailabilityCheck({fromDependency: dependency, mode}), + Thing.composite.earlyExitIfAvailabilityCheckFailed({value}), + ]); + }, + + // Early exits if this property's update value isn't available. + // See withResultOfAvailabilityCheck for {mode} options! + earlyExitWithoutUpdateValue({ + mode = 'null', + value = null, + } = {}) { + return Thing.composite.from(`Thing.composite.earlyExitWithoutDependency`, [ + Thing.composite.withResultOfAvailabilityCheck({fromUpdateValue: true, mode}), + Thing.composite.earlyExitIfAvailabilityCheckFailed({value}), + ]); + }, + + // Raises if a dependency isn't available. + // See withResultOfAvailabilityCheck for {mode} options! + raiseWithoutDependency(dependency, { + mode = 'null', + map = {}, + raise = {}, + } = {}) { + return Thing.composite.from(`Thing.composite.raiseWithoutDependency`, [ + Thing.composite.withResultOfAvailabilityCheck({fromDependency: dependency, mode}), + + { + dependencies: ['#availability'], + compute: ({'#availability': availability}, continuation) => + (availability + ? continuation.raise() + : continuation()), + }, + + { + options: {raise}, + mapContinuation: map, + compute: ({'#options': {raise}}, continuation) => + continuation.raiseAbove(raise), + }, + ]); + }, + + // Raises if this property's update value isn't available. + // See withResultOfAvailabilityCheck for {mode} options! + raiseWithoutUpdateValue({ + mode = 'null', + map = {}, + raise = {}, + } = {}) { + return Thing.composite.from(`Thing.composite.raiseWithoutUpdateValue`, [ + Thing.composite.withResultOfAvailabilityCheck({fromUpdateValue: true, mode}), + + { + mapDependencies: {availability}, + compute: ({availability}, continuation) => + (availability + ? continuation.raise() + : continuation()), + }, + + { + options: {raise}, + mapContinuation: map, + compute: ({'#options': {raise}}, continuation) => + continuation.raiseAbove(raise), + }, + ]); + }, + + // -- Compositional steps for processing data -- + + // Resolves the contribsByRef contained in the provided dependency, + // providing (named by the second argument) the result. "Resolving" + // means mapping the "who" reference of each contribution to an artist + // object, and filtering out those whose "who" doesn't match any artist. + withResolvedContribs({from, to}) { + return { + annotation: `Thing.composite.withResolvedContribs`, + flags: {expose: true, compose: true}, + + expose: { + dependencies: ['artistData'], + mapDependencies: {from}, + mapContinuation: {to}, + compute: ({artistData, from}, continuation) => + continuation({ + to: Thing.findArtistsFromContribs(from, artistData), + }), + }, + }; + }, + + // Resolves a reference by using the provided find function to match it + // within the provided thingData dependency. This will early exit if the + // data dependency is null, or, if earlyExitIfNotFound is set to true, + // if the find function doesn't match anything for the reference. + // Otherwise, the data object is provided on the output dependency; + // or null, if the reference doesn't match anything or itself was null + // to begin with. + withResolvedReference({ + ref, + data, + to, + find: findFunction, + earlyExitIfNotFound = false, + }) { + return Thing.composite.from(`Thing.composite.withResolvedReference`, [ + Thing.composite.raiseWithoutDependency(ref, {map: {to}, raise: {to: null}}), + Thing.composite.earlyExitWithoutDependency(data), + + { + options: {findFunction, earlyExitIfNotFound}, + mapDependencies: {ref, data}, + mapContinuation: {match: to}, + + compute({ref, data, '#options': {findFunction, earlyExitIfNotFound}}, continuation) { + const match = findFunction(ref, data, {mode: 'quiet'}); + + if (match === null && earlyExitIfNotFound) { + return continuation.exit(null); + } + + return continuation.raise({match}); + }, + }, + ]); + }, + + // Check out the info on Thing.common.reverseReferenceList! + // This is its composable form. + withReverseReferenceList({ + data, + to = '#reverseReferenceList', + refList: refListProperty, + }) { + return Thing.composite.from(`Thing.common.reverseReferenceList`, [ + Thing.composite.earlyExitWithoutDependency(data, {value: []}), + + { + dependencies: ['this'], + mapDependencies: {data}, + mapContinuation: {to}, + options: {refListProperty}, + + compute: ({this: thisThing, data, '#options': {refListProperty}}, continuation) => + continuation({ + to: data.filter(thing => thing[refListProperty].includes(thisThing)), + }), + }, + ]); + }, + }; } diff --git a/src/data/things/track.js b/src/data/things/track.js index e176acb4..bf56a6dd 100644 --- a/src/data/things/track.js +++ b/src/data/things/track.js @@ -44,60 +44,72 @@ export class Track extends Thing { sampledTracksByRef: Thing.common.referenceList(Track), artTagsByRef: Thing.common.referenceList(ArtTag), - hasCoverArt: { - flags: {update: true, expose: true}, - - update: { - validate(value) { - if (value !== false) { - throw new TypeError(`Expected false or null`); - } - - return true; - }, - }, - - expose: { - dependencies: ['albumData', 'coverArtistContribsByRef'], - transform: (hasCoverArt, { - albumData, - coverArtistContribsByRef, - [Track.instance]: track, - }) => - Track.hasCoverArt( - track, - albumData, - coverArtistContribsByRef, - hasCoverArt - ), + color: Thing.composite.from(`Track.color`, [ + Thing.composite.exposeUpdateValueOrContinue(), + Track.composite.withContainingTrackSection({earlyExitIfNotFound: false}), + + { + dependencies: ['#trackSection'], + compute: ({'#trackSection': trackSection}, continuation) => + // Album.trackSections guarantees the track section will have a + // color property (inheriting from the album's own color), but only + // if it's actually present! Color will be inherited directly from + // album otherwise. + (trackSection + ? trackSection.color + : continuation()), }, - }, - coverArtFileExtension: { - flags: {update: true, expose: true}, - - update: {validate: isFileExtension}, - - expose: { - dependencies: ['albumData', 'coverArtistContribsByRef'], - transform: (coverArtFileExtension, { - albumData, - coverArtistContribsByRef, - hasCoverArt, - [Track.instance]: track, - }) => - coverArtFileExtension ?? - (Track.hasCoverArt( - track, - albumData, - coverArtistContribsByRef, - hasCoverArt - ) - ? Track.findAlbum(track, albumData)?.trackCoverArtFileExtension - : Track.findAlbum(track, albumData)?.coverArtFileExtension) ?? - 'jpg', - }, - }, + Track.composite.withAlbumProperty('color'), + Thing.composite.exposeDependency('#album.color', { + update: {validate: isColor}, + }), + ]), + + // Disables presenting the track as though it has its own unique artwork. + // This flag should only be used in select circumstances, i.e. to override + // an album's trackCoverArtists. This flag supercedes that property, as well + // as the track's own coverArtists. + disableUniqueCoverArt: Thing.common.flag(), + + // File extension for track's corresponding media file. This represents the + // track's unique cover artwork, if any, and does not inherit the extension + // of the album's main artwork. It does inherit trackCoverArtFileExtension, + // if present on the album. + coverArtFileExtension: Thing.composite.from(`Track.coverArtFileExtension`, [ + // No cover art file extension if the track doesn't have unique artwork + // in the first place. + Track.composite.withHasUniqueCoverArt(), + Thing.composite.earlyExitWithoutDependency('#hasUniqueCoverArt', {mode: 'falsy'}), + + // Expose custom coverArtFileExtension update value first. + Thing.composite.exposeUpdateValueOrContinue(), + + // Expose album's trackCoverArtFileExtension if no update value set. + Track.composite.withAlbumProperty('trackCoverArtFileExtension'), + Thing.composite.exposeDependencyOrContinue('#album.trackCoverArtFileExtension'), + + // Fallback to 'jpg'. + Thing.composite.exposeConstant('jpg', { + update: {validate: isFileExtension}, + }), + ]), + + // Date of cover art release. Like coverArtFileExtension, this represents + // only the track's own unique cover artwork, if any. This exposes only as + // the track's own coverArtDate or its album's trackArtDate, so if neither + // is specified, this value is null. + coverArtDate: Thing.composite.from(`Track.coverArtDate`, [ + Track.composite.withHasUniqueCoverArt(), + Thing.composite.earlyExitWithoutDependency('#hasUniqueCoverArt', {mode: 'falsy'}), + + Thing.composite.exposeUpdateValueOrContinue(), + + Track.composite.withAlbumProperty('trackArtDate'), + Thing.composite.exposeDependency('#album.trackArtDate', { + update: {validate: isDate}, + }), + ]), originalReleaseTrackByRef: Thing.common.singleReference(Track), @@ -121,15 +133,10 @@ export class Track extends Thing { commentatorArtists: Thing.common.commentatorArtists(), - album: { - flags: {expose: true}, - - expose: { - dependencies: ['albumData'], - compute: ({[Track.instance]: track, albumData}) => - albumData?.find((album) => album.tracks.includes(track)) ?? null, - }, - }, + album: Thing.composite.from(`Track.album`, [ + Track.composite.withAlbum(), + Thing.composite.exposeDependency('#album'), + ]), // Note - this is an internal property used only to help identify a track. // It should not be assumed in general that the album and dataSourceAlbum match @@ -138,158 +145,120 @@ export class Track extends Thing { // not generally relevant information). It's also not guaranteed that // dataSourceAlbum is available (depending on the Track creator to optionally // provide dataSourceAlbumByRef). - dataSourceAlbum: Thing.common.dynamicThingFromSingleReference( - 'dataSourceAlbumByRef', - 'albumData', - find.album - ), - - date: { - flags: {expose: true}, - - expose: { - dependencies: ['albumData', 'dateFirstReleased'], - compute: ({albumData, dateFirstReleased, [Track.instance]: track}) => - dateFirstReleased ?? Track.findAlbum(track, albumData)?.date ?? null, + dataSourceAlbum: Thing.common.dynamicThingFromSingleReference('dataSourceAlbumByRef', 'albumData', find.album), + + date: Thing.composite.from(`Track.date`, [ + Thing.composite.exposeDependencyOrContinue('dateFirstReleased'), + Track.composite.withAlbumProperty('date'), + Thing.composite.exposeDependency('#album.date'), + ]), + + // Whether or not the track has "unique" cover artwork - a cover which is + // specifically associated with this track in particular, rather than with + // the track's album as a whole. This is typically used to select between + // displaying the track artwork and a fallback, such as the album artwork + // or a placeholder. (This property is named hasUniqueCoverArt instead of + // the usual hasCoverArt to emphasize that it does not inherit from the + // album.) + hasUniqueCoverArt: Thing.composite.from(`Track.hasUniqueCoverArt`, [ + Track.composite.withHasUniqueCoverArt(), + Thing.composite.exposeDependency('#hasUniqueCoverArt'), + ]), + + originalReleaseTrack: Thing.composite.from(`Track.originalReleaseTrack`, [ + Track.composite.withOriginalRelease(), + Thing.composite.exposeDependency('#originalRelease'), + ]), + + otherReleases: Thing.composite.from(`Track.otherReleases`, [ + Thing.composite.earlyExitWithoutDependency('trackData', {mode: 'empty'}), + Track.composite.withOriginalRelease({selfIfOriginal: true}), + + { + flags: {expose: true}, + expose: { + dependencies: ['this', 'trackData', '#originalRelease'], + compute: ({ + this: thisTrack, + trackData, + '#originalRelease': originalRelease, + }) => + (originalRelease === thisTrack + ? [] + : [originalRelease]) + .concat(trackData.filter(track => + track !== originalRelease && + track !== thisTrack && + track.originalReleaseTrack === originalRelease)), + }, }, - }, - - color: { - flags: {update: true, expose: true}, - - update: {validate: isColor}, - - expose: { - dependencies: ['albumData'], - - transform: (color, {albumData, [Track.instance]: track}) => - color ?? - Track.findAlbum(track, albumData) - ?.trackSections.find(({tracks}) => tracks.includes(track)) - ?.color ?? null, + ]), + + artistContribs: Thing.composite.from(`Track.artistContribs`, [ + Track.composite.inheritFromOriginalRelease({property: 'artistContribs'}), + + Thing.composite.withResolvedContribs({ + from: 'artistContribsByRef', + to: '#artistContribs', + }), + + { + dependencies: ['#artistContribs'], + compute: ({'#artistContribs': contribsFromTrack}, continuation) => + (empty(contribsFromTrack) + ? continuation() + : contribsFromTrack), }, - }, - coverArtDate: { - flags: {update: true, expose: true}, + Track.composite.withAlbumProperty('artistContribs'), + Thing.composite.exposeDependency('#album.artistContribs'), + ]), - update: {validate: isDate}, - - expose: { - dependencies: [ - 'albumData', - 'coverArtistContribsByRef', - 'dateFirstReleased', - 'hasCoverArt', - ], - transform: (coverArtDate, { - albumData, - coverArtistContribsByRef, - dateFirstReleased, - hasCoverArt, - [Track.instance]: track, - }) => - (Track.hasCoverArt(track, albumData, coverArtistContribsByRef, hasCoverArt) - ? coverArtDate ?? - dateFirstReleased ?? - Track.findAlbum(track, albumData)?.trackArtDate ?? - Track.findAlbum(track, albumData)?.date ?? - null - : null), - }, - }, + contributorContribs: Thing.composite.from(`Track.contributorContribs`, [ + Track.composite.inheritFromOriginalRelease({property: 'contributorContribs'}), + Thing.common.dynamicContribs('contributorContribsByRef'), + ]), - hasUniqueCoverArt: { - flags: {expose: true}, - - expose: { - dependencies: ['albumData', 'coverArtistContribsByRef', 'hasCoverArt'], - compute: ({ - albumData, - coverArtistContribsByRef, - hasCoverArt, - [Track.instance]: track, - }) => - Track.hasUniqueCoverArt( - track, - albumData, - coverArtistContribsByRef, - hasCoverArt - ), + // Cover artists aren't inherited from the original release, since it + // typically varies by release and isn't defined by the musical qualities + // of the track. + coverArtistContribs: Thing.composite.from(`Track.coverArtistContribs`, [ + { + dependencies: ['disableUniqueCoverArt'], + compute: ({disableUniqueCoverArt}, continuation) => + (disableUniqueCoverArt + ? null + : continuation()), }, - }, - originalReleaseTrack: Thing.common.dynamicThingFromSingleReference( - 'originalReleaseTrackByRef', - 'trackData', - find.track - ), - - otherReleases: { - flags: {expose: true}, - - expose: { - dependencies: ['originalReleaseTrackByRef', 'trackData'], - - compute: ({ - originalReleaseTrackByRef: t1origRef, - trackData, - [Track.instance]: t1, - }) => { - if (!trackData) { - return []; - } - - const t1orig = find.track(t1origRef, trackData); - - return [ - t1orig, - ...trackData.filter((t2) => { - const {originalReleaseTrack: t2orig} = t2; - return t2 !== t1 && t2orig && (t2orig === t1orig || t2orig === t1); - }), - ].filter(Boolean); - }, + Thing.composite.withResolvedContribs({ + from: 'coverArtistContribsByRef', + to: '#coverArtistContribs', + }), + + { + dependencies: ['#coverArtistContribs'], + compute: ({'#coverArtistContribs': contribsFromTrack}, continuation) => + (empty(contribsFromTrack) + ? continuation() + : contribsFromTrack), }, - }, - artistContribs: - Track.inheritFromOriginalRelease('artistContribs', [], - Thing.common.dynamicInheritContribs( - null, - 'artistContribsByRef', - 'artistContribsByRef', - 'albumData', - Track.findAlbum)), + Track.composite.withAlbumProperty('trackCoverArtistContribs'), + Thing.composite.exposeDependency('#album.trackCoverArtistContribs'), + ]), - contributorContribs: - Track.inheritFromOriginalRelease('contributorContribs', [], - Thing.common.dynamicContribs('contributorContribsByRef')), + referencedTracks: Thing.composite.from(`Track.referencedTracks`, [ + Track.composite.inheritFromOriginalRelease({property: 'referencedTracks'}), + Thing.common.dynamicThingsFromReferenceList('referencedTracksByRef', 'trackData', find.track), + ]), - // Cover artists aren't inherited from the original release, since it - // typically varies by release and isn't defined by the musical qualities - // of the track. - coverArtistContribs: - Thing.common.dynamicInheritContribs( - 'hasCoverArt', - 'coverArtistContribsByRef', - 'trackCoverArtistContribsByRef', - 'albumData', - Track.findAlbum), - - referencedTracks: - Track.inheritFromOriginalRelease('referencedTracks', [], - Thing.common.dynamicThingsFromReferenceList( - 'referencedTracksByRef', - 'trackData', - find.track)), - - sampledTracks: - Track.inheritFromOriginalRelease('sampledTracks', [], - Thing.common.dynamicThingsFromReferenceList( - 'sampledTracksByRef', - 'trackData', - find.track)), + sampledTracks: Thing.composite.from(`Track.sampledTracks`, [ + Track.composite.inheritFromOriginalRelease({property: 'sampledTracks'}), + Thing.common.dynamicThingsFromReferenceList('sampledTracksByRef', 'trackData', find.track), + ]), + + artTags: Thing.common.dynamicThingsFromReferenceList('artTagsByRef', 'artTagData', find.artTag), // Specifically exclude re-releases from this list - while it's useful to // get from a re-release to the tracks it references, re-releases aren't @@ -299,162 +268,321 @@ export class Track extends Thing { // counting the number of times a track has been referenced, for use in // the "Tracks - by Times Referenced" listing page (or other data // processing). - referencedByTracks: { - flags: {expose: true}, - - expose: { - dependencies: ['trackData'], - - compute: ({trackData, [Track.instance]: track}) => - trackData - ? trackData - .filter((t) => !t.originalReleaseTrack) - .filter((t) => t.referencedTracks?.includes(track)) - : [], - }, - }, + referencedByTracks: Track.composite.trackReverseReferenceList('referencedTracks'), // For the same reasoning, exclude re-releases from sampled tracks too. - sampledByTracks: { - flags: {expose: true}, - - expose: { - dependencies: ['trackData'], - - compute: ({trackData, [Track.instance]: track}) => - trackData - ? trackData - .filter((t) => !t.originalReleaseTrack) - .filter((t) => t.sampledTracks?.includes(track)) - : [], - }, - }, - - featuredInFlashes: Thing.common.reverseReferenceList( - 'flashData', - 'featuredTracks' - ), + sampledByTracks: Track.composite.trackReverseReferenceList('sampledTracks'), - artTags: Thing.common.dynamicThingsFromReferenceList( - 'artTagsByRef', - 'artTagData', - find.artTag - ), + featuredInFlashes: Thing.common.reverseReferenceList({ + data: 'flashData', + refList: 'featuredTracks', + }), }); - // This is a quick utility function for now, since the same code is reused in - // several places. Ideally it wouldn't be - we'd just reuse the `album` - // property - but support for that hasn't been coded yet :P - static findAlbum = (track, albumData) => - albumData?.find((album) => album.tracks.includes(track)); - - // Another reused utility function. This one's logic is a bit more complicated. - static hasCoverArt( - track, - albumData, - coverArtistContribsByRef, - hasCoverArt - ) { - if (!empty(coverArtistContribsByRef)) { - return true; - } + static composite = { + // Early exits with a value inherited from the original release, if + // this track is a rerelease, and otherwise continues with no further + // dependencies provided. If allowOverride is true, then the continuation + // will also be called if the original release exposed the requested + // property as null. + inheritFromOriginalRelease({ + property: originalProperty, + allowOverride = false, + }) { + return Thing.composite.from(`Track.composite.inheritFromOriginalRelease`, [ + Track.composite.withOriginalRelease(), + + { + dependencies: ['#originalRelease'], + compute({'#originalRelease': originalRelease}, continuation) { + if (!originalRelease) return continuation.raise(); + + const value = originalRelease[originalProperty]; + if (allowOverride && value === null) return continuation.raise(); + + return continuation.exit(value); + }, + }, + ]); + }, - const album = Track.findAlbum(track, albumData); - if (album && !empty(album.trackCoverArtistContribsByRef)) { - return true; - } + // Gets the track's album. Unless earlyExitIfNotFound is overridden false, + // this will early exit with null in two cases - albumData being missing, + // or not including an album whose .tracks array includes this track. + withAlbum({to = '#album', earlyExitIfNotFound = true} = {}) { + return Thing.composite.from(`Track.composite.withAlbum`, [ + Thing.composite.withResultOfAvailabilityCheck({ + fromDependency: 'albumData', + mode: 'empty', + to: '#albumDataAvailability', + }), + + { + dependencies: ['#albumDataAvailability'], + options: {earlyExitIfNotFound}, + mapContinuation: {to}, + + compute: ({ + '#albumDataAvailability': albumDataAvailability, + '#options': {earlyExitIfNotFound}, + }, continuation) => + (albumDataAvailability + ? continuation() + : (earlyExitIfNotFound + ? continuation.exit(null) + : continuation.raise({to: null}))), + }, - return false; - } + { + dependencies: ['this', 'albumData'], + compute: ({this: track, albumData}, continuation) => + continuation({ + '#album': + albumData.find(album => album.tracks.includes(track)), + }), + }, - static hasUniqueCoverArt( - track, - albumData, - coverArtistContribsByRef, - hasCoverArt - ) { - if (!empty(coverArtistContribsByRef)) { - return true; - } + { + dependencies: ['#album'], + options: {earlyExitIfNotFound}, + mapContinuation: {to}, + compute: ({ + '#album': album, + '#options': {earlyExitIfNotFound}, + }, continuation) => + (album + ? continuation.raise({to: album}) + : (earlyExitIfNotFound + ? continuation.exit(null) + : continuation.raise({to: album}))), + }, + ]); + }, - if (hasCoverArt === false) { - return false; - } + // Gets a single property from this track's album, providing it as the same + // property name prefixed with '#album.' (by default). If the track's album + // isn't available, and earlyExitIfNotFound hasn't been set, the property + // will be provided as null. + withAlbumProperty(property, { + to = '#album.' + property, + earlyExitIfNotFound = false, + } = {}) { + return Thing.composite.from(`Track.composite.withAlbumProperty`, [ + Track.composite.withAlbum({earlyExitIfNotFound}), + + { + dependencies: ['#album'], + options: {property}, + mapContinuation: {to}, + + compute: ({ + '#album': album, + '#options': {property}, + }, continuation) => + (album + ? continuation.raise({to: album[property]}) + : continuation.raise({to: null})), + }, + ]); + }, - const album = Track.findAlbum(track, albumData); - if (album && !empty(album.trackCoverArtistContribsByRef)) { - return true; - } + // Gets the listed properties from this track's album, providing them as + // dependencies (by default) with '#album.' prefixed before each property + // name. If the track's album isn't available, and earlyExitIfNotFound + // hasn't been set, the same dependency names will be provided as null. + withAlbumProperties({ + properties, + prefix = '#album', + earlyExitIfNotFound = false, + }) { + return Thing.composite.from(`Track.composite.withAlbumProperties`, [ + Track.composite.withAlbum({earlyExitIfNotFound}), + + { + dependencies: ['#album'], + options: {properties, prefix}, + + compute({ + '#album': album, + '#options': {properties, prefix}, + }, continuation) { + const raise = {}; + + if (album) { + for (const property of properties) { + raise[prefix + '.' + property] = album[property]; + } + } else { + for (const property of properties) { + raise[prefix + '.' + property] = null; + } + } + + return continuation.raise(raise); + }, + }, + ]); + }, - return false; - } + // Gets the track section containing this track from its album's track list. + // Unless earlyExitIfNotFound is overridden false, this will early exit if + // the album can't be found or if none of its trackSections includes the + // track for some reason. + withContainingTrackSection({ + to = '#trackSection', + earlyExitIfNotFound = true, + } = {}) { + return Thing.composite.from(`Track.composite.withContainingTrackSection`, [ + Track.composite.withAlbumProperty('trackSections', {earlyExitIfNotFound}), + + { + dependencies: ['this', '#album.trackSections'], + mapContinuation: {to}, + + compute({ + this: track, + '#album.trackSections': trackSections, + }, continuation) { + if (!trackSections) { + return continuation.raise({to: null}); + } + + const trackSection = + trackSections.find(({tracks}) => tracks.includes(track)); + + if (trackSection) { + return continuation.raise({to: trackSection}); + } else if (earlyExitIfNotFound) { + return continuation.exit(null); + } else { + return continuation.raise({to: null}); + } + }, + }, + ]); + }, - static inheritFromOriginalRelease( - originalProperty, - originalMissingValue, - ownPropertyDescriptor - ) { - return { - flags: {expose: true}, - - expose: { - dependencies: [ - ...ownPropertyDescriptor.expose.dependencies, - 'originalReleaseTrackByRef', - 'trackData', - ], - - compute(dependencies) { - const { - originalReleaseTrackByRef, - trackData, - } = dependencies; + // Just includes the original release of this track as a dependency. + // If this track isn't a rerelease, then it'll provide null, unless the + // {selfIfOriginal} option is set, in which case it'll provide this track + // itself. Note that this will early exit if the original release is + // specified by reference and that reference doesn't resolve to anything. + // Outputs to '#originalRelease' by default. + withOriginalRelease({ + to = '#originalRelease', + selfIfOriginal = false, + } = {}) { + return Thing.composite.from(`Track.composite.withOriginalRelease`, [ + Thing.composite.withResolvedReference({ + ref: 'originalReleaseTrackByRef', + data: 'trackData', + to: '#originalRelease', + find: find.track, + earlyExitIfNotFound: true, + }), + + { + dependencies: ['this', '#originalRelease'], + options: {selfIfOriginal}, + mapContinuation: {to}, + compute: ({ + this: track, + '#originalRelease': originalRelease, + '#options': {selfIfOriginal}, + }, continuation) => + continuation.raise({ + to: + (originalRelease ?? + (selfIfOriginal + ? track + : null)), + }), + }, + ]); + }, - if (originalReleaseTrackByRef) { - if (!trackData) return originalMissingValue; - const original = find.track(originalReleaseTrackByRef, trackData, {mode: 'quiet'}); - if (!original) return originalMissingValue; - return original[originalProperty]; - } + // The algorithm for checking if a track has unique cover art is used in a + // couple places, so it's defined in full as a compositional step. + withHasUniqueCoverArt({ + to = '#hasUniqueCoverArt', + } = {}) { + return Thing.composite.from(`Track.composite.withHasUniqueCoverArt`, [ + { + dependencies: ['disableUniqueCoverArt'], + mapContinuation: {to}, + compute: ({disableUniqueCoverArt}, continuation) => + (disableUniqueCoverArt + ? continuation.raise({to: false}) + : continuation()), + }, - return ownPropertyDescriptor.expose.compute(dependencies); + Thing.composite.withResolvedContribs({ + from: 'coverArtistContribsByRef', + to: '#coverArtistContribs', + }), + + { + dependencies: ['#coverArtistContribs'], + mapContinuation: {to}, + compute: ({'#coverArtistContribs': contribsFromTrack}, continuation) => + (empty(contribsFromTrack) + ? continuation() + : continuation.raise({to: true})), }, - }, - }; - } - [inspect.custom]() { - const base = Thing.prototype[inspect.custom].apply(this); + Track.composite.withAlbumProperty('trackCoverArtistContribs'), - const rereleasePart = - (this.originalReleaseTrackByRef - ? `${color.yellow('[rerelease]')} ` - : ``); + { + dependencies: ['#album.trackCoverArtistContribs'], + mapContinuation: {to}, + compute: ({'#album.trackCoverArtistContribs': contribsFromAlbum}, continuation) => + (empty(contribsFromAlbum) + ? continuation.raise({to: false}) + : continuation.raise({to: true})), + }, + ]); + }, - const {album, dataSourceAlbum} = this; + trackReverseReferenceList(refListProperty) { + return Thing.composite.from(`Track.composite.trackReverseReferenceList`, [ + Thing.composite.withReverseReferenceList({ + data: 'trackData', + refList: refListProperty, + originalTracksOnly: true, + }), + + { + flags: {expose: true}, + expose: { + dependencies: ['#reverseReferenceList'], + compute: ({'#reverseReferenceList': reverseReferenceList}) => + reverseReferenceList.filter(track => !track.originalReleaseTrack), + }, + }, + ]); + }, + }; - const albumName = - (album - ? album.name - : dataSourceAlbum?.name); + [inspect.custom](depth) { + const parts = []; - const albumIndex = - albumName && - (album - ? album.tracks.indexOf(this) - : dataSourceAlbum.tracks.indexOf(this)); + parts.push(Thing.prototype[inspect.custom].apply(this)); - const trackNum = - albumName && + if (this.originalReleaseTrackByRef) { + parts.unshift(`${color.yellow('[rerelease]')} `); + } + + let album; + if (depth >= 0 && (album = this.album ?? this.dataSourceAlbum)) { + const albumName = album.name; + const albumIndex = album.tracks.indexOf(this); + const trackNum = (albumIndex === -1 ? '#?' : `#${albumIndex + 1}`); + parts.push(` (${color.yellow(trackNum)} in ${color.green(albumName)})`); + } - const albumPart = - albumName - ? ` (${color.yellow(trackNum)} in ${color.green(albumName)})` - : ``; - - return rereleasePart + base + albumPart; + return parts.join(''); } } diff --git a/src/data/things/wiki-info.js b/src/data/things/wiki-info.js index e906cab1..e8279987 100644 --- a/src/data/things/wiki-info.js +++ b/src/data/things/wiki-info.js @@ -59,10 +59,6 @@ export class WikiInfo extends Thing { // Expose only - divideTrackListsByGroups: Thing.common.dynamicThingsFromReferenceList( - 'divideTrackListsByGroupsByRef', - 'groupData', - find.group - ), + divideTrackListsByGroups: Thing.common.dynamicThingsFromReferenceList('divideTrackListsByGroupsByRef', 'groupData', find.group), }); } diff --git a/src/data/yaml.js b/src/data/yaml.js index 35943199..2ad2d41d 100644 --- a/src/data/yaml.js +++ b/src/data/yaml.js @@ -316,6 +316,10 @@ export const processTrackDocument = makeProcessDocument(T.Track, { 'Date First Released': (value) => new Date(value), 'Cover Art Date': (value) => new Date(value), + 'Has Cover Art': (value) => + (value === true ? false : + value === false ? true : + value), 'Artists': parseContributors, 'Contributors': parseContributors, @@ -336,7 +340,7 @@ export const processTrackDocument = makeProcessDocument(T.Track, { dateFirstReleased: 'Date First Released', coverArtDate: 'Cover Art Date', coverArtFileExtension: 'Cover Art File Extension', - hasCoverArt: 'Has Cover Art', + disableUniqueCoverArt: 'Has Cover Art', // This gets transformed to flip true/false. lyrics: 'Lyrics', commentary: 'Commentary', @@ -1316,13 +1320,27 @@ export async function loadAndProcessDataDocuments({dataPath}) { // Data linking! Basically, provide (portions of) wikiData to the Things which // require it - they'll expose dynamically computed properties as a result (many -// of which are required for page HTML generation). -export function linkWikiDataArrays(wikiData) { +// of which are required for page HTML generation and other expected behavior). +// +// The XXX_decacheWikiData option should be used specifically to mark +// points where you *aren't* replacing any of the arrays under wikiData with +// new values, and are using linkWikiDataArrays to instead "decache" data +// properties which depend on any of them. It's currently not possible for +// a CacheableObject to depend directly on the value of a property exposed +// on some other CacheableObject, so when those values change, you have to +// manually decache before the object will realize its cache isn't valid +// anymore. +export function linkWikiDataArrays(wikiData, { + XXX_decacheWikiData = false, +} = {}) { function assignWikiData(things, ...keys) { + if (things === undefined) return; for (let i = 0; i < things.length; i++) { const thing = things[i]; for (let j = 0; j < keys.length; j++) { const key = keys[j]; + if (!(key in wikiData)) continue; + if (XXX_decacheWikiData) thing[key] = []; thing[key] = wikiData[key]; } } @@ -1340,7 +1358,7 @@ export function linkWikiDataArrays(wikiData) { assignWikiData(WD.flashData, 'artistData', 'flashActData', 'trackData'); assignWikiData(WD.flashActData, 'flashData'); assignWikiData(WD.artTagData, 'albumData', 'trackData'); - assignWikiData(WD.homepageLayout.rows, 'albumData', 'groupData'); + assignWikiData(WD.homepageLayout?.rows, 'albumData', 'groupData'); } export function sortWikiDataArrays(wikiData) { diff --git a/src/util/sugar.js b/src/util/sugar.js index 5b1f3193..1ba3f3ae 100644 --- a/src/util/sugar.js +++ b/src/util/sugar.js @@ -168,12 +168,24 @@ export function setIntersection(set1, set2) { return intersection; } -export function filterProperties(obj, properties) { - const set = new Set(properties); - return Object.fromEntries( - Object - .entries(obj) - .filter(([key]) => set.has(key))); +export function filterProperties(object, properties) { + if (typeof object !== 'object' || object === null) { + throw new TypeError(`Expected object to be an object, got ${object}`); + } + + if (!Array.isArray(properties)) { + throw new TypeError(`Expected properties to be an array, got ${properties}`); + } + + const filteredObject = {}; + + for (const property of properties) { + if (Object.hasOwn(object, property)) { + filteredObject[property] = object[property]; + } + } + + return filteredObject; } export function queue(array, max = 50) { diff --git a/test/lib/index.js b/test/lib/index.js index b9cc82f8..6eaaa656 100644 --- a/test/lib/index.js +++ b/test/lib/index.js @@ -1,3 +1,4 @@ export * from './content-function.js'; export * from './generic-mock.js'; +export * from './wiki-data.js'; export * from './strict-match-error.js'; diff --git a/test/lib/wiki-data.js b/test/lib/wiki-data.js new file mode 100644 index 00000000..c4083a56 --- /dev/null +++ b/test/lib/wiki-data.js @@ -0,0 +1,24 @@ +import {linkWikiDataArrays} from '#yaml'; + +export function linkAndBindWikiData(wikiData) { + linkWikiDataArrays(wikiData); + + return { + // Mutate to make the below functions aware of new data objects, or of + // reordering the existing ones. Don't mutate arrays such as trackData + // in-place; assign completely new arrays to this wikiData object instead. + wikiData, + + // Use this after you've mutated wikiData to assign new data arrays. + // It'll automatically relink everything on wikiData so all the objects + // are caught up to date. + linkWikiDataArrays: + linkWikiDataArrays.bind(null, wikiData), + + // Use this if you HAVEN'T mutated wikiData and just need to decache + // indirect dependencies on exposed properties of other data objects. + // See documentation on linkWikiDataArarys (in yaml.js) for more info. + XXX_decacheWikiData: + linkWikiDataArrays.bind(null, wikiData, {XXX_decacheWikiData: true}), + }; +} diff --git a/test/unit/data/things/track.js b/test/unit/data/things/track.js index 383e3e3f..8939d964 100644 --- a/test/unit/data/things/track.js +++ b/test/unit/data/things/track.js @@ -1,74 +1,511 @@ import t from 'tap'; + +import {linkAndBindWikiData} from '#test-lib'; import thingConstructors from '#things'; const { Album, + Artist, + Flash, + FlashAct, Thing, Track, - TrackGroup, } = thingConstructors; -function stubAlbum(tracks) { +function stubAlbum(tracks, directory = 'bar') { const album = new Album(); - album.trackSections = [ - { - tracksByRef: tracks.map(t => Thing.getReference(t)), - }, - ]; - album.trackData = tracks; + album.directory = directory; + + const tracksByRef = tracks.map(t => Thing.getReference(t)); + album.trackSections = [{tracksByRef}]; + return album; } -t.test(`Track.coverArtDate`, t => { +function stubTrack(directory = 'foo') { + const track = new Track(); + track.directory = directory; + + return track; +} + +function stubTrackAndAlbum(trackDirectory = 'foo', albumDirectory = 'bar') { + const track = stubTrack(trackDirectory); + const album = stubAlbum([track], albumDirectory); + return {track, album}; +} + +function stubArtistAndContribs() { + const artist = new Artist(); + artist.name = `Test Artist`; + + const contribs = [{who: `Test Artist`, what: null}]; + const badContribs = [{who: `Figment of Your Imagination`, what: null}]; + + return {artist, contribs, badContribs}; +} + +function stubFlashAndAct(directory = 'zam') { + const flash = new Flash(); + flash.directory = directory; + + const flashAct = new FlashAct(); + flashAct.flashesByRef = [Thing.getReference(flash)]; + + return {flash, flashAct}; +} + +t.test(`Track.album`, t => { + t.plan(6); + + // Note: These asserts use manual albumData/trackData relationships + // to illustrate more specifically the properties which are expected to + // be relevant for this case. Other properties use the same underlying + // get-album behavior as Track.album so aren't tested as aggressively. + + const track1 = stubTrack('track1'); + const track2 = stubTrack('track2'); + const album1 = new Album(); + const album2 = new Album(); + + t.equal(track1.album, null, + `album #1: defaults to null`); + + track1.albumData = [album1, album2]; + track2.albumData = [album1, album2]; + album1.trackData = [track1, track2]; + album2.trackData = [track1, track2]; + album1.trackSections = [{tracksByRef: ['track:track1']}]; + album2.trackSections = [{tracksByRef: ['track:track2']}]; + + t.equal(track1.album, album1, + `album #2: is album when album's trackSections matches track`); + + track1.albumData = [album2, album1]; + + t.equal(track1.album, album1, + `album #3: is album when albumData is in different order`); + + track1.albumData = []; + + t.equal(track1.album, null, + `album #4: is null when track missing albumData`); + + album1.trackData = []; + track1.albumData = [album1, album2]; + + t.equal(track1.album, null, + `album #5: is null when album missing trackData`); + + album1.trackData = [track1, track2]; + album1.trackSections = [{tracksByRef: ['track:track2']}]; + + // XXX_decacheWikiData + track1.albumData = []; + track1.albumData = [album1, album2]; + + t.equal(track1.album, null, + `album #6: is null when album's trackSections don't match track`); +}); + +t.test(`Track.color`, 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, album} = stubTrackAndAlbum(); - const track = new Track(); - track.directory = 'foo'; + const {wikiData, linkWikiDataArrays, XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album], + trackData: [track], + }); + + t.equal(track.color, null, + `color #1: defaults to null`); + + album.color = '#abcdef'; + album.trackSections = [{ + color: '#beeeef', + tracksByRef: [Thing.getReference(track)], + }]; + XXX_decacheWikiData(); + + t.equal(track.color, '#beeeef', + `color #2: inherits from track section before album`); + + // Replace the album with a completely fake one. This isn't realistic, since + // in correct data, Album.tracks depends on Albums.trackSections and so the + // track's album will always have a corresponding track section. But if that + // connection breaks for some future reason (with the album still present), + // Track.color should still inherit directly from the album. + wikiData.albumData = [ + new Proxy({ + color: '#abcdef', + tracks: [track], + trackSections: [ + {color: '#baaaad', tracks: []}, + ], + }, {getPrototypeOf: () => Album.prototype}), + ]; + + linkWikiDataArrays(); + + t.equal(track.color, '#abcdef', + `color #3: inherits from album without matching track section`); + + track.color = '#123456'; + + t.equal(track.color, '#123456', + `color #4: is own value`); + + t.throws(() => { track.color = '#aeiouw'; }, TypeError, + `color #5: must be set to valid color`); +}); + +t.test(`Track.coverArtDate`, t => { + t.plan(6); + + const {track, album} = stubTrackAndAlbum(); + const {artist, contribs} = stubArtistAndContribs(); + + const {XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album], + artistData: [artist], + trackData: [track], + }); + + track.coverArtistContribsByRef = contribs; + + t.equal(track.coverArtDate, null, + `coverArtDate #1: defaults to null`); + + album.trackArtDate = new Date('2012-12-12'); + + XXX_decacheWikiData(); + + t.same(track.coverArtDate, new Date('2012-12-12'), + `coverArtDate #2: inherits album trackArtDate`); + + track.coverArtDate = new Date('2009-09-09'); + + t.same(track.coverArtDate, new Date('2009-09-09'), + `coverArtDate #3: is own value`); + + track.coverArtistContribsByRef = []; + + t.equal(track.coverArtDate, null, + `coverArtDate #4: is null if track is missing coverArtists`); + + album.trackCoverArtistContribsByRef = contribs; + + XXX_decacheWikiData(); + + t.same(track.coverArtDate, new Date('2009-09-09'), + `coverArtDate #5: is not null if album specifies trackCoverArtistContribs`); + + track.disableUniqueCoverArt = true; + + t.equal(track.coverArtDate, null, + `coverArtDate #6: is null if track disables unique cover artwork`); +}); + +t.test(`Track.coverArtFileExtension`, t => { + t.plan(8); + + const {track, album} = stubTrackAndAlbum(); + const {artist, contribs} = stubArtistAndContribs(); + + const {XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album], + artistData: [artist], + trackData: [track], + }); + + t.equal(track.coverArtFileExtension, null, + `coverArtFileExtension #1: defaults to null`); + + track.coverArtistContribsByRef = contribs; + + t.equal(track.coverArtFileExtension, 'jpg', + `coverArtFileExtension #2: is jpg if has cover art and not further specified`); + + track.coverArtistContribsByRef = []; + + album.coverArtistContribsByRef = contribs; + XXX_decacheWikiData(); + + t.equal(track.coverArtFileExtension, null, + `coverArtFileExtension #3: only has value for unique cover art`); - const album = stubAlbum([track]); + track.coverArtistContribsByRef = contribs; - track.albumData = [album]; + album.trackCoverArtFileExtension = 'png'; + XXX_decacheWikiData(); - // 1. coverArtDate defaults to null + t.equal(track.coverArtFileExtension, 'png', + `coverArtFileExtension #4: inherits album trackCoverArtFileExtension (1/2)`); - t.equal(track.coverArtDate, null); + track.coverArtFileExtension = 'gif'; - // 2. coverArtDate inherits album release date + t.equal(track.coverArtFileExtension, 'gif', + `coverArtFileExtension #5: is own value (1/2)`); - album.date = albumDate; + track.coverArtistContribsByRef = []; - // XXX clear cache so change in album's property is reflected - track.albumData = []; - track.albumData = [album]; + album.trackCoverArtistContribsByRef = contribs; + XXX_decacheWikiData(); - t.equal(track.coverArtDate, albumDate); + t.equal(track.coverArtFileExtension, 'gif', + `coverArtFileExtension #6: is own value (2/2)`); + + track.coverArtFileExtension = null; + + t.equal(track.coverArtFileExtension, 'png', + `coverArtFileExtension #7: inherits album trackCoverArtFileExtension (2/2)`); + + track.disableUniqueCoverArt = true; + + t.equal(track.coverArtFileExtension, null, + `coverArtFileExtension #8: is null if track disables unique cover art`); +}); + +t.test(`Track.date`, t => { + t.plan(3); + + const {track, album} = stubTrackAndAlbum(); + + const {XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album], + trackData: [track], + }); + + t.equal(track.date, null, + `date #1: defaults to null`); + + album.date = new Date('2012-12-12'); + XXX_decacheWikiData(); + + t.same(track.date, album.date, + `date #2: inherits from album`); + + track.dateFirstReleased = new Date('2009-09-09'); + + t.same(track.date, new Date('2009-09-09'), + `date #3: is own dateFirstReleased`); +}); + +t.test(`Track.featuredInFlashes`, t => { + t.plan(2); + + const {track, album} = stubTrackAndAlbum('track1'); + + const {flash: flash1, flashAct: flashAct1} = stubFlashAndAct('flash1'); + const {flash: flash2, flashAct: flashAct2} = stubFlashAndAct('flash2'); + + const {XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album], + trackData: [track], + flashData: [flash1, flash2], + flashActData: [flashAct1, flashAct2], + }); + + t.same(track.featuredInFlashes, [], + `featuredInFlashes #1: defaults to empty array`); + + flash1.featuredTracksByRef = ['track:track1']; + flash2.featuredTracksByRef = ['track:track1']; + XXX_decacheWikiData(); + + t.same(track.featuredInFlashes, [flash1, flash2], + `featuredInFlashes #2: matches flashes' featuredTracks`); +}); + +t.test(`Track.hasUniqueCoverArt`, t => { + t.plan(7); + + const {track, album} = stubTrackAndAlbum(); + const {artist, contribs, badContribs} = stubArtistAndContribs(); + + const {XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album], + artistData: [artist], + trackData: [track], + }); + + t.equal(track.hasUniqueCoverArt, false, + `hasUniqueCoverArt #1: defaults to false`); + + album.trackCoverArtistContribsByRef = contribs; + XXX_decacheWikiData(); + + t.equal(track.hasUniqueCoverArt, true, + `hasUniqueCoverArt #2: is true if album specifies trackCoverArtistContribs`); + + track.disableUniqueCoverArt = true; + + t.equal(track.hasUniqueCoverArt, false, + `hasUniqueCoverArt #3: is false if disableUniqueCoverArt is true (1/2)`); + + track.disableUniqueCoverArt = false; + + album.trackCoverArtistContribsByRef = badContribs; + XXX_decacheWikiData(); + + t.equal(track.hasUniqueCoverArt, false, + `hasUniqueCoverArt #4: is false if album's trackCoverArtistContribsByRef resolve empty`); + + track.coverArtistContribsByRef = contribs; + + t.equal(track.hasUniqueCoverArt, true, + `hasUniqueCoverArt #5: is true if track specifies coverArtistContribs`); + + track.disableUniqueCoverArt = true; + + t.equal(track.hasUniqueCoverArt, false, + `hasUniqueCoverArt #6: is false if disableUniqueCoverArt is true (2/2)`); + + track.disableUniqueCoverArt = false; + + track.coverArtistContribsByRef = badContribs; + + t.equal(track.hasUniqueCoverArt, false, + `hasUniqueCoverArt #7: is false if track's coverArtistContribsByRef resolve empty`); +}); + +t.only(`Track.originalReleaseTrack`, t => { + t.plan(3); + + const {track: track1, album: album1} = stubTrackAndAlbum('track1'); + const {track: track2, album: album2} = stubTrackAndAlbum('track2'); + + const {wikiData, linkWikiDataArrays, XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album1, album2], + trackData: [track1, track2], + }); + + t.equal(track2.originalReleaseTrack, null, + `originalReleaseTrack #1: defaults to null`); + + track2.originalReleaseTrackByRef = 'track:track1'; + + t.equal(track2.originalReleaseTrack, track1, + `originalReleaseTrack #2: is resolved from originalReleaseTrackByRef`); + + track2.trackData = []; + + t.equal(track2.originalReleaseTrack, null, + `originalReleaseTrack #3: is null when track missing trackData`); +}); + +t.test(`Track.otherReleases`, t => { + t.plan(6); + + const {track: track1, album: album1} = stubTrackAndAlbum('track1'); + const {track: track2, album: album2} = stubTrackAndAlbum('track2'); + const {track: track3, album: album3} = stubTrackAndAlbum('track3'); + const {track: track4, album: album4} = stubTrackAndAlbum('track4'); + + const {wikiData, linkWikiDataArrays, XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album1, album2, album3, album4], + trackData: [track1, track2, track3, track4], + }); + + t.same(track1.otherReleases, [], + `otherReleases #1: defaults to empty array`); + + track2.originalReleaseTrackByRef = 'track:track1'; + track3.originalReleaseTrackByRef = 'track:track1'; + track4.originalReleaseTrackByRef = 'track:track1'; + XXX_decacheWikiData(); + + t.same(track1.otherReleases, [track2, track3, track4], + `otherReleases #2: otherReleases of original release are its rereleases`); + + wikiData.trackData = [track1, track3, track2, track4]; + linkWikiDataArrays(); + + t.same(track1.otherReleases, [track3, track2, track4], + `otherReleases #3: otherReleases matches trackData order`); + + wikiData.trackData = [track3, track2, track1, track4]; + linkWikiDataArrays(); + + t.same(track2.otherReleases, [track1, track3, track4], + `otherReleases #4: otherReleases of rerelease are original track then other rereleases (1/3)`); + + t.same(track3.otherReleases, [track1, track2, track4], + `otherReleases #5: otherReleases of rerelease are original track then other rereleases (2/3)`); + + t.same(track4.otherReleases, [track1, track3, track2], + `otherReleases #6: otherReleases of rerelease are original track then other rereleases (1/3)`); +}); + +t.test(`Track.referencedByTracks`, t => { + t.plan(4); + + const {track: track1, album: album1} = stubTrackAndAlbum('track1'); + const {track: track2, album: album2} = stubTrackAndAlbum('track2'); + const {track: track3, album: album3} = stubTrackAndAlbum('track3'); + const {track: track4, album: album4} = stubTrackAndAlbum('track4'); + + const {XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album1, album2, album3, album4], + trackData: [track1, track2, track3, track4], + }); + + t.same(track1.referencedByTracks, [], + `referencedByTracks #1: defaults to empty array`); + + track2.referencedTracksByRef = ['track:track1']; + track3.referencedTracksByRef = ['track:track1']; + XXX_decacheWikiData(); + + t.same(track1.referencedByTracks, [track2, track3], + `referencedByTracks #2: matches tracks' referencedTracks`); + + track4.sampledTracksByRef = ['track:track1']; + XXX_decacheWikiData(); + + t.same(track1.referencedByTracks, [track2, track3], + `referencedByTracks #3: doesn't match tracks' sampledTracks`); + + track3.originalReleaseTrackByRef = 'track:track2'; + XXX_decacheWikiData(); + + t.same(track1.referencedByTracks, [track2], + `referencedByTracks #4: doesn't include re-releases`); +}); - // 3. coverArtDate inherits album trackArtDate +t.test(`Track.sampledByTracks`, t => { + t.plan(4); - album.trackArtDate = albumTrackArtDate; + const {track: track1, album: album1} = stubTrackAndAlbum('track1'); + const {track: track2, album: album2} = stubTrackAndAlbum('track2'); + const {track: track3, album: album3} = stubTrackAndAlbum('track3'); + const {track: track4, album: album4} = stubTrackAndAlbum('track4'); - // XXX clear cache again - track.albumData = []; - track.albumData = [album]; + const {XXX_decacheWikiData} = linkAndBindWikiData({ + albumData: [album1, album2, album3, album4], + trackData: [track1, track2, track3, track4], + }); - t.equal(track.coverArtDate, albumTrackArtDate); + t.same(track1.sampledByTracks, [], + `sampledByTracks #1: defaults to empty array`); - // 4. coverArtDate is overridden dateFirstReleased + track2.sampledTracksByRef = ['track:track1']; + track3.sampledTracksByRef = ['track:track1']; + XXX_decacheWikiData(); - track.dateFirstReleased = trackDateFirstReleased; + t.same(track1.sampledByTracks, [track2, track3], + `sampledByTracks #2: matches tracks' sampledTracks`); - t.equal(track.coverArtDate, trackDateFirstReleased); + track4.referencedTracksByRef = ['track:track1']; + XXX_decacheWikiData(); - // 5. coverArtDate is overridden coverArtDate + t.same(track1.sampledByTracks, [track2, track3], + `sampledByTracks #3: doesn't match tracks' referencedTracks`); - track.coverArtDate = trackCoverArtDate; + track3.originalReleaseTrackByRef = 'track:track2'; + XXX_decacheWikiData(); - t.equal(track.coverArtDate, trackCoverArtDate); + t.same(track1.sampledByTracks, [track2], + `sampledByTracks #4: doesn't include re-releases`); }); |