Просмотр исходного кода

test(frontend): shadow-parse harness asserting legacy class and Zod converge

Add Step 3c's safety net: for every inbound golden fixture, run the raw
payload through both pipelines —

  legacy:  Inbound.Settings.fromJson(protocol, raw.settings).toJson()
  zod:     InboundSettingsSchema.parse(raw).settings

— canonicalize each (recursively sort keys, drop empty arrays / null /
undefined), and assert byte-equality. This locks the wire shape across the
upcoming class-to-pure-function extraction in Step 3d. Any normalization
drift introduced by the rewrite trips an assertion here before it can
reach users.

Two ergonomic wrinkles handled inline:
  - The legacy class lumps hysteria + hysteria2 onto a single
    HysteriaSettings (no hysteria2 case in the dispatch table); the test
    routes hysteria2 fixtures through the HYSTERIA branch.
  - Empty arrays in Zod's output (e.g. fallbacks: [] from a .default([]))
    are treated as equivalent to the legacy class's omit-when-empty
    behavior. Same wire state, different syntactic surface.

All 26 tests across 4 test files pass on first run.
MHSanaei 22 часов назад
Родитель
Сommit
a7a8041b13
1 измененных файлов с 78 добавлено и 0 удалено
  1. 78 0
      frontend/src/test/shadow.test.ts

+ 78 - 0
frontend/src/test/shadow.test.ts

@@ -0,0 +1,78 @@
+/// <reference types="vite/client" />
+import { describe, expect, it } from 'vitest';
+
+import { Inbound } from '@/models/inbound';
+import { InboundSettingsSchema } from '@/schemas/protocols';
+
+// Walks every inbound golden fixture through both pipelines:
+//   OLD:   Inbound.Settings.fromJson(protocol, raw.settings).toJson()
+//   NEW:   InboundSettingsSchema.parse(raw).settings
+// Then canonicalizes (deep key-sort, undefined-strip via JSON round-trip)
+// and asserts byte-equality. This is the safety net for Step 3d — once we
+// start extracting class methods into lib/xray/* pure functions, any
+// normalization drift trips a snapshot diff here.
+
+const fixtures = import.meta.glob<unknown>(
+  './golden/fixtures/inbound/*.json',
+  { eager: true, import: 'default' },
+);
+
+type FixtureShape = { protocol: string; settings: unknown };
+
+// The OLD panel class collapses hysteria + hysteria2 onto a single
+// HysteriaSettings (distinguished only by `version`), so when a fixture
+// carries the wire-level hysteria2 protocol literal we dispatch to the
+// HYSTERIA branch on the legacy side.
+function legacyProtocolFor(protocol: string): string {
+  if (protocol === 'hysteria2') return 'hysteria';
+  return protocol;
+}
+
+// Drops empty arrays and undefined/null fields, then sorts keys. The legacy
+// class's toJson() omits optional fields whose value is the empty array
+// (e.g. fallbacks: []); the Zod schema includes them because of .default([]).
+// Both represent the same wire state, so we treat them as equivalent here.
+function canonicalize(value: unknown): string {
+  function normalize(v: unknown): unknown {
+    if (Array.isArray(v)) {
+      const items = v.map(normalize).filter((x) => x !== undefined);
+      return items.length === 0 ? undefined : items;
+    }
+    if (v && typeof v === 'object') {
+      const entries = Object.entries(v as Record<string, unknown>)
+        .map(([k, val]) => [k, normalize(val)] as const)
+        .filter(([, val]) => val !== undefined && val !== null)
+        .sort(([a], [b]) => a.localeCompare(b));
+      return entries.length === 0 ? undefined : Object.fromEntries(entries);
+    }
+    return v;
+  }
+  return JSON.stringify(normalize(value) ?? null);
+}
+
+function fixtureName(path: string): string {
+  const file = path.split('/').pop() ?? path;
+  return file.replace(/\.json$/, '');
+}
+
+describe('shadow parse: legacy class vs Zod schema', () => {
+  const entries = Object.entries(fixtures).sort(([a], [b]) => a.localeCompare(b));
+
+  for (const [path, raw] of entries) {
+    const fixture = raw as FixtureShape;
+    const name = fixtureName(path);
+
+    it(`${name}: legacy toJson() and Zod parse converge`, () => {
+      const legacyInstance = Inbound.Settings.fromJson(
+        legacyProtocolFor(fixture.protocol),
+        fixture.settings,
+      );
+      expect(legacyInstance, `legacy dispatch returned null for ${fixture.protocol}`).not.toBeNull();
+      const legacyJson = legacyInstance.toJson();
+
+      const zodParsed = InboundSettingsSchema.parse(fixture);
+
+      expect(canonicalize(zodParsed.settings)).toBe(canonicalize(legacyJson));
+    });
+  }
+});