Initial Commit
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
# `runBackendConformance`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`runBackendConformance` is the shared, runner-neutral conformance
|
||||
harness every concrete `@suaveplan/i18n-backend-*` package runs
|
||||
against its `Backend` implementation. It exists so the universal
|
||||
contract — name + capabilities surface, read-of-absent → typed
|
||||
`I18nError`, optional `readMulti` merge, optional `save` round-trip,
|
||||
optional `lastModified` monotonicity — gets one canonical test body
|
||||
that every backend re-uses verbatim. New backends inherit the
|
||||
guarantees automatically; no copy-pasted test code drifting between
|
||||
implementations.
|
||||
|
||||
The harness lives under the `@suaveplan/i18n-shared/testing` subpath
|
||||
so backend test files can import it without pulling the full i18n
|
||||
core or any other backend. It deliberately uses
|
||||
`@suaveplan/testing/runner` so it executes identically under Vitest
|
||||
and Bun's test runner.
|
||||
|
||||
## Features
|
||||
|
||||
- One `describe(...)` per backend, one `it(...)` per universal
|
||||
contract clause.
|
||||
- Capability-gated assertions: `readMulti` / `save` /
|
||||
`lastModified` tests early-return for backends that do not declare
|
||||
the corresponding capability flag, keeping the suite green for
|
||||
read-only backends like HTTP.
|
||||
- Optional `seed` adapter on the factory return so backends with
|
||||
`save` can pre-populate without exposing internals; backends
|
||||
without `save` (HTTP-style) seed via their constructor options
|
||||
instead.
|
||||
- Runner-neutral: imports `describe` / `it` / `expect` from
|
||||
`@suaveplan/testing/runner`, so the same harness runs under
|
||||
Vitest or Bun's test runner without modification.
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { runBackendConformance } from "@suaveplan/i18n-shared/testing";
|
||||
import { MemoryBackend } from "@suaveplan/i18n-backend-memory";
|
||||
|
||||
runBackendConformance("MemoryBackend", async () => {
|
||||
const backend = new MemoryBackend();
|
||||
return {
|
||||
backend,
|
||||
async seed(locale, namespace, record) {
|
||||
await backend.save(locale, namespace, record as never);
|
||||
},
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
A read-only backend (no `save`, no `readMulti`) supplies its own
|
||||
seeding via constructor options and omits `seed` from the factory
|
||||
return. The harness inspects `backend.capabilities` and the
|
||||
structural presence of optional methods to decide which assertions
|
||||
to run; unsupported assertions exit early.
|
||||
|
||||
```ts
|
||||
import { runBackendConformance } from "@suaveplan/i18n-shared/testing";
|
||||
import { httpBackend } from "@suaveplan/i18n-backend-http";
|
||||
|
||||
runBackendConformance("HttpBackend (seeded fetch)", async () => {
|
||||
const backend = httpBackend({
|
||||
loadPath: "https://i18n.example/{{locale}}/{{ns}}.json",
|
||||
fetch: async () =>
|
||||
new Response(JSON.stringify({ login: { title: "Sign in" } })),
|
||||
});
|
||||
return { backend };
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `runBackendConformance(label, makeFactory)`
|
||||
|
||||
- `label: string` — Human-readable backend label inserted into the
|
||||
outer `describe(...)` title.
|
||||
- `makeFactory: () => Promise<ConformanceContext>` — Async factory
|
||||
invoked once per `it(...)`; produces a fresh backend instance and
|
||||
optional `seed` callback. Always producing a fresh backend per
|
||||
call prevents cross-test state leakage.
|
||||
- Returns: `void`. The harness registers `describe` / `it` blocks
|
||||
with the active runner; assertions execute when the runner runs
|
||||
the file.
|
||||
|
||||
### `ConformanceContext`
|
||||
|
||||
```ts
|
||||
interface ConformanceContext {
|
||||
readonly backend: Backend; // from @suaveplan/types
|
||||
readonly seed?: (
|
||||
locale: string,
|
||||
namespace: string,
|
||||
record: Record<string, unknown>
|
||||
) => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
### `ConformanceFactory`
|
||||
|
||||
`type ConformanceFactory = () => Promise<ConformanceContext>;`
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The harness gates each capability check on **both** the structural
|
||||
presence of the method (`backend.save`) **and** the corresponding
|
||||
capability flag (`backend.capabilities.canSave`). This catches
|
||||
backends whose flags drift out of sync with their methods at
|
||||
authoring time and keeps the harness honest for partial
|
||||
implementations.
|
||||
- The harness imports the `Backend` interface from
|
||||
`@suaveplan/types`, never from `@suaveplan/i18n` core. Backends
|
||||
consume the same type from the same location, so the harness adds
|
||||
zero coupling to the slim core.
|
||||
- `lastModified` monotonicity uses a 2-ms `setTimeout` between the
|
||||
two `save` calls to defend against backends whose injected clock
|
||||
has 1-ms resolution. Backends with deterministic counters always
|
||||
satisfy `t2 >= t1`.
|
||||
- This harness is the Wave 5 sequencing keystone: the five backend
|
||||
packages (`memory`, `http`, `storage`, `fs-multifile`, `chained`)
|
||||
all call it. Lifting it here lets every backend land independently
|
||||
without copy-pasting test fixtures.
|
||||
@@ -0,0 +1,221 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import type { Backend, TranslationRecord } from "@suaveplan/types";
|
||||
import { I18N_CODES } from "../errors/error-codes.js";
|
||||
import { I18nError } from "../errors/i18n-error.js";
|
||||
import { runBackendConformance } from "./conformance.js";
|
||||
import type { ConformanceContext } from "./conformance.types.js";
|
||||
|
||||
/**
|
||||
* Inline full-featured mock backend exercising every capability path
|
||||
* the harness inspects. Reused across the harness self-tests below.
|
||||
*/
|
||||
class MockFullBackend implements Backend {
|
||||
public readonly name = "mock-full";
|
||||
public readonly capabilities = {
|
||||
canRead: true as const,
|
||||
canReadMulti: true,
|
||||
canSave: true,
|
||||
canTrackModification: true,
|
||||
canWatch: false,
|
||||
};
|
||||
readonly #data = new Map<string, Map<string, TranslationRecord>>();
|
||||
readonly #modified = new Map<string, number>();
|
||||
#clock = 1000;
|
||||
|
||||
public async read(
|
||||
locale: string,
|
||||
namespace: string
|
||||
): Promise<TranslationRecord> {
|
||||
const ns = this.#data.get(locale)?.get(namespace);
|
||||
if (ns === undefined) {
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE,
|
||||
`mock: no record for ${locale}/${namespace}`,
|
||||
{ context: { locale, namespace, backend: this.name } }
|
||||
);
|
||||
}
|
||||
return ns;
|
||||
}
|
||||
|
||||
public async readMulti(
|
||||
locale: string,
|
||||
namespaces: readonly string[]
|
||||
): Promise<Record<string, TranslationRecord>> {
|
||||
const out: Record<string, TranslationRecord> = {};
|
||||
const byLocale = this.#data.get(locale);
|
||||
if (!byLocale) return out;
|
||||
for (const ns of namespaces) {
|
||||
const r = byLocale.get(ns);
|
||||
if (r !== undefined) out[ns] = r;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public async save(
|
||||
locale: string,
|
||||
namespace: string,
|
||||
record: TranslationRecord
|
||||
): Promise<void> {
|
||||
let byNs = this.#data.get(locale);
|
||||
if (!byNs) {
|
||||
byNs = new Map();
|
||||
this.#data.set(locale, byNs);
|
||||
}
|
||||
byNs.set(namespace, record);
|
||||
this.#modified.set(`${locale} ${namespace}`, this.#clock++);
|
||||
}
|
||||
|
||||
public async lastModified(
|
||||
locale: string,
|
||||
namespace: string
|
||||
): Promise<number | undefined> {
|
||||
return this.#modified.get(`${locale} ${namespace}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only mock — `canRead: true`, every other flag `false`. Used to
|
||||
* exercise the harness's capability-gating branches (early returns
|
||||
* for save / readMulti / lastModified).
|
||||
*/
|
||||
class MockReadOnlyBackend implements Backend {
|
||||
public readonly name = "mock-readonly";
|
||||
public readonly capabilities = {
|
||||
canRead: true as const,
|
||||
canReadMulti: false,
|
||||
canSave: false,
|
||||
canTrackModification: false,
|
||||
canWatch: false,
|
||||
};
|
||||
|
||||
public async read(
|
||||
locale: string,
|
||||
namespace: string
|
||||
): Promise<TranslationRecord> {
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE,
|
||||
`mock-readonly: no record for ${locale}/${namespace}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Capability-mismatch mock — declares methods are present but flags
|
||||
* them off. Exercises the second disjunct of every capability gate
|
||||
* (`backend.<method>` truthy AND `capabilities.canX === false` →
|
||||
* early return).
|
||||
*/
|
||||
class MockCapabilityMismatchBackend implements Backend {
|
||||
public readonly name = "mock-mismatch";
|
||||
public readonly capabilities = {
|
||||
canRead: true as const,
|
||||
canReadMulti: false,
|
||||
canSave: false,
|
||||
canTrackModification: false,
|
||||
canWatch: false,
|
||||
};
|
||||
|
||||
public async read(
|
||||
locale: string,
|
||||
namespace: string
|
||||
): Promise<TranslationRecord> {
|
||||
throw new I18nError(
|
||||
I18N_CODES.BACKEND_LOCALE_NOT_AVAILABLE,
|
||||
`mock-mismatch: no record for ${locale}/${namespace}`
|
||||
);
|
||||
}
|
||||
|
||||
public async readMulti(
|
||||
_locale: string,
|
||||
_namespaces: readonly string[]
|
||||
): Promise<Record<string, TranslationRecord>> {
|
||||
return {};
|
||||
}
|
||||
|
||||
public async save(
|
||||
_locale: string,
|
||||
_namespace: string,
|
||||
_record: TranslationRecord
|
||||
): Promise<void> {
|
||||
// no-op
|
||||
}
|
||||
|
||||
public async lastModified(
|
||||
_locale: string,
|
||||
_namespace: string
|
||||
): Promise<number | undefined> {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Run the harness against the full-featured mock — exercises every
|
||||
// "supported" branch. Registered at module load so vitest picks it up.
|
||||
|
||||
runBackendConformance(
|
||||
"MockFullBackend",
|
||||
async (): Promise<ConformanceContext> => {
|
||||
const backend = new MockFullBackend();
|
||||
return {
|
||||
backend,
|
||||
async seed(locale, namespace, record) {
|
||||
await backend.save(locale, namespace, record as never);
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ── Run the harness against the read-only mock — exercises every
|
||||
// "not supported" early-return branch (no seed, no save, no
|
||||
// readMulti, no lastModified).
|
||||
|
||||
runBackendConformance(
|
||||
"MockReadOnlyBackend",
|
||||
async (): Promise<ConformanceContext> => ({
|
||||
backend: new MockReadOnlyBackend(),
|
||||
})
|
||||
);
|
||||
|
||||
// ── Mismatch mock with seed: methods exist but capability flags off
|
||||
// — exercises the second disjunct of each capability gate.
|
||||
|
||||
runBackendConformance(
|
||||
"MockCapabilityMismatchBackend",
|
||||
async (): Promise<ConformanceContext> => ({
|
||||
backend: new MockCapabilityMismatchBackend(),
|
||||
})
|
||||
);
|
||||
|
||||
// ── Full backend whose capability flags are all flipped off — this
|
||||
// exercises the second disjunct of every capability gate (the
|
||||
// `!ctx.backend.capabilities.canX` short-circuit) when the seed
|
||||
// callback IS present.
|
||||
|
||||
class MockFlagsOffBackend extends MockFullBackend {
|
||||
public override readonly capabilities = {
|
||||
canRead: true as const,
|
||||
canReadMulti: false,
|
||||
canSave: false,
|
||||
canTrackModification: false,
|
||||
canWatch: false,
|
||||
};
|
||||
}
|
||||
|
||||
runBackendConformance(
|
||||
"MockFlagsOffBackend",
|
||||
async (): Promise<ConformanceContext> => {
|
||||
const backend = new MockFlagsOffBackend();
|
||||
return {
|
||||
backend,
|
||||
async seed(locale, namespace, record) {
|
||||
await backend.save(locale, namespace, record as never);
|
||||
},
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
describe("runBackendConformance", () => {
|
||||
it("is a callable function", { timeout: 5000 }, () => {
|
||||
expect(typeof runBackendConformance).toBe("function");
|
||||
expect(runBackendConformance.length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { I18nError } from "../errors/i18n-error.js";
|
||||
import type { ConformanceFactory } from "./conformance.types.js";
|
||||
|
||||
/**
|
||||
* Run the universal `Backend` interface contract against a factory.
|
||||
*
|
||||
* Every concrete backend in the `@suaveplan/i18n-backend-*` family
|
||||
* MUST call this from its own test file with a factory that produces
|
||||
* a fresh backend per call. The harness covers the universal contract
|
||||
* (name + capabilities surface, read of an absent record, read of a
|
||||
* seeded record, optional readMulti merge, optional save round-trip,
|
||||
* optional lastModified monotonicity) — per-backend specifics live
|
||||
* in their own test files.
|
||||
*
|
||||
* Capability gating: the harness inspects `backend.capabilities` and
|
||||
* the structural presence of `readMulti` / `save` / `lastModified`
|
||||
* to decide which assertions apply. Backends that do not support a
|
||||
* capability silently skip the corresponding assertion (it still
|
||||
* runs, but exits early), keeping the suite green for read-only
|
||||
* backends like HTTP.
|
||||
*
|
||||
* @param label - Human-readable backend label inserted into the
|
||||
* `describe(...)` block title. Conventionally the class name.
|
||||
* @param makeFactory - Async factory producing a fresh
|
||||
* {@link ConformanceContext} per invocation.
|
||||
*/
|
||||
export function runBackendConformance(
|
||||
label: string,
|
||||
makeFactory: ConformanceFactory
|
||||
): void {
|
||||
describe(`Backend conformance: ${label}`, () => {
|
||||
it("exposes a non-empty name", { timeout: 5000 }, async () => {
|
||||
const { backend } = await makeFactory();
|
||||
expect(typeof backend.name).toBe("string");
|
||||
expect(backend.name.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("exposes capabilities with canRead === true", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { backend } = await makeFactory();
|
||||
expect(backend.capabilities.canRead).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes capabilities with the full 5-flag matrix", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { backend } = await makeFactory();
|
||||
const c = backend.capabilities;
|
||||
expect(typeof c.canRead).toBe("boolean");
|
||||
expect(typeof c.canReadMulti).toBe("boolean");
|
||||
expect(typeof c.canSave).toBe("boolean");
|
||||
expect(typeof c.canTrackModification).toBe("boolean");
|
||||
expect(typeof c.canWatch).toBe("boolean");
|
||||
});
|
||||
|
||||
it("read(absent) rejects with BACKEND_LOCALE_NOT_AVAILABLE", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { backend } = await makeFactory();
|
||||
await expect(backend.read("xx", "missing")).rejects.toBeInstanceOf(
|
||||
I18nError
|
||||
);
|
||||
await expect(backend.read("xx", "missing")).rejects.toMatchObject({
|
||||
code: "I18N.BACKEND_LOCALE_NOT_AVAILABLE",
|
||||
});
|
||||
});
|
||||
|
||||
it("read(seeded) resolves to the seeded record", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const ctx = await makeFactory();
|
||||
if (!ctx.seed) return;
|
||||
await ctx.seed("en", "auth", { login: { title: "Sign in" } });
|
||||
const record = await ctx.backend.read("en", "auth");
|
||||
expect(record).toEqual({ login: { title: "Sign in" } });
|
||||
});
|
||||
|
||||
it("readMulti merges when supported", { timeout: 5000 }, async () => {
|
||||
const ctx = await makeFactory();
|
||||
if (!ctx.backend.readMulti || !ctx.seed) return;
|
||||
if (!ctx.backend.capabilities.canReadMulti) return;
|
||||
await ctx.seed("en", "auth", { ok: "Ok" });
|
||||
await ctx.seed("en", "common", { cancel: "Cancel" });
|
||||
const result = await ctx.backend.readMulti("en", [
|
||||
"auth",
|
||||
"common",
|
||||
]);
|
||||
expect(result).toEqual({
|
||||
auth: { ok: "Ok" },
|
||||
common: { cancel: "Cancel" },
|
||||
});
|
||||
});
|
||||
|
||||
it("save then read round-trips when supported", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { backend } = await makeFactory();
|
||||
if (!backend.save || !backend.capabilities.canSave) return;
|
||||
await backend.save("en", "auth", { login: { title: "Sign in" } });
|
||||
const record = await backend.read("en", "auth");
|
||||
expect(record).toEqual({ login: { title: "Sign in" } });
|
||||
});
|
||||
|
||||
it("lastModified increases monotonically when supported", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { backend } = await makeFactory();
|
||||
if (!backend.save || !backend.lastModified) return;
|
||||
if (!backend.capabilities.canTrackModification) return;
|
||||
await backend.save("en", "auth", { a: "1" });
|
||||
const t1 = await backend.lastModified("en", "auth");
|
||||
expect(t1).toBeDefined();
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
await backend.save("en", "auth", { a: "2" });
|
||||
const t2 = await backend.lastModified("en", "auth");
|
||||
expect(t2).toBeDefined();
|
||||
expect(t2 as number).toBeGreaterThanOrEqual(t1 as number);
|
||||
});
|
||||
|
||||
it("lastModified(absent) returns undefined when supported", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const { backend } = await makeFactory();
|
||||
if (!backend.lastModified) return;
|
||||
if (!backend.capabilities.canTrackModification) return;
|
||||
const t = await backend.lastModified("xx", "missing");
|
||||
expect(t).toBeUndefined();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Backend } from "@suaveplan/types";
|
||||
|
||||
/**
|
||||
* Context returned by a {@link ConformanceFactory}. The harness calls
|
||||
* the factory once per assertion to produce a fresh, empty backend
|
||||
* along with an optional `seed` adapter that pre-populates the backend
|
||||
* with a record at a given `(locale, namespace)` slot.
|
||||
*
|
||||
* Backends that lack `save` (HTTP-style read-only sources) supply
|
||||
* their own seeding through their factory's options instead and omit
|
||||
* the `seed` field — the harness skips read-after-seed assertions
|
||||
* that depend on it.
|
||||
*/
|
||||
export interface ConformanceContext {
|
||||
readonly backend: Backend;
|
||||
readonly seed?: (
|
||||
locale: string,
|
||||
namespace: string,
|
||||
record: Record<string, unknown>
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory signature for {@link runBackendConformance}. The harness
|
||||
* invokes this once per `it(...)` so the backend instance under test
|
||||
* is always fresh — no cross-test state leakage.
|
||||
*/
|
||||
export type ConformanceFactory = () => Promise<ConformanceContext>;
|
||||
@@ -0,0 +1,7 @@
|
||||
// Explicit named re-exports — no `export *` per CLAUDE.md §10.
|
||||
|
||||
export { runBackendConformance } from "./conformance.js";
|
||||
export type {
|
||||
ConformanceContext,
|
||||
ConformanceFactory,
|
||||
} from "./conformance.types.js";
|
||||
Reference in New Issue
Block a user