Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,9 @@ node_modules
.env.test.local
.env.production.local

# Personal, per-developer instructions — never committed
# Personal, per-developer instructions and working notes — never committed
CLAUDE.local.md
*.local.md

# Testing
coverage
Expand Down
5 changes: 5 additions & 0 deletions apps/dev/src/collections/Playground.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { DeepNestBlock } from "../blocks/DeepNest";
// The top-level `layout` blocks field is intentionally NOT localized (the leaves inside DeepNest
// are) — see blocks/DeepNest.ts for the rationale. Create a doc, fill the fields in `en`, save,
// switch locale, then use the per-field translate control at any depth.
// Drafts are on here and nowhere else with this depth: `pages` has versions but a flat
// shape, so this is the only place where an unpublished draft meets nested blocks.
export const Playground: CollectionConfig = {
slug: "playground",
admin: {
Expand All @@ -25,4 +27,7 @@ export const Playground: CollectionConfig = {
type: "blocks",
},
],
versions: {
drafts: true,
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { TestPayload } from "./bootTestPayload";
// (multiple boots per process collide on Payload's module singletons). "xx" is not a configured
// locale → dropped at config time with a warning; "de" still translates.

const rev = (s: string) => [...s].reverse().join("");
const tr = (locale: string, s: string) => (s.trim() ? `${locale}:${s}` : s);
const PROVENANCE = "translator-provenance";

describe("auto-translate — unknown target locale dropped", () => {
Expand All @@ -27,7 +27,7 @@ describe("auto-translate — unknown target locale dropped", () => {
});
const id = String(created.id);
const de = await ctx.payload.findByID({ collection: "docs", id, locale: "de" });
expect(de.title).toBe(rev("Unknown-locale src"));
expect(de.title).toBe(tr("de", "Unknown-locale src"));
const records = await ctx.payload.find({
collection: PROVENANCE,
where: { documentId: { equals: id } },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { TestPayload } from "./bootTestPayload";
// R5 — auto-translate (#51), one dedicated case per behavior. Trigger is the real afterChange hook:
// publishing source-locale content runs the sync pipeline inline and writes the targets.

const rev = (s: string) => [...s].reverse().join("");
const tr = (locale: string, s: string) => (s.trim() ? `${locale}:${s}` : s);
const PROVENANCE = "translator-provenance";

const provenanceFor = async (ctx: TestPayload, id: string) => {
Expand Down Expand Up @@ -36,8 +36,8 @@ describe("auto-translate — targets de, fr", () => {
const id = String(created.id);
const de = await ctx.payload.findByID({ collection: "docs", id, locale: "de" });
const fr = await ctx.payload.findByID({ collection: "docs", id, locale: "fr" });
expect(de.title).toBe(rev("Auto src"));
expect(fr.title).toBe(rev("Auto src"));
expect(de.title).toBe(tr("de", "Auto src"));
expect(fr.title).toBe(tr("fr", "Auto src"));
});

it("publish-gate: a draft (unpublished) save is NOT auto-translated", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { callEndpoint } from "./callEndpoint";
// without cross-contaminating leaves or corrupting an already-translated locale, and skip_existing
// respects per-block edits — matched by id even when only some blocks are filled ("partially differ").

const rev = (s: string) => [...s].reverse().join("");
const tr = (locale: string, s: string) => (s.trim() ? `${locale}:${s}` : s);

type Block = { id?: string; blockType: string; heading?: string; caption?: string };

Expand Down Expand Up @@ -65,7 +65,11 @@ describe("blocks — id-based pairing, ordering, partial differences", () => {
await enqueue(ctx, id, "de");

const de = await sectionsOf(ctx, id, "de");
expect(de.map((b) => b.heading)).toEqual([rev("Alpha"), rev("Bravo"), rev("Charlie")]);
expect(de.map((b) => b.heading)).toEqual([
tr("de", "Alpha"),
tr("de", "Bravo"),
tr("de", "Charlie"),
]);
});

it("re-mirrors SOURCE order on reorder+edit, without corrupting an already-translated locale", async () => {
Expand Down Expand Up @@ -112,9 +116,9 @@ describe("blocks — id-based pairing, ordering, partial differences", () => {
const de = await sectionsOf(ctx, id, "de");
expect(de.map((b) => b.id)).toEqual([three.id, one.id, two.id]); // ids stable, reordered
expect(de.map((b) => b.heading ?? b.caption)).toEqual([
rev("Three"),
rev("One EDITED"),
rev("Two"),
tr("de", "Three"),
tr("de", "One EDITED"),
tr("de", "Two"),
]);

// Source survives the reorder+re-translate.
Expand All @@ -125,7 +129,11 @@ describe("blocks — id-based pairing, ordering, partial differences", () => {
// previously-translated leaves are intact (not wiped by the DE pass) — old "One" translation kept.
const fr = await sectionsOf(ctx, id, "fr");
expect(fr.map((b) => b.id)).toEqual([three.id, one.id, two.id]);
expect(fr.map((b) => b.heading ?? b.caption)).toEqual([rev("Three"), rev("One"), rev("Two")]);
expect(fr.map((b) => b.heading ?? b.caption)).toEqual([
tr("fr", "Three"),
tr("fr", "One"),
tr("fr", "Two"),
]);
});

it("skip_existing fills empty block leaves but keeps a manually-edited one (matched by id)", async () => {
Expand Down Expand Up @@ -163,6 +171,6 @@ describe("blocks — id-based pairing, ordering, partial differences", () => {

const de = await sectionsOf(ctx, id, "de");
// Empty siblings filled from source; the manually-edited middle block kept — paired by id.
expect(de.map((b) => b.heading)).toEqual([rev("Src A"), "MANUAL B", rev("Src C")]);
expect(de.map((b) => b.heading)).toEqual([tr("de", "Src A"), "MANUAL B", tr("de", "Src C")]);
});
});
29 changes: 24 additions & 5 deletions apps/dev/src/integration/translator/bootTestPayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
createTranslationProvider,
createSyncRunner,
documentLevel,
fieldLevel,
translatorPlugin,
withAutoTranslate,
} from "@focus-reactive/payload-plugin-translator";
Expand All @@ -19,7 +20,8 @@ import type { CollectionConfig, Payload } from "payload";
import { getPayload } from "payload";

import { createTestDatabase } from "../../lib/database/resolveAdapter";
import { reverseComplete } from "../../lib/translator/fakeComplete";
import { fakeComplete } from "../../lib/translator/fakeComplete";
import type { FakeTranslationOptions } from "../../lib/translator/fakeComplete";
import { buildTestCollections } from "./testCollections";

/** Payload's `autoRun.limit` default — these specs reproduce the cron's batching, not a run of one. */
Expand Down Expand Up @@ -83,6 +85,17 @@ export async function bootTestPayload(opts?: {
failFor?: string[];
onTranslate?: (targetLng: string) => Promise<void> | void;
runner?: TaskRunnerProvider;
/** Turn on container-granular rich-text translation, and declare the provider able to keep marks. */
inlineMarks?: boolean;
/**
* Whether the provider declares `capabilities.inlineMarks`. Defaults to `inlineMarks`; set it to
* `false` with the flag on to stand in for a third-party provider that cannot keep marks.
*/
declareCapability?: boolean;
/** How the fake answers a marked value — reorder by default, keep order, or corrupt it. */
fake?: FakeTranslationOptions;
/** Also register the synchronous per-field surface, `POST {basePath}/field`. */
fieldSurface?: boolean;
}): Promise<TestPayload> {
const dir = mkdtempSync(join(tmpdir(), "translator-int-"));
const { db, drop } = createTestDatabase(join(dir, "test.db"));
Expand All @@ -96,15 +109,20 @@ export async function bootTestPayload(opts?: {
? collections.map((c) => (c.slug === "docs" ? withAutoTranslate(c, autoTranslate) : c))
: collections;

const baseProvider = createTranslationProvider({ complete: reverseComplete });
const declaresMarks = opts?.declareCapability ?? opts?.inlineMarks ?? false;
const baseProvider = createTranslationProvider({
complete: fakeComplete(opts?.fake),
...(declaresMarks ? { capabilities: { inlineMarks: true } } : {}),
});
const failFor = new Set(opts?.failFor);
let translateCalls = 0;
const countingProvider: TranslationProvider = {
translate: async (input, sourceLng, targetLng) => {
...(declaresMarks ? { capabilities: { inlineMarks: true } } : {}),
translate: async (input, sourceLng, targetLng, options) => {
translateCalls += 1;
await opts?.onTranslate?.(targetLng);
if (failFor.has(targetLng)) throw new Error(`provider unavailable for ${targetLng}`);
return await baseProvider.translate(input, sourceLng, targetLng);
return await baseProvider.translate(input, sourceLng, targetLng, options);
},
};

Expand Down Expand Up @@ -139,8 +157,9 @@ export async function bootTestPayload(opts?: {
collections: managed,
translationProvider: countingProvider,
runner: opts?.runner ?? createSyncRunner(),
levels: [documentLevel()],
levels: opts?.fieldSurface ? [documentLevel(), fieldLevel()] : [documentLevel()],
provenance: true,
...(opts?.inlineMarks ? { experimental: { inlineMarks: true } } : {}),
}),
],
});
Expand Down
72 changes: 39 additions & 33 deletions apps/dev/src/integration/translator/data-integrity.int.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { callEndpoint } from "./callEndpoint";
// 3. non-localized data inside a shared row survives in every locale,
// 4. re-translating the same locale is non-destructive.

const rev = (s: string) => [...s].reverse().join("");
const tr = (locale: string, s: string) => (s.trim() ? `${locale}:${s}` : s);

type Block = {
id?: string;
Expand Down Expand Up @@ -99,13 +99,13 @@ describe("data integrity — translation never destroys content", () => {
// DE is fully populated before the FR pass.
const deBefore = await read(ctx, id, "de");
expect((deBefore.sections as Block[]).map((b) => b.heading ?? b.caption)).toEqual([
rev("Hero one"),
rev("Cta two"),
rev("Hero three"),
tr("de", "Hero one"),
tr("de", "Cta two"),
tr("de", "Hero three"),
]);
expect((deBefore.items as Item[]).map((i) => i.label)).toEqual([
rev("Item one"),
rev("Item two"),
tr("de", "Item one"),
tr("de", "Item two"),
]);

// Translate a second locale — this is what deleted+recreated the shared rows under the bug.
Expand All @@ -114,11 +114,14 @@ describe("data integrity — translation never destroys content", () => {
// DE must be UNCHANGED (the bug wiped it here).
const de = await read(ctx, id, "de");
expect((de.sections as Block[]).map((b) => b.heading ?? b.caption)).toEqual([
rev("Hero one"),
rev("Cta two"),
rev("Hero three"),
tr("de", "Hero one"),
tr("de", "Cta two"),
tr("de", "Hero three"),
]);
expect((de.items as Item[]).map((i) => i.label)).toEqual([
tr("de", "Item one"),
tr("de", "Item two"),
]);
expect((de.items as Item[]).map((i) => i.label)).toEqual([rev("Item one"), rev("Item two")]);

// Source (EN) intact; FR populated.
const en = await read(ctx, id, "en");
Expand All @@ -128,7 +131,10 @@ describe("data integrity — translation never destroys content", () => {
"Hero three",
]);
const fr = await read(ctx, id, "fr");
expect((fr.items as Item[]).map((i) => i.label)).toEqual([rev("Item one"), rev("Item two")]);
expect((fr.items as Item[]).map((i) => i.label)).toEqual([
tr("fr", "Item one"),
tr("fr", "Item two"),
]);
});

it("keeps block/array ids stable across translation (in-place update, no recreate)", async () => {
Expand All @@ -153,9 +159,9 @@ describe("data integrity — translation never destroys content", () => {
// Everything above is an ABSENCE of change, which a run that translated nothing satisfies just
// as well. The claim is "translated in place", so the run has to be shown doing the translating.
expect((de.sections as Block[]).map((b) => b.heading ?? b.caption)).toEqual([
rev("Hero one"),
rev("Cta two"),
rev("Hero three"),
tr("de", "Hero one"),
tr("de", "Cta two"),
tr("de", "Hero three"),
]);
});

Expand All @@ -177,9 +183,9 @@ describe("data integrity — translation never destroys content", () => {
// it the case says only "nothing changed", which is true of a run that did nothing.
const de = await read(ctx, id, "de");
expect((de.sections as Block[]).map((b) => b.heading ?? b.caption)).toEqual([
rev("Hero one"),
rev("Cta two"),
rev("Hero three"),
tr("de", "Hero one"),
tr("de", "Cta two"),
tr("de", "Hero three"),
]);
});

Expand Down Expand Up @@ -233,10 +239,10 @@ describe("data integrity — translation never destroys content", () => {
// Positive control: "nothing was deleted" is also what a pipeline that did nothing produces.
const de = await readDraft(ctx, id, "de");
expect(headings(de)).toEqual([
rev("Hero one"),
rev("Cta two"),
rev("Hero three"),
rev("Draft-only hero"),
tr("de", "Hero one"),
tr("de", "Cta two"),
tr("de", "Hero three"),
tr("de", "Draft-only hero"),
]);
});

Expand All @@ -250,8 +256,8 @@ describe("data integrity — translation never destroys content", () => {
"Hero three",
]);
expect(headings(await readDraft(ctx, id, "de"))).toEqual([
rev("Hero one"),
rev("Hero three"),
tr("de", "Hero one"),
tr("de", "Hero three"),
]);
});

Expand All @@ -266,9 +272,9 @@ describe("data integrity — translation never destroys content", () => {
"Hero one",
]);
expect(headings(await readDraft(ctx, id, "de"))).toEqual([
rev("Hero three"),
rev("Cta two"),
rev("Hero one"),
tr("de", "Hero three"),
tr("de", "Cta two"),
tr("de", "Hero one"),
]);
});

Expand All @@ -289,10 +295,10 @@ describe("data integrity — translation never destroys content", () => {
"Draft-only hero",
]);
expect(headings(await read(ctx, id, "de")), "the locale did not go live").toEqual([
rev("Hero one"),
rev("Cta two"),
rev("Hero three"),
rev("Draft-only hero"),
tr("de", "Hero one"),
tr("de", "Cta two"),
tr("de", "Hero three"),
tr("de", "Draft-only hero"),
]);
});
});
Expand All @@ -305,9 +311,9 @@ describe("data integrity — translation never destroys content", () => {
// Pin what the first run PRODUCED before comparing the second to it: "both runs agree" is
// satisfied by "both runs produced nothing".
expect((first.sections as Block[]).map((b) => b.heading ?? b.caption)).toEqual([
rev("Hero one"),
rev("Cta two"),
rev("Hero three"),
tr("de", "Hero one"),
tr("de", "Cta two"),
tr("de", "Hero three"),
]);

await enqueue(ctx, id, "de"); // run it again
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { callEndpoint } from "./callEndpoint";
// nesting container + a non-localized field, with >=2 blocks and >=2 array items so id reconciliation
// (the c0a49d1b failure mode) is exercised and the source-not-wiped lock is meaningful.

const rev = (s: string) => [...s].reverse().join("");
const tr = (locale: string, s: string) => (s.trim() ? `${locale}:${s}` : s);

const EN = {
_status: "published" as const,
Expand Down Expand Up @@ -56,30 +56,30 @@ describe("document translation (manual enqueue, en -> de/fr)", () => {

it("translates every localized leaf across group / array / blocks / tabs into de", async () => {
const de = await ctx.payload.findByID({ collection: "docs", id, locale: "de" });
expect(de.title).toBe(rev("Title source"));
expect((de.meta as { subtitle: string }).subtitle).toBe(rev("Subtitle source"));
expect(de.title).toBe(tr("de", "Title source"));
expect((de.meta as { subtitle: string }).subtitle).toBe(tr("de", "Subtitle source"));
const items = de.items as { label: string }[];
expect(items.map((i) => i.label)).toEqual([rev("Item one"), rev("Item two")]);
expect(items.map((i) => i.label)).toEqual([tr("de", "Item one"), tr("de", "Item two")]);
const sections = de.sections as { blockType: string; heading?: string; caption?: string }[];
expect(sections.map((b) => b.heading ?? b.caption)).toEqual([
rev("Hero one"),
rev("Cta text"),
rev("Hero two"),
tr("de", "Hero one"),
tr("de", "Cta text"),
tr("de", "Hero two"),
]);
expect((de.seo as { seoTitle: string }).seoTitle).toBe(rev("Seo source"));
expect(de.note).toBe(rev("Note source"));
expect((de.seo as { seoTitle: string }).seoTitle).toBe(tr("de", "Seo source"));
expect(de.note).toBe(tr("de", "Note source"));
});

it("populates fr as well (both configured targets)", async () => {
const fr = await ctx.payload.findByID({ collection: "docs", id, locale: "fr" });
expect(fr.title).toBe(rev("Title source"));
expect((fr.seo as { seoTitle: string }).seoTitle).toBe(rev("Seo source"));
expect(fr.title).toBe(tr("fr", "Title source"));
expect((fr.seo as { seoTitle: string }).seoTitle).toBe(tr("fr", "Seo source"));
});

it("translates localized fields but leaves non-localized ones untouched", async () => {
const de = await ctx.payload.findByID({ collection: "docs", id, locale: "de" });

expect(de.title).toBe(rev(EN.title));
expect(de.title).toBe(tr("de", EN.title));
expect(de.ref).toBe("REF-123");
expect((de.meta as { sku: string }).sku).toBe("SKU-9");
});
Expand Down
Loading
Loading