From 4bbd4cc2d964d60ce2a70dfb453340f1dc487d49 Mon Sep 17 00:00:00 2001 From: Kir_Antipov Date: Fri, 27 Jan 2023 12:04:42 +0000 Subject: [PATCH] `Version` refactoring --- src/utils/versioning/version.ts | 176 +++++++++++++++++--- tests/unit/utils/versioning/version.spec.ts | 81 +++++++++ 2 files changed, 236 insertions(+), 21 deletions(-) create mode 100644 tests/unit/utils/versioning/version.spec.ts diff --git a/src/utils/versioning/version.ts b/src/utils/versioning/version.ts index 0fdb07a..0349791 100644 --- a/src/utils/versioning/version.ts +++ b/src/utils/versioning/version.ts @@ -1,31 +1,165 @@ -export default class Version { - public readonly major: number; - public readonly minor: number; - public readonly build: number; +import { SemVer, coerce, parse as parseSemVer } from "semver"; - public constructor(major: number, minor: number, build: number); +/** + * Represents a version number, which is a set of three non-negative integers: major, minor, and patch. + * + * This interface provides methods to compare versions and format them into a string representation. + */ +export interface Version { + /** + * The major version number. + */ + get major(): number; - public constructor(version: string); + /** + * The minor version number. + */ + get minor(): number; - public constructor(major: number | string, minor?: number, build?: number) { - if (typeof major === "string") { - [this.major, this.minor, this.build] = major.split(".").map(x => isNaN(+x) ? 0 : +x).concat(0, 0); - } else { - this.major = major || 0; - this.minor = minor || 0; - this.build = build || 0; - } + /** + * The patch version number. + */ + get patch(): number; + + /** + * Compares the current version to another one. + * + * @param other - The version to compare with. + * + * @returns A number indicating the comparison result: + * + * - 0 if both versions are equal. + * - A positive number if the current version is greater. + * - A negative number if the other version is greater. + */ + compare(other?: string | Version): number; + + /** + * Formats the version into a string representation. + * + * @returns The string representation of the version. + */ + format(): string; + + /** + * Returns the original string representation of the version. + * + * @returns The original string representation of the version. + */ + toString(): string; +} + +/** + * Parses a version string into a {@link Version} instance. + * + * @param version - The version string to parse. + * + * @returns A {@link Version} instance if parsing is successful, or `undefined` if it fails. + */ +export function parseVersion(version: string): Version | undefined { + return SemVerVersion.parse(version); +} + +/** + * Regular expression for matching semver-like tags in version strings. + */ +const SEMVER_TAG_REGEX = /[a-z]{0,2}((\d+\.\d+)(\.\d+)?(.*))/i; + +/** + * Represents a version number compliant with the Semantic Versioning specification. + */ +class SemVerVersion implements Version { + /** + * The SemVer object representing the parsed semantic version. + */ + private readonly _semver: SemVer; + + /** + * The original string representation of the version. + */ + private readonly _version: string; + + /** + * Constructs a new {@link SemVerVersion} instance. + * + * @param semver - The SemVer object representing the parsed semantic version. + * @param version - The original string representation of the version. + */ + constructor(semver: SemVer, version?: string) { + this._semver = semver; + this._version = version ?? semver.format(); } - public equals(version: unknown): boolean { - if (version instanceof Version) { - return this.major === version.major && this.minor === version.minor && this.build === version.build; + /** + * Parses a version string into a {@link SemVerVersion} instance. + * + * @param version - The version string to parse. + * + * @returns A {@link SemVerVersion} instance if parsing is successful, or `undefined` if it fails. + */ + static parse(version: string): SemVerVersion | undefined { + const semver = parseSemVer(version); + if (semver) { + return new SemVerVersion(semver, version); } - return typeof version === "string" && this.equals(new Version(version)); + + const match = version.match(SEMVER_TAG_REGEX); + if (match) { + const numericVersion = match[3] ? match[1] : `${match[2]}.0${match[4]}`; + const parsedSemVer = parseSemVer(numericVersion) || coerce(numericVersion); + return new SemVerVersion(parsedSemVer, match[0]); + } + + return undefined; } - public static fromName(name: string): string { - const match = name.match(/[a-z]{0,2}\d+\.\d+.*/i); - return match ? match[0] : name; + /** + * @inheritdoc + */ + get major(): number { + return this._semver.major; + } + + /** + * @inheritdoc + */ + get minor(): number { + return this._semver.minor; + } + + /** + * @inheritdoc + */ + get patch(): number { + return this._semver.patch; + } + + /** + * @inheritdoc + */ + compare(other?: string | Version): number { + if (other === null || other === undefined) { + return 1; + } + + if (typeof other === "string") { + other = SemVerVersion.parse(other); + } + + return other instanceof SemVerVersion ? this._semver.compare(other._semver) : -other.compare(this); + } + + /** + * @inheritdoc + */ + format(): string { + return this._semver.format(); + } + + /** + * @inheritdoc + */ + toString(): string { + return this._version; } } diff --git a/tests/unit/utils/versioning/version.spec.ts b/tests/unit/utils/versioning/version.spec.ts new file mode 100644 index 0000000..1c5a6c5 --- /dev/null +++ b/tests/unit/utils/versioning/version.spec.ts @@ -0,0 +1,81 @@ +import { parseVersion } from "@/utils/versioning/version"; + +describe("parseVersion", () => { + test("returns undefined when parsing invalid string", () => { + const version = parseVersion("abc"); + + expect(version).toBeUndefined(); + }); + + test("parses classic semver format (major.minor.patch)", () => { + const version = parseVersion("1.2.3"); + + expect(version).toMatchObject({ major: 1, minor: 2, patch: 3 }); + }); + + test("parses classic semver format with pre-release information", () => { + const version = parseVersion("1.2.3-alpha.1"); + + expect(version).toMatchObject({ major: 1, minor: 2, patch: 3 }); + expect(version.toString()).toBe("1.2.3-alpha.1"); + }); + + test("parses version strings with missing patch number (major.minor)", () => { + const version = parseVersion("1.2"); + + expect(version).toMatchObject({ major: 1, minor: 2, patch: 0 }); + expect(version.format()).toBe("1.2.0"); + expect(version.toString()).toBe("1.2"); + }); + + test("parses version strings with missing patch number and pre-release information", () => { + const version = parseVersion("1.2-alpha.1"); + + expect(version).toMatchObject({ major: 1, minor: 2, patch: 0 }); + expect(version.format()).toBe("1.2.0-alpha.1"); + expect(version.toString()).toBe("1.2-alpha.1"); + }); + + test("file version is correctly extracted from the filename", () => { + expect(String(parseVersion("sodium-fabric-mc1.17.1-0.3.2+build.7"))).toBe("mc1.17.1-0.3.2+build.7"); + expect(String(parseVersion("fabric-api-0.40.1+1.18_experimental"))).toBe("0.40.1+1.18_experimental"); + expect(String(parseVersion("TechReborn-5.0.8-beta+build.111"))).toBe("5.0.8-beta+build.111"); + expect(String(parseVersion("TechReborn-1.17-5.0.1-beta+build.29"))).toBe("1.17-5.0.1-beta+build.29"); + expect(String(parseVersion("Terra-forge-5.3.3-BETA+ec3b0e5d"))).toBe("5.3.3-BETA+ec3b0e5d"); + expect(String(parseVersion("modmenu-2.0.12"))).toBe("2.0.12"); + expect(String(parseVersion("enhancedblockentities-0.5+1.17"))).toBe("0.5+1.17"); + expect(String(parseVersion("sync-mc1.17.x-1.2"))).toBe("mc1.17.x-1.2"); + }); +}); + +describe("Version", () => { + test("contains valid major, minor, and patch numbers", () => { + expect(parseVersion("1.2.3")).toMatchObject({ major: 1, minor: 2, patch: 3 }); + expect(parseVersion("1.2.3-alpha.1")).toMatchObject({ major: 1, minor: 2, patch: 3 }); + expect(parseVersion("1.2")).toMatchObject({ major: 1, minor: 2, patch: 0 }); + expect(parseVersion("1.2-alpha.1")).toMatchObject({ major: 1, minor: 2, patch: 0 }); + }); + + test("compares versions correctly", () => { + const version1 = parseVersion("1.2.3"); + const version2 = parseVersion("2.3.4"); + + expect(version1.compare(version2)).toBeLessThan(0); + expect(version2.compare(version1)).toBeGreaterThan(0); + expect(version1.compare(version1)).toBe(0); + }); + + test("formats correctly", () => { + expect(parseVersion("1.0.0").format()).toEqual("1.0.0"); + expect(parseVersion("1.0.0-alpha.1").format()).toEqual("1.0.0-alpha.1"); + expect(parseVersion("1.0").format()).toEqual("1.0.0"); + expect(parseVersion("1.0-alpha.1").format()).toEqual("1.0.0-alpha.1"); + }); + + test("toString returns the original string representation", () => { + expect(parseVersion("1.0.0").toString()).toEqual("1.0.0"); + expect(parseVersion("1.0").toString()).toEqual("1.0"); + expect(parseVersion("1.0.0-alpha.1").toString()).toEqual("1.0.0-alpha.1"); + expect(parseVersion("1.0-alpha.1").toString()).toEqual("1.0-alpha.1"); + }); +});