145 lines
5.2 KiB
TypeScript
145 lines
5.2 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
computeSignature,
|
|
normalizeFilePath,
|
|
extractStaticText,
|
|
extractDistinctiveAttrs,
|
|
type SignatureNode,
|
|
type SignatureContext,
|
|
} from '../signature';
|
|
|
|
// Minimal node shape that matches what we extract from @vue/compiler-dom AST.
|
|
// In production we adapt the parser's ElementNode → SignatureNode.
|
|
const node = (overrides: Partial<SignatureNode> = {}): SignatureNode => ({
|
|
tag: 'div',
|
|
props: [],
|
|
children: [],
|
|
...overrides,
|
|
});
|
|
|
|
const ctx = (overrides: Partial<SignatureContext> = {}): SignatureContext => ({
|
|
filePath: 'app/resources/js/components/AppSidebar.vue',
|
|
appRoot: 'app/',
|
|
ancestorChain: ['AppSidebar'],
|
|
sameTagOrdinal: 0,
|
|
...overrides,
|
|
});
|
|
|
|
describe('normalizeFilePath', () => {
|
|
it('strips appRoot and .vue extension, uses forward slashes', () => {
|
|
expect(
|
|
normalizeFilePath('app/resources/js/components/A.vue', 'app/'),
|
|
).toBe('resources/js/components/A');
|
|
});
|
|
|
|
it('handles backslashes on Windows-style paths', () => {
|
|
expect(
|
|
normalizeFilePath('app\\resources\\js\\A.vue', 'app/'),
|
|
).toBe('resources/js/A');
|
|
});
|
|
});
|
|
|
|
describe('extractStaticText', () => {
|
|
it('returns first 24 chars of plain text child', () => {
|
|
const n = node({ children: [{ type: 'text', content: 'Создать проект сейчас же' }] });
|
|
expect(extractStaticText(n)).toBe('создать проект сейчас же');
|
|
});
|
|
|
|
it('truncates >24 chars', () => {
|
|
const n = node({ children: [{ type: 'text', content: 'A'.repeat(50) }] });
|
|
expect(extractStaticText(n)).toHaveLength(24);
|
|
});
|
|
|
|
it('ignores mustache interpolations', () => {
|
|
const n = node({
|
|
children: [
|
|
{ type: 'text', content: 'count is ' },
|
|
{ type: 'interpolation', content: '{{ count }}' },
|
|
],
|
|
});
|
|
expect(extractStaticText(n)).toBe('count is');
|
|
});
|
|
|
|
it('returns null when no static text', () => {
|
|
const n = node({ children: [{ type: 'interpolation', content: '{{ x }}' }] });
|
|
expect(extractStaticText(n)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('extractDistinctiveAttrs', () => {
|
|
it('extracts allowlisted static attrs sorted by name', () => {
|
|
const n = node({
|
|
tag: 'v-btn',
|
|
props: [
|
|
{ type: 'attribute', name: 'icon', value: 'plus' },
|
|
{ type: 'attribute', name: 'role', value: 'button' },
|
|
{ type: 'attribute', name: 'class', value: 'mr-2' },
|
|
],
|
|
});
|
|
expect(extractDistinctiveAttrs(n)).toBe('icon=plus,role=button');
|
|
});
|
|
|
|
it('ignores v-bind / : attrs (dynamic)', () => {
|
|
const n = node({
|
|
props: [
|
|
{ type: 'directive', name: 'bind', arg: 'icon', exp: 'someVar' },
|
|
{ type: 'attribute', name: 'id', value: 'static-id' },
|
|
],
|
|
});
|
|
expect(extractDistinctiveAttrs(n)).toBe('id=static-id');
|
|
});
|
|
|
|
it('returns empty string if no allowlisted attrs', () => {
|
|
const n = node({ props: [{ type: 'attribute', name: 'class', value: 'foo' }] });
|
|
expect(extractDistinctiveAttrs(n)).toBe('');
|
|
});
|
|
|
|
it('escape-hatch: data-dev-name takes precedence', () => {
|
|
const n = node({
|
|
tag: 'v-btn',
|
|
props: [
|
|
{ type: 'attribute', name: 'icon', value: 'plus' },
|
|
{ type: 'attribute', name: 'data-dev-name', value: 'sidebar.create-btn' },
|
|
],
|
|
});
|
|
expect(extractDistinctiveAttrs(n)).toContain('data-dev-name=sidebar.create-btn');
|
|
});
|
|
});
|
|
|
|
describe('computeSignature', () => {
|
|
it('canonical signature for a plain element', () => {
|
|
const n = node({
|
|
tag: 'v-btn',
|
|
props: [{ type: 'attribute', name: 'icon', value: 'plus' }],
|
|
children: [{ type: 'text', content: 'Создать' }],
|
|
});
|
|
expect(computeSignature(n, ctx({ ancestorChain: ['AppSidebar', 'nav', 'v-list-item'] }))).toBe(
|
|
'resources/js/components/AppSidebar::AppSidebar>nav>v-list-item::v-btn[icon=plus]::создать::0',
|
|
);
|
|
});
|
|
|
|
it('identical templates produce identical signatures', () => {
|
|
const a = node({ tag: 'span', children: [{ type: 'text', content: 'hi' }] });
|
|
const b = node({ tag: 'span', children: [{ type: 'text', content: 'hi' }] });
|
|
expect(computeSignature(a, ctx())).toBe(computeSignature(b, ctx()));
|
|
});
|
|
|
|
it('different ordinalAmongSameTag yields different signatures', () => {
|
|
const a = node({ tag: 'v-btn' });
|
|
const s0 = computeSignature(a, ctx({ sameTagOrdinal: 0 }));
|
|
const s1 = computeSignature(a, ctx({ sameTagOrdinal: 1 }));
|
|
expect(s0).not.toBe(s1);
|
|
});
|
|
|
|
it('data-dev-name overrides structural pieces', () => {
|
|
const a = node({
|
|
tag: 'v-btn',
|
|
props: [{ type: 'attribute', name: 'data-dev-name', value: 'sidebar.create' }],
|
|
});
|
|
const sigA = computeSignature(a, ctx({ sameTagOrdinal: 0 }));
|
|
// Same name, different ordinal — signature MUST be identical (escape hatch)
|
|
const sigB = computeSignature(a, ctx({ sameTagOrdinal: 7 }));
|
|
expect(sigA).toBe(sigB);
|
|
});
|
|
});
|