Repository navigation
Expand file tree
/
Copy pathtest_diagram_view.cpp
More file actions
623 lines (565 loc) · 30.9 KB
/
Copy pathtest_diagram_view.cpp
File metadata and controls
623 lines (565 loc) · 30.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
// SPDX-License-Identifier: GPL-3.0-or-later
//
// test_diagram_view — native-Qt diagram renderer + export (NO WebEngine).
// Builds in BOTH flavors. Validates layout, non-empty paint, hit-test, and
// PNG/SVG/PDF export through the public DiagramView API + DiagramRender module.
#include "diagram/diagram_render.h"
#include "diagram/diagram_view.h"
#include "diagram/npd_parser.h"
#include <QApplication>
#include <QImage>
#include <QJsonArray>
#include <QJsonObject>
#include <QPainter>
#include <QTemporaryDir>
#include <QFileInfo>
#include <QtTest>
static const char *kNpd =
"diagram system\n"
"title \"Test\"\n"
"palette ocean\n"
"node a (Start)\n"
"node b {Valid?}\n"
"node c [Process] :: \"hover detail\"\n"
"node d ([Store])\n"
"icon e :database \"DB\"\n"
"a -> b\n"
"b -> c : yes\n"
"b -> d : no\n"
"c -> e\n"
"d <-> e : sync\n";
class TestDiagramView : public QObject {
Q_OBJECT
private slots:
void layout_has_geometry() {
const Npd::Diagram d = Npd::parse(QString::fromUtf8(kNpd));
QVERIFY(d.ok());
QCOMPARE(d.nodes.size(), 5);
const auto lay = DiagramRender::computeLayout(d);
QVERIFY(lay.canvasW > 100);
QVERIFY(lay.canvasH > 100);
QCOMPARE(lay.nodeRects.size(), 5);
for (const auto &n : d.nodes) {
QVERIFY(lay.nodeRects.contains(n.id));
QVERIFY(lay.nodeRects[n.id].width() > 0);
}
QVERIFY(lay.nodeRects["a"].center().y() < lay.nodeRects["b"].center().y());
QVERIFY(lay.nodeRects["b"].center().y() < lay.nodeRects["c"].center().y());
}
void paint_is_nonempty() {
const Npd::Diagram d = Npd::parse(QString::fromUtf8(kNpd));
const auto lay = DiagramRender::computeLayout(d);
const auto pal = DiagramRender::palette(d.palette);
QImage img(int(lay.canvasW), int(lay.canvasH), QImage::Format_ARGB32);
img.fill(Qt::transparent);
QPainter p(&img);
DiagramRender::paint(p, d, lay, pal);
p.end();
int painted = 0;
for (int y = 0; y < img.height(); y += 3)
for (int x = 0; x < img.width(); x += 3)
if (qAlpha(img.pixel(x, y)) > 0) ++painted;
QVERIFY(painted > 100);
}
void hittest_finds_node() {
const Npd::Diagram d = Npd::parse(QString::fromUtf8(kNpd));
const auto lay = DiagramRender::computeLayout(d);
const QString id = DiagramRender::nodeAt(d, lay, lay.nodeRects["c"].center());
QCOMPARE(id, QStringLiteral("c"));
QVERIFY(DiagramRender::nodeAt(d, lay, QPointF(-50, -50)).isEmpty());
}
void export_all_formats() {
QTemporaryDir tmp; QVERIFY(tmp.isValid());
DiagramView view;
view.resize(800, 600);
view.setSource(QString::fromUtf8(kNpd));
// first 4 bytes that prove the file is really that format
auto magicOk = [](const QString &path, const QByteArray &magic) {
QFile f(path); if (!f.open(QIODevice::ReadOnly)) return false;
return f.read(magic.size()) == magic;
};
const QStringList supported = DiagramView::supportedExportFormats(); // PNG/JPEG/PDF[/WebP/SVG/HTML]
qInfo() << "Supported on this build:" << supported.join(", ");
struct Case { const char *fmt; QByteArray magic; };
const QVector<Case> cases = {
{"png", QByteArray::fromHex("89504e47")}, // ‰PNG
{"jpeg", QByteArray::fromHex("ffd8ff")}, // JPEG SOI
{"pdf", QByteArray("%PDF")},
{"webp", QByteArray("RIFF")}, // RIFF....WEBP
{"svg", QByteArray("<?xml")},
{"html", QByteArray("<!doc")},
};
for (const Case &c : cases) {
const bool wantSupported =
supported.contains(QString(c.fmt), Qt::CaseInsensitive);
const QString path = tmp.filePath(QString("d.") + c.fmt);
const bool ok = view.exportTo(c.fmt, path);
if (wantSupported) {
QVERIFY2(ok, c.fmt);
QVERIFY2(QFileInfo(path).size() > 200, c.fmt);
QVERIFY2(magicOk(path, c.magic), c.fmt);
qInfo(" %-5s -> OK %lld bytes (valid)", c.fmt, QFileInfo(path).size());
} else {
QVERIFY2(!ok, c.fmt); // must fail cleanly, not silently write garbage
qInfo(" %-5s -> n/a (not in this build; export refused)", c.fmt);
}
}
QVERIFY(!view.exportTo("xyz", tmp.filePath("d.xyz"))); // unknown format
}
void icon_aliases_render() {
QImage img(64, 64, QImage::Format_ARGB32); img.fill(Qt::transparent);
QPainter p(&img);
DiagramRender::drawIcon(p, "postgres", QRectF(8,8,48,48), QColor("#7cc4ff")); // -> database
DiagramRender::drawIcon(p, "k8s", QRectF(8,8,48,48), QColor("#7cc4ff")); // -> kubernetes
p.end();
int painted = 0;
for (int y = 0; y < 64; ++y) for (int x = 0; x < 64; ++x) if (qAlpha(img.pixel(x,y))>0) ++painted;
QVERIFY(painted > 20);
}
// v0.1.104 SECURITY regression (#5): an untrusted .npd title containing
// "</title><script>…" must NOT inject a live tag into exported HTML/SVG —
// it has to be HTML-escaped at the write site.
void html_export_escapes_title_xss() {
if (!DiagramView::supportedExportFormats().contains(QStringLiteral("HTML"),
Qt::CaseInsensitive))
QSKIP("HTML export not supported on this build");
QTemporaryDir tmp; QVERIFY(tmp.isValid());
const QString payload = "x</title><script>alert(1)</script>";
const QString npd =
"diagram flow\n"
"title \"" + payload + "\"\n"
"node a (Start)\n"
"a -> a\n";
DiagramView view;
view.resize(800, 600);
view.setSource(npd);
const QString path = tmp.filePath(QStringLiteral("xss.html"));
QVERIFY(view.exportTo(QStringLiteral("html"), path));
QFile f(path); QVERIFY(f.open(QIODevice::ReadOnly));
const QString html = QString::fromUtf8(f.readAll());
QVERIFY2(!html.contains(QStringLiteral("<script>")),
"live <script> tag leaked into exported HTML");
QVERIFY2(!html.contains(QStringLiteral("</title><script")),
"title was not escaped — </title> break-out present");
QVERIFY2(html.contains(QStringLiteral("<script>")),
"escaped payload not found in exported HTML");
// Same vector via standalone SVG export: QSvgGenerator::setTitle() does
// NOT escape, so the SVG <title> must be escaped at the call site.
if (DiagramView::supportedExportFormats().contains(QStringLiteral("SVG"),
Qt::CaseInsensitive)) {
const QString spath = tmp.filePath(QStringLiteral("xss.svg"));
QVERIFY(view.exportTo(QStringLiteral("svg"), spath));
QFile sf(spath); QVERIFY(sf.open(QIODevice::ReadOnly));
const QString svg = QString::fromUtf8(sf.readAll());
QVERIFY2(!svg.contains(QStringLiteral("<script>")),
"live <script> tag leaked into exported SVG");
QVERIFY2(!svg.contains(QStringLiteral("</title><script")),
"SVG <title> not escaped — </title> break-out present");
}
}
// v0.1.104 FEATURE: optional inline per-node colour. A #hex token is always
// taken; a colour NAME is taken only after a shape/quote boundary, so a bare
// word in an unbracketed label is left alone. Label/hover/shape survive.
void inline_color_parses() {
const char *src =
"diagram flow\n"
"node a (Start) green\n"
"node b [Process] #1565c0 :: \"detail\"\n"
"node c {Valid?} orange\n"
"node d [Plain label]\n"
"node e Plain red\n" // unbracketed → 'red' is NOT a colour
"icon f :database \"Users\" #6a1b9a :: \"store\"\n"
"a -> b\n";
const Npd::Diagram d = Npd::parse(QString::fromUtf8(src));
QVERIFY(d.ok());
auto byId = [&](const QString &id) -> Npd::Node {
for (const auto &n : d.nodes) if (n.id == id) return n; return {}; };
const Npd::Node a = byId("a");
QCOMPARE(a.color, QStringLiteral("green"));
QCOMPARE(a.label, QStringLiteral("Start"));
QCOMPARE(a.shape, Npd::Shape::Pill);
const Npd::Node b = byId("b");
QCOMPARE(b.color, QStringLiteral("#1565c0"));
QCOMPARE(b.label, QStringLiteral("Process"));
QCOMPARE(b.hover, QStringLiteral("detail"));
QCOMPARE(b.shape, Npd::Shape::Box);
QCOMPARE(byId("c").color, QStringLiteral("orange"));
QCOMPARE(byId("c").shape, Npd::Shape::Decision);
// bracketed plain label, no trailing colour
QVERIFY(byId("d").color.isEmpty());
QCOMPARE(byId("d").label, QStringLiteral("Plain label"));
// conservative rule: 'red' has no shape/quote boundary in front of it
QVERIFY2(byId("e").color.isEmpty(), "bare word in unbracketed label wrongly taken as colour");
QCOMPARE(byId("e").label, QStringLiteral("Plain red"));
const Npd::Node f = byId("f");
QCOMPARE(f.color, QStringLiteral("#6a1b9a"));
QCOMPARE(f.label, QStringLiteral("Users"));
QCOMPARE(f.icon, QStringLiteral("database"));
QCOMPARE(f.hover, QStringLiteral("store"));
// colour rides through to the JSON contract the renderer/export consume
const QJsonObject g = Npd::toGraphJson(d);
const QJsonArray nodes = g.value(QStringLiteral("nodes")).toArray();
bool sawColouredA = false;
for (const auto &v : nodes) {
const QJsonObject o = v.toObject();
if (o.value(QStringLiteral("id")).toString() == QLatin1String("a"))
sawColouredA = (o.value(QStringLiteral("color")).toString() == QLatin1String("green"));
}
QVERIFY2(sawColouredA, "toGraphJson dropped the node colour");
}
// A coloured node must actually paint different pixels than the same node
// left to the palette — and the fill must be in the requested colour family.
void colored_node_changes_pixels() {
const QString bare = QStringLiteral("diagram flow\nnode a [Step]\n");
const QString colored = QStringLiteral("diagram flow\nnode a [Step] #d81b60\n"); // magenta
const Npd::Diagram db = Npd::parse(bare), dc = Npd::parse(colored);
const auto lay = DiagramRender::computeLayout(db); // identical geometry
const auto pal = DiagramRender::palette(db.palette);
auto render = [&](const Npd::Diagram &d) {
QImage img(int(lay.canvasW), int(lay.canvasH), QImage::Format_ARGB32);
img.fill(Qt::transparent);
QPainter p(&img); DiagramRender::paint(p, d, lay, pal); p.end();
return img;
};
const QImage ib = render(db), ic = render(dc);
// Sample the FILL above the vertically-centred label (the label glyph is
// white auto-contrast, so the centre pixel would read as text, not fill).
const QRectF ra = lay.nodeRects["a"];
const QPoint fill(qRound(ra.center().x()), qRound(ra.top() + ra.height() * 0.20));
QVERIFY2(ib.pixel(fill) != ic.pixel(fill), "coloured node did not change the fill");
const QRgb px = ic.pixel(fill);
// #d81b60 is red-dominant magenta; gradient keeps red the clear max channel
QVERIFY2(qRed(px) > 120, "coloured fill is not in the requested (reddish) family");
QVERIFY2(qRed(px) > qGreen(px) + 30 && qRed(px) >= qBlue(px),
"coloured fill hue does not match #d81b60");
}
// v0.1.104 FEATURE: chained arrows. `a -> b -> c` expands to a→b and b→c —
// it must NOT create one node literally named "b -> c" (the old bug). Each
// hop keeps its own direction; a trailing ": label" is verbatim (so a label
// may contain "->") and decorates the LAST hop.
void chain_arrows_parse() {
const char *src =
"diagram flow\n"
"a -> b -> c -> d\n" // 3-hop directed chain
"m -> n <-> o\n" // mixed: m→n directed, n↔o bidirectional
"p -> q -> r : done\n" // trailing label decorates the LAST hop
"req -> resp : maps A -> B\n"; // ': label' is verbatim — the arrow in
// the label is NOT a hop to a node "B"
const Npd::Diagram dg = Npd::parse(QString::fromUtf8(src));
QVERIFY2(dg.ok(), qPrintable(dg.errors.join("; ")));
// no node id ever contains an arrow — the old code made "b -> c" boxes
for (const auto &n : dg.nodes)
QVERIFY2(!n.id.contains(QStringLiteral("->")),
qPrintable(QStringLiteral("stray arrow-node: ") + n.id));
auto hasEdge = [&](const QString &f, const QString &t, bool bidir, const QString &lbl) {
for (const auto &e : dg.edges)
if (e.from == f && e.to == t && e.bidirectional == bidir && e.label == lbl)
return true;
return false;
};
// 3-hop chain → three directed edges
QVERIFY(hasEdge("a", "b", false, QString()));
QVERIFY(hasEdge("b", "c", false, QString()));
QVERIFY(hasEdge("c", "d", false, QString()));
// mixed directionality within one line
QVERIFY(hasEdge("m", "n", false, QString()));
QVERIFY(hasEdge("n", "o", true, QString()));
// trailing label rides the final hop only (p→q stays unlabelled)
QVERIFY(hasEdge("p", "q", false, QString()));
QVERIFY(hasEdge("q", "r", false, QStringLiteral("done")));
// a ': label' is verbatim: "maps A -> B" is ONE label, not a hop to "B"
QVERIFY(hasEdge("req", "resp", false, QStringLiteral("maps A -> B")));
// every node across the whole chain is auto-created
QStringList ids; for (const auto &n : dg.nodes) ids << n.id;
const QStringList want = {"a","b","c","d","m","n","o","p","q","r","req","resp"};
for (const QString &id : want)
QVERIFY2(ids.contains(id), qPrintable(QStringLiteral("missing node: ") + id));
// the label text never leaks into a node id
QVERIFY2(!ids.contains(QStringLiteral("B")), "arrow-in-label wrongly created a node");
// single-segment edges are untouched (regression guard)
const Npd::Diagram one = Npd::parse(QStringLiteral("a -> b : yes\n"));
QCOMPARE(one.edges.size(), 1);
QCOMPARE(one.edges.first().label, QStringLiteral("yes"));
QVERIFY(!one.edges.first().bidirectional);
}
// v0.1.104 — adversarial-audit fixes. The label ':' is only the one written
// AFTER the first arrow AND with a leading space, so colons inside node ids
// (URLs, namespaces, typed ids) survive; a leading keyword always wins so a
// node label may contain "->"; '::' on an edge yields a clean label.
void edge_colon_keyword_robustness() {
auto edgeOf = [](const Npd::Diagram &d) { return d.edges; };
auto only = [&](const QString &src) -> Npd::Edge {
const Npd::Diagram d = Npd::parse(src);
Q_ASSERT(d.ok()); Q_ASSERT(d.edges.size() == 1);
return d.edges.first();
};
// #1/#3 colon in the LEFT node id (namespace / URL) — was a regression
{
const Npd::Diagram d = Npd::parse(QStringLiteral("a:start -> b\n"));
QVERIFY2(d.ok(), qPrintable(d.errors.join("; ")));
QCOMPARE(d.edges.size(), 1);
QCOMPARE(d.edges.first().from, QStringLiteral("a:start"));
QCOMPARE(d.edges.first().to, QStringLiteral("b"));
QVERIFY(d.edges.first().label.isEmpty());
}
{
const Npd::Diagram d = Npd::parse(QStringLiteral("http://x -> y\n"));
QVERIFY2(d.ok(), qPrintable(d.errors.join("; ")));
QCOMPARE(d.edges.size(), 1);
QCOMPARE(d.edges.first().from, QStringLiteral("http://x"));
QCOMPARE(d.edges.first().to, QStringLiteral("y"));
}
// #7/#10 colon in a RIGHT / mid-chain node id is part of the id, not a label
{
const Npd::Diagram d = Npd::parse(QStringLiteral("a -> b:state -> c\n"));
QVERIFY2(d.ok(), qPrintable(d.errors.join("; ")));
QCOMPARE(d.edges.size(), 2);
QCOMPARE(d.edges.at(0).from, QStringLiteral("a"));
QCOMPARE(d.edges.at(0).to, QStringLiteral("b:state"));
QVERIFY(d.edges.at(0).label.isEmpty());
QCOMPARE(d.edges.at(1).from, QStringLiteral("b:state"));
QCOMPARE(d.edges.at(1).to, QStringLiteral("c"));
}
// a real " : label" still works and rides the (single) hop
{
const Npd::Edge e = only(QStringLiteral("check -> dash : yes\n"));
QCOMPARE(e.from, QStringLiteral("check"));
QCOMPARE(e.to, QStringLiteral("dash"));
QCOMPARE(e.label, QStringLiteral("yes"));
}
// …and a label may itself contain "->"
{
const Npd::Edge e = only(QStringLiteral("req -> resp : maps A -> B\n"));
QCOMPARE(e.to, QStringLiteral("resp"));
QCOMPARE(e.label, QStringLiteral("maps A -> B"));
}
// #4/#11 '::' on an edge line → clean label (no stray leading colon)
{
const Npd::Edge e = only(QStringLiteral("a -> b :: detail\n"));
QCOMPARE(e.from, QStringLiteral("a"));
QCOMPARE(e.to, QStringLiteral("b"));
QCOMPARE(e.label, QStringLiteral("detail"));
}
// #5 a NODE line whose label contains "->" is a node, not an edge
{
const Npd::Diagram d = Npd::parse(QStringLiteral("node x [Maps A -> B]\n"));
QVERIFY2(d.ok(), qPrintable(d.errors.join("; ")));
QCOMPARE(edgeOf(d).size(), 0);
QCOMPARE(d.nodes.size(), 1);
QCOMPARE(d.nodes.first().id, QStringLiteral("x"));
QCOMPARE(d.nodes.first().label, QStringLiteral("Maps A -> B"));
QCOMPARE(d.nodes.first().shape, Npd::Shape::Box);
}
// …same for a textbox caption containing an arrow
{
const Npd::Diagram d = Npd::parse(QStringLiteral("textbox \"x -> y flow\"\n"));
QVERIFY2(d.ok(), qPrintable(d.errors.join("; ")));
QCOMPARE(edgeOf(d).size(), 0);
QCOMPARE(d.textboxes.size(), 1);
QCOMPARE(d.textboxes.first(), QStringLiteral("x -> y flow"));
}
}
// v0.1.120: on a LIGHT palette a per-node colour is a TINT — soft fill, the
// colour at full strength on the border, and the normal dark palette text.
// A dark palette keeps the long-standing solid fill.
void colored_node_is_tinted_on_light_palettes() {
const QString src = QStringLiteral("palette paper\nnode a [Step] #1565c0\n");
const Npd::Diagram d = Npd::parse(src);
QVERIFY2(d.ok(), qPrintable(d.errors.join("; ")));
const auto lay = DiagramRender::computeLayout(d);
const auto paper = DiagramRender::palette(d.palette);
QImage img(int(lay.canvasW), int(lay.canvasH), QImage::Format_ARGB32);
img.fill(Qt::transparent);
QPainter p(&img); DiagramRender::paint(p, d, lay, paper); p.end();
const QRectF r = lay.nodeRects["a"];
// fill sampled well inside the border, above the label band
const QColor fill = QColor(img.pixel(qRound(r.center().x()),
qRound(r.top() + r.height() * 0.22)));
QVERIFY2(fill.lightness() > 180,
qPrintable(QStringLiteral("tinted fill is not light: %1 (L=%2)")
.arg(fill.name()).arg(fill.lightness())));
QVERIFY2(fill.blue() > fill.red() + 10, "tint lost the blue hue of #1565c0");
// the darkest pixel INSIDE the node (border excluded) is the label glyph
int darkest = 255;
const QRect inner = r.toRect().adjusted(6, 6, -6, -6);
for (int y = inner.top(); y <= inner.bottom(); ++y)
for (int x = inner.left(); x <= inner.right(); ++x)
darkest = qMin(darkest, QColor(img.pixel(x, y)).lightness());
QVERIFY2(darkest < 80, "label text is not dark on the tinted node");
// …and the dark palettes still flood-fill with the colour itself
const Npd::Diagram dark = Npd::parse(QStringLiteral("palette default\nnode a [Step] #1565c0\n"));
const auto dlay = DiagramRender::computeLayout(dark);
const auto dpal = DiagramRender::palette(dark.palette);
QImage dimg(int(dlay.canvasW), int(dlay.canvasH), QImage::Format_ARGB32);
dimg.fill(Qt::transparent);
QPainter dp(&dimg); DiagramRender::paint(dp, dark, dlay, dpal); dp.end();
const QRectF dr = dlay.nodeRects["a"];
const QColor dfill = QColor(dimg.pixel(qRound(dr.center().x()),
qRound(dr.top() + dr.height() * 0.22)));
QCOMPARE(dfill.name(), QStringLiteral("#1565c0"));
}
// v0.1.120: `direction LR` computes the TB layout and transposes it, so for
// `a -> b` the b rect sits to the RIGHT of a on one shared row, while the
// node boxes keep their own proportions (only positions flip).
void lr_direction_transposes_layout() {
const Npd::Diagram tb = Npd::parse(QStringLiteral("node a [A]\nnode b [B]\na -> b\n"));
const Npd::Diagram lr = Npd::parse(QStringLiteral("direction LR\nnode a [A]\nnode b [B]\na -> b\n"));
QVERIFY(tb.ok() && lr.ok());
const auto ltb = DiagramRender::computeLayout(tb);
const auto llr = DiagramRender::computeLayout(lr);
QVERIFY2(!ltb.lr, "TB layout claimed to be LR");
QVERIFY2(ltb.nodeRects["b"].top() > ltb.nodeRects["a"].bottom(), "TB stacked wrongly");
QVERIFY2(llr.lr, "LR layout not flagged");
QVERIFY2(llr.nodeRects["b"].left() > llr.nodeRects["a"].right(),
"b is not to the right of a under `direction LR`");
QVERIFY2(qAbs(llr.nodeRects["b"].center().y() - llr.nodeRects["a"].center().y()) < 4.0,
"LR siblings drifted off their shared row");
QCOMPARE(llr.nodeRects["a"].size(), ltb.nodeRects["a"].size());
QVERIFY2(llr.canvasW > llr.canvasH, "LR canvas is not wider than it is tall");
// the title still occupies the top band, not the left edge
const Npd::Diagram titled = Npd::parse(QStringLiteral(
"direction LR\ntitle \"T\"\nnode a [A]\nnode b [B]\na -> b\n"));
const auto lt = DiagramRender::computeLayout(titled);
QVERIFY2(lt.nodeRects["a"].top() > llr.nodeRects["a"].top(),
"LR title band did not push the graph down");
}
// A group container wraps exactly its members (padded, with a label band on
// top) and leaves non-members outside.
void group_container_wraps_members() {
const Npd::Diagram d = Npd::parse(QStringLiteral(
"node a [A]\nnode b [B]\nnode c [C]\n"
"a -> b\na -> c\n"
"group \"Tier\" : b c\n"));
QVERIFY2(d.ok(), qPrintable(d.errors.join("; ")));
const auto lay = DiagramRender::computeLayout(d);
QCOMPARE(lay.groupRects.size(), 1);
const QRectF g = lay.groupRects.at(0);
QVERIFY2(g.contains(lay.nodeRects["b"]), "group does not contain member b");
QVERIFY2(g.contains(lay.nodeRects["c"]), "group does not contain member c");
QVERIFY2(!g.contains(lay.nodeRects["a"].center()), "group swallowed a non-member");
QVERIFY2(g.top() < lay.nodeRects["b"].top() - 20, "no label band above the members");
QVERIFY2(g.left() < lay.nodeRects["b"].left() && g.right() > lay.nodeRects["c"].right(),
"group is not padded around its members");
}
// The light palettes are actually light — and the dark ones are byte-identical
// to what they have always been (no existing diagram may change).
void light_palettes_and_named_palettes_unchanged() {
const auto paper = DiagramRender::palette(QStringLiteral("paper"));
QVERIFY2(paper.bg.lightness() > 200, "paper background is not light");
QVERIFY2(paper.text.lightness() < 60, "paper text is not dark");
QCOMPARE(paper.bg.name(), QStringLiteral("#f5f4ee"));
QCOMPARE(paper.accent.name(), QStringLiteral("#cc785c"));
const auto slate = DiagramRender::palette(QStringLiteral("slate"));
QVERIFY2(slate.bg.lightness() > 200, "slate background is not light");
QCOMPARE(slate.accent.name(), QStringLiteral("#2f6fb3"));
QCOMPARE(DiagramRender::palette(QStringLiteral("ocean")).bg.name(), QStringLiteral("#0e1a29"));
QCOMPARE(DiagramRender::palette(QStringLiteral("forest")).bg.name(), QStringLiteral("#0e1f15"));
QCOMPARE(DiagramRender::palette(QStringLiteral("clay")).bg.name(), QStringLiteral("#241712"));
QCOMPARE(DiagramRender::palette(QStringLiteral("mono")).bg.name(), QStringLiteral("#15171a"));
QCOMPARE(DiagramRender::palette(QStringLiteral("default")).bg.name(), QStringLiteral("#12151b"));
QCOMPARE(DiagramRender::palette(QStringLiteral("nonsense")).bg.name(), QStringLiteral("#12151b"));
}
// `palette auto` (the default when no palette line is written) resolves from
// the HOST widget's theme — paper on a light host, default on a dark one.
void auto_palette_follows_host_theme() {
QCOMPARE(DiagramRender::palette(QStringLiteral("auto"), true).bg,
DiagramRender::palette(QStringLiteral("default")).bg);
QCOMPARE(DiagramRender::palette(QStringLiteral("auto"), false).bg,
DiagramRender::palette(QStringLiteral("paper")).bg);
// a NAMED palette ignores the host theme entirely
QCOMPARE(DiagramRender::palette(QStringLiteral("ocean"), false).bg,
DiagramRender::palette(QStringLiteral("ocean")).bg);
// …and the widget really resolves it from its own QPalette: exported PNG
// corners are the resolved background.
QTemporaryDir tmp; QVERIFY(tmp.isValid());
const QString src = QStringLiteral("node a [Step]\n"); // no palette line → auto
auto corner = [&](const QColor &window, const QString &file) {
DiagramView v; v.resize(400, 300);
QPalette pal = v.palette(); pal.setColor(QPalette::Window, window); v.setPalette(pal);
v.setSource(src);
const QString path = tmp.filePath(file);
if (!v.exportTo(QStringLiteral("png"), path)) return QColor();
const QImage img(path);
return img.isNull() ? QColor() : QColor(img.pixel(2, 2));
};
const QColor onLight = corner(QColor("#ffffff"), QStringLiteral("auto-light.png"));
const QColor onDark = corner(QColor("#101010"), QStringLiteral("auto-dark.png"));
QVERIFY2(onLight.isValid() && onDark.isValid(), "auto-palette export failed");
QVERIFY2(onLight.lightness() > 200, "auto on a light host did not render the paper ground");
QVERIFY2(onDark.lightness() < 80, "auto on a dark host did not render the dark ground");
}
// Dashed edges, a note card, a legend and a group all render and export
// without crashing, and the canvas grows to hold the extras.
void dashed_note_legend_group_render_and_export() {
const QString src = QStringLiteral(
"diagram flow\n"
"title \"Pipeline\"\n"
"palette paper\n"
"direction LR\n"
"node a (Start)\n"
"node b [Work]\n"
"node c ([Store])\n"
"a -> b\n"
"b -.-> c : async\n"
"group \"Core\" : b c\n"
"note b \"Retries three times with exponential backoff\"\n"
"legend dashed \"async hop\"\n"
"legend #cc785c \"hot path\"\n"
"textbox \"caption\"\n");
const Npd::Diagram d = Npd::parse(src);
QVERIFY2(d.ok(), qPrintable(d.errors.join("; ")));
QVERIFY(d.edges.at(1).dashed);
const auto lay = DiagramRender::computeLayout(d);
QCOMPARE(lay.noteRects.size(), 1);
QVERIFY2(!lay.noteRects.at(0).isEmpty(), "note got no geometry");
QVERIFY2(!lay.legendRect.isEmpty(), "legend got no geometry");
QVERIFY2(lay.noteRects.at(0).right() <= lay.canvasW,
"canvas did not grow to fit the note");
QVERIFY2(lay.legendRect.right() <= lay.canvasW && lay.legendRect.bottom() <= lay.canvasH,
"legend box falls off the canvas");
// no legend statement ⇒ no legend box at all
QVERIFY(DiagramRender::computeLayout(
Npd::parse(QStringLiteral("node a [A]\n"))).legendRect.isEmpty());
QTemporaryDir tmp; QVERIFY(tmp.isValid());
DiagramView view; view.resize(900, 600);
view.setSource(src);
const QString png = tmp.filePath(QStringLiteral("rich.png"));
QVERIFY(view.exportTo(QStringLiteral("png"), png));
QVERIFY(QFileInfo(png).size() > 500);
if (DiagramView::supportedExportFormats().contains(QStringLiteral("SVG"), Qt::CaseInsensitive)) {
const QString svg = tmp.filePath(QStringLiteral("rich.svg"));
QVERIFY(view.exportTo(QStringLiteral("svg"), svg));
QVERIFY(QFileInfo(svg).size() > 500);
}
}
// Colour is not gated on diagram type or shape: it works on database
// cylinders (ER) and icon nodes (system) exactly as on flow boxes.
void color_on_er_and_system_nodes() {
const Npd::Diagram er = Npd::parse(QStringLiteral(
"diagram er\n"
"node customers ([Customers]) #2e7d32 :: \"id, name\"\n"
"node orders ([Orders]) #1565c0\n"
"customers -> orders : places\n"));
QVERIFY2(er.ok(), qPrintable(er.errors.join("; ")));
auto eid=[&](const QString&id){ for(const auto&n:er.nodes) if(n.id==id) return n; return Npd::Node{}; };
QCOMPARE(eid("customers").color, QStringLiteral("#2e7d32")); // colour before :: hover
QCOMPARE(eid("customers").shape, Npd::Shape::Database);
QCOMPARE(eid("customers").hover, QStringLiteral("id, name"));
QCOMPARE(eid("orders").color, QStringLiteral("#1565c0"));
QCOMPARE(eid("orders").shape, Npd::Shape::Database);
const Npd::Diagram sys = Npd::parse(QStringLiteral(
"diagram system\n"
"icon user :user \"Browser\" #00838f :: \"client\"\n"
"icon db :database \"DB\" #6a1b9a\n"
"user -> db\n"));
QVERIFY2(sys.ok(), qPrintable(sys.errors.join("; ")));
auto sid=[&](const QString&id){ for(const auto&n:sys.nodes) if(n.id==id) return n; return Npd::Node{}; };
QCOMPARE(sid("user").color, QStringLiteral("#00838f"));
QCOMPARE(sid("user").shape, Npd::Shape::Icon);
QCOMPARE(sid("user").icon, QStringLiteral("user"));
QCOMPARE(sid("user").hover, QStringLiteral("client"));
QCOMPARE(sid("db").color, QStringLiteral("#6a1b9a"));
}
};
QTEST_MAIN(TestDiagramView)
#include "test_diagram_view.moc"