Skip to content
Open
171 changes: 171 additions & 0 deletions src/__tests__/_media-features.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import type { MediaFeatureComparison } from "react-native-css/compiler";

/**
* The range vocabulary `@media` and `@container` share: the five comparison
* operators, the two size features, and what each operator means.
*
* It is shared rather than restated per suite because one meaning has to hold
* across the primitive, both evaluators and both at-rules. A second copy of a
* five-armed operator table is the exact shape of the defect these tests
* guard: two hand-written switches over the same five operators, differing by
* one character, one of them wrong.
*
* This is not a test file — `testPathIgnorePatterns` skips a path segment
* starting with an underscore.
*
* A note on how the tables built from this are read. A rendered case asserts a
* verdict, and the two verdicts fail under opposite defects: a `matches: true`
* row reddens when a condition stops being answered, because the block is then
* dropped or refused; a `matches: false` row reddens when a condition stops
* being asked, because the block is then emitted with nothing to check and
* applies everywhere. Neither half observes the other's direction, so a table
* of one verdict is half a table however many rows it has — which is why every
* table here carries both, and why the counts of the two are worth keeping
* near each other.
*
* A table's size is also not evidence that it covers anything. Every table is
* generated from this census, so its length is the census's length by
* construction and agrees with a census that lost an operator. Coverage is
* asserted against {@link COMPARISON_MATCHES} instead, whose keys are the
* `MediaFeatureComparison` union itself.
*/

/**
* Where the measured value sits relative to the threshold the condition is
* written against. Every range comparison is decided by this and nothing else,
* so it is the dimension a table has to vary — and it is the dimension a
* copy-pasted operator arm hides in, because any two operators agree on at
* least one third of it.
*/
export type Ordering =
| "measured < threshold"
| "measured === threshold"
| "measured > threshold";

export const ORDERINGS: Ordering[] = [
"measured < threshold",
"measured === threshold",
"measured > threshold",
];

/**
* What each comparison operator means, written out rather than computed.
*
* This is the specification every table is measured against. Deriving it from
* the code under test would make each table agree with whatever that code
* does, including a wrong operator — so it is literal, and it is the one place
* the semantics are stated.
*
* Typed as a total `Record`, so an operator added to `MediaFeatureComparison`
* is a compile error here rather than a silently uncovered arm.
*/
export const COMPARISON_MATCHES: Record<
MediaFeatureComparison,
Record<Ordering, boolean>
> = {
"=": {
"measured < threshold": false,
"measured === threshold": true,
"measured > threshold": false,
},
">": {
"measured < threshold": false,
"measured === threshold": false,
"measured > threshold": true,
},
">=": {
"measured < threshold": false,
"measured === threshold": true,
"measured > threshold": true,
},
"<": {
"measured < threshold": true,
"measured === threshold": false,
"measured > threshold": false,
},
"<=": {
"measured < threshold": true,
"measured === threshold": true,
"measured > threshold": false,
},
};

export const COMPARISON_OPERATORS: MediaFeatureComparison[] = [
"=",
">",
">=",
"<",
"<=",
];

/**
* The `min-`/`max-` prefixed spelling of the two operators that have one.
*
* lightningcss normalises `(min-width: 400px)` into a `>=` range condition, so
* the prefixed form is not a separate feature — it is the same condition
* written a second way, and it has to compile to the same tuple and evaluate
* to the same verdict. It is also the spelling almost every author writes, so
* an operator defect reaches users through this row first.
*/
export const RANGE_PREFIX: Partial<
Record<MediaFeatureComparison, "min" | "max">
> = {
">=": "min",
"<=": "max",
};

/**
* The two size features a range condition is written against. Both at-rules
* accept both, and each has its own measurement — reading one axis off the
* other is a defect no single-axis table can see.
*/
export const SIZE_FEATURES = ["width", "height"] as const;

export type SizeFeature = (typeof SIZE_FEATURES)[number];

export interface SizeComparison {
/** The operator the runtime is handed, whatever spelling the CSS used. */
operator: MediaFeatureComparison;
feature: SizeFeature;
/** `range` is `(width >= 400px)`; `prefixed` is `(min-width: 400px)`. */
spelling: "range" | "prefixed";
/** The condition as written inside the query's parentheses. */
condition: (threshold: number) => string;
/** Test-name fragment, e.g. `width >=` or `min-width:`. */
label: string;
}

/**
* Every way to write a size range condition: each operator on each axis, plus
* the prefixed spelling of the two operators that have one.
*/
export function sizeComparisons(): SizeComparison[] {
return SIZE_FEATURES.flatMap((feature) => {
return COMPARISON_OPERATORS.flatMap((operator): SizeComparison[] => {
const prefix = RANGE_PREFIX[operator];

const range: SizeComparison = {
operator,
feature,
spelling: "range",
condition: (threshold) => `(${feature} ${operator} ${threshold}px)`,
label: `${feature} ${operator}`,
};

if (!prefix) {
return [range];
}

return [
range,
{
operator,
feature,
spelling: "prefixed",
condition: (threshold) => `(${prefix}-${feature}: ${threshold}px)`,
label: `${prefix}-${feature}:`,
},
];
});
});
}
135 changes: 135 additions & 0 deletions src/__tests__/compiler/conditional-group-rules.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { compile, type StyleRule } from "react-native-css/compiler";

/**
* Returns every rule the compiler emitted for `.child`.
*
* A conditional group rule (`@media`, `@container`) contributes its inner
* rules to this list; if the block is skipped the list is empty.
*/
function compileChildRules(css: string): StyleRule[] {
const stylesheet = compile(css).stylesheet();

return (
stylesheet.s?.flatMap(([className, ruleSet]) => {
return className === "child" ? ruleSet : [];
}) ?? []
);
}

/**
* Conditions this compiler cannot evaluate, one per reason it cannot.
*
* A block guarded by one of these can never be shown to match, so it must not
* be emitted. Each case is also a vacuity guard on the case above it: if
* support for one of these lands, its `m`/`cq` stops being absent and the test
* fails, which is the signal to move the case rather than delete it.
*/
const uncompilable: [label: string, css: string][] = [
[
"a container style() query",
"@container style(--foo: bar) { .child { color: red } }",
],
[
"a container feature value the compiler cannot resolve",
"@container (width > env(safe-area-inset-top)) { .child { color: red } }",
],
[
"a media feature value the compiler cannot resolve",
"@media (width > env(safe-area-inset-top)) { .child { color: red } }",
],
[
"a negated media condition the compiler cannot resolve",
"@media not (width > env(safe-area-inset-top)) { .child { color: red } }",
],
// A `<ratio>` stands for its quotient, and a zero denominator has none.
// There is no number a comparison against it could be written as: the
// emitted value would be `Infinity` or `NaN`, which the bundle serialises to
// `null` and the runtime then reads as an unresolved bound anyway.
[
"a media ratio with no finite quotient",
"@media (min-aspect-ratio: 1/0) { .child { color: red } }",
],
[
"a media ratio that is not a number at all",
"@media (min-aspect-ratio: 0/0) { .child { color: red } }",
],
[
"a container ratio with no finite quotient",
"@container (min-aspect-ratio: 1/0) { .child { color: red } }",
],
];

describe("a block whose condition does not compile is not emitted", () => {
test.each(uncompilable)("%s", (_label, css) => {
// Emitting the rule with no condition is worse than emitting nothing: the
// declarations then apply to every element that carries the class, which
// is the opposite of what the author wrote.
expect(compileChildRules(css)).toStrictEqual([]);
});
});

describe("a block whose condition does compile is emitted", () => {
/**
* The control for the table above — without it, a compiler that emitted
* nothing at all would pass every case there.
*
* Each case names the condition the rule must carry rather than counting the
* rules, because the two failures being pinned are opposite and a count sees
* only one of them: a block dropped when it should not be, and a block kept
* but stripped of the condition that was the whole point of it. The second
* is the more dangerous, since the declarations then apply everywhere.
*
* An absent condition is therefore stated, not omitted. `@media all` and
* `@media not print and (…)` genuinely carry none — `not print` reads `not
* (print and …)`, true on every non-print device whatever follows — and that
* is exactly the state a condition which failed to compile must not be
* confused with.
*/
const cases: [
label: string,
css: string,
conditions: Pick<StyleRule, "m" | "cq">,
][] = [
[
"@container",
"@container (width > 400px) { .child { color: red } }",
{ m: undefined, cq: [{ m: [">", "width", 400] }] },
],
[
"@media",
"@media (width > 400px) { .child { color: red } }",
{ m: [[">", "width", 400]], cq: undefined },
],
[
"@media all",
"@media all { .child { color: red } }",
{ m: undefined, cq: undefined },
],
[
"@media screen",
"@media screen { .child { color: red } }",
{ m: undefined, cq: undefined },
],
[
"@media not print",
"@media not print and (width > 400px) { .child { color: red } }",
{ m: undefined, cq: undefined },
],
[
"a media query list with one uncompilable branch",
"@media (width > env(safe-area-inset-top)), (width > 400px) { .child { color: red } }",
{ m: [[">", "width", 400]], cq: undefined },
],
[
"a ratio whose quotient is finite",
"@media (min-aspect-ratio: 0/1) { .child { color: red } }",
{ m: [[">=", "aspect-ratio", 0]], cq: undefined },
],
];

test.each(cases)("%s", (_label, css, conditions) => {
expect(
compileChildRules(css).map((rule) => ({ m: rule.m, cq: rule.cq })),
).toStrictEqual([conditions]);
});
});
Loading