Add proposal application fixtures
This commit is contained in:
172
internal/framework/proposals/apply_fixtures_test.go
Normal file
172
internal/framework/proposals/apply_fixtures_test.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package proposals
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/audita/internal/core/schema"
|
||||
)
|
||||
|
||||
type applyFixture struct {
|
||||
Policy string `json:"policy"`
|
||||
Transcript schema.Transcript `json:"transcript"`
|
||||
Proposals []EnrichedCorrectionProposal `json:"proposals"`
|
||||
}
|
||||
|
||||
type recordsPayload struct {
|
||||
AppliedChanges []AppliedChange `json:"applied_changes"`
|
||||
SkippedChanges []SkippedChange `json:"skipped_changes"`
|
||||
}
|
||||
|
||||
func TestApplyProposalsFixtureTranscriptGoldens(t *testing.T) {
|
||||
testCases := []string{
|
||||
"simple_replacement",
|
||||
"multiple_replacements_one_segment",
|
||||
"multiple_proposals_one_segment",
|
||||
"replace_all_behavior",
|
||||
"categories_metadata_preservation",
|
||||
}
|
||||
|
||||
for _, name := range testCases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
fixture := readApplyFixture(t, name+".input.json")
|
||||
policy, err := ParseReplacementPolicy(fixture.Policy)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse fixture policy: %v", err)
|
||||
}
|
||||
|
||||
result := ApplyProposals(&fixture.Transcript, fixture.Proposals, policy)
|
||||
assertJSONSemanticallyEqualFromGolden(t, result.Transcript, name+".transcript.golden.json")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyProposalsFixtureSkipCoverage(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
expectedSkipReason ProposalSkipReason
|
||||
}{
|
||||
{name: "stale_after_earlier_replacement", expectedSkipReason: SkipReasonMissingOriginalText},
|
||||
{name: "ambiguous_require_unique", expectedSkipReason: SkipReasonAmbiguousOriginal},
|
||||
{name: "missing_segment", expectedSkipReason: SkipReasonMissingSegment},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fixture := readApplyFixture(t, tc.name+".input.json")
|
||||
policy, err := ParseReplacementPolicy(fixture.Policy)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse fixture policy: %v", err)
|
||||
}
|
||||
|
||||
result := ApplyProposals(&fixture.Transcript, fixture.Proposals, policy)
|
||||
if len(result.Skipped) == 0 {
|
||||
t.Fatal("expected at least one skipped change")
|
||||
}
|
||||
|
||||
if got := result.Skipped[0].SkipReason; got != tc.expectedSkipReason {
|
||||
t.Fatalf("unexpected skip reason: got %q want %q", got, tc.expectedSkipReason)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyProposalsFixtureMixedScenarioRecordGoldens(t *testing.T) {
|
||||
fixture := readApplyFixture(t, "mixed_scenarios.input.json")
|
||||
policy, err := ParseReplacementPolicy(fixture.Policy)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse fixture policy: %v", err)
|
||||
}
|
||||
|
||||
result := ApplyProposals(&fixture.Transcript, fixture.Proposals, policy)
|
||||
payload := recordsPayload{
|
||||
AppliedChanges: result.Applied,
|
||||
SkippedChanges: result.Skipped,
|
||||
}
|
||||
|
||||
// Records must be JSON-serializable for diagnostics/reporting.
|
||||
if _, err := json.Marshal(payload.AppliedChanges); err != nil {
|
||||
t.Fatalf("failed to marshal applied changes: %v", err)
|
||||
}
|
||||
if _, err := json.Marshal(payload.SkippedChanges); err != nil {
|
||||
t.Fatalf("failed to marshal skipped changes: %v", err)
|
||||
}
|
||||
if _, err := json.Marshal(payload); err != nil {
|
||||
t.Fatalf("failed to marshal combined records payload: %v", err)
|
||||
}
|
||||
|
||||
for i, skipped := range payload.SkippedChanges {
|
||||
if !isStableSkipReason(skipped.SkipReason) {
|
||||
t.Fatalf("unstable skip reason at skipped[%d]: %q", i, skipped.SkipReason)
|
||||
}
|
||||
}
|
||||
|
||||
assertJSONSemanticallyEqualFromGolden(t, payload, "mixed_scenarios.records.golden.json")
|
||||
}
|
||||
|
||||
func readApplyFixture(t *testing.T, fileName string) applyFixture {
|
||||
t.Helper()
|
||||
path := filepath.Join("testdata", fileName)
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read fixture %s: %v", path, err)
|
||||
}
|
||||
|
||||
var fixture applyFixture
|
||||
if err := json.Unmarshal(raw, &fixture); err != nil {
|
||||
t.Fatalf("failed to parse fixture %s: %v", path, err)
|
||||
}
|
||||
|
||||
return fixture
|
||||
}
|
||||
|
||||
func assertJSONSemanticallyEqualFromGolden(t *testing.T, actual any, goldenFileName string) {
|
||||
t.Helper()
|
||||
goldenPath := filepath.Join("testdata", goldenFileName)
|
||||
expectedRaw, err := os.ReadFile(goldenPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read golden file %s: %v", goldenPath, err)
|
||||
}
|
||||
|
||||
assertJSONSemanticallyEqual(t, actual, expectedRaw)
|
||||
}
|
||||
|
||||
func assertJSONSemanticallyEqual(t *testing.T, actual any, expectedJSON []byte) {
|
||||
t.Helper()
|
||||
|
||||
actualRaw, err := json.Marshal(actual)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to marshal actual JSON: %v", err)
|
||||
}
|
||||
|
||||
var actualValue any
|
||||
if err := json.Unmarshal(actualRaw, &actualValue); err != nil {
|
||||
t.Fatalf("failed to parse marshaled actual JSON: %v", err)
|
||||
}
|
||||
|
||||
var expectedValue any
|
||||
if err := json.Unmarshal(expectedJSON, &expectedValue); err != nil {
|
||||
t.Fatalf("failed to parse expected JSON: %v", err)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(actualValue, expectedValue) {
|
||||
t.Fatalf("semantic JSON mismatch\nactual: %s\nexpected: %s", string(actualRaw), string(expectedJSON))
|
||||
}
|
||||
}
|
||||
|
||||
func isStableSkipReason(reason ProposalSkipReason) bool {
|
||||
switch reason {
|
||||
case
|
||||
SkipReasonMissingSegment,
|
||||
SkipReasonMissingOriginalText,
|
||||
SkipReasonAmbiguousOriginal,
|
||||
SkipReasonNoEffect,
|
||||
SkipReasonInvalidProposal:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
7
internal/framework/proposals/doc.go
Normal file
7
internal/framework/proposals/doc.go
Normal file
@@ -0,0 +1,7 @@
|
||||
// Package proposals provides deterministic, literal proposal preview and
|
||||
// application helpers for transcript rewriting.
|
||||
//
|
||||
// The package only enforces structural and replacement-safety rules.
|
||||
// It does not perform semantic validation; LLM-backed and deterministic
|
||||
// validators are layered later in the pipeline.
|
||||
package proposals
|
||||
25
internal/framework/proposals/testdata/ambiguous_require_unique.input.json
vendored
Normal file
25
internal/framework/proposals/testdata/ambiguous_require_unique.input.json
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"policy": "require_unique",
|
||||
"transcript": {
|
||||
"segments": [
|
||||
{
|
||||
"id": 1,
|
||||
"speaker": "A",
|
||||
"start": 0,
|
||||
"end": 2,
|
||||
"text": "rank rank"
|
||||
}
|
||||
]
|
||||
},
|
||||
"proposals": [
|
||||
{
|
||||
"proposal_index": 0,
|
||||
"module_key": "homophones",
|
||||
"module_instance": "homophones",
|
||||
"id": 1,
|
||||
"original_text": "rank",
|
||||
"corrected_text": "Hrank",
|
||||
"confidence": 0.9
|
||||
}
|
||||
]
|
||||
}
|
||||
34
internal/framework/proposals/testdata/categories_metadata_preservation.input.json
vendored
Normal file
34
internal/framework/proposals/testdata/categories_metadata_preservation.input.json
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"policy": "require_unique",
|
||||
"transcript": {
|
||||
"segments": [
|
||||
{
|
||||
"id": 1,
|
||||
"speaker": "Narrator",
|
||||
"start": 0,
|
||||
"end": 3,
|
||||
"text": "status",
|
||||
"categories": ["meta", "state"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"speaker": "Player",
|
||||
"start": 3,
|
||||
"end": 6,
|
||||
"text": "rank",
|
||||
"categories": ["combat"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"proposals": [
|
||||
{
|
||||
"proposal_index": 0,
|
||||
"module_key": "glossary",
|
||||
"module_instance": "glossary_1",
|
||||
"id": 2,
|
||||
"original_text": "rank",
|
||||
"corrected_text": "Hrank",
|
||||
"confidence": 0.9
|
||||
}
|
||||
]
|
||||
}
|
||||
20
internal/framework/proposals/testdata/categories_metadata_preservation.transcript.golden.json
vendored
Normal file
20
internal/framework/proposals/testdata/categories_metadata_preservation.transcript.golden.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"segments": [
|
||||
{
|
||||
"id": 1,
|
||||
"speaker": "Narrator",
|
||||
"start": 0,
|
||||
"end": 3,
|
||||
"text": "status",
|
||||
"categories": ["meta", "state"]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"speaker": "Player",
|
||||
"start": 3,
|
||||
"end": 6,
|
||||
"text": "Hrank",
|
||||
"categories": ["combat"]
|
||||
}
|
||||
]
|
||||
}
|
||||
25
internal/framework/proposals/testdata/missing_segment.input.json
vendored
Normal file
25
internal/framework/proposals/testdata/missing_segment.input.json
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"policy": "require_unique",
|
||||
"transcript": {
|
||||
"segments": [
|
||||
{
|
||||
"id": 1,
|
||||
"speaker": "A",
|
||||
"start": 0,
|
||||
"end": 1,
|
||||
"text": "hello"
|
||||
}
|
||||
]
|
||||
},
|
||||
"proposals": [
|
||||
{
|
||||
"proposal_index": 0,
|
||||
"module_key": "grammar",
|
||||
"module_instance": "grammar_1",
|
||||
"id": 99,
|
||||
"original_text": "hello",
|
||||
"corrected_text": "hi",
|
||||
"confidence": 0.9
|
||||
}
|
||||
]
|
||||
}
|
||||
68
internal/framework/proposals/testdata/mixed_scenarios.input.json
vendored
Normal file
68
internal/framework/proposals/testdata/mixed_scenarios.input.json
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"policy": "require_unique",
|
||||
"transcript": {
|
||||
"segments": [
|
||||
{
|
||||
"id": 1,
|
||||
"speaker": "A",
|
||||
"start": 0,
|
||||
"end": 4,
|
||||
"text": "foo bar rank rank"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"speaker": "B",
|
||||
"start": 4,
|
||||
"end": 5,
|
||||
"text": "hello"
|
||||
}
|
||||
]
|
||||
},
|
||||
"proposals": [
|
||||
{
|
||||
"proposal_index": 0,
|
||||
"module_key": "grammar",
|
||||
"module_instance": "grammar_1",
|
||||
"id": 1,
|
||||
"original_text": "foo bar",
|
||||
"corrected_text": "foo-bar",
|
||||
"confidence": 0.9
|
||||
},
|
||||
{
|
||||
"proposal_index": 1,
|
||||
"module_key": "grammar",
|
||||
"module_instance": "grammar_1",
|
||||
"id": 1,
|
||||
"original_text": "foo bar",
|
||||
"corrected_text": "foobar",
|
||||
"confidence": 0.9
|
||||
},
|
||||
{
|
||||
"proposal_index": 2,
|
||||
"module_key": "homophones",
|
||||
"module_instance": "homophones",
|
||||
"id": 1,
|
||||
"original_text": "rank",
|
||||
"corrected_text": "Hrank",
|
||||
"confidence": 0.9
|
||||
},
|
||||
{
|
||||
"proposal_index": 3,
|
||||
"module_key": "grammar",
|
||||
"module_instance": "grammar_1",
|
||||
"id": 99,
|
||||
"original_text": "x",
|
||||
"corrected_text": "y",
|
||||
"confidence": 0.9
|
||||
},
|
||||
{
|
||||
"proposal_index": 4,
|
||||
"module_key": "spoken_word",
|
||||
"module_instance": "spoken_word",
|
||||
"id": 2,
|
||||
"original_text": "hello",
|
||||
"corrected_text": "hello",
|
||||
"confidence": 0.9
|
||||
}
|
||||
]
|
||||
}
|
||||
55
internal/framework/proposals/testdata/mixed_scenarios.records.golden.json
vendored
Normal file
55
internal/framework/proposals/testdata/mixed_scenarios.records.golden.json
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"applied_changes": [
|
||||
{
|
||||
"proposal_index": 0,
|
||||
"module_key": "grammar",
|
||||
"module_instance": "grammar_1",
|
||||
"target_segment_id": 1,
|
||||
"original_text": "foo bar",
|
||||
"corrected_text": "foo-bar",
|
||||
"replacement_count": 1
|
||||
}
|
||||
],
|
||||
"skipped_changes": [
|
||||
{
|
||||
"proposal_index": 1,
|
||||
"module_key": "grammar",
|
||||
"module_instance": "grammar_1",
|
||||
"target_segment_id": 1,
|
||||
"original_text": "foo bar",
|
||||
"corrected_text": "foobar",
|
||||
"skip_reason": "missing_original_text",
|
||||
"message": "original_text was not found in current segment text"
|
||||
},
|
||||
{
|
||||
"proposal_index": 2,
|
||||
"module_key": "homophones",
|
||||
"module_instance": "homophones",
|
||||
"target_segment_id": 1,
|
||||
"original_text": "rank",
|
||||
"corrected_text": "Hrank",
|
||||
"skip_reason": "ambiguous_original_text",
|
||||
"message": "original_text matched multiple spans under require_unique policy"
|
||||
},
|
||||
{
|
||||
"proposal_index": 3,
|
||||
"module_key": "grammar",
|
||||
"module_instance": "grammar_1",
|
||||
"target_segment_id": 99,
|
||||
"original_text": "x",
|
||||
"corrected_text": "y",
|
||||
"skip_reason": "missing_segment",
|
||||
"message": "target segment was not found"
|
||||
},
|
||||
{
|
||||
"proposal_index": 4,
|
||||
"module_key": "spoken_word",
|
||||
"module_instance": "spoken_word",
|
||||
"target_segment_id": 2,
|
||||
"original_text": "hello",
|
||||
"corrected_text": "hello",
|
||||
"skip_reason": "no_effect",
|
||||
"message": "original_text and corrected_text are identical"
|
||||
}
|
||||
]
|
||||
}
|
||||
34
internal/framework/proposals/testdata/multiple_proposals_one_segment.input.json
vendored
Normal file
34
internal/framework/proposals/testdata/multiple_proposals_one_segment.input.json
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"policy": "require_unique",
|
||||
"transcript": {
|
||||
"segments": [
|
||||
{
|
||||
"id": 1,
|
||||
"speaker": "A",
|
||||
"start": 0,
|
||||
"end": 2,
|
||||
"text": "foo bar"
|
||||
}
|
||||
]
|
||||
},
|
||||
"proposals": [
|
||||
{
|
||||
"proposal_index": 2,
|
||||
"module_key": "grammar",
|
||||
"module_instance": "grammar_1",
|
||||
"id": 1,
|
||||
"original_text": "bar",
|
||||
"corrected_text": "BAR",
|
||||
"confidence": 0.9
|
||||
},
|
||||
{
|
||||
"proposal_index": 1,
|
||||
"module_key": "grammar",
|
||||
"module_instance": "grammar_1",
|
||||
"id": 1,
|
||||
"original_text": "foo",
|
||||
"corrected_text": "FOO",
|
||||
"confidence": 0.9
|
||||
}
|
||||
]
|
||||
}
|
||||
11
internal/framework/proposals/testdata/multiple_proposals_one_segment.transcript.golden.json
vendored
Normal file
11
internal/framework/proposals/testdata/multiple_proposals_one_segment.transcript.golden.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"segments": [
|
||||
{
|
||||
"id": 1,
|
||||
"speaker": "A",
|
||||
"start": 0,
|
||||
"end": 2,
|
||||
"text": "FOO BAR"
|
||||
}
|
||||
]
|
||||
}
|
||||
25
internal/framework/proposals/testdata/multiple_replacements_one_segment.input.json
vendored
Normal file
25
internal/framework/proposals/testdata/multiple_replacements_one_segment.input.json
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"policy": "replace_all",
|
||||
"transcript": {
|
||||
"segments": [
|
||||
{
|
||||
"id": 1,
|
||||
"speaker": "A",
|
||||
"start": 0,
|
||||
"end": 2,
|
||||
"text": "uh uh uh"
|
||||
}
|
||||
]
|
||||
},
|
||||
"proposals": [
|
||||
{
|
||||
"proposal_index": 0,
|
||||
"module_key": "spoken_word",
|
||||
"module_instance": "spoken_word",
|
||||
"id": 1,
|
||||
"original_text": "uh",
|
||||
"corrected_text": "um",
|
||||
"confidence": 0.9
|
||||
}
|
||||
]
|
||||
}
|
||||
11
internal/framework/proposals/testdata/multiple_replacements_one_segment.transcript.golden.json
vendored
Normal file
11
internal/framework/proposals/testdata/multiple_replacements_one_segment.transcript.golden.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"segments": [
|
||||
{
|
||||
"id": 1,
|
||||
"speaker": "A",
|
||||
"start": 0,
|
||||
"end": 2,
|
||||
"text": "um um um"
|
||||
}
|
||||
]
|
||||
}
|
||||
25
internal/framework/proposals/testdata/replace_all_behavior.input.json
vendored
Normal file
25
internal/framework/proposals/testdata/replace_all_behavior.input.json
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"policy": "replace_all",
|
||||
"transcript": {
|
||||
"segments": [
|
||||
{
|
||||
"id": 1,
|
||||
"speaker": "A",
|
||||
"start": 0,
|
||||
"end": 2,
|
||||
"text": "ha-ha-ha"
|
||||
}
|
||||
]
|
||||
},
|
||||
"proposals": [
|
||||
{
|
||||
"proposal_index": 0,
|
||||
"module_key": "spoken_word",
|
||||
"module_instance": "spoken_word",
|
||||
"id": 1,
|
||||
"original_text": "ha",
|
||||
"corrected_text": "ho",
|
||||
"confidence": 0.9
|
||||
}
|
||||
]
|
||||
}
|
||||
11
internal/framework/proposals/testdata/replace_all_behavior.transcript.golden.json
vendored
Normal file
11
internal/framework/proposals/testdata/replace_all_behavior.transcript.golden.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"segments": [
|
||||
{
|
||||
"id": 1,
|
||||
"speaker": "A",
|
||||
"start": 0,
|
||||
"end": 2,
|
||||
"text": "ho-ho-ho"
|
||||
}
|
||||
]
|
||||
}
|
||||
25
internal/framework/proposals/testdata/simple_replacement.input.json
vendored
Normal file
25
internal/framework/proposals/testdata/simple_replacement.input.json
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"policy": "require_unique",
|
||||
"transcript": {
|
||||
"segments": [
|
||||
{
|
||||
"id": 1,
|
||||
"speaker": "A",
|
||||
"start": 0,
|
||||
"end": 1.25,
|
||||
"text": "gestures"
|
||||
}
|
||||
]
|
||||
},
|
||||
"proposals": [
|
||||
{
|
||||
"proposal_index": 0,
|
||||
"module_key": "glossary",
|
||||
"module_instance": "glossary_1",
|
||||
"id": 1,
|
||||
"original_text": "gestures",
|
||||
"corrected_text": "Jesters",
|
||||
"confidence": 0.95
|
||||
}
|
||||
]
|
||||
}
|
||||
11
internal/framework/proposals/testdata/simple_replacement.transcript.golden.json
vendored
Normal file
11
internal/framework/proposals/testdata/simple_replacement.transcript.golden.json
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"segments": [
|
||||
{
|
||||
"id": 1,
|
||||
"speaker": "A",
|
||||
"start": 0,
|
||||
"end": 1.25,
|
||||
"text": "Jesters"
|
||||
}
|
||||
]
|
||||
}
|
||||
34
internal/framework/proposals/testdata/stale_after_earlier_replacement.input.json
vendored
Normal file
34
internal/framework/proposals/testdata/stale_after_earlier_replacement.input.json
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"policy": "require_unique",
|
||||
"transcript": {
|
||||
"segments": [
|
||||
{
|
||||
"id": 1,
|
||||
"speaker": "A",
|
||||
"start": 0,
|
||||
"end": 2,
|
||||
"text": "foo bar"
|
||||
}
|
||||
]
|
||||
},
|
||||
"proposals": [
|
||||
{
|
||||
"proposal_index": 0,
|
||||
"module_key": "grammar",
|
||||
"module_instance": "grammar_1",
|
||||
"id": 1,
|
||||
"original_text": "foo bar",
|
||||
"corrected_text": "foo-bar",
|
||||
"confidence": 0.9
|
||||
},
|
||||
{
|
||||
"proposal_index": 1,
|
||||
"module_key": "grammar",
|
||||
"module_instance": "grammar_1",
|
||||
"id": 1,
|
||||
"original_text": "foo bar",
|
||||
"corrected_text": "foobar",
|
||||
"confidence": 0.9
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user