|
| 1 | +/** |
| 2 | + * ObjectUI |
| 3 | + * Copyright (c) 2024-present ObjectStack Inc. |
| 4 | + * |
| 5 | + * This source code is licensed under the MIT license found in the |
| 6 | + * LICENSE file in the root directory of this source tree. |
| 7 | + */ |
| 8 | + |
| 9 | +/** |
| 10 | + * objectui#10466 — the `draft` member's doc comment on the published |
| 11 | + * `InlineEditContextValue` must teach a live-value read in which an OWN draft |
| 12 | + * key wins even when its value is empty. |
| 13 | + * |
| 14 | + * ## The defect |
| 15 | + * |
| 16 | + * The doc comment prescribed a read that falls back to the saved value |
| 17 | + * whenever the draft value is nullish. A field the user emptied, or a cascade |
| 18 | + * clear emptied (it stages `null` since objectui#10291), is exactly an own |
| 19 | + * draft key with an empty value. A host that copied the published sentence |
| 20 | + * therefore showed, and handed its widgets, the value the user had just |
| 21 | + * removed; on the record page's highlights strip that read looped an option |
| 22 | + * widget's prune forever (objectui#7190). No in-tree host followed the |
| 23 | + * sentence. The trap was for the next host author, human or AI, who copies it |
| 24 | + * from the `.d.ts` hover. |
| 25 | + * |
| 26 | + * ## Why a source-text pin |
| 27 | + * |
| 28 | + * A doc comment is erased before anything runs, so no behavioural test can |
| 29 | + * reach it. This takes the same narrow exception the sibling doc pins in this |
| 30 | + * package take for prescriptive doc text (`LazyPluginLoader.jsdocExample`, |
| 31 | + * `useNavigationOverlay.docExampleRecordSource-7638`). |
| 32 | + * |
| 33 | + * ## What it asserts, kept deliberately loose |
| 34 | + * |
| 35 | + * Only the READ RULE taught by the doc comment's code spans, never its prose, |
| 36 | + * wording or ordering: |
| 37 | + * 1. no code span reads the draft and then falls back through a nullish or |
| 38 | + * falsy operator; |
| 39 | + * 2. at least one code span names a read that keeps an own draft key. |
| 40 | + * |
| 41 | + * Both matchers are proven able to fire on synthetic spellings before any |
| 42 | + * verdict on the real doc comment is believed. |
| 43 | + */ |
| 44 | + |
| 45 | +import { describe, it, expect } from 'vitest'; |
| 46 | +import { readFileSync } from 'node:fs'; |
| 47 | +import { dirname, join } from 'node:path'; |
| 48 | +import { fileURLToPath } from 'node:url'; |
| 49 | + |
| 50 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 51 | +/** The context under test, by SOURCE path: the doc comment lives here and ships in `dist` typings. */ |
| 52 | +const SOURCE = readFileSync(join(HERE, '..', 'InlineEditContext.tsx'), 'utf8'); |
| 53 | + |
| 54 | +/** |
| 55 | + * The doc comment directly above the `draft:` member of |
| 56 | + * `InlineEditContextValue`, with its `*` gutters removed. Anchored on the |
| 57 | + * interface so a `draft` member of any other type in the file is never read. |
| 58 | + */ |
| 59 | +function draftDocComment(): string { |
| 60 | + const iface = SOURCE.indexOf('export interface InlineEditContextValue'); |
| 61 | + if (iface === -1) throw new Error('`export interface InlineEditContextValue` not found; re-anchor this pin.'); |
| 62 | + const ifaceEnd = SOURCE.indexOf('\n}', iface); |
| 63 | + const member = SOURCE.indexOf('\n draft:', iface); |
| 64 | + if (member === -1 || member > ifaceEnd) { |
| 65 | + throw new Error('`draft:` member not found in `InlineEditContextValue`; re-anchor this pin.'); |
| 66 | + } |
| 67 | + const open = SOURCE.lastIndexOf('/**', member); |
| 68 | + const close = SOURCE.indexOf('*/', open); |
| 69 | + if (open < iface || close === -1 || close > member || SOURCE.slice(close + 2, member).trim() !== '') { |
| 70 | + throw new Error('no doc comment directly above `draft:`; re-anchor this pin.'); |
| 71 | + } |
| 72 | + return SOURCE.slice(open + 3, close) |
| 73 | + .split('\n') |
| 74 | + .map((line) => line.replace(/^\s*\* ?/, '')) |
| 75 | + .join('\n'); |
| 76 | +} |
| 77 | + |
| 78 | +/** The backtick code spans of a block of prose, whitespace collapsed (a span may wrap a line). */ |
| 79 | +function codeSpans(text: string): string[] { |
| 80 | + return [...text.matchAll(/`([^`]+)`/g)].map((m) => m[1].replace(/\s+/g, ' ').trim()); |
| 81 | +} |
| 82 | + |
| 83 | +/** A draft read followed by a fallback operator: it returns the saved value for an emptied field. */ |
| 84 | +const FALLBACK_READ = /\bdraft\b.*(?:\?\?|\|\|)/; |
| 85 | + |
| 86 | +/** Reads that keep an own draft key even when its value is empty. */ |
| 87 | +const OWN_KEY_READS: readonly RegExp[] = [ |
| 88 | + // the staged-record spread, draft LAST so its own keys win |
| 89 | + /\{\s*\.\.\.(?!draft\b)[\w.?]+\s*,\s*\.\.\.draft\s*\}/, |
| 90 | + // an explicit own-key test |
| 91 | + /\bin\s+draft\b/, |
| 92 | + /\bhasOwn(?:Property\.call)?\(\s*draft\b/, |
| 93 | +]; |
| 94 | +const keepsOwnKey = (span: string): boolean => OWN_KEY_READS.some((re) => re.test(span)); |
| 95 | + |
| 96 | +describe('InlineEditContextValue.draft doc comment teaches the own-key read (objectui#10466)', () => { |
| 97 | + const doc = draftDocComment(); |
| 98 | + const spans = codeSpans(doc); |
| 99 | + |
| 100 | + it('LIT CONTROL: the doc comment was read and carries code spans', () => { |
| 101 | + // Every verdict below is about these spans. An empty list would make |
| 102 | + // "no fallback read" vacuously true. |
| 103 | + expect(doc.trim().length).toBeGreaterThan(0); |
| 104 | + expect(spans.length).toBeGreaterThan(0); |
| 105 | + }); |
| 106 | + |
| 107 | + it('CONTROL: the fallback matcher fires on the read objectui#10466 removed, and only on fallbacks', () => { |
| 108 | + expect(FALLBACK_READ.test('draft[name] ?? data[name]')).toBe(true); |
| 109 | + expect(FALLBACK_READ.test('draft[name] || data[name]')).toBe(true); |
| 110 | + expect(FALLBACK_READ.test('{ ...data, ...draft }')).toBe(false); |
| 111 | + expect(FALLBACK_READ.test('name in draft ? draft[name] : data[name]')).toBe(false); |
| 112 | + }); |
| 113 | + |
| 114 | + it('CONTROL: the own-key recogniser accepts own-key reads and refuses the rest', () => { |
| 115 | + expect(keepsOwnKey('{ ...data, ...draft }')).toBe(true); |
| 116 | + expect(keepsOwnKey('name in draft ? draft[name] : data[name]')).toBe(true); |
| 117 | + expect(keepsOwnKey('Object.hasOwn(draft, name) ? draft[name] : data[name]')).toBe(true); |
| 118 | + expect(keepsOwnKey('Object.prototype.hasOwnProperty.call(draft, name)')).toBe(true); |
| 119 | + // the saved record spread last: the draft never wins |
| 120 | + expect(keepsOwnKey('{ ...draft, ...data }')).toBe(false); |
| 121 | + expect(keepsOwnKey('draft[name] ?? data[name]')).toBe(false); |
| 122 | + }); |
| 123 | + |
| 124 | + it('teaches no read that falls back to the saved value on an empty draft value', () => { |
| 125 | + expect( |
| 126 | + spans.filter((span) => FALLBACK_READ.test(span)), |
| 127 | + 'A host author copies this read. A fallback on an empty draft value returns the saved ' + |
| 128 | + 'value for a field the user or a cascade clear emptied (objectui#7190, objectui#10466).', |
| 129 | + ).toEqual([]); |
| 130 | + }); |
| 131 | + |
| 132 | + it('names a read in which an own draft key wins', () => { |
| 133 | + expect(spans.some(keepsOwnKey), `code spans read: ${JSON.stringify(spans)}`).toBe(true); |
| 134 | + }); |
| 135 | +}); |
0 commit comments