diff --git a/app/.meteor/packages b/app/.meteor/packages index f627a7cd..258a617b 100644 --- a/app/.meteor/packages +++ b/app/.meteor/packages @@ -52,3 +52,4 @@ seba:minifiers-autoprefixer akryum:vue-component akryum:vue-sass percolate:migrations +meteortesting:mocha diff --git a/app/.meteor/versions b/app/.meteor/versions index 73bc1e23..bcc20581 100644 --- a/app/.meteor/versions +++ b/app/.meteor/versions @@ -62,6 +62,9 @@ mdg:validated-method@1.2.0 meteor@1.9.3 meteor-base@1.4.0 meteorhacks:subs-manager@1.6.4 +meteortesting:browser-tests@1.3.4 +meteortesting:mocha@2.0.2 +meteortesting:mocha-core@8.0.1 mikowals:batch-insert@1.2.0 minifier-css@1.5.4 minifier-js@2.6.1 diff --git a/app/imports/api/creature/computation/creatureComputation.test.js b/app/imports/api/creature/computation/creatureComputation.disabledTest.js similarity index 100% rename from app/imports/api/creature/computation/creatureComputation.test.js rename to app/imports/api/creature/computation/creatureComputation.disabledTest.js diff --git a/app/imports/api/creature/creatureProperties/CreatureProperties.js b/app/imports/api/creature/creatureProperties/CreatureProperties.js index 4b7d170f..fe1f51a6 100644 --- a/app/imports/api/creature/creatureProperties/CreatureProperties.js +++ b/app/imports/api/creature/creatureProperties/CreatureProperties.js @@ -10,6 +10,10 @@ import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; let CreatureProperties = new Mongo.Collection('creatureProperties'); let CreaturePropertySchema = new SimpleSchema({ + _id: { + type: String, + regEx: SimpleSchema.RegEx.Id, + }, _migrationError: { type: String, optional: true, diff --git a/app/imports/api/properties/Actions.js b/app/imports/api/properties/Actions.js index c1e058ce..d17c3d66 100644 --- a/app/imports/api/properties/Actions.js +++ b/app/imports/api/properties/Actions.js @@ -5,9 +5,8 @@ import { InlineCalculationFieldSchema, } from '/imports/api/properties/subSchemas/InlineCalculationFieldSchema.js'; import { - FieldToComputeSchema, - ComputedOnlyFieldSchema, - ComputedFieldSchema, + fieldToCompute, + computedOnlyField, } from '/imports/api/properties/subSchemas/ComputedFieldSchema.js'; import { ResourcesSchema, @@ -60,7 +59,7 @@ let ActionSchema = new SimpleSchema({ }, // Calculation of how many times this action can be used uses: { - type: FieldToComputeSchema, + type: Object, optional: true, }, // Integer of how many times it has already been used @@ -74,7 +73,7 @@ let ActionSchema = new SimpleSchema({ allowedValues: ['longRest', 'shortRest'], optional: true, }, -}); +}).extend(fieldToCompute('uses')); const ComputedOnlyActionSchema = new SimpleSchema({ summary: { @@ -85,10 +84,6 @@ const ComputedOnlyActionSchema = new SimpleSchema({ type: ComputedOnlyInlineCalculationFieldSchema, optional: true, }, - uses: { - type: ComputedOnlyFieldSchema, - optional: true, - }, resources: { type: ResourcesComputedOnlySchema, defaultValue: {}, @@ -99,16 +94,12 @@ const ComputedOnlyActionSchema = new SimpleSchema({ type: Boolean, optional: true, }, -}); +}).extend(computedOnlyField('uses')); const ComputedActionSchema = new SimpleSchema() .extend(ActionSchema) .extend(ComputedOnlyActionSchema) .extend({ - uses: { - type: ComputedFieldSchema, - optional: true, - }, summary: { type: InlineCalculationFieldSchema, optional: true, diff --git a/app/imports/api/properties/Adjustments.js b/app/imports/api/properties/Adjustments.js index 9b3c12fb..8ab97d4f 100644 --- a/app/imports/api/properties/Adjustments.js +++ b/app/imports/api/properties/Adjustments.js @@ -1,15 +1,20 @@ import SimpleSchema from 'simpl-schema'; -import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; +import { + fieldToCompute, + computedOnlyField, +} from '/imports/api/properties/subSchemas/ComputedFieldSchema.js'; const AdjustmentSchema = new SimpleSchema({ // The roll that determines how much to change the attribute // This can be simplified, but should only compute when activated amount: { - type: String, + type: Object, optional: true, - defaultValue: '1', - max: STORAGE_LIMITS.calculation, + }, + 'amount.calculation': { + type: String, + defaultValue: 1, }, // Who this adjustment applies to target: { @@ -32,22 +37,9 @@ const AdjustmentSchema = new SimpleSchema({ allowedValues: ['set', 'increment'], defaultValue: 'increment', }, -}); +}).extend(fieldToCompute('amount')); -const ComputedOnlyAdjustmentSchema = new SimpleSchema({ - amountResult: { - type: SimpleSchema.oneOf(String, Number), - optional: true, - }, - amountErrors: { - type: Array, - optional: true, - maxCount: STORAGE_LIMITS.errorCount, - }, - 'amountErrors.$':{ - type: ErrorSchema, - }, -}); +const ComputedOnlyAdjustmentSchema = computedOnlyField('amount'); const ComputedAdjustmentSchema = new SimpleSchema() .extend(AdjustmentSchema) diff --git a/app/imports/api/properties/Attacks.js b/app/imports/api/properties/Attacks.js index e2363e59..e10677b8 100644 --- a/app/imports/api/properties/Attacks.js +++ b/app/imports/api/properties/Attacks.js @@ -1,6 +1,9 @@ import SimpleSchema from 'simpl-schema'; import { ActionSchema, ComputedOnlyActionSchema } from '/imports/api/properties/Actions.js'; -import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js'; +import { + fieldToCompute, + computedOnlyField, +} from '/imports/api/properties/subSchemas/ComputedFieldSchema.js'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; // Attacks are special instances of actions @@ -9,10 +12,12 @@ let AttackSchema = new SimpleSchema() .extend({ // What gets added to the d20 roll rollBonus: { + type: Object, + optional: true, + }, + 'rollBonus.calculation': { type: String, defaultValue: 'strength.modifier + proficiencyBonus', - optional: true, - max: STORAGE_LIMITS.calculation, }, // Set better defaults for the action actionType: { @@ -29,24 +34,11 @@ let AttackSchema = new SimpleSchema() type: String, max: STORAGE_LIMITS.tagLength, }, - }); + }).extend(fieldToCompute('rollBonus')); const ComputedOnlyAttackSchema = new SimpleSchema() .extend(ComputedOnlyActionSchema) - .extend({ - rollBonusResult: { - type: Number, - optional: true, - }, - rollBonusErrors: { - type: Array, - optional: true, - maxCount: STORAGE_LIMITS.errorCount, - }, - 'rollBonusErrors.$':{ - type: ErrorSchema, - }, - }); + .extend(computedOnlyField('rollBonus')); const ComputedAttackSchema = new SimpleSchema() .extend(AttackSchema) diff --git a/app/imports/api/properties/subSchemas/AttributeConsumedSchema.js b/app/imports/api/properties/subSchemas/AttributeConsumedSchema.js index 622e87a3..0c00ade4 100644 --- a/app/imports/api/properties/subSchemas/AttributeConsumedSchema.js +++ b/app/imports/api/properties/subSchemas/AttributeConsumedSchema.js @@ -1,9 +1,8 @@ import SimpleSchema from 'simpl-schema'; import { Random } from 'meteor/random'; import { - FieldToComputeSchema, - ComputedOnlyFieldSchema, - ComputedFieldSchema, + fieldToCompute, + computedOnlyField, } from '/imports/api/properties/subSchemas/ComputedFieldSchema.js'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; @@ -21,10 +20,10 @@ const AttributeConsumedSchema = new SimpleSchema({ max: STORAGE_LIMITS.variableName, }, quantity: { - type: FieldToComputeSchema, + type: Object, optional: true, }, -}); +}).extend(fieldToCompute('quantity')); const ComputedOnlyAttributeConsumedSchema = new SimpleSchema({ available: { @@ -41,21 +40,11 @@ const ComputedOnlyAttributeConsumedSchema = new SimpleSchema({ optional: true, max: STORAGE_LIMITS.name, }, - quantity: { - type: ComputedOnlyFieldSchema, - optional: true, - }, -}); +}).extend(computedOnlyField('quantity')); const ComputedAttributeConsumedSchema = new SimpleSchema() .extend(AttributeConsumedSchema) - .extend(ComputedOnlyAttributeConsumedSchema) - .extend({ - quantity: { - type: ComputedFieldSchema, - optional: true, - }, - }); + .extend(ComputedOnlyAttributeConsumedSchema); export { AttributeConsumedSchema, diff --git a/app/imports/api/properties/subSchemas/ComputedFieldSchema.js b/app/imports/api/properties/subSchemas/ComputedFieldSchema.js index 70307db4..2455c237 100644 --- a/app/imports/api/properties/subSchemas/ComputedFieldSchema.js +++ b/app/imports/api/properties/subSchemas/ComputedFieldSchema.js @@ -2,34 +2,53 @@ import SimpleSchema from 'simpl-schema'; import ErrorSchema from '/imports/api/properties/subSchemas/ErrorSchema.js'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; -const FieldToComputeSchema = new SimpleSchema({ - // This is required, if we don't have a calculation delete the whole object - calculation: { - type: String, - }, -}); +// Get schemas that apply fields directly so they can be gracefully extended +// because {type: Schema} fields can't be extended +function fieldToCompute(field){ + return new SimpleSchema({ + // The object should already be set, but set again just in case + [field]: { + type: Object, + optional: true, + }, + // This is required, if we don't have a calculation delete the whole object + [`${field}.calculation`]: { + type: String, + max: STORAGE_LIMITS.calculation, + }, + }); +} -const ComputedOnlyFieldSchema = new SimpleSchema({ - value: { - type: SimpleSchema.oneOf(String, Number), - optional: true, - }, - errors: { - type: Array, - optional: true, - maxCount: STORAGE_LIMITS.errorCount, - }, - 'errors.$':{ - type: ErrorSchema, - }, -}); +function computedOnlyField(field){ + return new SimpleSchema({ + // The object should already be set, but set again just in case + [field]: { + type: Object, + optional: true, + }, + [`${field}.value`]: { + type: SimpleSchema.oneOf(String, Number), + optional: true, + }, + [`${field}.errors`]: { + type: Array, + optional: true, + maxCount: STORAGE_LIMITS.errorCount, + }, + [`${field}.errors.$`]:{ + type: ErrorSchema, + }, + }); +} -const ComputedFieldSchema = new SimpleSchema() - .extend(FieldToComputeSchema) - .extend(ComputedOnlyFieldSchema) +// This should rarely be used, since the other two will merge correctly when +// uncomputed and computedOnly schemas are merged +function computedField(field){ + return computedField(field).extend(computedOnlyField(field)); +} export { - FieldToComputeSchema, - ComputedOnlyFieldSchema, - ComputedFieldSchema + fieldToCompute, + computedOnlyField, + computedField, }; diff --git a/app/imports/api/properties/subSchemas/ItemConsumedSchema.js b/app/imports/api/properties/subSchemas/ItemConsumedSchema.js index b17d8b0f..762ed985 100644 --- a/app/imports/api/properties/subSchemas/ItemConsumedSchema.js +++ b/app/imports/api/properties/subSchemas/ItemConsumedSchema.js @@ -1,9 +1,8 @@ import SimpleSchema from 'simpl-schema'; import { Random } from 'meteor/random'; import { - FieldToComputeSchema, - ComputedOnlyFieldSchema, - ComputedFieldSchema, + fieldToCompute, + computedOnlyField, } from '/imports/api/properties/subSchemas/ComputedFieldSchema.js'; import { storedIconsSchema } from '/imports/api/icons/Icons.js'; import STORAGE_LIMITS from '/imports/constants/STORAGE_LIMITS.js'; @@ -21,7 +20,7 @@ const ItemConsumedSchema = new SimpleSchema({ optional: true, }, quantity: { - type: FieldToComputeSchema, + type: Object, optional: true, }, itemId: { @@ -29,17 +28,13 @@ const ItemConsumedSchema = new SimpleSchema({ regEx: SimpleSchema.RegEx.Id, optional: true, }, -}); +}).extend(fieldToCompute('quantity')); const ComputedOnlyItemConsumedSchema = new SimpleSchema({ available: { type: Number, optional: true, }, - quantity: { - type: ComputedOnlyFieldSchema, - optional: true, - }, // This appears both in the computed and uncomputed schema because it can be // set by both a computation or a form itemId: { @@ -62,17 +57,11 @@ const ComputedOnlyItemConsumedSchema = new SimpleSchema({ optional: true, max: STORAGE_LIMITS.color, }, -}) +}).extend(computedOnlyField('quantity')); const ComputedItemConsumedSchema = new SimpleSchema() .extend(ItemConsumedSchema) - .extend(ComputedOnlyItemConsumedSchema) - .extend({ - quantity: { - type: ComputedFieldSchema, - optional: true, - }, - }); + .extend(ComputedOnlyItemConsumedSchema); export { ItemConsumedSchema, diff --git a/app/imports/constants/SCHEMA_VERSION.js b/app/imports/constants/SCHEMA_VERSION.js new file mode 100644 index 00000000..4ac57384 --- /dev/null +++ b/app/imports/constants/SCHEMA_VERSION.js @@ -0,0 +1,3 @@ +const SCHEMA_VERSION = 1; + +export default SCHEMA_VERSION; diff --git a/app/imports/migrations/2.0-beta.33-dbv1.js b/app/imports/migrations/2.0-beta.33-dbv1.js deleted file mode 100644 index f8f107f8..00000000 --- a/app/imports/migrations/2.0-beta.33-dbv1.js +++ /dev/null @@ -1,186 +0,0 @@ -import { Migrations } from 'meteor/percolate:migrations'; -import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; -import { get, merge } from 'lodash'; - -// Git version 2.0-beta.33 -// Database version 1 -Migrations.add({ - version: 1, - name: 'Unifies calculated field schema', - up(){ - CreatureProperties.find({}).forEach(prop => { - const modifier = getUpPropModifier(prop); - if (!modifier) return; - updateOrStoreError(CreatureProperties, prop, modifier); - }); - }, - down(){ - CreatureProperties.find({}).forEach(prop => { - const modifier = getDownPropModifier(prop); - if (!modifier) return; - updateOrStoreError(CreatureProperties, prop, modifier); - }); - }, -}); - -function updateOrStoreError(collection, prop, modifier){ - try { - collection.update(prop._id, modifier, { - bypassCollection2: true, - //selector: {type: prop.type}, - }); - } catch(e){ - let errorString = e.toString(); - if (errorString){ - console.warn(errorString, prop._id); - collection.update(prop._id, { - $set: {_migrationError: e.toString()} - }, { - bypassCollection2: true, - }); - } - } -} - -function getUpPropModifier(prop){ - const modifiers = typeUpModifiers[prop.type]?.(prop); - if (!modifiers) return; - return cleanModifier(merge(...modifiers)); -} - -function getDownPropModifier(prop){ - const modifiers = typeDownModifiers[prop.type]?.(prop); - if (!modifiers) return; - return cleanModifier(merge(...modifiers)); -} - -function cleanModifier(modifier){ - if (modifier.$set && !Object.keys(modifier.$set).length){ - delete modifier.$set; - } - if (modifier.$unset && !Object.keys(modifier.$unset).length){ - delete modifier.$unset; - } - if (!modifier.$set && !modifier.$unset) return; - return modifier; -} - -const typeUpModifiers = { - action(prop){ - return [ - convertComputedField(prop, 'uses'), - // TODO: This doesn't work on itemsConsumed because it is an array field - // Need to iterate over every item consumed - convertComputedField(prop, 'resources.itemsConsumed.quantity'), - convertComputedField(prop, 'resources.attributesConsumed.quantity'), - convertInlineComputationField(prop, 'summary'), - convertInlineComputationField(prop, 'description'), - ]; - }, -}; - -const typeDownModifiers = { - action(prop){ - const modifiers = [ - unConvertComputedField(prop, 'uses'), - unConvertComputedField(prop, 'resources.itemsConsumed.quantity'), - unConvertComputedField(prop, 'resources.attributesConsumed.quantity'), - unConvertInlineComputationField(prop, 'summary'), - unConvertInlineComputationField(prop, 'description'), - ]; - return modifiers; - }, -}; - -function convertComputedField(object, field){ - const calculation = get(object, field); - if (!calculation) return { - $unset: { - [field]: 1, - [field + 'Errors']: 1, - [field + 'Result']: 1, - } - }; - const errors = get(object, field + 'Errors'); - let value = get(object, field + 'Result'); - // If the calculation can be cast to number, use that for value - if (value === undefined && Number.isFinite(+calculation)){ - value = +calculation; - } - const modifier = { - $unset:{ - [field + 'Errors']: 1, - [field + 'Result']: 1, - }, - $set: { - [field]: { - value, - calculation, - errors, - } - } - }; - return modifier; -} - -function unConvertComputedField(object, field){ - const calculation = get(object, field)?.calculation; - if (!calculation) return { - $unset: { - [field]: 1, - } - }; - const errors = get(object, field).errors; - let value = get(object, field).value; - // If the calculation can be cast to number, use that for value - if (value === undefined && Number.isFinite(+calculation)){ - value = +calculation; - } - const modifier = { - $set:{ - [field]: calculation, - [field + 'Errors']: errors, - [field + 'Result']: value, - }, - }; - return modifier; -} - -function convertInlineComputationField(object, field){ - const text = get(object, field); - const inlineCalculations = get(object, field + 'Calculations'); - if (inlineCalculations){ - inlineCalculations.forEach(calc => { - calc.value = calc.result; - delete calc.result; - }); - } - return { - $unset: { - [field + 'Calculations']: 1, - }, - $set: { - [field]: { - text, - inlineCalculations, - } - }, - }; -} - -function unConvertInlineComputationField(object, field){ - const text = get(object, field)?.text; - const inlineCalculations = get(object, field)?.inlineCalculations; - if (inlineCalculations) { - inlineCalculations.forEach(calc => { - calc.result = calc.value; - delete calc.value; - }); - } - return { - $set: { - [field]: text, - [field + 'Calculations']: inlineCalculations, - }, - }; -} diff --git a/app/imports/migrations/server/2.0-beta.33-dbv1.js b/app/imports/migrations/server/2.0-beta.33-dbv1.js new file mode 100644 index 00000000..da930803 --- /dev/null +++ b/app/imports/migrations/server/2.0-beta.33-dbv1.js @@ -0,0 +1,88 @@ +import { Migrations } from 'meteor/percolate:migrations'; +import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; +import LibraryNodes from '/imports/api/library/LibraryNodes.js'; +import { get } from 'lodash'; +import embedInlineCalculations from '/imports/api/creature/computation/afterComputation/embedInlineCalculations.js'; +import transformFields from '/imports/migrations/server/transformFields.js'; +import SCHEMA_VERSION from '/imports/constants/SCHEMA_VERSION.js'; + +// Git version 2.0-beta.33 +// Database version 1 +Migrations.add({ + version: 1, + name: 'Unifies calculated field schema', + up(){ + migrate(); + }, + down(){ + migrate({reversed: true}); + }, +}); + +function migrate({reversed} = {}){ + migrateCollection({collection: CreatureProperties, reversed}); + migrateCollection({collection: LibraryNodes, reversed}); +} + +function migrateCollection({collection, reversed}){ + const bulk = collection.rawCollection().initializeUnorderedBulkOp(); + collection.find({}).forEach(prop => { + const newProp = migrateProperty({collection, reversed, prop}); + bulk.find({ _id: prop._id }).replaceOne(newProp); + }); + bulk.execute(); +} + +export default function migrateProperty({collection, reversed, prop}){ + const transforms = transformsByPropType[prop.type]; + let migratedProp = transformFields(prop, transforms, reversed); + const schema = collection.simpleSchema({type: prop.type}); + // Only clean if the schema version matches our destination version + if(!reversed && SCHEMA_VERSION === 1){ + try { + migratedProp = schema.clean(migratedProp); + schema.validate(migratedProp); + } catch(e){ + console.warn(e); + } + } + return migratedProp; +} + +const actionTransforms = [ + ...getComputedPropertyTransforms('uses'), + ...getComputedPropertyTransforms('resources.attributesConsumed.$.quantity'), + ...getComputedPropertyTransforms('resources.itemsConsumed.$.quantity'), + ...getInlineComputationTransforms('summary'), + ...getInlineComputationTransforms('description'), +]; + +const transformsByPropType = { + 'action': actionTransforms, + 'adjustment': [ + ...getComputedPropertyTransforms('amount'), + ], + 'attack': [ + ...actionTransforms, + ...getComputedPropertyTransforms('rollBonus'), + ], +}; + +function getComputedPropertyTransforms(key){ + return [ + {from: key, to: `${key}.calculation`}, + {from: `${key}Result`, to: `${key}.value`}, + {from: `${key}Errors`, to: `${key}.errors`}, + ]; +} + +function getInlineComputationTransforms(key){ + return [ + {from: key, to: `${key}.text`}, + {from: `${key}Calculations`, to: `${key}.inlineCalculations`}, + {to: `${key}.value`, up: (val, doc) => + embedInlineCalculations(get(doc, key), get(doc, `${key}Calculations`)) + }, + {from: `${key}Calculations.$.result`, to: `${key}.inlineCalculations.$.value`}, + ]; +} diff --git a/app/imports/migrations/server/2.0-beta.33-dbv1.test.js b/app/imports/migrations/server/2.0-beta.33-dbv1.test.js new file mode 100644 index 00000000..fd79387c --- /dev/null +++ b/app/imports/migrations/server/2.0-beta.33-dbv1.test.js @@ -0,0 +1,85 @@ +import CreatureProperties from '/imports/api/creature/creatureProperties/CreatureProperties.js'; +import migrateProperty from './2.0-beta.33-dbv1.js'; +import { assert } from 'chai'; + +const exampleAction = { + '_id':'hY5MKZ4ivaoTRpNWy', + 'actionType':'bonus', + 'target':'singleTarget', + 'tags':[], + 'resources':{ + 'itemsConsumed':[], + 'attributesConsumed':[{ + '_id':'FaK6jXEj3pSe7mNuu', + 'quantity':1, + 'variableName':'HunterTech', + 'statId':'qccf9j5tfNJjZ3GGn', + 'statName':'Hunter\'s Technique', + 'available':5 + }], + }, + 'type':'action', + 'name':'Hexblade\\\'s Curse', + 'parent':{ + 'id':'JqtDmqa5Zd3xpts5G', + 'collection':'creatureProperties' + }, + 'ancestors':[ + { + 'collection':'creatures', + 'id':'X9rzFhsgFhodYfHmG' + }, + ], + 'order':315, + 'summary':'Curse a creature for 1 minute. The curse ends early if {warlock.level >14 ? "" : "the target dies, or"} you are incapacitated. \nGain the following benefits: \n- *Bonus to damage rolls against the cursed target of* **+{proficiencyBonus}**. \n- Any attack roll you make against the cursed target is a **critical hit on a roll of 19 or 20**. \n- If the cursed target dies, you **regain {warlock.level+charisma.modifier} hit points**. \n{warlock.level <9 ? "" : "- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses."}', + 'uses':'1', + 'usesResult':1, + 'reset':'shortRest', + 'usesUsed':0, + 'description':'Starting at 1st level, you gain the ability to place a baleful curse on someone. As a bonus action, choose one creature you can see within 30 feet of you. The target is cursed for 1 minute. The curse ends early if the target dies, you die, or you are incapacitated. Until the curse ends, you gain the following benefits:\n\n- You gain a bonus to damage rolls against the cursed target. The bonus equals your proficiency bonus.\n- Any attack roll you make against the cursed target is a critical hit on a roll of 19 or 20 on the d20.\n- If the cursed target dies, you regain hit points equal to your warlock level + your Charisma modifier (minimum of 1 hit point). \n{warlock.level <10 ? "" :"- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses."} \nYou can\\\'t use this feature again until you finish a short or long rest.', + 'color':'#8e24aa', + 'dependencies':[ + '4eM4YkgAaoCJfCfQ8', + ], + 'descriptionCalculations':[ + { + 'calculation':'warlock.level <10 ? "" :"- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses."', + 'result':'- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses.' + } + ], + 'summaryCalculations':[ + { + 'calculation':'warlock.level >14 ? "" : "the target dies, or"', + 'result':'the target dies, or' + }, + { + 'calculation':'proficiencyBonus', + 'result':'4' + }, + { + 'calculation':'warlock.level+charisma.modifier', + 'result':'15' + }, + { + 'calculation':'warlock.level <9 ? "" : "- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses."', + 'result':'- If you are hit with an attack by your cursed target, use your reaction to roll a d6. On a 4 or higher, the attack instead misses.' + } + ] +}; + +describe('migrateProperty', function () { + it('Migrates actions reversibly', function () { + const action = {...exampleAction}; + const newAction = migrateProperty({ + collection: CreatureProperties, + prop: action + }); + const reversedAction = migrateProperty({ + collection: CreatureProperties, + prop: newAction, + reversed: true, + }); + assert.deepEqual(action, exampleAction, 'action should not be bashed'); + assert.deepEqual(exampleAction, reversedAction, 'operation should be reversible'); + }); +}); diff --git a/app/imports/migrations/index.js b/app/imports/migrations/server/index.js similarity index 52% rename from app/imports/migrations/index.js rename to app/imports/migrations/server/index.js index 97fcde14..615035e1 100644 --- a/app/imports/migrations/index.js +++ b/app/imports/migrations/server/index.js @@ -1,2 +1 @@ import './2.0-beta.33-dbv1.js'; -import './methods/index.js'; diff --git a/app/imports/migrations/server/transformFields.js b/app/imports/migrations/server/transformFields.js new file mode 100644 index 00000000..e99d17af --- /dev/null +++ b/app/imports/migrations/server/transformFields.js @@ -0,0 +1,112 @@ +import { get, set, unset, forEachRight, cloneDeep } from 'lodash'; + +export default function transformFields(src, transformList, reversed = false){ + // don't bash the old document during the transforms + let doc = cloneDeep(src); + for(let originalTransform of transformList){ + let transform; + // Swap to and from when reversing + if (reversed){ + transform = { + to: originalTransform.from, + from: originalTransform.to, + up: originalTransform.down, + } + } else { + transform = {...originalTransform}; + } + if (transform.from?.includes('$.')){ + transformArrayField(src, doc, transform, reversed); + } else { + transformSingleField(src, doc, transform); + } + } + return doc; +} + +function transformSingleField(src, doc, {from, to, up}){ + // Get the value in the `from` path and delete it + let value = undefined; + if (from){ + value = get(src, from); + unset(doc, from); + } + + // apply the transform function + if (up){ + value = up(value, src, doc); + } + + // Store the value in the `to` path or unset it if undefined + if (to){ + if (value === undefined){ + unset(doc, to); + } else { + set(doc, to, value); + } + } +} + +/** + * from: 'from.$.here', to: 'to.$.here' + * where from and to are an [array, of, objects] that each need to be modified + * documents at 'from.x.here' will map to 'to.x.here' + * Attempts to support 'from.$.here.$.nested' + * by mapping 'from.x.here.y.nest.z.deep' to 'to.y.nest.z.lessDeep' + * from depth must be >= to depth + */ +function transformArrayField(src, doc, {from, to, up}, reversed){ + const fromSplit = from.split('.$'); + const toSplit = to.split('.$'); + + if (toSplit.length > fromSplit.length){ + throw 'Can\'t transform array fields where "to" is deeper than "from"' + } + + // Stack based depth first traversal of arrays + const stack = [{ + array: get(src, fromSplit[0]), + paths: fromSplit.slice(1), + currentPath: fromSplit[0], + indices: [], + }]; + while(stack.length){ + const state = stack.pop(); + // Iterate forwads or backwads defpending on our migration direction + if (reversed){ + forEachRight(state.array, iterate(stack, state, src, doc, toSplit, up)); + } else { + state.array.forEach(iterate(stack, state, src, doc, toSplit, up)); + } + } +} + +function iterate(stack, state, src, doc, toSplit, up){return function(key, index){ + const currentPath = `${state.currentPath}[${index}]${state.paths[0]}` + if (state.paths.length == 1){ + transformSingleField(src, doc, { + from: currentPath, + to: buildToPath(toSplit, [...state.indices, index]), + up + }); + } else { + stack.push({ + array: get(src, currentPath), + paths: state.paths.slice(1), + currentPath, + indices: [...state.indices, index], + }); + } +}} + +function buildToPath(toSplit, indices){ + let toPath = ''; + let offset = indices.length - toSplit.length + 1; + toSplit.forEach((path, i) => { + toPath += `${path}`; + if (i < toSplit.length - 1){ + toPath += `[${indices[i + offset]}]` + } + }); + return toPath; +} diff --git a/app/imports/migrations/server/transformFields.test.js b/app/imports/migrations/server/transformFields.test.js new file mode 100644 index 00000000..b34d09e0 --- /dev/null +++ b/app/imports/migrations/server/transformFields.test.js @@ -0,0 +1,85 @@ +import transformFields from './transformFields.js'; +import { assert } from 'chai'; + +const originalDoc = { + name: 'doc name', + description: 'a document to test transforms on', + nest: { + deeper: { + field: 'some nested field' + }, + }, + array: [{num: 1}, {num: 3}, {num: 5}], + nestArray: [ + {array: [{item: 2},{item: 4},{item: 6}]}, + {array: [{item: 8},{item: 10},{item: 12}]}, + {array: [{item: 14},{item: 16},{item: 18}]}, + ], +}; + +describe('transformFields', function () { + + it('Takes a doc and transforms it according to single field rules', function () { + let doc = {...originalDoc}; + const transformList = [ + {from: 'name', to: 'title'}, + ]; + + assert.equal(doc.name, 'doc name', '.name is set'); + assert.doesNotHaveAnyKeys(doc, ['title'], '.title doesn\'t exist'); + + doc = transformFields(doc, transformList); + + assert.equal(doc.title, 'doc name', '.name -> .title'); + assert.doesNotHaveAnyKeys(doc, ['name'], '.name deleted'); + }); + + it('Takes a doc and transforms it with functions', function () { + let doc = {...originalDoc}; + const transformList = [ + {from: 'name', to: 'name', up: name => name.toUpperCase()}, + ]; + assert.equal(doc.name, 'doc name', 'name in lowercase'); + doc = transformFields(doc, transformList); + assert.equal(doc.name, 'DOC NAME', 'name in uppercase'); + }); + + it('Handles empty to and from fields', function () { + let doc = {...originalDoc}; + const transformList = [ + {to: 'created', up: () => 'from thin air'}, + {from: 'description'}, + ]; + doc = transformFields(doc, transformList); + assert.equal(doc.created, 'from thin air', 'created field success'); + assert.doesNotHaveAnyKeys(doc, ['description'], '.description deleted'); + }); + + it('Takes a nested field and transforms it into a different nested field', function () { + let doc = {...originalDoc}; + const transformList = [ + {from: 'nest.deeper', to: 'different.deep'}, + ]; + doc = transformFields(doc, transformList); + assert.equal(doc.different.deep.field, 'some nested field', 'field moved correctly'); + assert.doesNotHaveAnyKeys(doc.nest, ['deeper'], 'doc.nest.deeper deleted'); + }); + + it('Transforms arrays', function () { + let doc = {...originalDoc}; + const transformList = [ + {from: 'array.$.num', to: 'list.$.number'}, + ]; + doc = transformFields(doc, transformList); + assert.equal(doc.list[1].number, 3, 'array field moved correctly'); + }); + + it('Transforms deep arrays', function () { + let doc = {...originalDoc}; + const transformList = [ + {from: 'nestArray.$.array.$.item', to: 'nestList.$.list.$.ting'}, + ]; + doc = transformFields(doc, transformList); + assert.equal(doc.nestList[2].list[1].ting, 16, 'nested array field moved correctly'); + }); +}); diff --git a/app/package-lock.json b/app/package-lock.json index 055c293f..d38d5600 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -289,6 +289,12 @@ "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=" }, + "assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true + }, "astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -433,6 +439,20 @@ "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=" }, + "chai": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.4.tgz", + "integrity": "sha512-yS5H68VYOCtN1cjfwumDSuzn/9c+yza4f3reKXlE5rUg7SFcCEy90gJvydNgOYtblyf4Zi6jIWRnXOgErta0KA==", + "dev": true, + "requires": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.2", + "deep-eql": "^3.0.1", + "get-func-name": "^2.0.0", + "pathval": "^1.1.1", + "type-detect": "^4.0.5" + } + }, "chalk": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.1.tgz", @@ -484,6 +504,12 @@ } } }, + "check-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz", + "integrity": "sha1-V00xLt2Iu13YkS6Sht1sCu1KrII=", + "dev": true + }, "chokidar": { "version": "3.5.2", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.2.tgz", @@ -651,6 +677,15 @@ "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=" }, + "deep-eql": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-3.0.1.tgz", + "integrity": "sha512-+QeIQyN5ZuO+3Uk5DYh6/1eKO0m0YmJFGNmFHGACpf1ClL1nmlV/p4gNgbl2pJGxgXb4faqo6UE+M5ACEMyVcw==", + "dev": true, + "requires": { + "type-detect": "^4.0.0" + } + }, "deep-is": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", @@ -1106,6 +1141,12 @@ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==" }, + "get-func-name": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz", + "integrity": "sha1-6td0q+5y4gQJQzoGY2YCPdaIekE=", + "dev": true + }, "getpass": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", @@ -2491,6 +2532,12 @@ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true }, + "pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true + }, "performance-now": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", @@ -2966,9 +3013,9 @@ } }, "tar": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.6.tgz", - "integrity": "sha512-oaWyu5dQbHaYcyZCTfyPpC+VmI62/OM2RTUYavTk1MDr1cwW5Boi3baeYQKiZbY2uSQJGr+iMOzb/JFxLrft+g==", + "version": "6.1.11", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.1.11.tgz", + "integrity": "sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==", "requires": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -3024,6 +3071,12 @@ "prelude-ls": "^1.2.1" } }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, "type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", diff --git a/app/package.json b/app/package.json index 36bfacf1..3e39c519 100644 --- a/app/package.json +++ b/app/package.json @@ -52,6 +52,7 @@ "vuex": "^3.1.3" }, "devDependencies": { + "chai": "^4.3.4", "eslint": "^7.31.0", "eslint-plugin-vue": "^7.14.0", "eslint-plugin-vuetify": "^1.0.1", @@ -96,7 +97,8 @@ "es2020": true, "browser": true, "node": true, - "meteor": true + "meteor": true, + "mocha": true }, "rules": { "quotes": [ diff --git a/app/server/main.js b/app/server/main.js index f7ac36c6..1a70dfbe 100644 --- a/app/server/main.js +++ b/app/server/main.js @@ -6,4 +6,5 @@ import '/imports/server/publications/index.js'; import '/imports/server/cron/deleteSoftRemovedDocuments.js'; import '/imports/api/parenting/organizeMethods.js'; import '/imports/api/users/patreon/updatePatreonOnLogin.js'; -import '/imports/migrations/index.js'; +import '/imports/migrations/server/index.js'; +import '/imports/migrations/methods/index.js'