diff --git a/.gitignore b/.gitignore index 22277af..58ee2f8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ dist node_modules .vscode-test/ *.vsix -.dependencygraph \ No newline at end of file +.dependencygraph +local/ \ No newline at end of file diff --git a/src/generator/module/ModuleLicenseGenerator.ts b/src/generator/module/ModuleLicenseGenerator.ts index 48eff93..61c4d2b 100644 --- a/src/generator/module/ModuleLicenseGenerator.ts +++ b/src/generator/module/ModuleLicenseGenerator.ts @@ -11,21 +11,16 @@ export default class ModuleLicenseGenerator extends TemplateGenerator< | TemplatePath.LicenseApache20 | TemplatePath.LicenseOslv3 > { - public constructor(protected data: ModuleWizardData | ModuleWizardComposerData) { - const params = { + public constructor(data: ModuleWizardData | ModuleWizardComposerData) { + super('LICENSE.txt', TemplatePath.LicenseMit, { ...data, year: new Date().getFullYear(), - }; - - super('LICENSE.txt', TemplatePath.LicenseMit, params); + }); } public async generate(workspaceUri: Uri): Promise { - const moduleDirectory = Magento.getModuleDirectory( - this.data.vendor, - this.data.module, - workspaceUri - ); + const data = this.data as ModuleWizardData | ModuleWizardComposerData; + const moduleDirectory = Magento.getModuleDirectory(data.vendor, data.module, workspaceUri); return super.generate(moduleDirectory); } diff --git a/src/test/cache/DocumentCache.test.ts b/src/test/cache/DocumentCache.test.ts new file mode 100644 index 0000000..46b4bae --- /dev/null +++ b/src/test/cache/DocumentCache.test.ts @@ -0,0 +1,83 @@ +import * as assert from 'assert'; +import { describe, it } from 'mocha'; +import { TextDocument } from 'vscode'; +import DocumentCache from 'cache/DocumentCache'; + +function fakeDocument(fsPath: string, version: number): TextDocument { + return { uri: { fsPath }, version } as unknown as TextDocument; +} + +describe('DocumentCache Tests', () => { + it('should return a cached value when the document version matches', () => { + const doc = fakeDocument('/ws/a.xml', 1); + DocumentCache.set(doc, 'ast', { nodes: 3 }); + + assert.deepStrictEqual(DocumentCache.get(doc, 'ast'), { nodes: 3 }); + assert.strictEqual(DocumentCache.has(doc, 'ast'), true); + }); + + it('should miss when the document version has advanced', () => { + const v1 = fakeDocument('/ws/b.xml', 1); + DocumentCache.set(v1, 'ast', { nodes: 3 }); + + const v2 = fakeDocument('/ws/b.xml', 2); + assert.strictEqual(DocumentCache.get(v2, 'ast'), undefined); + assert.strictEqual(DocumentCache.has(v2, 'ast'), false); + }); + + it('should return undefined for an unknown key', () => { + const doc = fakeDocument('/ws/c.xml', 1); + assert.strictEqual(DocumentCache.get(doc, 'missing'), undefined); + assert.strictEqual(DocumentCache.has(doc, 'missing'), false); + }); + + it('should keep entries for different paths isolated', () => { + const a = fakeDocument('/ws/d.xml', 1); + const b = fakeDocument('/ws/e.xml', 1); + DocumentCache.set(a, 'ast', 'value-a'); + DocumentCache.set(b, 'ast', 'value-b'); + + assert.strictEqual(DocumentCache.get(a, 'ast'), 'value-a'); + assert.strictEqual(DocumentCache.get(b, 'ast'), 'value-b'); + }); + + it('should keep entries for different keys on the same document isolated', () => { + const doc = fakeDocument('/ws/f.xml', 1); + DocumentCache.set(doc, 'ast', 'ast-value'); + DocumentCache.set(doc, 'symbols', 'symbols-value'); + + assert.strictEqual(DocumentCache.get(doc, 'ast'), 'ast-value'); + assert.strictEqual(DocumentCache.get(doc, 'symbols'), 'symbols-value'); + }); + + it('should delete a single entry', () => { + const doc = fakeDocument('/ws/g.xml', 1); + DocumentCache.set(doc, 'ast', 'value'); + DocumentCache.delete(doc, 'ast'); + + assert.strictEqual(DocumentCache.get(doc, 'ast'), undefined); + }); + + it('should clear every key for a document by path prefix', () => { + const doc = fakeDocument('/ws/h.xml', 1); + DocumentCache.set(doc, 'ast', 'a'); + DocumentCache.set(doc, 'symbols', 'b'); + + DocumentCache.clear(doc); + + assert.strictEqual(DocumentCache.get(doc, 'ast'), undefined); + assert.strictEqual(DocumentCache.get(doc, 'symbols'), undefined); + }); + + it('should not clear a path that merely shares a prefix string', () => { + const target = fakeDocument('/ws/i.xml', 1); + const sibling = fakeDocument('/ws/i.xml.bak', 1); + DocumentCache.set(target, 'ast', 'target'); + DocumentCache.set(sibling, 'ast', 'sibling'); + + DocumentCache.clear(target); + + assert.strictEqual(DocumentCache.get(target, 'ast'), undefined); + assert.strictEqual(DocumentCache.get(sibling, 'ast'), 'sibling'); + }); +}); diff --git a/src/test/common/MagentoCli.test.ts b/src/test/common/MagentoCli.test.ts new file mode 100644 index 0000000..cad57c0 --- /dev/null +++ b/src/test/common/MagentoCli.test.ts @@ -0,0 +1,57 @@ +import * as assert from 'assert'; +import { describe, it } from 'mocha'; +import MagentoCli from 'common/MagentoCli'; + +// `quote` is a private static helper; it is the shell-injection defence for every +// command MagentoCli runs, so it is tested directly via a cast. +const quote = (value: string): string => + (MagentoCli as unknown as { quote(value: string): string }).quote(value); + +describe('MagentoCli.quote Tests', () => { + it('should wrap a plain token in single quotes', () => { + assert.strictEqual(quote('setup:upgrade'), `'setup:upgrade'`); + }); + + it('should quote an empty string as an empty quoted token', () => { + assert.strictEqual(quote(''), `''`); + }); + + it('should keep spaces safe inside the quotes', () => { + assert.strictEqual(quote('a b c'), `'a b c'`); + }); + + it('should escape a single quote using the POSIX close-escape-reopen sequence', () => { + assert.strictEqual(quote(`it's`), `'it'\\''s'`); + }); + + it('should escape multiple consecutive single quotes', () => { + assert.strictEqual(quote(`'''`), `''\\'''\\'''\\'''`); + }); + + it('should not expand command substitution, backticks or variables', () => { + assert.strictEqual(quote('$(whoami)'), `'$(whoami)'`); + assert.strictEqual(quote('`id`'), "'`id`'"); + assert.strictEqual(quote('$HOME'), `'$HOME'`); + }); + + it('should keep command separators inert', () => { + assert.strictEqual(quote('; rm -rf /'), `'; rm -rf /'`); + assert.strictEqual(quote('foo && bar'), `'foo && bar'`); + assert.strictEqual(quote('a | b'), `'a | b'`); + }); + + it('should neutralise an injection payload that breaks out with a quote', () => { + const payload = `'; rm -rf /; echo '`; + const quoted = quote(payload); + + // The result must be a single shell token: it starts and ends with a quote, + // and every embedded quote is the escaped `'\''` sequence so nothing breaks out. + assert.ok(quoted.startsWith(`'`)); + assert.ok(quoted.endsWith(`'`)); + assert.strictEqual(quoted.replace(/'\\''/g, ''), `'; rm -rf /; echo '`); + }); + + it('should preserve newlines inside the quoted token', () => { + assert.strictEqual(quote('line1\nline2'), `'line1\nline2'`); + }); +}); diff --git a/src/test/common/PhpNamespace.test.ts b/src/test/common/PhpNamespace.test.ts new file mode 100644 index 0000000..bc05735 --- /dev/null +++ b/src/test/common/PhpNamespace.test.ts @@ -0,0 +1,112 @@ +import * as assert from 'assert'; +import { describe, it } from 'mocha'; +import PhpNamespace from 'common/PhpNamespace'; + +describe('PhpNamespace Tests', () => { + describe('fromString', () => { + it('should split a namespace into parts', () => { + assert.deepStrictEqual(PhpNamespace.fromString('Vendor\\Module\\Model').getParts(), [ + 'Vendor', + 'Module', + 'Model', + ]); + }); + + it('should trim a leading separator', () => { + assert.deepStrictEqual(PhpNamespace.fromString('\\Vendor\\Module').getParts(), [ + 'Vendor', + 'Module', + ]); + }); + + it('should drop empty parts from doubled separators', () => { + assert.deepStrictEqual(PhpNamespace.fromString('Vendor\\\\Module').getParts(), [ + 'Vendor', + 'Module', + ]); + }); + + it('should round-trip through toString', () => { + assert.strictEqual( + PhpNamespace.fromString('Vendor\\Module\\Model').toString(), + 'Vendor\\Module\\Model' + ); + }); + }); + + describe('head / tail / pop', () => { + it('should expose the head and tail parts', () => { + const ns = PhpNamespace.fromParts(['Vendor', 'Module', 'Model']); + assert.strictEqual(ns.getHead(), 'Vendor'); + assert.strictEqual(ns.getTail(), 'Model'); + }); + + it('should pop the last part', () => { + const ns = PhpNamespace.fromParts(['Vendor', 'Module', 'Model']); + assert.strictEqual(ns.pop(), 'Model'); + assert.deepStrictEqual(ns.getParts(), ['Vendor', 'Module']); + }); + }); + + describe('append', () => { + it('should append string parts', () => { + assert.strictEqual( + PhpNamespace.fromParts(['Vendor', 'Module']).append('Model', 'Product').toString(), + 'Vendor\\Module\\Model\\Product' + ); + }); + + it('should append another namespace by flattening its parts', () => { + const base = PhpNamespace.fromParts(['Vendor', 'Module']); + const tail = PhpNamespace.fromParts(['Model', 'Product']); + assert.strictEqual(base.append(tail).toString(), 'Vendor\\Module\\Model\\Product'); + }); + + it('should not mutate the original namespace', () => { + const base = PhpNamespace.fromParts(['Vendor', 'Module']); + base.append('Model'); + assert.deepStrictEqual(base.getParts(), ['Vendor', 'Module']); + }); + }); + + describe('prepend', () => { + it('should prepend a string part', () => { + assert.strictEqual( + PhpNamespace.fromParts(['Module', 'Model']).prepend('Vendor').toString(), + 'Vendor\\Module\\Model' + ); + }); + + it('should prepend another namespace', () => { + const ns = PhpNamespace.fromParts(['Model']); + const prefix = PhpNamespace.fromParts(['Vendor', 'Module']); + assert.strictEqual(ns.prepend(prefix).toString(), 'Vendor\\Module\\Model'); + }); + }); + + describe('isSubNamespaceOf', () => { + it('should return true for a deeper namespace', () => { + const child = PhpNamespace.fromString('Vendor\\Module\\Model\\Product'); + const parent = PhpNamespace.fromString('Vendor\\Module'); + assert.strictEqual(child.isSubNamespaceOf(parent), true); + }); + + it('should return true for an equal namespace', () => { + const a = PhpNamespace.fromString('Vendor\\Module'); + const b = PhpNamespace.fromString('Vendor\\Module'); + assert.strictEqual(a.isSubNamespaceOf(b), true); + }); + + it('should return false for a shallower namespace', () => { + const parent = PhpNamespace.fromString('Vendor\\Module'); + const child = PhpNamespace.fromString('Vendor\\Module\\Model'); + assert.strictEqual(parent.isSubNamespaceOf(child), false); + }); + + it('should return false for a sibling namespace', () => { + const a = PhpNamespace.fromString('Vendor\\Other\\Model'); + const b = PhpNamespace.fromString('Vendor\\Module'); + assert.strictEqual(a.isSubNamespaceOf(b), false); + }); + }); +}); diff --git a/src/test/common/Validation.test.ts b/src/test/common/Validation.test.ts new file mode 100644 index 0000000..d5f9e76 --- /dev/null +++ b/src/test/common/Validation.test.ts @@ -0,0 +1,57 @@ +import * as assert from 'assert'; +import { describe, it } from 'mocha'; +import Validation from 'common/Validation'; + +describe('Validation Tests', () => { + describe('isValidModuleName', () => { + ['Vendor_Module', 'Foo_Bar', 'Magento_Catalog', 'A1_B2'].forEach(name => { + it(`should accept "${name}"`, () => { + assert.strictEqual(Validation.isValidModuleName(name).isValid, true); + }); + }); + + [ + 'vendor_module', + 'Vendor', + 'Vendor_', + '_Module', + 'Vendor__Module', + 'Vendor_module', + '', + ].forEach(name => { + it(`should reject "${name}"`, () => { + const result = Validation.isValidModuleName(name); + assert.strictEqual(result.isValid, false); + assert.ok(result.errors && result.errors.length > 0); + }); + }); + }); + + describe('isValidClassName', () => { + ['ClassName', '_Class', 'classNAME', 'Class123'].forEach(name => { + it(`should accept "${name}"`, () => { + assert.strictEqual(Validation.isValidClassName(name).isValid, true); + }); + }); + + ['123Class', 'class-name', 'class name', ''].forEach(name => { + it(`should reject "${name}"`, () => { + assert.strictEqual(Validation.isValidClassName(name).isValid, false); + }); + }); + }); + + describe('isSnakeCase', () => { + ['snake_case', 'one', 'a_b_c', 'a_1_b', 'value123'].forEach(name => { + it(`should accept "${name}"`, () => { + assert.strictEqual(Validation.isSnakeCase(name).isValid, true); + }); + }); + + ['camelCase', 'PascalCase', 'snake-case', 'UPPER', 'has space', ''].forEach(name => { + it(`should reject "${name}"`, () => { + assert.strictEqual(Validation.isSnakeCase(name).isValid, false); + }); + }); + }); +}); diff --git a/src/test/definition/findVirtualTypeRange.test.ts b/src/test/definition/findVirtualTypeRange.test.ts new file mode 100644 index 0000000..6e7d0e9 --- /dev/null +++ b/src/test/definition/findVirtualTypeRange.test.ts @@ -0,0 +1,53 @@ +import * as assert from 'assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { describe, it, before, after } from 'mocha'; +import { workspace } from 'vscode'; +import { findVirtualTypeRange } from 'definition/util/findVirtualTypeRange'; + +const DI_XML = [ + '', + '', + ' ', + ' ', + '', + '', +].join('\n'); + +describe('findVirtualTypeRange Tests', () => { + let diPath: string; + + before(() => { + diPath = path.join(os.tmpdir(), `mt-virtualtype-${process.pid}-di.xml`); + fs.writeFileSync(diPath, DI_XML, 'utf8'); + }); + + after(() => { + fs.rmSync(diPath, { force: true }); + }); + + it('should return a range that exactly covers the requested virtualType name', async () => { + const result = await findVirtualTypeRange(diPath, 'Foo\\Bar\\FirstVirtual'); + + assert.ok(result, 'expected a match'); + const document = await workspace.openTextDocument(result!.uri); + assert.strictEqual(document.getText(result!.range), 'Foo\\Bar\\FirstVirtual'); + assert.strictEqual(result!.range.start.line, 2); + }); + + it('should resolve the correct entry when multiple virtualTypes exist', async () => { + const result = await findVirtualTypeRange(diPath, 'Foo\\Bar\\SecondVirtual'); + + assert.ok(result, 'expected a match'); + const document = await workspace.openTextDocument(result!.uri); + assert.strictEqual(document.getText(result!.range), 'Foo\\Bar\\SecondVirtual'); + assert.strictEqual(result!.range.start.line, 3); + }); + + it('should return undefined when the name is not present', async () => { + const result = await findVirtualTypeRange(diPath, 'Foo\\Bar\\Missing'); + + assert.strictEqual(result, undefined); + }); +}); diff --git a/src/test/diagnostics/PluginDeclarationDiagnostics.test.ts b/src/test/diagnostics/PluginDeclarationDiagnostics.test.ts new file mode 100644 index 0000000..131d7ba --- /dev/null +++ b/src/test/diagnostics/PluginDeclarationDiagnostics.test.ts @@ -0,0 +1,52 @@ +import * as assert from 'assert'; +import { describe, it } from 'mocha'; +import PluginDeclarationDiagnostics from 'diagnostics/xml/PluginDeclarationDiagnostics'; + +interface PluginInternals { + getScopeFromPath(fsPath: string): string; + scopesConflict(a: string, b: string): boolean; +} + +const internals = (): PluginInternals => + new PluginDeclarationDiagnostics() as unknown as PluginInternals; + +describe('PluginDeclarationDiagnostics internals Tests', () => { + describe('getScopeFromPath', () => { + it('should extract the area segment from an area-scoped di.xml', () => { + assert.strictEqual( + internals().getScopeFromPath('/app/code/Foo/Bar/etc/frontend/di.xml'), + 'frontend' + ); + }); + + it('should treat a top-level etc/di.xml as global', () => { + assert.strictEqual(internals().getScopeFromPath('/app/code/Foo/Bar/etc/di.xml'), 'global'); + }); + + it('should normalise Windows separators', () => { + assert.strictEqual( + internals().getScopeFromPath('C:\\app\\code\\Foo\\Bar\\etc\\adminhtml\\di.xml'), + 'adminhtml' + ); + }); + + it('should fall back to global for an unrelated path', () => { + assert.strictEqual(internals().getScopeFromPath('/some/other/file.xml'), 'global'); + }); + }); + + describe('scopesConflict', () => { + it('should conflict when both scopes are equal', () => { + assert.strictEqual(internals().scopesConflict('frontend', 'frontend'), true); + }); + + it('should conflict when either scope is global', () => { + assert.strictEqual(internals().scopesConflict('global', 'frontend'), true); + assert.strictEqual(internals().scopesConflict('frontend', 'global'), true); + }); + + it('should not conflict between two distinct area scopes', () => { + assert.strictEqual(internals().scopesConflict('frontend', 'adminhtml'), false); + }); + }); +}); diff --git a/src/test/generator/module/ModuleLicenseGenerator.test.ts b/src/test/generator/module/ModuleLicenseGenerator.test.ts new file mode 100644 index 0000000..25487cf --- /dev/null +++ b/src/test/generator/module/ModuleLicenseGenerator.test.ts @@ -0,0 +1,55 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import { describe, it, before, afterEach } from 'mocha'; +import sinon from 'sinon'; +import ModuleLicenseGenerator from 'generator/module/ModuleLicenseGenerator'; +import { ModuleWizardData } from 'wizard/ModuleWizard'; +import { License } from 'types/global'; +import { getTestWorkspaceUri } from 'test/util'; +import { setup } from 'test/setup'; + +describe('ModuleLicenseGenerator Tests', () => { + const moduleWizardData: ModuleWizardData = { + vendor: 'Foo', + module: 'Bar', + sequence: [], + license: License.MIT, + version: '1.0.0', + copyright: 'Test Copyright', + composer: false, + }; + + before(async () => { + await setup(); + }); + + afterEach(() => { + sinon.restore(); + }); + + it('should render the license body with the injected copyright and year', async () => { + sinon.stub(Date.prototype, 'getFullYear').returns(2026); + const generator = new ModuleLicenseGenerator(moduleWizardData); + + const generatedFile = await generator.generate(getTestWorkspaceUri()); + + assert.ok( + generatedFile.content.startsWith('Copyright © 2026-present Test Copyright'), + 'license should start with the copyright line including the stubbed year' + ); + assert.ok( + generatedFile.content.includes('THE SOFTWARE IS PROVIDED'), + 'license should contain the MIT body' + ); + }); + + it('should place LICENSE.txt at the module root', async () => { + const generator = new ModuleLicenseGenerator(moduleWizardData); + + const generatedFile = await generator.generate(getTestWorkspaceUri()); + + assert.ok( + generatedFile.uri.fsPath.endsWith(path.join('app', 'code', 'Foo', 'Bar', 'LICENSE.txt')) + ); + }); +}); diff --git a/src/test/indexer/IndexDataSerializer.test.ts b/src/test/indexer/IndexDataSerializer.test.ts new file mode 100644 index 0000000..2a724b8 --- /dev/null +++ b/src/test/indexer/IndexDataSerializer.test.ts @@ -0,0 +1,65 @@ +import * as assert from 'assert'; +import { describe, it } from 'mocha'; +import { IndexDataSerializer } from 'indexer/IndexDataSerializer'; +import { SavedIndex } from 'types/indexer'; + +describe('IndexDataSerializer Tests', () => { + const serializer = new IndexDataSerializer(); + + it('should round-trip the data Map preserving entries', () => { + const saved: SavedIndex = { + version: 1, + data: new Map([ + ['/a.xml', 1], + ['/b.xml', 2], + ]), + }; + + const result = serializer.deserialize(serializer.serialize(saved)); + + assert.strictEqual(result.version, 1); + assert.ok(result.data instanceof Map); + assert.strictEqual(result.data.get('/a.xml'), 1); + assert.strictEqual(result.data.get('/b.xml'), 2); + assert.strictEqual(result.data.size, 2); + }); + + it('should round-trip an empty Map', () => { + const saved: SavedIndex = { version: 3, data: new Map() }; + + const result = serializer.deserialize(serializer.serialize(saved)); + + assert.ok(result.data instanceof Map); + assert.strictEqual(result.data.size, 0); + }); + + it('should preserve nested object values stored in the Map', () => { + const value = { class: 'Foo\\Bar', methods: ['execute'] }; + const saved: SavedIndex = { + version: 2, + data: new Map([['/etc/di.xml', value]]), + }; + + const result = serializer.deserialize(serializer.serialize(saved)); + + assert.deepStrictEqual(result.data.get('/etc/di.xml'), value); + }); + + it('should preserve the version field as a number', () => { + const saved: SavedIndex = { version: 42, data: new Map() }; + + const result = serializer.deserialize(serializer.serialize(saved)); + + assert.strictEqual(result.version, 42); + assert.strictEqual(typeof result.version, 'number'); + }); + + it('should serialize a Map to the tagged envelope shape', () => { + const saved: SavedIndex = { version: 1, data: new Map([['k', 'v']]) }; + + const raw = JSON.parse(serializer.serialize(saved)); + + assert.strictEqual(raw.data.__type, 'Map'); + assert.deepStrictEqual(raw.data.value, [['k', 'v']]); + }); +}); diff --git a/src/test/indexer/IndexManager.test.ts b/src/test/indexer/IndexManager.test.ts new file mode 100644 index 0000000..24f5c2a --- /dev/null +++ b/src/test/indexer/IndexManager.test.ts @@ -0,0 +1,60 @@ +import * as assert from 'assert'; +import * as path from 'path'; +import { describe, it } from 'mocha'; +import IndexManager from 'indexer/IndexManager'; + +// `deleteByPathPrefix` is a private helper underpinning rename/delete handling; +// an off-by-one on the separator would orphan or over-delete index entries. +const deleteByPathPrefix = (indexData: Map, fsPath: string): boolean => + ( + IndexManager as unknown as { + deleteByPathPrefix(indexData: Map, fsPath: string): boolean; + } + ).deleteByPathPrefix(indexData, fsPath); + +describe('IndexManager.deleteByPathPrefix Tests', () => { + it('should remove an exact file match and report removal', () => { + const file = path.join('/ws', 'app', 'a.xml'); + const map = new Map([[file, 1]]); + + assert.strictEqual(deleteByPathPrefix(map, file), true); + assert.strictEqual(map.has(file), false); + }); + + it('should remove every descendant when a directory is removed', () => { + const dir = path.join('/ws', 'app', 'Foo'); + const child = path.join(dir, 'etc', 'di.xml'); + const grandchild = path.join(dir, 'view', 'frontend', 'layout', 'a.xml'); + const outside = path.join('/ws', 'app', 'Bar', 'etc', 'di.xml'); + const map = new Map([ + [child, 1], + [grandchild, 2], + [outside, 3], + ]); + + assert.strictEqual(deleteByPathPrefix(map, dir), true); + assert.strictEqual(map.has(child), false); + assert.strictEqual(map.has(grandchild), false); + assert.strictEqual(map.has(outside), true); + }); + + it('should not delete sibling paths that merely share a string prefix', () => { + const dir = path.join('/ws', 'app', 'Foo'); + const sibling = path.join('/ws', 'app', 'FooBar', 'etc', 'di.xml'); + const map = new Map([[sibling, 1]]); + + assert.strictEqual(deleteByPathPrefix(map, dir), false); + assert.strictEqual(map.has(sibling), true); + }); + + it('should return false when nothing matches', () => { + const map = new Map([[path.join('/ws', 'a.xml'), 1]]); + + assert.strictEqual(deleteByPathPrefix(map, path.join('/ws', 'b.xml')), false); + assert.strictEqual(map.size, 1); + }); + + it('should return false for an empty map', () => { + assert.strictEqual(deleteByPathPrefix(new Map(), path.join('/ws', 'a.xml')), false); + }); +}); diff --git a/src/test/indexer/IndexManagerCache.test.ts b/src/test/indexer/IndexManagerCache.test.ts new file mode 100644 index 0000000..12a7958 --- /dev/null +++ b/src/test/indexer/IndexManagerCache.test.ts @@ -0,0 +1,65 @@ +import * as assert from 'assert'; +import { describe, it, before } from 'mocha'; +import { Uri, WorkspaceFolder } from 'vscode'; +import IndexManager from 'indexer/IndexManager'; +import ModuleIndexer from 'indexer/module/ModuleIndexer'; +import { setup } from 'test/setup'; + +interface IndexManagerInternals { + indexStorage: { set(wf: WorkspaceFolder, key: string, value: Map): void }; + invalidateIndexData(wf: WorkspaceFolder, key: string): void; +} + +const internals = IndexManager as unknown as IndexManagerInternals; + +function fakeWorkspace(fsPath: string): WorkspaceFolder { + return { uri: Uri.file(fsPath), name: fsPath, index: 0 }; +} + +describe('IndexManager.getIndexData cache Tests', () => { + before(async () => { + await setup(); + }); + + it('should return undefined when no index data is stored', () => { + const wf = fakeWorkspace('/tmp/mt-cache-none'); + + assert.strictEqual(IndexManager.getIndexData(ModuleIndexer.KEY, wf), undefined); + }); + + it('should reuse the same derived data instance across calls', () => { + const wf = fakeWorkspace('/tmp/mt-cache-reuse'); + internals.indexStorage.set(wf, ModuleIndexer.KEY, new Map()); + + const first = IndexManager.getIndexData(ModuleIndexer.KEY, wf); + const second = IndexManager.getIndexData(ModuleIndexer.KEY, wf); + + assert.ok(first); + assert.strictEqual(first, second); + }); + + it('should keep separate instances per workspace', () => { + const wfA = fakeWorkspace('/tmp/mt-cache-a'); + const wfB = fakeWorkspace('/tmp/mt-cache-b'); + internals.indexStorage.set(wfA, ModuleIndexer.KEY, new Map()); + internals.indexStorage.set(wfB, ModuleIndexer.KEY, new Map()); + + const a = IndexManager.getIndexData(ModuleIndexer.KEY, wfA); + const b = IndexManager.getIndexData(ModuleIndexer.KEY, wfB); + + assert.ok(a && b); + assert.notStrictEqual(a, b); + }); + + it('should build a fresh instance after invalidation', () => { + const wf = fakeWorkspace('/tmp/mt-cache-invalidate'); + internals.indexStorage.set(wf, ModuleIndexer.KEY, new Map()); + + const first = IndexManager.getIndexData(ModuleIndexer.KEY, wf); + internals.invalidateIndexData(wf, ModuleIndexer.KEY); + const second = IndexManager.getIndexData(ModuleIndexer.KEY, wf); + + assert.ok(first && second); + assert.notStrictEqual(first, second); + }); +}); diff --git a/src/test/util/Magento.test.ts b/src/test/util/Magento.test.ts new file mode 100644 index 0000000..7d5e6d8 --- /dev/null +++ b/src/test/util/Magento.test.ts @@ -0,0 +1,96 @@ +import * as assert from 'assert'; +import { describe, it } from 'mocha'; +import Magento from 'util/Magento'; +import { MagentoScope } from 'types/global'; + +describe('Magento Tests', () => { + describe('isPluginMethod', () => { + ['aroundExecute', 'beforeSave', 'afterGetName'].forEach(method => { + it(`should detect "${method}" as a plugin method`, () => { + assert.strictEqual(Magento.isPluginMethod(method), true); + }); + }); + + ['execute', 'getName', 'AroundExecute', 'Before', ''].forEach(method => { + it(`should not detect "${method}" as a plugin method`, () => { + assert.strictEqual(Magento.isPluginMethod(method), false); + }); + }); + }); + + describe('pluginMethodToMethodName', () => { + const cases: [string, string][] = [ + ['aroundExecute', 'execute'], + ['beforeSave', 'save'], + ['afterGetName', 'getName'], + ]; + + cases.forEach(([input, expected]) => { + it(`should map "${input}" to "${expected}"`, () => { + assert.strictEqual(Magento.pluginMethodToMethodName(input), expected); + }); + }); + }); + + describe('splitModule / getModuleName', () => { + it('should split a module name into vendor and module', () => { + assert.deepStrictEqual(Magento.splitModule('Vendor_Module'), { + vendor: 'Vendor', + module: 'Module', + }); + }); + + it('should rebuild a module name from vendor and module', () => { + assert.strictEqual(Magento.getModuleName('Vendor', 'Module'), 'Vendor_Module'); + }); + + it('should round-trip split and rebuild', () => { + const { vendor, module } = Magento.splitModule('Foo_Bar'); + assert.strictEqual(Magento.getModuleName(vendor, module), 'Foo_Bar'); + }); + }); + + describe('getArea', () => { + it('should detect the frontend area', () => { + assert.strictEqual(Magento.getArea('/ws/view/frontend/layout/a.xml'), MagentoScope.Frontend); + }); + + it('should detect the adminhtml area', () => { + assert.strictEqual( + Magento.getArea('/ws/view/adminhtml/layout/a.xml'), + MagentoScope.Adminhtml + ); + }); + + it('should fall back to the global area', () => { + assert.strictEqual(Magento.getArea('/ws/etc/di.xml'), MagentoScope.Global); + }); + }); + + describe('getLayoutArea', () => { + it('should detect frontend from a module view path', () => { + assert.strictEqual( + Magento.getLayoutArea('/ws/view/frontend/layout/a.xml'), + MagentoScope.Frontend + ); + }); + + it('should detect adminhtml from a theme design path', () => { + assert.strictEqual( + Magento.getLayoutArea('/ws/app/design/adminhtml/Vendor/theme/a.xml'), + MagentoScope.Adminhtml + ); + }); + + it('should normalise Windows separators', () => { + assert.strictEqual( + Magento.getLayoutArea('C:\\ws\\view\\frontend\\layout\\a.xml'), + MagentoScope.Frontend + ); + }); + + it('should fall back to the base area', () => { + assert.strictEqual(Magento.getLayoutArea('/ws/view/base/layout/a.xml'), MagentoScope.Base); + }); + }); +}); diff --git a/src/test/util/Range.test.ts b/src/test/util/Range.test.ts new file mode 100644 index 0000000..2ba048d --- /dev/null +++ b/src/test/util/Range.test.ts @@ -0,0 +1,57 @@ +import * as assert from 'assert'; +import { describe, it } from 'mocha'; +import Range from 'util/Range'; + +describe('Range.fileRegexToVsCodeRange Tests', () => { + it('should map a single-line match to the correct range', () => { + const range = Range.fileRegexToVsCodeRange(/world/, 'line0\nhello world\nfoo'); + + assert.strictEqual(range.start.line, 1); + assert.strictEqual(range.start.character, 6); + assert.strictEqual(range.end.line, 1); + assert.strictEqual(range.end.character, 11); + }); + + it('should target the first capture group when present', () => { + const range = Range.fileRegexToVsCodeRange(/name="([^"]*)"/, ''); + + // Range should cover just "Foo", not the whole attribute. + assert.strictEqual(range.start.line, 0); + assert.strictEqual(range.start.character, 9); + assert.strictEqual(range.end.character, 12); + }); + + it('should span a multi-line match across lines', () => { + const range = Range.fileRegexToVsCodeRange(/a\nb\nc/, 'x\na\nb\nc\ny'); + + assert.strictEqual(range.start.line, 1); + assert.strictEqual(range.start.character, 0); + assert.strictEqual(range.end.line, 3); + assert.strictEqual(range.end.character, 1); + }); + + it('should return a zero range when there is no match', () => { + const range = Range.fileRegexToVsCodeRange(/xyz/, 'abc'); + + assert.strictEqual(range.start.line, 0); + assert.strictEqual(range.start.character, 0); + assert.strictEqual(range.end.line, 0); + assert.strictEqual(range.end.character, 0); + }); + + it('should return every match for a global pattern', () => { + const ranges = Range.fileRegexToVsCodeRanges(/a/g, 'a\na\na'); + + assert.strictEqual(ranges.length, 3); + assert.deepStrictEqual( + ranges.map(r => r.start.line), + [0, 1, 2] + ); + }); + + it('should not loop forever on a zero-width match', () => { + const ranges = Range.fileRegexToVsCodeRanges(/(?=b)/g, 'abab'); + + assert.strictEqual(ranges.length, 2); + }); +});