You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
366 lines
9.7 KiB
366 lines
9.7 KiB
import { describe, expect, it } from 'vitest';
|
|
|
|
import { lintRtlGeometry, lintRtlMirror } from './rtl-lint';
|
|
|
|
describe('lintRtlGeometry — RTL-1', () => {
|
|
// The regression this guard exists for: `slider.css` before `013ceac57`.
|
|
// Measured in Chrome at the time — root/thumb/ticks at x=961, rail and fill
|
|
// at x=955, a full track-width off-axis.
|
|
const SLIDER_BEFORE = `
|
|
[data-slider][data-orientation='vertical']::before {
|
|
inset-block: 0;
|
|
inset-inline-start: 50%;
|
|
inline-size: var(--_slider-track-size);
|
|
transform: translateX(-50%);
|
|
}
|
|
`;
|
|
|
|
// The fix that landed: same logical anchor, logical offset.
|
|
const SLIDER_AFTER = `
|
|
[data-slider][data-orientation='vertical']::before {
|
|
inset-block: 0;
|
|
inset-inline-start: 50%;
|
|
inline-size: var(--_slider-track-size);
|
|
margin-inline-start: calc(var(--_slider-track-size) / -2);
|
|
}
|
|
`;
|
|
|
|
it('catches the historical slider defect', () => {
|
|
const findings = lintRtlGeometry(SLIDER_BEFORE);
|
|
expect(findings).toHaveLength(1);
|
|
expect(findings[0].line).toBe(6);
|
|
expect(findings[0].translate).toBe('transform: translateX(-50%)');
|
|
expect(findings[0].anchors).toEqual(['inset-inline-start: 50%']);
|
|
expect(findings[0].selector).toBe("[data-slider][data-orientation='vertical']::before");
|
|
});
|
|
|
|
it('passes the fix that replaced it', () => {
|
|
expect(lintRtlGeometry(SLIDER_AFTER)).toEqual([]);
|
|
});
|
|
|
|
it('does NOT flag the block axis — it never flips', () => {
|
|
const css = `
|
|
[data-slider-thumb] {
|
|
inset-block-start: 50%;
|
|
transform: translateY(-50%);
|
|
}
|
|
`;
|
|
expect(lintRtlGeometry(css)).toEqual([]);
|
|
});
|
|
|
|
it('flags the X component of a two-axis translate', () => {
|
|
const css = `
|
|
[data-dialog-content] {
|
|
inset-inline-start: 50%;
|
|
inset-block-start: 50%;
|
|
transform: translate(-50%, -50%);
|
|
}
|
|
`;
|
|
expect(lintRtlGeometry(css)).toHaveLength(1);
|
|
});
|
|
|
|
it('reads the independent `translate` property, X component first', () => {
|
|
const flagged = `
|
|
[data-menu-dial] {
|
|
inset-inline-start: 50%;
|
|
translate: -50% 0;
|
|
}
|
|
`;
|
|
const blockAxisOnly = `
|
|
[data-carousel-trigger] {
|
|
inset-inline-start: 50%;
|
|
translate: 0 -50%;
|
|
}
|
|
`;
|
|
expect(lintRtlGeometry(flagged)).toHaveLength(1);
|
|
expect(lintRtlGeometry(blockAxisOnly)).toEqual([]);
|
|
expect(lintRtlGeometry('[data-x] { inset-inline-start: 0; translate: none; }')).toEqual([]);
|
|
});
|
|
|
|
it('treats a var() X as displacing — the sign is unknown at author time', () => {
|
|
// `sliding-indicator.css`: soma writes the offset rect into the HOST's
|
|
// namespace and the shared file reads it through the `--_indicator-*`
|
|
// alias each consuming recipe declares (firma §14, 2026-08-26).
|
|
const css = `
|
|
[data-sliding-indicator] {
|
|
inset-inline-start: 0;
|
|
translate: var(--_indicator-x, 0px) var(--_indicator-y, 0px);
|
|
}
|
|
`;
|
|
const findings = lintRtlGeometry(css);
|
|
expect(findings).toHaveLength(1);
|
|
// The comma inside var() must not split the X component off.
|
|
expect(findings[0].translate).toBe(
|
|
'translate: var(--_indicator-x, 0px) var(--_indicator-y, 0px)'
|
|
);
|
|
});
|
|
|
|
it('ignores a zero X displacement', () => {
|
|
const css = `
|
|
[data-x] {
|
|
margin-inline-start: 4px;
|
|
transform: translateX(0) translateY(-50%);
|
|
}
|
|
`;
|
|
expect(lintRtlGeometry(css)).toEqual([]);
|
|
});
|
|
|
|
it('ignores padding-inline — it cannot fight a translate', () => {
|
|
const css = `
|
|
[data-chat-log-pill] {
|
|
padding-inline: var(--chat-log-pill-padding-inline);
|
|
transform: translateX(-50%);
|
|
}
|
|
`;
|
|
expect(lintRtlGeometry(css)).toEqual([]);
|
|
});
|
|
|
|
it('flags margin-inline anchors too', () => {
|
|
const css = `
|
|
[data-switch-thumb] {
|
|
margin-inline-start: var(--switch-thumb-inset);
|
|
transform: translateX(var(--_switch-thumb-offset));
|
|
}
|
|
`;
|
|
expect(lintRtlGeometry(css)).toHaveLength(1);
|
|
});
|
|
|
|
it('judges each nested block on its own declarations', () => {
|
|
// The anchor is the parent's; the child rule may match another element.
|
|
const css = `
|
|
[data-parent] {
|
|
inset-inline-start: 50%;
|
|
|
|
& [data-child] {
|
|
transform: translateX(-50%);
|
|
}
|
|
}
|
|
`;
|
|
expect(lintRtlGeometry(css)).toEqual([]);
|
|
});
|
|
|
|
it('sees inside at-rules', () => {
|
|
const css = `
|
|
@media (min-width: 40rem) {
|
|
[data-x] {
|
|
inset-inline-start: 50%;
|
|
transform: translateX(-50%);
|
|
}
|
|
}
|
|
`;
|
|
expect(lintRtlGeometry(css)).toHaveLength(1);
|
|
});
|
|
|
|
it('honours the rtl-physical escape hatch, same line or the line above', () => {
|
|
const above = `
|
|
[data-cropper-handle][data-corner='nw'] {
|
|
inset-inline-start: 0;
|
|
/* rtl-physical: compass corner of an image that does not flip */
|
|
transform: translate(-50%, -50%);
|
|
}
|
|
`;
|
|
const sameLine = `
|
|
[data-x] {
|
|
inset-inline-start: 0;
|
|
transform: translateX(-50%); /* rtl-physical: deliberate */
|
|
}
|
|
`;
|
|
expect(lintRtlGeometry(above)).toEqual([]);
|
|
expect(lintRtlGeometry(sameLine)).toEqual([]);
|
|
});
|
|
|
|
it('ignores custom-property declarations — the consumer gets judged', () => {
|
|
const css = `
|
|
[data-x] {
|
|
inset-inline-start: 50%;
|
|
--x-offset: translateX(-50%);
|
|
}
|
|
`;
|
|
expect(lintRtlGeometry(css)).toEqual([]);
|
|
});
|
|
|
|
it('does not read commented-out code', () => {
|
|
const css = `
|
|
[data-x] {
|
|
inset-inline-start: 50%;
|
|
/* transform: translateX(-50%); */
|
|
margin-inline-start: calc(var(--w) / -2);
|
|
}
|
|
`;
|
|
expect(lintRtlGeometry(css)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe('lintRtlMirror — RTL-2', () => {
|
|
// The two regressions this guard exists for, verbatim from `dee2c9e3a^`.
|
|
// Both shipped: the migration of §6.3 swapped `[dir='rtl']` for `:dir(rtl)`
|
|
// without asking whether the BODY was right, so a mechanical rewrite carried
|
|
// the defect across untouched.
|
|
const FEED_BEFORE = `
|
|
[data-feed-thread] {
|
|
padding-inline-start: var(--_feed-thread-indent);
|
|
border-inline-start: var(--border-width-medium) solid var(--_feed-palette-element);
|
|
}
|
|
|
|
[data-feed-root]:dir(rtl) [data-feed-thread] {
|
|
border-inline-start: 0;
|
|
border-inline-end: var(--border-width-medium) solid var(--_feed-palette-element);
|
|
}
|
|
`;
|
|
|
|
const TREE_VIEW_BEFORE = `
|
|
[data-tree-view-root][data-indent-guides]:dir(rtl) [data-tree-view-branch]::before {
|
|
inset-inline-start: auto;
|
|
inset-inline-end: calc(var(--tree-depth, 0) * var(--_indent));
|
|
}
|
|
`;
|
|
|
|
it('catches the feed rail, which left the indent on the other edge', () => {
|
|
const findings = lintRtlMirror(FEED_BEFORE);
|
|
expect(findings).toHaveLength(1);
|
|
expect(findings[0].line).toBe(9);
|
|
expect(findings[0].family).toBe('border-inline');
|
|
expect(findings[0].neutral).toBe('border-inline-start: 0');
|
|
expect(findings[0].selector).toBe('[data-feed-root]:dir(rtl) [data-feed-thread]');
|
|
});
|
|
|
|
it('catches the tree-view indent guide, released with `auto`', () => {
|
|
const findings = lintRtlMirror(TREE_VIEW_BEFORE);
|
|
expect(findings).toHaveLength(1);
|
|
expect(findings[0].family).toBe('inset-inline');
|
|
expect(findings[0].neutral).toBe('inset-inline-start: auto');
|
|
});
|
|
|
|
it('collapses a multi-line value — the real tree-view calc() spans four lines', () => {
|
|
const css = `
|
|
[data-x]:dir(rtl) {
|
|
inset-inline-start: auto;
|
|
inset-inline-end: calc(
|
|
var(--depth, 0) * var(--indent) +
|
|
var(--indent) / 2
|
|
);
|
|
}
|
|
`;
|
|
const findings = lintRtlMirror(css);
|
|
expect(findings).toHaveLength(1);
|
|
expect(findings[0].payload).toBe(
|
|
'inset-inline-end: calc( var(--depth, 0) * var(--indent) + var(--indent) / 2 )'
|
|
);
|
|
});
|
|
|
|
it('passes the fix — deleting the rule, since the base already mirrored', () => {
|
|
const css = `
|
|
[data-feed-thread] {
|
|
padding-inline-start: var(--_feed-thread-indent);
|
|
border-inline-start: var(--border-width-medium) solid var(--_feed-palette-element);
|
|
}
|
|
`;
|
|
expect(lintRtlMirror(css)).toEqual([]);
|
|
});
|
|
|
|
it('does NOT flag a value changed without being relocated', () => {
|
|
// Asymmetric by design is real; what betrays the double flip is the PAIR.
|
|
const css = `
|
|
[data-x]:dir(rtl) {
|
|
padding-inline-start: 0;
|
|
}
|
|
`;
|
|
expect(lintRtlMirror(css)).toEqual([]);
|
|
});
|
|
|
|
it('does NOT flag two faces that both carry a value', () => {
|
|
// An author describing two different edges, not cancelling a mirror.
|
|
const css = `
|
|
[data-x]:dir(rtl) {
|
|
margin-inline-start: var(--a);
|
|
margin-inline-end: var(--b);
|
|
}
|
|
`;
|
|
expect(lintRtlMirror(css)).toEqual([]);
|
|
});
|
|
|
|
it('does NOT flag two faces that are both off — that is a reset', () => {
|
|
const css = `
|
|
[data-x]:dir(rtl) {
|
|
inset-inline-start: auto;
|
|
inset-inline-end: auto;
|
|
}
|
|
`;
|
|
expect(lintRtlMirror(css)).toEqual([]);
|
|
});
|
|
|
|
it('does NOT flag the pair outside a :dir() block', () => {
|
|
// Without the direction branch there is no mirror to cancel.
|
|
const css = `
|
|
[data-x] {
|
|
border-inline-start: 0;
|
|
border-inline-end: 1px solid red;
|
|
}
|
|
`;
|
|
expect(lintRtlMirror(css)).toEqual([]);
|
|
});
|
|
|
|
it("judges a child on its OWN prelude, not the parent's :dir()", () => {
|
|
// The same call RTL-1 makes about anchors.
|
|
const css = `
|
|
[data-x]:dir(rtl) {
|
|
color: red;
|
|
|
|
& [data-y] {
|
|
border-inline-start: 0;
|
|
border-inline-end: 1px solid red;
|
|
}
|
|
}
|
|
`;
|
|
expect(lintRtlMirror(css)).toEqual([]);
|
|
});
|
|
|
|
it('flags :dir(ltr) too — the shape is just as suspect', () => {
|
|
const css = `
|
|
[data-x]:dir(ltr) {
|
|
margin-inline-end: 0;
|
|
margin-inline-start: var(--gap);
|
|
}
|
|
`;
|
|
expect(lintRtlMirror(css)).toHaveLength(1);
|
|
});
|
|
|
|
it('honours the rtl-mirror escape hatch, same line or the line above', () => {
|
|
const sameLine = `
|
|
[data-x]:dir(rtl) {
|
|
border-inline-start: 0;
|
|
border-inline-end: 1px solid red; /* rtl-mirror: the design breaks the mirror here */
|
|
}
|
|
`;
|
|
const lineAbove = `
|
|
[data-x]:dir(rtl) {
|
|
border-inline-start: 0;
|
|
/* rtl-mirror: the design breaks the mirror here */
|
|
border-inline-end: 1px solid red;
|
|
}
|
|
`;
|
|
expect(lintRtlMirror(sameLine)).toEqual([]);
|
|
expect(lintRtlMirror(lineAbove)).toEqual([]);
|
|
});
|
|
|
|
it("does not accept RTL-1's marker — it would declare the wrong thing", () => {
|
|
const css = `
|
|
[data-x]:dir(rtl) {
|
|
border-inline-start: 0;
|
|
border-inline-end: 1px solid red; /* rtl-physical: wrong marker */
|
|
}
|
|
`;
|
|
expect(lintRtlMirror(css)).toHaveLength(1);
|
|
});
|
|
|
|
it('does not read commented-out code', () => {
|
|
const css = `
|
|
[data-x]:dir(rtl) {
|
|
border-inline-start: 0;
|
|
/* border-inline-end: 1px solid red; */
|
|
}
|
|
`;
|
|
expect(lintRtlMirror(css)).toEqual([]);
|
|
});
|
|
});
|