Skip to content

Commit 6303d3c

Browse files
committed
Rewrite editor highlighter for guaranteed character count preservation
1 parent 10beb3d commit 6303d3c

3 files changed

Lines changed: 191 additions & 269 deletions

File tree

src/ra-engine/raHighlight.test.ts

Lines changed: 96 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -2,168 +2,162 @@ import { describe, it, expect } from "vitest";
22
import { highlightRA } from "./raHighlight";
33
import { renderRAPreview } from "./RAPreview";
44

5-
describe("RA highlighter", () => {
6-
it("should highlight σ operator in purple", () => {
7-
const html = highlightRA("σ[age > 20](Person)");
8-
expect(html).toContain("color: #7c3aed"); // operator color
9-
expect(html).toContain("σ");
5+
/** Strip HTML tags and decode entities to recover visible text */
6+
function visibleText(html: string): string {
7+
return html
8+
.replace(/<[^>]+>/g, "")
9+
.replace(/&lt;/g, "<").replace(/&gt;/g, ">")
10+
.replace(/&amp;/g, "&").replace(/&quot;/g, '"');
11+
}
12+
13+
// ─── Character count invariant ──────────────────────────────────────────────
14+
// The editor overlay MUST produce visible text identical to the input.
15+
// These tests are the most important — if any fail, the editor is broken.
16+
17+
describe("RA highlighter character preservation", () => {
18+
const inputs = [
19+
"Person",
20+
"σ[age > 20](Person)",
21+
"π[name, city](Person)",
22+
"ρ[name→fullName](Person)",
23+
"σ_{age > 20}(Person)",
24+
"σ{age > 20}(Person)",
25+
"PI [name, city](Person)",
26+
"PI [name, person_id, address, postal_code] SIGMA [city='York'] Person",
27+
"PI name, person_id SIGMA city='York' Person",
28+
"PI _name, person_id Person",
29+
"A <- σ[age > 20](Person)\nπ[name](A)",
30+
"-- comment\nPerson",
31+
"Person ⋈ Student",
32+
"Person |X| Student",
33+
"Person |><| Student",
34+
"ρ[name->fullName](Person)",
35+
"σ[a <> 1](T)",
36+
"σ[a != 1](T)",
37+
"σ[a >= 1](T)",
38+
"σ[a <= 1](T)",
39+
"γ[city; COUNT(id) AS cnt](Person)",
40+
"Person ⋈[Person.id = Student.id] Student",
41+
"σ[name = 'Alice'](Person)",
42+
"PI [name SIGMA [age > 20] Person", // unclosed bracket
43+
"σ age > 20 (Person)", // implicit subscript
44+
"",
45+
];
46+
47+
for (const input of inputs) {
48+
it(`preserves: ${JSON.stringify(input).slice(0, 60)}`, () => {
49+
if (input === "") return; // empty input = empty output
50+
const html = highlightRA(input);
51+
expect(visibleText(html)).toBe(input);
52+
});
53+
}
54+
});
55+
56+
// ─── Token coloring ─────────────────────────────────────────────────────────
57+
58+
describe("RA highlighter coloring", () => {
59+
it("colors σ operator", () => {
60+
expect(highlightRA("σ[age > 20](Person)")).toContain("color: #7c3aed");
1061
});
1162

12-
it("should render brackets faintly", () => {
13-
const html = highlightRA("σ[age > 20](Person)");
14-
expect(html).toContain("opacity: 0.5"); // faint brackets
63+
it("colors keyword operators", () => {
64+
const html = highlightRA("sigma[age > 20](Person)");
65+
expect(html).toContain("color: #7c3aed");
66+
expect(html).toContain("sigma");
67+
});
68+
69+
it("colors binary keyword operators", () => {
70+
expect(highlightRA("A cross B")).toContain("color: #7c3aed");
1571
});
1672

17-
it("should render subscript content in light purple", () => {
18-
const html = highlightRA("π[name](Person)");
19-
expect(html).toContain("color: #c084fc"); // subscript styling
73+
it("renders brackets faintly", () => {
74+
expect(highlightRA("σ[age > 20](Person)")).toContain("opacity: 0.5");
2075
});
2176

22-
it("should highlight string literals in green", () => {
77+
it("colors string literals green", () => {
2378
const html = highlightRA("σ[name = 'Alice'](Person)");
24-
expect(html).toContain("color: #059669"); // string color
79+
expect(html).toContain("color: #059669");
2580
expect(html).toContain("Alice");
2681
});
2782

28-
it("should highlight numbers in amber", () => {
29-
const html = highlightRA("σ[age > 20](Person)");
30-
expect(html).toContain("color: #d97706"); // number color
83+
it("colors numbers amber", () => {
84+
expect(highlightRA("σ[age > 20](Person)")).toContain("color: #d97706");
3185
});
3286

33-
it("should highlight AND/OR in blue", () => {
87+
it("colors AND/OR/NOT blue", () => {
3488
const html = highlightRA("σ[a > 1 and b < 2](T)");
35-
expect(html).toContain("color: #2563eb"); // logic color
36-
expect(html).toContain("and");
89+
expect(html).toContain("color: #2563eb");
3790
});
3891

39-
it("should highlight comments in gray italic", () => {
92+
it("colors comments gray italic", () => {
4093
const html = highlightRA("-- this is a comment\nPerson");
4194
expect(html).toContain("font-style: italic");
42-
expect(html).toContain("this is a comment");
4395
});
4496

45-
it("should render <- with assignment styling", () => {
97+
it("colors <- assignment", () => {
4698
const html = highlightRA("A <- Person");
47-
expect(html).toContain("&lt;-");
4899
expect(html).toContain("color: #7c3aed");
100+
expect(html).toContain("&lt;-");
49101
});
50102

51-
it("should render -> with operator styling", () => {
103+
it("colors -> rename arrow", () => {
52104
const html = highlightRA("ρ[name->fullName](Person)");
53105
expect(html).toContain("-&gt;");
54106
});
55107

56-
it("should handle LaTeX-style _{} notation", () => {
108+
it("colors _ as bracket when before {", () => {
57109
const html = highlightRA("σ_{age > 20}(Person)");
58-
expect(html).toContain("opacity: 0.5"); // faint brackets
59-
expect(html).toContain("color: #c084fc"); // subscript content
60-
});
61-
62-
it("should highlight keyword operators", () => {
63-
const html = highlightRA("sigma[age > 20](Person)");
64-
expect(html).toContain("color: #7c3aed"); // operator color
65-
expect(html).toContain("sigma");
66-
});
67-
68-
it("should highlight binary keyword operators", () => {
69-
const html = highlightRA("A cross B");
70-
expect(html).toContain("color: #7c3aed");
71-
expect(html).toContain("cross");
72-
});
73-
74-
it("should preserve newlines", () => {
75-
const html = highlightRA("A <- Person\nA");
76-
expect(html).toContain("\n");
77-
});
78-
79-
it("should handle implicit subscripts with whitespace", () => {
80-
const html = highlightRA("σ age > 20 (Person)");
81-
expect(html).toContain("color: #c084fc"); // subscript styling for implicit content
82-
});
83-
84-
it("should preserve character count for _{} notation", () => {
85-
const input = "σ_{age > 20}(Person)";
86-
const html = highlightRA(input);
87-
// Strip HTML tags and decode entities to get visible text
88-
const textOnly = html
89-
.replace(/<[^>]+>/g, "")
90-
.replace(/&lt;/g, "<").replace(/&gt;/g, ">")
91-
.replace(/&amp;/g, "&").replace(/&quot;/g, '"');
92-
expect(textOnly.length).toBe(input.length);
93-
expect(textOnly).toBe(input);
110+
expect(html).toContain("opacity: 0.5");
94111
});
95112

96-
it("should preserve character count for keyword _{} notation", () => {
97-
const input = "sigma_{age > 20}(Person)";
98-
const html = highlightRA(input);
99-
const textOnly = html
100-
.replace(/<[^>]+>/g, "")
101-
.replace(/&lt;/g, "<").replace(/&gt;/g, ">")
102-
.replace(/&amp;/g, "&").replace(/&quot;/g, '"');
103-
expect(textOnly.length).toBe(input.length);
104-
expect(textOnly).toBe(input);
113+
it("does not color _ when part of identifier", () => {
114+
const html = highlightRA("PI _name (Person)");
115+
// _name should not have bracket styling
116+
expect(html).not.toMatch(/opacity.*_n/);
105117
});
106118

107-
it("should preserve character count for [] notation", () => {
108-
const input = "σ[age > 20](Person)";
109-
const html = highlightRA(input);
110-
const textOnly = html
111-
.replace(/<[^>]+>/g, "")
112-
.replace(/&lt;/g, "<").replace(/&gt;/g, ">")
113-
.replace(/&amp;/g, "&").replace(/&quot;/g, '"');
114-
expect(textOnly.length).toBe(input.length);
115-
});
116-
117-
it("should preserve character count for {} notation", () => {
118-
const input = "σ{age > 20}(Person)";
119-
const html = highlightRA(input);
120-
const textOnly = html
121-
.replace(/<[^>]+>/g, "")
122-
.replace(/&lt;/g, "<").replace(/&gt;/g, ">")
123-
.replace(/&amp;/g, "&").replace(/&quot;/g, '"');
124-
expect(textOnly.length).toBe(input.length);
119+
it("preserves newlines", () => {
120+
expect(highlightRA("A <- Person\nA")).toContain("\n");
125121
});
126122
});
127123

124+
// ─── RAPreview rendering ────────────────────────────────────────────────────
125+
128126
describe("RA preview", () => {
129127
it("should render paren-free implicit projection without eating the table name", () => {
130128
const html = renderRAPreview("PI person_id, address Person");
131-
// "Person" must NOT be inside a <sub> tag — it's the operand, not part of the subscript
132-
expect(html).toMatch(/Person(?!.*<\/sub>)/);
133-
// The subscript should contain the column list
134-
expect(html).toContain("<sub");
135129
expect(html).toMatch(/<sub[^>]*>.*person_id.*address.*<\/sub>/);
130+
// Person must not be in a subscript
131+
expect(html).not.toMatch(/<sub[^>]*>.*Person.*<\/sub>/s);
136132
});
137133

138134
it("should render paren-free implicit projection with Unicode symbol", () => {
139135
const html = renderRAPreview("π person_id, address Person");
140-
expect(html).toMatch(/Person(?!.*<\/sub>)/);
141136
expect(html).toMatch(/<sub[^>]*>.*person_id.*<\/sub>/);
142137
});
143138

144139
it("should still render parenthesised implicit subscript correctly", () => {
145140
const html = renderRAPreview("π person_id, address (Person)");
146-
// All of "person_id, address" should be in subscript
147141
expect(html).toMatch(/<sub[^>]*>.*person_id.*address.*<\/sub>/);
148142
});
149143

150144
it("should render paren-free σ condition Table correctly", () => {
151145
const html = renderRAPreview("sigma age > 20 Person");
152-
// "Person" should not be in the subscript
153146
expect(html).toContain("Person");
154147
expect(html).toMatch(/<sub[^>]*>.*age.*<\/sub>/);
155148
});
156149

150+
it("should not swallow content after unclosed bracket", () => {
151+
const html = renderRAPreview("PI [name, person_id SIGMA city='York' Person");
152+
const opMatches = html.match(/ra-prev-op/g);
153+
expect(opMatches!.length).toBeGreaterThanOrEqual(2);
154+
});
155+
157156
it("should render chained paren-free operators correctly", () => {
158157
const html = renderRAPreview("PI name, person_id, address, postal_code SIGMA city='York' Person");
159-
// SIGMA should be rendered as its own operator, not swallowed into PI's subscript
160-
// Count operator spans — should have both π and σ
161158
const opMatches = html.match(/ra-prev-op/g);
162-
expect(opMatches!.length).toBeGreaterThanOrEqual(2); // at least π and σ
163-
// PI's subscript should contain the column list only
159+
expect(opMatches!.length).toBeGreaterThanOrEqual(2);
164160
expect(html).toMatch(/<sub[^>]*>.*name.*postal_code.*<\/sub>/);
165-
// "Person" should not be inside a subscript of PI
166-
expect(html).not.toMatch(/<sub[^>]*>.*Person.*<\/sub>/s);
167161
});
168162

169163
it("should render chained Unicode operators correctly", () => {
@@ -174,31 +168,7 @@ describe("RA preview", () => {
174168

175169
it("should not treat underscore-prefixed column names as LaTeX subscript", () => {
176170
const html = renderRAPreview("PI _name, person_id Person");
177-
// _name should not be split — the underscore is part of the column name
178171
expect(html).toMatch(/<sub[^>]*>.*_name.*person_id.*<\/sub>/);
179-
expect(html).not.toMatch(/opacity/); // no faint bracket styling for _
180-
});
181-
});
182-
183-
describe("RA highlighter character preservation with underscore columns", () => {
184-
it("should preserve character count with underscore-prefixed columns", () => {
185-
const input = "PI _name, person_id (Person)";
186-
const html = highlightRA(input);
187-
const textOnly = html
188-
.replace(/<[^>]+>/g, "")
189-
.replace(/&lt;/g, "<").replace(/&gt;/g, ">")
190-
.replace(/&amp;/g, "&").replace(/&quot;/g, '"');
191-
expect(textOnly).toBe(input);
192-
});
193-
194-
it("should still handle real _{} LaTeX notation with Unicode symbol", () => {
195-
const input = "σ_{age > 20}(Person)";
196-
const html = highlightRA(input);
197-
const textOnly = html
198-
.replace(/<[^>]+>/g, "")
199-
.replace(/&lt;/g, "<").replace(/&gt;/g, ">")
200-
.replace(/&amp;/g, "&").replace(/&quot;/g, '"');
201-
expect(textOnly).toBe(input);
202-
expect(html).toContain("opacity: 0.5"); // _ rendered as faint bracket
172+
expect(html).not.toMatch(/opacity/);
203173
});
204174
});

0 commit comments

Comments
 (0)