@delurio/cli 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +62 -3
- package/SPEC.md +363 -0
- package/dist/changes.js +69 -0
- package/dist/check.js +266 -0
- package/dist/cli.js +114 -0
- package/dist/compare.js +143 -0
- package/dist/diff-flow.js +547 -0
- package/dist/diff-nav.js +391 -0
- package/dist/diff.js +316 -0
- package/dist/edges.js +120 -0
- package/dist/expr.js +157 -0
- package/dist/graph.js +67 -0
- package/dist/html.js +284 -0
- package/dist/kit.js +710 -0
- package/dist/mermaid.js +28 -0
- package/dist/model.js +178 -0
- package/dist/nav-page.js +271 -0
- package/dist/nav.js +566 -0
- package/dist/spine.js +149 -0
- package/dist/starts.js +81 -0
- package/dist/story.js +109 -0
- package/dist/wireframe.js +252 -0
- package/package.json +22 -3
package/dist/diff-nav.js
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
import { GraphError } from './graph.js';
|
|
2
|
+
import { checkGoals, findings, menuRows, pathTo, viewOf } from './nav.js';
|
|
3
|
+
import { deepDiff, fieldChanges, joinWords, listChanges, matchOrdered, moved, otherKeys, quote, same, union } from './compare.js';
|
|
4
|
+
const raw = (x) => (x ?? {});
|
|
5
|
+
const opOf = (b, a) => (b === undefined ? 'added' : a === undefined ? 'removed' : 'changed');
|
|
6
|
+
const capital = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
7
|
+
const KIND_WORDS = { screen: 'built', placeholder: 'not built', missing: 'a page that does not exist', redirect: 'a redirect', external: 'another application' };
|
|
8
|
+
const STANDING_WORDS = { ok: 'opens', placeholder: 'is not built', missing: 'does not exist', refused: 'refuses them', unreachable: 'cannot be reached' };
|
|
9
|
+
const STANDING_NOW = { ok: 'now opens', placeholder: 'is now not built', missing: 'now does not exist', refused: 'now refuses them', unreachable: 'can no longer be reached' };
|
|
10
|
+
const STANDING_PAST = { ok: 'opened', placeholder: 'was not built', missing: 'did not exist', refused: 'refused them', unreachable: 'could not be reached' };
|
|
11
|
+
const word = (g, key) => g.catalog[key] ?? key;
|
|
12
|
+
const pageName = (g, id) => (id === null ? 'no page' : g.pages[id] === undefined ? id : quote(g.pages[id].title));
|
|
13
|
+
const fromTokens = (s) => (Array.isArray(s.from) ? s.from : [s.from]);
|
|
14
|
+
const menuName = (g, frame) => (g.frames[frame]?.menu ?? frame).toLowerCase();
|
|
15
|
+
function fromWords(g, s) {
|
|
16
|
+
return joinWords(fromTokens(s).map((t) => {
|
|
17
|
+
if (!t.startsWith('frame:'))
|
|
18
|
+
return pageName(g, t);
|
|
19
|
+
const frame = t.slice('frame:'.length);
|
|
20
|
+
const f = g.frames[frame];
|
|
21
|
+
if (f === undefined)
|
|
22
|
+
return t;
|
|
23
|
+
return s.how === 'menu' && f.menu !== undefined ? `the ${menuName(g, frame)}` : `every page of ${quote(f.title)}`;
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
function entryWords(g, s) {
|
|
27
|
+
const label = s.label === undefined ? '' : quote(word(g, s.label));
|
|
28
|
+
if (s.how === 'menu')
|
|
29
|
+
return `menu entry ${label} in ${fromWords(g, s)}`;
|
|
30
|
+
if (s.how === 'picker')
|
|
31
|
+
return `scope picker entry ${label} on ${fromWords(g, s)}`;
|
|
32
|
+
if (s.how === 'user')
|
|
33
|
+
return `entry ${label} in the person’s corner of ${fromWords(g, s)}`;
|
|
34
|
+
if (s.how === 'auto')
|
|
35
|
+
return `automatic redirect from ${fromWords(g, s)}`;
|
|
36
|
+
return `${s.how} ${label} on ${fromWords(g, s)}`;
|
|
37
|
+
}
|
|
38
|
+
const entryKey = (s) => `entry ${s.id ?? `${fromTokens(s).join(', ')} → ${s.to} (${s.how})`}`;
|
|
39
|
+
const pathWords = (v, page) => {
|
|
40
|
+
const words = pathTo(v.reach, page).filter((e) => e.label !== '').map((e) => e.label);
|
|
41
|
+
return words.length === 0 ? 'automatic' : words.join(' → ');
|
|
42
|
+
};
|
|
43
|
+
function pageFields(p) {
|
|
44
|
+
return { route: p.route, title: p.titleKey ?? p.title, kind: p.kind, frame: p.frame, opensFor: p.opensFor ?? undefined, refusal: p.refusal ?? undefined, pageCodes: p.pageCodes ?? undefined, source: p.source ?? undefined, evidence: p.evidence ?? undefined };
|
|
45
|
+
}
|
|
46
|
+
export function navStructure(b, a) {
|
|
47
|
+
const out = [];
|
|
48
|
+
const gb = b.g;
|
|
49
|
+
const ga = a.g;
|
|
50
|
+
// the graph itself
|
|
51
|
+
const meta = (g) => ({ id: g.id, title: g.title, status: g.status, summary: g.summary, locale: g.locale, adapter: g.adapter ?? undefined });
|
|
52
|
+
out.push(...fieldChanges('graph', 'graph', meta(gb), meta(ga), ['title', 'summary', 'status', 'locale', 'id', 'adapter'], (f, x, y) => {
|
|
53
|
+
if (f === 'title')
|
|
54
|
+
return `The graph is now called ${quote(String(y))} (was ${quote(String(x))}).`;
|
|
55
|
+
if (f === 'summary')
|
|
56
|
+
return `New summary: ${quote(String(y))}`;
|
|
57
|
+
if (f === 'status')
|
|
58
|
+
return `Status: ${String(x)} → ${String(y)}.`;
|
|
59
|
+
if (f === 'locale')
|
|
60
|
+
return `The wording is now in ${String(y)} (was ${String(x)}).`;
|
|
61
|
+
return null;
|
|
62
|
+
}));
|
|
63
|
+
deepDiff('citations', 'graph source', gb.source, ga.source, out);
|
|
64
|
+
// frames
|
|
65
|
+
for (const id of union(gb.frames, ga.frames)) {
|
|
66
|
+
const x = gb.frames[id];
|
|
67
|
+
const y = ga.frames[id];
|
|
68
|
+
if (same(x, y))
|
|
69
|
+
continue;
|
|
70
|
+
const key = `frame ${id}`;
|
|
71
|
+
if (x === undefined || y === undefined) {
|
|
72
|
+
const f = (y ?? x);
|
|
73
|
+
out.push({ area: 'frames', key, op: opOf(x, y), say: y === undefined ? `The frame ${quote(f.title)} was removed.` : `New frame: ${quote(f.title)}${f.menu === undefined ? '' : `, with the ${f.menu.toLowerCase()}`}.` });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
out.push(...fieldChanges('frames', key, raw(x), raw(y), ['title', 'menu', 'shows'], (f, p, q) => {
|
|
77
|
+
if (f === 'title')
|
|
78
|
+
return `The frame ${quote(x.title)} is now called ${quote(y.title)}.`;
|
|
79
|
+
if (f === 'menu')
|
|
80
|
+
return q === undefined ? `${quote(y.title)} no longer carries a menu.` : `${quote(y.title)} now carries the ${String(q).toLowerCase()}${p === undefined ? '' : ` (was the ${String(p).toLowerCase()})`}.`;
|
|
81
|
+
return null;
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
// facts and named conditions: formal, listed under the exact changes; who they affect shows under behaviour
|
|
85
|
+
for (const id of union(gb.facts, ga.facts)) {
|
|
86
|
+
if (!same(gb.facts[id], ga.facts[id]))
|
|
87
|
+
out.push({ area: 'facts', key: `fact ${id}`, op: opOf(gb.facts[id], ga.facts[id]), before: gb.facts[id], after: ga.facts[id], say: null });
|
|
88
|
+
}
|
|
89
|
+
for (const id of union(gb.guards, ga.guards)) {
|
|
90
|
+
if (!same(gb.guards[id], ga.guards[id]))
|
|
91
|
+
out.push({ area: 'facts', key: `guard ${id}`, op: opOf(gb.guards[id], ga.guards[id]), before: gb.guards[id], after: ga.guards[id], say: null });
|
|
92
|
+
}
|
|
93
|
+
// kinds of user
|
|
94
|
+
const pb = Object.fromEntries(gb.personas.map((p) => [p.id, p]));
|
|
95
|
+
const pa = Object.fromEntries(ga.personas.map((p) => [p.id, p]));
|
|
96
|
+
for (const id of union(pb, pa)) {
|
|
97
|
+
const x = pb[id];
|
|
98
|
+
const y = pa[id];
|
|
99
|
+
if (same(x, y))
|
|
100
|
+
continue;
|
|
101
|
+
const key = `persona ${id}`;
|
|
102
|
+
if (x === undefined) {
|
|
103
|
+
out.push({ area: 'people', key, op: 'added', say: `New kind of user: ${quote(y.title)}, ${y.say}.` });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (y === undefined) {
|
|
107
|
+
out.push({ area: 'people', key, op: 'removed', say: `Kind of user removed: ${quote(x.title)}.` });
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (x.title !== y.title)
|
|
111
|
+
out.push({ area: 'people', key, op: 'changed', field: 'title', before: x.title, after: y.title, say: `${quote(x.title)} is now called ${quote(y.title)}.` });
|
|
112
|
+
if (x.say !== y.say)
|
|
113
|
+
out.push({ area: 'people', key, op: 'changed', field: 'say', before: x.say, after: y.say, say: `${quote(y.title)} is now described as: ${y.say}.` });
|
|
114
|
+
for (const fact of union(x.facts, y.facts)) {
|
|
115
|
+
const p = x.facts[fact];
|
|
116
|
+
const q = y.facts[fact];
|
|
117
|
+
if (same(p, q))
|
|
118
|
+
continue;
|
|
119
|
+
let say;
|
|
120
|
+
if (Array.isArray(p) && Array.isArray(q)) {
|
|
121
|
+
const ch = listChanges(p.map(String), q.map(String));
|
|
122
|
+
const parts = [ch.added.length > 0 ? `now also has ${ch.added.join(', ')}` : '', ch.removed.length > 0 ? `no longer has ${ch.removed.join(', ')}` : ''].filter((s) => s !== '');
|
|
123
|
+
say = `${quote(y.title)}: ${fact} ${parts.length > 0 ? parts.join('; ') : 'is in a new order'}.`;
|
|
124
|
+
}
|
|
125
|
+
else
|
|
126
|
+
say = `${quote(y.title)}: ${fact} is now ${String(q)} (was ${String(p)}).`;
|
|
127
|
+
out.push({ area: 'people', key, op: opOf(p, q), field: `facts.${fact}`, before: p, after: q, say });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
// pages
|
|
131
|
+
for (const id of union(gb.pages, ga.pages)) {
|
|
132
|
+
const x = gb.pages[id];
|
|
133
|
+
const y = ga.pages[id];
|
|
134
|
+
const key = `page ${id}`;
|
|
135
|
+
if (x === undefined) {
|
|
136
|
+
out.push({ area: 'pages', key, op: 'added', say: `New page ${pageName(ga, id)} (${y.route})${y.kind === 'screen' ? '' : `, ${KIND_WORDS[y.kind]}`}.` });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (y === undefined) {
|
|
140
|
+
out.push({ area: 'pages', key, op: 'removed', say: `Page ${pageName(gb, id)} (${x.route}) removed.` });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const name = pageName(ga, id);
|
|
144
|
+
out.push(...fieldChanges('pages', key, pageFields(x), pageFields(y), ['route', 'title', 'kind', 'frame', 'opensFor', 'refusal', 'pageCodes', 'source', 'evidence'], (f, p, q) => {
|
|
145
|
+
if (f === 'route')
|
|
146
|
+
return `${name} moved to ${String(q)} (was ${String(p)}).`;
|
|
147
|
+
if (f === 'title')
|
|
148
|
+
return `${pageName(gb, id)} is now titled ${name}.`;
|
|
149
|
+
if (f === 'kind')
|
|
150
|
+
return `${name} is now ${KIND_WORDS[String(q)]} (was ${KIND_WORDS[String(p)]}).`;
|
|
151
|
+
if (f === 'frame')
|
|
152
|
+
return `${name} is now drawn in ${quote(ga.frames[String(q)]?.title ?? String(q))} (was ${quote(gb.frames[String(p)]?.title ?? String(p))}).`;
|
|
153
|
+
if (f === 'opensFor')
|
|
154
|
+
return q === undefined ? `${name} now opens for everyone.` : p === undefined ? `${name} now opens only for some people.` : `Who ${name} opens for changed.`;
|
|
155
|
+
if (f === 'refusal')
|
|
156
|
+
return q === undefined ? `${name} no longer says how it refuses.` : `${name} now refuses with: ${quote(String(q))}.`;
|
|
157
|
+
return null;
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
// menus, links and buttons: entries are paired by their id or by where they start, where they go, how and their label;
|
|
161
|
+
// what is left by (start, how, label) when the target changed, then by (start, target, how) when the label changed
|
|
162
|
+
const sb = gb.specs;
|
|
163
|
+
const sa = ga.specs;
|
|
164
|
+
const k1 = (s) => s.id ?? `${fromTokens(s).join(',')}|${s.to}|${s.how}|${s.label ?? ''}`;
|
|
165
|
+
const k2 = (s) => (s.id !== undefined ? null : `${fromTokens(s).join(',')}|${s.how}|${s.label ?? ''}`);
|
|
166
|
+
const k3 = (s) => (s.id !== undefined ? null : `${fromTokens(s).join(',')}|${s.to}|${s.how}`);
|
|
167
|
+
const pairs = matchOrdered(sb, sa, [k1, k2, k3]);
|
|
168
|
+
const inB = new Set(pairs.map((p) => p[0]));
|
|
169
|
+
const inA = new Set(pairs.map((p) => p[1]));
|
|
170
|
+
sa.forEach((s, j) => {
|
|
171
|
+
if (inA.has(j))
|
|
172
|
+
return;
|
|
173
|
+
out.push({ area: 'entries', key: entryKey(s), op: 'added', after: s, say: `New ${entryWords(ga, s)} → ${pageName(ga, s.to)}.` });
|
|
174
|
+
});
|
|
175
|
+
sb.forEach((s, i) => {
|
|
176
|
+
if (inB.has(i))
|
|
177
|
+
return;
|
|
178
|
+
out.push({ area: 'entries', key: entryKey(s), op: 'removed', before: s, say: `Removed: the ${entryWords(gb, s)} → ${pageName(gb, s.to)}.` });
|
|
179
|
+
});
|
|
180
|
+
const ENTRY_KNOWN = ['id', 'from', 'to', 'how', 'label', 'when', 'clicks', 'data', 'back'];
|
|
181
|
+
for (const [i, j] of pairs) {
|
|
182
|
+
const x = sb[i];
|
|
183
|
+
const y = sa[j];
|
|
184
|
+
const what = `The ${entryWords(ga, y)}`;
|
|
185
|
+
out.push(...fieldChanges('entries', entryKey(y), raw(x), raw(y), ENTRY_KNOWN, (f, p, q) => {
|
|
186
|
+
if (f === 'to')
|
|
187
|
+
return `${what} now opens ${pageName(ga, y.to)} (was ${pageName(gb, x.to)}).`;
|
|
188
|
+
if (f === 'label')
|
|
189
|
+
return `The ${entryWords(gb, x)} now reads ${quote(word(ga, y.label ?? ''))}.`;
|
|
190
|
+
if (f === 'when')
|
|
191
|
+
return `Who sees the ${entryWords(ga, y)} changed.`;
|
|
192
|
+
if (f === 'clicks')
|
|
193
|
+
return `${what} now takes ${String(q ?? 1)} clicks (was ${String(p ?? 1)}).`;
|
|
194
|
+
if (f === 'data')
|
|
195
|
+
return q === true ? `${what} now needs an item in a list.` : `${what} no longer needs an item in a list.`;
|
|
196
|
+
if (f === 'back')
|
|
197
|
+
return q === true ? `${what} now goes back.` : `${what} no longer goes back.`;
|
|
198
|
+
return null;
|
|
199
|
+
}));
|
|
200
|
+
otherKeys(entryKey(y), raw(x), raw(y), ENTRY_KNOWN, out);
|
|
201
|
+
}
|
|
202
|
+
// the order of a menu is what people see; other entries are not ordered for them
|
|
203
|
+
const groups = new Map();
|
|
204
|
+
for (const [i, j] of pairs) {
|
|
205
|
+
if (sb[i].how !== 'menu' || sa[j].how !== 'menu')
|
|
206
|
+
continue;
|
|
207
|
+
const g = fromTokens(sa[j]).join(',');
|
|
208
|
+
groups.set(g, [...(groups.get(g) ?? []), [i, j]]);
|
|
209
|
+
}
|
|
210
|
+
for (const [g, group] of groups) {
|
|
211
|
+
const posB = sb.map((s, i) => [s, i]).filter(([s]) => s.how === 'menu' && fromTokens(s).join(',') === g).map(([, i]) => i);
|
|
212
|
+
const posA = sa.map((s, j) => [s, j]).filter(([s]) => s.how === 'menu' && fromTokens(s).join(',') === g).map(([, j]) => j);
|
|
213
|
+
for (const [i, j] of moved(group)) {
|
|
214
|
+
const s = sa[j];
|
|
215
|
+
out.push({ area: 'entries', key: entryKey(s), op: 'moved', before: posB.indexOf(i) + 1, after: posA.indexOf(j) + 1, say: `In ${fromWords(ga, s)}, ${quote(word(ga, s.label ?? ''))} is now entry ${posA.indexOf(j) + 1} (was ${posB.indexOf(i) + 1}).` });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// goals
|
|
219
|
+
const goalsB = Object.fromEntries(gb.goals.map((x) => [x.id, x]));
|
|
220
|
+
const goalsA = Object.fromEntries(ga.goals.map((x) => [x.id, x]));
|
|
221
|
+
if (Object.keys(goalsB).length !== gb.goals.length || Object.keys(goalsA).length !== ga.goals.length)
|
|
222
|
+
throw new GraphError('a goal id is written twice');
|
|
223
|
+
const who = (g, ids) => joinWords(ids.map((id) => quote(g.personas.find((p) => p.id === id)?.title ?? id)));
|
|
224
|
+
for (const id of union(goalsB, goalsA)) {
|
|
225
|
+
const x = goalsB[id];
|
|
226
|
+
const y = goalsA[id];
|
|
227
|
+
if (same(x, y))
|
|
228
|
+
continue;
|
|
229
|
+
const key = `goal ${id}`;
|
|
230
|
+
if (x === undefined)
|
|
231
|
+
out.push({ area: 'goals', key, op: 'added', say: `New goal: ${quote(y.say)}, for ${who(ga, y.for)}, within ${y.within} ${y.within === 1 ? 'click' : 'clicks'}.` });
|
|
232
|
+
else if (y === undefined)
|
|
233
|
+
out.push({ area: 'goals', key, op: 'removed', say: `Goal dropped: ${quote(x.say)}.` });
|
|
234
|
+
else {
|
|
235
|
+
out.push(...fieldChanges('goals', key, raw(x), raw(y), ['say', 'page', 'for', 'within'], (f) => {
|
|
236
|
+
if (f === 'say')
|
|
237
|
+
return `Goal reworded: ${quote(y.say)} (was ${quote(x.say)}).`;
|
|
238
|
+
if (f === 'page')
|
|
239
|
+
return `The goal ${quote(y.say)} now means reaching ${pageName(ga, y.page)} (was ${pageName(gb, x.page)}).`;
|
|
240
|
+
if (f === 'for')
|
|
241
|
+
return `The goal ${quote(y.say)} is now checked for ${who(ga, y.for)} (was ${who(gb, x.for)}).`;
|
|
242
|
+
return `The goal ${quote(y.say)} now allows ${y.within} ${y.within === 1 ? 'click' : 'clicks'} (was ${x.within}).`;
|
|
243
|
+
}));
|
|
244
|
+
otherKeys(key, raw(x), raw(y), ['id', 'say', 'page', 'for', 'within'], out);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const nm = listChanges(gb.notModeled, ga.notModeled);
|
|
248
|
+
for (const x of nm.added)
|
|
249
|
+
out.push({ area: 'notModeled', key: 'notModeled', op: 'added', after: x, say: `Now listed as not modeled: ${quote(x)}.` });
|
|
250
|
+
for (const x of nm.removed)
|
|
251
|
+
out.push({ area: 'notModeled', key: 'notModeled', op: 'removed', before: x, say: `No longer listed as not modeled: ${quote(x)}.` });
|
|
252
|
+
for (const k of union(gb.catalog, ga.catalog)) {
|
|
253
|
+
const p = gb.catalog[k];
|
|
254
|
+
const q = ga.catalog[k];
|
|
255
|
+
if (p === q)
|
|
256
|
+
continue;
|
|
257
|
+
out.push({ area: 'wording', key: `catalog ${k}`, op: opOf(p, q), before: p, after: q, say: p === undefined ? `New text: ${quote(q)}.` : q === undefined ? `Text removed: ${quote(p)}.` : `${quote(p)} now reads ${quote(q)}.` });
|
|
258
|
+
}
|
|
259
|
+
return { changes: out, entries: pairs };
|
|
260
|
+
}
|
|
261
|
+
export function runNav(g, side) {
|
|
262
|
+
try {
|
|
263
|
+
const views = g.personas.map((p) => viewOf(g, p));
|
|
264
|
+
const goals = checkGoals(g, views);
|
|
265
|
+
return { g, views, goals, found: findings(g, views, goals) };
|
|
266
|
+
}
|
|
267
|
+
catch (err) {
|
|
268
|
+
throw new GraphError(`${side}: ${err.message}`);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
const outcomeWords = (c) => (c === undefined ? 'not checked' : c.outcome === 'ok' ? `ok, ${c.clicks} of ${c.goal.within} ${c.goal.within === 1 ? 'click' : 'clicks'}` : c.outcome === 'too many clicks' ? `${c.clicks} clicks, the limit is ${c.goal.within}` : c.outcome);
|
|
272
|
+
export function navBehaviour(rb, ra, s) {
|
|
273
|
+
const out = [];
|
|
274
|
+
const gb = rb.g;
|
|
275
|
+
const ga = ra.g;
|
|
276
|
+
const rowsB = menuRows(gb, rb.views);
|
|
277
|
+
const rowsA = menuRows(ga, ra.views);
|
|
278
|
+
const pairedA = new Map(s.entries.map(([i, j]) => [j, i]));
|
|
279
|
+
const pairedB = new Set(s.entries.map(([i]) => i));
|
|
280
|
+
for (const [ia, va] of ra.views.entries()) {
|
|
281
|
+
const id = va.persona.id;
|
|
282
|
+
const who = va.persona.title;
|
|
283
|
+
const ib = rb.views.findIndex((v) => v.persona.id === id);
|
|
284
|
+
const add = (topic, key, before, after, say, worse = false) => {
|
|
285
|
+
out.push({ topic, key: `${id} ${key}`, persona: who, before, after, say, detail: [], worse });
|
|
286
|
+
};
|
|
287
|
+
const reached = (v, p) => v.standing[p] !== undefined && v.standing[p] !== 'unreachable';
|
|
288
|
+
if (ib < 0) {
|
|
289
|
+
const n = Object.keys(ga.pages).filter((p) => reached(va, p)).length;
|
|
290
|
+
add('persona', 'new', null, va.landing, `A new kind of user: lands on ${pageName(ga, va.landing)} and reaches ${n} of ${Object.keys(ga.pages).length} pages.`);
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
const vb = rb.views[ib];
|
|
294
|
+
if (vb.landing !== va.landing)
|
|
295
|
+
add('landing', 'landing', vb.landing, va.landing, `Lands on ${pageName(ga, va.landing)} (was ${pageName(gb, vb.landing)}).`);
|
|
296
|
+
for (const page of union(gb.pages, ga.pages)) {
|
|
297
|
+
const inB = gb.pages[page] !== undefined && reached(vb, page);
|
|
298
|
+
const inA = ga.pages[page] !== undefined && reached(va, page);
|
|
299
|
+
const name = ga.pages[page] === undefined ? pageName(gb, page) : pageName(ga, page);
|
|
300
|
+
if (!inB && inA) {
|
|
301
|
+
const st = va.standing[page];
|
|
302
|
+
add('page', page, null, { clicks: va.reach.get(page)?.clicks, standing: st }, `Can now reach ${name}${st === 'ok' ? '' : `, which ${STANDING_WORDS[st]}`} (${va.reach.get(page)?.clicks ?? 0} clicks: ${pathWords(va, page)}).`);
|
|
303
|
+
}
|
|
304
|
+
else if (inB && !inA) {
|
|
305
|
+
add('page', page, { clicks: vb.reach.get(page)?.clicks, standing: vb.standing[page] }, null, `Can no longer reach ${name}${ga.pages[page] === undefined ? ' (the page was removed)' : ''}.`);
|
|
306
|
+
}
|
|
307
|
+
else if (inB && inA) {
|
|
308
|
+
const stB = vb.standing[page];
|
|
309
|
+
const stA = va.standing[page];
|
|
310
|
+
if (stB !== stA)
|
|
311
|
+
add('page', page, stB, stA, `${capital(name)} ${STANDING_NOW[stA]} (it ${STANDING_PAST[stB]}).`);
|
|
312
|
+
const cb = vb.reach.get(page)?.clicks ?? 0;
|
|
313
|
+
const ca = va.reach.get(page)?.clicks ?? 0;
|
|
314
|
+
if (cb !== ca)
|
|
315
|
+
add('clicks', page, cb, ca, `${capital(name)}: ${cb} → ${ca} ${ca === 1 ? 'click' : 'clicks'} (${pathWords(va, page)}).`);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
// the menu they see
|
|
319
|
+
for (const row of rowsA) {
|
|
320
|
+
const shownA = row.cells[ia].shown;
|
|
321
|
+
const i = pairedA.get(row.spec);
|
|
322
|
+
const rowB = i === undefined ? undefined : rowsB.find((r) => r.spec === i);
|
|
323
|
+
const shownB = rowB === undefined ? false : rowB.cells[ib].shown;
|
|
324
|
+
const label = quote(row.entry.label);
|
|
325
|
+
if (shownA && !shownB)
|
|
326
|
+
add('menu', entryKey(ga.specs[row.spec]), false, true, `Their ${menuName(ga, row.frame)} now shows ${label}${i === undefined ? ' (a new entry)' : ''}.`);
|
|
327
|
+
else if (!shownA && shownB)
|
|
328
|
+
add('menu', entryKey(ga.specs[row.spec]), true, false, `Their ${menuName(ga, row.frame)} no longer shows ${label}.`);
|
|
329
|
+
}
|
|
330
|
+
for (const row of rowsB) {
|
|
331
|
+
if (pairedB.has(row.spec) || !row.cells[ib].shown)
|
|
332
|
+
continue;
|
|
333
|
+
add('menu', entryKey(gb.specs[row.spec]), true, false, `Their ${menuName(gb, row.frame)} no longer shows ${quote(row.entry.label)} (the entry was removed).`);
|
|
334
|
+
}
|
|
335
|
+
// what they come to do
|
|
336
|
+
for (const c of ra.goals.filter((x) => x.persona.id === id)) {
|
|
337
|
+
const before = rb.goals.find((x) => x.persona.id === id && x.goal.id === c.goal.id);
|
|
338
|
+
if (before !== undefined && before.outcome === c.outcome && before.clicks === c.clicks)
|
|
339
|
+
continue;
|
|
340
|
+
if (before === undefined && c.outcome === 'ok')
|
|
341
|
+
continue;
|
|
342
|
+
const worse = c.outcome !== 'ok' && (before === undefined || before.outcome === 'ok');
|
|
343
|
+
add('goal', `goal ${c.goal.id}`, before?.outcome ?? null, c.outcome, `${quote(c.goal.say)}: ${outcomeWords(c)} (was ${outcomeWords(before)}).`, worse);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// findings, compared line by line: a finding's headline carries counts, so its detail lines are what is compared. A new
|
|
347
|
+
// line and a gone line that start the same way ("Ders nazırı: …") and are the only such pair are one case that changed.
|
|
348
|
+
const lines = (f) => (f.detail.length > 0 ? f.detail : [f.say]);
|
|
349
|
+
const codes = union(Object.fromEntries(rb.found.map((f) => [f.code, f])), Object.fromEntries(ra.found.map((f) => [f.code, f])));
|
|
350
|
+
for (const code of codes) {
|
|
351
|
+
const fb = rb.found.find((f) => f.code === code);
|
|
352
|
+
const fa = ra.found.find((f) => f.code === code);
|
|
353
|
+
const lb = fb === undefined ? [] : lines(fb);
|
|
354
|
+
const la = fa === undefined ? [] : lines(fa);
|
|
355
|
+
let added = la.filter((l) => !lb.includes(l));
|
|
356
|
+
let resolved = lb.filter((l) => !la.includes(l));
|
|
357
|
+
const head = (l) => (l.includes(': ') ? l.slice(0, l.indexOf(': ')) : null);
|
|
358
|
+
const changed = [];
|
|
359
|
+
for (const l of [...added]) {
|
|
360
|
+
const h = head(l);
|
|
361
|
+
if (h === null || added.filter((x) => head(x) === h).length > 1)
|
|
362
|
+
continue;
|
|
363
|
+
const was = resolved.filter((x) => head(x) === h);
|
|
364
|
+
if (was.length !== 1)
|
|
365
|
+
continue;
|
|
366
|
+
changed.push(`${l} (was: ${was[0].slice(h.length + 2)})`);
|
|
367
|
+
added = added.filter((x) => x !== l);
|
|
368
|
+
resolved = resolved.filter((x) => x !== was[0]);
|
|
369
|
+
}
|
|
370
|
+
const level = (fa ?? fb);
|
|
371
|
+
const push = (say, detail, worse, before, after) => {
|
|
372
|
+
out.push({ topic: 'finding', key: code, before, after, say, detail: detail.map((d) => `- ${d}`), worse });
|
|
373
|
+
};
|
|
374
|
+
if (fb === undefined && fa !== undefined)
|
|
375
|
+
push(`New ${fa.level}: ${fa.say}`, fa.detail, fa.level === 'problem', null, la);
|
|
376
|
+
else if (fb !== undefined && fa === undefined)
|
|
377
|
+
push(`Resolved ${fb.level}: ${fb.say}`, fb.detail, false, lb, null);
|
|
378
|
+
else {
|
|
379
|
+
if (added.length > 0)
|
|
380
|
+
push(`${capital(level.level)}, new cases: ${fa.say}`, added, level.level === 'problem', null, added);
|
|
381
|
+
if (resolved.length > 0)
|
|
382
|
+
push(`${capital(level.level)}, cases resolved: ${fb.say}`, resolved, false, resolved, null);
|
|
383
|
+
if (changed.length > 0)
|
|
384
|
+
push(`${capital(level.level)}, cases that changed: ${fa.say}`, changed, false, null, changed);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
389
|
+
export function navSameBehaviour(r) {
|
|
390
|
+
return `Nothing: each of the ${r.views.length} kinds of user lands on the same page, reaches the same pages in the same number of clicks and sees the same menu; goals and findings are the same.`;
|
|
391
|
+
}
|