1 · Start with the problem
- Explore {active.length} active projects covering generation, validation, telemetry,
- event sourcing, Aspire, and build automation.
+ Explore {active.length} active projects covering generation, validation, telemetry, event
+ sourcing, Aspire, and build automation.
diff --git a/src/tests/unit/assets.test.ts b/src/tests/unit/assets.test.ts
new file mode 100644
index 0000000..b3acf69
--- /dev/null
+++ b/src/tests/unit/assets.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, test } from 'bun:test';
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+import { collectAssets } from '../../scripts/sync-assets';
+import { loadProjects } from '../../src/lib/manifest/load';
+
+describe('project asset mirroring', () => {
+ test('collects the declared assets with their repository and output', () => {
+ const assets = collectAssets(loadProjects());
+ const build = assets.find((asset) => asset.projectId === 'build');
+ expect(build).toEqual({
+ projectId: 'build',
+ repository: 'purview-dev/build',
+ path: 'purview-build.schema.json',
+ output: 'schemas/purview-build.json',
+ description: expect.any(String),
+ });
+ });
+
+ test('every declared output is a unique, relative path', () => {
+ const assets = collectAssets(loadProjects());
+ const outputs = assets.map((asset) => asset.output);
+ expect(new Set(outputs).size).toBe(outputs.length);
+ for (const output of outputs) {
+ expect(output.startsWith('/')).toBe(false);
+ expect(output.split('/')).not.toContain('..');
+ }
+ });
+
+ test('the committed fixture is a valid JSON Schema', () => {
+ const fixture = resolve('fixtures/assets/build/purview-build.schema.json');
+ const schema = JSON.parse(readFileSync(fixture, 'utf8')) as Record
;
+ expect(schema.$schema).toBe('https://json-schema.org/draft/2020-12/schema');
+ expect(schema.type).toBe('object');
+ expect(schema.properties).toBeTypeOf('object');
+ });
+});
diff --git a/src/tests/unit/check-projects.test.ts b/src/tests/unit/check-projects.test.ts
index d6de305..9a2a351 100644
--- a/src/tests/unit/check-projects.test.ts
+++ b/src/tests/unit/check-projects.test.ts
@@ -1,8 +1,17 @@
+import type { ResolvedProject } from '../../src/lib/manifest/load';
+import type { NuGetSearchEntry, ReleaseCacheData } from '../../src/lib/releases/types';
+
import { describe, expect, test } from 'bun:test';
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
-import { checkProjects } from '../../scripts/check-projects';
+import {
+ checkProjects,
+ targetFrameworkFindings,
+ versionConsistencyFindings,
+} from '../../scripts/check-projects';
+import { loadProjects } from '../../src/lib/manifest/load';
+import { RELEASE_CACHE_SCHEMA_VERSION } from '../../src/lib/releases/types';
/**
* The catalogue guard reads the generated docs cache, which only exists after a
@@ -12,6 +21,54 @@ import { checkProjects } from '../../scripts/check-projects';
*/
const HAS_DOCS_CACHE = existsSync(resolve('.cache/docs/index.json'));
+/** Load a real catalogue record so the synthetic cases carry realistic defaults. */
+function fixtureProject(id: string): ResolvedProject {
+ const found = loadProjects().find((candidate) => candidate.id === id);
+ if (!found) {
+ throw new Error(`Missing fixture project: ${id}`);
+ }
+ return found;
+}
+
+function project(overrides: Partial): ResolvedProject {
+ return { ...fixtureProject('zodsharp'), ...overrides };
+}
+
+interface PackageInput {
+ versions: string[];
+ targetFrameworks?: string[];
+}
+
+function cache(
+ packages: Record,
+ search: Record = {},
+): ReleaseCacheData {
+ return {
+ schema: RELEASE_CACHE_SCHEMA_VERSION,
+ retrievedAt: '2026-01-01T00:00:00.000Z',
+ repos: {},
+ releases: {},
+ packages: Object.fromEntries(
+ Object.entries(packages).map(([id, value]) => [id, { id, ...value }]),
+ ),
+ packageSearch: search,
+ };
+}
+
+function searchEntry(overrides: Partial = {}): NuGetSearchEntry {
+ return {
+ id: 'Purview.ZodSharp',
+ version: '2.0.1',
+ description: null,
+ totalDownloads: 0,
+ tags: [],
+ published: null,
+ deprecated: false,
+ listed: true,
+ ...overrides,
+ };
+}
+
describe('catalogue guard', () => {
test.skipIf(!HAS_DOCS_CACHE)('the committed catalogue passes every invariant', () => {
const { errors } = checkProjects();
@@ -30,3 +87,122 @@ describe('catalogue guard', () => {
}
});
});
+
+describe('version consistency findings', () => {
+ test('flags a preview project that already ships a stable package', () => {
+ const findings = versionConsistencyFindings(
+ project({ status: 'preview', experimental: false, packages: [{ id: 'Purview.ZodSharp' }] }),
+ cache({ 'Purview.ZodSharp': { versions: ['1.0.0', '2.0.0-prerelease.1'] } }),
+ );
+ expect(findings.map((finding) => finding.field)).toContain('status');
+ });
+
+ test('does not flag an experimental project for staying preview', () => {
+ const findings = versionConsistencyFindings(
+ project({ status: 'preview', experimental: true, packages: [{ id: 'Purview.ZodSharp' }] }),
+ cache({ 'Purview.ZodSharp': { versions: ['1.0.0'] } }),
+ );
+ expect(findings).toEqual([]);
+ });
+
+ test('flags deprecated and unlisted packages', () => {
+ const findings = versionConsistencyFindings(
+ project({ status: 'stable', packages: [{ id: 'Purview.ZodSharp' }] }),
+ cache(
+ { 'Purview.ZodSharp': { versions: ['2.0.1'] } },
+ { 'Purview.ZodSharp': searchEntry({ deprecated: true, listed: false }) },
+ ),
+ );
+ const observed = findings.map((finding) => finding.observed);
+ expect(observed).toContain('deprecated on NuGet');
+ expect(observed).toContain('unlisted on NuGet');
+ });
+
+ test('ignores archived projects', () => {
+ const findings = versionConsistencyFindings(
+ project({ status: 'archived', packages: [{ id: 'Purview.ZodSharp' }] }),
+ cache({ 'Purview.ZodSharp': { versions: ['1.0.0'] } }),
+ );
+ expect(findings).toEqual([]);
+ });
+});
+
+describe('target framework findings', () => {
+ test('flags a project whose declared frameworks miss a published one', () => {
+ const findings = targetFrameworkFindings(
+ project({ targetFrameworks: ['net8.0'], packages: [{ id: 'Purview.ZodSharp' }] }),
+ cache({
+ 'Purview.ZodSharp': { versions: ['1.0.0'], targetFrameworks: ['net8.0', 'net9.0'] },
+ }),
+ );
+ expect(findings).toHaveLength(1);
+ expect(findings[0]?.field).toBe('targetFrameworks');
+ expect(findings[0]?.observed).toContain('missing net9.0');
+ });
+
+ test('flags a project that declares no frameworks at all', () => {
+ const findings = targetFrameworkFindings(
+ project({ targetFrameworks: undefined, packages: [{ id: 'Purview.ZodSharp' }] }),
+ cache({ 'Purview.ZodSharp': { versions: ['1.0.0'], targetFrameworks: ['net8.0'] } }),
+ );
+ expect(findings).toHaveLength(1);
+ expect(findings[0]?.observed).toBe('none');
+ });
+
+ test('treats netstandard2.0 as an analyzer target, not a consumer framework', () => {
+ const findings = targetFrameworkFindings(
+ project({
+ targetFrameworks: ['net8.0', 'net9.0', 'net10.0'],
+ packages: [
+ { id: 'Purview.Telemetry.SourceGenerator', targetFrameworks: ['netstandard2.0'] },
+ ],
+ }),
+ cache({
+ 'Purview.Telemetry.SourceGenerator': {
+ versions: ['5.0.1'],
+ targetFrameworks: ['.NETStandard2.0'],
+ },
+ }),
+ );
+ expect(findings).toEqual([]);
+ });
+
+ test('recommends a package-level override for an undeclared analyzer package', () => {
+ const findings = targetFrameworkFindings(
+ project({
+ targetFrameworks: ['net8.0'],
+ packages: [{ id: 'Purview.Results' }, { id: 'Purview.Results.SourceGenerator' }],
+ }),
+ cache({
+ 'Purview.Results': { versions: ['1.0.0'], targetFrameworks: ['net8.0'] },
+ 'Purview.Results.SourceGenerator': {
+ versions: ['1.0.0'],
+ targetFrameworks: ['netstandard2.0'],
+ },
+ }),
+ );
+ expect(findings).toHaveLength(1);
+ expect(findings[0]?.field).toBe('packages["Purview.Results.SourceGenerator"]');
+ });
+
+ test('skips framework equality for tool and SDK projects', () => {
+ const findings = targetFrameworkFindings(
+ project({
+ install: 'msbuild-sdk',
+ targetFrameworks: ['msbuild'],
+ packages: [{ id: 'Purview.BuildSdk' }],
+ }),
+ cache({ 'Purview.BuildSdk': { versions: ['1.0.3'], targetFrameworks: ['netstandard2.0'] } }),
+ );
+ expect(findings).toEqual([]);
+ });
+
+ test('flags an invalid framework moniker', () => {
+ const findings = targetFrameworkFindings(
+ project({ targetFrameworks: ['dotnet'], packages: [] }),
+ cache({}),
+ );
+ expect(findings).toHaveLength(1);
+ expect(findings[0]?.observed).toBe('dotnet');
+ });
+});
diff --git a/src/tests/unit/discovery/discovery-manifest.test.ts b/src/tests/unit/discovery/discovery-manifest.test.ts
index 316691e..f0022df 100644
--- a/src/tests/unit/discovery/discovery-manifest.test.ts
+++ b/src/tests/unit/discovery/discovery-manifest.test.ts
@@ -23,9 +23,10 @@ import {
import { DISCOVERY_MANIFEST_SCHEMA_VERSION } from '../../../src/lib/discovery/types';
import { siteOrigin } from '../../../src/lib/discovery/urls';
import { loadProjects } from '../../../src/lib/manifest/load';
+import { RELEASE_CACHE_SCHEMA_VERSION } from '../../../src/lib/releases/types';
const emptyReleaseData: ReleaseCacheData = {
- schema: 1,
+ schema: RELEASE_CACHE_SCHEMA_VERSION,
retrievedAt: '2026-01-01T00:00:00.000Z',
repos: {},
releases: {},
diff --git a/src/tests/unit/discovery/discovery-resources.test.ts b/src/tests/unit/discovery/discovery-resources.test.ts
index b29d2eb..b5f9730 100644
--- a/src/tests/unit/discovery/discovery-resources.test.ts
+++ b/src/tests/unit/discovery/discovery-resources.test.ts
@@ -10,9 +10,10 @@ import {
partitionResources,
} from '../../../src/lib/discovery/resources';
import { loadProjects } from '../../../src/lib/manifest/load';
+import { RELEASE_CACHE_SCHEMA_VERSION } from '../../../src/lib/releases/types';
const emptyReleaseData: ReleaseCacheData = {
- schema: 1,
+ schema: RELEASE_CACHE_SCHEMA_VERSION,
retrievedAt: '2026-01-01T00:00:00.000Z',
repos: {},
releases: {},
diff --git a/src/tests/unit/fix-projects.test.ts b/src/tests/unit/fix-projects.test.ts
new file mode 100644
index 0000000..d2a145e
--- /dev/null
+++ b/src/tests/unit/fix-projects.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, test } from 'bun:test';
+
+import {
+ applyAutofix,
+ setPackageTargetFrameworks,
+ setProjectTargetFrameworks,
+ setStatus,
+} from '../../scripts/fix-projects';
+
+const SAMPLE = [
+ '$schema: ../../lib/manifest/schema.ts',
+ '',
+ 'id: sample',
+ 'name: Sample',
+ 'status: preview',
+ 'docs:',
+ ' source: readme',
+ 'targetFrameworks:',
+ ' - net8.0',
+ 'packages:',
+ ' - id: Sample.Core',
+ ' description: Core.',
+ ' primary: true',
+ ' - id: Sample.Generator',
+ ' description: Generator.',
+ ' targetFrameworks:',
+ ' - netstandard2.0',
+ '',
+];
+
+describe('fix-projects line editing', () => {
+ test('replaces an existing project targetFrameworks list', () => {
+ const updated = setProjectTargetFrameworks([...SAMPLE], ['net8.0', 'net9.0']);
+ expect(updated.join('\n')).toContain('targetFrameworks:\n - net8.0\n - net9.0\npackages:');
+ });
+
+ test('is a no-op when the project list already matches', () => {
+ expect(setProjectTargetFrameworks([...SAMPLE], ['net8.0'])).toEqual(SAMPLE);
+ });
+
+ test('inserts a project targetFrameworks block before packages', () => {
+ const without = SAMPLE.filter((line) => line !== 'targetFrameworks:' && line !== ' - net8.0');
+ const updated = setProjectTargetFrameworks(without, ['net8.0']);
+ const packagesIndex = updated.indexOf('packages:');
+ expect(updated.slice(packagesIndex - 2, packagesIndex)).toEqual([
+ 'targetFrameworks:',
+ ' - net8.0',
+ ]);
+ });
+
+ test('inserts a package-level targetFrameworks block after the last property', () => {
+ const updated = setPackageTargetFrameworks([...SAMPLE], 'Sample.Core', ['net8.0', 'net9.0']);
+ expect(updated.join('\n')).toContain(
+ [
+ ' - id: Sample.Core',
+ ' description: Core.',
+ ' primary: true',
+ ' targetFrameworks:',
+ ' - net8.0',
+ ' - net9.0',
+ ' - id: Sample.Generator',
+ ].join('\n'),
+ );
+ });
+
+ test('replaces an existing package-level targetFrameworks list', () => {
+ const updated = setPackageTargetFrameworks([...SAMPLE], 'Sample.Generator', ['netstandard2.0']);
+ expect(updated).toEqual(SAMPLE);
+ });
+
+ test('sets the top-level status', () => {
+ expect(setStatus([...SAMPLE], 'stable').join('\n')).toContain('status: stable');
+ });
+
+ test('dispatches a structured fix', () => {
+ const updated = applyAutofix([...SAMPLE], { kind: 'set-status', value: 'stable' });
+ expect(updated.join('\n')).toContain('status: stable');
+ });
+});
diff --git a/src/tests/unit/manifest.test.ts b/src/tests/unit/manifest.test.ts
index 6017e26..6b57064 100644
--- a/src/tests/unit/manifest.test.ts
+++ b/src/tests/unit/manifest.test.ts
@@ -186,6 +186,59 @@ describe('project manifest', () => {
});
});
+describe('project assets', () => {
+ test('the real catalogue mirrors the purview-build schema', () => {
+ const build = loadProjects().find((project) => project.id === 'build');
+ expect(build?.assets).toHaveLength(1);
+ expect(build?.assets[0]).toMatchObject({
+ path: 'purview-build.schema.json',
+ output: 'schemas/purview-build.json',
+ });
+ });
+
+ test('projects without assets resolve to an empty list', () => {
+ const projects = parseManifest({ projects: [makeProject('a')] }, 'fixture.yml');
+ expect(projects[0]?.assets).toEqual([]);
+ });
+
+ test('accepts a declared asset', () => {
+ const projects = parseManifest(
+ {
+ projects: [{ ...makeProject('a'), assets: [{ path: 'x.json', output: 'schemas/x.json' }] }],
+ },
+ 'fixture.yml',
+ );
+ expect(projects[0]?.assets).toEqual([{ path: 'x.json', output: 'schemas/x.json' }]);
+ });
+
+ test('rejects an absolute or escaping output path', () => {
+ for (const output of ['/schemas/x.json', '../x.json', 'schemas/../../x.json']) {
+ expect(() =>
+ parseManifest(
+ { projects: [{ ...makeProject('a'), assets: [{ path: 'x.json', output }] }] },
+ 'fixture.yml',
+ ),
+ ).toThrow(ManifestValidationError);
+ }
+ });
+
+ test('rejects duplicate asset output paths across projects', () => {
+ const first = {
+ ...makeProject('a'),
+ packages: [{ id: 'Purview.A' }],
+ assets: [{ path: 'x.json', output: 'schemas/x.json' }],
+ };
+ const second = {
+ ...makeProject('b'),
+ packages: [{ id: 'Purview.B' }],
+ assets: [{ path: 'y.json', output: 'schemas/x.json' }],
+ };
+ expect(() => parseManifest({ projects: [first, second] }, 'fixture.yml')).toThrow(
+ /schemas\/x\.json/,
+ );
+ });
+});
+
describe('project use cases', () => {
test('loads concrete use cases from the real manifest', () => {
const projects = loadProjects();
diff --git a/src/tests/unit/releases.test.ts b/src/tests/unit/releases.test.ts
index 9c8fbe4..ac7d874 100644
--- a/src/tests/unit/releases.test.ts
+++ b/src/tests/unit/releases.test.ts
@@ -12,6 +12,7 @@ import {
parseGitHubRelease,
parseGitHubRepo,
parseNuGetIndex,
+ parseNuGetNuspec,
parseNuGetSearchEntry,
} from '../../src/lib/releases/github';
import {
@@ -106,7 +107,7 @@ describe('GitHub normalisation (real fixtures)', () => {
expect(repo.hasDiscussions).toBe(false);
});
- test('parses release fixtures including the prerelease-flag quirk', () => {
+ test('parses release fixtures and classifies by the semver tag', () => {
const raw = fixture('github/releases/telemetry-sourcegenerator.json') as Array<{
tag_name: string;
}>;
@@ -120,10 +121,16 @@ describe('GitHub normalisation (real fixtures)', () => {
// The expectation follows the fixture head instead of a pinned tag, so a
// fixture refresh never invalidates this test.
expect(latest.tagName).toBe(raw[0]?.tag_name ?? '');
- // The org publishes prerelease tags with the GitHub prerelease flag set to
- // false; classification must follow the semver tag, not the flag.
- expect(latest.prerelease).toBe(false);
- expect(isPrereleaseRelease(latest)).toBe(true);
+ // Classification follows the semver tag rather than GitHub's prerelease
+ // flag, which the org's changesets pipeline has reported inconsistently.
+ const prerelease = releases.find((release) => isPrereleaseVersion(release.tagName));
+ expect(prerelease).toBeDefined();
+ expect(prerelease && isPrereleaseRelease(prerelease)).toBe(true);
+ const stable = releases.find(
+ (release) => isValidVersion(release.tagName) && !isPrereleaseVersion(release.tagName),
+ );
+ expect(stable).toBeDefined();
+ expect(stable && isPrereleaseRelease(stable)).toBe(false);
});
});
@@ -154,6 +161,20 @@ describe('NuGet normalisation (real fixtures)', () => {
parseNuGetSearchEntry({ id: 'Purview.Build', version: '0.2.4' }, 'purview.build'),
).not.toBeNull();
});
+
+ test('parses target frameworks from a nuspec fixture', () => {
+ const nuspec = readFileSync(
+ resolve('fixtures', 'nuget', 'nuspec', 'purview.results.nuspec'),
+ 'utf8',
+ );
+ expect(parseNuGetNuspec(nuspec)).toEqual(['net10.0', 'net11.0']);
+ });
+
+ test('reports no target frameworks for a package whose nuspec declares none', () => {
+ expect(parseNuGetNuspec('')).toEqual(
+ [],
+ );
+ });
});
describe('version selection (NuGet precedence)', () => {
@@ -184,7 +205,7 @@ describe('version selection (NuGet precedence)', () => {
expect(selectPrereleaseVersion(versions)).toBe('1.0.0-prerelease.55');
});
- test('selects the latest prerelease for the BuildSdk fixture', () => {
+ test('selects the stable and prerelease channels for the BuildSdk fixture', () => {
const index = parseNuGetIndex(
fixture('nuget/purview.buildsdk.json') as Record,
'Purview.BuildSdk',
@@ -192,10 +213,10 @@ describe('version selection (NuGet precedence)', () => {
expect(index.versions.length).toBeGreaterThan(0);
expect(index.versions.some((version) => isPrereleaseVersion(version))).toBe(true);
const selection = selectVersions(index.versions);
- expect(selection.stable).toBeNull();
- // The package only publishes prereleases, so the latest prerelease must be
- // the highest semver version in the fixture's flat-container index.
- expect(selection.prerelease).toBe(sortVersionsDescending(index.versions)[0] ?? null);
+ expect(selection.stable).not.toBeNull();
+ expect(isPrereleaseVersion(selection.stable ?? '')).toBe(false);
+ expect(selection.prerelease).not.toBeNull();
+ expect(isPrereleaseVersion(selection.prerelease ?? '')).toBe(true);
});
test('handles a package with no releases at all', () => {
diff --git a/src/tests/unit/target-frameworks.test.ts b/src/tests/unit/target-frameworks.test.ts
new file mode 100644
index 0000000..7e015f0
--- /dev/null
+++ b/src/tests/unit/target-frameworks.test.ts
@@ -0,0 +1,134 @@
+import { describe, expect, test } from 'bun:test';
+
+import {
+ diffTargetFrameworks,
+ isAnalyzerTargetFramework,
+ isAnalyzerTargetFrameworks,
+ isRecognizedTargetFramework,
+ normalizeTargetFramework,
+ normalizeTargetFrameworks,
+ runtimeTargetFrameworks,
+ selectVersionForTargetFrameworks,
+ sortTargetFrameworks,
+} from '../../src/lib/releases/target-frameworks';
+
+describe('target framework normalisation', () => {
+ test('expands the historical long forms', () => {
+ expect(normalizeTargetFramework('.NETStandard2.0')).toBe('netstandard2.0');
+ expect(normalizeTargetFramework('.NETFramework4.7.2')).toBe('net472');
+ expect(normalizeTargetFramework('.NETCoreApp3.1')).toBe('netcoreapp3.1');
+ expect(normalizeTargetFramework('.NET5.0')).toBe('net5.0');
+ });
+
+ test('pads a platform version to four components', () => {
+ expect(normalizeTargetFramework('net10.0-windows10.0.19041')).toBe(
+ 'net10.0-windows10.0.19041.0',
+ );
+ expect(normalizeTargetFramework('net10.0-windows10.0.19041.0')).toBe(
+ 'net10.0-windows10.0.19041.0',
+ );
+ });
+
+ test('lowercases and de-duplicates, sorted', () => {
+ expect(normalizeTargetFrameworks(['NET10.0', 'net8.0', 'net10.0'])).toEqual([
+ 'net10.0',
+ 'net8.0',
+ ]);
+ });
+});
+
+describe('target framework classification', () => {
+ test('recognises TFMs and the msbuild sentinel', () => {
+ for (const value of [
+ 'net8.0',
+ 'net10.0',
+ 'net472',
+ 'netstandard2.0',
+ 'netcoreapp3.1',
+ 'net10.0-windows10.0.19041.0',
+ 'msbuild',
+ ]) {
+ expect(isRecognizedTargetFramework(value)).toBe(true);
+ }
+ expect(isRecognizedTargetFramework('dotnet')).toBe(false);
+ expect(isRecognizedTargetFramework('net10.0 windows')).toBe(false);
+ });
+
+ test('identifies analyzer frameworks', () => {
+ expect(isAnalyzerTargetFramework('netstandard2.0')).toBe(true);
+ expect(isAnalyzerTargetFramework('.NETStandard2.0')).toBe(true);
+ expect(isAnalyzerTargetFramework('net10.0')).toBe(false);
+ expect(isAnalyzerTargetFrameworks(['.NETStandard2.0'])).toBe(true);
+ expect(isAnalyzerTargetFrameworks(['netstandard2.0', 'net8.0'])).toBe(false);
+ expect(isAnalyzerTargetFrameworks([])).toBe(false);
+ });
+
+ test('removes sentinels and, optionally, analyzer frameworks', () => {
+ expect(runtimeTargetFrameworks(['msbuild'])).toEqual([]);
+ expect(
+ runtimeTargetFrameworks(['net8.0', 'netstandard2.0'], { excludeAnalyzer: true }),
+ ).toEqual(['net8.0']);
+ });
+});
+
+describe('natural target framework ordering', () => {
+ test('orders by family and numeric version, not lexicographically', () => {
+ expect(sortTargetFrameworks(['net10.0', 'net8.0', 'net9.0', 'net11.0'])).toEqual([
+ 'net8.0',
+ 'net9.0',
+ 'net10.0',
+ 'net11.0',
+ ]);
+ });
+
+ test('places the base framework before its platform variant', () => {
+ expect(sortTargetFrameworks(['net10.0-windows10.0.19041.0', 'net10.0'])).toEqual([
+ 'net10.0',
+ 'net10.0-windows10.0.19041.0',
+ ]);
+ });
+
+ test('normalises before sorting', () => {
+ expect(sortTargetFrameworks(['.NETStandard2.0', 'net8.0'])).toEqual([
+ 'net8.0',
+ 'netstandard2.0',
+ ]);
+ });
+});
+
+describe('target framework diff', () => {
+ test('reports missing and extra frameworks', () => {
+ expect(diffTargetFrameworks(['net8.0', 'net9.0'], ['net8.0', 'net10.0'])).toEqual({
+ missing: ['net10.0'],
+ extra: ['net9.0'],
+ });
+ });
+
+ test('normalises both sides before comparing', () => {
+ expect(
+ diffTargetFrameworks(['net10.0-windows10.0.19041.0'], ['net10.0-windows10.0.19041']),
+ ).toEqual({ missing: [], extra: [] });
+ expect(diffTargetFrameworks(['.NETStandard2.0'], ['netstandard2.0'])).toEqual({
+ missing: [],
+ extra: [],
+ });
+ });
+});
+
+describe('version selection for nuspec lookup', () => {
+ test('prefers the newest stable version', () => {
+ expect(selectVersionForTargetFrameworks(['2.0.0-prerelease.9', '2.0.1', '2.0.0'])).toBe(
+ '2.0.1',
+ );
+ });
+
+ test('falls back to the newest prerelease', () => {
+ expect(selectVersionForTargetFrameworks(['2.0.0-prerelease.1', '2.0.0-prerelease.9'])).toBe(
+ '2.0.0-prerelease.9',
+ );
+ });
+
+ test('returns null when nothing is published', () => {
+ expect(selectVersionForTargetFrameworks([])).toBeNull();
+ });
+});