diff --git a/src/components/navigation/NavigationMenu.vue b/src/components/navigation/NavigationMenu.vue index 9a2ae473d..bcea0af9b 100644 --- a/src/components/navigation/NavigationMenu.vue +++ b/src/components/navigation/NavigationMenu.vue @@ -67,7 +67,6 @@ import R2Error from '../../model/errors/R2Error'; import Game from '../../model/game/Game'; import GameManager from '../../model/game/GameManager'; import Profile from '../../model/Profile'; -import ThunderstoreMod from '../../model/ThunderstoreMod'; import { LaunchMode, launch, @@ -83,11 +82,9 @@ export default class NavigationMenu extends Vue { private LaunchMode = LaunchMode; get thunderstoreModCount() { - let mods: ThunderstoreMod[] = this.$store.state.tsMods.mods; - return this.$store.state.modFilters.showDeprecatedPackages - ? mods.length - : mods.filter((m) => !m.isDeprecated()).length; + ? this.$store.state.tsMods.mods.length + : this.$store.getters['tsMods/undeprecatedModCount']; } get localModCount(): number { diff --git a/src/components/views/LocalModList/LocalModCard.vue b/src/components/views/LocalModList/LocalModCard.vue index 97b9a41d4..6c800e6d0 100644 --- a/src/components/views/LocalModList/LocalModCard.vue +++ b/src/components/views/LocalModList/LocalModCard.vue @@ -28,7 +28,7 @@ export default class LocalModCard extends Vue { } get isDeprecated() { - return this.tsMod ? this.tsMod.isDeprecated() : false; + return this.$store.state.tsMods.deprecated.get(this.mod.getName()) || false; } get isLatestVersion() { diff --git a/src/components/views/OnlineModView.vue b/src/components/views/OnlineModView.vue index d84b9531d..c74a3b8b6 100644 --- a/src/components/views/OnlineModView.vue +++ b/src/components/views/OnlineModView.vue @@ -155,7 +155,9 @@ export default class OnlineModView extends Vue { this.searchableThunderstoreModList = this.searchableThunderstoreModList.filter(mod => !mod.getNsfwFlag()); } if (!showDeprecatedPackages) { - this.searchableThunderstoreModList = this.searchableThunderstoreModList.filter(mod => !mod.isDeprecated()); + this.searchableThunderstoreModList = this.searchableThunderstoreModList.filter( + mod => !this.$store.state.tsMods.deprecated.get(mod.getFullName()) + ); } if (filterCategories.length > 0) { this.searchableThunderstoreModList = this.searchableThunderstoreModList.filter((x: ThunderstoreMod) => { diff --git a/src/r2mm/data/ThunderstorePackages.ts b/src/r2mm/data/ThunderstorePackages.ts index 617895638..ceaa78cc8 100644 --- a/src/r2mm/data/ThunderstorePackages.ts +++ b/src/r2mm/data/ThunderstorePackages.ts @@ -5,8 +5,6 @@ import ConnectionProvider from '../../providers/generic/connection/ConnectionPro import * as PackageDb from '../manager/PackageDexieStore'; export default class ThunderstorePackages { - - public static PACKAGES_MAP: Map = new Map(); // TODO: would IndexedDB or Vuex be more suitable place for exclusions? public static EXCLUSIONS: string[] = []; @@ -34,50 +32,84 @@ export default class ThunderstorePackages { } public static getDeprecatedPackageMap(packages: ThunderstoreMod[]): Map { - ThunderstorePackages.PACKAGES_MAP = packages.reduce((map, pkg) => { + const packageMap = packages.reduce((map, pkg) => { map.set(pkg.getFullName(), pkg); return map; }, new Map()); + const deprecationMap = new Map(); + const currentChain = new Set(); - const result = new Map(); packages.forEach(pkg => { - this.populateDeprecatedPackageMapForModChain(pkg, result); + this._populateDeprecatedPackageMapForModChain(pkg, packageMap, deprecationMap, currentChain); }); - return result; + + return deprecationMap; } /** - * TODO: This doesn't really do what the dosctring below says: - * deprecated dependencies do NOT mark the dependant deprecated. - * - * "Smart" package deprecation determination by keeping track of previously determine dependencies. + * "Smart" package deprecation determination by keeping track of previously determined dependencies. * This ensures that we hit as few iterations as possible to speed up calculation time. * * @param mod The mod to check for deprecation status / deprecated dependencies - * @param map A map to record previously hit items - * @private + * @param deprecationMap A map to record previously hit items + * @param currentChain A set to record recursion stack to avoid infinite loops + * @public (to allow tests to mock the function) */ - private static populateDeprecatedPackageMapForModChain(mod: ThunderstoreMod, map: Map) { - if (map.get(mod.getFullName()) != undefined) { - return; // Deprecation status has already been decided. - } else { - if (mod.isDeprecated()) { - map.set(mod.getFullName(), true); - } else { - for (const value of mod.getDependencies()) { - const tsVariant = this.PACKAGES_MAP.get(value) - if (tsVariant === undefined) { - continue; - } - this.populateDeprecatedPackageMapForModChain(tsVariant, map); - } - // If mod was not set down the chain then has no deprecated dependencies. - // This means the mod does not result in a deprecation status. - if (map.get(mod.getFullName()) === undefined) { - map.set(mod.getFullName(), false); - } + public static _populateDeprecatedPackageMapForModChain( + mod: ThunderstoreMod, + packageMap: Map, + deprecationMap: Map, + currentChain: Set + ): boolean { + const previouslyCalculatedValue = deprecationMap.get(mod.getFullName()); + if (previouslyCalculatedValue !== undefined) { + return previouslyCalculatedValue; + } + + // No need to check dependencies if the mod itself is deprecated. + // Dependencies will be checked by the for-loop in the calling + // function anyway. + if (mod.isDeprecated()) { + deprecationMap.set(mod.getFullName(), true); + return true; + } + + for (const dependencyNameAndVersion of mod.getLatestVersion().getDependencies()) { + const dependencyName = dependencyNameAndVersion.substring(0, dependencyNameAndVersion.lastIndexOf('-')); + + if (currentChain.has(dependencyName)) { + continue; + } + const dependency = packageMap.get(dependencyName); + + // Package isn't available on Thunderstore, so we can't tell + // if it's deprecated or not. This will also include deps of + // packages uploaded into wrong community since the + // packageMap contains only packages from this community. + // Based on manual testing with real data, caching these to + // deprecationMap doesn't seem to improve overall performance. + if (dependency === undefined) { + continue; + } + + // Keep track of the dependency chain currently under + // investigation to avoid infinite recursive loops. + currentChain.add(mod.getFullName()); + const dependencyDeprecated = this._populateDeprecatedPackageMapForModChain( + dependency, packageMap, deprecationMap, currentChain + ); + currentChain.delete(mod.getFullName()); + deprecationMap.set(dependencyName, dependencyDeprecated); + + // Eject early on the first deprecated dependency for performance. + if (dependencyDeprecated) { + deprecationMap.set(mod.getFullName(), true); + return true; } } - } + // Package is not depreceated by itself nor due to dependencies. + deprecationMap.set(mod.getFullName(), false); + return false; + } } diff --git a/src/store/modules/TsModsModule.ts b/src/store/modules/TsModsModule.ts index 1be513677..d44ff4e4c 100644 --- a/src/store/modules/TsModsModule.ts +++ b/src/store/modules/TsModsModule.ts @@ -95,6 +95,10 @@ export const TsModsModule = { /*** Return ThunderstoreMod representation of a ManifestV2 */ tsMod: (_state, getters) => (mod: ManifestV2): ThunderstoreMod | undefined => { return getters.cachedMod(mod).tsMod; + }, + + undeprecatedModCount(state) { + return [...state.deprecated].filter(([_, isDeprecated]) => !isDeprecated).length; } }, diff --git a/test/jest/__tests__/utils/utils.getDeprecatedPackageMap.ts.spec.ts b/test/jest/__tests__/utils/utils.getDeprecatedPackageMap.ts.spec.ts new file mode 100644 index 000000000..2d1682c73 --- /dev/null +++ b/test/jest/__tests__/utils/utils.getDeprecatedPackageMap.ts.spec.ts @@ -0,0 +1,232 @@ +import ThunderstoreMod from "../../../../src/model/ThunderstoreMod"; +import ThunderstoreVersion from "../../../../src/model/ThunderstoreVersion"; +import ThunderstorePackages from "../../../../src/r2mm/data/ThunderstorePackages"; + +describe("ThunderstorePackages.getDeprecatedPackageMap", () => { + let spyedPopulator: jest.SpyInstance; + + beforeEach(() => { + spyedPopulator = jest.spyOn(ThunderstorePackages, '_populateDeprecatedPackageMapForModChain'); + }); + + afterEach(() => { + jest.restoreAllMocks(); // restore the spy created with spyOn + }); + + it("Handles simple undeprecated chain", () => { + const mods =[ + createStubMod('TeamA-Mod1', false, ['TeamA-Mod2', 'TeamB-Mod1']), + createStubMod('TeamA-Mod2'), + createStubMod('TeamB-Mod1', false, ['TeamC-Mod1']), + createStubMod('TeamC-Mod1') + ]; + + const actual = ThunderstorePackages.getDeprecatedPackageMap(mods); + + expect(actual.size).toBe(4); + expect(actual.get('TeamA-Mod1')).toStrictEqual(false); + expect(actual.get('TeamA-Mod2')).toStrictEqual(false); + expect(actual.get('TeamB-Mod1')).toStrictEqual(false); + expect(actual.get('TeamC-Mod1')).toStrictEqual(false); + }); + + it("Handles simple chain with deprecation", () => { + const mods =[ + createStubMod('TeamA-Mod1', false, ['TeamA-Mod2', 'TeamB-Mod1']), + createStubMod('TeamA-Mod2'), + createStubMod('TeamB-Mod1', true, ['TeamC-Mod1']), + createStubMod('TeamC-Mod1') + ]; + + const actual = ThunderstorePackages.getDeprecatedPackageMap(mods); + + expect(actual.size).toBe(4); + expect(actual.get('TeamA-Mod1')).toStrictEqual(true); + expect(actual.get('TeamA-Mod2')).toStrictEqual(false); + expect(actual.get('TeamB-Mod1')).toStrictEqual(true); + expect(actual.get('TeamC-Mod1')).toStrictEqual(false); + }); + + it("Doesn't infinite-loop on direct dependency loop", () => { + const mods =[ + createStubMod('Degrec-Alfie_Knee', false, ['Degrec-Alfie_Other_Knee']), + createStubMod('Degrec-Alfie_Other_Knee', false, ['Degrec-Alfie_Knee']), + ]; + + const actual = ThunderstorePackages.getDeprecatedPackageMap(mods); + + expect(actual.size).toBe(2); + expect(actual.get('Degrec-Alfie_Knee')).toStrictEqual(false); + expect(actual.get('Degrec-Alfie_Other_Knee')).toStrictEqual(false); + }); + + it("Doesn't infinite-loop on three-way dependency loop", () => { + const mods =[ + createStubMod('Loop1-Mod1', false, ['Loop1-Mod2']), + createStubMod('Loop1-Mod2', false, ['Loop1-Mod3']), + createStubMod('Loop1-Mod3', false, ['Loop1-Mod1']), + ]; + + const actual = ThunderstorePackages.getDeprecatedPackageMap(mods); + + expect(actual.size).toBe(3); + expect(actual.get('Loop1-Mod1')).toStrictEqual(false); + expect(actual.get('Loop1-Mod2')).toStrictEqual(false); + expect(actual.get('Loop1-Mod3')).toStrictEqual(false); + }); + + it("Marks all mods deprecated on dependency loop", () => { + const mods =[ + createStubMod('Loop2-Mod1', false, ['Loop2-Mod4']), + createStubMod('Loop2-Mod2', false, ['Loop2-Mod3']), + createStubMod('Loop2-Mod3', true, ['Loop2-Mod1']), + createStubMod('Loop2-Mod4', false, ['Loop2-Mod2']), + ]; + + const actual = ThunderstorePackages.getDeprecatedPackageMap(mods); + + expect(actual.size).toBe(4); + expect(actual.get('Loop2-Mod1')).toStrictEqual(true); + expect(actual.get('Loop2-Mod2')).toStrictEqual(true); + expect(actual.get('Loop2-Mod3')).toStrictEqual(true); + expect(actual.get('Loop2-Mod4')).toStrictEqual(true); + }); + + it("Doesn't recheck already processed chains (top-down)", () => { + const mods =[ + createStubMod('X-Root1', false, ['X-ChainTop']), + createStubMod('X-Root2', false, ['X-ChainTop']), + createStubMod('X-Root3', false, ['X-ChainTop']), + createStubMod('X-ChainTop', false, ['X-ChainMiddle']), + createStubMod('X-ChainMiddle', false, ['X-ChainBottom']), + createStubMod('X-ChainBottom'), + ]; + + const actual = ThunderstorePackages.getDeprecatedPackageMap(mods); + + // Each mod causes one call due to for-loop (6 calls). + // Root1 causes three recursive calls down the chain (total 9 calls). + // Root2 and Root3 should each recursively call X-ChainTop, but + // no further down the chain (total 11 calls). + expect(spyedPopulator).toBeCalledTimes(11); + expect(actual.size).toBe(6); + expect(actual.get('X-Root1')).toStrictEqual(false); + expect(actual.get('X-Root2')).toStrictEqual(false); + expect(actual.get('X-Root3')).toStrictEqual(false); + expect(actual.get('X-ChainTop')).toStrictEqual(false); + expect(actual.get('X-ChainMiddle')).toStrictEqual(false); + expect(actual.get('X-ChainBottom')).toStrictEqual(false); + jest.restoreAllMocks(); // restore the spy created with spyOn + }); + + it("Doesn't recheck already processed chains (bottom-up)", () => { + const mods =[ + createStubMod('X-ChainBottom'), + createStubMod('X-ChainMiddle', false, ['X-ChainBottom']), + createStubMod('X-ChainTop', false, ['X-ChainMiddle']), + createStubMod('X-Root1', false, ['X-ChainTop']), + createStubMod('X-Root2', false, ['X-ChainTop']), + createStubMod('X-Root3', false, ['X-ChainTop']), + ]; + + const actual = ThunderstorePackages.getDeprecatedPackageMap(mods); + + // Each mod causes one call due to for-loop (6 calls). + // Excluding ChainBottom, each mod should recursively call their + // first dependency, but no further down the chain (total 11 calls). + expect(spyedPopulator).toBeCalledTimes(11); + expect(actual.size).toBe(6); + expect(actual.get('X-Root1')).toStrictEqual(false); + expect(actual.get('X-Root2')).toStrictEqual(false); + expect(actual.get('X-Root3')).toStrictEqual(false); + expect(actual.get('X-ChainTop')).toStrictEqual(false); + expect(actual.get('X-ChainMiddle')).toStrictEqual(false); + expect(actual.get('X-ChainBottom')).toStrictEqual(false); + }); + + it("Doesn't recheck already processed chains of deprecated mods (top-down)", () => { + const mods =[ + createStubMod('X-Root1', false, ['X-ChainTop']), + createStubMod('X-Root2', false, ['X-ChainTop']), + createStubMod('X-Root3', false, ['X-ChainTop']), + createStubMod('X-ChainTop', true, ['X-ChainMiddle']), + createStubMod('X-ChainMiddle', false, ['X-ChainBottom']), + createStubMod('X-ChainBottom'), + ]; + + const actual = ThunderstorePackages.getDeprecatedPackageMap(mods); + + // Each mod causes one call due to for-loop (6 calls). + // Each root mod recursively calls X-ChainTop, but the chain is + // not processed further since it's deprecated (total 9 calls). + // X-ChainMiddle recursively calls X-ChainBottom (total 10 calls). + expect(spyedPopulator).toBeCalledTimes(10); + expect(actual.size).toBe(6); + expect(actual.get('X-Root1')).toStrictEqual(true); + expect(actual.get('X-Root2')).toStrictEqual(true); + expect(actual.get('X-Root3')).toStrictEqual(true); + expect(actual.get('X-ChainTop')).toStrictEqual(true); + expect(actual.get('X-ChainMiddle')).toStrictEqual(false); + expect(actual.get('X-ChainBottom')).toStrictEqual(false); + }); + + it("Doesn't recheck already processed chains of deprecated mods (bottom-up)", () => { + const mods =[ + createStubMod('X-ChainBottom'), + createStubMod('X-ChainMiddle', false, ['X-ChainBottom']), + createStubMod('X-ChainTop', true, ['X-ChainMiddle']), + createStubMod('X-Root1', false, ['X-ChainTop']), + createStubMod('X-Root2', false, ['X-ChainTop']), + createStubMod('X-Root3', false, ['X-ChainTop']), + ]; + + const actual = ThunderstorePackages.getDeprecatedPackageMap(mods); + + // Each mod causes one call due to for-loop (6 calls). + // Each mod recursively calls it's direct dependency, except for + // X-ChainBottom (no dependencies) and X-ChainTop (because it's + // deprecated itself) (total 10 calls). + expect(spyedPopulator).toBeCalledTimes(10); + expect(actual.size).toBe(6); + expect(actual.get('X-Root1')).toStrictEqual(true); + expect(actual.get('X-Root2')).toStrictEqual(true); + expect(actual.get('X-Root3')).toStrictEqual(true); + expect(actual.get('X-ChainTop')).toStrictEqual(true); + expect(actual.get('X-ChainMiddle')).toStrictEqual(false); + expect(actual.get('X-ChainBottom')).toStrictEqual(false); + }); + + it("Ingores unknown dependencies", () => { + const mods =[ + createStubMod('Known-Mod1', false, ['Unknown-Mod1', 'Known-Mod2', 'Unknown-Mod2']), + createStubMod('Known-Mod2'), + ]; + + const actual = ThunderstorePackages.getDeprecatedPackageMap(mods); + + // Both known mods cause one call due to for-loop (2 calls). + // Known-Mod1 causes 1 recursive call (total 3 calls). + // Unknown mods are ignored and don't cause calls + expect(spyedPopulator).toBeCalledTimes(3); + expect(actual.size).toBe(2); + expect(actual.get('Known-Mod1')).toStrictEqual(false); + expect(actual.get('Known-Mod2')).toStrictEqual(false); + }); +}); + +const createStubMod = ( + modName: string, + deprecated: boolean = false, + dependencyNames: string[] = [] +): ThunderstoreMod => { + const mod = new ThunderstoreMod(); + mod.setFullName(modName); + mod.setDeprecatedStatus(deprecated); + + const version = new ThunderstoreVersion(); + version.setFullName(`${mod}-1.0.0`); + version.setDependencies(dependencyNames.map((depName) => `${depName}-1.0.0`)); + mod.setVersions([version]); + + return mod; +}